Merge branch 'flow/distribution/P2.1' into distribution-plan

This commit is contained in:
agra
2026-06-06 00:18:26 +03:00
10 changed files with 532 additions and 0 deletions

View File

@@ -1,5 +1,10 @@
# Subplan 02 - Product Domain And Storage
> **Flow decomposition:** Slice 1 is split across two flow steps — **P2.1**
> (Core Structs + boundary validation) and **P2.3** (in-memory repository +
> `db.json` persistence). Token ships in **Slice 5 - Token Security**, not
> Slice 1. See "Milestone 1 — Flow Step ↔ Subplan Slice Mapping" in `PLAN.md`.
## Goal
Define the distribution platform's core data model and persistence layer once

21
PLAN.md
View File

@@ -271,6 +271,27 @@ Milestone 1 is complete when:
- The admin UI can inspect apps, releases, validations, tokens, and audit logs.
- A Docker image can run on a UGREEN NAS with a persistent data volume.
## Milestone 1 — Flow Step ↔ Subplan Slice Mapping
The flow decomposes subplan 02 (`.agents/subplans/02-domain-and-storage.md`)
Slice 1 into two steps. This records that decomposition so each step's scope is
verifiable from the repo:
- **P2.1 — domain structs + boundary validation.** Delivers subplan-02 *Core
Structs* (App, Platform, Release, Artifact, Channel, AuditEvent) and the
*boundary validation* portion of Slice 1 — slug, version, channel name,
platform id, and required-field presence, each with a distinct typed error.
Per the PO ruling this includes `Release.published_at` and `Artifact.metadata`.
Code lives under `src/domain/`; the acceptance test is
`tests/domain_validate.sx`.
- **P2.3 — in-memory repository + persistence.** Delivers the rest of Slice 1:
the in-memory repository (create/list/get/update, find-by-slug,
find-artifact-by-digest) plus `db.json` persistence.
- **Token is in neither P2.1 nor P2.3.** Subplan 02 lists Token under *Core
Structs*, but its delivery slice is **Slice 5 — Token Security** (generation,
hashing at rest, scopes, expiration/revocation). It is out of the Slice-1
steps above.
## Non-goals For Version 1
- Public marketplace payments.

31
src/domain/app.sx Normal file
View File

@@ -0,0 +1,31 @@
#import "modules/std.sx";
#import "platform.sx";
// Who can see an app in listings: `private` = owner only, `unlisted` =
// reachable by direct link, `public` = listed.
Visibility :: enum u8 {
private;
unlisted;
public;
}
// A reverse-DNS bundle identifier (e.g. "co.acme.app") for one platform.
// An app carries at most one per platform; kept as a flat list scanned
// linearly (no HashMap yet).
BundleId :: struct {
platform: Platform;
value: string;
}
// A distributable application. `slug` is the URL-safe handle; `id` is the
// opaque primary key. Timestamps are unix epoch seconds.
App :: struct {
id: string;
slug: string;
display_name: string;
bundle_ids: List(BundleId);
owner: string;
visibility: Visibility = .private;
created_at: s64;
updated_at: s64;
}

28
src/domain/artifact.sx Normal file
View File

@@ -0,0 +1,28 @@
#import "modules/std.sx";
#import "platform.sx";
// Where an artifact sits in the validation pipeline: artifacts enter
// `pending`, then validation moves them to `valid` or `invalid`.
ValidationStatus :: enum u8 {
pending;
valid;
invalid;
}
// A single uploaded binary belonging to a release, for one platform.
// `sha256` is the lowercase-hex content digest; `storage_key` locates the
// bytes in the blob store. `metadata` is an opaque string (mirroring
// AuditEvent.metadata) carrying per-artifact data the manifest emits later.
Artifact :: struct {
id: string;
app_id: string;
release_id: string;
platform: Platform;
filename: string;
content_type: string;
size_bytes: s64;
sha256: string;
storage_key: string;
metadata: string;
validation_status: ValidationStatus = .pending;
}

15
src/domain/audit.sx Normal file
View File

@@ -0,0 +1,15 @@
#import "modules/std.sx";
// An append-only record of a mutation. `actor` is the user or service that
// performed `action` on the target identified by `target_type` +
// `target_id`. `metadata` is an opaque string for now (structured JSON
// arrives with std/json in a later step). `created_at` is unix epoch seconds.
AuditEvent :: struct {
id: string;
actor: string;
action: string;
target_type: string;
target_id: string;
metadata: string;
created_at: s64;
}

19
src/domain/channel.sx Normal file
View File

@@ -0,0 +1,19 @@
#import "modules/std.sx";
// How a channel rolls a release out: `manual` is all-or-nothing once
// published; `percentage` stages exposure by `rollout_percent` (0..100).
RolloutPolicy :: enum u8 {
manual;
percentage;
}
// A named publishing track for an app (e.g. "stable", "beta"), pointing at
// the release currently serving traffic. `rollout_percent` is meaningful
// only under the `percentage` policy.
Channel :: struct {
app_id: string;
name: string;
current_release_id: string;
policy: RolloutPolicy = .manual;
rollout_percent: s64 = 100;
}

11
src/domain/platform.sx Normal file
View File

@@ -0,0 +1,11 @@
#import "modules/std.sx";
// Target platforms a release can ship to. `android_apk` names the APK
// artifact form specifically; the remaining variants are one per OS.
Platform :: enum u8 {
ios;
android_apk;
macos;
linux;
windows;
}

17
src/domain/release.sx Normal file
View File

@@ -0,0 +1,17 @@
#import "modules/std.sx";
// A versioned build of an app, published to one channel. `version` is the
// human semver string; `build` is the monotonic build number. `channel`
// names the Channel it targets. Timestamps are unix epoch seconds;
// `published_at = 0` means the release is still a draft (not yet published).
Release :: struct {
id: string;
app_id: string;
version: string;
build: s64;
channel: string;
notes: string;
created_by: string;
created_at: s64;
published_at: s64;
}

173
src/domain/validate.sx Normal file
View File

@@ -0,0 +1,173 @@
#import "modules/std.sx";
#import "platform.sx";
#import "app.sx";
#import "release.sx";
#import "artifact.sx";
#import "channel.sx";
// Typed failures from boundary validation. One distinct tag per failure
// class so callers (and, later, the CLI) can surface a precise message
// instead of a bare bool that drops the reason.
ValidationErr :: error {
MissingField, // a required string field was empty
BadSlug, // slug broke the [a-z0-9-] / hyphen-position rules
EmptyVersion, // version string was empty
BadVersion, // version non-empty but not MAJOR.MINOR.PATCH(-suffix)
BadChannelName, // channel name empty or outside [a-z0-9-]
UnknownPlatform, // platform id did not name a Platform variant
BadRollout, // rollout_percent outside 0..100
BadSize, // artifact size_bytes was not positive
BadDigest, // sha256 was not exactly 64 lowercase-hex chars
}
// ── Character classes (ASCII byte codes) ────────────────────────────────
is_lower :: (c: u8) -> bool { return c >= 97 and c <= 122; } // 'a'..'z'
is_upper :: (c: u8) -> bool { return c >= 65 and c <= 90; } // 'A'..'Z'
is_digit :: (c: u8) -> bool { return c >= 48 and c <= 57; } // '0'..'9'
// ── Field validators ────────────────────────────────────────────────────
// slug: non-empty; every byte in [a-z0-9-]; no leading or trailing hyphen;
// no two consecutive hyphens. So "acme-app" passes; "", "-x", "x-", "a--b",
// and "App" are all rejected.
validate_slug :: (s: string) -> !ValidationErr {
if s.len == 0 { raise error.BadSlug; }
if s[0] == 45 { raise error.BadSlug; } // 45='-' leading
if s[s.len - 1] == 45 { raise error.BadSlug; } // trailing
i := 0;
while i < s.len {
c := s[i];
if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadSlug; }
if c == 45 and i > 0 {
if s[i - 1] == 45 { raise error.BadSlug; } // double hyphen
}
i += 1;
}
return;
}
// version: MAJOR.MINOR.PATCH, each component a run of >=1 digit, optionally
// followed by "-<suffix>" where suffix is a non-empty run of [A-Za-z0-9.-]
// (e.g. "1.0.0", "2.10.3-beta.1"). Empty -> EmptyVersion; any other
// deviation -> BadVersion.
validate_version :: (s: string) -> !ValidationErr {
if s.len == 0 { raise error.EmptyVersion; }
i := 0;
comp := 0;
while comp < 3 {
start := i;
while i < s.len {
if !is_digit(s[i]) { break; }
i += 1;
}
if i == start { raise error.BadVersion; } // no digits in component
if comp < 2 {
if i >= s.len { raise error.BadVersion; } // missing '.' separator
if s[i] != 46 { raise error.BadVersion; } // 46='.'
i += 1;
}
comp += 1;
}
if i < s.len {
if s[i] != 45 { raise error.BadVersion; } // only a '-' suffix allowed
i += 1;
if i >= s.len { raise error.BadVersion; } // empty suffix
while i < s.len {
c := s[i];
if !(is_lower(c) or is_upper(c) or is_digit(c) or c == 45 or c == 46) {
raise error.BadVersion;
}
i += 1;
}
}
return;
}
// sha256: exactly 64 lowercase-hex bytes ([0-9a-f]) — the rendered form of
// a 32-byte digest. Any other length or character -> BadDigest.
validate_sha256 :: (s: string) -> !ValidationErr {
if s.len != 64 { raise error.BadDigest; }
i := 0;
while i < s.len {
c := s[i];
if !(is_digit(c) or (c >= 97 and c <= 102)) { raise error.BadDigest; } // 'a'..'f'
i += 1;
}
return;
}
// channel name: non-empty; every byte in [a-z0-9-]. Looser than slug —
// hyphen position is unconstrained (e.g. "stable", "beta-2").
validate_channel_name :: (s: string) -> !ValidationErr {
if s.len == 0 { raise error.BadChannelName; }
i := 0;
while i < s.len {
c := s[i];
if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadChannelName; }
i += 1;
}
return;
}
// Parse a platform id into the Platform enum. Accepted ids are exactly the
// variant names; anything else -> UnknownPlatform.
parse_platform :: (s: string) -> (Platform, !ValidationErr) {
if s == "ios" { return .ios; }
if s == "android_apk" { return .android_apk; }
if s == "macos" { return .macos; }
if s == "linux" { return .linux; }
if s == "windows" { return .windows; }
raise error.UnknownPlatform;
}
// ── Aggregate validators (required-field presence + field rules) ─────────
// App requires id, slug, display_name, owner; slug must also pass
// validate_slug. Presence is checked first, so an empty slug surfaces as
// MissingField and a present-but-malformed slug as BadSlug.
validate_app :: (a: App) -> !ValidationErr {
if a.id.len == 0 { raise error.MissingField; }
if a.slug.len == 0 { raise error.MissingField; }
if a.display_name.len == 0 { raise error.MissingField; }
if a.owner.len == 0 { raise error.MissingField; }
try validate_slug(a.slug);
return;
}
// Release requires id, app_id, created_by; version goes through
// validate_version (empty -> EmptyVersion) and channel through
// validate_channel_name.
validate_release :: (r: Release) -> !ValidationErr {
if r.id.len == 0 { raise error.MissingField; }
if r.app_id.len == 0 { raise error.MissingField; }
if r.created_by.len == 0 { raise error.MissingField; }
try validate_version(r.version);
try validate_channel_name(r.channel);
return;
}
// Artifact requires id, app_id, release_id, filename, content_type,
// storage_key (non-empty -> MissingField); size_bytes must be positive
// (-> BadSize); sha256 must be a real digest (-> BadDigest via
// validate_sha256). platform and validation_status are enums, hence always
// well-formed. `metadata` is opaque and unchecked.
validate_artifact :: (a: Artifact) -> !ValidationErr {
if a.id.len == 0 { raise error.MissingField; }
if a.app_id.len == 0 { raise error.MissingField; }
if a.release_id.len == 0 { raise error.MissingField; }
if a.filename.len == 0 { raise error.MissingField; }
if a.content_type.len == 0 { raise error.MissingField; }
if a.storage_key.len == 0 { raise error.MissingField; }
if a.size_bytes <= 0 { raise error.BadSize; }
try validate_sha256(a.sha256);
return;
}
// Channel requires app_id; name goes through validate_channel_name and
// rollout_percent must be in 0..100.
validate_channel :: (c: Channel) -> !ValidationErr {
if c.app_id.len == 0 { raise error.MissingField; }
try validate_channel_name(c.name);
if c.rollout_percent < 0 or c.rollout_percent > 100 { raise error.BadRollout; }
return;
}

212
tests/domain_validate.sx Normal file
View File

@@ -0,0 +1,212 @@
// Acceptance for P2.1 — the domain boundary validator. Constructs valid
// App/Release/Artifact/Channel and asserts they are accepted, then mutates
// each into one invalid form and asserts it is rejected with the SPECIFIC
// typed ValidationErr (not merely "some failure"). Prints a pass/fail line
// per case and exits non-zero if any case behaves unexpectedly.
#import "modules/std.sx";
#import "../src/domain/platform.sx";
#import "../src/domain/app.sx";
#import "../src/domain/release.sx";
#import "../src/domain/artifact.sx";
#import "../src/domain/channel.sx";
#import "../src/domain/validate.sx";
// ── Valid fixtures ──────────────────────────────────────────────────────
valid_app :: () -> App {
a : App = .{
id = "app_01",
slug = "acme-app",
display_name = "Acme App",
owner = "user_01",
visibility = .public,
created_at = 1700000000,
updated_at = 1700000000,
};
a.bundle_ids.append(BundleId.{ platform = .ios, value = "co.acme.app" });
return a;
}
valid_release :: () -> Release {
return Release.{
id = "rel_01",
app_id = "app_01",
version = "1.2.3-beta.1",
build = 42,
channel = "stable",
notes = "first cut",
created_by = "user_01",
created_at = 1700000000,
published_at = 1700000200,
};
}
valid_artifact :: () -> Artifact {
return Artifact.{
id = "art_01",
app_id = "app_01",
release_id = "rel_01",
platform = .android_apk,
filename = "acme.apk",
content_type = "application/vnd.android.package-archive",
size_bytes = 10485760,
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
storage_key = "apps/app_01/rel_01/acme.apk",
metadata = "{\"min_os\":\"14\"}",
validation_status = .valid,
};
}
valid_channel :: () -> Channel {
return Channel.{
app_id = "app_01",
name = "stable",
current_release_id = "rel_01",
policy = .percentage,
rollout_percent = 25,
};
}
// ── Cases ───────────────────────────────────────────────────────────────
check_accepts_app :: () -> bool {
a := valid_app();
ok := true;
validate_app(a) catch { ok = false; };
return ok;
}
check_accepts_release :: () -> bool {
r := valid_release();
ok := true;
validate_release(r) catch { ok = false; };
return ok;
}
check_accepts_artifact :: () -> bool {
a := valid_artifact();
ok := true;
validate_artifact(a) catch { ok = false; };
return ok;
}
check_accepts_channel :: () -> bool {
c := valid_channel();
ok := true;
validate_channel(c) catch { ok = false; };
return ok;
}
check_accepts_platform :: () -> bool {
p, e := parse_platform("android_apk");
ok := false;
if !e { ok = (p == .android_apk); }
return ok;
}
check_rejects_bad_slug :: () -> bool {
a := valid_app();
a.slug = "Bad_Slug"; // uppercase + underscore
matched := false;
validate_app(a) catch e { matched = (e == error.BadSlug); };
return matched;
}
check_rejects_empty_version :: () -> bool {
r := valid_release();
r.version = "";
matched := false;
validate_release(r) catch e { matched = (e == error.EmptyVersion); };
return matched;
}
check_rejects_bad_version :: () -> bool {
r := valid_release();
r.version = "1.2"; // missing PATCH component
matched := false;
validate_release(r) catch e { matched = (e == error.BadVersion); };
return matched;
}
check_rejects_unknown_platform :: () -> bool {
_, e := parse_platform("plan9");
return e == error.UnknownPlatform;
}
check_rejects_bad_channel :: () -> bool {
c := valid_channel();
c.name = "Bad Channel"; // space + uppercase
matched := false;
validate_channel(c) catch e { matched = (e == error.BadChannelName); };
return matched;
}
check_rejects_empty_content_type :: () -> bool {
a := valid_artifact();
a.content_type = ""; // required string cleared
matched := false;
validate_artifact(a) catch e { matched = (e == error.MissingField); };
return matched;
}
check_rejects_bad_size :: () -> bool {
a := valid_artifact();
a.size_bytes = -1; // a content-addressed artifact has positive bytes
matched := false;
validate_artifact(a) catch e { matched = (e == error.BadSize); };
return matched;
}
check_rejects_bad_digest :: () -> bool {
a := valid_artifact();
a.sha256 = "not-a-sha"; // not 64 lowercase-hex chars
matched := false;
validate_artifact(a) catch e { matched = (e == error.BadDigest); };
return matched;
}
// Contract pins: read back the renamed Release.published_at and the new
// Artifact.metadata. These references fail to compile against the pre-change
// structs (Release.updated_at / no Artifact.metadata).
check_release_published_at :: () -> bool {
r := valid_release();
return r.published_at == 1700000200;
}
check_artifact_metadata :: () -> bool {
a := valid_artifact();
return a.metadata == "{\"min_os\":\"14\"}";
}
run_case :: (label: string, ok: bool) -> s32 {
if ok { print(" PASS {}\n", label); return 0; }
print(" FAIL {}\n", label);
return 1;
}
main :: () -> s32 {
failures : s32 = 0;
failures += run_case("accept: valid app", check_accepts_app());
failures += run_case("accept: valid release", check_accepts_release());
failures += run_case("accept: valid artifact", check_accepts_artifact());
failures += run_case("accept: valid channel", check_accepts_channel());
failures += run_case("accept: platform 'android_apk'", check_accepts_platform());
failures += run_case("reject: bad slug -> BadSlug", check_rejects_bad_slug());
failures += run_case("reject: empty version -> EmptyVersion", check_rejects_empty_version());
failures += run_case("reject: bad version -> BadVersion", check_rejects_bad_version());
failures += run_case("reject: unknown platform -> UnknownPlatform", check_rejects_unknown_platform());
failures += run_case("reject: bad channel -> BadChannelName", check_rejects_bad_channel());
failures += run_case("reject: empty content_type -> MissingField", check_rejects_empty_content_type());
failures += run_case("reject: non-positive size_bytes -> BadSize", check_rejects_bad_size());
failures += run_case("reject: malformed sha256 -> BadDigest", check_rejects_bad_digest());
failures += run_case("contract: Release.published_at readback", check_release_published_at());
failures += run_case("contract: Artifact.metadata readback", check_artifact_metadata());
print("------------------------------------------------\n");
if failures == 0 {
print("domain_validate: ALL CASES PASS\n");
return 0;
}
print("domain_validate: {} CASE(S) FAILED\n", failures);
return 1;
}