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).
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.
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.
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.
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.
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).
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.
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.