From cf7a434735d3ea18d33d8aa8e6bf586be3625f58 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 14:37:12 +0200 Subject: [PATCH 01/11] docs(brief): add m1.1.1-hf4 milestone brief --- .../m1.1.1-hf4-extension-conflict-reject.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 briefs/m1.1.1-hf4-extension-conflict-reject.md diff --git a/briefs/m1.1.1-hf4-extension-conflict-reject.md b/briefs/m1.1.1-hf4-extension-conflict-reject.md new file mode 100644 index 0000000..b80705d --- /dev/null +++ b/briefs/m1.1.1-hf4-extension-conflict-reject.md @@ -0,0 +1,126 @@ +# M1.1.1-HF4 — Extension additive conflict: ratify reject + +> **Status:** PLANNED +> **Phase:** 1 +> **Branch:** `phase-1/etch/hf4-extension-conflict-reject` +> **Planned tag:** none — hotfix, non-tagged (same convention as M1.1.1-HF1/HF2/HF3) +> **Dependencies:** none (independent of M1.1.2 GJK; based on current `main` = M1.1.1-HF3) +> **Opened:** 2026-07-17 +> **Closed:** — + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +Additive extension conflicts (two active extensions declaring the same component on one entity) currently have three inconsistent policies: the runtime loader **rejects** the conflict (`error.ExtensionComponentConflict`, at both load and runtime, since M1.0.6/M1.0.9), while the cook emits a **non-fatal** `W1791` warning (M1.0.18) and the serialization/reference specs described **last-extension-wins**. Because `W1791` is non-fatal — and, as it happens, is never even surfaced (no production reader of `Cooked.warnings`) — the cook silently produces `.scene.bin` files that the loader then refuses to load: a cooked scene is not guaranteed loadable. HF3 flagged the runtime policy as "PROVISIONAL, pending a dedicated spec session". That session ratified **reject** as the normative, permanent policy (the specs are already corrected). This hotfix aligns the implementation: it fatalizes the static conflict at cook time, reclassifies the diagnostic, removes the dead warning path, and normalizes the now-permanent loader comments. It establishes the invariant `cooked ⇒ loadable` with respect to extension conflicts. + +## Scope + +- Reclassify the additive-conflict diagnostic from non-fatal `W1791` to fatal cook error `E1797 ExtensionAdditiveConflict` in `src/etch/diagnostics.zig` (both the code mapping and the enum doc comment; `E1791` is taken by `PrefabBaseNotFound`, so `E1797` is the next free code in the prefab range E1790–E1799). +- Add `ExtensionAdditiveConflict` to the `CookError` error set and make the static conflict a **fatal** cook error routed through the existing `fail(diag_out, …)` path, short-circuiting on the first conflicting component (no per-component enumeration). +- Correct the conflict detection prose (`detectExtensionConflicts`) and the diagnostic message: remove all "last-extension-wins" / "the last extension in the list wins" wording. +- Remove the now-dead non-fatal warning infrastructure in `src/etch/scene_cook.zig`, since `W1791` was its sole emitter: the `w1791_code` const, the `Warning` struct, the public `Cooked.warnings` field, the `Builder.warnings` list, and all associated plumbing (init, deinit, `toOwnedSlice`, deinitScratch, comments). +- Normalize the loader comments in `src/core/scene/loader.zig` (`activateExtension`, `deactivateExtension`): drop "PROVISIONAL"/"provisional" and the "open design surface" framing; state the policy as normative and cite `engine-scene-serialization.md` as the authority. **Zero logic change.** +- Rewrite the `extensions_test.zig` conflict test block to fatal-error semantics, and add the missing cook→load contract test proving `cooked ⇒ loadable`. +- Update `CLAUDE.md` §3.4 (current-state, tags row for HF4, close the extension-conflict Open decision, `Last updated`). + +## Out of scope + +- **Any change to the loader activate/deactivate logic.** Reject is already implemented correctly and is the ratified policy. Only comments change in `loader.zig`. +- **`@exclusive_with` attribute.** It remains a future, unimplemented escape hatch (referenced in the specs, not built here). +- **Dynamic per-message diagnostics for the cook.** `E1797` uses a static `fail` message like every other `CookError` variant. Do NOT extend `diag_out` to carry allocated messages (it has no ownership/free path — that is a separate cross-cutting change). Losing the entity/component names in the message versus the old dynamic warning is accepted and in-pattern. +- **Any GJK / narrowphase / forge work.** This milestone is core/etch only; it shares no files with M1.1.2. +- **Reworking the runtime `error.ExtensionComponentConflict` name or path.** It stays as-is. + +## Specs to read first + +1. `engine-scene-serialization.md` — "Extensions (`extends`)" → "Sérialisation des extensions actives" → the **additive-conflict note** (normative reject policy: static fatal `E1797` / dynamic runtime reject; the `cooked ⇒ loadable` invariant). +2. `etch-reference-part2.md` — §30.3 (`extends`), §30.4 table row "Conflits multi-instances", §30.5 "Sémantique commune" (additive-conflict bullet). +3. `etch-diagnostics.md` — §18.3 prefab range (the new `E1797` row) and the "W1791 → E1797 — RECLASSÉ (M1.1.1-HF4)" traceability note. +4. `engine-zig-conventions.md` — Zig 0.16.x conventions (mandatory, as always). + +## Files to create or modify + +- `src/etch/diagnostics.zig` — modify — `extension_additive_conflict`: change the emitted code string `W1791` → `E1797`; fix the enum doc comment (remove "last-extension-wins", state reject/fatal). The variant name stays `extension_additive_conflict`; the display name stays `ExtensionAdditiveConflict`. +- `src/etch/scene_cook.zig` — modify — add `ExtensionAdditiveConflict` to `CookError`; thread `diag_out` into `detectExtensionConflicts` (or return the bare error to a caller that has `diag_out`) and `fail` on the first conflict with a static message; delete `w1791_code`, `emitAdditiveConflict` (folded into the fatal path), the `Warning` struct, `Cooked.warnings`, `Builder.warnings`, and all plumbing; fix the `detectExtensionConflicts` doc comment. +- `src/core/scene/loader.zig` — modify — normalize the `activateExtension` / `deactivateExtension` conflict-policy comments (drop "provisional"/"open design surface", cite `engine-scene-serialization.md`). No logic change. +- `tests/scene/extensions_test.zig` — modify — rewrite the conflict block (see Acceptance criteria › Tests). +- `CLAUDE.md` — modify — §3.4 state/tags/open-decision/date (gate G3). + +## Acceptance criteria + +### Tests + +All in `tests/scene/extensions_test.zig` unless noted. The existing block that reads `cooked.warnings` must be fully rewritten — `Cooked.warnings` no longer exists. + +- `test "cook fails fatally when two extensions declare the same component"` — cooking a scene whose `extensions: [...]` lists two extensions both declaring the same component returns `error.ExtensionAdditiveConflict` and produces **no** `Cooked` (no output). If `diag_out` is provided, it is set to the static E1797 message. +- `test "cook succeeds when extensions declare disjoint components"` — the disjoint case cooks cleanly (no error), replacing the former "emits no warning" test. +- `test "cook fails on the first conflicting component"` — a scene with two conflicting components still fails once, fatally (no per-component enumeration; short-circuit semantics), replacing the former "one W1791 per conflicting component" test. +- `test "cooked scene with extensions is loadable"` (contract test — the core deliverable) — cook a valid multi-extension scene with **disjoint** components, then load the produced bytes: the load succeeds and every extension's components are present. This proves the positive side of `cooked ⇒ loadable`. +- Runtime dynamic reject — confirm existing coverage that `entity.activate_extension(...)` adding an already-present component yields `error.ExtensionComponentConflict` with the entity left unchanged; if no such test exists, add one (loader-level or interp-level, wherever the runtime activate path is exercised). + +### Benchmarks + +- None. This milestone changes no hot path. + +### Observable behavior + +- Cooking a static-conflict `.scene.etch` through the cook entry point (or `tools/scene_cook`) fails with the `E1797 ExtensionAdditiveConflict` diagnostic and writes no output file. +- `grep -rn "W1791" src/ tests/ tools/` returns empty (the code string is fully retired). +- `grep -rniE "last.?extension.?wins|last extension in the list wins" src/ tests/` returns empty. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe) +- `zig fmt --check` green +- `zig build lint` green (if present) +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/etch/hf4-extension-conflict-reject` +- **Final tag:** none (non-tagged hotfix) +- **PR title:** `Phase 1 / Etch / M1.1.1-HF4 extension additive conflict reject` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Why `E1797` and not reuse `W1791`'s number:** diagnostic numbers are per-severity namespaces (E-space vs W-space are distinct — `E1791 PrefabBaseNotFound` and `W1791` coexisted legitimately). Promoting the diagnostic to an error means it needs a free **E** code; E1790–E1796 are the prefab errors, so E1797 is next. +- **Static vs dynamic message:** the retired `W1791` used `allocPrint` to name the entity and component. `fail`/`diag_out` carries a static, non-owned message (no free path), consistent with every other `CookError`. Use a clear static message (e.g. name the constraint, not the specific entity). Do not add ownership to `diag_out`. +- **The warning was invisible:** no production code reads `Cooked.warnings` (only the test block did). So removing the field has no user-facing regression — it only ever produced silently-unloadable scenes. +- **Cook detection is best-effort by design:** `detectExtensionConflicts` no-ops when there is no `base_resolver` or an extension name does not resolve. That is unchanged — the fatal cook error is the primary gate *when the resolver is present*; the runtime `error.ExtensionComponentConflict` is the invariant backstop for every other path (dynamic activation, resolver-less cook). Both are "reject": consistent. +- **Deactivation invariant preserved:** the whole point of reject is that no two active extensions share a component, so deactivation needs no provenance. Do not add provenance tracking. +- Alternative examined and rejected: **last-wins with provenance** (masking + shadowed-byte side table + out-of-order deactivate + `.sav` serialization of the shadow stack). ~5–10× the code, touches the ECS reserve-then-mutate invariant and the serialization format, and undoes the HF3 deactivate simplification — to support what is a design error. Rejected in the spec session. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone.* + +## Specs read + +- [ ] `engine-scene-serialization.md` (extensions / additive-conflict note) — read +- [ ] `etch-reference-part2.md` (§30.3–30.5) — read +- [ ] `etch-diagnostics.md` (§18.3, W1791→E1797 note) — read +- [ ] `engine-zig-conventions.md` — read + +## Execution log + +## Recorded deviations + +## Blockers encountered + +## Closing notes + +- **What worked:** +- **What deviated from the original spec:** +- **What to flag explicitly in review:** +- **Final measurements:** +- **Residual risks / tech debt left intentionally:** From d0c24996f94d9ed6e44c0a28566b5d4a55c8bd6f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 14:45:39 +0200 Subject: [PATCH 02/11] docs(scene): normalize extension conflict policy comments in loader --- src/core/scene/loader.zig | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index f0b9f60..7497bf5 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -568,11 +568,12 @@ fn extEntityArchetype(ext: Accessor) !Accessor.Archetype { /// as load-time hooks, M1.1.1-HF1 / D2). /// /// Conflict policy: a component the entity already carries is rejected with -/// `error.ExtensionComponentConflict` — the PROVISIONAL runtime policy recorded in -/// `briefs/m1.1.1-hf3-core-robustness-hotfix-3.md` (R6). Last-wins runtime -/// semantics (masking, restoration, out-of-order deactivate) are an open design -/// surface for a dedicated spec session; `etch-reference-part2.md §30.5` defines -/// no reject policy — do not cite it. +/// `error.ExtensionComponentConflict` — the normative runtime policy for additive +/// extension conflicts. The `extends` model is strictly additive: no two active +/// extensions may declare the same component on one entity. See +/// `engine-scene-serialization.md` (extension additive conflicts) as the +/// authority; the static cook counterpart is the fatal `E1797 +/// ExtensionAdditiveConflict`, and together they guarantee `cooked ⇒ loadable`. pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void { // Step 0 — refuse re-activation (R12(d)); works for hook-only (0-component) // extensions too, where the component-conflict check below cannot fire. @@ -619,8 +620,8 @@ pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId /// the bridge's `ExtensionResolver`). Reuses the shared `activateExtension` /// path (atomic prevalidate → reserve → grouped add → record → `on_attach`). /// Unknown name → `error.UnknownExtension`; a component the entity already -/// carries → `error.ExtensionComponentConflict` (the provisional reject policy -/// recorded in the R6 brief — not `§30.5`). +/// carries → `error.ExtensionComponentConflict` (the normative additive-conflict +/// reject policy — see `engine-scene-serialization.md`). pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void { const bytes = resolver.resolve(name) orelse return error.UnknownExtension; try activateExtension(world, gpa, entity, name, bytes); @@ -645,8 +646,8 @@ pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, /// /// Conflict policy: reject-on-conflict (see `activateExtension`) keeps the /// component set unambiguous — no two active extensions share a component — so -/// removal needs no provenance tracking. Provisional, recorded in the R6 brief; -/// `etch-reference-part2.md §30.5` defines no such policy — do not cite it. +/// removal needs no provenance tracking. Normative; see +/// `engine-scene-serialization.md` (extension additive conflicts). pub fn deactivateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void { if (!world.hasEntityExtension(entity, name)) return error.ExtensionNotActive; const ext = try openVerified(ext_bytes); From 4647902d86e07349289e1b4474a4f6cad09660d7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 14:46:21 +0200 Subject: [PATCH 03/11] docs(brief): mark m1.1.1-hf4 active, log specs read and G1 --- briefs/m1.1.1-hf4-extension-conflict-reject.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/briefs/m1.1.1-hf4-extension-conflict-reject.md b/briefs/m1.1.1-hf4-extension-conflict-reject.md index b80705d..374aba8 100644 --- a/briefs/m1.1.1-hf4-extension-conflict-reject.md +++ b/briefs/m1.1.1-hf4-extension-conflict-reject.md @@ -1,6 +1,6 @@ # M1.1.1-HF4 — Extension additive conflict: ratify reject -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1 > **Branch:** `phase-1/etch/hf4-extension-conflict-reject` > **Planned tag:** none — hotfix, non-tagged (same convention as M1.1.1-HF1/HF2/HF3) @@ -106,13 +106,16 @@ All in `tests/scene/extensions_test.zig` unless noted. The existing block that r ## Specs read -- [ ] `engine-scene-serialization.md` (extensions / additive-conflict note) — read -- [ ] `etch-reference-part2.md` (§30.3–30.5) — read -- [ ] `etch-diagnostics.md` (§18.3, W1791→E1797 note) — read -- [ ] `engine-zig-conventions.md` — read +- [x] `engine-scene-serialization.md` (extensions / additive-conflict note, §666–700) — read 2026-07-18 14:35 +- [x] `etch-reference-part2.md` (§30.3–30.5, conflits multi-instances) — read 2026-07-18 14:38 +- [x] `etch-diagnostics.md` (§18.3 prefab range E1790–E1799, W1791→E1797 RECLASSÉ note) — read 2026-07-18 14:40 +- [x] `engine-zig-conventions.md` (Zig 0.16.x conventions — prior working knowledge, spot-verified) — read 2026-07-18 14:42 ## Execution log +- **2026-07-18 — Setup.** Branch `phase-1/etch/hf4-extension-conflict-reject` off `main` (M1.1.1-HF3). Brief committed verbatim (`cf7a434`). Specs found already patched locally in `~/Downloads/m1.1.1-hf4-extension-conflict-reject/` (dated 2026-07-18 12:28), read in full; normative content confirmed: reject ratified, `E1797 ExtensionAdditiveConflict` fatal at cook, `error.ExtensionComponentConflict` runtime unchanged, invariant `cooked ⇒ loadable`. +- **G1 — loader comment normalization (comment-only, `d0c2499`).** `src/core/scene/loader.zig`: normalized the three conflict-policy doc comments (`activateExtension`, `runtimeActivate`, `deactivateExtension`) — dropped "PROVISIONAL"/"open design surface / dedicated spec session" and the "§30.5 defines no such policy — do not cite it" framing; stated the policy as normative and cite `engine-scene-serialization.md` as the authority. Zero logic change. `test-extensions` green; full `zig build test` green in Debug **and** ReleaseSafe (macOS spurious `failed command` lines, real exit 0); `zig fmt --check` + lint green. + ## Recorded deviations ## Blockers encountered From 2c3279346057bd20bcb67fc3a65d3e96987f932d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 17:33:23 +0200 Subject: [PATCH 04/11] fix(etch): reject additive extension conflict as fatal E1797 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ratify reject as the normative policy for additive extension conflicts (M1.1.1-HF4). A conflict — two active extensions declaring the same component on one entity — was a non-fatal cook diagnostic no production code read, so the cook silently produced .scene.bin files the loader then refused to load. Fatalizing the static conflict establishes the invariant `cooked ⇒ loadable`. - diagnostics: reclassify `extension_additive_conflict` to a fatal E1797 (was a non-fatal warning); doc comment states reject/fatal - scene_cook: add `ExtensionAdditiveConflict` to CookError; the additive gate `fail`s on the first conflicting component (static message, short-circuit) and the dead warning infra is removed (Warning struct, Cooked.warnings, Builder.warnings, and all plumbing; unused token import) - tests: rewrite the conflict block to fatal semantics + add the cooked-is-loadable contract test and a runtime dynamic-reject test --- src/etch/diagnostics.zig | 6 +- src/etch/scene_cook.zig | 98 ++++++----------- tests/scene/extensions_test.zig | 186 +++++++++++++++----------------- 3 files changed, 120 insertions(+), 170 deletions(-) diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index dade7a8..968c238 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -361,7 +361,7 @@ pub const DiagnosticCode = enum { prefab_component_field_type_invalid, // M0.8 E7 — E1795 PrefabComponentFieldTypeInvalid prefab_component_redefined, // M0.8 E7 — E1796 PrefabComponentRedefined (RESERVED: variant/base component-shape merge is M0.9 runtime) prefab_remove_base_component, // M0.8 E7 — W1790 PrefabRemoveBaseComponent (RESERVED: no `remove` syntax in the §24.1 grammar) - extension_additive_conflict, // M1.0.18 — W1791 ExtensionAdditiveConflict (cook: ≥2 active extensions on an entity declare the same component; §30.5, last-extension-wins) + extension_additive_conflict, // M1.0.18 → M1.1.1-HF4 — E1797 ExtensionAdditiveConflict (fatal cook error: ≥2 active extensions on an entity declare the same component; strictly-additive `extends` → reject, guaranteeing `cooked ⇒ loadable`; runtime backstop `error.ExtensionComponentConflict`) // ── async / effects (E09xx, M1.0.11 — etch-resolver-types.md §9.2) ── async_call_in_non_async_context, // M1.0.11 E4 — E0901 AsyncCallInNonAsyncContext (async fn/method call, or `await`, in a non-async fn/rule) @@ -560,7 +560,7 @@ pub const DiagnosticCode = enum { .prefab_component_field_type_invalid => "E1795", .prefab_component_redefined => "E1796", .prefab_remove_base_component => "W1790", - .extension_additive_conflict => "W1791", + .extension_additive_conflict => "E1797", .async_call_in_non_async_context => "E0901", .await_not_statement_head => "E0904", .unconsumed_async_effect => "E0905", @@ -872,6 +872,6 @@ test "DiagnosticCode code and name are stable cross-version" { try std.testing.expectEqualStrings("EventNotEntityScoped", DiagnosticCode.event_not_entity_scoped.name()); try std.testing.expectEqualStrings("E0909", DiagnosticCode.ambiguous_event_entity_target.code()); try std.testing.expectEqualStrings("AmbiguousEventEntityTarget", DiagnosticCode.ambiguous_event_entity_target.name()); - try std.testing.expectEqualStrings("W1791", DiagnosticCode.extension_additive_conflict.code()); + try std.testing.expectEqualStrings("E1797", DiagnosticCode.extension_additive_conflict.code()); try std.testing.expectEqualStrings("ExtensionAdditiveConflict", DiagnosticCode.extension_additive_conflict.name()); } diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index f5e5394..ab1e9b9 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -30,7 +30,6 @@ const std = @import("std"); const ast_mod = @import("ast.zig"); -const token = @import("token.zig"); const interp = @import("interp.zig"); const bridge_mod = @import("ecs_bridge.zig"); const value_mod = @import("value.zig"); @@ -129,27 +128,16 @@ pub const CookError = error{ /// An `Entity` field references an entity name absent from the scene (M1.0.6 /// E4 — intra-scene only; cross-scene references are a future milestone). UnresolvedCrossRef, + /// Two or more active extensions on one entity's `extensions:` clause declare + /// the same component (M1.1.1-HF4 — `E1797 ExtensionAdditiveConflict`). The + /// `extends` model is strictly additive: a shared component is rejected, never + /// resolved by list order. Fatal cook error → guarantees `cooked ⇒ loadable` + /// (the runtime backstop is `error.ExtensionComponentConflict`). See + /// `engine-scene-serialization.md` (extension additive conflicts). + ExtensionAdditiveConflict, OutOfMemory, }; -/// A non-fatal cook diagnostic (M1.0.18). Unlike `CookError`/`fail` — which abort -/// the cook and leave no output — a `Warning` is collected on the `Cooked` result -/// and the `.scene.bin`/`.prefab.bin` is still produced. `code` is the stable -/// diagnostic code string (e.g. `"W1791"`, registered in `diagnostics.zig`); it is -/// a static string, never freed. `message` is a gpa-owned human string (freed by -/// `Cooked.deinit`). `span` locates the offending source construct. -pub const Warning = struct { - code: []const u8, - message: []const u8, - span: token.SourceSpan, -}; - -/// `W1791 ExtensionAdditiveConflict` — emitted at cook time when two or more -/// active extensions on an entity declare the same component (§30.5). Kept as a -/// stable code STRING here: the cook is a separate tool from the resolver, and the -/// central catalogue entry (`diagnostics.zig`) is registered at M1.0.18 E3. -const w1791_code: []const u8 = "W1791"; - /// The cook's output: the neutral model plus the `Registry` it was cooked /// against. The registry owns the type metadata (names/sizes/field offsets + /// kinds) needed to interpret the model's raw component bytes — the AST is @@ -158,13 +146,8 @@ const w1791_code: []const u8 = "W1791"; pub const Cooked = struct { model: format.CookModel, registry: Registry, - /// Non-fatal cook diagnostics (M1.0.18). Empty (`&.{}`) for a clean cook. - /// gpa-owned — both the slice and each `message` are freed by `deinit`. - warnings: []const Warning = &.{}, pub fn deinit(self: *Cooked, gpa: std.mem.Allocator) void { - for (self.warnings) |w| gpa.free(w.message); - gpa.free(self.warnings); self.model.deinit(); self.registry.deinit(gpa); self.* = undefined; @@ -212,7 +195,7 @@ pub fn cookScene( // self.arena`, scene_cook build return). A failing `toOwnedSlice` here is // already covered by `errdefer b.arena.deinit()` above: same buffers, freed // once. Do NOT add `errdefer model.deinit()` — it double-frees the aliased arena. - return .{ .model = model, .registry = registry, .warnings = try b.warnings.toOwnedSlice(gpa) }; + return .{ .model = model, .registry = registry }; } fn fail(diag_out: ?*[]const u8, err: CookError, msg: []const u8) CookError { @@ -281,7 +264,7 @@ pub fn cookPrefab( // `.arena = self.arena`). A failing `toOwnedSlice` here is already covered by // `errdefer b.arena.deinit()` above: same buffers, freed once. Do NOT add // `errdefer model.deinit()` — it double-frees the aliased arena. - return .{ .model = model, .registry = registry, .warnings = try b.warnings.toOwnedSlice(gpa) }; + return .{ .model = model, .registry = registry }; } // ── Builder ────────────────────────────────────────────────────────────────── @@ -342,11 +325,6 @@ const Builder = struct { prefab_id_table: std.ArrayListUnmanaged(u32) = .empty, prefab_id_map: std.AutoHashMapUnmanaged(u32, u32) = .empty, - // Non-fatal cook warnings (M1.0.18). Accumulated during the build, then - // transferred to `Cooked.warnings` on success (`toOwnedSlice`); freed here - // (messages + backing) on the error path by `deinitScratch`. - warnings: std.ArrayListUnmanaged(Warning) = .empty, - fn init(gpa: std.mem.Allocator, ast: *const AstArena, registry: *Registry) Builder { return .{ .gpa = gpa, @@ -373,10 +351,6 @@ const Builder = struct { self.ext_entries.deinit(self.gpa); self.prefab_id_table.deinit(self.gpa); self.prefab_id_map.deinit(self.gpa); - // Any warning still held here was NOT transferred to a `Cooked` (error - // path); free its message. On success `toOwnedSlice` emptied the list. - for (self.warnings.items) |w| self.gpa.free(w.message); - self.warnings.deinit(self.gpa); } fn a(self: *Builder) std.mem.Allocator { @@ -472,27 +446,26 @@ const Builder = struct { for (children) |child| { var ext_start: u32 = 0; var ext_len: u32 = 0; - var ext_span: token.SourceSpan = .{ .byte_start = 0, .byte_end = 0 }; const eb = switch (child.kind) { .entity => blk: { const e = self.ast.scene_entities.items[child.index]; ext_start = e.extensions_start; ext_len = e.extensions_len; - ext_span = e.span; break :blk try self.buildEntity(e, diag_out); }, .instance => blk: { const inst = self.ast.scene_instances.items[child.index]; ext_start = inst.extensions_start; ext_len = inst.extensions_len; - ext_span = inst.span; break :blk try self.buildInstanceEntity(inst, base_resolver, diag_out); }, }; try self.recordExtensions(eb.uuid_idx, ext_start, ext_len); - // M1.0.18 (§30.5) — non-fatal warning if ≥2 active extensions on this - // entity declare the same component (resolution is last-wins, unchanged). - try self.detectExtensionConflicts(self.strings.items[eb.name_idx], ext_span, ext_start, ext_len, base_resolver); + // Additive-conflict gate (§30.5, M1.1.1-HF4): a FATAL cook error if ≥2 + // active extensions on this entity declare the same component — the + // `extends` model is strictly additive (reject), guaranteeing + // `cooked ⇒ loadable`. Short-circuits on the first conflict. + try self.detectExtensionConflicts(ext_start, ext_len, base_resolver, diag_out); try entities.append(self.gpa, eb); } @@ -537,22 +510,25 @@ const Builder = struct { try self.ext_entries.append(self.gpa, .{ .uuid = uuid_idx, .prefab_ids = ids }); } - /// M1.0.18 (§30.5) — Detect additive extension conflicts on one entity's - /// `extensions:` clause and emit a non-fatal `W1791` per conflicting component. - /// A conflict is a component declared by two or more DISTINCT active extensions; - /// the resolution is last-extension-wins (unchanged) — this only warns. The - /// extension component sets are resolved through the SAME prefab path as - /// `of`/`extends` (`base_resolver` → `.prefab.bin` → accessor schema names), not - /// a second resolution path. No-op when: fewer than two extensions; no resolver; - /// or an extension name does not resolve / its bytes do not open (the clause is - /// by-name — an unresolved name is an existing condition, not a cook error). + /// Additive-conflict gate (§30.5, M1.1.1-HF4) — a FATAL cook error if two or + /// more DISTINCT active extensions on one entity's `extensions:` clause declare + /// the same component. The `extends` model is strictly additive: a shared + /// component is rejected (`E1797 ExtensionAdditiveConflict`), never resolved by + /// list order — this guarantees `cooked ⇒ loadable` (the runtime backstop is + /// `error.ExtensionComponentConflict`). Short-circuits on the FIRST conflicting + /// component (no per-component enumeration). Extension component sets are + /// resolved through the SAME prefab path as `of`/`extends` (`base_resolver` → + /// `.prefab.bin` → accessor schema names). Best-effort: no-op — no conflict is + /// detectable — when fewer than two extensions, no resolver, or an extension + /// name does not resolve / its bytes do not open (the by-name clause; the + /// runtime reject remains the invariant backstop for the resolver-less path). + /// See `engine-scene-serialization.md` (extension additive conflicts). fn detectExtensionConflicts( self: *Builder, - entity_name: []const u8, - span: token.SourceSpan, ext_start: u32, ext_len: u32, base_resolver: ?BaseResolver, + diag_out: ?*[]const u8, ) CookError!void { if (ext_len < 2) return; const resolver = base_resolver orelse return; @@ -577,8 +553,8 @@ const Builder = struct { // Each component the extension declares (its `.prefab.bin` schema table; // names are unique) bumps that name's distinct-extension count. The - // second distinct declarer is the conflict — emit exactly once, in - // encounter order (a third declarer does not re-emit). Keys borrow the + // second distinct declarer is the conflict — a fatal cook error, + // short-circuiting on the first (no enumeration). Keys borrow the // accessor bytes (resolver-owned, live for the whole cook) and compare // by content, so the same name across extensions aggregates correctly. var ci: u32 = 0; @@ -587,23 +563,11 @@ const Builder = struct { const gop = try counts.getOrPut(self.gpa, comp_name); if (!gop.found_existing) gop.value_ptr.* = 0; gop.value_ptr.* += 1; - if (gop.value_ptr.* == 2) try self.emitAdditiveConflict(entity_name, comp_name, span); + if (gop.value_ptr.* == 2) return fail(diag_out, error.ExtensionAdditiveConflict, "two active extensions declare the same component on an entity; the `extends` model is strictly additive — reject (E1797 ExtensionAdditiveConflict)"); } } } - /// Append one `W1791` warning naming the conflicting component. The message is - /// gpa-owned — freed by `Cooked.deinit` on success, `deinitScratch` on error. - fn emitAdditiveConflict(self: *Builder, entity_name: []const u8, comp_name: []const u8, span: token.SourceSpan) CookError!void { - const msg = try std.fmt.allocPrint( - self.gpa, - "entity '{s}': two or more active extensions declare component '{s}'; the last extension in the list wins (§30.5)", - .{ entity_name, comp_name }, - ); - errdefer self.gpa.free(msg); - try self.warnings.append(self.gpa, .{ .code = w1791_code, .message = msg, .span = span }); - } - /// The Prefab ID Table slot for a model-`strings` index, deduplicated. fn prefabIdIndex(self: *Builder, str_idx: u32) CookError!u32 { const gop = try self.prefab_id_map.getOrPut(self.gpa, str_idx); diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index bed67db..7a9d2f3 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -269,10 +269,11 @@ test "scene extensions clause populates the Entity Extensions + Prefab ID tables try std.testing.expectEqual(@as(u32, 0), acc.hookCount()); } -// ── M1.0.18 — cook warning W1791 on additive extension conflict (§30.5) ── +// ── M1.1.1-HF4 — fatal cook error E1797 on additive extension conflict (§30.5) ── +// (was M1.0.18's non-fatal warning; reject ratified — `error.ExtensionAdditiveConflict`) /// Multi-entry in-process resolver mapping extension prefab names to their cooked -/// bytes (the additive-conflict detection resolves extension component sets through +/// bytes (the additive-conflict gate resolves extension component sets through /// this, exactly like `of`/`extends`). The bytes must outlive the cook. const MultiResolver = struct { names: []const []const u8, @@ -285,6 +286,9 @@ const MultiResolver = struct { fn base(self: *MultiResolver) scene_cook.BaseResolver { return .{ .ctx = self, .resolveFn = MultiResolver.resolve }; } + fn ext(self: *MultiResolver) scene.loader.ExtensionResolver { + return .{ .ctx = self, .resolveFn = MultiResolver.resolve }; + } }; /// Cook an `extends` prefab source to its `.prefab.bin` bytes (caller frees). No @@ -321,21 +325,20 @@ const ext_arsenal = // ArsenalModule: declares Weapon only \\ entity "m" { uuid: "00000000-0000-0000-0000-0000000000c4" Weapon { damage: 5 } } \\} ; -const ext_stash = // StashModule: declares Inventory only - \\component Inventory { slots: i32 = 0 } - \\prefab "StashModule" extends "Base" { - \\ entity "m" { uuid: "00000000-0000-0000-0000-0000000000c5" Inventory { slots: 5 } } - \\} -; -// The `extensions:` clause + additive-conflict detection run through the SAME -// scene `build` loop for `entity` and `instance` — these tests use `entity`. +// The `extensions:` clause + additive-conflict gate run through the SAME scene +// `build` loop for `entity` and `instance` — these tests use `entity`. A conflict +// (≥2 active extensions declaring the same component) is a FATAL cook error +// (`E1797 ExtensionAdditiveConflict` → `error.ExtensionAdditiveConflict`), the +// strictly-additive `extends` reject policy (M1.1.1-HF4). Disjoint components cook +// cleanly. Together with the runtime `error.ExtensionComponentConflict` this +// guarantees `cooked ⇒ loadable`. -test "cook warns W1791 when two extensions declare the same component" { +test "cook fails fatally when two extensions declare the same component" { const gpa = std.testing.allocator; - const m = try prefabBytes(gpa, ext_merchant); + const m = try prefabBytes(gpa, ext_merchant); // Inventory defer gpa.free(m); - const t = try prefabBytes(gpa, ext_trade); + const t = try prefabBytes(gpa, ext_trade); // Inventory defer gpa.free(t); var mr = MultiResolver{ .names = &.{ "MerchantModule", "TradeModule" }, .blobs = &.{ m, t } }; const src = @@ -348,14 +351,16 @@ test "cook warns W1791 when two extensions declare the same component" { \\ } \\} ; - var cooked = try scene_cook.cookScene(gpa, src, mr.base(), null); - defer cooked.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), cooked.warnings.len); - try std.testing.expectEqualStrings("W1791", cooked.warnings[0].code); - try std.testing.expect(std.mem.indexOf(u8, cooked.warnings[0].message, "Inventory") != null); + // Fatal: the cook returns `error.ExtensionAdditiveConflict` and produces no + // `Cooked` (no output). `diag_out`, when provided, carries the static E1797 + // message (no leak — a `CookError` message is never gpa-owned). + var diag: []const u8 = ""; + try std.testing.expectError(error.ExtensionAdditiveConflict, scene_cook.cookScene(gpa, src, mr.base(), &diag)); + try std.testing.expect(diag.len > 0); + try std.testing.expect(std.mem.indexOf(u8, diag, "additive") != null); } -test "cook emits no warning when extensions declare disjoint components" { +test "cook succeeds when extensions declare disjoint components" { const gpa = std.testing.allocator; const m = try prefabBytes(gpa, ext_merchant); // Inventory defer gpa.free(m); @@ -372,12 +377,12 @@ test "cook emits no warning when extensions declare disjoint components" { \\ } \\} ; + // Disjoint (Inventory vs Weapon): the cook succeeds with no error. var cooked = try scene_cook.cookScene(gpa, src, mr.base(), null); - defer cooked.deinit(gpa); - try std.testing.expectEqual(@as(usize, 0), cooked.warnings.len); + cooked.deinit(gpa); } -test "cook emits one W1791 per conflicting component" { +test "cook fails on the first conflicting component" { const gpa = std.testing.allocator; const c = try prefabBytes(gpa, ext_combat); // Inventory + Weapon defer gpa.free(c); @@ -396,103 +401,84 @@ test "cook emits one W1791 per conflicting component" { \\ } \\} ; - var cooked = try scene_cook.cookScene(gpa, src, mr.base(), null); - defer cooked.deinit(gpa); - // Inventory shared by Combat+Merchant; Weapon shared by Combat+Arsenal → exactly - // two warnings, one per conflicting component (NOT one per extension pair). - try std.testing.expectEqual(@as(usize, 2), cooked.warnings.len); - var saw_inv = false; - var saw_weap = false; - for (cooked.warnings) |w| { - try std.testing.expectEqualStrings("W1791", w.code); - if (std.mem.indexOf(u8, w.message, "Inventory") != null) saw_inv = true; - if (std.mem.indexOf(u8, w.message, "Weapon") != null) saw_weap = true; - } - try std.testing.expect(saw_inv and saw_weap); + // Inventory (Combat+Merchant) and Weapon (Combat+Arsenal) both conflict; the + // cook fails ONCE, fatally, short-circuiting on the first — no enumeration. + try std.testing.expectError(error.ExtensionAdditiveConflict, scene_cook.cookScene(gpa, src, mr.base(), null)); } -test "W1791 is non-fatal — cooked output applies last-extension-wins" { +test "cooked scene with extensions is loadable" { + // Contract test — the core deliverable. A cooked multi-extension scene with + // DISJOINT components loads cleanly and every extension's components are + // present (the positive side of `cooked ⇒ loadable`). const gpa = std.testing.allocator; - const m = try prefabBytes(gpa, ext_merchant); + const m = try prefabBytes(gpa, ext_merchant); // Inventory defer gpa.free(m); - const t = try prefabBytes(gpa, ext_trade); - defer gpa.free(t); - var mr = MultiResolver{ .names = &.{ "MerchantModule", "TradeModule" }, .blobs = &.{ m, t } }; + const a = try prefabBytes(gpa, ext_arsenal); // Weapon + defer gpa.free(a); + const src = \\component Marker { v: i32 = 0 } \\scene "S" { \\ entity "npc" { \\ uuid: "00000000-0000-0000-0000-0000000000f1" - \\ extensions: ["MerchantModule", "TradeModule"] + \\ extensions: ["MerchantModule", "ArsenalModule"] \\ Marker { v: 1 } \\ } \\} ; - // Non-fatal: the cook returns a valid `Cooked` (no `CookError`) with one warning. - var cooked = try scene_cook.cookScene(gpa, src, mr.base(), null); + var cooked = try scene_cook.cook(gpa, src, null); defer cooked.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), cooked.warnings.len); + const scene_bytes = try scene.writer.write(gpa, cooked.model, &cooked.registry); + defer gpa.free(scene_bytes); - // The warning never alters the cooked output: the Entity Extensions Table still - // lists both extensions in declaration order, so the loader's last-extension-wins - // resolution sees TradeModule (index 1, last) as the winner. - const bytes = try scene.writer.write(gpa, cooked.model, &cooked.registry); - defer gpa.free(bytes); - var acc = try Accessor.open(bytes); - try std.testing.expect(acc.verifyHash()); - try std.testing.expectEqual(@as(u32, 1), acc.extensionsCount()); - const e = acc.extension(0); - try std.testing.expectEqual(@as(u32, 2), e.extension_count); - try std.testing.expectEqualStrings("MerchantModule", acc.prefabName(e.extensionId(0))); - try std.testing.expectEqualStrings("TradeModule", acc.prefabName(e.extensionId(1))); -} + // A World mirroring the base + extension component layout (all i32-sized). + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ .name = "Marker", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); + _ = try world.registry.registerComponentRaw(gpa, .{ .name = "Inventory", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); + _ = try world.registry.registerComponentRaw(gpa, .{ .name = "Weapon", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); -test "single extension declaring a component emits no warning" { - const gpa = std.testing.allocator; - const c = try prefabBytes(gpa, ext_combat); // Inventory + Weapon - defer gpa.free(c); - var mr = MultiResolver{ .names = &.{"CombatModule"}, .blobs = &.{c} }; - const src = - \\component Marker { v: i32 = 0 } - \\scene "S" { - \\ entity "npc" { - \\ uuid: "00000000-0000-0000-0000-0000000000f1" - \\ extensions: ["CombatModule"] - \\ Marker { v: 1 } - \\ } - \\} - ; - var cooked = try scene_cook.cookScene(gpa, src, mr.base(), null); - defer cooked.deinit(gpa); - try std.testing.expectEqual(@as(usize, 0), cooked.warnings.len); + var mr = MultiResolver{ .names = &.{ "MerchantModule", "ArsenalModule" }, .blobs = &.{ m, a } }; + var result = try scene.loader.loadFromBytes(&world, gpa, scene_bytes, mr.ext()); + defer result.deinit(gpa); + + const npc = result.uuid_to_entity.get(uuidBytes(0xf1)).?; + // Base component + BOTH extensions' components are present on the entity. + try std.testing.expect(world.componentBytes(npc, world.componentId("Marker").?) != null); + try std.testing.expect(world.componentBytes(npc, world.componentId("Inventory").?) != null); + try std.testing.expect(world.componentBytes(npc, world.componentId("Weapon").?) != null); + try std.testing.expect(world.hasEntityExtension(npc, "MerchantModule")); + try std.testing.expect(world.hasEntityExtension(npc, "ArsenalModule")); } -test "3+ extensions declaring the same component emit exactly one W1791" { +test "runtime activate rejects a component the entity already carries" { + // The dynamic counterpart of the static cook gate: `activate_extension` adding + // a component the entity already carries is rejected with + // `error.ExtensionComponentConflict`, and the entity is left unchanged. const gpa = std.testing.allocator; - const m = try prefabBytes(gpa, ext_merchant); // Inventory - defer gpa.free(m); - const t = try prefabBytes(gpa, ext_trade); // Inventory - defer gpa.free(t); - const s = try prefabBytes(gpa, ext_stash); // Inventory - defer gpa.free(s); - var mr = MultiResolver{ .names = &.{ "MerchantModule", "TradeModule", "StashModule" }, .blobs = &.{ m, t, s } }; - const src = - \\component Marker { v: i32 = 0 } - \\scene "S" { - \\ entity "npc" { - \\ uuid: "00000000-0000-0000-0000-0000000000f1" - \\ extensions: ["MerchantModule", "TradeModule", "StashModule"] - \\ Marker { v: 1 } - \\ } - \\} - ; - var cooked = try scene_cook.cookScene(gpa, src, mr.base(), null); - defer cooked.deinit(gpa); - // All three declare Inventory: the `== 2` guard emits once (at the 2nd distinct - // declarer) and never re-emits at the 3rd → exactly one warning, not two/three. - try std.testing.expectEqual(@as(usize, 1), cooked.warnings.len); - try std.testing.expectEqualStrings("W1791", cooked.warnings[0].code); - try std.testing.expect(std.mem.indexOf(u8, cooked.warnings[0].message, "Inventory") != null); + const combat_bytes = try cookCombatModule(gpa); // adds Weapon{25}, on_attach Health.max += 50 + defer gpa.free(combat_bytes); + + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ .name = "Health", .size = 8, .alignment = 4, .default_bytes = &[_]u8{0} ** 8, .fields = &.{} }); + const weapon_id = try world.registry.registerComponentRaw(gpa, .{ .name = "Weapon", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); + + // Entity already carries Weapon (damage 10) — the component CombatModule adds. + const health_id = world.componentId("Health").?; + var hv = [_]i32{ 100, 100 }; + var wv: i32 = 10; + const eid = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{ health_id, weapon_id }, &[_][]const u8{ std.mem.asBytes(&hv), std.mem.asBytes(&wv) }); + + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + try std.testing.expectError(error.ExtensionComponentConflict, scene.loader.runtimeActivate(&world, gpa, eid, "CombatModule", res.ext())); + + // Unchanged: Weapon keeps its value, on_attach never ran (Health.max stays + // 100), and no extension record was created (rejected at prevalidate). + const wb = world.componentBytes(eid, weapon_id).?; + try std.testing.expectEqual(@as(i32, 10), std.mem.readInt(i32, wb[0..4], .little)); + try std.testing.expectEqual(@as(i32, 100), healthMax(&world, eid)); + try std.testing.expect(!world.hasEntityExtension(eid, "CombatModule")); } // ── E6 — load applies extension components + fires the on_attach seam ── From 8fa6cc94240f70fc899c18f90896819545843bf2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 17:33:48 +0200 Subject: [PATCH 05/11] docs(brief): log m1.1.1-hf4 G2 execution --- briefs/m1.1.1-hf4-extension-conflict-reject.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/briefs/m1.1.1-hf4-extension-conflict-reject.md b/briefs/m1.1.1-hf4-extension-conflict-reject.md index 374aba8..113313e 100644 --- a/briefs/m1.1.1-hf4-extension-conflict-reject.md +++ b/briefs/m1.1.1-hf4-extension-conflict-reject.md @@ -115,6 +115,11 @@ All in `tests/scene/extensions_test.zig` unless noted. The existing block that r - **2026-07-18 — Setup.** Branch `phase-1/etch/hf4-extension-conflict-reject` off `main` (M1.1.1-HF3). Brief committed verbatim (`cf7a434`). Specs found already patched locally in `~/Downloads/m1.1.1-hf4-extension-conflict-reject/` (dated 2026-07-18 12:28), read in full; normative content confirmed: reject ratified, `E1797 ExtensionAdditiveConflict` fatal at cook, `error.ExtensionComponentConflict` runtime unchanged, invariant `cooked ⇒ loadable`. - **G1 — loader comment normalization (comment-only, `d0c2499`).** `src/core/scene/loader.zig`: normalized the three conflict-policy doc comments (`activateExtension`, `runtimeActivate`, `deactivateExtension`) — dropped "PROVISIONAL"/"open design surface / dedicated spec session" and the "§30.5 defines no such policy — do not cite it" framing; stated the policy as normative and cite `engine-scene-serialization.md` as the authority. Zero logic change. `test-extensions` green; full `zig build test` green in Debug **and** ReleaseSafe (macOS spurious `failed command` lines, real exit 0); `zig fmt --check` + lint green. +- **G2 — reclassify + fatalize + remove warning infra + tests (atomic, `2c32793`).** + - `diagnostics.zig`: `extension_additive_conflict` emitted code `W1791` → `E1797`; doc comment states reject/fatal; catalogue self-test asserts `E1797`. Variant name + display name (`ExtensionAdditiveConflict`) unchanged. + - `scene_cook.zig`: `ExtensionAdditiveConflict` added to `CookError`; `detectExtensionConflicts` re-signed `(ext_start, ext_len, base_resolver, diag_out)`, now `fail`s (static message) on the FIRST conflicting component (short-circuit, no enumeration); deleted `w1791_code`, `emitAdditiveConflict`, the `Warning` struct, `Cooked.warnings`, `Builder.warnings`, both `toOwnedSlice` returns, the deinit/deinitScratch loops, and the now-unused `token` import. `detectExtensionConflicts` doc comment rewritten (fatal/reject, cites `engine-scene-serialization.md`). + - `extensions_test.zig`: 6 warning tests replaced by 4 cook tests (fatal-two-ext / disjoint-OK / fail-on-first / `cooked ⇒ loadable` contract) + 1 runtime dynamic-reject test (`error.ExtensionComponentConflict`, entity unchanged); `MultiResolver.ext()` added (contract-test plumbing); orphaned `ext_stash` fixture + stale section header removed. + - Gate end: `grep -rn "W1791" src/ tests/ tools/` empty; `grep -rniE "last.?extension.?wins…" src/ tests/` empty. `test-extensions` 19/19 pass; full `zig build test` green in Debug **and** ReleaseSafe; `zig fmt --check` + lint green. ## Recorded deviations From 4a2f3cac697d86dc9cba812cd2e8a59e86574ef7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 19:42:20 +0200 Subject: [PATCH 06/11] docs(brief): close m1.1.1-hf4 Close M1.1.1-HF4 (extension additive conflict: ratify reject). CLAUDE.md: current-state HF4 mention, +1 Hotfixes-table row (untagged), the extension-conflict Open decision marked RESOLVED (reject ratified, E1797). Brief: G3 execution log, recorded deviations (none), closing notes, Status CLOSED. Language + drift audits clean. --- CLAUDE.md | 7 ++++--- .../m1.1.1-hf4-extension-conflict-reject.md | 19 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c9846e7..b3f1677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | (none — between milestones) | +| Current milestone | M1.1.1-HF4 — extension additive conflict: ratify reject (`E1797`) — code-complete, PR open (untagged hotfix on `main` = M1.1.2) | | Last released tag | `v0.11.2-narrowphase-gjk` | -| Active branch | `main` | +| Active branch | `phase-1/etch/hf4-extension-conflict-reject` (hotfix; PR open, not merged) | | Next planned milestone | M1.1.3 — Forge 3D narrowphase: EPA + contact manifold (penetration depth, contact points, normal; seeded from the GJK terminal simplex) — fourth core sub-milestone of the M1.1 rigid arc (M1.1.0–15, `PhysicsModule` interface freeze at M1.1.15), per the Forme A execution order (module cores M1.1→M1.5, then M1.6 Asset Pipeline, then the M1.7 demo). M1.1.0 (foundations), M1.1.1 (broadphase dynamic multi-layer BVH), and M1.1.2 (narrowphase GJK convex detection) are CLOSED. The M1.0 core-language series (M1.0.0–M1.0.18, 19 sub-milestones) is CLOSED — C1.6 Etch-closure tag `v0.10.18-extension-additive-warning`. Reserved (Tier-1-dependent, NOT core gaps): `future` — the sole reserved await target; `override` — the last reserved top-level construct keyword (waits for a Tier-1 overridable module); `quantize` — reserved in `non_s3_keywords` (beat/bar musical clock, later Sequencer-adjacent milestone). | ## Tags @@ -70,6 +70,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, | `M1.1.1-HF1` | 2026-07-13 | `50086b1` | Standalone hotfix (external Codex review, 7/7 defects verified on `main` then fixed; zero-deferred-debt). D3/D4 **reserve-then-mutate** (new invariant name): `EntityIdentityStore.release` + `Registry.release`/`freeSlot` reserve free-list capacity before any mutation — on OOM, no observable mutation, call retryable (FailingAllocator tests). D7: `World.despawn` purges the entity's `entity_extensions` entry (owned names freed at despawn, not stranded until `deinit`). D6: `RuntimeHeader.validate(bin_len)` (version + u64-widened section bounds) + data-hash check in `Loader.readBin`; `LoadError += MalformedAsset`; `WELD_ASSET_PIPELINE_PROTOCOL_VERSION` 1→2 (tracked pinned-set migration). D5: `sendWithHandles` flushes a short `sendmsg` via the shared `writeAll` loop (fds ride the first segment; remainder carries no ancillary; `n == 0` → `BrokenPipe`); POSIX only, Windows stays `Unimplemented` (Phase 3). D1: loaded resource strings are **refcounted** persistent blocks owned by their `StringSlot` (ecs_bridge read-old/write-new/decref-old discipline); `LoadResult.resource_strings` removed (teardown parity with interp-written strings). D2: `loadFromBytes` is loader-transactional — per-resource journal (pre-write snapshot; null = fresh), commit decrefs replaced old blocks, committed-guarded errdefer rolls back in **LIFO** (decref new + restore/remove; reverse-order despawn, D7 purges extensions); LIFO order corrected at E4 review (forward undo corrupted the refcount on duplicate same-cid entries — regression test pinned). Contract excludes user-hook side effects; best-effort under OOM. | | `M1.1.1-HF2` | 2026-07-16 | `b54115b` | Standalone hotfix (second external Codex review on post-HF1 `main`; 6 fixable defects verified then fixed, 1 documented non-defect — zero-deferred-debt). C1: `EntityIdentityStore.allocate` maintains `free_indices.capacity >= slots.len` (fresh path reserves via `ensureTotalCapacity(slots.len + 1)` BEFORE growing — B1 corrected the frozen `ensureUnusedCapacity(gpa, 1)`, which froze capacity at 1); `release` is infallible `void` (`appendAssumeCapacity`), the three `spawn*` errdefers drop their swallowing `catch {}`, `despawn` drops its `try` — an allocated handle is always releasable without allocation. C4: Tier-0 `World.releaseResourcePayloads(gpa)` owns the uniform resource-payload decref walk (strings + collections, via registry `FieldKind`), idempotent by slot zeroing; called from `Interpreter.deinit` BEFORE destroying immortal `persistent_literals` (ordering is load-bearing — reversal is a UAF) and from `World.deinit` before `resources.deinit`; the `bridge.resources`-specific walk is removed — bare worlds and out-of-bridge resources no longer leak. C2: scene `instantiate` pre-reserves `spawned` + `uuid_to_entity` to accessor totals, per-entity inserts are assume-capacity — no fallible insert after a spawn, no orphan outside the rollback (exhaustive fail-index sweep test); entity uuid ordinals validated (out-of-range and duplicate → MalformedScene) before dereference, and the entity total is guarded against u32 overflow — C2b. C6: `ResourceEdit.dirty_before` captured pre-`getMutResource`, restored on rollback via `ResourceStore.setDirty` (pub Tier-0-internal seam) — a rejected load leaves no spurious `when resource T changed`. C3: asset `Loader.finish` reserves the payload-map slot BEFORE `allocWithUuid`; `putAssumeCapacity` post-alloc; the fragile `unload catch {}` unwind is gone. C5: IPC posix `writeAll` sends via `send(MSG_NOSIGNAL)` (EINTR/n==0 semantics verbatim) + `SO_NOSIGPIPE` at fd creation on macOS — peer-closed sends error instead of killing the process. C7 (non-defect): `Broadphase.Proxy` without generation packing stays as designed (documented tradeoff, internal ids); a one-line spec guard lands at the M1.1.x integration milestone that first consumes proxy ids. Review process fix applied: every gate crossed the reserve-then-mutate invariant against ALL composite call surfaces, not each leaf in isolation. | | `M1.1.1-HF3` | (pending merge) | (pending merge) | Standalone hotfix (external Codex reviews #3 AND #4; review #3 on post-HF2 `main` — 9 findings, 8 fixable verified, 1 partially obsolete — gates E1–E6; review #4 on the E1–E6 branch pre-merge — 6 further findings R10–R15, all verified — gates E7–E8; zero-deferred-debt; review #3's scope was itself counter-reviewed before freeze). R1+R10+R11: standalone `src/core/scene/validate.zig` — full structural validation of `.scene.bin` BEFORE any accessor exposure (section order/bounds, string/schema/uuid/crossref refs, column geometry, resource `data_size == schemaSize` (R10 — the loader memcpys behind a ReleaseFast-stripped assert), string-field offsets bounded, schema indices per archetype STRICTLY increasing (R11, normative "sorted ascending"; covers extension prefabs via the shared `openVerified`)), all arithmetic checked; `buildSchemaRemap` rejects two schema entries resolving to one runtime `ComponentId`; seeded-mutation test (10 000 hash-fixed corruptions) walks EVERY accessor getter on validation survivors (E1 review STOP: survivors were initially not getter-exercised). R3+R15: PNG `max_dimension = 16384` cap, checked size math, exact expected inflated size precomputed from IHDR (Adam7 pass table shared budget↔walk), `inflate`/`zlib.decompress` gain `max_out` (single `appendByte` choke point) + strict output-length equality; inflated `raw` freed the instant deinterlacing completes — peak residency ~2×(w×h×4)+IDAT, not ~3× (R15; a configurable byte budget was REVIEWED AND REJECTED for the hotfix — new API surface; the cap + exact budget already bound the footprint). R4+R13: glTF bounds-checked `accessorAt`/`viewAt`/`bufferAt`, semantic accessor validation with the Khronos-conformant interleaved span `byteOffset + stride×(count−1) + elem` (R13 — `count×stride` falsely rejected valid interleaved buffers), declared `type` checked (`VEC2`/`VEC3`/`SCALAR`) before any buffer read, `indices < vertex_count`; `MalformedGltf`. R6+R12: grouped Tier-0 `World.addComponentsDynamic` + `prepareRemoveComponentsDynamic`/`commitRemoveComponentsDynamic`/`abortRemoveComponentsDynamic` (ONE migration; ALL fallible work precedes the first observable mutation; real duplicate/present checks — `DuplicateComponent`/`UnknownComponent` — replace a phantom assert, R11(c)); `activateExtension` prevalidate → reserve → single-fallible-step → infallible commit → `on_attach` (commits BEFORE the hook, HF1/D2 contract) + `ExtensionAlreadyActive` on re-activation (R12(d), closes the hook-only re-fire); `deactivateExtension` prevalidate → PREPARE → `on_detach` (errdefer abort) → infallible commit (R12 — after the hook succeeds, not one fallible step remains; OOM-sweep test with a hook-call counter proves `on_detach` fires exactly once); strict mono-entity cardinality shared by both paths (`extEntityArchetype`); the FABRICATED "§30.5 reject policy" comments removed — reject is the PROVISIONAL runtime policy recorded in the HF3 brief; last-wins runtime semantics = open design surface (see Open decisions). R2: `chmod 0600` post-bind hard-fail + fail-closed peer-UID check on every `accept` (`SO_PEERCRED`/`getpeereid`) — the authoritative boundary closing the bind→chmod TOCTOU; socket path stays spec-pinned in `/tmp`; umask-000 test (serialized, restored; Linux mode read via `statx` — `std.c.Stat` is void on Linux). R5+R14: `command_encoder` — `destroy` waits idle unconditionally and FREES the command buffer (the "pool reset handles it" claim was false — monotonic pool growth closed) and `create` gains the missing post-allocate `errdefer` (R14); errdefers on `createView`/`createRender`/`createCompute`/debug messenger; swapchain `create` unified native-cleanup errdefer (`views_created` + `registered`) fixing the mid-loop AND the `swapchain_owned` post-adoption native leaks; `deinit` destroys views before images. R8: `createRender` attachment guard (mirror of `render_pass.begin`) + `max_color_attachments` device-limit check. R7: `reload` validates `asset_type` vs `handle.type_tag` before any payload mutation → `AssetTypeMismatch`. R9: `retain` guards saturated `u32` → `ReferenceCountOverflow`; generation-wrap slots PERMANENTLY retired (bounded documented leak; `AssetHandle` `u64` layout FROZEN C0.5, generation stays `u16`). `WELD_ASSET_PIPELINE_PROTOCOL_VERSION` 2→3 (single bump covering both pinned-set widenings). Review-process note: review #4 caught two E1-review misses (resource `data_size`, the normative sorted-indices line) and one frozen-brief contract weaker than achievable (fallible-but-atomic post-hook remove) — the corrected contract is the prepare/commit split. Residual risks recorded in the brief: glTF attribute-count consistency unconstrained (no in-tree consumer zips), one-shot server accept after `PeerCredentialMismatch` (reachable only in the TOCTOU microsecond). CI hardening (E9/E9b, post-review-#4 Windows failures): the park test is now deterministic (two-phase proof — Σ parks_entered > Σ parks_completed establishes a live park, then a completed increase proves the wake; new stats-only `parks_entered` counter incremented under the park-path lock, propagated to WorkerStats/Snapshot/reset/dump with the `completed <= entered` invariant); watchdog coverage extended to EVERY Scheduler-starting test INCLUDING teardown (`no_alloc_steady_state*` armed the global watchdog around `Scheduler.deinit()` — the prior local wrapper only covered dispatch loops); the windows-2025/ReleaseSafe hang (4 consecutive pre-E9b occurrences) did not recur on the post-E9b merge run — unexplained, instrumented, first recurrence self-names via the dump. | +| `M1.1.1-HF4` | (pending merge) | (pending merge) | Standalone hotfix — ratify **reject** as the normative additive-extension-conflict policy (dedicated spec session; `engine-scene-serialization.md` / `etch-reference-part2.md §30.5` / `etch-diagnostics.md §18.3` corrected). A static conflict (≥2 active extensions declaring the same component on one entity) is now a **fatal cook error** `E1797 ExtensionAdditiveConflict` — reclassified from the non-fatal M1.0.18 warning whose sole reader was a test, so the cook silently produced `.scene.bin` files the loader then refused to load. `detectExtensionConflicts` now `fail`s on the FIRST conflicting component (static message, short-circuit); the dead warning infra is removed (`Warning` struct, `Cooked.warnings`, `Builder.warnings`, `emitAdditiveConflict`, and all plumbing incl. the now-unused `token` import). Runtime `error.ExtensionComponentConflict` (+ `ExtensionAlreadyActive`) UNCHANGED — no logic change to the loader activate/deactivate paths; only their conflict-policy comments normalized (normative, cite `engine-scene-serialization.md`). Establishes the `cooked ⇒ loadable` invariant. Tests: 4 cook (fatal / disjoint-OK / fail-on-first / `cooked ⇒ loadable` contract) + a runtime dynamic-reject. Closes the extension-conflict Open decision (reject ratified — see Open decisions). `@exclusive_with` stays a future unimplemented escape hatch. | ## Hypotheses validated by spikes @@ -113,7 +114,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **M1.1.0 scope boundary (Forge 3D foundations)**: `src/interfaces/PhysicsModule.zig` + the `PhysicsModule(Impl)` comptime wrapper + `core.ModuleContext` are DEFERRED to the milestone that wires forge_3d as a stepping module — instantiating the wrapper needs `ModuleContext`, whose spec carries an unresolved Tier-0→Tier-1 reference (`asset_loader: *AssetLoader`), and the repo precedent (RenderModule, C0.5) froze a module-root namespace, not a `src/interfaces/` file: the location ruling belongs to the interface-landing milestone. The day-1 contract is carried by the `api/` descriptor types (mirrored verbatim); when the interface file lands, declarations move there and `api/` re-exports — zero call sites. `Velocity` stays a core component (`api/` re-exports; moving it would invert core→Tier-1). `Mat4` and a math-level `Transform` pose type: excluded, purely additive (first consumers later). Shapes beyond sphere/box/capsule: `error.UnsupportedShape` until their sub-milestones (pre-freeze, additive). Descriptor validation policy (typed errors on degenerate mass/geometry) is a later milestone — M1.1.0 guards the dynamic path with a `mass > 0` debug assert only. Pending KB spec patch on `engine-tier-interfaces.md` §1 (ShapeType set, damping default, `foundation.math`, union-form `ShapeDescriptor`), produced at milestone close. - **M1.1.1 scope boundary (broadphase dynamic multi-layer BVH)**: `pipeline/broadphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`; the scalar is the comptime parameter `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports), and `user_data` is an opaque `u32` (forge_3d passes the packed `BodyId`). Determinism by construction (anticipates M1.1.14): NO hash containers anywhere — index pool + LIFO free-list, tree shape a pure function of the op sequence, pairs sorted by packed `u64 (min<<32)|max` + adjacent-deduped; SAH/balance ties fixed. `insert` realizes the brief's schematic `insert(aabb, user_data) u32` as `insert(gpa, tight_aabb, user_data) !u32` (unmanaged-first convention `§3`, `BodyManager.addBody` precedent) — a realization, not a scope change (Guy-confirmed, no Recorded deviation). `computePairs` is **moved-driven** and consumes (clears) the per-layer moved-logs; a stale moved id (freed, not reused) is skipped via `isLiveLeaf`; `user_data` must be unique across all layers (documented on `Broadphase.insert`). The `default_layer_pairs` matrix is a `const`; an overridable `CollisionConfig` matrix is a LATER milestone (the field `Body.collision_layer` is stored now but nothing consumes it — object-layer pair filtering unwired). **OUT (not gaps, additive on the same structure):** raycast/shapecast tree traversal (M1.1.9–10), velocity-based AABB prediction / speculative expansion (M1.1.5), trigger/debris `BodyDescriptor` layer-assignment sources (M1.1.13 — only `body_type`-derived static/dynamic exercised now), `PhysicsWorld`/stepping/`ModuleContext`/`PhysicsModule` instantiation (M1.1.15), parallel/GPU broadphase (Phase 4+), narrowphase. **File-length note:** `broadphase.zig` is 686 lines (over the 500-line Review guideline) — the brief mandates `Bvh(T)` + `Broadphase(T)` + `BroadphaseLayer` + `BroadphaseConfig` + pair generation in this single file; conscious brief-driven overage, not lint-enforced. **E0 directory flatten** removed the `solvers_3d/` wrapper (`solvers_3d/forge_3d/` → `forge_3d/`); `engine-directory-structure.md` §9.1 and `engine-tier-interfaces.md` were pre-patched to the flat layout; the stale `solvers_{2d,3d}/` reference in `engine-zig-conventions.md §14` (KB, out of repo) remains for a KB reconciliation. - **M1.1.2 scope boundary (narrowphase GJK convex detection)**: `pipeline/narrowphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`/`broadphase`; the scalar is the comptime `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports). GJK runs on the convex **cores** (point/segment/box) + an inflation radius (Jolt convex-radius architecture); touch = `dist(cores) ≤ r_a + r_b`, `separated` iff the core distance exceeds `r_a + r_b` beyond an ABSOLUTE noise-scaled contact margin `conv_k·floatEps(T)·coordScale` (exact touch → `shallow`, face-inclusive convention). Distance-based (NOT boolean) GJK: the simplex stores `(w, support_a, support_b)` triplets so closest points on A/B reconstruct from the barycentrics; the Voronoi simplex solver is Ericson (not Johnson, not signed-volumes). Determinism by construction (anticipates M1.1.14): no hash containers, no trig (dot/cross only), `max_gjk_iterations = 32`, named relative progress tolerance, fixed support tie-breaks; the search direction is NEVER normalized (squared distances, one `sqrt`); computation frozen in the frame of A (`rot_rel`/`pos_rel` precompute, conjugate-rotate never `inverse()`) — changing the frame after M1.1.14 would break bit-exactness. `GjkResult.deep` carries the terminal origin-enclosing simplex as the EPA seed — no depth/normal/manifold here. The forge_3d-side adapters (`shape.supportShape`, `BodyManager.gjkPair`) own the `Shape → SupportShape` conversion + the `BodyId`-level entry, mirroring the broadphase `user_data` discipline. **Classification robustness (six post-delivery correction cycles, Guy-directed — RESOLVES the E3 absolute-`deep_eps`, the Fix 3 vertex-relative residual, the P1/P2 remaining geometric dependencies, the P1b accumulated-rounding under-dimensioning, the P1c A/B-order asymmetry, AND the P1d anisotropic-tetra false-degeneracy; the extreme-aspect radius-0 box `.deep` residual is DOCUMENTED and deferred to M1.1.4/M1.1.3):** GUIDING PRINCIPLE — no `.deep`/`.shallow`/`.separated` decision depends on a GEOMETRIC quantity (radius, shape size, `w`-vertex magnitude); `.deep` = geometric enclosure; every remaining threshold is `k·floatEps(T)·coordScale` (coordScale = absolute support magnitude, `@sqrt(maxSupportMagSq)`), absorbing only float rounding noise. (a) `.deep` = a non-degenerate tetra enclosing the origin (`count==4`, degeneracy tested DIMENSIONLESSLY `|bary_det|² ≤ deg_rel²·(|b−a|²·|c−a|²·|d−a|²)` = squared normalized volume, aspect-ratio-invariant — P1d replaced the `maxEdgeSq³` normalization that rejected valid anisotropic tetra and read a sharp box's interior `.separated`), NO distance threshold on that path; (a2, Fix 3b→P2) the sole distance early-out (`degenerateOriginReached`) is a numerical-NOISE floor `closest·closest ≤ mach_eps²·maxSupportMagSq` (scaled by the ABSOLUTE support magnitude), with `mach_eps = noise_k·floatEps(T)` (P2 recalibrated from the 8×-eps `1e-6` literal — which was a visible geometric threshold, ~8.7e-5 at half-extent 50 — down to `noise_k`≈2 ULPs); the earlier vertex-relative `rel_deep²·maxVertexMag²` (Fix 3) reproduced the bug for large shapes and was removed; (b) an anti-cycling duplicate-support guard kills degenerate Minkowski tetrahedra at the source; (c, Fix 4→P1) `.separated iff dist − r_sum > conv_k·floatEps(T)·coordScale` — an ABSOLUTE additive margin (P1 removed the `r_sum·(1+contact_rel)` form, whose `contact_rel·r_sum` slack — 0.1 m at r_sum 1000 — was a collision margin beyond the core radius, out of scope; it mis-classified two r=500 spheres 5 cm apart as `.shallow`), honoring the frozen touch=shallow rule via the convergence-noise margin; (c2, P1b) `conv_k` = 16, not 2 — 2 ULP under-dimensioned the ACCUMULATED pipeline rounding (quaternion + Voronoi tetra + sqrt), so an exact tangency (Codex case: `dist − r_sum` = 7.15e-7 ≈ 2.1 ULP relative, zero convergence residue) tipped `.separated`; 16 ULP bounds it and stays at the noise level (the deep noise floor keeps `noise_k = 2`, a distinct point-noise floor — aligning them recreates a P1b/P2 false positive). Scale-robust, verified at O(1), half-extents 50–500, and at the f32 noise floor (~9 orders tighter in f64 — an f32 fundamental limit, not a geometric margin); a measure-zero EXACT tangency can still, in rare pathological configs (~1/20000 in sweep), tip `.separated` by one ULP — irreducible float limit, corrected next frame by penetration, no absolute guarantee claimed. (c3, P1c) the contact margin's `coord_scale` is SYMMETRIC by construction — `|pos_b − pos_a| + coreExtent(a) + coreExtent(b)` (`coreExtent` = the core's max local support magnitude, radius excluded: point→0, segment→half_height, box→`|half_extents|`) — replacing the A-frame terminal-support magnitude `@sqrt(maxSupportMagSq)`, which was frame-dependent and made one tangency classify `.separated` in one A/B order and `.shallow` in the other (a structural defect, not calibration). `.deep`/`.shallow`/`.separated` is therefore invariant under an A/B swap by construction, asserted by `gjk classification is order-independent`; `maxSupportMagSq` stays (anti-cycling guard + deep noise floor — `w`-magnitude, a distinct quantity). **File-length note:** `narrowphase.zig` is 726 lines (over the 500 Review guideline) — the brief mandates the whole GJK stack (support shapes + simplex solver + loop + result) in this single file (mirror of `broadphase.zig` at 686); conscious brief-driven overage. **OUT (not gaps; additive/later):** EPA + penetration depth + contact normal + manifold (M1.1.3 — in particular do NOT compute `depth = r_a+r_b−dist` or a normal for `.shallow`); pair-type fast paths ss/sb/cc/bb (M1.1.4); speculative-contact thresholds / margins beyond the core radius / box convex radius (box radius 0 here); `collision_layer`/`CollisionConfig` matrix wiring; stepping/`PhysicsWorld`/`PhysicsModule` instantiation (M1.1.15); raycast/shapecast/point queries (M1.1.9–10); shapes beyond sphere/box/capsule (`SupportShape.Core` stays additively extensible); batched/SIMD pair processing; `forge_2d` (M1.8); robust `.deep` for radius-0 box cores of EXTREME aspect ratio (>~50:1) — a GJK-f32 limit on sharp cores (tetra degeneracy at extreme anisotropy + premature anti-cycling termination before the enclosure tetra forms), reliable to ~30:1, deferred to M1.1.4 analytic box fast paths / M1.1.3 EPA (P1d). -- **Extension-conflict runtime semantics (last-wins vs reject) — dedicated spec session scheduled post-HF3**: `engine-scene-serialization.md` normatively describes cook-time LAST-WINS for additive extension conflicts (worked merchant/combat `Inventory` example), but its RUNTIME semantics are unspecified (masking/provider stack, restoration on deactivate, out-of-order deactivation, hot-reload of a masked extension, base-prefab conflicts, zero-component extensions). The runtime today implements REJECT (`error.ExtensionComponentConflict`) plus `ExtensionAlreadyActive` on re-activation, recorded as PROVISIONAL in `briefs/m1.1.1-hf3-core-robustness-hotfix-3.md` (R6/R12) — the former code comments citing a "§30.5 reject policy" were fabricated (§30.5 defines no such policy) and were removed in HF3. The session's output: reconciled KB spec files + an implementation milestone if the policy changes. Until then, code comments cite the HF3 brief, never §30.5. +- **Extension-conflict semantics (last-wins vs reject) — RESOLVED (M1.1.1-HF4)**: the dedicated post-HF3 spec session ratified **reject** as the normative, permanent policy for additive extension conflicts (two active extensions declaring the same component on one entity), superseding the former "last-extension-wins" prose. Static conflict → **fatal cook error** `E1797 ExtensionAdditiveConflict` (no output; `weld check` fails in CI), guaranteeing `cooked ⇒ loadable`; dynamic activation adding an already-present component → runtime `error.ExtensionComponentConflict` (atomic — entity unchanged) + `ExtensionAlreadyActive` on re-activation. Specs corrected (`engine-scene-serialization.md` additive-conflict note, `etch-reference-part2.md §30.3–30.5`, `etch-diagnostics.md §18.3` + the W1791→E1797 reclassification note); code comments now cite `engine-scene-serialization.md` (the authority), not the HF3 brief. Deactivation needs no provenance — the reject invariant means no two active extensions ever share a component. Alternative examined and rejected in the session: last-wins-with-provenance (masking + shadowed-byte side table + out-of-order deactivate + `.sav` shadow-stack serialization — ≈5–10× the code, touches the ECS reserve-then-mutate invariant and the serialization format, undoes the HF3 deactivate simplification — to support a design error). `@exclusive_with` remains a future, unimplemented escape hatch. ## Non-negotiable rules diff --git a/briefs/m1.1.1-hf4-extension-conflict-reject.md b/briefs/m1.1.1-hf4-extension-conflict-reject.md index 113313e..6fa76a4 100644 --- a/briefs/m1.1.1-hf4-extension-conflict-reject.md +++ b/briefs/m1.1.1-hf4-extension-conflict-reject.md @@ -1,12 +1,12 @@ # M1.1.1-HF4 — Extension additive conflict: ratify reject -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/etch/hf4-extension-conflict-reject` > **Planned tag:** none — hotfix, non-tagged (same convention as M1.1.1-HF1/HF2/HF3) > **Dependencies:** none (independent of M1.1.2 GJK; based on current `main` = M1.1.1-HF3) > **Opened:** 2026-07-17 -> **Closed:** — +> **Closed:** 2026-07-18 --- @@ -120,15 +120,20 @@ All in `tests/scene/extensions_test.zig` unless noted. The existing block that r - `scene_cook.zig`: `ExtensionAdditiveConflict` added to `CookError`; `detectExtensionConflicts` re-signed `(ext_start, ext_len, base_resolver, diag_out)`, now `fail`s (static message) on the FIRST conflicting component (short-circuit, no enumeration); deleted `w1791_code`, `emitAdditiveConflict`, the `Warning` struct, `Cooked.warnings`, `Builder.warnings`, both `toOwnedSlice` returns, the deinit/deinitScratch loops, and the now-unused `token` import. `detectExtensionConflicts` doc comment rewritten (fatal/reject, cites `engine-scene-serialization.md`). - `extensions_test.zig`: 6 warning tests replaced by 4 cook tests (fatal-two-ext / disjoint-OK / fail-on-first / `cooked ⇒ loadable` contract) + 1 runtime dynamic-reject test (`error.ExtensionComponentConflict`, entity unchanged); `MultiResolver.ext()` added (contract-test plumbing); orphaned `ext_stash` fixture + stale section header removed. - Gate end: `grep -rn "W1791" src/ tests/ tools/` empty; `grep -rniE "last.?extension.?wins…" src/ tests/` empty. `test-extensions` 19/19 pass; full `zig build test` green in Debug **and** ReleaseSafe; `zig fmt --check` + lint green. +- **G3 — CLAUDE.md + closing audits + close.** `CLAUDE.md`: current-state row (HF4 mention + active-branch = the hotfix branch); +1 Hotfixes-table row `M1.1.1-HF4` (untagged, `(pending merge)`); the extension-conflict Open decision rewritten `RESOLVED (M1.1.1-HF4)` — reject ratified, `E1797`; `Last updated` already 2026-07-18. Language audit clean (no FR in code; the only accented strings are the brief FROZEN "Specs to read" list quoting French spec section titles verbatim — Claude.ai content). Drift audit clean: no orphaned `W1791` / `last-extension-wins` / warning infra in `src/`/`tests/`/`tools/`; residual mentions are all accurate historical records (the M1.0.18 tag row + M1.0.18/HF3 briefs) or the new HF4 documentation. ## Recorded deviations +- **None from the brief scope.** All removals/additions were within the brief's "delete … all plumbing" + "rewrite the conflict block" mandate. In-scope implementation consequences (not deviations): `detectExtensionConflicts` shed its now-unused `entity_name`/`span` params (the static message names the constraint, not the entity — per brief Notes); the `token` import and the `ext_stash` test fixture became orphaned by the warning-infra removal and were deleted; `MultiResolver.ext()` was added as contract-test plumbing. + ## Blockers encountered +- None. + ## Closing notes -- **What worked:** -- **What deviated from the original spec:** -- **What to flag explicitly in review:** -- **Final measurements:** -- **Residual risks / tech debt left intentionally:** +- **What worked:** the gate-by-gate STOP+GO cadence; specs were found already patched locally (no round-trip); the atomic G2 (reclassify + fatalize + infra removal + test rewrite in one commit) kept the tree compilable and green throughout, avoiding a red intermediate state. +- **What deviated from the original spec:** nothing material — see Recorded deviations. The static cook message intentionally drops the entity/component names (brief Out-of-scope: `diag_out` carries a non-owned static message, no ownership/free path), a knowing trade vs the old dynamic `allocPrint` warning. +- **What to flag explicitly in review:** (1) cook conflict detection stays **best-effort** — it no-ops without a `base_resolver` or when an extension name does not resolve; the runtime `error.ExtensionComponentConflict` is the invariant backstop for the resolver-less path (both are "reject", consistent). (2) The M1.0.18 tag row + the M1.0.18/HF3 briefs intentionally retain "W1791"/"last-extension-wins" wording — accurate historical records, not orphans (the grep gate scopes to `src/ tests/ tools/`). (3) `loader.zig` conflict-policy edits are comment-only — zero logic change (reject was already implemented correctly in HF3). +- **Final measurements:** none (no hot path touched). `zig build test` green Debug + ReleaseSafe; `test-extensions` 19/19; pre-push gate ×2 green on both G1 and G2 pushes. +- **Residual risks / tech debt left intentionally:** `@exclusive_with` remains a future, unimplemented escape hatch (referenced in the specs, out of scope). The two accented strings in the brief's FROZEN "Specs to read first" list quote French spec section titles verbatim (Claude.ai-authored; not editable by Claude Code outside a round-trip). From 7f01c9e788631c35f273b96ac6dab1512f4fcf2d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 23:29:09 +0200 Subject: [PATCH 07/11] fix(etch): complete additive extension-conflict gate (forms a/b/c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ratified `cooked ⇒ loadable` invariant is "base + active extensions conflict-free" — three forms. The prior gate covered only (a) two extensions declaring the same component; a post-G3 review found forms (b)/(c) and an accessor-contract gap still open (over-declared invariant). Completed here. - detectExtensionConflicts: seed `counts` with the entity's BASE component names at 1 each (from eb.comp_ids via registry.componentName), then count each extension — a name reaching 2 is fatal, unifying (a) ext-vs-ext and (b) ext-vs-base; drop the `ext_len < 2` early-out (a lone extension can conflict with base). `eb.comp_ids` threaded from the caller. - form (c): a repeated extension name now fails fatally (distinct static message) instead of silently dedup-continuing (which masked a load-time ExtensionAlreadyActive). - P1#3: validate.structure runs after verifyHash and before any accessor getter (failure → skip), closing a panic on a malformed-but-rehashed prefab. - BaseResolver comment: "warning (§30.5)" → "check (fatal E1797)". - tests: +3 (form b, form c, malformed-but-rehashed no-panic). --- src/etch/scene_cook.zig | 84 +++++++++++++++++++++------------ tests/scene/extensions_test.zig | 70 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 30 deletions(-) diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index ab1e9b9..eef94d3 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -49,6 +49,7 @@ const format = weld_core.scene.format; // M1.0.6 E2 — `of` variant resolution reads the base prefab's cooked `.prefab.bin` // back through the same zero-copy accessor the loader uses. const accessor = weld_core.scene.accessor; +const validate = weld_core.scene.validate; const AstArena = ast_mod.AstArena; const StringId = ast_mod.StringId; @@ -211,9 +212,9 @@ fn fail(diag_out: ?*[]const u8, err: CookError, msg: []const u8) CookError { /// the on-disk cook output, and tests wire it to an in-process byte buffer. /// /// The resolved bytes must outlive the cook (mirror of `loader.zig`'s -/// `ExtensionResolver`): since M1.0.18 the additive-conflict warning (§30.5) -/// borrows them as component-name map keys ACROSS resolves, so a resolver -/// returning a reused/temporary buffer would be a use-after-free. +/// `ExtensionResolver`): the additive-conflict check (fatal E1797) borrows them +/// as component-name map keys ACROSS resolves, so a resolver returning a +/// reused/temporary buffer would be a use-after-free. pub const BaseResolver = struct { ctx: *anyopaque, resolveFn: *const fn (ctx: *anyopaque, name: []const u8) ?[]const u8, @@ -461,11 +462,14 @@ const Builder = struct { }, }; try self.recordExtensions(eb.uuid_idx, ext_start, ext_len); - // Additive-conflict gate (§30.5, M1.1.1-HF4): a FATAL cook error if ≥2 - // active extensions on this entity declare the same component — the - // `extends` model is strictly additive (reject), guaranteeing + // Additive-conflict gate (§30.5, M1.1.1-HF4): a FATAL cook error unless + // the entity's {base components} ∪ {active extensions' components} is + // conflict-free — three rejected forms: (a) two extensions declare the + // same component, (b) an extension declares a component already carried + // by the base (or an earlier extension), (c) the same extension is listed + // twice. Strictly-additive `extends` reject, guaranteeing // `cooked ⇒ loadable`. Short-circuits on the first conflict. - try self.detectExtensionConflicts(ext_start, ext_len, base_resolver, diag_out); + try self.detectExtensionConflicts(eb.comp_ids, ext_start, ext_len, base_resolver, diag_out); try entities.append(self.gpa, eb); } @@ -510,34 +514,50 @@ const Builder = struct { try self.ext_entries.append(self.gpa, .{ .uuid = uuid_idx, .prefab_ids = ids }); } - /// Additive-conflict gate (§30.5, M1.1.1-HF4) — a FATAL cook error if two or - /// more DISTINCT active extensions on one entity's `extensions:` clause declare - /// the same component. The `extends` model is strictly additive: a shared - /// component is rejected (`E1797 ExtensionAdditiveConflict`), never resolved by - /// list order — this guarantees `cooked ⇒ loadable` (the runtime backstop is - /// `error.ExtensionComponentConflict`). Short-circuits on the FIRST conflicting - /// component (no per-component enumeration). Extension component sets are - /// resolved through the SAME prefab path as `of`/`extends` (`base_resolver` → - /// `.prefab.bin` → accessor schema names). Best-effort: no-op — no conflict is - /// detectable — when fewer than two extensions, no resolver, or an extension - /// name does not resolve / its bytes do not open (the by-name clause; the - /// runtime reject remains the invariant backstop for the resolver-less path). + /// Additive-conflict gate (§30.5, M1.1.1-HF4) — a FATAL cook error unless the + /// set {entity base components} ∪ {each active extension's components} is + /// conflict-free. The `extends` model is strictly additive; three forms are + /// rejected (never resolved by list order), each a fatal `E1797 + /// ExtensionAdditiveConflict`: + /// (a) two active extensions declare the same component; + /// (b) an extension declares a component already carried by the base + /// (or an earlier extension); + /// (c) the same extension is listed twice in the `extensions:` clause. + /// This guarantees `cooked ⇒ loadable` (runtime backstops: + /// `error.ExtensionComponentConflict` for a/b, `error.ExtensionAlreadyActive` + /// for c). Short-circuits on the FIRST conflict (no enumeration); the + /// duplicate-extension form carries a distinct static message. + /// + /// Forms (a) and (b) are UNIFIED: `counts` is seeded with the entity's BASE + /// components at 1 each, then each extension's components are counted; a name + /// reaching 2 is a conflict whether the earlier declarant was the base seed or + /// another extension. Extension component sets are resolved through the SAME + /// prefab path as `of`/`extends` (`base_resolver` → `.prefab.bin` → validated + /// accessor schema names). Best-effort: an extension whose name does not + /// resolve, whose bytes do not open, whose hash mismatches, or whose structure + /// is invalid is SKIPPED (the by-name clause; the runtime reject is the + /// invariant backstop for the resolver-less path). No `ext_len < 2` early-out — + /// a single extension can conflict with the base. /// See `engine-scene-serialization.md` (extension additive conflicts). fn detectExtensionConflicts( self: *Builder, + base_comp_ids: []const ComponentId, ext_start: u32, ext_len: u32, base_resolver: ?BaseResolver, diag_out: ?*[]const u8, ) CookError!void { - if (ext_len < 2) return; const resolver = base_resolver orelse return; - // component name → number of DISTINCT active extensions that declare it. + // component name → number of DISTINCT active declarants (the base counts as + // one). Seeding the base at 1 (form b) makes the FIRST extension re-declaring + // a base component reach 2 = conflict. Base names borrow the registry (owned, + // live for the whole cook); extension names borrow the resolver-owned bytes. var counts: std.StringHashMapUnmanaged(u32) = .empty; defer counts.deinit(self.gpa); - // Dedup the extension names: activating the same extension twice must not - // conflict with itself. + for (base_comp_ids) |id| try counts.put(self.gpa, self.registry.componentName(id), 1); + + // Dedup the extension names: the same extension listed twice is form (c). var seen: std.StringHashMapUnmanaged(void) = .empty; defer seen.deinit(self.gpa); @@ -545,25 +565,29 @@ const Builder = struct { while (k < ext_len) : (k += 1) { const ext_name = self.ast.strings.slice(self.ast.scene_extensions.items[ext_start + k]); const seen_gop = try seen.getOrPut(self.gpa, ext_name); - if (seen_gop.found_existing) continue; + if (seen_gop.found_existing) return fail(diag_out, error.ExtensionAdditiveConflict, "the same extension is listed more than once in an entity's `extensions:` clause; the `extends` model is strictly additive — reject (E1797 ExtensionAdditiveConflict)"); const bytes = resolver.resolve(ext_name) orelse continue; var acc = accessor.Accessor.open(bytes) catch continue; if (!acc.verifyHash()) continue; + // P1#3 (HF3/R1 accessor contract): structurally validate BEFORE calling + // any getter — a malformed-but-rehashed prefab (hash valid, structure + // invalid) would otherwise panic on `schemaCount`/`schema` reads. + validate.structure(acc.bytes, acc.header) catch continue; // Each component the extension declares (its `.prefab.bin` schema table; - // names are unique) bumps that name's distinct-extension count. The - // second distinct declarer is the conflict — a fatal cook error, - // short-circuiting on the first (no enumeration). Keys borrow the - // accessor bytes (resolver-owned, live for the whole cook) and compare - // by content, so the same name across extensions aggregates correctly. + // names are unique per prefab) bumps that name's distinct-declarant + // count. Reaching 2 is the conflict — form (a) if the prior declarant was + // another extension, form (b) if it was the base seed — a fatal cook + // error, short-circuiting on the first. Keys borrow the accessor bytes + // (resolver-owned, live for the whole cook) and compare by content. var ci: u32 = 0; while (ci < acc.schemaCount()) : (ci += 1) { const comp_name = acc.schema(ci).name; const gop = try counts.getOrPut(self.gpa, comp_name); if (!gop.found_existing) gop.value_ptr.* = 0; gop.value_ptr.* += 1; - if (gop.value_ptr.* == 2) return fail(diag_out, error.ExtensionAdditiveConflict, "two active extensions declare the same component on an entity; the `extends` model is strictly additive — reject (E1797 ExtensionAdditiveConflict)"); + if (gop.value_ptr.* == 2) return fail(diag_out, error.ExtensionAdditiveConflict, "a component is carried by more than one active declarant (base or extension) on an entity; the `extends` model is strictly additive — reject (E1797 ExtensionAdditiveConflict)"); } } } diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 7a9d2f3..a65d2aa 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -451,6 +451,76 @@ test "cooked scene with extensions is loadable" { try std.testing.expect(world.hasEntityExtension(npc, "ArsenalModule")); } +test "cook fails when an extension declares a component already on the base (form b)" { + const gpa = std.testing.allocator; + const m = try prefabBytes(gpa, ext_merchant); // declares Inventory + defer gpa.free(m); + var mr = MultiResolver{ .names = &.{"MerchantModule"}, .blobs = &.{m} }; + const src = + \\component Inventory { slots: i32 = 0 } + \\scene "S" { + \\ entity "npc" { + \\ uuid: "00000000-0000-0000-0000-0000000000f1" + \\ extensions: ["MerchantModule"] + \\ Inventory { slots: 5 } + \\ } + \\} + ; + // The base entity carries Inventory; the single active extension also declares + // Inventory → form (b) additive conflict, fatal (a lone extension suffices). + try std.testing.expectError(error.ExtensionAdditiveConflict, scene_cook.cookScene(gpa, src, mr.base(), null)); +} + +test "cook fails when the same extension is listed twice (form c)" { + const gpa = std.testing.allocator; + const m = try prefabBytes(gpa, ext_merchant); // Inventory, disjoint from base + defer gpa.free(m); + var mr = MultiResolver{ .names = &.{"MerchantModule"}, .blobs = &.{m} }; + const src = + \\component Marker { v: i32 = 0 } + \\scene "S" { + \\ entity "npc" { + \\ uuid: "00000000-0000-0000-0000-0000000000f1" + \\ extensions: ["MerchantModule", "MerchantModule"] + \\ Marker { v: 1 } + \\ } + \\} + ; + // The same extension listed twice → form (c) additive conflict, fatal. + try std.testing.expectError(error.ExtensionAdditiveConflict, scene_cook.cookScene(gpa, src, mr.base(), null)); +} + +test "cook skips a structurally-invalid but data-hash-correct extension (no panic)" { + const gpa = std.testing.allocator; + const orig = try prefabBytes(gpa, ext_merchant); // valid MerchantModule + defer gpa.free(orig); + + // Forge a malformed-but-rehashed prefab: bloat `schema_count` (header field @20, + // OUTSIDE the hashed region bytes[64..], so the data hash stays correct) to a + // value that overflows the schema table. `Accessor.open` (magic/version only) + + // `verifyHash` still pass; only `validate.structure` rejects it. Without the + // detector's structural guard, `schemaCount()`/`schema()` would read OOB → panic. + const buf = try gpa.dupe(u8, orig); + defer gpa.free(buf); + std.mem.writeInt(u32, buf[20..24], 0xFFFF, .little); // schema_count := 65535 + + var mr = MultiResolver{ .names = &.{"MerchantModule"}, .blobs = &.{buf} }; + const src = + \\component Marker { v: i32 = 0 } + \\scene "S" { + \\ entity "npc" { + \\ uuid: "00000000-0000-0000-0000-0000000000f1" + \\ extensions: ["MerchantModule"] + \\ Marker { v: 1 } + \\ } + \\} + ; + // The malformed extension is SKIPPED (validate.structure fails → continue): the + // cook does not panic and, with no detectable conflict, succeeds. + var cooked = try scene_cook.cookScene(gpa, src, mr.base(), null); + cooked.deinit(gpa); +} + test "runtime activate rejects a component the entity already carries" { // The dynamic counterpart of the static cook gate: `activate_extension` adding // a component the entity already carries is rejected with From f8e645412208e14d68e596a4921f1413a872d13c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 18 Jul 2026 23:29:15 +0200 Subject: [PATCH 08/11] docs(brief): re-read expanded specs, log m1.1.1-hf4 G4 --- .../m1.1.1-hf4-extension-conflict-reject.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/briefs/m1.1.1-hf4-extension-conflict-reject.md b/briefs/m1.1.1-hf4-extension-conflict-reject.md index 6fa76a4..93c98af 100644 --- a/briefs/m1.1.1-hf4-extension-conflict-reject.md +++ b/briefs/m1.1.1-hf4-extension-conflict-reject.md @@ -106,9 +106,9 @@ All in `tests/scene/extensions_test.zig` unless noted. The existing block that r ## Specs read -- [x] `engine-scene-serialization.md` (extensions / additive-conflict note, §666–700) — read 2026-07-18 14:35 -- [x] `etch-reference-part2.md` (§30.3–30.5, conflits multi-instances) — read 2026-07-18 14:38 -- [x] `etch-diagnostics.md` (§18.3 prefab range E1790–E1799, W1791→E1797 RECLASSÉ note) — read 2026-07-18 14:40 +- [x] `engine-scene-serialization.md` (extensions / additive-conflict note, §666–700) — read 2026-07-18 14:35; **re-read expanded (3-form invariant a/b/c, §695–706)** 2026-07-18 23:20 +- [x] `etch-reference-part2.md` (§30.3–30.5, conflits multi-instances) — read 2026-07-18 14:38; **re-read expanded (§30.4–30.5, 3-form invariant, runtime a/b vs c)** 2026-07-18 23:22 +- [x] `etch-diagnostics.md` (§18.3 prefab range E1790–E1799, W1791→E1797 reclassification note) — read 2026-07-18 14:40; **re-read expanded (E1797 row = 3-form invariant, message distinct par forme)** 2026-07-18 23:24 - [x] `engine-zig-conventions.md` (Zig 0.16.x conventions — prior working knowledge, spot-verified) — read 2026-07-18 14:42 ## Execution log @@ -122,6 +122,19 @@ All in `tests/scene/extensions_test.zig` unless noted. The existing block that r - Gate end: `grep -rn "W1791" src/ tests/ tools/` empty; `grep -rniE "last.?extension.?wins…" src/ tests/` empty. `test-extensions` 19/19 pass; full `zig build test` green in Debug **and** ReleaseSafe; `zig fmt --check` + lint green. - **G3 — CLAUDE.md + closing audits + close.** `CLAUDE.md`: current-state row (HF4 mention + active-branch = the hotfix branch); +1 Hotfixes-table row `M1.1.1-HF4` (untagged, `(pending merge)`); the extension-conflict Open decision rewritten `RESOLVED (M1.1.1-HF4)` — reject ratified, `E1797`; `Last updated` already 2026-07-18. Language audit clean (no FR in code; the only accented strings are the brief FROZEN "Specs to read" list quoting French spec section titles verbatim — Claude.ai content). Drift audit clean: no orphaned `W1791` / `last-extension-wins` / warning infra in `src/`/`tests/`/`tools/`; residual mentions are all accurate historical records (the M1.0.18 tag row + M1.0.18/HF3 briefs) or the new HF4 documentation. +### Post-G3 correction (Codex review — invariant was over-declared) + +A post-G3 Codex review established that the ratified invariant is **base + active extensions conflict-free** (three forms a/b/c), but the merged G1–G3 work covered only form (a) (two extensions declaring the same component) — leaving two more cooked-but-unloadable paths open and one accessor-contract violation. The expanded specs (re-read above) were re-uploaded to define b/c + the widened `E1797`. Corrected in flight before merge (G4/G5), zero-deferred-debt. See Recorded deviations. + +- **G4 — cook gate completion + robustness (`scene_cook.zig` `detectExtensionConflicts`).** + - **Form (b) + unification:** `counts` is seeded with the entity's BASE component names (`eb.comp_ids` → `registry.componentName`) at 1 each BEFORE walking extensions; the `if (ext_len < 2) return` early-out is removed (a lone extension can conflict with base). One unified count now covers (a) ext-vs-ext AND (b) ext-vs-base — a name reaching 2 is fatal. `eb.comp_ids` threaded into the signature from the caller. + - **Form (c):** a repeated extension name (`seen.found_existing`) now `fail`s (distinct static "listed more than once" message) instead of `continue` — the silent dedup masked a cooked-but-unloadable case (`ExtensionAlreadyActive` at load). + - **P1#3 (HF3/R1 accessor contract):** `validate.structure(acc.bytes, acc.header)` runs after `verifyHash()` and BEFORE any getter; failure → `continue` (best-effort). Closes the panic on a malformed-but-rehashed prefab. `validate` imported from `weld_core.scene`. + - **Minor:** the `BaseResolver` doc comment "additive-conflict warning (§30.5)" → "additive-conflict check (fatal E1797)". + - **Messages:** `E1797` stays a static `fail`, one message per form — a/b share "component carried by more than one active declarant", c = "extension listed more than once". No ownership added to `diag_out`. + - **Tests (+3):** form (b) base-vs-extension fatal; form (c) duplicate-name fatal; P1#3 malformed-but-rehashed extension (bloated `schema_count` @20, outside the hashed region → data hash stays valid) is SKIPPED, cook does not panic and succeeds. Forging the fixture in-test was practical (header-only mutation, no rehash needed) — no STOP. + - Gate end: `grep W1791` / `last-extension-wins` empty; `test-extensions` 22/22; full `zig build test` green Debug **and** ReleaseSafe; `zig fmt --check` + lint green. + ## Recorded deviations - **None from the brief scope.** All removals/additions were within the brief's "delete … all plumbing" + "rewrite the conflict block" mandate. In-scope implementation consequences (not deviations): `detectExtensionConflicts` shed its now-unused `entity_name`/`span` params (the static message names the constraint, not the entity — per brief Notes); the `token` import and the `ext_stash` test fixture became orphaned by the warning-infra removal and were deleted; `MultiResolver.ext()` was added as contract-test plumbing. From 2dcc4e13fd91b165aeed2d0431880ead687d0450 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 19 Jul 2026 00:15:54 +0200 Subject: [PATCH 09/11] fix(etch): make duplicate-extension conflict resolver-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Form (c) (the same extension listed twice) was gated behind `base_resolver orelse return`, so `cook` — which always passes a null resolver — let `extensions: ["X","X"]` cook and then fail at load with `ExtensionAlreadyActive`. Form (c) is a pure lexical check and must not depend on the resolver. - detectExtensionConflicts split into two passes: pass 1 dedups the extension names (fatal E1797 on the first duplicate) with NO resolver; then the `base_resolver orelse return` guard; then pass 2 (base seed + extension schemas → forms a/b) with the resolver. - widen two lagging code comments to the full 3-form invariant (diagnostics enum + CookError.ExtensionAdditiveConflict doc). - test: cook(null resolver) on a duplicate extension → E1797 (the path the resolver-gated form-(c) test could not reach). --- src/etch/diagnostics.zig | 2 +- src/etch/scene_cook.zig | 78 ++++++++++++++++++++------------- tests/scene/extensions_test.zig | 18 ++++++++ 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 968c238..5a8f20b 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -361,7 +361,7 @@ pub const DiagnosticCode = enum { prefab_component_field_type_invalid, // M0.8 E7 — E1795 PrefabComponentFieldTypeInvalid prefab_component_redefined, // M0.8 E7 — E1796 PrefabComponentRedefined (RESERVED: variant/base component-shape merge is M0.9 runtime) prefab_remove_base_component, // M0.8 E7 — W1790 PrefabRemoveBaseComponent (RESERVED: no `remove` syntax in the §24.1 grammar) - extension_additive_conflict, // M1.0.18 → M1.1.1-HF4 — E1797 ExtensionAdditiveConflict (fatal cook error: ≥2 active extensions on an entity declare the same component; strictly-additive `extends` → reject, guaranteeing `cooked ⇒ loadable`; runtime backstop `error.ExtensionComponentConflict`) + extension_additive_conflict, // M1.0.18 → M1.1.1-HF4 — E1797 ExtensionAdditiveConflict (fatal cook error, strictly-additive `extends` → reject: (a) two extensions declare the same component, (b) an extension declares a component already carried by the base/an earlier extension, (c) the same extension is listed twice; guarantees `cooked ⇒ loadable`; runtime backstops `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c)) // ── async / effects (E09xx, M1.0.11 — etch-resolver-types.md §9.2) ── async_call_in_non_async_context, // M1.0.11 E4 — E0901 AsyncCallInNonAsyncContext (async fn/method call, or `await`, in a non-async fn/rule) diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index eef94d3..08f4c71 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -129,12 +129,15 @@ pub const CookError = error{ /// An `Entity` field references an entity name absent from the scene (M1.0.6 /// E4 — intra-scene only; cross-scene references are a future milestone). UnresolvedCrossRef, - /// Two or more active extensions on one entity's `extensions:` clause declare - /// the same component (M1.1.1-HF4 — `E1797 ExtensionAdditiveConflict`). The - /// `extends` model is strictly additive: a shared component is rejected, never - /// resolved by list order. Fatal cook error → guarantees `cooked ⇒ loadable` - /// (the runtime backstop is `error.ExtensionComponentConflict`). See - /// `engine-scene-serialization.md` (extension additive conflicts). + /// The entity's {base components} ∪ {active extensions' components} is not + /// conflict-free (M1.1.1-HF4 — `E1797 ExtensionAdditiveConflict`). Three + /// rejected forms: (a) two extensions declare the same component; (b) an + /// extension declares a component already carried by the base (or an earlier + /// extension); (c) the same extension is listed twice. The `extends` model is + /// strictly additive — a conflict is rejected, never resolved by list order. + /// Fatal cook error → guarantees `cooked ⇒ loadable` (runtime backstops: + /// `error.ExtensionComponentConflict` for a/b, `error.ExtensionAlreadyActive` + /// for c). See `engine-scene-serialization.md` (extension additive conflicts). ExtensionAdditiveConflict, OutOfMemory, }; @@ -528,16 +531,18 @@ const Builder = struct { /// for c). Short-circuits on the FIRST conflict (no enumeration); the /// duplicate-extension form carries a distinct static message. /// - /// Forms (a) and (b) are UNIFIED: `counts` is seeded with the entity's BASE - /// components at 1 each, then each extension's components are counted; a name - /// reaching 2 is a conflict whether the earlier declarant was the base seed or - /// another extension. Extension component sets are resolved through the SAME - /// prefab path as `of`/`extends` (`base_resolver` → `.prefab.bin` → validated - /// accessor schema names). Best-effort: an extension whose name does not - /// resolve, whose bytes do not open, whose hash mismatches, or whose structure - /// is invalid is SKIPPED (the by-name clause; the runtime reject is the - /// invariant backstop for the resolver-less path). No `ext_len < 2` early-out — - /// a single extension can conflict with the base. + /// Structure (two passes): form (c) is a PURE LEXICAL pass over the extension + /// names, independent of any resolver — it fires even when `base_resolver` is + /// null (the public `cook` entry), so a duplicate never cooks into a load-time + /// `ExtensionAlreadyActive`. Forms (a)/(b) need each extension's component set + /// (resolved through the SAME prefab path as `of`/`extends`), so they run only + /// when a resolver is present and are UNIFIED by seeding `counts` with the + /// entity's BASE components at 1 each: a component name reaching 2 is a conflict + /// whether the earlier declarant was the base seed or another extension. No + /// `ext_len < 2` early-out — a single extension can conflict with the base. + /// Best-effort for a/b: an extension whose name does not resolve, whose bytes do + /// not open, whose hash mismatches, or whose structure is invalid is SKIPPED + /// (the runtime reject is the invariant backstop for the resolver-less path). /// See `engine-scene-serialization.md` (extension additive conflicts). fn detectExtensionConflicts( self: *Builder, @@ -547,26 +552,37 @@ const Builder = struct { base_resolver: ?BaseResolver, diag_out: ?*[]const u8, ) CookError!void { + // Pass 1 — form (c): a PURE LEXICAL check, no resolver needed. The same + // extension listed twice is a fatal conflict; running BEFORE the resolver + // guard means `cook` (always a null resolver) still rejects it rather than + // cooking a scene that fails at load with `ExtensionAlreadyActive`. + { + var seen: std.StringHashMapUnmanaged(void) = .empty; + defer seen.deinit(self.gpa); + var k: u32 = 0; + while (k < ext_len) : (k += 1) { + const ext_name = self.ast.strings.slice(self.ast.scene_extensions.items[ext_start + k]); + const gop = try seen.getOrPut(self.gpa, ext_name); + if (gop.found_existing) return fail(diag_out, error.ExtensionAdditiveConflict, "the same extension is listed more than once in an entity's `extensions:` clause; the `extends` model is strictly additive — reject (E1797 ExtensionAdditiveConflict)"); + } + } + + // Forms (a)/(b) need the extensions' component sets (the prefab path). Without + // a resolver they cannot be checked here; the runtime reject is the backstop. const resolver = base_resolver orelse return; - // component name → number of DISTINCT active declarants (the base counts as - // one). Seeding the base at 1 (form b) makes the FIRST extension re-declaring - // a base component reach 2 = conflict. Base names borrow the registry (owned, - // live for the whole cook); extension names borrow the resolver-owned bytes. + // Pass 2 — forms (a) ext-vs-ext and (b) ext-vs-base, UNIFIED: seed the base + // components at 1 each, then count each extension's components; a name + // reaching 2 is fatal. Extension names are unique here (pass 1 guaranteed it). + // Base names borrow the registry (owned, live for the whole cook); extension + // names borrow the resolver-owned bytes. var counts: std.StringHashMapUnmanaged(u32) = .empty; defer counts.deinit(self.gpa); for (base_comp_ids) |id| try counts.put(self.gpa, self.registry.componentName(id), 1); - // Dedup the extension names: the same extension listed twice is form (c). - var seen: std.StringHashMapUnmanaged(void) = .empty; - defer seen.deinit(self.gpa); - var k: u32 = 0; while (k < ext_len) : (k += 1) { const ext_name = self.ast.strings.slice(self.ast.scene_extensions.items[ext_start + k]); - const seen_gop = try seen.getOrPut(self.gpa, ext_name); - if (seen_gop.found_existing) return fail(diag_out, error.ExtensionAdditiveConflict, "the same extension is listed more than once in an entity's `extensions:` clause; the `extends` model is strictly additive — reject (E1797 ExtensionAdditiveConflict)"); - const bytes = resolver.resolve(ext_name) orelse continue; var acc = accessor.Accessor.open(bytes) catch continue; if (!acc.verifyHash()) continue; @@ -576,10 +592,10 @@ const Builder = struct { validate.structure(acc.bytes, acc.header) catch continue; // Each component the extension declares (its `.prefab.bin` schema table; - // names are unique per prefab) bumps that name's distinct-declarant - // count. Reaching 2 is the conflict — form (a) if the prior declarant was - // another extension, form (b) if it was the base seed — a fatal cook - // error, short-circuiting on the first. Keys borrow the accessor bytes + // names unique per prefab) bumps that name's distinct-declarant count. + // Reaching 2 is the conflict — form (a) if the prior declarant was another + // extension, form (b) if it was the base seed — a fatal cook error, + // short-circuiting on the first. Keys borrow the accessor bytes // (resolver-owned, live for the whole cook) and compare by content. var ci: u32 = 0; while (ci < acc.schemaCount()) : (ci += 1) { diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index a65d2aa..c4a8bd2 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -490,6 +490,24 @@ test "cook fails when the same extension is listed twice (form c)" { try std.testing.expectError(error.ExtensionAdditiveConflict, scene_cook.cookScene(gpa, src, mr.base(), null)); } +test "cook without a resolver still fails on a duplicate extension (form c)" { + const gpa = std.testing.allocator; + // `cook` always passes a null base_resolver. Form (c) is a PURE LEXICAL check + // and MUST fire even with no resolver — otherwise the scene cooks and fails at + // load with `error.ExtensionAlreadyActive`. The extension name need not resolve. + const src = + \\component Marker { v: i32 = 0 } + \\scene "S" { + \\ entity "npc" { + \\ uuid: "00000000-0000-0000-0000-0000000000f1" + \\ extensions: ["CombatModule", "CombatModule"] + \\ Marker { v: 1 } + \\ } + \\} + ; + try std.testing.expectError(error.ExtensionAdditiveConflict, scene_cook.cook(gpa, src, null)); +} + test "cook skips a structurally-invalid but data-hash-correct extension (no panic)" { const gpa = std.testing.allocator; const orig = try prefabBytes(gpa, ext_merchant); // valid MerchantModule From bbd78d9f32330482fc5bcb09a902311ec957b0d8 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 19 Jul 2026 00:15:55 +0200 Subject: [PATCH 10/11] docs(brief): log m1.1.1-hf4 G4b --- briefs/m1.1.1-hf4-extension-conflict-reject.md | 1 + 1 file changed, 1 insertion(+) diff --git a/briefs/m1.1.1-hf4-extension-conflict-reject.md b/briefs/m1.1.1-hf4-extension-conflict-reject.md index 93c98af..1402094 100644 --- a/briefs/m1.1.1-hf4-extension-conflict-reject.md +++ b/briefs/m1.1.1-hf4-extension-conflict-reject.md @@ -134,6 +134,7 @@ A post-G3 Codex review established that the ratified invariant is **base + activ - **Messages:** `E1797` stays a static `fail`, one message per form — a/b share "component carried by more than one active declarant", c = "extension listed more than once". No ownership added to `diag_out`. - **Tests (+3):** form (b) base-vs-extension fatal; form (c) duplicate-name fatal; P1#3 malformed-but-rehashed extension (bloated `schema_count` @20, outside the hashed region → data hash stays valid) is SKIPPED, cook does not panic and succeeds. Forging the fixture in-test was practical (header-only mutation, no rehash needed) — no STOP. - Gate end: `grep W1791` / `last-extension-wins` empty; `test-extensions` 22/22; full `zig build test` green Debug **and** ReleaseSafe; `zig fmt --check` + lint green. +- **G4b — form (c) is resolver-independent (2nd Codex review).** The review found form (c) was gated behind `base_resolver orelse return`, so `cook` (always a null resolver) let `extensions: ["X","X"]` cook and then fail at load (`ExtensionAlreadyActive`). Form (c) is a pure lexical check — it must not depend on the resolver. `detectExtensionConflicts` restructured into two passes: **pass 1** dedups the extension names (fatal E1797 on the first duplicate) with NO resolver; then `base_resolver orelse return`; then **pass 2** (base seed + extension schemas, forms a/b) with the resolver. P3: two code comments widened to the full 3-form invariant (`diagnostics.zig` enum `extension_additive_conflict`; `scene_cook.zig` `CookError.ExtensionAdditiveConflict`) — the KB already described all three, the comments lagged. Test +1: `cook(gpa, src, null)` on `["X","X"]` → `error.ExtensionAdditiveConflict` (the null-resolver path the resolver-gated test could not reach). Gate end: `grep W1791` / `last-extension-wins` empty; `test-extensions` 23/23; full `zig build test` green Debug **and** ReleaseSafe; `zig fmt --check` + lint green. ## Recorded deviations From 3df2b8b36c0ac7f9b84ae337af6fe34ef63b5fde Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 19 Jul 2026 00:24:15 +0200 Subject: [PATCH 11/11] docs: correct m1.1.1-hf4 invariant docs to three forms Post-review documentation close. The ratified invariant is "base + active extensions conflict-free" (three forms a/b/c); the docs from G1-G3 described only the two-extension case (form a). Aligns docs + drift-audit remediation. - CLAUDE.md: HF4 Hotfixes row + the extension-conflict Open decision rewritten to the full a/b/c invariant (widened E1797, two-pass detector, runtime ExtensionComponentConflict a/b + ExtensionAlreadyActive c); Last updated. - brief: Recorded deviations logs BOTH Codex rounds (1st: forms b/c + accessor contract R1 unspecified; 2nd: form c under null resolver + comment drift); Closing notes refreshed (best-effort scoped to a/b, form c always fatal, 23/23). - drift remediation (comment-only, zero logic): the test section header and the loader deactivate doc widened from the two-extension phrasing to "base or extension" declarants. --- CLAUDE.md | 6 +++--- briefs/m1.1.1-hf4-extension-conflict-reject.md | 13 ++++++++----- src/core/scene/loader.zig | 4 ++-- tests/scene/extensions_test.zig | 8 +++++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b3f1677..d8df4da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, | `M1.1.1-HF1` | 2026-07-13 | `50086b1` | Standalone hotfix (external Codex review, 7/7 defects verified on `main` then fixed; zero-deferred-debt). D3/D4 **reserve-then-mutate** (new invariant name): `EntityIdentityStore.release` + `Registry.release`/`freeSlot` reserve free-list capacity before any mutation — on OOM, no observable mutation, call retryable (FailingAllocator tests). D7: `World.despawn` purges the entity's `entity_extensions` entry (owned names freed at despawn, not stranded until `deinit`). D6: `RuntimeHeader.validate(bin_len)` (version + u64-widened section bounds) + data-hash check in `Loader.readBin`; `LoadError += MalformedAsset`; `WELD_ASSET_PIPELINE_PROTOCOL_VERSION` 1→2 (tracked pinned-set migration). D5: `sendWithHandles` flushes a short `sendmsg` via the shared `writeAll` loop (fds ride the first segment; remainder carries no ancillary; `n == 0` → `BrokenPipe`); POSIX only, Windows stays `Unimplemented` (Phase 3). D1: loaded resource strings are **refcounted** persistent blocks owned by their `StringSlot` (ecs_bridge read-old/write-new/decref-old discipline); `LoadResult.resource_strings` removed (teardown parity with interp-written strings). D2: `loadFromBytes` is loader-transactional — per-resource journal (pre-write snapshot; null = fresh), commit decrefs replaced old blocks, committed-guarded errdefer rolls back in **LIFO** (decref new + restore/remove; reverse-order despawn, D7 purges extensions); LIFO order corrected at E4 review (forward undo corrupted the refcount on duplicate same-cid entries — regression test pinned). Contract excludes user-hook side effects; best-effort under OOM. | | `M1.1.1-HF2` | 2026-07-16 | `b54115b` | Standalone hotfix (second external Codex review on post-HF1 `main`; 6 fixable defects verified then fixed, 1 documented non-defect — zero-deferred-debt). C1: `EntityIdentityStore.allocate` maintains `free_indices.capacity >= slots.len` (fresh path reserves via `ensureTotalCapacity(slots.len + 1)` BEFORE growing — B1 corrected the frozen `ensureUnusedCapacity(gpa, 1)`, which froze capacity at 1); `release` is infallible `void` (`appendAssumeCapacity`), the three `spawn*` errdefers drop their swallowing `catch {}`, `despawn` drops its `try` — an allocated handle is always releasable without allocation. C4: Tier-0 `World.releaseResourcePayloads(gpa)` owns the uniform resource-payload decref walk (strings + collections, via registry `FieldKind`), idempotent by slot zeroing; called from `Interpreter.deinit` BEFORE destroying immortal `persistent_literals` (ordering is load-bearing — reversal is a UAF) and from `World.deinit` before `resources.deinit`; the `bridge.resources`-specific walk is removed — bare worlds and out-of-bridge resources no longer leak. C2: scene `instantiate` pre-reserves `spawned` + `uuid_to_entity` to accessor totals, per-entity inserts are assume-capacity — no fallible insert after a spawn, no orphan outside the rollback (exhaustive fail-index sweep test); entity uuid ordinals validated (out-of-range and duplicate → MalformedScene) before dereference, and the entity total is guarded against u32 overflow — C2b. C6: `ResourceEdit.dirty_before` captured pre-`getMutResource`, restored on rollback via `ResourceStore.setDirty` (pub Tier-0-internal seam) — a rejected load leaves no spurious `when resource T changed`. C3: asset `Loader.finish` reserves the payload-map slot BEFORE `allocWithUuid`; `putAssumeCapacity` post-alloc; the fragile `unload catch {}` unwind is gone. C5: IPC posix `writeAll` sends via `send(MSG_NOSIGNAL)` (EINTR/n==0 semantics verbatim) + `SO_NOSIGPIPE` at fd creation on macOS — peer-closed sends error instead of killing the process. C7 (non-defect): `Broadphase.Proxy` without generation packing stays as designed (documented tradeoff, internal ids); a one-line spec guard lands at the M1.1.x integration milestone that first consumes proxy ids. Review process fix applied: every gate crossed the reserve-then-mutate invariant against ALL composite call surfaces, not each leaf in isolation. | | `M1.1.1-HF3` | (pending merge) | (pending merge) | Standalone hotfix (external Codex reviews #3 AND #4; review #3 on post-HF2 `main` — 9 findings, 8 fixable verified, 1 partially obsolete — gates E1–E6; review #4 on the E1–E6 branch pre-merge — 6 further findings R10–R15, all verified — gates E7–E8; zero-deferred-debt; review #3's scope was itself counter-reviewed before freeze). R1+R10+R11: standalone `src/core/scene/validate.zig` — full structural validation of `.scene.bin` BEFORE any accessor exposure (section order/bounds, string/schema/uuid/crossref refs, column geometry, resource `data_size == schemaSize` (R10 — the loader memcpys behind a ReleaseFast-stripped assert), string-field offsets bounded, schema indices per archetype STRICTLY increasing (R11, normative "sorted ascending"; covers extension prefabs via the shared `openVerified`)), all arithmetic checked; `buildSchemaRemap` rejects two schema entries resolving to one runtime `ComponentId`; seeded-mutation test (10 000 hash-fixed corruptions) walks EVERY accessor getter on validation survivors (E1 review STOP: survivors were initially not getter-exercised). R3+R15: PNG `max_dimension = 16384` cap, checked size math, exact expected inflated size precomputed from IHDR (Adam7 pass table shared budget↔walk), `inflate`/`zlib.decompress` gain `max_out` (single `appendByte` choke point) + strict output-length equality; inflated `raw` freed the instant deinterlacing completes — peak residency ~2×(w×h×4)+IDAT, not ~3× (R15; a configurable byte budget was REVIEWED AND REJECTED for the hotfix — new API surface; the cap + exact budget already bound the footprint). R4+R13: glTF bounds-checked `accessorAt`/`viewAt`/`bufferAt`, semantic accessor validation with the Khronos-conformant interleaved span `byteOffset + stride×(count−1) + elem` (R13 — `count×stride` falsely rejected valid interleaved buffers), declared `type` checked (`VEC2`/`VEC3`/`SCALAR`) before any buffer read, `indices < vertex_count`; `MalformedGltf`. R6+R12: grouped Tier-0 `World.addComponentsDynamic` + `prepareRemoveComponentsDynamic`/`commitRemoveComponentsDynamic`/`abortRemoveComponentsDynamic` (ONE migration; ALL fallible work precedes the first observable mutation; real duplicate/present checks — `DuplicateComponent`/`UnknownComponent` — replace a phantom assert, R11(c)); `activateExtension` prevalidate → reserve → single-fallible-step → infallible commit → `on_attach` (commits BEFORE the hook, HF1/D2 contract) + `ExtensionAlreadyActive` on re-activation (R12(d), closes the hook-only re-fire); `deactivateExtension` prevalidate → PREPARE → `on_detach` (errdefer abort) → infallible commit (R12 — after the hook succeeds, not one fallible step remains; OOM-sweep test with a hook-call counter proves `on_detach` fires exactly once); strict mono-entity cardinality shared by both paths (`extEntityArchetype`); the FABRICATED "§30.5 reject policy" comments removed — reject is the PROVISIONAL runtime policy recorded in the HF3 brief; last-wins runtime semantics = open design surface (see Open decisions). R2: `chmod 0600` post-bind hard-fail + fail-closed peer-UID check on every `accept` (`SO_PEERCRED`/`getpeereid`) — the authoritative boundary closing the bind→chmod TOCTOU; socket path stays spec-pinned in `/tmp`; umask-000 test (serialized, restored; Linux mode read via `statx` — `std.c.Stat` is void on Linux). R5+R14: `command_encoder` — `destroy` waits idle unconditionally and FREES the command buffer (the "pool reset handles it" claim was false — monotonic pool growth closed) and `create` gains the missing post-allocate `errdefer` (R14); errdefers on `createView`/`createRender`/`createCompute`/debug messenger; swapchain `create` unified native-cleanup errdefer (`views_created` + `registered`) fixing the mid-loop AND the `swapchain_owned` post-adoption native leaks; `deinit` destroys views before images. R8: `createRender` attachment guard (mirror of `render_pass.begin`) + `max_color_attachments` device-limit check. R7: `reload` validates `asset_type` vs `handle.type_tag` before any payload mutation → `AssetTypeMismatch`. R9: `retain` guards saturated `u32` → `ReferenceCountOverflow`; generation-wrap slots PERMANENTLY retired (bounded documented leak; `AssetHandle` `u64` layout FROZEN C0.5, generation stays `u16`). `WELD_ASSET_PIPELINE_PROTOCOL_VERSION` 2→3 (single bump covering both pinned-set widenings). Review-process note: review #4 caught two E1-review misses (resource `data_size`, the normative sorted-indices line) and one frozen-brief contract weaker than achievable (fallible-but-atomic post-hook remove) — the corrected contract is the prepare/commit split. Residual risks recorded in the brief: glTF attribute-count consistency unconstrained (no in-tree consumer zips), one-shot server accept after `PeerCredentialMismatch` (reachable only in the TOCTOU microsecond). CI hardening (E9/E9b, post-review-#4 Windows failures): the park test is now deterministic (two-phase proof — Σ parks_entered > Σ parks_completed establishes a live park, then a completed increase proves the wake; new stats-only `parks_entered` counter incremented under the park-path lock, propagated to WorkerStats/Snapshot/reset/dump with the `completed <= entered` invariant); watchdog coverage extended to EVERY Scheduler-starting test INCLUDING teardown (`no_alloc_steady_state*` armed the global watchdog around `Scheduler.deinit()` — the prior local wrapper only covered dispatch loops); the windows-2025/ReleaseSafe hang (4 consecutive pre-E9b occurrences) did not recur on the post-E9b merge run — unexplained, instrumented, first recurrence self-names via the dump. | -| `M1.1.1-HF4` | (pending merge) | (pending merge) | Standalone hotfix — ratify **reject** as the normative additive-extension-conflict policy (dedicated spec session; `engine-scene-serialization.md` / `etch-reference-part2.md §30.5` / `etch-diagnostics.md §18.3` corrected). A static conflict (≥2 active extensions declaring the same component on one entity) is now a **fatal cook error** `E1797 ExtensionAdditiveConflict` — reclassified from the non-fatal M1.0.18 warning whose sole reader was a test, so the cook silently produced `.scene.bin` files the loader then refused to load. `detectExtensionConflicts` now `fail`s on the FIRST conflicting component (static message, short-circuit); the dead warning infra is removed (`Warning` struct, `Cooked.warnings`, `Builder.warnings`, `emitAdditiveConflict`, and all plumbing incl. the now-unused `token` import). Runtime `error.ExtensionComponentConflict` (+ `ExtensionAlreadyActive`) UNCHANGED — no logic change to the loader activate/deactivate paths; only their conflict-policy comments normalized (normative, cite `engine-scene-serialization.md`). Establishes the `cooked ⇒ loadable` invariant. Tests: 4 cook (fatal / disjoint-OK / fail-on-first / `cooked ⇒ loadable` contract) + a runtime dynamic-reject. Closes the extension-conflict Open decision (reject ratified — see Open decisions). `@exclusive_with` stays a future unimplemented escape hatch. | +| `M1.1.1-HF4` | (pending merge) | (pending merge) | Standalone hotfix (dedicated spec session + two follow-up Codex reviews) — ratify **reject** as the normative additive-extension-conflict policy (`engine-scene-serialization.md` / `etch-reference-part2.md §30.4–30.5` / `etch-diagnostics.md §18.3` corrected). The invariant is **{base components} ∪ {active extensions' components} conflict-free**; three rejected forms: (a) two extensions declare the same component; (b) an extension declares a component already carried by the base (or an earlier extension); (c) the same extension is listed twice. A static conflict is a **fatal cook error** `E1797 ExtensionAdditiveConflict` (widened from the two-extension-only case; distinct static message per form) — reclassified from the non-fatal M1.0.18 warning whose sole reader was a test, so the cook silently produced `.scene.bin` files the loader then refused to load. `detectExtensionConflicts` runs two passes: pass 1 = a resolver-independent lexical dedup of the extension names (form c, so `cook`'s null-resolver path still rejects `["X","X"]`); pass 2 = base-seeded component counting over each resolved extension's schema (forms a/b), guarded by `validate.structure` before any accessor getter (P1#3 — no panic on a malformed-but-rehashed prefab). The dead warning infra is removed (`Warning` struct, `Cooked.warnings`, `Builder.warnings`, `emitAdditiveConflict`, the now-unused `token` import). Runtime UNCHANGED — `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c); only the loader's conflict-policy comments normalized (normative, cite `engine-scene-serialization.md`). Establishes the `cooked ⇒ loadable` invariant. Tests: 8 cook (the three fatal forms incl. a resolver-less duplicate, disjoint-OK, fail-on-first, `cooked ⇒ loadable` contract, malformed-but-rehashed no-panic) + a runtime dynamic-reject. Closes the extension-conflict Open decision. `@exclusive_with` stays a future unimplemented escape hatch. | ## Hypotheses validated by spikes @@ -114,7 +114,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **M1.1.0 scope boundary (Forge 3D foundations)**: `src/interfaces/PhysicsModule.zig` + the `PhysicsModule(Impl)` comptime wrapper + `core.ModuleContext` are DEFERRED to the milestone that wires forge_3d as a stepping module — instantiating the wrapper needs `ModuleContext`, whose spec carries an unresolved Tier-0→Tier-1 reference (`asset_loader: *AssetLoader`), and the repo precedent (RenderModule, C0.5) froze a module-root namespace, not a `src/interfaces/` file: the location ruling belongs to the interface-landing milestone. The day-1 contract is carried by the `api/` descriptor types (mirrored verbatim); when the interface file lands, declarations move there and `api/` re-exports — zero call sites. `Velocity` stays a core component (`api/` re-exports; moving it would invert core→Tier-1). `Mat4` and a math-level `Transform` pose type: excluded, purely additive (first consumers later). Shapes beyond sphere/box/capsule: `error.UnsupportedShape` until their sub-milestones (pre-freeze, additive). Descriptor validation policy (typed errors on degenerate mass/geometry) is a later milestone — M1.1.0 guards the dynamic path with a `mass > 0` debug assert only. Pending KB spec patch on `engine-tier-interfaces.md` §1 (ShapeType set, damping default, `foundation.math`, union-form `ShapeDescriptor`), produced at milestone close. - **M1.1.1 scope boundary (broadphase dynamic multi-layer BVH)**: `pipeline/broadphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`; the scalar is the comptime parameter `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports), and `user_data` is an opaque `u32` (forge_3d passes the packed `BodyId`). Determinism by construction (anticipates M1.1.14): NO hash containers anywhere — index pool + LIFO free-list, tree shape a pure function of the op sequence, pairs sorted by packed `u64 (min<<32)|max` + adjacent-deduped; SAH/balance ties fixed. `insert` realizes the brief's schematic `insert(aabb, user_data) u32` as `insert(gpa, tight_aabb, user_data) !u32` (unmanaged-first convention `§3`, `BodyManager.addBody` precedent) — a realization, not a scope change (Guy-confirmed, no Recorded deviation). `computePairs` is **moved-driven** and consumes (clears) the per-layer moved-logs; a stale moved id (freed, not reused) is skipped via `isLiveLeaf`; `user_data` must be unique across all layers (documented on `Broadphase.insert`). The `default_layer_pairs` matrix is a `const`; an overridable `CollisionConfig` matrix is a LATER milestone (the field `Body.collision_layer` is stored now but nothing consumes it — object-layer pair filtering unwired). **OUT (not gaps, additive on the same structure):** raycast/shapecast tree traversal (M1.1.9–10), velocity-based AABB prediction / speculative expansion (M1.1.5), trigger/debris `BodyDescriptor` layer-assignment sources (M1.1.13 — only `body_type`-derived static/dynamic exercised now), `PhysicsWorld`/stepping/`ModuleContext`/`PhysicsModule` instantiation (M1.1.15), parallel/GPU broadphase (Phase 4+), narrowphase. **File-length note:** `broadphase.zig` is 686 lines (over the 500-line Review guideline) — the brief mandates `Bvh(T)` + `Broadphase(T)` + `BroadphaseLayer` + `BroadphaseConfig` + pair generation in this single file; conscious brief-driven overage, not lint-enforced. **E0 directory flatten** removed the `solvers_3d/` wrapper (`solvers_3d/forge_3d/` → `forge_3d/`); `engine-directory-structure.md` §9.1 and `engine-tier-interfaces.md` were pre-patched to the flat layout; the stale `solvers_{2d,3d}/` reference in `engine-zig-conventions.md §14` (KB, out of repo) remains for a KB reconciliation. - **M1.1.2 scope boundary (narrowphase GJK convex detection)**: `pipeline/narrowphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`/`broadphase`; the scalar is the comptime `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports). GJK runs on the convex **cores** (point/segment/box) + an inflation radius (Jolt convex-radius architecture); touch = `dist(cores) ≤ r_a + r_b`, `separated` iff the core distance exceeds `r_a + r_b` beyond an ABSOLUTE noise-scaled contact margin `conv_k·floatEps(T)·coordScale` (exact touch → `shallow`, face-inclusive convention). Distance-based (NOT boolean) GJK: the simplex stores `(w, support_a, support_b)` triplets so closest points on A/B reconstruct from the barycentrics; the Voronoi simplex solver is Ericson (not Johnson, not signed-volumes). Determinism by construction (anticipates M1.1.14): no hash containers, no trig (dot/cross only), `max_gjk_iterations = 32`, named relative progress tolerance, fixed support tie-breaks; the search direction is NEVER normalized (squared distances, one `sqrt`); computation frozen in the frame of A (`rot_rel`/`pos_rel` precompute, conjugate-rotate never `inverse()`) — changing the frame after M1.1.14 would break bit-exactness. `GjkResult.deep` carries the terminal origin-enclosing simplex as the EPA seed — no depth/normal/manifold here. The forge_3d-side adapters (`shape.supportShape`, `BodyManager.gjkPair`) own the `Shape → SupportShape` conversion + the `BodyId`-level entry, mirroring the broadphase `user_data` discipline. **Classification robustness (six post-delivery correction cycles, Guy-directed — RESOLVES the E3 absolute-`deep_eps`, the Fix 3 vertex-relative residual, the P1/P2 remaining geometric dependencies, the P1b accumulated-rounding under-dimensioning, the P1c A/B-order asymmetry, AND the P1d anisotropic-tetra false-degeneracy; the extreme-aspect radius-0 box `.deep` residual is DOCUMENTED and deferred to M1.1.4/M1.1.3):** GUIDING PRINCIPLE — no `.deep`/`.shallow`/`.separated` decision depends on a GEOMETRIC quantity (radius, shape size, `w`-vertex magnitude); `.deep` = geometric enclosure; every remaining threshold is `k·floatEps(T)·coordScale` (coordScale = absolute support magnitude, `@sqrt(maxSupportMagSq)`), absorbing only float rounding noise. (a) `.deep` = a non-degenerate tetra enclosing the origin (`count==4`, degeneracy tested DIMENSIONLESSLY `|bary_det|² ≤ deg_rel²·(|b−a|²·|c−a|²·|d−a|²)` = squared normalized volume, aspect-ratio-invariant — P1d replaced the `maxEdgeSq³` normalization that rejected valid anisotropic tetra and read a sharp box's interior `.separated`), NO distance threshold on that path; (a2, Fix 3b→P2) the sole distance early-out (`degenerateOriginReached`) is a numerical-NOISE floor `closest·closest ≤ mach_eps²·maxSupportMagSq` (scaled by the ABSOLUTE support magnitude), with `mach_eps = noise_k·floatEps(T)` (P2 recalibrated from the 8×-eps `1e-6` literal — which was a visible geometric threshold, ~8.7e-5 at half-extent 50 — down to `noise_k`≈2 ULPs); the earlier vertex-relative `rel_deep²·maxVertexMag²` (Fix 3) reproduced the bug for large shapes and was removed; (b) an anti-cycling duplicate-support guard kills degenerate Minkowski tetrahedra at the source; (c, Fix 4→P1) `.separated iff dist − r_sum > conv_k·floatEps(T)·coordScale` — an ABSOLUTE additive margin (P1 removed the `r_sum·(1+contact_rel)` form, whose `contact_rel·r_sum` slack — 0.1 m at r_sum 1000 — was a collision margin beyond the core radius, out of scope; it mis-classified two r=500 spheres 5 cm apart as `.shallow`), honoring the frozen touch=shallow rule via the convergence-noise margin; (c2, P1b) `conv_k` = 16, not 2 — 2 ULP under-dimensioned the ACCUMULATED pipeline rounding (quaternion + Voronoi tetra + sqrt), so an exact tangency (Codex case: `dist − r_sum` = 7.15e-7 ≈ 2.1 ULP relative, zero convergence residue) tipped `.separated`; 16 ULP bounds it and stays at the noise level (the deep noise floor keeps `noise_k = 2`, a distinct point-noise floor — aligning them recreates a P1b/P2 false positive). Scale-robust, verified at O(1), half-extents 50–500, and at the f32 noise floor (~9 orders tighter in f64 — an f32 fundamental limit, not a geometric margin); a measure-zero EXACT tangency can still, in rare pathological configs (~1/20000 in sweep), tip `.separated` by one ULP — irreducible float limit, corrected next frame by penetration, no absolute guarantee claimed. (c3, P1c) the contact margin's `coord_scale` is SYMMETRIC by construction — `|pos_b − pos_a| + coreExtent(a) + coreExtent(b)` (`coreExtent` = the core's max local support magnitude, radius excluded: point→0, segment→half_height, box→`|half_extents|`) — replacing the A-frame terminal-support magnitude `@sqrt(maxSupportMagSq)`, which was frame-dependent and made one tangency classify `.separated` in one A/B order and `.shallow` in the other (a structural defect, not calibration). `.deep`/`.shallow`/`.separated` is therefore invariant under an A/B swap by construction, asserted by `gjk classification is order-independent`; `maxSupportMagSq` stays (anti-cycling guard + deep noise floor — `w`-magnitude, a distinct quantity). **File-length note:** `narrowphase.zig` is 726 lines (over the 500 Review guideline) — the brief mandates the whole GJK stack (support shapes + simplex solver + loop + result) in this single file (mirror of `broadphase.zig` at 686); conscious brief-driven overage. **OUT (not gaps; additive/later):** EPA + penetration depth + contact normal + manifold (M1.1.3 — in particular do NOT compute `depth = r_a+r_b−dist` or a normal for `.shallow`); pair-type fast paths ss/sb/cc/bb (M1.1.4); speculative-contact thresholds / margins beyond the core radius / box convex radius (box radius 0 here); `collision_layer`/`CollisionConfig` matrix wiring; stepping/`PhysicsWorld`/`PhysicsModule` instantiation (M1.1.15); raycast/shapecast/point queries (M1.1.9–10); shapes beyond sphere/box/capsule (`SupportShape.Core` stays additively extensible); batched/SIMD pair processing; `forge_2d` (M1.8); robust `.deep` for radius-0 box cores of EXTREME aspect ratio (>~50:1) — a GJK-f32 limit on sharp cores (tetra degeneracy at extreme anisotropy + premature anti-cycling termination before the enclosure tetra forms), reliable to ~30:1, deferred to M1.1.4 analytic box fast paths / M1.1.3 EPA (P1d). -- **Extension-conflict semantics (last-wins vs reject) — RESOLVED (M1.1.1-HF4)**: the dedicated post-HF3 spec session ratified **reject** as the normative, permanent policy for additive extension conflicts (two active extensions declaring the same component on one entity), superseding the former "last-extension-wins" prose. Static conflict → **fatal cook error** `E1797 ExtensionAdditiveConflict` (no output; `weld check` fails in CI), guaranteeing `cooked ⇒ loadable`; dynamic activation adding an already-present component → runtime `error.ExtensionComponentConflict` (atomic — entity unchanged) + `ExtensionAlreadyActive` on re-activation. Specs corrected (`engine-scene-serialization.md` additive-conflict note, `etch-reference-part2.md §30.3–30.5`, `etch-diagnostics.md §18.3` + the W1791→E1797 reclassification note); code comments now cite `engine-scene-serialization.md` (the authority), not the HF3 brief. Deactivation needs no provenance — the reject invariant means no two active extensions ever share a component. Alternative examined and rejected in the session: last-wins-with-provenance (masking + shadowed-byte side table + out-of-order deactivate + `.sav` shadow-stack serialization — ≈5–10× the code, touches the ECS reserve-then-mutate invariant and the serialization format, undoes the HF3 deactivate simplification — to support a design error). `@exclusive_with` remains a future, unimplemented escape hatch. +- **Extension-conflict semantics (last-wins vs reject) — RESOLVED (M1.1.1-HF4)**: the dedicated post-HF3 spec session ratified **reject** as the normative, permanent policy for additive extension conflicts, superseding the former "last-extension-wins" prose. The invariant is **{base components} ∪ {active extensions' components} conflict-free**; three rejected forms: (a) two extensions declare the same component; (b) an extension declares a component already carried by the base (or an earlier extension); (c) the same extension is listed twice. Static conflict → **fatal cook error** `E1797 ExtensionAdditiveConflict` (all three forms, distinct static message; no output; `weld check` fails in CI), guaranteeing `cooked ⇒ loadable`; dynamic activation → runtime `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c), atomic (entity unchanged). Specs corrected (`engine-scene-serialization.md` additive-conflict note §695–706, `etch-reference-part2.md §30.4–30.5`, `etch-diagnostics.md §18.3` E1797 row + the W1791→E1797 reclassification note); code comments cite `engine-scene-serialization.md` (the authority), not the HF3 brief. Deactivation needs no provenance — the reject invariant means no two active declarants ever share a component. Cook detection: form (c) is a resolver-independent lexical pass (so the public `cook` null-resolver path rejects duplicates); forms (a)/(b) need the prefab resolver and are `validate.structure`-guarded before any accessor getter. Alternative examined and rejected in the session: last-wins-with-provenance (masking + shadowed-byte side table + out-of-order deactivate + `.sav` shadow-stack serialization — ≈5–10× the code, touches the ECS reserve-then-mutate invariant and the serialization format, undoes the HF3 deactivate simplification — to support a design error). `@exclusive_with` remains a future, unimplemented escape hatch. ## Non-negotiable rules @@ -248,4 +248,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-07-18 +Last updated: 2026-07-19 diff --git a/briefs/m1.1.1-hf4-extension-conflict-reject.md b/briefs/m1.1.1-hf4-extension-conflict-reject.md index 1402094..89a0512 100644 --- a/briefs/m1.1.1-hf4-extension-conflict-reject.md +++ b/briefs/m1.1.1-hf4-extension-conflict-reject.md @@ -138,7 +138,10 @@ A post-G3 Codex review established that the ratified invariant is **base + activ ## Recorded deviations -- **None from the brief scope.** All removals/additions were within the brief's "delete … all plumbing" + "rewrite the conflict block" mandate. In-scope implementation consequences (not deviations): `detectExtensionConflicts` shed its now-unused `entity_name`/`span` params (the static message names the constraint, not the entity — per brief Notes); the `token` import and the `ext_stash` test fixture became orphaned by the warning-infra removal and were deleted; `MultiResolver.ext()` was added as contract-test plumbing. +- **Scope extended in flight by two relayed prompts (G4, then G4b + G5), after the merged-into-PR G1–G3 shipped an OVER-DECLARED invariant.** The FROZEN acceptance criteria specified only **one** of the three `cooked ⇒ loadable` paths — form (a), two extensions declaring the same component — and G1–G3 (PR #55) implemented exactly that, while the brief's Context/Scope already claimed the full "cooked ⇒ loadable" invariant. Two Codex reviews on the open PR closed the gap before merge (no re-opening of a merged brief; zero-deferred-debt): + - **1st review (post-G3):** the ratified invariant is **base + active extensions conflict-free** (forms a/b/c), but form (b) (extension vs base) and form (c) (duplicate extension) were unspecified in the acceptance criteria, and the HF3/R1 accessor contract (validate structure before any getter) was not honored in the detector — a malformed-but-rehashed prefab could panic on a schema read. The three expanded specs were re-uploaded to define b/c + the widened `E1797`. → **G4**: base-seeded unified count (forms a/b), fatal duplicate (c), `validate.structure` guard (P1#3), +3 tests. + - **2nd review (post-G4):** form (c) was gated behind `base_resolver orelse return`, so the public `cook` (always a null resolver) let `["X","X"]` cook and fail only at load (`ExtensionAlreadyActive`); and two code comments still described the two-extension-only case. → **G4b**: `detectExtensionConflicts` split into a resolver-independent lexical pass 1 (form c) + a resolver-gated pass 2 (forms a/b); two comments widened; +1 null-resolver test. +- **In-scope implementation consequences (not deviations):** `detectExtensionConflicts` shed its now-unused `entity_name`/`span` params (the static message names the constraint, not the entity — per brief Notes); the `token` import and the `ext_stash` test fixture became orphaned by the warning-infra removal and were deleted; `MultiResolver.ext()` was added as contract-test plumbing; `eb.comp_ids` threaded into the detector signature (G4). ## Blockers encountered @@ -146,8 +149,8 @@ A post-G3 Codex review established that the ratified invariant is **base + activ ## Closing notes -- **What worked:** the gate-by-gate STOP+GO cadence; specs were found already patched locally (no round-trip); the atomic G2 (reclassify + fatalize + infra removal + test rewrite in one commit) kept the tree compilable and green throughout, avoiding a red intermediate state. -- **What deviated from the original spec:** nothing material — see Recorded deviations. The static cook message intentionally drops the entity/component names (brief Out-of-scope: `diag_out` carries a non-owned static message, no ownership/free path), a knowing trade vs the old dynamic `allocPrint` warning. -- **What to flag explicitly in review:** (1) cook conflict detection stays **best-effort** — it no-ops without a `base_resolver` or when an extension name does not resolve; the runtime `error.ExtensionComponentConflict` is the invariant backstop for the resolver-less path (both are "reject", consistent). (2) The M1.0.18 tag row + the M1.0.18/HF3 briefs intentionally retain "W1791"/"last-extension-wins" wording — accurate historical records, not orphans (the grep gate scopes to `src/ tests/ tools/`). (3) `loader.zig` conflict-policy edits are comment-only — zero logic change (reject was already implemented correctly in HF3). -- **Final measurements:** none (no hot path touched). `zig build test` green Debug + ReleaseSafe; `test-extensions` 19/19; pre-push gate ×2 green on both G1 and G2 pushes. +- **What worked:** the gate-by-gate STOP+GO cadence; specs were found already patched locally (no round-trip); the atomic G2 (reclassify + fatalize + infra removal + test rewrite in one commit) kept the tree compilable and green throughout, avoiding a red intermediate state; the same cadence absorbed the two review-driven corrections (G4, G4b) cleanly, each landing green before the next. +- **What deviated from the original spec:** the merged G1–G3 covered only form (a) of a three-form invariant the brief's prose already claimed in full — the acceptance criteria were the gap. Corrected in flight before merge over two Codex reviews (G4 forms b/c + accessor guard; G4b form-c resolver independence). See Recorded deviations. The static cook message intentionally drops the entity/component names (brief Out-of-scope: `diag_out` carries a non-owned static message, no ownership/free path), a knowing trade vs the old dynamic `allocPrint` warning. +- **What to flag explicitly in review:** (1) forms (a)/(b) detection is **best-effort** — pass 2 no-ops without a `base_resolver` or when an extension name does not resolve / fails `validate.structure`; the runtime `error.ExtensionComponentConflict` is the invariant backstop there. **Form (c) is NOT best-effort** — it is a resolver-independent lexical pass, fatal even under `cook`'s null resolver. (2) The M1.0.18 tag row + the M1.0.18/HF3 briefs intentionally retain "W1791"/"last-extension-wins" wording — accurate historical records, not orphans (the grep gate scopes to `src/ tests/ tools/`). (3) `loader.zig` conflict-policy edits are comment-only — zero logic change (reject was already implemented correctly in HF3). (4) form-(c) precedence: `["X","X"]` where X also conflicts with base is reported as form (c) (pass 1 runs first) — acceptable, both fatal E1797. +- **Final measurements:** none (no hot path touched). `zig build test` green Debug + ReleaseSafe at every gate; `test-extensions` 23/23 at close (4 G2 cook + 3 G4 + 1 G4b + 1 runtime-reject, over the extensions suite's 23 total); pre-push gate ×2 green on all pushes (G1–G4b). - **Residual risks / tech debt left intentionally:** `@exclusive_with` remains a future, unimplemented escape hatch (referenced in the specs, out of scope). The two accented strings in the brief's FROZEN "Specs to read first" list quote French spec section titles verbatim (Claude.ai-authored; not editable by Claude Code outside a round-trip). diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 7497bf5..bdb1988 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -645,8 +645,8 @@ pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, /// just fires `on_detach` then drops the record.) /// /// Conflict policy: reject-on-conflict (see `activateExtension`) keeps the -/// component set unambiguous — no two active extensions share a component — so -/// removal needs no provenance tracking. Normative; see +/// component set unambiguous — no two active declarants (base or extension) share a +/// component — so removal needs no provenance tracking. Normative; see /// `engine-scene-serialization.md` (extension additive conflicts). pub fn deactivateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void { if (!world.hasEntityExtension(entity, name)) return error.ExtensionNotActive; diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index c4a8bd2..a0b5cc4 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -328,11 +328,13 @@ const ext_arsenal = // ArsenalModule: declares Weapon only // The `extensions:` clause + additive-conflict gate run through the SAME scene // `build` loop for `entity` and `instance` — these tests use `entity`. A conflict -// (≥2 active extensions declaring the same component) is a FATAL cook error +// — the entity's base + active extensions not conflict-free: (a) two extensions +// declare the same component, (b) an extension re-declares a base/earlier-extension +// component, or (c) the same extension is listed twice — is a FATAL cook error // (`E1797 ExtensionAdditiveConflict` → `error.ExtensionAdditiveConflict`), the // strictly-additive `extends` reject policy (M1.1.1-HF4). Disjoint components cook -// cleanly. Together with the runtime `error.ExtensionComponentConflict` this -// guarantees `cooked ⇒ loadable`. +// cleanly. Together with the runtime rejects (`error.ExtensionComponentConflict` +// for a/b, `error.ExtensionAlreadyActive` for c) this guarantees `cooked ⇒ loadable`. test "cook fails fatally when two extensions declare the same component" { const gpa = std.testing.allocator;