Intended `dist` entrypoint: std.cli os_args/parse dispatch to stub handlers
for `ci publish`, `release promote`, `release rollback`, with help/usage,
EX_USAGE(64) exit contract, and `--json` purity (machine JSON on stdout via
std.json, human text on stderr).
NOT wired into `make build`/`tests/` and NOT buildable: `std.cli` and
`std.json` both export a top-level `parse`; with both in the compilation
closure the sx IR lowering crashes on `cli.parse`
(lower.zig lazyLowerFunction assert `func.params.len == fd.params.len + ctx_slots`).
json_out.sx isolates the std.json import, but `sx build` lowers the whole
transitive closure so the bare-name collision persists. See the P3.1 worker
report for the minimal repro. Step is BLOCKED pending an sx fix.
Close the remaining publish aggregate-consistency edges (review round 2, F1
continued):
- chan.name == release.channel — the promoted channel must be the one the
release declares as its target; promoting a "beta" channel for a release
whose channel is "stable" committed an edge contradicting the release's own
target. Now rejected with Integrity + rollback.
- release.id must be new — a colliding id would shadow the existing release
(get_release resolves to the OLD one), so the channel edge would silently
point at a different release than the one published. Now rejected with
Integrity + rollback.
tests/repo_transaction.sx: add a channel-name-mismatch case and a
release-id-collision case (both assert Integrity + model unchanged); existing
fully-consistent publish still commits. Both new cases fail on the pre-fix
repo.sx and pass after.
Repo.publish validated entities individually and checked
artifact.release_id == release.id, but never verified the published
aggregate forms one consistent identity graph. It could commit a channel
whose app_id differs from the release's app (a channel of app B pointing
at app A's release) or artifacts whose app_id differs from the release's
app — exactly the dangling/cross-app edge the acceptance forbids.
Add Integrity preconditions to the publish transaction (reusing the
existing len-reset/channel-restore rollback so the model is unchanged on
failure): the release's app must exist, the promoted channel must belong
to that app (chan.app_id == release.app_id), and every artifact must
belong to that app AND name this release (a.app_id == release.app_id and
a.release_id == release.id).
Extend tests/repo_transaction.sx with cross-app channel and cross-app
artifact cases asserting publish raises Integrity and leaves the model
unchanged; the existing rollback and no-dangling assertions stay green.
Adds the in-memory repository over the P2.1 domain and whole-model
persistence to <root>/db.json via std.json (subplan-02 Slice 1, the part
P2.1's mapping deferred).
src/repo/repo.sx
- Repo over App/Release/Artifact/Channel/AuditEvent, each a growable
List scanned LINEARLY (no index — Slice 1).
- create/get/list/update per entity; find_app_by_slug;
find_artifact_by_digest (the P2.2 content-address key).
- publish(): atomic-ish transaction (release + artifacts + channel
pointer). A failure midway rolls the model back by snapshot/restore —
no half-inserted entities and no channel left pointing at a release
that isn't in the repo.
- Long-lived-container rule: init captures own_allocator :=
context.allocator and every List growth forwards it explicitly, so the
backing stores outlive any single call's transient context allocator.
src/repo/db.sx
- save()/load() the whole model to/from <root>/db.json via std.json.
- Stable (insertion-order) field order: entities emit in declaration
order; top-level order is apps, releases, artifacts, channels,
audit_events. Re-saving an unchanged model is byte-identical.
- Enums serialize as their variant name. Read-back is strict: a missing
field, wrong JSON type, or unknown enum name -> typed LoadErr.BadShape.
Loaded strings are copied into the new repo's own allocator.
tests/
- repo_roundtrip.sx: save -> reparse (valid JSON) -> reload into a fresh
repo, asserting every field round-trips and a re-save is byte-identical.
- repo_transaction.sx: a publish that fails midway leaves the model
unchanged (no dangling release/channel), in memory and after reload.
- repo_owns_allocator.sx: deterministic proof that every owned list grows
through the captured allocator, not the call-site context allocator.
Gate: make build + make test both green (6/6).
put_file hashed the source path, then copied the source again — two reads.
A source mutated in between would publish bytes whose digest != returned key,
breaking the content-addressed invariant. Now copy the source once into a
provisional staging file, derive the key from the SHA-256 of that staged file
(the exact bytes published), then dedup/atomic-rename. Guarantees
key == digest(published object) with a single source read.
Extends the acceptance test: re-hashes the stored object and asserts it equals
the returned key (and std.hash / shasum of the fixture), asserts cross-path
dedup (put_file and put_bytes of identical content share one object), and
asserts the staging temp is cleaned up on both the success and dedup paths.
Local blob store under src/store/, the first real consumer of std.hash.
Objects are addressed by lowercase-hex SHA-256: the digest is the storage
key and bytes live at <root>/objects/<sha256>.
- put_bytes / put_file compute the digest via std.hash, write to a
staging file, then atomically rename into objects/<sha256>. The rename
is the only step that publishes, so an interrupted/failed write never
leaves a torn object at the final path.
- Dedup: an already-published object short-circuits without re-staging.
- stage_write/stage_copy + publish expose the two phases for the test.
tests/store_content_addressed.sx asserts the storage key equals std.hash,
an independent `shasum -a 256`, and the pinned SHA-256("abc") vector;
that dedup stores one object and never rewrites it; that a staged write
is invisible until publish and a failed publish leaves no object; and
that put_file round-trips bytes. Gate: make build + make test both green.
Document the already-decided decomposition of subplan-02 Slice 1 so the
P2.1 boundary is verifiable from the repo:
- P2.1 = Core Structs + boundary validation (validation portion of Slice 1)
- P2.3 = in-memory repository + db.json (rest of Slice 1)
- Token = Slice 5 (Token Security), out of both
Planning-doc record only; no domain or test code changed.
Add the core distribution domain model under src/domain/ (App, Release,
Artifact, Channel, AuditEvent + Platform/Visibility/ValidationStatus/
RolloutPolicy enums) and a boundary validator that returns one distinct
typed ValidationErr per failure class (BadSlug, EmptyVersion, BadVersion,
BadChannelName, UnknownPlatform, MissingField, BadRollout).
Pure sx, depends only on modules/std.sx; lookups left as linear scans over
List (no HashMap). tests/domain_validate.sx asserts valid App/Release/
Artifact/Channel are accepted and each invalid case is rejected with the
exact expected error tag.
Stand up the foundation every later step depends on:
- Source layout: src/, src/infra/, tests/, examples/ (.gitkeep markers).
- Makefile: `build` compiles the smoke program via $SX, `test` runs the
runner over tests/**/*.sx, `publish-example` placeholder (real in P3.4).
Compiler located via `SX ?= /Users/agra/projects/sx/zig-out/bin/sx`.
- tests/run.sh: POSIX-sh runner; discovers tests/**/*.sx, runs each via
`$SX run`, prints ok/FAIL, exits 0 only when all pass (errors on zero
tests so the gate is never silently empty).
- tests/smoke.sx: passing smoke test importing modules/std.sx — proves
toolchain wiring end-to-end (std resolves via the binary's own location).
- .gitignore: ignore build/ artifacts.