Skip to content

Phase 1 / Core / ECS access enforcement - #78

Merged
guysenpai merged 26 commits into
mainfrom
phase-1/core/ecs-access-enforcement
Sep 13, 2026
Merged

guysenpai merged 26 commits into
mainfrom
phase-1/core/ecs-access-enforcement

Conversation

@guysenpai

Copy link
Copy Markdown
Contributor

Brief: briefs/M1.A-ecs-access-enforcement.md

ARCH-030 closed in Zig, and its Tier 3 form aligned. The declared access set becomes the type of what a system receives.

Closing notes

What worked.

  • Deriving the view and the DAG from ONE spec removed the failure the milestone exists to close, rather than detecting it. SystemDescriptor.of(phase, name, spec, body) produces the descriptors AND the type of the context; there is no second list, so nothing can disagree. It also retired a hazard nobody asked it to: registerSystem stores the caller's accesses slice without duplicating it, and forge/sync.zig documents a real crash from an inline &.{ … } temporary dangling — a derived set is a comptime constant and cannot.
  • Erasing the world's TYPE instead of trying to hide it. Zig has no private field and the brief says so; the view stores the pointer as *anyopaque, so the bypass costs an explicit cast at the call site instead of a field access. Pairing that with the job-bound marker was necessary rather than decorative: the erasure makes carriesMarked blind, and a view in a worker's arguments is exactly the hazard the marker exists for.
  • The counter-proofs shipping WITH the guarantee, not after it. Both halves of the harness were shown to redden before either was believed — and one of those counter-factuals found a defect in the harness itself.
  • Reading the diff after a mechanical pass. Two separate regressions from one regex — an eaten allocator parameter in every multi-line signature, and a real argument removed at every call whose receiver was a World — were caught by reading the diff pair by pair, not by the compiler, which was happy with several of them.

What deviated from the original spec.

  • The FROZEN SECTION's registration count is refuted by measurement: 55 sites across 11 files, not 51 across 10. Recorded under Recorded deviations; the frozen text is a Claude.ai round-trip and was not edited here.
  • WELD_ECS_PROTOCOL_VERSION 1 → 2. Not named by the brief; prescribed by root.zig's own doc comment for exactly this case.
  • Three additive World accessors (changedTick, resourceBytes, currentTick) outside the brief's file list, and observers.zig likewise — both justified in the Execution log.
  • The instruction-for-instruction comparison is zig build ecs-access-zero-cost, not a test block: a test cannot disassemble the binary it lives in, and the repository's only precedent for reading a claim in emitted code is a build step feeding a Zig scanner (forge-asm-inventory).

What to flag explicitly in review.

  • The protocol bump. Three shapes root.zig names as versioned changed, all breaking for a Tier 1 caller. If the intent was to keep the surface at 1, that is a decision to take before merge.
  • FrameContext.user is the residual the view cannot close, and it is the path every system in the tree actually uses to reach its query. The enforcement is real on the view's own surface and silent on that one.
  • The Tier 3 C mirror is hand-written because no binding generator exists in this tree. It is pinned on both sides, but the day weld_api.h is generated this header should be deleted rather than reconciled.
  • The registerOn* family lost its world parameter at 42 call sites. Mechanical, but wide.

Final measurements.

  • Test floor 2252 → 2280 (macOS, measured: 2261/2280 tests passed, 19 skipped) and 2278 on windows-2025 (derived from the guard's two only_on = .windows entries; the CI cell confirms it). Re-derived from the suite four times as blocks landed — 2263, 2271, 2275, 2279, 2280 — the closure agreeing independently at each.
  • Four corners green: Debug and ReleaseSafe, f32 and f64, 312/312 steps.
  • zig build forge-determinism on all four corners: eight committed witnesses byte-identical, four discrete invariants OK, divergence frame: none within K=60 — on a DAG whose edges changed, the forge system having gained Reads(RigidBody).
  • C0.1 ReleaseFast, interleaved against 251db05 through a worktree: 3.66 ms against 3.66 ms, delta 0.0 %. S1 at --workers=4 over eight interleaved rounds: 52 666 ns against 52 646 ns, +0.0 %.
  • Zero cost: the two witness functions are ONE body of 8 instructions at ReleaseSafe — the backend found them byte-identical and emitted a single function. Counter-factual: a runtime check inside getMut takes it to 23 against 8.
  • The enforcement's first catch is in production code: authorityOf reads RigidBody from inside the registered system and the declared set named it nowhere. Second catch in the bench: bench_integrate declared nothing while its worker body mutates two columns.

Residual risks / tech debt left intentionally.

  • No new M1.D entry has M1.A as its cause, which is the milestone's own exit rule. The residuals are named in CLAUDE.md under M1.A residuals and none is deferred work: FrameContext.user, the absent view.query (no consumer), services/physics.zig's *World on an Etch-callable path (not a system entry point, so outside the invariant's object), M1.D.23 neither fixed nor promoted, and the Tier 3 guarantee bounded to a compilation witness.
  • Pre-existing and untouched, reported rather than stepped over: M1.D.23 and M1.D.24.

Validation

  • Every deliverable of § Scope present
  • No drift into § Out of scope
  • zig build test green on the four corners (Debug + ReleaseSafe × f32 + f64) — 2261/2280
  • bench/ecs_benchmark.zig C0.1 and S1: no regression beyond 5 % against 251db05, measured interleaved
  • Observable behaviour demonstrable — zig build ecs-access-counterproof, ecs-access-zero-cost, c-api-read-column-constness, forge-determinism
  • zig build, zig build test, zig fmt --check, zig build lint green
  • CLAUDE.md updated (current-state table, +1 Tags row, residuals, date) — and its current-state row corrected, it still described M1.E as active after that milestone's merge
  • Language audit (§3.6.1): 13 hits on the branch diff, all the verbatim French section title of engine-ecs-internals.md cited by the brief, plus English plus and the Zig identifier un. Zero French prose. Run with Python, not a grep bracket class.
  • Local drift audit (§3.6.1): two orphaned comments found and patched (sync.zig "carries the World", scheduler.zig "the cmd buffer borrows world")
  • § Closing notes filled
  • Status: CLOSED, Closed: 2026-09-12
  • Final commit docs(brief): close M1.A

🤖 Generated with Claude Code

`registerSystem`'s conflict pass refuses two writers of ONE id and nothing
more, so two systems whose declarations CROSS — `Reads(T), Writes(U)`
against `Writes(T), Reads(U)` — both registered and closed a two-node DAG
cycle. `computeLevels` then reported it at the FIRST dispatch as
`error.WriteWriteConflict`, permanently for that phase, under a name
`RegistrationError` documents as something else, with the offending
descriptor already committed.

Pre-existing and byte-identical at the merge base, so not a regression —
but this branch makes it reachable for Forge, whose `step_spec` now
declares a read where it declared none.

`RegistrationError` gains `DependencyCycle`. `registerSystem` gains a
reachability walk from the new system's successors over the existing
edges, asking whether any reaches one of its predecessors, placed ahead
of the commit so the refusal touches nothing of the scheduler. The two
codes stay distinct: a write-write conflict is a property of ONE
component, a cycle a property of a SET and of no single member.

Two colours suffice where `registry.zig`'s `@requires` closure needs
three — the question is REACHABILITY in a graph already known acyclic,
not cycle-finding. `computeLevels` KEEPS its branch rather than gaining
an `unreachable`: the invariant is held by a sibling function and does
not survive a registration that failed on `OutOfMemory`.

Counter-factual, the pass removed: `2263/2284 tests passed (2 failed)`,
both reading `expected error.DependencyCycle, found void`, with the
non-vacuity test — a crossing declaration closing NO cycle — staying
green. Four corners green at 2265/2284. Floor 2281 → 2284 / 2282,
re-derived from the suite.

Two enumerations swept with it, neither false and both short:
`root.zig` listed three shapes broken by the protocol bump (four — the
new variant breaks an exhaustive `switch`), and `README.md` listed one
registration refusal (two).
Three corrections from Guy's closing review, plus the journal of the DAG
cycle fix.

The Recorded deviation read 55 sites across 11 files; the like-for-like
figure against what the FROZEN SECTION actually names — `tests/` and
`bench/` — is 52 across 9. The 55 reached that number by adding the
`src/` sites, counted at the branch HEAD where three of the five in
`scheduler.zig` are inline tests THIS BRANCH added: a migration count
that counts what the migration created measures the wrong thing, and
mixing a second perimeter into a refutation blunts it. The frozen text
is untouched; it stays a Claude.ai round-trip.

`FrameContext.user` was called "the path every system in the tree
actually uses to reach its query". Measured: zero readers under `src/`,
and the eight files that read it are all under `tests/` and `bench/`.
True of the test corpus, false of production, and the difference is the
whole weight of the residual.

The Tier 1 seam takes `anytype` and that was unsaid. Eight functions
accept either a view or a `*World` under one spelling, which is what
lets one body serve both regimes — and is also the price: nothing at
the call site says which arrived. The enforcement holds because the ONE
registered caller passes `ctx.view`, upstream of the seam and not at it.

And one correction of my own, found before the review returned it: the
contract said the refusals run "before the first mutation". False —
`access.resolve` calls `ensureComponentRegistered`, so the WORLD is
mutated before either check. It is benign, being idempotent and
monotone, and it is now stated rather than denied.
Five independent lenses over the cycle fix raised 27 findings; 20 survived
a verifier told to refuse anything the source did not confirm, and they
deduplicate to seven classes. Every one is mine.

`computeLevels`' branch carried TWO reasons for staying an error rather
than an `unreachable`, and the second was FALSE: it claimed the branch
covers a scheduler half-mutated by a failed registration. It does not —
such a scheduler holds an edge naming an index the rollback popped, and
the in-degree count twenty lines above faults on it, `in_degree[target]
+= 1` with `target == n`, before one level is built. The scenario cannot
reach the line it justified. Deleted; the first reason was always
sufficient alone.

The walk's TRANSITIVITY was unpinned — its whole reason for existing. In
both the two-node and three-node cases the predecessor sits ONE hop from
a seed, so a walk that checks its seeds, expands one level and stops
passes them. Measured: with the walk replaced by that bounded form,
`310/312 steps succeeded; 2266/2286 tests passed (1 failed)` — exactly
one red, the four-node ring added for it, every other test green. A
diamond joins it, the only shape in the file that reaches a node twice,
written for termination and the answer and NOT for necessity.

`RegistrationError` is not the set `registerSystem` returns: the
resolver is `anyerror!ComponentId`, so the inferred set is `anyerror` and
a caller annotating against the alias does not compile. Pre-existing;
both the declaration and the re-export now say what it IS for.

`forge/sync.zig`'s preflight enumerated two deterministic failures where
there are three, and the third is reachable: `wouldConflict` compares
write against write only, and any later `fixed_update` system reading
`Transform` and writing `RigidBody` closes a cycle with `step_spec`. The
omission is argued, not papered — predicting it means a second copy of
the scheduler's edge construction inside a Tier 1 module.

Two contracts were wider than what holds. "A scheduler that has seen an
allocation failure here is unusable" reasoned about the commit and was
scoped to the function, false for every pre-commit allocation. "NOTHING
WAS COMMITTED" promised four things and asserted a count of descriptors;
it now walks edges and both tracker maps. The `topologicalLevels` check
beside it was deleted rather than strengthened — dominated by the
assertion above it, and its comment named a failure it could not see.

Floor 2284 -> 2286 / 2284, re-derived from the suite. Four corners green.
Two of the three findings a Codex review refused the closure on. Both are
one class — a guarantee affirmed in a comment that the code does not give.

P1-2. `View.world_erased` was `*anyopaque`, the same type for every
declared set, so `View(&write_spec).fromErased(ctx.view.world_erased)`
promoted a read declaration to a write one with no cast, no builtin and
no diagnostic. The file header claimed the escape cost "an explicit
`@ptrCast(@aligncast)` at the call site — a deliberate and greppable
act", which was true of recovering a `*World` and false of the promotion,
which is the bypass that is actually useful.

Closed at the type. `ErasedFor(spec)` is an opaque generated per declared
set; the field and `fromErased`'s parameter are spec-typed; the one
narrowing left is generated inside the trampoline, where the spec is
known. The design rests on comptime memoisation keying on the slice's
IDENTITY — one `const spec` names one type wherever it appears, two
separate declarations of an identical set name two — which was measured
before the design was written and is now pinned in the suite rather than
left in a throwaway program.

Counter-proof both ways, as required. `case_view_promotion.zig` must NOT
compile and fires on the compiler's own type error, not the view's
marker: a marker would mean the second view had been built and was
objecting afterwards. A positive test builds a view the way the
trampoline does and reaches what it declared, because a `fromErased` that
accepted nothing would satisfy the refusal perfectly.

P2. `WELD_API_VERSION_MAJOR` sat at 0 under a doc comment prescribing the
increment on every binary break, while `query_create` went from five
parameters to seven and `WeldQueryChunk` changed layout. Same C0.5 rule
that took the ECS surface to 2, applied on one axis and missed on the
axis the loader enforces. Bumped to 1, minor reset.

The bump is what made the missing half of the check live: the loader
refused only `api_version_min > host`, so a plugin built against major 0
would have loaded on a major-1 runtime and called entries whose
signatures had moved. A major is a break, so the admissible band is one
value; `error.ApiVersionTooOld` is its lower bound, with a fixture that
could not have existed before — nothing is older than the first major.
Counter-factual, the bound removed: the legacy stub reaches
`state = .loaded`, one red of 2288.

Floor 2286 -> 2290 / 2288, re-derived from the suite. P1-1 is next and is
not in this commit.
The third finding, and its general form is worse than the witness the
review names. Refusing the empty set would have closed one value; what
was open is that `registerSystem` took a `SystemDescriptor` — a struct
with public fields — so nothing tied `accesses` to `run`. Take the
descriptor `of(&.{Writes(A)})` produces, assign `.accesses =
&.{Reads(Z)}`, and the body writes A while the DAG is told it reads Z.
That is ARCH-030's original defect rebuilt by field assignment, and no
check inside `registerSystem` could see it: both halves are individually
well-formed.

Zig gives no way to seal a pair, and three forms were refused before the
fourth. A non-pub witness field does not help — a caller copies an
`of`-produced descriptor and overwrites the field beside it. A
`*const Bound` indirection does not either: the child type is reachable
through `@typeInfo` and a literal can then be written. Field access
through a non-pub TYPE is legal, measured. So the closure is not to hide
the pair but to refuse to ACCEPT one: the entry is generic on the
declared set, derives the descriptors AND the body's context type from
it, and `SystemDescriptor.of` is private. The type stays public because
the scheduler stores it and `systemsInPhase` returns it; nothing
consumes one from outside.

What that bounds is REGISTRATION, stated rather than rounded up:
`phases` is a public field, so a holder of the scheduler can rewrite a
stored descriptor after the fact. That is tampering with a DAG already
built, not a registration that lies.

80 call sites across 11 files. The scripted pass ran dry first, which is
what caught a defect in ITSELF: it keyed each generated declaration on
the BODY name, and one `nopSystem` serves twenty-five registrations
declaring twenty-five different sets. Keyed on the system name it parses
clean, and a body used at several sites becomes generic over the spec —
the consequence of a body's parameter type being its declaration. The
identity rule then earned its keep: two tests registering "the same
system in another phase" through two separately declared identical sets
were refused, and now share one declaration.

`case_missing_accesses.zig` is DELETED, not repaired. It omitted the
field and matched `missing struct field: accesses`, measuring that Zig
refuses a literal missing a field without a default — a fact owing
nothing to this milestone. A counter-proof that tests what the compiler
does anyway is green for a reason unrelated to the invariant, and that is
how the general form stood unclosed with a fixture guarding it.
`case_mismatched_pair.zig` replaces it.

The empty-set rule is NOT implemented and is raised, not skipped: the
refusal was written and fires on twelve registrations that are all
honest — five drive the phase pipeline and touch no component, the rest
mutate through `ctx.cmd`, deferred to a flush the DAG does not order.
Journalled under Blockers encountered.

Four corners 314/314, 2271/2290, zero assertions. Determinism on all
four: eight witnesses, four invariants OK, no divergence. Zero cost still
ONE body of 8 instructions. C0.1 3.36 ms, S1 52750 ns, both GO.
`build-and-test (windows-2025, ReleaseSafe, f32)` failed at `e054b26`
and failed again on its first same-SHA re-run. Cleared on the second,
which is the only admissible clearance for this class and which this
class has needed twice before.

Discriminants, both attempts: signature `test runner failed to respond`
present once, sibling class `failed without output` absent, zero
`error: '...' failed:` lines, and 53.0 then 48.6 minutes against a
55-minute budget with conclusion `failure` rather than `cancelled` —
which excludes the cold-cache timeout class on both its own
discriminants at once rather than on duration alone.

Both attempts lost exactly ONE test, where every previous pair of
failures at one SHA had lost different counts. Two identical losses is
the shape a specific implicated test produces, so the reading was tested
rather than repeated — and it is refuted by a measurement the debt's own
note calls impossible.

That note says naming the hung step from the log does not work because a
hung step emits no output. True of the step's SOURCE name; false of its
identity. The `failed command:` line immediately following the hang
message carries the step's build-cache hash, and the two attempts differ:
`01a580472aec240c02328baaccfbc5f8` against
`c810df3f34968acc6a2110b9fd5002da`. Two different executables hung at one
SHA. The equal loss then explains itself with no culprit — both sit in
the contiguous family whose neighbours all report `0 pass, 1 skip
(1 total)`, where a hang costs exactly one test whichever member it hits.
The count was never the discriminant; the identity is, and it is one grep
away in every log this class has already produced.

Collateral, because two debts met on one job: the run carries `Zig cache
is 11222302720 bytes, over the 10737418240 cap - SKIPPING the final
save`. A skipped final save leaves the next run on that cell colder,
which is the cold-cache class's trigger. Neither debt is new; their
interaction is recorded nowhere else.

And a measurement error of my own, reported for the same reason: the
first read of the failing job said every step was green. The filter
`select(.name|contains("ReleaseSafe, false"))` matches three jobs -
ubuntu, ubuntu-arm and windows - and jq concatenated their step lists, so
a green ubuntu job was read as the windows one. Querying by numeric job
id gave the truth at once.

20/20 checks pass at `e054b26`.
The closing review ruled on the one question this milestone raised, and
the ruling does not amend the rule — it reads it.

ARCH-030's object is the IMPLICIT empty set, the one an omission
produces. Since `spec` became a mandatory comptime parameter, omitting it
is a COMPILE error, which is stronger than the registration error the
invariant asks for. A hand-written `&.{}` is a declaration its author
made, not an omission that happened to them.

The decisive half removes the question instead of arbitrating it: the
pairing closure made an empty declaration self-verifying. The body
receives `SystemContextOf(&.{})`, hence `View(&.{})`, whose `get` and
`getMut` refuse at comptime for every `T` — already pinned by view.zig's
"an empty declaration grants nothing". A system that declares nothing
cannot reach a column, so it has no edge to place. Before the pairing was
closed an empty set could lie; after it, it cannot.

The comment at the site carried the measurement and the wrong reason.
"Twelve sites protest" is an argument from the tree; "the type guarantees
it" is an argument from the design, and only the second survives someone
deleting the twelve. Rewritten on the second, citing the pin.

`SystemScheduler.phases` stays public and stays named: rewriting a stored
descriptor needs the scheduler itself, and ARCH-030's threat model is the
system, which receives a context and nothing else.

Comment-only. 314/314, 2271/2290, counter-proof 6/6.
`weld_no_job_body` sat on `View` and not on `ErasedFor`, so `job_bound`
refused a view in a dispatched body's arguments and admitted
`ctx.view.world_erased` — a `*ErasedFor(spec)`, which `fromErased` takes
directly. A worker holding one rebuilds the view with no cast and reaches
any entity of the world by handle, which is word for word the reach the
marker's own text declares impossible.

Measured before anything was written, in both forms:
`declaresMarker(ErasedFor)` false, `carriesMarked(*ErasedFor)` false bare
AND false wrapped in a struct, against `carriesMarked(View)` true. The
guard had nothing to find.

What matters more than the fix is that the previous remedy opened this.
`ErasedFor` did not exist before 9223a8d; it was minted to close the
promotion between views and was born without the guarantee its twin
carried, because that guarantee is implemented in another file and
nothing at the point of creation recalled it. Second time in this
milestone a remedy opened the next breach. So the site carries the RULE
and not just the constant: any type through which a `*World` can be
recovered must declare this marker, whatever else it is for — written
where the next such type will be written, since the file that enforces it
is not the file that will forget it.

Census measured before, as required, and unchanged after: 6 arg-passing
entries derived, 4 dispatch across workers, 4 of those guarded, 5 guarded
in total. It could not have moved, and that was measured rather than
assumed: nothing in the tree transports an `ErasedFor` into a job.

Counter-proofs in both forms, diagnostics measured not predicted. Bare,
the refusal carries the marker's text in full, which is what says the
reason travelled. Wrapped, it reads `refuses that: no reason declared` —
`reasonOf` walking only pointers and optionals where `carriesMarkedIn`
enters everything, which is a debt this milestone named and left to the
bound's owner. The wrapped fixture's mark deliberately avoids that tail
and anchors on the type name: a fixture anchored on a defect's symptom
goes red the day it is repaired, and the repair then reads as a
regression.

The property is pinned in the suite as well, with a non-vacuity control,
because the harness runs on one CI cell and the suite on thirteen.
Counter-factual, the marker removed: exactly one test reddens.

Four corners 314/314, 2271/2290, zero assertions. Determinism on all
four, four invariants OK. Zero cost still ONE body of 8 instructions.

Also raised and NOT fixed: the plugin entry is called before the version
check, so a superseded-major plugin receives the current table. Bounded
by measurement - the table handed over is all-default stubs and one site
hands it - so the consequence is latent until a real table is handed
over. The remedy is a loading-protocol change; closing it hastily is what
produced a type born without its twin's guarantee.
`build-and-test (windows-2025, ReleaseSafe, f64)` at `565012c`, its f32
twin cancelled behind it, cleared on the first same-SHA re-run.

Signature present TWICE — two hangs in one run, the shape the tenth
occurrence had — sibling class absent, zero `error: '...' failed:`,
`2253/2285 tests passed (32 skipped)` against a declared floor of 2288
giving three tests lost across two hung steps, and 52.6 minutes against a
55-minute budget with conclusion `failure`, which excludes the
cold-cache class on both its discriminants.

The discriminant found at the twelfth paid immediately. The two `failed
command:` lines name two different executables in ONE build —
`fecde19354ea9ccbd9ecb5d20cdb00ac` and
`2fe9c3b586059afa4881744abc5715ef` — which is the within-run twin of the
within-SHA comparison the twelfth produced. Four distinct identities are
now recorded across three attempts and no two agree.

Where the tenth occurrence could only say "at least one of the two cost
no test at all" and left that asymmetry unexplained, the identities say
plainly that two unrelated steps hung, so three tests across two steps
needs no theory of a culprit.

20/20 checks pass at `565012c`.
`build-and-test (windows-2025, Debug, f32)` failed at `0aff532` and is
NOT the hang class: the hang signature is absent, zero tests are lost
(2288 collected, exactly the floor), and one assertion genuinely fires —
`error.Win32ThreadSafetyTimeout`, a 30-second wall-clock bound on three
threads doing 100 createWindow/destroyWindow pairs each. The job ran 9.65
minutes against the ~50 a hang costs. Cleared on the first same-SHA
re-run.

The discriminant that settles it is general to every flake class and
costs one command: a diff with no Zig in it. That commit's diff is two
Markdown files and seven lines, on top of a commit where this same cell
had passed — so no code changed between the green and the red, and the
commit cannot be the cause. The sibling Debug/f64 cell passing at the
same SHA corroborates and is weaker, Win32 windowing having nothing to do
with the physics scalar.

It must not be confused with the class M1.1.9 retired from
`crash_recovery.zig`. Those assertions ran after the blocking receive had
returned and guarded nothing — a duration asserted post hoc. This loop
bounds a wait and abandons it, which is a correctly shaped hang guard;
what fires is its budget on a runner the suite saturates. Removing it
removes a real guard and raising it weakens the bound it gives, so it is
recorded rather than touched.

Stated because it is not free: four of this session's CI runs went on
documentation-only commits recording flakes, and two of those runs
themselves flaked.
@guysenpai
guysenpai merged commit 2006ed0 into main Sep 13, 2026
20 checks passed
@guysenpai
guysenpai deleted the phase-1/core/ecs-access-enforcement branch September 13, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant