server_http gains the case that caught the live crash: 1000 keep-alive
requests at c=10 against /api/apps (full SQLite load per request, on
the 4-worker pool) must complete with 0 failures and leave the server
answering. sqlite_api's threadsafe pins flip to guard the NEW invariant
— a regression to THREADSAFE=0 reintroduces heap corruption under the
pool (free-of-unallocated inside yy_reduce, caught under ab -c20).
thread_pool_count = 4 turns on std.http's pooled dispatch: reads
(store load + render per request, fully independent) now run in
parallel across workers. POST/PUT stay under one mutex — every write
is a whole-model load-modify-save on dist.db, so concurrent writers
would lose updates; the old sequential loop serialized them by
accident, this serializes them on purpose.
The hand-rolled sequential accept loop, its SO_RCVTIMEO band-aid, and
the whole src/server/http.sx module are retired: distd is now a
std.http handler. Server.init gets the store directory through the ctx
word; route() fills a Response instead of writing to a socket; every
handler ports mechanically (respond_error/load_or_503/respond_render
take *Response; bodies allocate from the per-request arena, never the
stack, since serialization happens after the handler returns).
Downloads keep X-Checksum-SHA256 via extra_headers; auth takes the
extracted Authorization value; the 411 contract (POST/PUT must declare
Content-Length) moves into the handler, pinned as before.
Config: 512 MiB read cap (whole-body artifact uploads), 120s request
deadline, 5s keepalive, 200 requests per connection. Idle connections
now cost nothing — timeouts evict, never block.
http_client gains its own 10s read timeout (the old shared helper's
secs->ms change had silently shrunk it to 10ms).
tests/server_http.sx pins the architecture: a request answers within
1s while SIX idle preconnects are held open (the retired loop paid
250ms-2s per idle socket serially), and two requests ride one
keep-alive connection. make test 24/24 green.
Each idle browser preconnect blocks the sequential accept loop for the
full SO_RCVTIMEO serially (measured: ~1.9s behind one, ~3.9s behind
two), so the timeout must stay well under human patience until the
readiness loop lands (PLAN-HTTPZ A1 retires the mechanism).
set_read_timeout takes milliseconds now.
Rebuilds, frees the port (stale servers from earlier sessions squat on
it), and restarts detached on 0.0.0.0:8787; the .lan/ store and its log
are long-lived and gitignored.
Subplan 06, first slice. src/server/admin.sx server-renders the
operator console the same way the index and install pages are built —
sx string rendering, no client framework, no build step:
/admin apps overview (platform coverage, channel and
release counts, latest release)
/admin/apps/<slug> metadata + iOS install-mode chip, channels with
policy/rollout/retention, releases newest-first
/admin/releases/<id> artifacts with validation chips and download
links, channels serving the release, audit
timeline (by target, by metadata, by artifact
id prefix)
/admin/tokens lifecycle status via token_status; never a
secret or hash
/admin/audit the full log, newest first
Reads are public like every distd GET in v0; mutations stay on the
token-gated POST endpoints (UI actions are a later slice). Unknown
slug/release/route answer 404 JSON errors with stable codes
(admin.unknown_app / admin.unknown_release / http.not_found). The
module is self-contained (adm_-prefixed helpers) so distd can import it
without a cycle; timestamps render through a local civil-from-days
formatter. The public index footer links the console.
tests/server_admin.sx drives the built binary over curl and pins every
screen, the no-secret-material guarantee, and the 404 codes.
make test 24/24 green.
Subplan 02 Slice 4. Channel gains retention_keep (0 = keep everything;
N = keep the newest N published releases of the channel's lineage), set
via the new `dist channel set --retention-keep`. The new `dist store
cleanup` prunes lineage-expired releases — never one any channel points
at, so cross-promoted releases survive — drops their artifact rows,
GCs objects/ files no surviving artifact references, and sweeps stale
staging/ leftovers; every deletion writes an audit event. The pruned
model is saved before any unlink, so a crash leaves orphan blobs (next
run catches them), never dangling references.
repo.publish no longer replaces an existing channel row wholesale: only
the pointer moves, so policy/rollout/retention survive every publish
(previously each publish silently reset them to defaults).
std.fs has no directory listing, so cleanup.sx carries a local
opendir/readdir/closedir shim, like publish.sx's time(2) shim.
dist.db channels gains the retention_keep column (idempotent ALTER for
pre-retention stores); db.json import treats it as optional.
tests/retention_cleanup.sx pins the whole scenario; the repo.publish
assertion fails on the pre-fix code. make test 23/23 green.
The amalgamation and the bindings now ship with sx itself
(sx library/vendors/sqlite/ — bindings + c/ amalgamation); every
import flips from ../src/db/sqlite.sx to vendors/sqlite/sqlite.sx,
resolved through the compiler's stdlib search paths. vendor/ and
src/db/ leave this repo entirely. make test 22/22 — the object cache
keys on content, not path, so the relocated source still hits the
existing cache entries.
src/db/sqlite.sx declares the vendored amalgamation as a named
'#import c' unit (pinned defines + -O2 + #source); every #foreign
binding resolves against it with UNPREFIXED sqlite3_* names. sx
compiles the unit through its content-addressed object cache — once
per checkout — links the objects into 'sx build' binaries, and loads
them as a priority symbol target under 'sx run', so the OS libsqlite3
can never shadow the vendored copy (the version pin in sqlite_smoke
proves it).
Retired: the Makefile vendor targets (cc -> .a + jit/.dylib), the
GENERATED dist_sqlite3_* rename.h (the JIT no longer resolves
program-owned symbols through the process images, so the rename's
reason is gone), and the -L plumbing in make build + tests/run.sh.
make test 22/22; otool -L build/dist carries no libsqlite3.
src/repo/db.sx persists the whole Repo to <store>/dist.db through the
vendored SQLite bindings, keeping the load-whole/save-whole call shape.
One table per entity; enums as lowercase variant names; list order
round-trips via rowid. Enforced uniqueness: apps.slug,
channels(app_id, name), tokens.token_hash; lookup indexes on
releases(app_id) and artifacts(sha256) (non-unique - identical bytes
may ship in several releases). save is DELETE-all + INSERT-all inside
BEGIN IMMEDIATE...COMMIT with rollback on failure; every connection
sets busy_timeout so the CLI and a running distd interleave safely.
A store holding only a pre-SQLite db.json imports once on first load,
then the file is renamed db.json.imported; a store with neither starts
empty. Consumers gate on db.store_exists instead of probing db.json.
The JSON read-back stays for the import path; the entity->json writers
stay for distd's /api responses.
Tests that parsed db.json directly now assert by querying dist.db
through the SQLite bindings; tests/db_import.sx pins the import path;
tests/repo_roundtrip.sx pins the SQLite round-trip. make test 22/22.
The wrapper surface previously widened every integer to i64. Wrappers
now carry SQLite's own widths: i32 where the C API says int (result
codes, counts, indexes, byte sizes), u32/u64 where it says unsigned
(prepare flags, strlike escape, zeroblob64), i64 only for the genuinely
64-bit APIs (rowids, changes64, memory accounting, serialize sizes).
sx converts implicitly in both directions, so call sites are unchanged.
Whole C strings now cross as cstring (sx 1d17b0a): IN via to_cstring
with literal coercion, OUT as ?cstring copied through sq_from_cstr
(std from_cstring + substr) before sqlite's buffer dies. sq_cstr and
the manual strlen loop are gone. column_text/column_blob/serialize
deliberately stay ?*u8 + explicit byte counts — their payloads may
carry interior NULs that a strlen would truncate. 21/21 green.
The null moves into the type: errmsg/errstr/libversion/sourceid/
column_text/blob/name/decltype/*_name/db_filename/sql/expanded_sql/
bind_parameter_name/serialize return ?*u8 instead of *u8 with manual
cast null-checks, and the helpers take the optional directly. Verified
that an optional pointer crosses the FFI with the null-pointer niche in
both JIT and AOT modes.
?[:0]u8 would be the ideal shape (nullable null-terminated string) but
does not resolve in sx yet — it panics LLVM emission; filed as an sx
followup. The header comment records the constraint.
src/db/sqlite.sx grows from the P5.1 subset (~19 fns) to the complete
practical surface (~100): open_v2 + flags, extended errcodes +
error_offset, txn_state/autocommit, changes64/total_changes64, limits,
the full bind/column families (double/blob/zeroblob), parameter and
column introspection (built with SQLITE_ENABLE_COLUMN_METADATA),
table_column_metadata, statement introspection (sql/expanded_sql/
readonly/busy/isexplain/status), incremental blob I/O (SqliteBlob),
online backup (SqliteBackup + sqlite_backup_run), serialize/
deserialize, and library utilities (complete, strglob/strlike/stricmp,
randomness, memory, compileoptions). One variant per duplicate family
(modern/64-bit preferred; bind_text/blob keep the 32-bit length forms
that skip text64's encoding arg). Not bound, by design: callback-taking
APIs (hooks/UDFs/collations need C->sx callbacks), sqlite3_value_*
(UDF-coupled), varargs config, UTF-16, and subsystems this build omits
— the boundary list lives in the module header and vendor README.
rename.h is now GENERATED by make into build/vendor/ from the bindings'
#foreign names — src/db/sqlite.sx is the single source of truth and the
rename list cannot drift (checked-in vendor/sqlite/rename.h removed).
make test 21/21 (new: sqlite_api.sx — 15 cases over every wrapper
family, including a blob round trip with interior NULs, UNIQUE
constraint extended errcodes, txn_state through BEGIN IMMEDIATE,
backup db->db, and a serialize->deserialize round trip).
KNOWN sx BOUNDARY (filed as a followup): 'if !e' on an error binding
evaluates true even when the error is set — negated error logic in
tests routes through plain bools.
Whole C strings IN are [:0]u8 (the std convention, popen/getenv) with
terminated copies built via cstring(); nullable C strings OUT are *u8
with the getenv-style cast null-check — usize said nothing about what
crossed the boundary. prepare_v2/bind_text stay [*]u8 + explicit byte
length on purpose: those are (ptr, nByte) APIs, which is what lets
un-terminated sx string views pass without copying. Handles stay usize
so sqlite3_open's out-param is a single-level *usize.
The typed-local workaround is unnecessary since sx 1bc60d3: enum
literals returned into an optional target now resolve against the
unwrapped child and wrap. tests/server_install.sx pins the UA
detection behavior; 20/20 green.
Subplan 02 Slice 2 foundation. vendor/sqlite/ holds the amalgamation
(provenance + upgrade notes in its README); make build compiles it into
build/vendor/libsqlite3.a (statically linked into dist via -L) and
build/vendor/jit/libsqlite3.dylib (dlopen'd by sx run via tests/run.sh's
-L flag) — separate directories because the macOS linker prefers a dylib
over an archive in one search dir.
The sx JIT resolves #foreign symbols via dlsym(RTLD_DEFAULT), where the
already-loaded OS libsqlite3 wins by load order — so the vendored build
renames its API to dist_sqlite3_* (vendor/sqlite/rename.h, -include'd),
making resolution unambiguous in both modes: those symbols exist only in
the vendored products.
src/db/sqlite.sx binds the renamed surface behind Sqlite/SqliteStmt
(open/exec/prepare/bind/step/column/finalize, errmsg, last_insert_rowid,
changes, libversion); opaque handles cross the FFI as usize, strings
read from sqlite are copied before its buffers die.
make test 20/20 (new: sqlite_smoke.sx — pins the loaded version to the
vendored 3.53.2, round trip, reopen persistence, BEGIN/ROLLBACK, errmsg;
also verified as an AOT binary with no libsqlite3 in otool -L).
The human install surface (subplan 04 Slice 5), honest per PLAN.md's
iOS Install Policy. App gains ios_mode (artifact_only default /
testflight / enterprise) and testflight_url; absent db.json members
load as defaults. dist app set is the admin mutator (apps are created
by publish), enforcing the policy preconditions as machine-readable
errors: testflight requires --testflight-url, enterprise requires
--ios-bundle-id. Every set appends an app.update audit event.
GET /install/<slug>/<channel> renders the channel's current release as
platform sections — the User-Agent's platform ordered first and marked
— with per-mode iOS actions: TestFlight link, itms-services OTA deep
link, or an IPA download explicitly labeled as not installable from
the page. Size + sha256 are visible on every artifact row.
GET /install/<slug>/<channel>/manifest.plist serves the enterprise OTA
manifest (bundle id, version, https package URL off the Host header)
and 404s in any other mode. The index links channels to their pages.
KNOWN sx BOUNDARY (issue 0098): an enum literal returned directly into
an optional target silently lowers to variant 0 — ua_platform routes
every variant through a typed local.
make test 19/19 (new: server_install.sx pinned acceptance).
The PLAN.md CI contract in remote mode. client/http_client.sx is the
temporary in-repo HTTP client shim over std.socket (connect(2) via libc
FFI), targeting http://<ipv4-or-localhost>:<port> — DNS and TLS are
loud v0 boundaries (https:// is refused; the deployment story
terminates TLS at a reverse proxy).
publish/remote.sx drives the same manifest front half as local publish,
uploads each artifact through POST /api/upload (verifying a
manifest-declared sha256 against the server-computed digest), then
publishes via POST /api/apps/<slug>/releases. The 200 response is
parsed back into a PublishOutcome so --json/human rendering reuse the
local paths; non-2xx responses pass the server's JSON error code
through report_failure. ci publish takes exactly one of --local-store
or --server (+--token); anything else is a usage error.
make test 18/18 (new: remote_publish.sx pinned acceptance against a
live distd).
distd stops being read-only. http.sx learns the write-side request
surface: header capture with case-insensitive lookup, a Content-Length-
bounded body read loop (8K header cap, 512 MiB body cap -> 413, 411 for
length-less POST/PUT), and the matching status texts.
Auth (server/auth.sx): Authorization: Bearer is re-hashed and resolved
via find_token_by_hash, then gated through check_token — 401 for
missing/malformed/unknown credentials, 403 with a refusal-specific code
(auth.revoked/expired/missing_scope/app_forbidden/channel_forbidden);
successful auth stamps last_used_at. check_token's app gate now treats
an empty REQUEST app like an empty request channel (uploads are
app-agnostic until a release references them).
Write routes (POST, publish scope): /api/upload content-addresses the
body; /api/apps/<slug>/releases publishes over already-uploaded objects
through commit_publish — the back half extracted from run_publish so
CLI and HTTP publishes share one find/create-app -> transaction ->
audit -> persist pipeline; channels/<name>/promote|rollback delegate to
the P3.5 CLI pipelines. Reads stay public.
make test 17/17 (new: server_write.sx pinned acceptance over curl).
Subplan 02 Slice 5: Token domain entity (scopes, app/channel scoping,
expiry, revocation, last-used) with boundary validation; secrets are
dist_<64 hex> drawn from arc4random_buf and only their SHA-256 is
persisted. check_token gates revocation > expiry > scope > app/channel;
mark_token_used stamps usage for the P4.4 server auth.
CLI: dist token create (raw secret shown exactly once; works on a fresh
store so CI tokens can predate the first publish), list (lifecycle
status, never the secret), revoke (unknown id and double-revoke are
distinct errors). Every mutation appends an audit event; tokens joins
db.json's persisted arrays, with an absent member loading as empty so
older db.json files stay readable.
make test 16/16 (new: token_check.sx unit suite, token_ops.sx pinned
CLI acceptance).
Mechanical sweep of all .sx sources and plan docs (PLAN.md, current/,
.agents/) for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64).
Verified: make build + make test, 14/14.
GET / now serves a dense server-rendered console: each app with its
channels' current releases and every release's artifacts as direct
/download/<sha256> links. Read-only off the per-request db.json reload;
text escaped for HTML. A browser hitting the server root sees the store
instead of a 404.
A browser speculative preconnection sends no bytes; the sequential
accept loop blocked in read() on it forever while real requests sat in
the backlog — LAN clients saw a dead server while curl (connect+send in
one shot) worked. SO_RCVTIMEO frees the loop. Regression case pinned in
tests/server_http.sx (fails 000 pre-fix, 200 post-fix).
dist server run binds 0.0.0.0:<port> (default 8787) and serves /healthz,
/api/apps, /api/apps/<slug>, and /download/<sha256> (X-Checksum-SHA256,
bytes verified content-identical). db.json reloads per request so CLI
publishes/promotes are visible immediately. Errors reuse the CLI's JSON
error shape with matching HTTP statuses. HTTP/1.1 is an in-repo shim over
std.socket (src/server/http.sx), liftable to the sx stdlib later.
Response buffers are heap slices: a 64K+ stack array in one frame
segfaults the sx LLVM backend (DAGCombiner); 4-16K stack buffers are
fine. Pinned in tests/server_http.sx including a freshness case.
promote points an (app, channel) at a release id — cross-channel
promotion allowed, missing channel created, manual policy gate stubbed.
rollback moves the pointer to the previous PUBLISHED release in the
channel's publish-order lineage (cross-promoted pointer falls back to the
channel's own latest; at the earliest release it refuses with
rollback.no_previous). Both append a cli-actor audit event and re-persist
db.json; failures follow the P3.4b contract (dotted-code JSON error,
exit 1, store untouched). Acceptance pinned in tests/release_ops.sx;
cli_dispatch reworked off the removed stubs.
Every abort site writes a CliFailure (stable dotted code + human message
naming the offending input); under --json the CLI emits a single
{"status":"error","error":{code,message}} object on stdout and exits 1 —
distinct from the parser's EX_USAGE 64. All aborts happen before db.save
and the repo transaction rolls back, so a failed publish never changes
db.json. Pinned test drives all five failure classes plus the
non-empty-store no-partial-state crux.
sx delivered most of the foundation since the plan was written: alias
re-exports instead of pub, the modules/std.sx facade barrel, and native
std fs/process/json/cli/hash/log/test/socket/mem. Replace the 'sx
Foundation Work' section with as-built status + remaining gaps, mark
Phase 0 done / Phase 1 partial, note the superseded subplan-01 slices,
and drop the retired flow-harness execution contract (subplan 08, roles,
checkpoint files).
The June stdlib restructure deleted the flat library modules; the old
paths kept resolving only through a stale zig-out/library install
snapshot. Verified green with that snapshot removed. Drop the empty
src/infra/ — the planned hash/json/cli shims shipped as sx std modules
instead.
`dist ci publish` now seeds the Repo from a pre-existing <store>/db.json
before find-or-create, so separate CLI invocations share state: a new
version accumulates under the single found app, and re-publishing the same
release id is rejected by the P2.3 integrity transaction (db.json left
unchanged). An absent db.json still starts empty. The loaded model grows
through its owning allocator (context.allocator), per the long-lived rule.
Wiring db.load into the dist program (which already links manifest.sx)
exposed two latent issues, both fixed:
- db.sx's load-path helpers (dup_str/obj_find/req_obj/req_arr/
artifact_from_json) collided by name with manifest.sx's same-named
helpers; sx resolves bare top-level names across the whole program, so
load_into bound to manifest's versions and failed the LoadErr error-set
check. Renamed db.sx's five helpers with a db_ prefix (load-path only;
save path and public API untouched).
- publish's `existing!.id` (only reachable once an app is found, i.e. never
before this change) read garbage: sx miscompiles postfix-`!` chained with
`.field`. Bound the unwrap to a local first, matching the codebase idiom.
tests/publish_persist.sx drives build/dist twice into one store: publish A,
then a different version B accumulates (two releases, one app, both objects),
then re-publishing A's id fails and leaves db.json unchanged. Fails on the
pre-fix write-only persistence, passes after.
Real local publish success pipeline replacing the ci-publish stub: validate manifest,
find/create app + draft release, per-artifact content-address store (P2.2) + common
validation (P3.3) with optional manifest-declared size/sha256 (PO ruling), publish via
the repo integrity transaction with channel promotion + audit events (P2.3), persist
db.json (P2.3), emit stable JSON (release id, artifact ids, sha256, file:// URLs) with
--json purity. make publish-example target + tests/publish_happy.sx (fail-before/pass-after).
Salvaged from a worker that completed the work (make test 10/10) but hit the 50-min wall
before committing; manager-verified at ground truth (make test green, make publish-example
exit 0, stored object re-hashes to its key via shasum, db.json records release/2 artifacts/
channel/4 audit events).
Add the platform-agnostic on-disk validation subset (subplan 05). Pure sx
consuming std.hash + the P2.1/P3.2 domain; no sx-repo changes.
validate_artifact_file(path, expected_size, expected_sha256, platform,
content_type) -> ValidationOutcome { status, reason } runs, in order:
file exists, on-disk size == expected_size, SHA-256 (recomputed via
std.hash) == expected_sha256, content_type on the allow-list, and the
extension matches the platform policy (.apk->android_apk, .ipa->ios,
.dmg->macos, .appimage->linux, .exe->windows). First failing check sets a
specific ValidationReason; all pass -> ValidationStatus.valid/.ok (reusing
the P2.1 enum). Deep IPA/APK zip inspection deferred.
tests/validate_artifact_file.sx exercises both good fixtures and every
tampered class (wrong size, digest mismatch, .apk-as-ios, disallowed
content type, missing file), asserting the exact reason for each.
Define the v0 publish manifest, parse it via std.json into a typed
struct, and validate it.
- src/manifest/manifest.sx: typed Manifest { app, version, channel,
artifacts: List(ManifestArtifact) } + ManifestArtifact { platform,
path, filename, content_type, metadata }. parse_manifest walks the
std.json Value tree into the struct, surfacing every malformed/
missing/wrong-type field as a distinct typed ManifestErr (BadJson,
WrongType, MissingField, UnknownPlatform, MissingArtifact, Io) — never
a silent default. Reuses P2.1 parse_platform for the platform id.
validate_manifest checks each artifact path exists on disk (fs.exists),
resolved relative to the manifest's directory. Strings are copied into
the caller's allocator (long-lived-container rule).
- examples/dist.json: representative valid manifest (android_apk + ios).
- examples/fixtures/: tiny stand-in artifact byte files referenced by it.
- tests/manifest_parse.sx: parses dist.json and asserts fields; asserts
the three failure classes surface distinct typed errors.
The sx 0100 fix (cli.parse / json.parse name collision) is merged on sx
master, so `dist.sx` — co-importing std.cli (via dist) and std.json (via
json_out) — now lowers and builds. Finish the step:
- dist.sx: fix two real frontend errors the old IR-lowering crash had
masked — `main` returns `!` (noreturn exit tails), and the post-parse
dispatch is guarded by `if !perr` so the failable `p` is used only with
its error proven absent. Drop the stale BLOCKED narration.
- Makefile: `make build` now also compiles src/dist.sx -> build/dist;
`make test` depends on `build` so the acceptance test finds the binary.
- tests/cli_dispatch.sx: drives the BUILT build/dist via process.run and
asserts the std.cli exit-code + --json purity contract: no-args and
unknown-command -> human text on stderr + EX_USAGE (64); `ci publish
--json` -> stdout is a single valid JSON object (std.json.parse, no
trailing junk) with the human ack on stderr; `--help` lists ci/release.
Handlers stay honest stubs (real ci publish is P3.4). Gate green:
make build (build/dist), make test (7/7).
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).