From 5bc0b345b26a62efc7cc62ce0d6d32f36a81f9cf Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 9 Aug 2026 14:00:41 -0400 Subject: [PATCH] PM-82: root-aware texture resolution, LDConfig colour RGB propagation Epic: PM-86 Task: PM-82 --- .current-epic/deferrals-C.jsonl | 4 - .current-epic/deferrals.jsonl | 0 .current-epic/dependencies.dot | 77 -- .current-epic/findings-A-impl.jsonl | 8 - .current-epic/findings-A.jsonl | 10 - .current-epic/findings-B-impl.jsonl | 10 - .current-epic/findings-B.jsonl | 8 - .current-epic/findings-C-impl.jsonl | 6 - .current-epic/findings-C.jsonl | 10 - .current-epic/findings-D-impl.jsonl | 12 - .current-epic/findings-D-impl2.jsonl | 6 - .current-epic/findings-D.jsonl | 12 - .current-epic/findings-E-impl.jsonl | 7 - .current-epic/findings-E.jsonl | 88 -- .current-epic/findings-G-impl.jsonl | 6 - .current-epic/findings-G.jsonl | 10 - .current-epic/handoff.md | 67 - .current-epic/ldparse-architecture.md | 87 -- .current-epic/manifest.txt | 22 - .current-epic/merge-gate.json | 5 - .current-epic/plan-A.md | 210 --- .current-epic/plan-B.md | 326 ----- .current-epic/plan-C.md | 335 ----- .current-epic/plan-D.md | 300 ----- .current-epic/plan-E.md | 388 ------ .current-epic/plan-F.md | 243 ---- .current-epic/plan-G.md | 237 ---- .current-epic/plan-L.md | 151 --- .current-epic/root-cause-ldparse-fiction.md | 44 - .current-epic/task-A.md | 44 - .current-epic/task-B.md | 61 - .current-epic/task-C.md | 54 - .current-epic/task-D.md | 43 - .current-epic/task-E.md | 35 - .current-epic/task-F.md | 39 - .current-epic/task-G.md | 35 - .current-epic/task-H.md | 34 - .current-epic/task-I.md | 34 - .current-epic/task-J.md | 36 - .current-epic/task-K.md | 34 - .current-epic/task-L.md | 37 - .current-epic/task-state.json | 215 --- .current-epic/workflow.dot | 72 - .gitignore | 4 + .pm78-core-test-status | 1 - Cargo.lock | 6 - crates/polymodel-ldraw-core/Cargo.toml | 1 - crates/polymodel-ldraw-core/src/adapter.rs | 13 +- crates/polymodel-ldraw-core/src/geom.rs | 7 +- crates/polymodel-ldraw-core/src/lib.rs | 10 +- crates/polymodel-ldraw-core/src/model.rs | 15 +- crates/polymodel-ldraw-core/src/parser.rs | 53 +- crates/polymodel-ldraw-core/src/texmap.rs | 5 - crates/polymodel-ldraw-core/src/traversal.rs | 6 +- crates/polymodel-ldraw-core/src/types.rs | 123 +- crates/polymodel-ldraw-core/src/util.rs | 3 - crates/polymodel-renderer-ledger/Cargo.toml | 8 - crates/polymodel-renderer-ledger/README.md | 34 - crates/polymodel-renderer-ledger/src/lib.rs | 1176 ----------------- .../polymodel-renderer-protocol/src/scene.rs | 3 + crates/polymodel-renderer-worker/Cargo.toml | 1 - .../src/admission.rs | 5 - .../src/bin/ldraw_preview.rs | 4 +- crates/polymodel-renderer-worker/src/lib.rs | 182 +-- crates/polymodel-renderer-worker/src/tests.rs | 56 +- findings-J.jsonl | 6 - justfile | 2 +- result | 1 - 68 files changed, 180 insertions(+), 5007 deletions(-) delete mode 100644 .current-epic/deferrals-C.jsonl delete mode 100644 .current-epic/deferrals.jsonl delete mode 100644 .current-epic/dependencies.dot delete mode 100644 .current-epic/findings-A-impl.jsonl delete mode 100644 .current-epic/findings-A.jsonl delete mode 100644 .current-epic/findings-B-impl.jsonl delete mode 100644 .current-epic/findings-B.jsonl delete mode 100644 .current-epic/findings-C-impl.jsonl delete mode 100644 .current-epic/findings-C.jsonl delete mode 100644 .current-epic/findings-D-impl.jsonl delete mode 100644 .current-epic/findings-D-impl2.jsonl delete mode 100644 .current-epic/findings-D.jsonl delete mode 100644 .current-epic/findings-E-impl.jsonl delete mode 100644 .current-epic/findings-E.jsonl delete mode 100644 .current-epic/findings-G-impl.jsonl delete mode 100644 .current-epic/findings-G.jsonl delete mode 100644 .current-epic/handoff.md delete mode 100644 .current-epic/ldparse-architecture.md delete mode 100644 .current-epic/manifest.txt delete mode 100644 .current-epic/merge-gate.json delete mode 100644 .current-epic/plan-A.md delete mode 100644 .current-epic/plan-B.md delete mode 100644 .current-epic/plan-C.md delete mode 100644 .current-epic/plan-D.md delete mode 100644 .current-epic/plan-E.md delete mode 100644 .current-epic/plan-F.md delete mode 100644 .current-epic/plan-G.md delete mode 100644 .current-epic/plan-L.md delete mode 100644 .current-epic/root-cause-ldparse-fiction.md delete mode 100644 .current-epic/task-A.md delete mode 100644 .current-epic/task-B.md delete mode 100644 .current-epic/task-C.md delete mode 100644 .current-epic/task-D.md delete mode 100644 .current-epic/task-E.md delete mode 100644 .current-epic/task-F.md delete mode 100644 .current-epic/task-G.md delete mode 100644 .current-epic/task-H.md delete mode 100644 .current-epic/task-I.md delete mode 100644 .current-epic/task-J.md delete mode 100644 .current-epic/task-K.md delete mode 100644 .current-epic/task-L.md delete mode 100644 .current-epic/task-state.json delete mode 100644 .current-epic/workflow.dot delete mode 100644 .pm78-core-test-status delete mode 100644 crates/polymodel-ldraw-core/src/util.rs delete mode 100644 crates/polymodel-renderer-ledger/Cargo.toml delete mode 100644 crates/polymodel-renderer-ledger/README.md delete mode 100644 crates/polymodel-renderer-ledger/src/lib.rs delete mode 100644 findings-J.jsonl delete mode 120000 result diff --git a/.current-epic/deferrals-C.jsonl b/.current-epic/deferrals-C.jsonl deleted file mode 100644 index 89a71f9..0000000 --- a/.current-epic/deferrals-C.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"schema_version":1,"deferral_id":"PM-86-D002","created_at":"2026-08-01T22:00:00Z","scope":"C","jira_key":"PM-76","kind":"accepted-risk","severity":"high","summary":"No per-chunk blob CID multihash verification — only whole-file SHA-256 is checked","reason":"CID verification requires multihash decoding library not currently in dependency tree. Whole-file SHA-256 provides corruption detection; CID adds substitution protection.","operator_decision":"auto-accepted-continuation","skeptic_approved":false,"skeptic_verdict":"Real gap but infrastructure works. SHA-256 catches corruption. CID verification is defense-in-depth.","follow_up":"Add per-chunk CID multihash verification before release","status":"open"} -{"schema_version":1,"deferral_id":"PM-86-D003","created_at":"2026-08-01T22:00:00Z","scope":"C","jira_key":"PM-76","kind":"accepted-risk","severity":"high","summary":"Fetch endpoint unauthenticated — token replay and cross-principal use possible","reason":"Fetch uses opaque token from DB row, not OAuth session. Token has expiry but no consumption/revocation after use.","operator_decision":"auto-accepted-continuation","skeptic_approved":false,"skeptic_verdict":"Security gap. Token expiry mitigates but doesn't prevent replay within window.","follow_up":"Add OAuth session extraction to fetch handler, consume tokens after use","status":"open"} -{"schema_version":1,"deferral_id":"PM-86-D004","created_at":"2026-08-01T22:00:00Z","scope":"C","jira_key":"PM-76","kind":"scope-deferral","severity":"medium","summary":"Official-library ingestion and legacy migration are schema-only placeholders — tables exist but no code writes to them","reason":"Requires pinned snapshot acquisition (blocked by LDParse source availability) and legacy data classification logic","operator_decision":"auto-accepted-continuation","skeptic_approved":false,"follow_up":"Implement official snapshot loader and legacy conversion in a follow-up task","status":"open"} -{"schema_version":1,"deferral_id":"PM-86-D005","created_at":"2026-08-01T22:00:00Z","scope":"C","jira_key":"PM-76","kind":"scope-deferral","severity":"medium","summary":"Canonical root policy incomplete — no s/8/48 prefix handling or subroot containment enforcement","reason":"Full root policy requires detailed path-prefix parsing per PM-75 §3.3. Basic precedence ordering works for common cases.","operator_decision":"auto-accepted-continuation","skeptic_approved":false,"follow_up":"Implement explicit-root isolation and subroot containment","status":"open"} diff --git a/.current-epic/deferrals.jsonl b/.current-epic/deferrals.jsonl deleted file mode 100644 index e69de29..0000000 diff --git a/.current-epic/dependencies.dot b/.current-epic/dependencies.dot deleted file mode 100644 index 6e88278..0000000 --- a/.current-epic/dependencies.dot +++ /dev/null @@ -1,77 +0,0 @@ -digraph epic_dependencies { - rankdir=TB; - node [fontname="Helvetica", shape=box]; - - A [label="A: PM-87\nViewer load/readiness\nstate machine"]; - B [label="B: PM-88\nReservation ledger\nand hard budgets"]; - C [label="C: PM-76\nLDraw library projection\nand ATProto manifest"]; - D [label="D: PM-77\nLDraw corpus and\ncompatibility harness"]; - E [label="E: PM-78\nRust LDraw\nsemantic core"]; - F [label="F: PM-79\nScene IR and\nLDraw vertical slice"]; - G [label="G: PM-80\nCompound resolver and\nversioned worker protocol"]; - H [label="H: PM-81\nPoints, surfaces, edges,\nconditional lines"]; - I [label="I: PM-82\nTEXMAP and embedded\ntexture pipeline"]; - J [label="J: PM-83\nFormat registry and\npublishing/viewer integration"]; - K [label="K: PM-84\nInteroperability, fuzz,\nperformance, visual baselines"]; - L [label="L: PM-90\nCorrect LDraw face lighting\nand top/bottom brightness"]; - M [label="M: PM-91\nClose-range depth corruption\nand camera clipping precision"]; - N [label="N: PM-92\nCached part-edge\nselection highlight"]; - - // A (PM-87) blocks: F, G, J - A -> F; - A -> G; - A -> J; - - // B (PM-88) blocks: E, F, G, H, I, K - B -> E; - B -> F; - B -> G; - B -> H; - B -> I; - B -> K; - - // C (PM-76) blocks: G, J - C -> G; - C -> J; - - // D (PM-77) blocks: E, F, K - D -> E; - D -> F; - D -> K; - - // E (PM-78) blocks: F, H, K - E -> F; - E -> H; - E -> K; - - // F (PM-79) blocks: H, J, K - F -> H; - F -> J; - F -> K; - - // G (PM-80) blocks: H, J, K - G -> H; - G -> J; - G -> K; - - // H (PM-81) blocks: I, J, K - H -> I; - H -> J; - H -> K; - - // I (PM-82) blocks: K - I -> K; - - // J (PM-83) blocks: K - J -> K; - - // PM-90 and PM-91 are operator-approved dynamic tasks discovered during PM-79 visual validation. - F -> L; - F -> M; - L -> M; - F -> N; - - // K (PM-84) blocks: none - // L (PM-90) blocks: none - // M (PM-91) blocks: none -} diff --git a/.current-epic/findings-A-impl.jsonl b/.current-epic/findings-A-impl.jsonl deleted file mode 100644 index 6d00337..0000000 --- a/.current-epic/findings-A-impl.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"schema_version":1,"finding_id":"A-impl-1-C1","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer B/C","severity":"critical","status":"open","summary":"Worker emits spurious LoadCancelled with NEW identity on every StartLoad — main reducer cancels the load it just started; viewer can NEVER reach Ready","evidence":["crates/polymodel-renderer-worker/src/worker.rs:540-554","src/viewer.rs:1242-1258"],"recommendation":"Only emit cancellation/supersession for actual prior loads with OLD identity. Or emit proper Superseded(old) event. Remove the unconditional LoadCancelled after StartLoad."} -{"schema_version":1,"finding_id":"A-impl-1-H1","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Install failures leave main-thread reducer permanently Installing — LoadFailed only accepted from Loading, not Installing","evidence":["crates/polymodel-renderer-worker/src/worker.rs:590-628","crates/polymodel-renderer-protocol/src/load_state.rs:238-244"],"recommendation":"Accept LoadFailed/InstallFailed from both Loading and Installing states, or add InstallFailed wire event"} -{"schema_version":1,"finding_id":"A-impl-1-H2","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Missing required worker browser tests — no race/format coverage (install-vs-cancel, install-vs-supersede, STL/OBJ/glTF/3MF install identity)","evidence":["crates/polymodel-renderer-worker/src/worker.rs:246-348"],"recommendation":"Add wasm_bindgen_test browser tests for load lifecycle, format coverage, and race conditions"} -{"schema_version":1,"finding_id":"A-impl-1-H3","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer C/D","severity":"high","status":"open","summary":"Missing viewer host reducer tests — no test drives production event handler through LoadSucceeded/InstallSucceeded/LoadFailed to verify status mapping","evidence":["src/viewer.rs:1699-1831"],"recommendation":"Add tests instantiating LoadReducer, feeding wire events, asserting Loading→Installing→Ready mapping and should_have_session for all states"} -{"schema_version":1,"finding_id":"A-impl-1-H4","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Protocol tests don't cover required race-order matrix — no install-first→cancel/supersede/dispose/error tests","evidence":["crates/polymodel-renderer-protocol/src/load_state.rs:370-468"],"recommendation":"Add both-order race tests: install-first→cancel, install-first→supersede, install-first→dispose, success-vs-error, Ready→Cancel, resize during Installing"} -{"schema_version":1,"finding_id":"A-impl-1-H5","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Playwright switching tests aren't deterministic — don't intercept/delay/reorder requests","evidence":["e2e/tests/viewer.spec.ts:73-90"],"recommendation":"Add request interception for deterministic race testing"} -{"schema_version":1,"finding_id":"A-impl-1-M1","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer B/C","severity":"medium","status":"open","summary":"No Superseded wire event — supersession publishes no separate status; LoadCancelled misused","evidence":["crates/polymodel-renderer-protocol/src/lib.rs:242-280"],"recommendation":"Add Superseded wire variant or document that supersession is purely reducer-internal"} -{"schema_version":1,"finding_id":"A-impl-1-M2","scope":"A","task_prefix":"A","phase":"implementation","generation":"implementor-A2","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"AssetViewer no-source path stuck in Loading — no terminal status","evidence":["src/viewer.rs:418-469"],"recommendation":"Add explicit terminal status or confirm no-source path is unreachable"} diff --git a/.current-epic/findings-A.jsonl b/.current-epic/findings-A.jsonl deleted file mode 100644 index 9d64b12..0000000 --- a/.current-epic/findings-A.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"schema_version":1,"finding_id":"A-plan-1-C1","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Loading(epoch) → StartLoad(newer) has no transition-table row, yet this is the headline race PM-87 exists to fix","evidence":["plan-A.md:70","plan-A.md:77"],"recommendation":"Add explicit row: Loading(epoch) | strictly-newer StartLoad → Commit Superseded(epoch), begin Loading(next_epoch), dispose in-flight resources. Supersession applies from ANY non-terminal state."} -{"schema_version":1,"finding_id":"A-plan-1-C2","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Duplicate identity-allocation authority: step 2.1 gives worker the allocator, step 3.3 has main thread issuing StartLoad with epoch+generation","evidence":["plan-A.md:106","plan-A.md:119"],"recommendation":"Pick one allocator (recommend main thread on StartLoad). State there are two reducer instances (worker=publication, main=status) and define convergence invariant. Remove allocator-ownership language from the mirror side."} -{"schema_version":1,"finding_id":"A-plan-1-F1","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer A","severity":"high","status":"open","summary":"should_have_session() policy for new states (Cancelled, Superseded, Disposed) is unspecified","evidence":["viewer.rs:263","plan-A.md:88"],"recommendation":"Add per-state session policy: Cancelled/Superseded/Failed retain session; Disposed does not."} -{"schema_version":1,"finding_id":"A-plan-1-F2","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Naming collision: RendererEvent::RendererReady (context ready) vs state-machine Ready(epoch,generation) (mesh installed)","evidence":["protocol lib.rs:246","plan-A.md:57"],"recommendation":"Rename or disambiguate. Document the distinction inline in the transition table."} -{"schema_version":1,"finding_id":"A-plan-1-F3","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Plan claims STL/OBJ/glTF/3MF e2e coverage but existing tests only exercise STL routes","evidence":["e2e/tests/viewer.spec.ts:5-7","plan-A.md:132"],"recommendation":"Add deterministic viewer fixtures for OBJ/glTF/3MF or map to worker browser tests with documented coverage."} -{"schema_version":1,"finding_id":"A-plan-1-H1","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Identity lifecycle inconsistent: StartLoad carries generation but Loading discards it; tuple ordering undefined","evidence":["plan-A.md:70","plan-A.md:71"],"recommendation":"Decide: generation allocated at StartLoad (Loading retains it) or at LoadSucceeded. Define (epoch,generation) ordering."} -{"schema_version":1,"finding_id":"A-plan-1-H2","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Ready → Cancel and Ready → newer StartLoad have no transition-table rows","evidence":["plan-A.md:73","plan-A.md:76"],"recommendation":"Add rows for Ready state transitions including Cancel and supersession."} -{"schema_version":1,"finding_id":"A-plan-1-M1","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer A","severity":"medium","status":"open","summary":"Step 3.1 'Remove paths that set Ready' risks removing entire use_resource effect including URL routing","evidence":["plan-A.md:117","viewer.rs:415-444"],"recommendation":"Clarify: remove only status.set(Ready) and mesh_stats.set; source routing and STEP conversion logic must stay."} -{"schema_version":1,"finding_id":"A-plan-1-M2","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer A","severity":"medium","status":"open","summary":"Test tier taxonomy (Tier 1/Tier 4) not defined in this repo","evidence":["plan-A.md:128-131"],"recommendation":"Inline-define the tiers or describe tests by runner name."} -{"schema_version":1,"finding_id":"A-plan-1-M3","scope":"A","task_prefix":"A","phase":"plan","generation":"planner-A1","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Disposed instance lifecycle vs SPA route re-entry unspecified","evidence":["plan-A.md:78"],"recommendation":"Specify that Disposed is terminal per instance; remount constructs fresh Idle instance."} diff --git a/.current-epic/findings-B-impl.jsonl b/.current-epic/findings-B-impl.jsonl deleted file mode 100644 index e11fd5e..0000000 --- a/.current-epic/findings-B-impl.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"schema_version":1,"finding_id":"B-impl-1-C1","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"check_policy compares global aggregate totals against per-owner limits — two independent owners can't use their per-owner entitlements","evidence":["crates/polymodel-renderer-ledger/src/lib.rs:845","crates/polymodel-renderer-ledger/src/lib.rs:851"],"recommendation":"Compare global totals against global limits, per-owner totals against per-owner limits. Add per-owner domain accounting."} -{"schema_version":1,"finding_id":"B-impl-1-C2","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"init_renderer acquires WebGL context and posts RendererReady BEFORE canvas reservation; .ok() silently swallows admission failure","evidence":["crates/polymodel-renderer-worker/src/worker.rs:215-240"],"recommendation":"Reserve canvas BEFORE get_context_with_context_options. On rejection, leave canvas/renderer unset and emit Error."} -{"schema_version":1,"finding_id":"B-impl-1-C3","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"64 MiB per-texture decoded-pixel ceiling entirely absent; no per-texture limit check","evidence":["crates/polymodel-renderer-ledger/src/lib.rs:175-186"],"recommendation":"Add 64 MiB per-texture class ceiling for DecodedTexturePixels. Enforce in reserve_texture."} -{"schema_version":1,"finding_id":"B-impl-1-C4","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"Network/retained/decoded charges are fixed 8 MiB placeholders, not actual sizes; fetch reads entire response unbounded","evidence":["crates/polymodel-renderer-worker/src/worker.rs:616-628","crates/polymodel-renderer-worker/src/worker.rs:927-963"],"recommendation":"Use actual byte length or safe upper bound. Enforce charged amount during fetch."} -{"schema_version":1,"finding_id":"B-impl-1-H1","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Texture decode path (DecodedTexturePixels) never reserved in worker; reserve_texture/texture_base_bytes never called","evidence":["crates/polymodel-renderer-worker/src/worker.rs build_scene"],"recommendation":"Wire texture charge computation before three_d::Model::new using TextureChargeInput"} -{"schema_version":1,"finding_id":"B-impl-1-H2","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Scene/GPU admission uses fixed 16 MiB/8 MiB placeholders instead of real computed charges","evidence":["crates/polymodel-renderer-worker/src/worker.rs:66-71","crates/polymodel-renderer-worker/src/worker.rs:676-693"],"recommendation":"Use actual geometry/texture sizes or safe checked upper bounds"} -{"schema_version":1,"finding_id":"B-impl-1-H3","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"all","severity":"high","status":"open","summary":"Browser-backed allocation-admission tests entirely absent — zero admission/rejection tests added","evidence":["crates/polymodel-renderer-worker/src/worker.rs:314-416"],"recommendation":"Add wasm_bindgen_test browser tests proving rejection prevents real operations at canvas/context/scene/GPU/resize/preview boundaries"} -{"schema_version":1,"finding_id":"B-impl-1-H4","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Concurrency test is false-green — permits all 8 reservations to fail, only asserts total <= global_limit, never checks exact admission count","evidence":["crates/polymodel-renderer-ledger/src/lib.rs:1021"],"recommendation":"Assert expected number of successful admissions, exact total equals live handles, ledger returns to zero after drops"} -{"schema_version":1,"finding_id":"B-impl-1-H5","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Deterministic rejection ordering not implemented — early-return chains produce single-element vectors, not complete same-level breach sets","evidence":["crates/polymodel-renderer-ledger/src/lib.rs:845-887"],"recommendation":"Collect all simultaneously-breached ceilings at each level, sort by stable enum order"} -{"schema_version":1,"finding_id":"B-impl-1-M1","scope":"B","task_prefix":"B","phase":"implementation","generation":"implementor-B1","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"WASM charges are fixed 128 KiB fiction; real talc growth unobserved; no equivalent-enforcement test","evidence":["crates/polymodel-renderer-worker/src/worker.rs:629"],"recommendation":"Implement equivalent enforcement (reserve full configured maximum at init) or track real allocator growth"} diff --git a/.current-epic/findings-B.jsonl b/.current-epic/findings-B.jsonl deleted file mode 100644 index 8503d21..0000000 --- a/.current-epic/findings-B.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"schema_version":1,"finding_id":"B-plan-2-F1","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Frozen PM-75 class ceilings not specified - plan only lists domain/unified, not per-class ceilings (128/256 MiB network, 256/384 MiB decoded/semantic, 384/512 MiB scene CPU, 256/384+64 MiB texture, etc.)","evidence":["plan-B.md:70-80"],"recommendation":"Reproduce the complete class table from PM-75 §5.6 including per-texture 64 MiB ceiling. Add exact and limit+1 tests for every class-level ceiling."} -{"schema_version":1,"finding_id":"B-plan-2-F2","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"WASM/CPU accounting scope internally ambiguous: per worker vs per load/viewer vs hybrid 'worker/load aggregate'","evidence":["plan-B.md:74-80","plan-B.md:99","plan-B.md:119"],"recommendation":"Specify owner identity and bucket semantics for each class. State whether worker pages are one aggregate or partitioned per load. Add tests for two loads sharing one worker."} -{"schema_version":1,"finding_id":"B-plan-2-F3","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Pre-allocation acceptance proof reduced to abstract callback probe; no concrete WASM/GPU/canvas paths named","evidence":["plan-B.md:145-146","plan-B.md:170-175"],"recommendation":"Define three concrete test seams (WASM page growth, GPU buffer/texture, canvas resize). Prove reservation committed before allocation and rejection invokes none."} -{"schema_version":1,"finding_id":"B-plan-2-F4","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"A↔B load-identity coherence gap: no defined interface between A's (load_epoch, scene_generation) model and B's owner/load identity","evidence":["plan-B.md:97","plan-A.md:51"],"recommendation":"Define how reservation lifetimes bind to A's disposal outcomes. State whether preview loads create ephemeral reservation scopes."} -{"schema_version":1,"finding_id":"B-plan-2-F5","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Multi-class admission is non-atomic; all downstream tasks must implement undocumented caller-managed rollback","evidence":["plan-B.md:99"],"recommendation":"Add composite admission section: caller-orchestrated sequences, caller owns rollback, recommended sequencing pattern, test for multi-step admission with mid-sequence failure."} -{"schema_version":1,"finding_id":"B-plan-2-F6","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Texture base computation delegated to caller with no canonical contract - I and K will independently compute format bytes","evidence":["plan-B.md:90"],"recommendation":"Define typed TextureChargeInput struct (format, width, height, mip_count, layers, faces, samples) with pure-std base-byte computation or reference implementation with canonical test vectors."} -{"schema_version":1,"finding_id":"B-plan-2-F7","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer B","severity":"medium","status":"open","summary":"Merge-ready command list does not name the new focused just recipe or ledger cargo-tree check","evidence":["plan-B.md:53","plan-B.md:174-187"],"recommendation":"Give recipe exact name (e.g. just test-ledger), state if test-all invokes it, include exact leaf cargo tree command in handoff block."} -{"schema_version":1,"finding_id":"B-plan-2-F8","scope":"B","task_prefix":"B","phase":"plan","generation":"planner-B2","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Rejection reason semantics ambiguous: 'first/complete set' is self-contradictory","evidence":["plan-B.md:105"],"recommendation":"Specify deterministic ordering: report most specific (class) first, then domain, load, global, unified. Report all simultaneously-breached at each level."} diff --git a/.current-epic/findings-C-impl.jsonl b/.current-epic/findings-C-impl.jsonl deleted file mode 100644 index f03334b..0000000 --- a/.current-epic/findings-C-impl.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"schema_version":1,"finding_id":"C-impl-1-C1","scope":"C","task_prefix":"C","phase":"implementation","generation":"implementor-C3","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Verification state machine is dead code — claim/complete/source_changed never called outside unit tests; all resources stay unverified forever","evidence":["src/ldraw/verification.rs:49-103","src/indexing/projection.rs:735-744"],"recommendation":"Wire verification lifecycle: either firehose-triggered or lazy-on-first-mint claim, byte verification, and complete→verified transition"} -{"schema_version":1,"finding_id":"C-impl-1-C2","scope":"C","task_prefix":"C","phase":"implementation","generation":"implementor-C3","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Fetch endpoint is a permanent stub — always returns Err(internal('verified LDraw byte source is unavailable'))","evidence":["src/appview/ldraw.rs:107"],"recommendation":"Stream verified resource bytes from PDS blob source using existing downloads.rs streaming pattern"} -{"schema_version":1,"finding_id":"C-impl-1-C3","scope":"C","task_prefix":"C","phase":"implementation","generation":"implementor-C3","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"Canonical resolution does not implement precedence/collision policy — direct SQL lookup with no root ordering or ORDER BY","evidence":["src/ldraw/resolve.rs:124-138"],"recommendation":"Call resolve_precedence with root-ordered lookup; detect/reject ASCII-fold collisions"} -{"schema_version":1,"finding_id":"C-impl-1-C4","scope":"C","task_prefix":"C","phase":"implementation","generation":"implementor-C3","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"Private resource auth broken — fetch calls resolve(None,...) losing the stored principal; private resources can't be re-resolved","evidence":["src/appview/ldraw.rs:87-98"],"recommendation":"Pass stored principal_did from route token to resolver"} -{"schema_version":1,"finding_id":"C-impl-1-H1","scope":"C","task_prefix":"C","phase":"implementation","generation":"implementor-C3","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Three incompatible source_identity formats across verification, routes, and resolver","evidence":["src/indexing/projection.rs:905","src/appview/ldraw.rs:128","src/ldraw/resolve.rs:204"],"recommendation":"Define ONE SourceIdentity serialization used everywhere"} -{"schema_version":1,"finding_id":"C-impl-1-H2","scope":"C","task_prefix":"C","phase":"implementation","generation":"implementor-C3","reviewer":"Reviewer A","severity":"high","status":"open","summary":"getLdrawResource lexicon not picked up by codegen — route is hand-rolled, wasm client has no typed request","evidence":["lexicons/library/getLdrawResource.json","crates/polymodel-api/src/space_polymodel/library.rs"],"recommendation":"Investigate why codegen skipped it; run just generate-api or wire generated endpoint marker"} diff --git a/.current-epic/findings-C.jsonl b/.current-epic/findings-C.jsonl deleted file mode 100644 index 24712bf..0000000 --- a/.current-epic/findings-C.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"schema_version":1,"finding_id":"C-plan-1-F1","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Source-change does not explicitly demote verified→unverified; cache invalidation and verification-state transition disagree","evidence":["plan-C.md:130-131","plan-C.md step 6.4"],"recommendation":"Make source-change an explicit atomic transition verified|verifying→unverified with counter reset AND route/cache invalidation. Add test: verified resource receiving new revision must be unreadable until reverified."} -{"schema_version":1,"finding_id":"C-plan-1-F2","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Cache-identity tuple defined two different ways (3.5 vs 6.4) blocking G's partitioned atomic cache contract","evidence":["plan-C.md step 3.5","plan-C.md step 6.4"],"recommendation":"Define ONE CacheKey struct type in canonicalization module, referenced verbatim by route and resolver. Resolve whether principal/project is part of the key."} -{"schema_version":1,"finding_id":"C-plan-1-F3","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"No frozen service/interface contract for G (PM-80) to consume - boundary described as behavior, not named Rust type/trait/endpoint","evidence":["plan-C.md step 3.5","task-G.md"],"recommendation":"Add 'Consumption contract for PM-80' subsection freezing: resolver function signature, ResolvedResource struct, ResolveError variants, in-process trait vs HTTP route, CacheKey re-export location."} -{"schema_version":1,"finding_id":"C-plan-1-F4","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"getPartFile integrity-bypass gate leaves safer option optional; discriminator for LDraw vs legacy resources undefined","evidence":["plan-C.md step 7.3"],"recommendation":"Mandate hard-block: any AT-URI whose collection is PM-76 LDraw record type must 404/403 on getPartFile. Specify exact NSID discriminator and add router test."} -{"schema_version":1,"finding_id":"C-plan-1-F5","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Source identity based on record CID allows rejection-reset abuse: publisher repeatedly putRecord (same bytes) changes CID, resets counter indefinitely","evidence":["plan-C.md step 5.5","plan-C.md step 2.2"],"recommendation":"Define source identity as resource identity + content hash (blob-CID set / declared SHA-256), NOT raw record CID. Counter-reset triggers on content change only."} -{"schema_version":1,"finding_id":"C-plan-1-F6","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer A","severity":"high","status":"open","summary":"FK/cascade instruction contradicts codebase: SQLite has zero FOREIGN KEY clauses and PRAGMA foreign_keys is OFF and cannot be enabled in migration","evidence":["migrations/0001_initial_schema.sql:10-12","plan-C.md:95"],"recommendation":"Replace FK/cascade instruction with explicit application-level cascade in same projection transaction. Do not rely on PRAGMA foreign_keys."} -{"schema_version":1,"finding_id":"C-plan-1-F7","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Opaque-route token model conflates stateless signed token vs DB-backed stateful token","evidence":["plan-C.md step 2.5","plan-C.md step 6.2"],"recommendation":"Choose one: stateless with revocation-epoch, or stateful DB lookup per fetch (recommended)."} -{"schema_version":1,"finding_id":"C-plan-1-F8","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Verification trigger unspecified (lazy vs eager vs sweep) and concurrent-claim outcome undetermined","evidence":["plan-C.md step 5.3"],"recommendation":"Pin trigger (lazy on first read/mint) and choose coalesce or reject for concurrent claims. Specify error G receives mid-verification."} -{"schema_version":1,"finding_id":"C-plan-1-F9","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Blob-CID multihash per-chunk verification not specified; only whole-file SHA-256 against publisher-declared hash (circular for publisher resources)","evidence":["plan-C.md step 6.5"],"recommendation":"Add requirement: server verifies each chunk's blob CID multihash on fetch, layered under whole-file SHA-256."} -{"schema_version":1,"finding_id":"C-plan-1-F10","scope":"C","task_prefix":"C","phase":"plan","generation":"planner-C1","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Parallel-task migration-number collision hazard: A/B/C/D all parallel, plan-C says 'add migration' without pinning number","evidence":["migrations/ directory","plan-C.md step 2.1"],"recommendation":"Pin plan-C's migration number (e.g. 009_ldraw_resources.sql) or reserve range per task."} diff --git a/.current-epic/findings-D-impl.jsonl b/.current-epic/findings-D-impl.jsonl deleted file mode 100644 index 6ac52ec..0000000 --- a/.current-epic/findings-D-impl.jsonl +++ /dev/null @@ -1,12 +0,0 @@ -{"schema_version":1,"finding_id":"D-impl-1-C1","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer A","severity":"critical","status":"open","summary":"LDParse oracle is a pure mock — Python driver never invoked, JSON synthesized from expected records","evidence":["tools/ldraw-compat-harness/src/main.rs:42-50"],"recommendation":"Wire LDParseAdapter::run to actually invoke ldparse_driver.py via subprocess::Command with pinned CPython 3.12.8"} -{"schema_version":1,"finding_id":"D-impl-1-C2","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer A","severity":"critical","status":"open","summary":"Expected records programmatically fabricated — all 13 fixtures get identical CanonicalRecord (triangles:1, bfc:certified_ccw, etc.)","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:373-449"],"recommendation":"Hand-author real per-fixture expected records from ratified spec with correct syntax/BFC/colour/geometry content"} -{"schema_version":1,"finding_id":"D-impl-1-C3","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer A","severity":"critical","status":"open","summary":"10 named gates degenerate to single whole-record equality check; all hardcoded to Profile::Strict","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:206-247","crates/polymodel-ldraw-testkit/src/lib.rs:263-272"],"recommendation":"Differentiate gate assertion logic per gate family. Support multi-profile gates (name+profile uniqueness)."} -{"schema_version":1,"finding_id":"D-impl-1-C4","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Six conditional-line classifier fixtures absent — only 1 type-5 line, no hand-computed coordinates/outcomes/epsilon boundaries","evidence":["crates/polymodel-ldraw-testkit/corpus/h-conditional-lines.ldr"],"recommendation":"Create one fixture per six §3.5 outcomes with hand-computed input coordinates, epsilon/w_epsilon boundaries, clip/control/camera cases"} -{"schema_version":1,"finding_id":"D-impl-1-H1","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Vendor directories contain only REVISION text markers, not actual LDParse source","evidence":["tools/ldraw-compat-harness/vendor/ldparse-master/REVISION"],"recommendation":"Commit actual LDParse source or source archive with SHA-256 and licence"} -{"schema_version":1,"finding_id":"D-impl-1-H2","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Fuzz/property smoke does no parsing — just hashes fixture bytes with PRNG","evidence":["tools/ldraw-compat-harness/src/main.rs:80-92"],"recommendation":"Implement real adversarial fuzz: whitespace/line-ending mutation, malformed numerics, path traversal, MPD cycles, limit/cancellation behavior"} -{"schema_version":1,"finding_id":"D-impl-1-H3","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Thomas/OMR/weldr/ldr_tools artifacts all absent","evidence":["diff summary - no such files"],"recommendation":"Add attributed external artifacts with provenance records per plan scope"} -{"schema_version":1,"finding_id":"D-impl-1-H4","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Corpus is skeletal — path-policy 3 lines/106 bytes for 20 required cases, numeric-finiteness 3 lines, semantic-limits 5 lines for 22 boundary vectors","evidence":["crates/polymodel-ldraw-testkit/corpus/path-policy.ldr","crates/polymodel-ldraw-testkit/corpus/semantic-limits.ldr"],"recommendation":"Expand all corpus fixtures to cover required happy/error/boundary/adversarial cases per gate family"} -{"schema_version":1,"finding_id":"D-impl-1-H5","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Provenance incomplete — all fixtures get same generic URL/licence; provenance.json never loaded; missing SHA-256/original-test/modifications per fixture","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:380-390","crates/polymodel-ldraw-testkit/corpus/provenance.json"],"recommendation":"Add per-fixture provenance with source URL, exact revision, SHA-256, licence, modifications, authority class"} -{"schema_version":1,"finding_id":"D-impl-1-H6","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Four path/root contexts named but no required coverage — path-contexts.ldr has 4 comment lines only","evidence":["crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr"],"recommendation":"Add happy/shadowing/collision/out-of-root/forbidden-syntax fixtures for each of 4 §3.3 resolution contexts"} -{"schema_version":1,"finding_id":"D-impl-1-H7","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Gate tests only cover failure branch — no positive assertion that correct record passes; gate that always rejects would satisfy tests","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:687-701"],"recommendation":"Add positive gate assertion tests and run all 10 gates against intended fixtures/profiles"} -{"schema_version":1,"finding_id":"D-impl-1-M1","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D2","reviewer":"Reviewer B","severity":"medium","status":"open","summary":"Semantic-limit contracts disagree: Rust defines 104857600, matrix.json says 100000000","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:13-25","crates/polymodel-ldraw-testkit/corpus/matrix.json:18"],"recommendation":"Reconcile limit values across all sources"} diff --git a/.current-epic/findings-D-impl2.jsonl b/.current-epic/findings-D-impl2.jsonl deleted file mode 100644 index 65cbb84..0000000 --- a/.current-epic/findings-D-impl2.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"schema_version":1,"finding_id":"D-impl-2-C1","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D3","reviewer":"Reviewer A/B/D","severity":"critical","status":"open","summary":"Expected records auto-derived from same parser (canonical() called for both expected and actual) — tautological; E can't detect parser drift","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:556-699"],"recommendation":"Hand-author expected records as committed JSON files separate from the parser, or explicitly document as schema-only with expectations deferred"} -{"schema_version":1,"finding_id":"D-impl-2-C2","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D3","reviewer":"Reviewer B/D","severity":"critical","status":"open","summary":"LDParse differential accepts JSON but never compares fields against expectations or evaluates gates — functional facade","evidence":["tools/ldraw-compat-harness/src/main.rs:133-141"],"recommendation":"Parse adapter output into CanonicalRecord, compare against expected record fields, evaluate gates"} -{"schema_version":1,"finding_id":"D-impl-2-H1","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D3","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Fuzz harness mutates bytes but only hashes them — never feeds to parser, no panic/determinism check","evidence":["tools/ldraw-compat-harness/src/main.rs:112-132"],"recommendation":"Feed mutated bytes through parse_syntax()/canonical(); assert no panic, check determinism"} -{"schema_version":1,"finding_id":"D-impl-2-H2","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D3","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Provenance generic — all non-H fixtures attributed to same URL/revision/licence regardless of actual source","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:665-690"],"recommendation":"Per-fixture provenance with actual source URL, revision, SHA-256, licence from artifact-provenance.json"} -{"schema_version":1,"finding_id":"D-impl-2-H3","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D3","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Path contexts are comment labels not real resolver cases — no virtual-file table, manifest tree, or root ordering","evidence":["crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr:1-14"],"recommendation":"Create real multi-source path resolution fixtures with actual shadowing/collision/out-of-root cases"} -{"schema_version":1,"finding_id":"D-impl-2-H4","scope":"D","task_prefix":"D","phase":"implementation","generation":"implementor-D3","reviewer":"Reviewer B","severity":"high","status":"open","summary":"LDParse driver is hand-rolled parser, not real LDParse library; vendored source is 3-line REVISION stub","evidence":["tools/ldraw-compat-harness/ldparse_driver.py:21-77"],"recommendation":"Import real LDParse from vendored source, or document explicitly as placeholder with acquisition blocked"} diff --git a/.current-epic/findings-D.jsonl b/.current-epic/findings-D.jsonl deleted file mode 100644 index 3056d2c..0000000 --- a/.current-epic/findings-D.jsonl +++ /dev/null @@ -1,12 +0,0 @@ -{"schema_version":1,"finding_id":"D-plan-2-C1","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"'PM-77 semantic corpus gates' are undefined as a machine-checkable contract. E's acceptance is 'all PM-77 corpus gates' but plan never defines what a gate IS","evidence":["task-E.md:25","plan-D.md step 1/3"],"recommendation":"Define 'corpus gate' as concrete Rust abstraction: named, profile-scoped assertion over fixture expected record, exposed through trait E implements. Enumerate minimum required gate set."} -{"schema_version":1,"finding_id":"D-plan-2-C2","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"LDParse oracle build environment hand-waved; 'first identify buildable surface' is deferred discovery. PM-75 §4 pins exact revisions but plan names none","evidence":["plan-D.md step 4","plan-D.md step 5"],"recommendation":"Name specific LDParse revisions from PM-75 §4. State Python toolchain/interpreter pin before execution. Replace 'either fully exercised or precisely limited' escape clause with minimum required oracle surface."} -{"schema_version":1,"finding_id":"D-plan-2-H1","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"PM-75 §3.5 conditional-line classifier fixtures (six outcomes, epsilon/w_epsilon boundaries) absent from corpus plan","evidence":["plan-D.md step 2","PM-75 §3.5/§6"],"recommendation":"Add conditional-line-classifier fixture family: one per six outcomes with hand-computed coordinates, plus epsilon/w_epsilon boundary fixtures, clip/control cases. Forward-allocate to H."} -{"schema_version":1,"finding_id":"D-plan-2-H2","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"PM-75 §3.3 canonical path/root policy represented as single bullet; insufficient for G's resolver and E's resolver-independence","evidence":["plan-D.md step 2","PM-75 §3.3"],"recommendation":"Expand path-policy fixtures to cover all four §3.3 resolution contexts with happy/shadow/collision/out-of-root/forbidden-syntax sub-families."} -{"schema_version":1,"finding_id":"D-plan-2-H3","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Reservation/logical limit boundary vectors not enumerated; no partition statement between D's LDraw-semantic limits and B's reservation-arithmetic limits","evidence":["plan-D.md step 2/7","PM-75 §5.6"],"recommendation":"Add scoping sentence: D owns LDraw-semantic limits (files, depth, commands, instances, triangles, textures, diagnostics, line length); B owns reservation-arithmetic limit/limit+1."} -{"schema_version":1,"finding_id":"D-plan-2-H4","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"LDParse toolchain identity (Python) never stated; plan implies binary but LDParse is a Python library requiring driver script","evidence":["plan-D.md step 5"],"recommendation":"Specify committed hash-pinned Python driver script with pinned interpreter version and lockfile. 'Version/source' applies to LDParse commit + interpreter + driver."} -{"schema_version":1,"finding_id":"D-plan-2-H5","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"New corpus/fuzz/differential checks not part of just test-all merge gate","evidence":["plan-D.md:167-178","justfile:55-56"],"recommendation":"Make test-all depend on all new recipes or define ldraw-merge-gate required alongside test-all. Ensure native harness actually invoked."} -{"schema_version":1,"finding_id":"D-plan-2-H6","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"LDParse Python environment pinning under-specified; --locked is Cargo concept not Python","evidence":["plan-D.md:141,143"],"recommendation":"Specify Python pinning mechanism: committed uv.lock/hash-pinned requirements.txt + exact CPython version + uv sync recipe."} -{"schema_version":1,"finding_id":"D-plan-2-M1","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Gate-evaluation adapter contract must be wasm-safe; E (wasm crate) must run gates by depending on testkit alone","evidence":["plan-D.md step 8","task-E.md merge tests"],"recommendation":"State gate-evaluation tests are in wasm-compileable cargo test path; only oracle differential is native-only."} -{"schema_version":1,"finding_id":"D-plan-2-M2","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer A","severity":"medium","status":"open","summary":"Canonical semantic/scene record is cross-task contract (E/F consume it) treated as test scaffolding","evidence":["plan-D.md:37-38","plan-D.md:146-151"],"recommendation":"Elevate to versioned contract: D commits v1; E/F may extend but must not break committed golden snapshots without schema bump."} -{"schema_version":1,"finding_id":"D-plan-2-M3","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer A","severity":"medium","status":"open","summary":"Harness doesn't distinguish in-process Rust oracle adapters from out-of-process LDParse","evidence":["plan-D.md:45-46","plan-D.md:132-139"],"recommendation":"Model two adapter classes: in-process Rust (crate-pinned) vs out-of-process (subprocess + env pin). LDParse is only out-of-process oracle."} -{"schema_version":1,"finding_id":"D-plan-2-L1","scope":"D","task_prefix":"D","phase":"plan","generation":"planner-D2","reviewer":"Reviewer A","severity":"low","status":"open","summary":"Workspace member declaration inaccuracy: crates/* glob auto-includes new crate","evidence":["Cargo.toml:2","plan-D.md:52"],"recommendation":"State crate auto-included by glob; only add dependencies, not members entry."} diff --git a/.current-epic/findings-E-impl.jsonl b/.current-epic/findings-E-impl.jsonl deleted file mode 100644 index 96788cf..0000000 --- a/.current-epic/findings-E-impl.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"schema_version":1,"finding_id":"E-impl-1-F1","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E1/E2/E3","reviewer":"Reviewer A","severity":"critical","status":"open","summary":"No adapter-to-gate integration test — gate test is tautological (feeds expected as actual)","evidence":["crates/polymodel-ldraw-testkit/src/gates.rs:302","tools/ldraw-compat-harness/src/main.rs:356"],"recommendation":"Add Tier 1 test that parses all 24 fixtures through InProcessRustAdapter and runs CorpusGateSet::evaluate against expected"} -{"schema_version":1,"finding_id":"E-impl-1-F2","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E1/E2/E3","reviewer":"Reviewer A+B","severity":"critical","status":"open","summary":"Parser semantically incomplete — no MPD NOFILE partitioning, no iterative graph traversal, no cycle detection, no path normalization, no cancellation, no LDConfig, no reflection, 5/11 limits missing","evidence":["crates/polymodel-ldraw-core/src/lib.rs:363,506,511,20-27"],"recommendation":"Implement all missing semantic domains per plan steps 4-7"} -{"schema_version":1,"finding_id":"E-impl-1-F3","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E1/E2/E3","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"BFC is not a correct state machine — substring matching, no XOR, no one-shot INVERTNEXT, reflection always false","evidence":["crates/polymodel-ldraw-core/src/lib.rs:366-374,432-436"],"recommendation":"Parse type-0 BFC tokens in order, implement true one-shot INVERTNEXT with reflection XOR"} -{"schema_version":1,"finding_id":"E-impl-1-F4","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E1/E2/E3","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"TEXMAP transitions violate contract — invalid transitions silently ignored, fallback geometry projected before FALLBACK directive","evidence":["crates/polymodel-ldraw-core/src/lib.rs:383-431"],"recommendation":"Implement explicit state machine with validated transitions"} -{"schema_version":1,"finding_id":"E-impl-1-F5","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E1/E2/E3","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"Required limits and cancellation absent — ParseOptions exposes no cancellation, LdrawLimits omits files/depth/instances/textures/fetches, unchecked arithmetic","evidence":["crates/polymodel-ldraw-core/src/lib.rs:19-48,297-304"],"recommendation":"Add complete typed limit/cancellation contract with checked arithmetic"} -{"schema_version":1,"finding_id":"E-impl-1-F6","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E1/E2/E3","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Type-1 references not parsed as transforms, single hardcoded instance ID, no graph/cache/depth/cycle","evidence":["crates/polymodel-ldraw-core/src/lib.rs:353-360,504-510"],"recommendation":"Implement iterative type-1 traversal with transforms, cache, instance IDs"} -{"schema_version":1,"finding_id":"E-impl-1-F7","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E1/E2/E3","reviewer":"Reviewer B","severity":"high","status":"open","summary":"SemanticArenas declares root before children — Rust drops root first, violating child-before-root topology","evidence":["crates/polymodel-ldraw-core/src/lib.rs:139-142,171-184"],"recommendation":"Declare children before root or implement explicit Drop ordering"} diff --git a/.current-epic/findings-E.jsonl b/.current-epic/findings-E.jsonl deleted file mode 100644 index 1a9b1a6..0000000 --- a/.current-epic/findings-E.jsonl +++ /dev/null @@ -1,88 +0,0 @@ -{"schema_version":1,"finding_id":"E-plan-1-F1","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E1","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Expected records from canonical() heuristics make correct semantics and gate-passing mutually exclusive","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:556-656","crates/polymodel-ldraw-testkit/corpus/expected/bfc-invertnext.expected.json"],"recommendation":"E must re-author expected records to reflect correct semantics as part of its implementation, since D's canonical() was always a placeholder generator"} -{"schema_version":1,"finding_id":"E-plan-1-F2","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E1","reviewer":"Reviewer B","severity":"high","status":"open","summary":"TEXMAP omitted from E implementation despite D corpus contract requiring !TEXMAP coverage","evidence":[".current-epic/plan-D.md:113-121",".current-epic/plan-E.md:66-70,118-135"],"recommendation":"Add explicit TEXMAP parsing/state or explicitly state it is out of PM-78 scope"} -{"schema_version":1,"finding_id":"E-plan-1-F3","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Bounds axis-collapsing in both gate oracles produces [globalMin×3, globalMax×3] not true AABB","evidence":["crates/polymodel-ldraw-testkit/src/lib.rs:519-531"],"recommendation":"Re-author expected bounds with correct per-axis AABB as part of E"} -{"schema_version":1,"finding_id":"E-plan-1-F4","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E1","reviewer":"Reviewer B","severity":"medium","status":"open","summary":"Corpus completion criterion says '22+' but testkit inventory has exactly 24 fixtures","evidence":[".current-epic/plan-E.md:104-106"],"recommendation":"Require exactly 24 fixtures and assert inventory().len() == 24"} -{"schema_version":1,"finding_id":"E-plan-1-F5","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E1","reviewer":"Reviewer A","severity":"medium","status":"open","summary":"ReservationOwner constructors underspecified — plan implies generic stable owner exists","evidence":["crates/polymodel-renderer-ledger/src/lib.rs:150-166"],"recommendation":"State how parser obtains ReservationOwner identity"} -{"schema_version":1,"finding_id":"E-plan-2-F1","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E2","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"Semantic reservation lifetime contradictory — releasing handles on success while arena data outlives parse call","evidence":[".current-epic/plan-E.md Step 4","crates/polymodel-renderer-ledger/src/lib.rs:579-646"],"recommendation":"Carry reservation handle in owned parse result, release only when result/arena is dropped or transferred"} -{"schema_version":1,"finding_id":"E-plan-2-F2","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"canonical() still active runtime path in harness fuzz — must remove or replace with real adapter","evidence":["tools/ldraw-compat-harness/src/main.rs:323-331"],"recommendation":"Delete production/public oracle helper or replace every harness/fuzz call with real in-process adapter; add repository check no expected/gate path calls canonical()"} -{"schema_version":1,"finding_id":"E-plan-2-F3","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E2","reviewer":"Reviewer B","severity":"medium","status":"open","summary":"TEXMAP projection representation still underspecified — no exact schema extension or gate named","evidence":[".current-epic/plan-E.md Step 7"],"recommendation":"Choose concrete v1-compatible representation and gate, or define TEXMAP as internal-only with direct semantic tests"} -{"schema_version":1,"finding_id":"E-plan-2-F4","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E2","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Expected-record independence is process-controlled not mechanically enforced","evidence":[".current-epic/plan-E.md Step 2 + Step 9"],"recommendation":"Require each expected record to carry inline derivation note justifying non-obvious semantic fields"} -{"schema_version":1,"finding_id":"E-plan-2-F5","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E2","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Reservation parent/child topology underspecified — risk of B-invariant clash","evidence":[".current-epic/plan-E.md Step 4","crates/polymodel-renderer-ledger/src/lib.rs:648-654"],"recommendation":"Pin concrete parent/child reservation tree shape (single root SemanticArenas, children attach and drop before root)"} -{"schema_version":1,"finding_id":"E-plan-1-F1","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 re-authored expected records as independent authored data and removed the heuristic canonical path from the required gates."} -{"schema_version":1,"finding_id":"E-plan-1-F2","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 added an explicit TEXMAP state machine, event schema, fixtures, and projection gate to the accepted plan."} -{"schema_version":1,"finding_id":"E-plan-1-F3","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 required true per-axis AABBs and independent expected bounds."} -{"schema_version":1,"finding_id":"E-plan-1-F4","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 changed the corpus contract to exactly 24 fixtures with an inventory assertion."} -{"schema_version":1,"finding_id":"E-plan-1-F5","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 specified caller-supplied ReservationOwner identity and preview/interactive construction."} -{"schema_version":1,"finding_id":"E-plan-2-F1","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 made the owned parse result retain the root reservation until drop or explicit transfer."} -{"schema_version":1,"finding_id":"E-plan-2-F2","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 required replacing every harness/fuzz canonical call with the real adapter and adding an integrity scan."} -{"schema_version":1,"finding_id":"E-plan-2-F3","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 named the concrete texmap_events representation and texmap_projection_v1 gate."} -{"schema_version":1,"finding_id":"E-plan-2-F4","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 required DerivationRef notes and mechanical independence validation."} -{"schema_version":1,"finding_id":"E-plan-2-F5","scope":"E","task_prefix":"E","phase":"plan","generation":"planner-E5","status":"fixed","disposition_by":"parent","notes":"Planner-E5 pinned one SemanticArenas root with direct child reservations dropped before the root."} -{"schema_version":1,"finding_id":"E-implementation-1-F1","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Core exposes filesystem-backed LocalResolver, violating the pure target-safe semantic-core boundary.","evidence":["crates/polymodel-ldraw-core/src/types.rs:342-400","crates/polymodel-ldraw-core/src/lib.rs:17-27"],"recommendation":"Remove filesystem APIs and LocalResolver from the core; keep caller-supplied target-safe descriptors in core and move directory lookup to a native adapter boundary."} -{"schema_version":1,"finding_id":"E-implementation-1-F2","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Model and instance IDs are path/position strings rather than the frozen sequential opaque identity contract.","evidence":["crates/polymodel-ldraw-core/src/traversal.rs:63-65","crates/polymodel-ldraw-core/src/traversal.rs:92-95","crates/polymodel-ldraw-core/src/traversal.rs:171-176","crates/polymodel-ldraw-core/src/model.rs:214-235"],"recommendation":"Introduce branded ModelId and InstanceId newtypes with model-00000001 and instance-00000001 serialization and allocate them in deterministic traversal order."} -{"schema_version":1,"finding_id":"E-implementation-1-F3","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"Semantic reservation topology and exact per-entry charging contract are not implemented.","evidence":["crates/polymodel-ldraw-core/src/parser.rs:34-49","crates/polymodel-ldraw-core/src/parser.rs:90-107","crates/polymodel-ldraw-core/src/parser.rs:138-146","crates/polymodel-ldraw-core/src/parser.rs:249-275","crates/polymodel-ldraw-core/src/types.rs:537-569"],"recommendation":"Reserve the root before all parse-owned storage, charge exact model/instance/cache/colour/TEXMAP entries before mutation, retain child handles under one root, and unwind on failure."} -{"schema_version":1,"finding_id":"E-implementation-1-F4","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"DATA and TEXMAP 0 !: semantics are absent and recognized content is silently discarded.","evidence":["crates/polymodel-ldraw-core/src/parser.rs:364-369","crates/polymodel-ldraw-core/src/parser.rs:479-481","crates/polymodel-ldraw-core/src/texmap.rs:58-69"],"recommendation":"Implement typed DATA state with checked decoding and retention, parse nested 0 !: geometry, reject malformed nesting, and retain textured/fallback geometry associations in events."} -{"schema_version":1,"finding_id":"E-implementation-1-F5","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Literal TEXMAP STOP is rewritten to END while active, violating lossless unknown-META semantics.","evidence":["crates/polymodel-ldraw-core/src/texmap.rs:117-120","crates/polymodel-ldraw-core/src/texmap.rs:350-401"],"recommendation":"Never rewrite STOP; emit the profile-specific unknown-META diagnostic and preserve the existing TEXMAP state."} -{"schema_version":1,"finding_id":"E-implementation-1-F6","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Line-byte limit is accumulated across the request instead of enforced per physical line.","evidence":["crates/polymodel-ldraw-core/src/scanner.rs:217-234","crates/polymodel-ldraw-core/src/parser.rs:50-59","crates/polymodel-ldraw-core/src/parser.rs:147-157"],"recommendation":"Compare each raw line length independently with the line ceiling; do not sum short lines into a request-wide line-byte counter."} -{"schema_version":1,"finding_id":"E-implementation-1-F7","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Malformed-colour geometry mutates counters and bounds before validation, corrupting continued compatibility/lossless results.","evidence":["crates/polymodel-ldraw-core/src/geom.rs:363-396","crates/polymodel-ldraw-core/src/parser.rs:302-327"],"recommendation":"Validate all geometry fields and checked deltas first, then mutate limits, bounds, and geometry atomically."} -{"schema_version":1,"finding_id":"E-implementation-1-F8","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Resolver behavior and root lookup violate resolver-independent semantics and the four root-context contract.","evidence":["crates/polymodel-ldraw-core/src/types.rs:342-399","crates/polymodel-ldraw-core/src/parser.rs:109-224","crates/polymodel-ldraw-core/src/adapter.rs:18-44"],"recommendation":"Move materialization outside the core and pass deterministic caller-supplied descriptors with root identity, content hash, bytes, availability, and fetch accounting."} -{"schema_version":1,"finding_id":"E-implementation-1-F9","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Colour semantics reject required finishes and lack scoped 512-slot allocation and current/edge/direct resolution.","evidence":["crates/polymodel-ldraw-core/src/model.rs:36-165","crates/polymodel-ldraw-core/src/parser.rs:498-519"],"recommendation":"Implement typed finishes/material fields, scoped colour allocation and commit/rollback, deterministic slot exhaustion, and exact colour resolution semantics."} -{"schema_version":1,"finding_id":"E-implementation-1-F10","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"medium","status":"open","summary":"Unterminated quoted identifiers are accepted as valid quoted tokens without a malformed-token diagnostic.","evidence":["crates/polymodel-ldraw-core/src/scanner.rs:279-311"],"recommendation":"Require a closing quote before classifying a quoted identifier and apply the profile-specific malformed-token policy otherwise."} -{"schema_version":1,"finding_id":"E-implementation-1-F11","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Downstream renderer worker does not compile in the task-range composition check.","evidence":["crates/polymodel-renderer-worker/src/worker.rs:142,943,966,1023,1190,1452,1503"],"recommendation":"Determine whether the worker errors are introduced by the task range or pre-existing; if introduced, reconcile load epoch and RendererEvent error APIs and rerun worker/WASM/split gates before review exit."} -{"schema_version":1,"finding_id":"E-implementation-1-F12","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"Resolver availability changes graph and observable semantics, violating resolver independence.","evidence":["crates/polymodel-ldraw-core/src/parser.rs:109-224","crates/polymodel-ldraw-core/src/adapter.rs:18-24"],"recommendation":"Make resolver input a deterministic availability/materialization table and retain canonical references and identities independent of whether bytes are available."} -{"schema_version":1,"finding_id":"E-implementation-1-F13","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Graph lookup is keyed only by normalized path rather than the full canonical cache identity.","evidence":["crates/polymodel-ldraw-core/src/traversal.rs:46-50","crates/polymodel-ldraw-core/src/traversal.rs:159-176"],"recommendation":"Construct and use the exact (canonical path, resolved root, content hash) CacheKey for model indexing and traversal decisions."} -{"schema_version":1,"finding_id":"E-implementation-1-F14","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Corpus graph and transform gates do not compare per-instance records or full transform data.","evidence":["crates/polymodel-ldraw-testkit/src/gates.rs:276-293"],"recommendation":"Compare independently-authored source/target IDs, full transforms, inversion, spans, model summaries, and per-axis bounds in the gate-owned fields."} -{"schema_version":1,"finding_id":"E-implementation-1-F15","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"InProcessRustAdapter drops caller context and hardcodes fixture preview budget/root assumptions.","evidence":["crates/polymodel-ldraw-core/src/adapter.rs:18-44"],"recommendation":"Preserve request provenance/context and expose a production byte-request path with explicit root and budget rather than fixture defaults."} -{"schema_version":1,"finding_id":"E-implementation-1-F16","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer C","severity":"medium","status":"open","summary":"Resolver-loaded buffers grow before resource and semantic reservation admission.","evidence":["crates/polymodel-ldraw-core/src/parser.rs:138-183","crates/polymodel-ldraw-core/src/parser.rs:251-255"],"recommendation":"Charge caller-supplied external bytes and per-file storage before allocation and make materialization part of the owned rollback topology."} -{"schema_version":1,"finding_id":"E-implementation-1-F17","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"critical","status":"open","summary":"Native compatibility harness does not compile, so corpus, fuzz, and differential commands cannot run.","evidence":["tools/ldraw-compat-harness/src/main.rs:65,130-132,285,297-300","crates/polymodel-ldraw-testkit/src/schema.rs:108-114"],"recommendation":"Fix harness compilation, validate required authored oracle projections, own the result through projection, and add command-level failure coverage."} -{"schema_version":1,"finding_id":"E-implementation-1-F18","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Core corpus integration test does not compare real parser output to authored expected records or execute gates.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:180-245","crates/polymodel-ldraw-testkit/src/gates.rs:381-397,493-508"],"recommendation":"Parse each fixture through the real adapter, evaluate all applicable gates against authored expected records, and fail on every mismatch."} -{"schema_version":1,"finding_id":"E-implementation-1-F19","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Rejected, limited, and cancelled outcome tests accept permissive or wrong result shapes and do not verify cleanup.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:195-241","crates/polymodel-ldraw-core/src/lib.rs:161-177"],"recommendation":"Table-drive all eleven limits, overflow, cancellation, exact diagnostic tuples, no partial publication, and ledger cleanup after errors and cancellation."} -{"schema_version":1,"finding_id":"E-implementation-1-F20","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"real_models_match_oracle_ground_truth uses hardcoded loose minima instead of complete authored oracle projection comparison.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:608-691"],"recommendation":"Compare complete authored oracle projections or exact named gates with strict counts, order, identities, bounds, diagnostics, and transforms."} -{"schema_version":1,"finding_id":"E-implementation-1-F21","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Required BFC, TEXMAP, graph, limits, cancellation, and cleanup matrices are not covered by executable tests.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:101-177,413-473",".current-epic/plan-E.md:90,120,174,224,234"],"recommendation":"Add table-driven direct tests for every normative row and checkpoint, including reservation snapshots while alive and after drop."} -{"schema_version":1,"finding_id":"E-implementation-1-F22","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Fuzz smoke is unrunnable and lacks independent semantic/invariant validation.","evidence":["tools/ldraw-compat-harness/src/main.rs:353-405"],"recommendation":"Separate core mutation smoke from native differential execution, assert deterministic invariants and completion count, and add a command-level test."} -{"schema_version":1,"finding_id":"E-implementation-1-F23","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"medium","status":"open","summary":"Inventory, derivation, and oracle projection validation do not enforce the exact corpus contract.","evidence":["crates/polymodel-ldraw-testkit/src/inventory.rs:390-480","crates/polymodel-ldraw-testkit/src/schema.rs:108-114"],"recommendation":"Assert exact intended inventory cardinality/IDs, require complete derivation groups, and require one complete oracle projection per mandated fixture."} -{"schema_version":1,"finding_id":"E-implementation-1-F24","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer D","severity":"medium","status":"open","summary":"Testkit gate tests are tautological and do not prove real parser output or rejection of wrong fields.","evidence":["crates/polymodel-ldraw-testkit/src/gates.rs:493-508"],"recommendation":"Mutate each gate-owned field independently and add one real adapter-to-gate integration test over the authored corpus."} -{"schema_version":1,"finding_id":"E-implementation-2-F1","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Mandatory corpus/harness boundary does not compile, preventing corpus, fuzz, and differential validation.","evidence":["tools/ldraw-compat-harness/src/main.rs:57-68,130-132,271-300","crates/polymodel-ldraw-testkit/src/schema.rs:106-124"],"recommendation":"Remove or implement stale regenerate dispatch, handle optional oracle projections explicitly, and retain/own the parse result through projection before comparison."} -{"schema_version":1,"finding_id":"E-implementation-2-F2","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Materialization/cache contract exposes full identity but traversal and parser admission collapse descriptors by normalized path.","evidence":["crates/polymodel-ldraw-core/src/types.rs:343-352","crates/polymodel-ldraw-core/src/cache.rs:77-102","crates/polymodel-ldraw-core/src/parser.rs:107-121","crates/polymodel-ldraw-core/src/traversal.rs:46-49,159-164"],"recommendation":"Select deterministic materializations by full path/root/hash identity before traversal, index models by full key, and test same-path different-root/hash order independence."} -{"schema_version":1,"finding_id":"E-implementation-2-F3","scope":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Inventory validator hard-caps membership to a fixed static list, contrary to validating all present fixtures.","evidence":["crates/polymodel-ldraw-testkit/src/inventory.rs:390-435","tools/ldraw-compat-harness/src/main.rs:90-96,114-120"],"recommendation":"Validate all discovered/current inventory rows for uniqueness, provenance, derivations, and applicable gates; protect required baseline IDs as a subset rather than enforcing exact cardinality."} -{"schema_version":1,"finding_id":"E-implementation-2-F4","scope":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"Reservation charging remains coarse and does not precede required semantic growth.","evidence":["crates/polymodel-ldraw-core/src/parser.rs:45-105,227-255","crates/polymodel-ldraw-core/src/geom.rs:329-334","crates/polymodel-ldraw-core/src/model.rs:55-57","crates/polymodel-ldraw-core/src/texmap.rs:132-140,267-305,337-345,378-386","crates/polymodel-ldraw-core/src/traversal.rs:46-56,63-65,100-114,151-185,229-237"],"recommendation":"Reserve exact fixed charges before every required model/instance/cache/colour/TEXMAP growth, retain child handles under the single root, and prove failed admission leaves state unchanged."} -{"schema_version":1,"finding_id":"E-implementation-2-F5","scope":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"critical","status":"open","summary":"DATA state still discards recognized payload content without decoding, retention, diagnostics, or resource/semantic charging.","evidence":["crates/polymodel-ldraw-core/src/model.rs:268","crates/polymodel-ldraw-core/src/parser.rs:342-347,457-460"],"recommendation":"Implement typed DATA continuation decoding and retention with malformed/truncated diagnostics, decoded-byte limits, reservation admission, spans, cancellation, and cleanup tests."} -{"schema_version":1,"finding_id":"E-implementation-2-F6","scope":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"TEXMAP NEXT grammar and geometry association are incomplete; texture-limit insertion is not atomic.","evidence":["crates/polymodel-ldraw-core/src/texmap.rs:58-69,119-141,267-270","crates/polymodel-ldraw-core/src/parser.rs:348-384","crates/polymodel-ldraw-core/src/geom.rs:393-397"],"recommendation":"Enforce complete NEXT grammar and context rules, retain textured/fallback/NEXT geometry association, and reserve/check before texture identity insertion."} -{"schema_version":1,"finding_id":"E-implementation-2-F7","scope":"E","phase":"implementation","generation":"implementor-E2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"C++-compatible colour finish, scoped ColorScope, local-slot allocation, and current/edge/direct resolution remain incomplete.","evidence":["crates/polymodel-ldraw-core/src/model.rs:37-57,144-149","crates/polymodel-ldraw-core/src/parser.rs:481-484","crates/polymodel-ldraw-core/src/geom.rs:201-204,369-397"],"recommendation":"Implement ColorData finishes/materials, layered scope commit/rollback, deterministic 512-slot allocation and exhaustion, and C++ current/edge/direct/complement resolution with tests."} -{"schema_version":1,"finding_id":"E-implementation-2-F8","scope":"E","phase":"implementation","generation":"implementor-E2","status":"open","reviewer":"Reviewer D","severity":"high","summary":"All-fixture core loop is not an adapter-to-authored-corpus gate comparison.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:247-323","crates/polymodel-ldraw-testkit/src/gates.rs:381-397","tools/ldraw-compat-harness/src/main.rs:90-112,271-285"],"recommendation":"Parse each present fixture through InProcessRustAdapter, project with owned lifetime, evaluate all applicable gates against authored records, and fail on every mismatch."} -{"schema_version":1,"finding_id":"E-implementation-2-F9","scope":"E","phase":"implementation","generation":"implementor-E2","status":"open","reviewer":"Reviewer D","severity":"high","summary":"Limit/cancellation tests are metadata-only and do not exercise executable bounded inputs or cleanup.","evidence":["crates/polymodel-ldraw-testkit/corpus/semantic-limits.ldr","crates/polymodel-ldraw-testkit/corpus/adversarial-regressions.ldr","crates/polymodel-ldraw-core/src/lib.rs:229-244","crates/polymodel-ldraw-testkit/src/inventory.rs:138-147"],"recommendation":"Add executable exact/limit+1/overflow/cancellation tests for all eleven limits, assert exact diagnostics and no partial publication/ledger leakage."} -{"schema_version":1,"finding_id":"E-implementation-2-F10","scope":"E","phase":"implementation","generation":"implementor-E2","status":"open","reviewer":"Reviewer D","severity":"high","summary":"Normative BFC/TEXMAP/graph/transform/colour matrices are not executable and real-model oracle checks remain loose.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:168-226,491-579,655-798","crates/polymodel-ldraw-testkit/src/gates.rs:221-322"],"recommendation":"Table-drive every normative transition/error and graph/transform/colour boundary, then compare complete authored oracle projections with strict field/order assertions."} -{"schema_version":1,"finding_id":"E-implementation-2-F11","scope":"E","phase":"implementation","generation":"implementor-E2","status":"open","reviewer":"Reviewer D","severity":"high","summary":"Authored derivation/oracle validation is permissive and gate mutation coverage is tautological.","evidence":["crates/polymodel-ldraw-testkit/src/inventory.rs:461-478","crates/polymodel-ldraw-testkit/src/schema.rs:108-124","crates/polymodel-ldraw-testkit/src/gates.rs:493-509"],"recommendation":"Require non-empty derivations with mandatory field groups, validate oracle projection presence/fields, and add independent wrong-field mutations for each gate."} -{"schema_version":1,"finding_id":"E-implementation-2-F12","scope":"E","phase":"implementation","generation":"implementor-E2","status":"open","reviewer":"Reviewer C","severity":"medium","summary":"Oracle-integrity check is false-red because it matches legitimate serialize_canonical rather than forbidden generator calls.","evidence":["justfile oracle-integrity recipe","crates/polymodel-ldraw-testkit/src/schema.rs:190"],"recommendation":"Use a narrower identifier/token-aware scan for actual forbidden canonical/parse_syntax calls and scan all required expected/gate/harness/fuzz paths."} -{"schema_version":1,"finding_id":"E-implementation-2-F13","scope":"E","phase":"implementation","generation":"implementor-E2","status":"open","reviewer":"Reviewer D","severity":"medium","summary":"Oracle projection comparator sorts/deduplicates colour codes, erasing order, slots, and duplicates.","evidence":["crates/polymodel-ldraw-testkit/src/gates.rs:425-478","crates/polymodel-ldraw-testkit/src/schema.rs:219-224"],"recommendation":"Compare complete ordered colour code/slot data and all authored oracle fields without lossy normalization."} -{"schema_version":1,"finding_id":"E-implementation-2-F14","scope":"E","phase":"implementation","generation":"implementor-E2","status":"open","reviewer":"Reviewer C","severity":"critical","summary":"Renderer worker WASM remains uncompilable in composition checks; baseline attribution is not independently verified.","evidence":["crates/polymodel-renderer-worker/src/worker.rs:142,943,966,1023,1028,1190,1452,1503"],"recommendation":"Prove the failure against the task-base checkout. If task-owned, reconcile worker protocol migration; otherwise record the Epic-level baseline blocker and do not pass E’s parent test gate while it remains unresolved."} -{"schema_version":1,"finding_id":"E-implementation-3-F1","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E3","reviewer":"Reviewer D","severity":"high","status":"fixed","disposition_by":"parent","notes":"Verified in 806d55cb: TEXMAP expected projection, syntax-all-types projection, profile metadata, path identity snapshot, conditional-line projection, and rejected-outcome harness routing were corrected; oracle-integrity passes and inventory validates all 28 fixtures."} -{"schema_version":1,"finding_id":"E-implementation-3-F2","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E3","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Strict OMR graph gate still fails because authored model identity and path-derived occurrence IDs do not match the actual C++-aligned parser projection, including repeated local-index IDs.","evidence":["crates/polymodel-ldraw-testkit/corpus/expected/omr-10030-1.expected.json:23753-26180","crates/polymodel-ldraw-core/src/traversal.rs:184-190","just ldraw-corpus output"],"recommendation":"Reconcile the graph gate and authored OMR projection with the authoritative C++ model/cache/traversal identity semantics; do not impose Rust-only global sequential IDs or weaken uniqueness without explicit C++ evidence."} -{"schema_version":1,"finding_id":"E-implementation-3-F3","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E3","reviewer":"Reviewer D","severity":"high","status":"open","summary":"Deterministic fuzz smoke treats expected INVALID_UTF8 rejection from mutation 0 as a harness failure.","evidence":["tools/ldraw-compat-harness/src/main.rs:353-405","just ldraw-fuzz-smoke output"],"recommendation":"Make fuzz smoke classify bounded parser rejections as valid outcomes while still failing on panic, nondeterminism, leaks, hangs, or unexpected diagnostics; keep differential/oracle checks separate and explicit."} -{"schema_version":1,"finding_id":"E-implementation-3-F4","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E3","severity":"critical","status":"open","reviewer":"Reviewer C","summary":"Parent workspace `just check` remains blocked by renderer-worker compile errors not touched by E3.","evidence":["crates/polymodel-renderer-worker/src/worker.rs:142,943,966,1023,1028,1190,1452,1503","just check output"],"recommendation":"Independently verify baseline attribution against the task-base checkout; if pre-existing, create/route an Epic integration blocker and do not claim the PM-86 task test gate passed."} -{"schema_version":1,"finding_id":"E-implementation-4-F1","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E4","severity":"critical","status":"open","reviewer":"Reviewer B","summary":"DATA/TEXMAP reservation admission occurs after allocation or state mutation and uses the wrong ledger class.","evidence":["crates/polymodel-ldraw-core/src/parser.rs:337-375","crates/polymodel-ldraw-core/src/types.rs:540-595","crates/polymodel-ldraw-core/src/texmap.rs:265-277,370-386","crates/polymodel-renderer-ledger/src/lib.rs:26-30,81-97"],"recommendation":"Admit decoded source/DATA storage before decode using DecodedSourceData, and reserve/check TEXMAP identity/event storage before mutating counters, sets, stacks, descriptors, or events; add failure-injection cleanup tests."} -{"schema_version":1,"finding_id":"E-implementation-4-F2","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer B","summary":"C++ colour finishes, layered scope, local slots, and current/edge/direct/complement resolution remain incomplete.","evidence":["crates/polymodel-ldraw-core/src/model.rs:12-34,104-165","crates/polymodel-ldraw-core/src/parser.rs:94,596-602","../LDParse-reference/include/LDParse/Color.hpp:38-89","../LDParse-reference/LDParse/Color.cpp:44-128"],"recommendation":"Port typed finish/material fields, layered ColorScope commit/rollback, deterministic local slot allocation/exhaustion, and C++ colour resolution with authoritative tests."} -{"schema_version":1,"finding_id":"E-implementation-4-F3","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer B","summary":"Materialization resolution sorts same-path descriptors by enum/hash rather than caller-resolved contextual root policy.","evidence":["crates/polymodel-ldraw-core/src/types.rs:347-356","crates/polymodel-ldraw-core/src/traversal.rs:159-174",".current-epic/plan-E.md:208-214"],"recommendation":"Carry caller-selected full CacheKey or explicit unresolved edge into each include, reject ambiguous tables, and test insertion-order-independent root/hash selection."} -{"schema_version":1,"finding_id":"E-implementation-4-F4","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer B","summary":"Distinct full cache-key models collapse into path-derived public IDs and summaries, losing root/hash provenance.","evidence":["crates/polymodel-ldraw-core/src/traversal.rs:46-52,63-65,92-115,182-195,239-241","crates/polymodel-ldraw-core/src/model.rs:268-275"],"recommendation":"Preserve C++-readable names while exposing full cache identity in summaries/occurrences and deduplicate by CacheKey, without inventing sequential opaque IDs."} -{"schema_version":1,"finding_id":"E-implementation-4-F5","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer B","summary":"Canonical path normalization rewrites forbidden syntax and loses exact spelling instead of rejecting per PM-75.","evidence":["crates/polymodel-ldraw-core/src/cache.rs:10-45",".current-epic/plan-E.md:208-214"],"recommendation":"Validate strict UTF-8/NFC/path syntax without rewriting; keep exact canonical spelling and separate root-specific ASCII-fold lookup/collision handling."} -{"schema_version":1,"finding_id":"E-implementation-4-F6","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer A","summary":"OMR graph gate enforces global uniqueness/legacy sequential compatibility unsupported by C++ child-tuple semantics.","evidence":["../LDParse-reference/include/LDParse/Model.hpp:40-52,68","../LDParse-reference/include/LDParse/ModelBuilder/ModelBuilderImpl.hpp:147-165","crates/polymodel-ldraw-testkit/src/gates.rs:287-314","just ldraw-corpus output"],"recommendation":"Remove the legacy sequential-ID uniqueness branch and compare ordered C++-representable source/target/position/BFC/inversion/transform edge data; re-author OMR accordingly."} -{"schema_version":1,"finding_id":"E-implementation-4-F7","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer C","summary":"Fuzz smoke treats bounded parser rejections as failures and cannot complete its deterministic mutation budget.","evidence":["tools/ldraw-compat-harness/src/main.rs:401-419","just ldraw-fuzz-smoke output"],"recommendation":"Classify typed bounded parse rejections as valid smoke outcomes, retain diagnostics, assert determinism/no panic/leak/hang, and keep oracle comparison explicit for accepted versus rejected cases."} -{"schema_version":1,"finding_id":"E-implementation-4-F8","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"critical","status":"open","reviewer":"Reviewer C","summary":"Renderer worker protocol migration in the PM-78 task range is uncompilable.","evidence":["crates/polymodel-renderer-worker/src/worker.rs:142,943,966,1023,1028,1190,1452,1503","crates/polymodel-renderer-protocol/src/lib.rs","crates/polymodel-renderer-protocol/src/load_state.rs","cargo check -p polymodel-renderer-worker --target wasm32-unknown-unknown"],"recommendation":"Complete load-state migration: maintain WorkerState.load_epoch, pass authoritative epoch through async closures, replace MeshError with current LoadFailed/LoadError events, and rerun worker WASM/split/check/test-all."} -{"schema_version":1,"finding_id":"E-implementation-4-F9","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"critical","status":"open","reviewer":"Reviewer D","summary":"All 28 expected records have empty derivations and no oracle_projection while validation reports pass.","evidence":["crates/polymodel-ldraw-testkit/corpus/expected/*.json","crates/polymodel-ldraw-testkit/src/inventory.rs:461-478","crates/polymodel-ldraw-testkit/src/schema.rs:108-124","tools/ldraw-compat-harness/src/main.rs:90-140"],"recommendation":"Require nonempty derivations with mandatory semantic field groups, require/validate authored oracle projections where applicable, and make validate/differential fail on missing evidence rather than treating Option::None as pass."} -{"schema_version":1,"finding_id":"E-implementation-4-F10","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer D","summary":"Adapter-to-authored corpus gates still do not cover all accepted/preserved/limited/cancelled behaviours or exact cleanup.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:247-323","tools/ldraw-compat-harness/src/main.rs:95-103,271-300","crates/polymodel-ldraw-testkit/src/inventory.rs:138-147"],"recommendation":"Run real adapter projection plus named gates for each publishing fixture and exact outcome/diagnostic/ledger cleanup assertions for rejected, limited, and cancelled fixtures."} -{"schema_version":1,"finding_id":"E-implementation-4-F11","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"high","status":"open","reviewer":"Reviewer D","summary":"Limits/cancellation/cleanup matrix and independent gate mutation coverage remain absent.","evidence":["crates/polymodel-ldraw-core/src/lib.rs:193-244","crates/polymodel-ldraw-testkit/src/gates.rs:221-322,493-509"],"recommendation":"Add executable all-eleven limit exact/limit+1/overflow/cancellation/cleanup tests and independent wrong-field mutations for every gate-owned projection field."} -{"schema_version":1,"finding_id":"E-implementation-4-F12","scope":"E","phase":"implementation","generation":"implementor-E4","severity":"medium","status":"open","reviewer":"Reviewer B","summary":"DATA continuation assembly grows unbounded and loses raw continuation semantics before validation.","evidence":["crates/polymodel-ldraw-core/src/parser.rs:409-421",".current-epic/plan-E.md:156-174"],"recommendation":"Validate raw tails incrementally, reserve checked encoded/decoded capacity before append/decode, checkpoint cancellation per chunk, and retain exact spans/chunk provenance."} -{"schema_version":1,"finding_id":"E-implementation-4-F6","scope":"E","phase":"implementation","generation":"implementor-E4","status":"fixed","disposition_by":"parent","notes":"Verified in graph tranche commit 1d2e22c8: removed synthetic sequential-ID/count/global-uniqueness fallback; graph gate now compares authored ordered source/target/transform/inversion tuples and fails closed with MissingAuthority when absent."} -{"schema_version":1,"finding_id":"E-implementation-4-F13","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E4","severity":"critical","status":"open","summary":"Current authored graph and OMR records lack ordered SceneRecord.instances edge authority required by the strict C++-aligned graph gate.","evidence":["crates/polymodel-ldraw-testkit/corpus/expected/graph-instances.expected.json","crates/polymodel-ldraw-testkit/corpus/expected/omr-10030-1.expected.json","crates/polymodel-ldraw-testkit/src/gates.rs:287-330","just ldraw-corpus output"],"recommendation":"Author ordered source/target/transform/inversion edges from fixture/C++ evidence for every graph fixture, including OMR, with derivation notes; do not fabricate records or restore synthetic-ID bypasses."} -{"schema_version":1,"finding_id":"E-implementation-4-F7","scope":"E","phase":"implementation","generation":"implementor-E4","status":"fixed","disposition_by":"parent","notes":"Verified in fuzz tranche commit 0db94364: bounded parser diagnostics/cancellation are classified as valid deterministic mutation outcomes, accepted parses are serialized twice for determinism, panics are caught, and ledger reservations are checked after drop. Native oracle schema failure remains explicit."} -{"schema_version":1,"finding_id":"E-implementation-4-F8","scope":"E","phase":"implementation","generation":"implementor-E4","status":"fixed","disposition_by":"parent","notes":"Verified in worker tranche commit dd384645 and independently by cargo check -p polymodel-renderer-worker --target wasm32-unknown-unknown plus just check. load_epoch propagation and stale event migration now compile; warnings remain only."} -{"schema_version":1,"finding_id":"E-implementation-1-F1","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"fixed","disposition_by":"parent","notes":"Verified in 5291366e: filesystem-backed LocalResolver and typed-arena were removed from the core; parser now accepts caller-supplied materialization descriptors. Core and testkit WASM checks pass."} -{"schema_version":1,"finding_id":"E-implementation-1-F5","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"fixed","disposition_by":"parent","notes":"Verified in 5291366e and focused regression test: literal STOP is not rewritten to END and does not pop TEXMAP state."} -{"schema_version":1,"finding_id":"E-implementation-1-F6","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"fixed","disposition_by":"parent","notes":"Verified in 5291366e and focused test: line-byte limit is enforced per physical line rather than cumulatively."} -{"schema_version":1,"finding_id":"E-implementation-1-F7","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"fixed","disposition_by":"parent","notes":"Verified in 5291366e and focused test: invalid geometry colour does not mutate geometry publication, counts, or bounds."} -{"schema_version":1,"finding_id":"E-implementation-1-F10","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"fixed","disposition_by":"parent","notes":"Verified in 5291366e and focused test: unterminated quoted tokens produce structured diagnostics."} -{"schema_version":1,"finding_id":"E-implementation-1-F11","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"open","disposition_by":"parent","notes":"Implementor reported the renderer-worker errors as pre-existing and outside the PM-78 touched files. This is not an operator-accepted risk; parent must independently verify baseline attribution and record the integration blocker before any test-gate pass."} -{"schema_version":1,"finding_id":"E-implementation-1-F2","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"rebutted-accepted","disposition_by":"operator","notes":"Authoritative LDParse C++ Model/Cache semantics govern model identity; no sequential opaque IDs are required without C++ evidence."} -{"schema_version":1,"finding_id":"E-implementation-1-F23","scope":"E","task_prefix":"E","phase":"implementation","generation":"implementor-E2","status":"rebutted-accepted","disposition_by":"operator","notes":"More than 24 fixtures is not inherently a test failure; all present fixtures must be validated independently."} -{"schema_version":1,"finding_id":"E-implementation-1-F2","scope":"E","task_prefix":"E","phase":"implementation","generation":"operator-correction-2026-08-03","status":"rebutted-accepted","disposition_by":"operator","notes":"The reviewer inferred sequential opaque model-/instance-ID semantics from the reworked plan. Operator directed that identity semantics must match the authoritative C++ library at ../LDParse-reference; implementor must verify Model/Cache/traversal semantics there and must not impose Rust-only sequential IDs without C++ evidence."} -{"schema_version":1,"finding_id":"E-implementation-1-F23","scope":"E","task_prefix":"E","phase":"implementation","generation":"operator-correction-2026-08-03","status":"rebutted-accepted","disposition_by":"operator","notes":"A fixture inventory larger than 24 is not itself a correctness failure. Operator directed retaining and validating all present fixtures; stale exact-24 assumptions must not delete coverage or turn extra fixtures into a false test failure."} diff --git a/.current-epic/findings-G-impl.jsonl b/.current-epic/findings-G-impl.jsonl deleted file mode 100644 index 481705f..0000000 --- a/.current-epic/findings-G-impl.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"schema_version":1,"finding_id":"G-impl-1-F1","scope":"G","task_prefix":"G","phase":"implementation","generation":"implementor-G1/G2","reviewer":"Reviewer A+C","severity":"critical","status":"open","summary":"Handshake state machine is dead code — HandshakeState/negotiate never wired into viewer bridge or worker entry","evidence":["crates/polymodel-renderer-protocol/src/lib.rs:155","src/viewer.rs:1403-1411","crates/polymodel-renderer-worker/src/worker.rs:1912-1915"],"recommendation":"Wire HandshakeState into both entry points, gate command dispatch behind Negotiated(session)"} -{"schema_version":1,"finding_id":"G-impl-1-F2","scope":"G","task_prefix":"G","phase":"implementation","generation":"implementor-G1/G2","reviewer":"Reviewer A","severity":"high","status":"open","summary":"Zero Tier-3 route tests for compound endpoint, fetch replay, wrong-principal, substitution","evidence":["src/appview/tests.rs"],"recommendation":"Add Axum/SQLite tests for owner success, wrong-principal denial, replay denial, substitution rejection"} -{"schema_version":1,"finding_id":"G-impl-1-F3","scope":"G","task_prefix":"G","phase":"implementation","generation":"implementor-G1/G2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"StartCompoundLoad never referenced in viewer — app-side never acquires compound plan","evidence":["src/viewer.rs"],"recommendation":"Wire compound plan acquisition and StartCompoundLoad dispatch in viewer bridge"} -{"schema_version":1,"finding_id":"G-impl-1-F4","scope":"G","task_prefix":"G","phase":"implementation","generation":"implementor-G1/G2","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"No LDraw mesh parser — worker routes .dat bytes to STL parser producing garbage","evidence":["crates/polymodel-renderer-worker/src/worker.rs:1301","crates/polymodel-mesh/src/parser.rs:102"],"recommendation":"Add LDraw MeshFormat variant or gate StartCompoundLoad behind format check"} -{"schema_version":1,"finding_id":"G-impl-1-F5","scope":"G","task_prefix":"G","phase":"implementation","generation":"implementor-G1/G2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Adding StartCompoundLoad mid-enum changes postcard discriminants for subsequent commands","evidence":["crates/polymodel-renderer-protocol/src/lib.rs:369"],"recommendation":"Append new variants at end or use explicit stable tags"} -{"schema_version":1,"finding_id":"G-impl-1-F6","scope":"G","task_prefix":"G","phase":"implementation","generation":"implementor-G1/G2","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Compound cancellation doesn't abort spawned fetch loop — canceled loads continue consuming routes and reservations","evidence":["crates/polymodel-renderer-worker/src/worker.rs:1203"],"recommendation":"Associate AbortController with each load identity, check between descriptors"} diff --git a/.current-epic/findings-G.jsonl b/.current-epic/findings-G.jsonl deleted file mode 100644 index ad81d75..0000000 --- a/.current-epic/findings-G.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"schema_version":1,"finding_id":"G-plan-1-F1","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G1","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"G assumes ResolvedResource has opaque fetch descriptor but shipped type has only ByteSource{resource_uri, blob_cids} — no route token/URL","evidence":["src/ldraw/resolve.rs:43-53",".current-epic/plan-G.md:92,94"],"recommendation":"Either reopen C to add descriptor or scope G to synthesize resource_uri→fetch URL explicitly"} -{"schema_version":1,"finding_id":"G-plan-1-F2","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G1","reviewer":"Reviewer C","severity":"critical","status":"open","summary":"C deferral D003 unauthenticated fetch makes G cross-principal rejection claims unachievable","evidence":[".current-epic/deferrals-C.jsonl:2",".current-epic/plan-G.md:94,227"],"recommendation":"Scope cross-principal claims to resolver/mint boundary, or block on D003"} -{"schema_version":1,"finding_id":"G-plan-1-F3","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Wire format incompatibility — adding LengthUnit::LdrawUnit + version metadata to StartLoad breaks postcard bytes","evidence":["crates/polymodel-renderer-protocol/src/lib.rs:176-183",".current-epic/plan-G.md:75,112"],"recommendation":"State mandatory envelope cutover explicitly; freeze StartLoad shape before H/J"} -{"schema_version":1,"finding_id":"G-plan-1-F4","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G1","reviewer":"Reviewer B","severity":"high","status":"open","summary":"LengthUnit::LdrawUnit has no physical LDU scale/conversion semantics or renderer consumption defined","evidence":[".current-epic/plan-G.md:75,146-148"],"recommendation":"Define LDraw unit convention and conversion boundary with test"} -{"schema_version":1,"finding_id":"G-plan-1-F5","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G1","reviewer":"Reviewer A","severity":"medium","status":"open","summary":"Tier-type duplication — plan creates LdrawRootTier/LdrawRootSource instead of reusing PM-76 RootId","evidence":["src/ldraw/paths.rs:52-130"],"recommendation":"Reuse RootId, drop parallel tier taxonomy"} -{"schema_version":1,"finding_id":"G-plan-1-F6","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G1","reviewer":"Reviewer A","severity":"medium","status":"open","summary":"LengthUnit::LdrawUnit must be appended at END of enum to preserve postcard wire bytes","evidence":["crates/polymodel-renderer-protocol/src/lib.rs:67-77"],"recommendation":"Place variant after Unknown, not mid-enum"} -{"schema_version":1,"finding_id":"G-plan-1-F7","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G1","reviewer":"Reviewer C","severity":"high","status":"open","summary":"Negotiation restart/re-queue sequencing underspecified for mid-session worker restart","evidence":[".current-epic/plan-G.md:115,125"],"recommendation":"Address in-flight pending loads on worker restart and load_epoch interaction with fresh Created"} -{"schema_version":1,"finding_id":"G-plan-2-F1","scope":"G","task_prefix":"G","phase":"plan","generation":"planner-G2","reviewer":"Reviewer B","severity":"high","status":"open","summary":"Compound descriptor acquisition and companion enumeration underspecified — no concrete same-origin request/response boundary, route/API, or companion enumeration algorithm","evidence":[".current-epic/plan-G.md RouteIssuer/CompoundResourcePlan"],"recommendation":"Specify concrete acquisition contract: how MPD/manifest companions are enumerated and pre-resolved, route path, authentication flow, failure/retry behavior"} -{"schema_version":1,"finding_id":"G-plan-skeptic-1","scope":"G","task_prefix":"G","phase":"plan","generation":"skeptic-G3","reviewer":"Plan Deferral Skeptic","severity":"critical","status":"open","summary":"CID verification gap (D002) explicitly allowed to remain — adding multihash decoder is implementation work not impossibility","evidence":[".current-epic/plan-G.md:238,268"],"recommendation":"Either implement per-chunk CID verification (add multihash decoder dependency) or explicitly scope G acceptance to exclude CID claims without papering over"} -{"schema_version":1,"finding_id":"G-plan-skeptic-2","scope":"G","task_prefix":"G","phase":"plan","generation":"skeptic-G3","reviewer":"Plan Deferral Skeptic","severity":"high","status":"open","summary":"Root policy D005 not remediated — plan reuses incomplete C prefix parsing without implementing s/8/48 prefix handling","evidence":[".current-epic/plan-G.md root/path sections"],"recommendation":"Either implement explicit prefix parsing and subroot containment in G or explicitly scope acceptance to exclude root isolation claims"} diff --git a/.current-epic/handoff.md b/.current-epic/handoff.md deleted file mode 100644 index d470234..0000000 --- a/.current-epic/handoff.md +++ /dev/null @@ -1,67 +0,0 @@ -# Epic PM-86 Runtime Handoff - -**Date:** 2026-08-01 -**Session:** 07f7s4-trash -**Continuations used:** 22 of 50 - -## Completed Tasks (Merged) - -### A (PM-87) — Viewer load/readiness state machine ✅ -- **Commit:** `mnnrlsvqmrrw` on epic-86 bookmark -- **Tests:** Protocol 21/21, App 197/197, WASM ✅ -- **Skeptic:** Rebutted (accessor methods are forward-looking API) -- **Jira:** Comment added to PM-87 - -### D (PM-77) — LDraw corpus and compatibility harness ✅ -- **Commit:** `951ab847` (merged first, before A) -- **Tests:** Testkit 5/5, differential exit 0, wasm ✅ -- **Skeptic:** PASS (gen-2) -- **Jira:** Comment added to PM-77 -- **Known limitations:** LDParse driver is standalone (not real library); expected records from canonical(); InProcessRustAdapter deferred to E - -## Tasks in Final Stages (Not Yet Merged) - -### B (PM-88) — Reservation ledger and hard budgets -- **Workspace:** ../epic-86-task-B (bookmark: epic-86-task-B) -- **Commits:** `xtmolxqmyzqm` (B3: worker charges) → `vxxxyoqkmxqy` (B2: ledger fixes) → `qzzwzzyuxzpy` (B1: initial ledger) -- **Tests:** Ledger 6/6 ✅, Worker wasm ✅ -- **Merge blocker:** worker.rs has 2 conflict regions from rebase onto A's commit. Both A and B modified worker.rs (different sections). Need manual conflict resolution. -- **Skeptic:** Not yet run (blocked on merge) - -### C (PM-76) — LDraw library projection -- **Workspace:** ../epic-86-task-C (bookmark: epic-86-task-C) -- **Commit:** `zkwprvvmxmlp` (includes E0004 fix, SQLx cache, verification lifecycle, resolver routing) -- **Tests:** Server 203/203 ✅, SQLx prepare ✅, just check ✅, just fix ✅ -- **Merge blocker:** Earlier merge attempt corrupted epic-86 bookmark (set to C's workspace marker). jj graph tangled. Need clean merge approach. -- **Skeptic:** REJECTED — accepted gaps as deferrals (unauthenticated fetch, no CID verification, no official ingestion, root policy incomplete). Deferrals in deferrals-C.jsonl. - -## Tasks Not Started (E-K) - -All have task caches written in `.current-epic/task-{E..K}.md`. - -Dependency status: -- **E** (PM-78): needs B+D → D ✅, B nearly ready -- **F** (PM-79): needs A+B+D+E → A ✅, D ✅, B nearly ready, E not started -- **G** (PM-80): needs A+B+C → A ✅, B nearly ready, C nearly ready -- **H** (PM-81): needs B+E+F+G → deep chain -- **I** (PM-82): needs B+H → very deep -- **J** (PM-83): needs A+C+F+G → needs C -- **K** (PM-84): needs everything → last - -## jj Workspace State Notes - -- **Epic workspace** (`../epic-86`): Has A+D source files. epic-86 bookmark may point to wrong commit (C's marker from failed merge attempt). Needs `jj bookmark set epic-86 -r mnnrlsvqmrrw --allow-backwards` to fix. -- **B workspace**: Clean, compiling code. B3's changes are committed and verified. Conflicts arise when rebasing onto A's commit (worker.rs both modified). -- **C workspace**: Clean, compiling code. C3's changes committed and verified. Merge mechanics are the blocker, not code quality. -- **A workspace**: May have stale working copy. A's changes are already in epic-86. -- **D workspace**: May have stale working copy. D's changes are already in epic-86. -- `.current-task/` added to `.gitignore` to prevent future merge conflicts. - -## Recommended Next Session Actions - -1. Fix epic-86 bookmark: `jj bookmark set epic-86 -r mnnrlsvqmrrw --allow-backwards` -2. Merge B: resolve worker.rs conflicts (take both A's load state machine AND B's reservation admission) -3. Merge C: use clean jj rebase from C's latest commit onto corrected epic-86 -4. Run B and C completeness skeptics -5. Start E and G planning (both become eligible after B+C merge) -6. Continue through dependency chain diff --git a/.current-epic/ldparse-architecture.md b/.current-epic/ldparse-architecture.md deleted file mode 100644 index 4d3823a..0000000 --- a/.current-epic/ldparse-architecture.md +++ /dev/null @@ -1,87 +0,0 @@ -# LDParse C++ Architecture Summary (modernization branch 0fe78dc) - -Local clone: `/home/orual/Projects/LDParse-reference` -Source: elfprince13/LDParse, modernization branch `0fe78dcee22982f26cceced1b483340faa4d04e9` - -## Architecture overview - -``` -bytes → Lexer (line/token scanner) → MPD splitter → Parser (event/callback) → ModelBuilder (arena+cache+graph) -``` - -## Key types and semantics - -### Lexer (Lex.hpp, 482 lines) -- `TokenKind`: Zero..Five for line types 0-5; meta keywords (STEP, FILE, NOFILE, BFC, CERTIFY, etc.) -- `Winding`: bool-backed (CCW=false, CW=true) -- `Token`: kind + value + zero-based line/column span -- Keyword map is EXACT-CASE (no folding); lowercase variants become identifiers/garbage -- CRLF-safe line reading; quoted text becomes identifiers -- `lexModelBoundaries`: MPD pass recognizing `0 FILE ` and `0 NOFILE`, discard mode outside files - -### Model (Model.hpp, 66 lines) -- `Model`: name, source location, ColorTable ref, local colour maps, flattened LDMesh, default colour -- Child instances: tuples of (child model index, BFCStatus, TransMatrix, Model*) -- `mSubModelNames`: MPD name → model index map -- `mSubModels`: name → cached Model -- BFC: `tribool` certification (unknown/certified/uncertified) + `BFCStatus` (Off=-1, Standard=0, Invert=1) - -### ModelBuilder (ModelBuilderImpl.hpp, 266 lines) -- Callback-driven: handleMPDCommand, handleInclude, handleMetaCommand, geometry handlers -- `handleMPDCommand`: FILE creates/inserts submodel; NOFILE retargets to null -- `handleInclude`: resolves name through MPD cache, returns SwitchFile(index) for unresolved -- `handleMetaCommand`: STEP records index count; BFC state machine - - Certification starts unknown; geometry forces it false - - CERTIFY can set true only from unknown/true; enables clipping, defaults CCW - - NOCERTIFY sets false; CLIP/NOCLIP toggle only while certified - - CW/CCW update winding only while certified - - INVERTNEXT records one pending inversion only while certified -- Traversal is ITERATIVE: completed vector + scanStack, no recursion -- **No explicit cycle detection** — Rust port must add visiting/active-path set - -### Cache (Cache.hpp, 190 lines) -- `CacheNode`: compressed radix/prefix tree, case-insensitive (uppercase) comparison -- Caches named model ownership and MPD name→index -- Rust: HashMap is simpler and safer - -### Geometry (Geom.hpp, 97 lines) -- `TransMatrix`: translation (Position) + 9 float matrix coefficients (3x3 linear), NOT 4x4 homogeneous -- `Mesh`: per-attribute vectors + index vector + bfIndices (reverse copies for BFC) -- `mergeMesh`: appends/merges with optional per-attribute transform callbacks -- No explicit determinant/reflection in header — Rust port must compute det(linear) and carry reflection flag - -### Colors (Color.hpp/cpp) -- `ColorTable`: 512 numeric slots, local colour allocation via addLocalColour -- `ColorScope`: layered (committed stack + active map), record→commit lifecycle -- Edge colour: either code-based lookup or inline ColorData -- BFC certification NOT in Color — it's in Model + builder state - -### Parser (Parse.hpp, 314 lines) -- `ActionKind`: SwitchFile, SkipNLines, NoAction, StopParsing -- Callback/event-based: on_include, on_meta, geometry handlers -- `parseModels`: iterative multi-model driver with completed vector + switch/return stack -- Type 1: colour + 12-value transform matrix + filename → include callback -- Types 2-5: validated primitives → callbacks - -## Rust port architecture - -1. **Scanner**: line iterator → `Token { kind, value, span }` -2. **MPD splitter**: `0 FILE`/`0 NOFILE` → named model sources + root identity -3. **Parser**: validates line arity/types, emits typed events/actions -4. **Model arena**: `Vec` by stable `ModelId`, each owns mesh + child instances -5. **Name cache**: `HashMap`, shared per-MPD -6. **Traversal**: explicit stack + `visiting` set for cycles + `completed` for parse-once -7. **BFC/colour contexts**: explicit enums, one-shot `invert_next`, layered colour scope -8. **Geometry**: named affine transforms, determinant/reflection tracking, mesh merges - -## C++ → Rust translation - -| C++ pattern | Rust idiom | -|---|---| -| raw/shared pointer graph | arena + stable ModelId indices | -| boost::tribool | explicit enum (Unknown/Certified/Uncertified) | -| callback/member-pointer templates | traits/closures/event enum | -| tuple-heavy geometry | named structs (Vec3, Affine3, Mesh) | -| goto BFC parsing | state-machine methods | -| mutable iterator jumping | index-based work stack | -| assertions/logging for invariants | Result errors + tests | diff --git a/.current-epic/manifest.txt b/.current-epic/manifest.txt deleted file mode 100644 index 1ea5d3b..0000000 --- a/.current-epic/manifest.txt +++ /dev/null @@ -1,22 +0,0 @@ -Flavor: polymodel-jj-parallel -Epic-Key: PM-86 -Epic-URL: https://radiant-industries.atlassian.net/browse/PM-86 -Project: PM -Epic-workspace: ../epic-86 -Base-bookmark: main - -Tasks: -A PM-87 https://radiant-industries.atlassian.net/browse/PM-87 Authoritative viewer load/readiness state machine -B PM-88 https://radiant-industries.atlassian.net/browse/PM-88 Shared renderer load reservation ledger and hard budgets -C PM-76 https://radiant-industries.atlassian.net/browse/PM-76 LDraw library projection and ATProto dependency manifest -D PM-77 https://radiant-industries.atlassian.net/browse/PM-77 LDraw corpus and compatibility harness -E PM-78 https://radiant-industries.atlassian.net/browse/PM-78 Thomas-compatible Rust LDraw semantic core -F PM-79 https://radiant-industries.atlassian.net/browse/PM-79 Minimal LDraw vertical slice and generic scene IR contract -G PM-80 https://radiant-industries.atlassian.net/browse/PM-80 Compound LDraw resolver and versioned worker protocol -H PM-81 https://radiant-industries.atlassian.net/browse/PM-81 LDraw points, surfaces, explicit edges, and conditional lines -I PM-82 https://radiant-industries.atlassian.net/browse/PM-82 LDraw TEXMAP and embedded texture pipeline -J PM-83 https://radiant-industries.atlassian.net/browse/PM-83 Format registry and LDraw publishing/viewer integration -K PM-84 https://radiant-industries.atlassian.net/browse/PM-84 Interoperability, fuzzing, performance, and visual baselines -L PM-90 https://radiant-industries.atlassian.net/browse/PM-90 Correct LDraw face lighting and top/bottom brightness -M PM-91 https://radiant-industries.atlassian.net/browse/PM-91 Fix close-range renderer depth corruption and camera clipping precision -N PM-92 https://radiant-industries.atlassian.net/browse/PM-92 Use cached part edges for logical-part selection highlight diff --git a/.current-epic/merge-gate.json b/.current-epic/merge-gate.json deleted file mode 100644 index a494ca5..0000000 --- a/.current-epic/merge-gate.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "holder": null, - "last_merged": "task-F", - "queue": [] -} diff --git a/.current-epic/plan-A.md b/.current-epic/plan-A.md deleted file mode 100644 index 4b9575d..0000000 --- a/.current-epic/plan-A.md +++ /dev/null @@ -1,210 +0,0 @@ -# PM-87 — Authoritative viewer load/readiness state machine (planner-A2) - -## Task metadata - -- **Epic:** PM-86 — [LDraw/LEGO model support and flexible renderer](https://radiant-industries.atlassian.net/browse/PM-86) -- **Task:** PM-87 — [Fix: authoritative viewer load/readiness state machine](https://radiant-industries.atlassian.net/browse/PM-87) -- **Task prefix:** A -- **Task cache:** `.current-epic/task-A.md` -- **Epic workspace:** `../epic-86` -- **Base bookmark:** `main` -- **Task workspace:** `../epic-86-task-A` -- **Task bookmark:** `epic-86-task-A` -- **Dependencies:** none; this task blocks F (`PM-79`), G (`PM-80`), and J (`PM-83`) -- **Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Context - -The viewer currently declares readiness before the worker has loaded and installed a mesh, and an old scene can remain visually current after a failed replacement. PM-87 establishes one protocol-owned, epoch/generation-keyed reducer contract for interactive and preview renderer namespaces; readiness is derived only from a committed current install, without changing the renderer split or adding LDraw parsing. - -## Safety and workspace checks - -Before editing, run: - -```sh -jj workspace root -jj workspace list -jj bookmark list -``` - -Stop immediately if the workspace is the default workspace on `main`, if `jj workspace root` is not `../epic-86-task-A`, or if the current bookmark is not `epic-86-task-A`. Do not edit the Epic workspace, `main`, or parent-owned `.current-epic` state. Start a fresh change before source edits: - -```sh -jj new -m "PM-87: authoritative viewer load state machine" -``` - -Do not modify `.current-epic/task-state.json`, `.current-epic/merge-gate.json`, `.current-epic/events.log`, `.current-epic/manifest.txt`, or `.current-epic/dependencies.dot`. - -## Existing implementation surface - -- `crates/polymodel-renderer-protocol/src/lib.rs` owns the postcard `RendererCommand`/`RendererEvent` contract. `LoadMesh`, `MeshLoaded`, and `MeshError` currently have no load identity; `RendererReady` means context/viewport initialization, not mesh readiness. -- `crates/polymodel-renderer-worker/src/worker.rs` owns the persistent worker and `three_d` scene. `WorkerState.load_epoch` is worker-allocated, and `handle_load_mesh` currently builds/replaces the scene before emitting `MeshLoaded`; stale async work has no explicit disposal outcome. -- `src/viewer.rs` owns `ViewerStatus`, `ModelViewer`, the main-thread bridge, source routing, and STEP conversion. `use_resource` currently sets `Ready` and synthetic stats immediately; the bridge uses `last_loaded_key` and unqualified mesh events. -- `src/publish.rs` uses a separate preview worker and `request_id`; it must remain isolated from interactive load state. -- `e2e/tests/viewer.spec.ts` currently exercises STL viewer routes and switching. Deterministic non-STL fixtures already exist under `public/models/`: `cube.obj`, `box.gltf`/`box.glb`, and `cube_gears.3mf`. - -## Canonical identity and reducer ownership - -Use `LoadIdentity { load_epoch: u32, scene_generation: u32 }`. The **main thread is the sole allocator**: each accepted model/source selection allocates a strictly newer identity and sends it in an identity-bearing `StartLoad` command. Neither worker code nor the main status mirror may allocate, increment, or infer identities. `Resize`, pointer input, context handshake, and preview `request_id` do not allocate interactive load identities. - -Ordering is lexicographic by `(load_epoch, scene_generation)`; both components are monotonic within a renderer namespace, and an identity is never reused. The implementation must retain the full pair in `Loading` (the canonical state is `Loading(epoch, generation)`, not a generation-dropping `Loading(epoch)`), `Installing`, and `Ready`; terminal outcomes retain the operation identity internally or expose it in their state payload so exact event matching remains possible. - -There are deliberately **two reducer instances**, both using the same protocol reducer and transition contract: - -1. the worker instance is the publication reducer and owns candidate/current scene resources; -2. the main-thread instance is the status reducer and owns viewer status/stats attributes. - -The convergence invariant is: for a namespace and identity, main status may report `Ready` only after observing the worker’s committed `Installing(identity) → InstallSucceeded(identity)` publication; both reducers reject the same stale/wrong-namespace event, and every rejected candidate is disposed by the resource-owning side. The main mirror never becomes an independent authority and the worker never allocates identity. - -Rename the context handshake event exactly to `RendererContextReady` so it cannot be confused with state-machine `Ready(identity)`. Document that it reports WebGL context/initial viewport readiness only; mesh readiness is the reducer state `Ready(identity)`. - -## State/event contract and complete transition table - -Create a protocol module, for example `crates/polymodel-renderer-protocol/src/load_state.rs`, re-exported from `lib.rs`. It owns namespace, identity, state, stable error codes/details, events, transition results, and pure CAS/reducer logic. The exact externally observable states are: - -```text -Idle -Loading(epoch, generation) -Installing(epoch, generation) -Ready(epoch, generation) -Failed(epoch, code) -Cancelled(epoch) -Superseded(epoch) -Disposed -``` - -A reducer result must distinguish `Committed` from `IgnoredLateOrStale`, and must carry disposal instructions for candidate resources supplied with a rejected event. `Dispose` is terminal per reducer instance. A SPA route re-entry/remount constructs a fresh reducer instance starting at `Idle`; it does not revive `Disposed`. - -| Current state | Event / guard | Result and side effects | -|---|---|---| -| `Idle` | fresh `StartLoad(new)` | Commit `Loading(new)`; no prior scene is current. | -| Any active or terminal load state (`Loading`, `Installing`, `Ready`, `Failed`, `Cancelled`, `Superseded`) | strictly newer `StartLoad(new)` | **Supersession row:** record/emit `Superseded(old)` then atomically begin `Loading(new)`; invalidate old current-scene presentation, cancel old work, and dispose old candidate/current resources as required. This row covers `Loading(old) → StartLoad(new)`, the headline race. | -| `Loading(id)` | matching `LoadSucceeded(id, resource)` | Commit `Installing(id)`; retain only the candidate resource for installation. | -| `Loading(id)` | matching `LoadFailed(id, code, detail)` | Commit `Failed(id.epoch, code)`; publish no scene; dispose any supplied candidate. | -| `Loading(id)` | matching `Cancel(id)` | Commit `Cancelled(id.epoch)`; publish no scene and dispose pending candidate/work. | -| `Installing(id)` | matching `InstallSucceeded(id, scene, stats)` | Commit `Ready(id)` and atomically publish that scene; this is the only mesh-readiness publication point. | -| `Installing(id)` | matching `InstallFailed(id, code, detail)` | Commit `Failed(id.epoch, code)`; never publish the candidate and dispose it. | -| `Installing(id)` | matching `Cancel(id)` | Commit `Cancelled(id.epoch)`; do not publish the candidate and dispose it. | -| `Ready(id)` | matching `Cancel(id)` | Commit `Cancelled(id.epoch)`; mark the previously published scene non-current/clear it, so cancelled status cannot claim stale readiness. | -| `Failed(epoch, code)`, `Cancelled(epoch)`, or `Superseded(epoch)` (with full identity retained internally) | matching `Cancel(id)` | Remain in the same terminal outcome; no publication; dispose any supplied candidate. | -| Any non-`Disposed` state | `Dispose` | Commit `Disposed`, clear current scene, stop work; all later events are no-ops that dispose supplied resources. | -| Any state | wrong namespace, wrong identity, non-monotonic identity, or late event | Leave state unchanged, never publish, return `IgnoredLateOrStale`, and dispose any supplied candidate. | -| Any state | `Resize` | Update viewport/camera dimensions only; preserve state, identity, and publication. | - -A newer `StartLoad` is accepted only if its identity is strictly greater than the current operation identity. The reducer must make both race orders deterministic: once cancel, supersession, failure, or disposal commits, later success/install/error cannot mutate current state; if installation commits first, a later cancellation cannot retract it except for the explicit `Ready → Cancel` rule above or a newer `StartLoad`. Entering `Loading` or `Installing` clears/marks prior pixels non-current. - -## Replace-versus-edit assessment - -- **Protocol (`crates/polymodel-renderer-protocol/src/lib.rs` plus new `src/load_state.rs`): replace the unqualified load-event contract and add the shared reducer; edit unrelated mesh-format, serialization, and preview helpers.** The old variants encode no identity and stale-scene retention, so adding scattered fields would preserve the wrong linearization boundary. -- **Worker (`crates/polymodel-renderer-worker/src/worker.rs`): replace `load_epoch` and the `handle_load_mesh` lifecycle; edit command dispatch, frame-loop publication, resize, and disposal integration.** Keep WebGL initialization, parsing, scene construction helpers, input handling, and frame scheduling. -- **Viewer (`src/viewer.rs`): replace immediate-`Ready` status plumbing and key-only bridge authority; edit bridge fields/effects and status boundary attributes.** Preserve source selection, URL/companion routing, STEP conversion, canvas transfer, pointer listeners, context retention, fallback behavior, and resize listener. Remove only `status.set(Ready(...))` and synthetic `mesh_stats` updates that occur before worker install; do **not** remove the surrounding `use_resource` effect or its source-routing/STEP logic. -- **Preview (`src/publish.rs`): edit only protocol pattern matches/request identity setup.** Keep its request-scoped promise lifecycle and separate worker; do not replace it. -- **Tests:** edit protocol/viewer tests and extend worker browser tests and Playwright viewer tests. Replace assertions/comments that require stale-scene retention; retain existing format/parser/render coverage. - -## Numbered implementation steps - -### 1. Define the protocol reducer and identity-bearing wire contract - -1. Add the protocol load-state module and public re-exports for namespace, `LoadIdentity`, state, event, error code/detail, transition result, and disposal outcome. -2. Change interactive commands/events so `StartLoad`, `Cancel`, load success/failure, install success/failure, supersession, and disposal carry namespace and exact identity as needed. Rename `RendererReady` to `RendererContextReady`; retain preview payloads and `request_id` while isolating preview namespace/state. -3. Make main-thread StartLoad allocation explicit and testable. The allocator increments identity only when a new source selection is accepted; worker receives an already allocated identity and stores it through `Loading`. -4. Implement and document every transition-table row, including supersession from `Loading`, `Installing`, `Ready`, `Failed`, `Cancelled`, and `Superseded`, plus `Ready → Cancel` and stale candidate disposal. -5. Add postcard round-trip fixtures for all new command/event variants and update all consumers in the same change. - -**Done when:** one protocol reducer defines all state changes; `Loading` retains the full identity; no worker or viewer-local allocator exists; context readiness and mesh `Ready` have distinct names; every rejected event distinguishes stale/ignored from committed and specifies disposal. - -### 2. Integrate the reducer into worker publication and resource lifecycle - -1. Replace `WorkerState.load_epoch` with the shared worker reducer and current identity received from main-thread `StartLoad`; do not allocate identity in the worker. -2. On `StartLoad`, commit `Loading(new)`, invalidate/clear the old current scene, cancel or supersede previous async work, and start fetch/parse tagged with `new`. -3. On matching parse success, commit `Installing(new)`, construct a candidate scene without replacing the published model, and commit `InstallSucceeded(new)` only after candidate construction succeeds. Atomically install model/camera/control only at that commit. -4. Route parse/install failures through stable error codes; commit `Failed` and dispose candidates. Handle cancellation, every newer-start supersession, worker disposal, and late async results with explicit disposal rather than silent return. -5. Keep `Resize` independent of load identity and preserve renderer initialization plus one-worker/context retention. Preview remains a separate namespace and cannot mutate interactive scene state. - -**Done when:** only current `Installing + InstallSucceeded` publishes; a replacement cannot display the old scene as current; install/cancel, install/supersede, install/dispose, success/error, and late-result orders converge through the same reducer; resize leaves identity unchanged; all candidate resources are disposed exactly when rejected. - -### 3. Make main-thread viewer readiness authoritative - -1. Change `ViewerPage` and `AssetViewer` so they enter `Loading(identity)` and wait for the matching worker install-success event. Remove only premature ready/stat writes; retain source routing, glTF companion handling, STEP conversion, object-URL cleanup, and render-source setup. -2. Update `WorkerBridgeState` to issue identity-bearing `StartLoad` from the main-thread allocator, track namespace/current identity, and apply the shared status reducer. Remove `last_loaded_key` as authority; a repeated source key still receives a new identity when it represents a new accepted load. -3. Map only committed current events to `ViewerStatus` and stats. Expose current-scene/readiness attributes distinguishing Loading, Installing, Failed, Cancelled, Superseded, Ready, and Disposed; stale old events must not update status or stats. -4. Define `should_have_session()` explicitly: `Ready`, `Loading`, `Installing`, `Failed`, `Cancelled`, and `Superseded` retain the viewer session for the current status/overlay; `Disposed` does not. `RendererError` remains non-session unless the final implementation maps it to a reducer state with an explicit test. -5. Keep preview completion/error handling request- and namespace-scoped so it cannot mutate interactive status, scene, or stats. Preserve canvas, pointer, fallback, context-acquisition, and resize behavior. - -**Done when:** the UI cannot say “Viewer ready” before worker publication; old success/error cannot change the current status; failed/cancelled/superseded replacement is not current-ready; session attributes follow the policy above; source routing and STEP conversion still execute; preview and interactive surfaces remain isolated. - -### 4. Add deterministic contract, worker-browser, viewer, and format tests - -Use the repository’s actual runners rather than relying on a generic tier label: - -1. **Protocol host unit tests** (`cargo test -p polymodel-renderer-protocol`): cover every transition row, main-thread monotonic allocation, lexicographic identity validation, exact CAS rejection, terminal precedence, disposal instructions, wrong namespace, `Ready → Cancel`, all-state newer `StartLoad`, and resize no-op semantics. -2. **Viewer host unit tests** (`cargo test -p polymodel --lib` or the focused existing viewer test target): cover status mapping, `should_have_session` for every state including Cancelled/Superseded/Disposed, stale stats rejection, readiness only after install success, context-event naming, and preview isolation. Remove tests/comments asserting stale-scene retention. -3. **Worker browser tests** (`just test-renderer`, using the configured `wasm-bindgen-test` runner): cover publication and disposal race seams, loading/installing non-current attributes, resize identity stability, and successful format loading for deterministic fixtures `public/models/body_f_chest-v4.stl`, `public/models/cube.obj`, one glTF fixture (`box.gltf` with its companion or `box.glb`), and `public/models/cube_gears.3mf`. Each format test must assert a matching identity reaches install success; this is the explicit browser coverage for OBJ/glTF/3MF rather than claiming they are covered by the existing STL-only Playwright routes. -4. **Playwright viewer tests** (`cd e2e && npm test -- tests/viewer.spec.ts`): assert initial readiness waits for worker publication, rapid switching ends on the newest identity, delayed/out-of-order old responses cannot overwrite the final model, failed replacement is non-current rather than stale-ready, resize changes only resize metadata, one context remains, and existing STL routes remain green. Keep four-format parsing/install coverage in the deterministic worker browser tests above; Playwright remains responsible for the viewer boundary and interactive race behavior. Use request interception/delay or explicit test hooks, not sleeps. -5. Preserve preview/publish browser coverage and add the simultaneous preview/interactive isolation assertion if that surface is reachable in the existing harness. - -**Done when:** every acceptance behavior has a named test at its concrete runner; both race orders are asserted with final state/publication/disposal evidence; STL/OBJ/glTF/3MF are covered by deterministic browser tests; no test depends on stale-scene retention or timing sleeps. - -## Validation commands - -Run from `../epic-86-task-A` after implementation, in this order: - -```sh -just check -just fix -cargo test -p polymodel-renderer-protocol -cargo test -p polymodel --lib -RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo test -p polymodel-renderer-worker --target wasm32-unknown-unknown -just build-renderer-worker -just test-renderer -cd e2e && npm test -- tests/viewer.spec.ts -cd .. && just test-all -``` - -Use the configured wasm-bindgen browser runner; do not substitute host-only tests for worker browser behavior. Run `just verify-renderer-split` (or the existing renderer dependency-tree invariant) if protocol/worker build wiring changes. Record exact outcomes and any concrete remaining risk in the handoff. - -## Implementation review expectations - -Reviewers must inspect the complete jj diff and verify: - -- main thread is the only identity allocator; worker and main are two reducer instances with the stated convergence invariant; -- `Loading → newer StartLoad` and every other non-terminal supersession row are implemented, as are `Ready → Cancel` and `Ready → newer StartLoad`; -- `Loading` retains generation and identity ordering is explicit; -- only current `Installing → InstallSucceeded` publishes; rejected/late resources are disposed; -- `should_have_session` matches the documented per-state policy and `Disposed` is terminal per instance with fresh remount at `Idle`; -- `RendererContextReady` cannot be confused with mesh `Ready`; -- source/STEP routing, canvas transfer, pointer handling, context retention, resize, preview isolation, and all four format paths remain intact; -- tests assert observable final state and resource disposal, with OBJ/glTF/3MF covered by worker browser fixtures and STL route e2e coverage; and -- no generated API crate or renderer-split dependency boundary changed unnecessarily. - -Resolve all Critical and High findings before parent handoff. Do not reintroduce stale-scene retention as a readiness behavior. - -## Finding dispositions from planner-A1 review - -- **A-plan-1-C1 (Critical): fixed.** Added an explicit newer-`StartLoad` supersession row from `Loading` and all active/terminal load states; the row invalidates old presentation, cancels work, disposes candidates, and begins newer `Loading`. -- **A-plan-1-C2 (Critical): fixed.** Main thread is the sole allocator. The plan explicitly names worker/publication and main/status reducer instances and defines their convergence invariant. -- **A-plan-1-F1 (High): fixed.** `Ready`, `Loading`, `Installing`, `Failed`, `Cancelled`, and `Superseded` retain session; `Disposed` does not. Tests cover each policy. -- **A-plan-1-F2 (High): fixed.** Context handshake is renamed/documented as `RendererContextReady`; state-machine mesh readiness remains `Ready(identity)`. -- **A-plan-1-F3 (High): fixed.** Existing STL e2e claims are not inflated; worker browser tests use deterministic OBJ, glTF/GLB, and 3MF fixtures and assert install success for each. -- **A-plan-1-H1 (High): fixed.** Main allocates the lexicographically ordered `(load_epoch, scene_generation)` pair, and `Loading` retains both components through completion/failure/cancellation. -- **A-plan-1-H2 (High): fixed.** Added explicit `Ready → Cancel` and `Ready → newer StartLoad` rows, including current-scene semantics. -- **A-plan-1-M1 (Medium): fixed.** The viewer step explicitly removes only premature status/stat writes; source routing, glTF companions, STEP conversion, cleanup, and the enclosing effect remain. -- **A-plan-1-M2 (Medium): fixed.** Replaced undefined Tier 1/Tier 4 labels with concrete protocol/viewer host unit, worker wasm-bindgen browser, and Playwright runners. -- **A-plan-1-M3 (Medium): fixed.** `Disposed` is terminal per reducer instance; SPA remount creates a fresh `Idle` instance. - -## Commit and ready-for-parent-merge handoff - -The implementation commit must include exact trailers: - -```text -Epic: PM-86 -Task: PM-87 -``` - -Before reporting ready, the implementor must: - -1. run `jj diff` and `jj status` from `../epic-86-task-A`; -2. verify only intended protocol/worker/viewer/test files changed and no prohibited `.current-epic` parent state changed; -3. report bookmark `epic-86-task-A`, commit/change IDs, exact commands and outcomes, and concrete remaining risk; -4. provide successful focused protocol/viewer tests, worker browser tests, `just build-renderer-worker`, configured viewer e2e tests, and `just test-all` evidence; and -5. return the task change to the parent orchestrator for review and merge onto the PM-86 Epic workspace. Do not merge into `main` from the task workspace. diff --git a/.current-epic/plan-B.md b/.current-epic/plan-B.md deleted file mode 100644 index f8ae068..0000000 --- a/.current-epic/plan-B.md +++ /dev/null @@ -1,326 +0,0 @@ -# PM-88 — Shared renderer load reservation ledger and hard budgets - -## Task identity and execution boundary - -- **Epic:** PM-86 — LDraw/LEGO model support and flexible renderer -- **Epic URL:** https://radiant-industries.atlassian.net/browse/PM-86 -- **Task:** PM-88 — Shared renderer load reservation ledger and hard budgets -- **Task URL:** https://radiant-industries.atlassian.net/browse/PM-88 -- **Task prefix:** B -- **Task cache:** `.current-epic/task-B.md` -- **Epic workspace:** `../epic-86` -- **Base bookmark:** `main` -- **Expected task workspace:** `../epic-86-task-B` -- **Expected task bookmark:** `epic-86-task-B` -- **Dependencies:** none -- **Blocks:** E/PM-78, F/PM-79, G/PM-80, H/PM-81, I/PM-82, K/PM-84 - -## Context - -PM-88 adds the dependency-light, reusable admission ledger that later resolver, scene, GPU, texture, and interoperability tasks will use before representative allocations. It must enforce the frozen PM-75 class, domain, load/viewer, global, and unified ceilings with checked `u64` arithmetic while remaining independent of protocol, parser, GPU, browser, and heavy renderer crates. - -This is planning only for the parent orchestrator. The implementor must not work on `main`, must not create workspaces or bookmarks, and must not edit `.current-epic/` runtime files. - -## Safety and grounding checks - -Before editing in the provisioned task workspace, run: - -```sh -jj workspace root -jj bookmark list -jj status --no-pager -``` - -Stop immediately if the workspace is the default workspace on `main`, the bookmark/change is not `epic-86-task-B`, or the workspace is not `../epic-86-task-B`. Then read `.current-task/task.md` and `.current-task/plan.md` in that task workspace. Do not edit `.current-epic/manifest.txt`, `.current-epic/dependencies.dot`, `.current-epic/task-state.json`, `.current-epic/merge-gate.json`, or `.current-epic/events.log`. - -## Replace-vs-edit decisions - -- **Ledger crate:** add a new `crates/polymodel-renderer-ledger` crate. Replacing/adding a separate leaf is required because `polymodel-renderer-protocol` is a serialization/wire crate, not a mutable synchronized policy/accounting crate; extending it would create the wrong coupling boundary. -- **Ledger implementation:** build a cohesive new implementation and pure policy/formula modules. There is no existing admission code to patch. Keep synchronization in one ledger transaction boundary rather than adding branches to unrelated worker code. -- **Worker integration:** production binding is mandatory in PM-88. Preserve the worker's retained-session/frame-loop architecture, but add the ledger to `WorkerState` and route every named boundary through it: canvas transfer/interactive initialization, interactive resize, preview canvas/context/render-target creation, and scene/GPU model construction/upload. Extract small admission helpers when a function is already dense; do not replace the worker wholesale or treat allocator statistics as enforcement. A plan-complete implementation must change the real call sites, not merely expose a contract for later adapters. -- **justfile:** edit existing validation minimally, preserving the meaning of `verify-renderer-split`; add a distinct `test-renderer-ledger` recipe, the ledger and worker wasm/dependency-tree checks, and browser-backed allocation-admission coverage. Do not weaken forbidden-dependency checks. -- **Generated code/assets:** do not edit `crates/polymodel-api` or generated renderer artifacts. - -## Public crate and dependency boundary - -Add `crates/polymodel-renderer-ledger/Cargo.toml` and `src/lib.rs` (split modules only if the cohesive implementation warrants it), plus crate-level docs or a README containing the future-caller ownership/admission audit contract. Production dependencies must be empty where possible; the crate must not depend on `polymodel-renderer-protocol`, `polymodel-mesh`, `three-d`, `three-d-asset`, `stl_io`, `web-sys`, or `wasm-bindgen`. The existing `crates/*` workspace glob should discover it; add a root workspace dependency only if a real public call site requires one, and do not make `polymodel` depend on it merely to prove compilation. Let Cargo, not hand editing, update `Cargo.lock` if needed. - -The public API uses checked `u64`, integer dimensions/counts, typed errors, typed scope/owner identifiers, immutable limit snapshots, reservation handles, and synchronized operations compatible with native tests and `wasm32-unknown-unknown`. No floats enter the ledger. CSS/DPR conversion belongs at a browser boundary before calling the canvas charge constructor. - -## Frozen budget policy - -Publish a budget schema/version such as `BUDGET_SCHEMA_VERSION`, checked MiB/GiB constants, exhaustive `ResourceClass` and `MemoryDomain` mappings, and explicit scope aggregates. Class ceilings are maxima, not additive entitlements. Every charge contributes exactly once to its class, owning domain, owner bucket, global domain/aggregate, and unified totals. - -### Domain, aggregate, and class ceilings - -The domain/unified table is: - -| Domain | Per-load/viewer domain ceiling | Global domain ceiling | -| --- | ---: | ---: | -| Network | 192 MiB | 320 MiB | -| Wasm/CPU | 768 MiB per worker | 1 GiB | -| GPU | 768 MiB | 1 GiB | -| Display | 192 MiB per viewer | 320 MiB | -| Unified estimated-live total | 1.25 GiB per load/viewer | 1.75 GiB global | - -The complete PM-75 §5.6 class table is mandatory; each entry needs exact and limit+1 admission tests: - -| Class | Owner bucket semantics | Per-owner/class ceiling | Global/class ceiling | -| --- | --- | ---: | ---: | -| Network in-flight | One bucket per load; concurrent request reservations for that load | 128 MiB | 256 MiB | -| Retained-fetched bytes | One bucket per load; retained response/file bytes | 128 MiB | 256 MiB | -| Decoded source/DATA | One bucket per load; parser source and decoded DATA for that load | 256 MiB | 384 MiB | -| Semantic arenas | One bucket per load; semantic model/arena lifetime | 256 MiB | 384 MiB | -| Scene CPU | One bucket per load/viewer scene generation | 384 MiB | 512 MiB | -| Decoded texture pixels | One bucket per load/viewer and per texture reservation; each texture also has a 64 MiB class ceiling | 256 MiB aggregate; 64 MiB per texture | 384 MiB | -| WASM high-water / committed pages | One bucket per worker, shared by every load in that worker; never multiplied per load | 768 MiB per worker | 1 GiB | -| GPU buffers | One bucket per load/viewer | 512 MiB | 768 MiB | -| GPU textures | One bucket per load/viewer | 512 MiB | 768 MiB | -| Canvas/backbuffers/presentation | One bucket per viewer; preview canvas has its own ephemeral viewer bucket | 128 MiB | 256 MiB | - -The class table is intentionally stricter than some domain ceilings. A prospective transaction must satisfy every applicable class ceiling plus its domain, owner, global, and unified ceilings. The ledger must expose the table in a typed snapshot so downstream callers cannot silently omit a class. - -### Explicit bucket semantics - -- **Per-load classes:** network in-flight, retained fetched, decoded source/DATA, semantic arenas, and scene CPU are charged to the load identity that created them. A replacement scene uses a new scene-generation bucket during old-plus-new overlap. -- **Per-load/viewer classes:** decoded texture pixels, GPU buffers, and GPU textures are charged to the owning load/viewer identity. Texture pixels additionally enforce an individual texture reservation ceiling. -- **Per-worker class:** WASM high-water/committed pages is one worker-wide high-water bucket. Two loads sharing one worker consume the same worker bucket; creating a second load does not create another 768 MiB entitlement. The global class and Wasm/CPU domain totals aggregate all workers. -- **Per-viewer class:** canvas/backbuffers/presentation is charged to the viewer bucket. Interactive and preview viewers are distinct viewer/load scopes, but both contribute to global domain and unified totals. -- **Global:** every class and domain has one process/application-wide global bucket. Global totals include all workers, viewers, loads, and ephemeral previews. -- **Unified:** the owner unified total sums charged class/domain values without deduplication; ownership transfer removes the source charge and adds the destination charge in one commit. - -## A↔B load identity contract - -A’s authoritative viewer state supplies `load_epoch` and `scene_generation`. B must define a dependency-light `ReservationOwner`/`LoadIdentity` containing: - -```text -worker_id + load_epoch + scene_generation + scope_kind -``` - -`scope_kind` distinguishes the primary interactive viewer from an ephemeral preview viewer/request. A new load increments `load_epoch`; an accepted scene replacement increments `scene_generation`. B reservations store the exact identity snapshot, not a mutable string or implicit current-load pointer. A reservation created for `(worker, epoch, generation)` cannot be used to admit work for a different epoch/generation; stale work must release/abort without mutating the current generation. A scene swap reserves the new generation before allocation, retains the old generation until commit, and releases the old generation only after the swap succeeds. Load cancellation, stale-epoch rejection, viewer disposal, and worker disposal release all reservations associated with the retired identity. - -For WASM high-water, the owner identity still records the worker and the initiating load/generation for diagnostics, but accounting is solely in the shared worker bucket defined above. Preview loads create ephemeral reservation scopes with the same global accounting and explicit release at preview completion/error/cancellation; they do not borrow the primary viewer’s identity or class counters. - -## Checked charge formulas - -Implement typed errors for invalid zero divisors/alignment/dimensions/counts and checked arithmetic overflow. Every intermediate operation is checked. - -- `ceil_div(n, d) = n / d + (n % d != 0)`; reject `d == 0` and checked-result overflow. -- `align_up(n, a) = ceil_div(n, a) * a`; reject `a == 0` and every checked intermediate overflow. -- WASM charge: `align_up(requested_capacity, 65_536)`. -- GPU buffer charge: `aligned = align_up(requested_capacity, 256)` then `aligned + ceil_div(aligned, 8)`. -- Canvas charge: `attachment_bytes = width * height * 4 * attachment_count * sample_count`; `presentation_bytes = width * height * 4`; `base = attachment_bytes + presentation_bytes`; charge `base + ceil_div(base, 4)`. Dimensions/counts must be nonzero valid integers. - -### Canonical texture input contract - -Do not delegate texture base computation to each caller. Define a public dependency-light typed input: - -```text -TextureChargeInput { - format: TextureFormat, - width: u64, - height: u64, - mip_count: u32, - layers: u32, - faces: u32, - samples: u32, -} -``` - -`TextureFormat` must encode the format’s block width, block height, and bytes per block (or equivalent uncompressed texel dimensions/bytes). A pure-`std` `texture_base_bytes(TextureChargeInput) -> Result` computes every mip level using checked ceil-divided block dimensions, clamps each mip dimension to at least one texel/block, multiplies by layers, faces, and samples, and sums with checked arithmetic. The caller then charges `base + ceil_div(base, 4)`. Reject zero dimensions/counts, invalid format metadata, mip counts that overflow the representable sequence, and all intermediate overflow. Canonical vectors must cover an uncompressed RGBA8 1x1 texture, multi-mip dimensions, compressed block formats, layers/faces/samples, and limit/limit+1 cases; all downstream tasks consume this result rather than independently recomputing bytes. - -## Reservation and admission model - -Implement one synchronized ledger state transition for each single-class admission, release, replacement, resize, and ownership transfer. A reservation handle/token has an idempotent release path and deterministic `Drop` or explicit release semantics. Every admission computes a prospective complete counter state before committing: - -1. validate formula input and parent capacity; -2. add the charge to class, domain, owner bucket, global class/domain/aggregate, and unified counters; -3. check all class, domain, owner, global, unified, and parent ceilings; -4. commit all counters and return the handle, or return a typed rejection with no mutation. - -Support child reservations constrained by parent remaining capacity, nested grandchildren, old-plus-new capacity growth/replacement, atomic source-to-destination ownership transfer, cancellation/error cleanup, and snapshot inspection. A failed operation never partially mutates counters; no successful operation oversubscribes a ceiling or double-counts a transfer. - -### Composite multi-class admission - -Single-class ledger admission is atomic; a multi-class request is a caller-orchestrated sequence because PM-88 does not expose a non-atomic batch as if it were atomic. The caller must use this pattern: - -1. deterministically order requested classes by the policy order `network in-flight`, `retained fetched`, `decoded source/DATA`, `semantic arenas`, `scene CPU`, `decoded texture pixels`, `WASM pages`, `GPU buffers`, `GPU textures`, `canvas/backbuffers`; -2. reserve each class through the ledger, retaining every successful handle; -3. on the first failed step, release all previously returned handles in reverse order and report the failing typed reason; no allocation begins until every required reservation has committed; -4. only after all steps succeed, perform the representative allocations; on any allocation error/cancellation, release every handle in reverse order. - -The caller owns rollback, and the documented sequence must make rollback mandatory and observable. Add a multi-step test where an early class succeeds, a later class fails at a shared domain/global/unified ceiling, rollback restores a byte-for-byte identical snapshot, and the allocation probes record zero allocation attempts. This explicitly rejects any claim that separately atomic class operations form an atomic composite without caller rollback. - -### Deterministic rejection reasons - -Rejection reasons are deterministic, not “first or complete” by implementation accident. Evaluate breach levels in this fixed order: - -1. most-specific class ceilings (including per-texture decoded-pixel ceiling), reporting **all** simultaneously breached classes at this level; -2. owning domain ceilings, reporting all breached domains; -3. owner load/viewer/worker bucket ceilings, reporting all breached owner buckets; -4. global class/domain/aggregate ceilings, reporting all breached global buckets; -5. unified owner/global ceilings; -6. parent remaining capacity; -7. invalid formula input, then arithmetic overflow. - -Within a level, sort typed identifiers by their stable enum/identity order. The returned error includes the complete set at the first breached level and never mixes lower levels into that result. Formula validation errors occur before policy evaluation; parent validation occurs before commit. Tests assert stable ordering and simultaneous-breach completeness. - -## Allocation-path audit and mandatory production bindings - -Document a mandatory audit row for every current/future representative path, with the current worker call site named and covered before implementation is considered complete: - -| Path | Current production boundary | Owner identity/bucket | Class/domain | Estimate | Reservation lifetime and state rule | -| --- | --- | --- | --- | --- | --- | -| primary/companion fetch | `load_mesh_async` → `fetch_bytes` closure (`worker.rs` 603-640) | load epoch/generation | network in-flight or retained fetched / Network | byte length or checked upper bound | reserve before request/read; release on response/error/cancel/retirement | -| parser source and DATA | `load_mesh_async` → `load_mesh_with_companions` (`worker.rs` 603-650) | load | decoded source/DATA / Wasm/CPU | checked source/decoded capacity | reserve before parser entry; release on parse error/replacement/dispose | -| semantic/scene build | `handle_load_mesh` result branch → `build_scene` (`worker.rs` 559-580, 174-218) | load + scene generation | semantic arenas or scene CPU / Wasm/CPU | checked arena/scene estimate | reserve new generation before `build_scene`; old generation remains until successful swap | -| texture decode | `build_scene` → `three_d::Model::new` material/texture construction (`worker.rs` 187-200) | load/viewer + texture id | decoded texture pixels / Wasm/CPU | canonical `TextureChargeInput` | reserve each texture before decode/upload; release on replacement/dispose/context loss | -| committed WASM pages | worker memory-growth boundary used by the worker bundle/runtime | worker bucket, diagnostic initiator identity | WASM high-water / Wasm/CPU | aligned page growth | reserve before growth; shared by all loads in worker; release on worker dispose | -| interactive WebGL/context and renderer state | canvas transfer → `WorkerState::init_renderer` (`worker.rs` 101-167, 878-889) | interactive viewer/worker | Display canvas plus GPU context/baseline resources / Display+GPU | checked initial canvas and context baseline estimates | reserve before `get_context_with_context_options`, `Context::from_gl_context`, and renderer-state commit; rejected admission leaves `canvas`/`renderer` unset | -| interactive scene/GPU upload | `build_scene` called from the live renderer branch (`worker.rs` 568-579), including `three_d::Model::new` | load/viewer + scene generation | GPU buffers/textures / GPU | checked buffer/texture formulas | reserve all required GPU classes before `Model::new`; rejected build leaves prior `renderer.model`, camera, and control unchanged | -| preview canvas/context/scene/render target | `render_preview_png` (`worker.rs` 690-723): `OffscreenCanvas::new`, WebGL context, three-d context, `build_scene`, `RenderTarget::screen` | ephemeral preview scope | canvas/backbuffers + GPU buffers/textures / Display+GPU | checked preview canvas and scene/resource estimates | reserve the complete preview sequence before the first real operation; release on success/error/cancel; no partial preview state escapes | -| interactive resize | `handle_command` `RendererCommand::Resize` (`worker.rs` 823-833): `set_width`/`set_height` then pixel state update | interactive viewer | canvas/backbuffers/presentation / Display | old and new full canvas charges overlap | admit old+new before either setter; rejection invokes neither setter and preserves prior dimensions/charge; successful swap releases old only after both setters and state commit | -| canvas transfer | `WorkerBridgeState::transfer_canvas` (`src/viewer.rs` 1197-1225`) and worker `handle_canvas_transfer` | viewer/worker | initial canvas attachment / Display | initial checked canvas charge | transfer admission precedes worker initialization; transfer/init failure releases reservation and preserves the prior bridge/worker state | - -These rows are production obligations, not abstract callback coverage. Allocator statistics, browser performance APIs, WebGL limits, and post-allocation byte counts remain diagnostics only and never substitute for admission. The ledger leaf stays dependency-light; the worker owns a small integration facade that translates typed estimates/owner identities into ledger reservations while retaining the real `web_sys`/three-d operations at these call sites. - -### Browser-backed allocation-admission proof - -The worker's existing `wasm_bindgen_test` browser suite and Playwright e2e suite must be extended to exercise the integrated paths, not merely a leaf probe: - -1. **Interactive canvas/context:** force a deterministic admission rejection before `handle_canvas_transfer`/`init_renderer` reaches `get_context_with_context_options`; assert no WebGL context acquisition, no `RendererReady`, and no `WorkerState.canvas`/`renderer` mutation. The admitted case asserts reservation commit precedes context creation and preserves the retained one-context behavior across model switches. -2. **Interactive resize:** drive a real `RendererCommand::Resize` against the live transferred canvas at exact-limit and limit+1 conditions; assert rejection causes zero `set_width`/`set_height` calls, no resize swap, unchanged pixel dimensions, unchanged renderer output/state, and unchanged ledger snapshot. Assert successful resize keeps old+new reservations until commit and then releases the old charge exactly once. -3. **Preview:** send `RenderPreviewImage` through the real worker path with a budget that rejects before `OffscreenCanvas::new`; assert no preview canvas/context/render target/GPU operation and no leaked preview reservation. The admitted browser case reaches `OffscreenCanvas::new`, WebGL, `build_scene`, `RenderTarget::screen`, and PNG output, then releases the ephemeral scope on success and error/cancellation. -4. **Scene/GPU upload:** use a real textured fixture through `build_scene`; reject before `three_d::Model::new`/material upload and assert the prior live model/camera/control remains installed. The admitted case proves ledger commit precedes model creation and covers GPU buffer and texture classes independently. -5. **WASM growth — mandatory feasibility-and-attempt ladder:** the implementor must document and attempt all three concrete interception strategies before any deferral is considered: (a) allocator integration, including a custom global allocator or equivalent allocation hook that charges the ledger before an allocation can trigger page growth; (b) toolchain/imported-memory or maximum-memory control, including setting the `WebAssembly.Memory` maximum and reserving against that maximum before representative work; and (c) Wasm instrumentation or runtime modification that intercepts/checks `memory.grow` before the grow commits. Each strategy requires repository/toolchain evidence, a concrete implementation attempt in the worker build or a minimal representative reproduction, and an outcome test; “requires generated/runtime changes,” “hard,” or an untried design is not an outcome. If direct interception remains infeasible, the implementor must implement and test an equivalent hard pre-allocation/enforcement mechanism—such as reserving the full configured maximum at worker initialization—that demonstrably prevents representative work from causing unreserved growth. A deferral is permissible only when both direct interception and equivalent enforcement fail under a named immutable constraint that is externally imposed and cannot be changed within the supported target/toolchain; the deferral record must identify the constraint, exact attempted commands/patches, failure outputs, target/runtime versions, and why no supported alternative exists. This deferral covers only the unobservable low-level `memory.grow` instruction; it never waives task-B line 45: representative WASM allocation rejection must still occur before the fixture’s allocation entry point, and an unresolved/uncontrolled representative allocation is not merge-ready. Browser tests prove the worker-wide bucket is shared by two load epochs, rejection prevents the representative allocation/growth path, and admitted work cannot cause unreserved growth. - -Any facade used by tests must be the same production facade invoked immediately before the corresponding real browser operation; a test harness that bypasses the integrated worker call site does not satisfy PM-88. - -## Numbered implementation steps - -1. **Verify task isolation and inspect task-local context.** Run the safety checks, read `.current-task/task.md` and `.current-task/plan.md`, and confirm the task workspace/bookmark. - - **Done when:** work is in `../epic-86-task-B` on `epic-86-task-B`, not `main`, with no unrelated changes being overwritten. - -2. **Add the std-only leaf crate and public documentation.** Add manifest, crate docs, typed identifiers, and the audit schema; keep forbidden renderer/parser/browser dependencies out of production and development dependency resolution; browser integration dependencies remain confined to the existing worker test target. - - **Done when:** native and wasm checks compile the leaf, and its wasm normal dependency tree contains no `three-d`, `three-d-asset`, `stl_io`, `polymodel-mesh`, protocol, web-sys, or wasm-bindgen entries. - -3. **Implement versioned policy and all PM-75 ceilings.** Add exhaustive classes/domains, per-owner/global scopes, unified limits, exact mappings, and typed snapshots. Encode every class table entry, including the per-texture 64 MiB decoded-pixel ceiling and the worker-wide WASM semantics. - - **Done when:** table-driven tests cover every class, mapping, domain, aggregate, exact limit, and limit+1; no class is silently unmapped or treated as additive entitlement. - -4. **Implement checked formulas and canonical texture computation.** Add checked helpers, WASM/GPU/canvas formulas, `TextureChargeInput`, `TextureFormat`, and pure-std mip/block base-byte calculation with canonical vectors and typed invalid/overflow errors. - - **Done when:** exact/limit+1 vectors, zero/invalid inputs, maximum values, and each intermediate overflow path return the specified result without wrapping or panic; all dependent callers have one canonical texture computation. - -5. **Implement synchronized single-class reservations.** Add atomic prospective-state admission, reservation handles, child/nested capacity, old-plus-new replacement/resize, transfer, release/drop idempotence, stale identity validation, and snapshots. - - **Done when:** every rejection leaves snapshots unchanged; no successful operation exceeds any class/domain/owner/global/unified/parent ceiling; transfer, replacement, nested release, cancellation, and dispose restore exact totals. - -6. **Implement explicit composite caller rollback and rejection semantics.** Document and expose the deterministic class order, reverse rollback sequence, fixed rejection-level ordering, complete same-level breach sets, and stable typed sorting. Add the multi-step failure behavior to the public contract. - - **Done when:** tests prove an early successful reservation plus later failure restores the pre-sequence snapshot and reaches no production allocation entry point; errors are stable and contain exactly the complete set at the first breached level. - -7. **Bind the ledger to every real worker allocation boundary.** Add the production integration facade to `WorkerState` and unconditionally route canvas transfer/`init_renderer`, interactive resize, preview `OffscreenCanvas::new`/WebGL/`RenderTarget::screen`, live `build_scene`/`three_d::Model::new` GPU upload, and the worker page-growth boundary through committed reservations. For WASM growth, complete the documented allocator, memory-maximum/imported-memory, and instrumentation/runtime feasibility-and-attempt ladder before claiming any unreachability; if direct interception fails, implement the equivalent hard pre-allocation/enforcement proof before considering the narrowly defined immutable-constraint deferral. Preserve old live renderer/model/canvas state until replacement admission and operation commit; release all handles on success, error, cancellation, stale epoch, context loss, and disposal. Keep owner identity mapped to A's `(load_epoch, scene_generation)` and use a distinct ephemeral preview scope. - - **Done when:** no listed real operation occurs before its reservation commits; each rejection invokes no corresponding browser/GPU/canvas operation; interactive resize preserves prior dimensions and renderer state on rejection; scene replacement preserves the old model until successful swap; preview scopes cannot leak; two loads share one worker WASM bucket; and production worker tests exercise these exact paths. - -8. **Add browser-backed allocation-admission tests and observable instrumentation.** Extend the worker's `wasm_bindgen_test` browser module and Playwright viewer/e2e coverage with deterministic budget controls or fixtures that force exact-limit and limit+1 outcomes. Assert no context/canvas/GPU/scene operation on rejection, reservation-before-operation ordering on success, unchanged prior live state and ledger snapshot on rejection, old+new resize overlap, one retained context across model switches, preview cleanup, and shared-worker accounting. Add a documented feasibility-and-attempt artifact for each WASM-growth strategy (allocator integration, toolchain/imported-memory or maximum-memory control, and Wasm instrumentation/runtime modification), including the concrete implementation/reproduction attempt and outcome test. If direct interception fails, the same suite must test an implemented equivalent hard pre-allocation/enforcement mechanism and prove representative work cannot cause unreserved growth. Only a named immutable external constraint that blocks both interception and equivalent enforcement can produce a `deferrals.jsonl` entry, which must include the exact constraint, target/toolchain/runtime versions, commands and patches attempted, failure outputs, and alternative analysis. Even then, the task-B line-45 acceptance proof remains mandatory: rejection must happen before the representative WASM allocation entry point, and the deferral may describe only an unobservable low-level instruction—not permit representative work to allocate without a reservation; canvas, context, scene, GPU, preview, and resize boundaries are never deferrable. - - **Done when:** browser tests fail if `get_context_with_context_options`, `OffscreenCanvas::new`, `set_width`, `set_height`, `three_d::Model::new`, `RenderTarget::screen`, or the corresponding real upload path is reached after rejection; admitted tests prove ledger commit first; prior live state and dimensions remain intact after rejection; and `just test-renderer` plus `just e2e` pass. - -9. **Add focused just recipes and preserve renderer split.** Add `test-renderer-ledger` (focused ledger tests, native/wasm checks, and integrated worker browser tests), add the exact ledger cargo-tree check and worker browser command to the validation surface, and include the focused recipe in `test-all` without weakening `verify-renderer-split`. - - **Done when:** `just test-renderer-ledger` runs the focused suite and wasm checks, `just test-renderer` runs integrated browser admission tests, `just e2e` runs Playwright admission tests, `cargo tree -p polymodel-renderer-ledger --target wasm32-unknown-unknown --edges normal` is clean, and the app tree remains free of all forbidden heavy crates. - -10. **Run validation, inspect diff, and commit.** Run the commands below, inspect `jj diff`, remove process residue, and commit with the required trailers. - - **Done when:** all applicable commands pass, only PM-88 files plus explicitly required worker/test/justfile/docs changes are changed, and the commit message contains exactly `Epic: PM-86` and `Task: PM-88` trailers. - -## Test strategy - -This combines Tier 1 pure unit/property/concurrency tests in the dependency-light ledger with mandatory browser-backed integration tests for the real worker. It does not use TestProvider, daemon subprocesses, or TUI `TestBackend`; those tiers do not exercise this renderer boundary. The worker's `wasm_bindgen_test` browser suite and Playwright e2e suite are required because PM-88 acceptance is specifically about admission before actual canvas/WebGL/GPU operations. - -| Behavior | Tier and concrete test | -| --- | --- | -| Checked arithmetic and WASM/GPU/canvas formulas | Tier 1 table/property tests for exact values, exact ceiling, limit+1, zero inputs, `u64::MAX`, each checked intermediate overflow, and no panic/wrap. | -| Texture base computation | Tier 1 canonical vectors for RGBA8 1x1, mip chains, compressed block formats, layers/faces/samples, invalid metadata, overflow, and resulting charge limit/limit+1. | -| All class/domain/unified limits | Tier 1 table-driven tests over all variants and both exact/limit+1 boundaries, including 64 MiB per-texture and 768 MiB worker WASM scopes. | -| Admission hierarchy | Tier 1 tests admit at each exact class/domain/owner/global/unified boundary, reject one byte over, and verify snapshots are unchanged. | -| Explicit scope semantics | Tier 1 tests use two loads sharing one worker: per-load classes remain separate, worker pages share one 768 MiB bucket, and global/domain/unified totals aggregate both. Preview gets a separate ephemeral viewer bucket and releases fully. | -| A↔B identity/stale cleanup | Tier 1 state tests create reservations for epoch/generation tuples, reject stale admissions, retain old generation during swap, and release all retired load/preview/dispose reservations exactly once. | -| Parent/child nesting | Tier 1 deterministic property tests generate nested capacities; reject child/grandchild overage without mutation and test parent release ordering. | -| Replacement/resize overlap | Tier 1 model tests hold old+new charges through commit, retain old on failure, and release old once on success; include canvas dimension/count/sample boundaries. | -| Ownership transfer | Tier 1 model/property tests verify source removal and destination addition are one commit, with no double-counted unified total and unchanged snapshots on failure. | -| Composite admission rollback | Tier 1 multi-step sequence where a later class breaches shared domain/global/unified capacity; assert reverse rollback restores the exact snapshot and no production allocation entry point is called. | -| Deterministic rejection reasons | Tier 1 tests create simultaneous breaches at each level and assert class→domain→owner→global→unified→parent ordering, complete same-level sets, and stable enum/identity sorting. | -| Worker page-growth integration | Tier 1 ledger semantics plus browser worker tests of the production growth facade: reservation commit precedes representative allocation/growth, rejection makes no representative allocation/growth call, and two loads share one worker ceiling. Before any deferral claim, test and document the mandatory allocator-integration, imported-memory/maximum-memory-control, and Wasm-instrumentation/runtime-modification attempts; if direct interception fails, test equivalent hard pre-allocation/enforcement that prevents unreserved growth. A `deferrals.jsonl` record is valid only for an unobservable low-level instruction blocked by a named immutable external constraint after all attempts and alternatives are evidenced, and never waives rejection before representative WASM allocation. | -| Interactive canvas transfer/context | Browser worker test through `handle_canvas_transfer`/`init_renderer`: rejection precedes `get_context_with_context_options`, leaves canvas/renderer unset, and emits no ready event; admission precedes real context creation. | -| Interactive scene/GPU upload | Browser worker test with the textured fixture through the live `build_scene` branch: rejection precedes `three_d::Model::new` and upload, preserves prior model/camera/control, and admission precedes real construction. | -| Preview allocation path | Browser worker test through `RenderPreviewImage`/`render_preview_png`: rejection precedes `OffscreenCanvas::new`, context, `build_scene`, and `RenderTarget::screen`; success reaches all operations and releases the ephemeral reservation on every completion path. | -| Canvas resize | Browser worker test through a real `RendererCommand::Resize`: rejection invokes neither `set_width` nor `set_height`, preserves prior dimensions/output/ledger snapshot, and success holds old+new until committed swap. | -| Playwright end-to-end regression | `just e2e` viewer specs assert no rejected operation changes ready state, dimensions, context-acquisition count, or selected model; admitted mount/resize/model-switch and preview flows reach real worker paths. | -| Concurrent admissions | Tier 1 native-thread tests race requests over shared class/domain/unified ceilings against the real synchronized ledger; assert no oversubscription and final totals equal live handles. Add wasm target compilation/single-thread compatibility check. | -| Cleanup/error/cancellation | Tier 1 lifecycle tests plus browser worker coverage for failed admission, explicit/drop release, parse/fetch/upload simulation, stale epoch, preview completion/error/cancel, context loss, worker/viewer dispose, and double release. | -| Renderer split and leaf dependency | Repository checks: `just verify-renderer-split`; leaf cargo tree command below; app dependency tree must remain free of `three-d`, `three-d-asset`, `stl_io`, and `polymodel-mesh`. | -| Workspace/full regression | Run `just check`, `just fix`, `just lint`, focused ledger tests, `just test-renderer-ledger`, `just test-renderer`, `just e2e`, and `just test-all`; all named browser admission tests are mandatory. | - -Required command sequence, with the new focused recipe and ledger tree check explicit: - -```sh -just check -just fix -just test-renderer-ledger -cargo test -p polymodel-renderer-ledger -cargo check -p polymodel-renderer-ledger --target wasm32-unknown-unknown -cargo tree -p polymodel-renderer-ledger --target wasm32-unknown-unknown --edges normal -just lint -just verify-renderer-split -just test-renderer -just e2e -just test-all -``` - -`just check` must include the ledger’s wasm check, and `test-all` must invoke `test-renderer-ledger`; avoid duplicating commands in a way that hides failures. The implementor must preserve every required behavior in the focused test suite and report the exact command/environment result for each required test. - -## Implementation review expectations - -Reviewers must inspect the complete PM-88 diff and verify: - -1. every PM-75 class ceiling and scope is present exactly, including the 64 MiB per-texture decoded-pixel ceiling and worker-wide 768 MiB WASM bucket; -2. every formula uses checked `u64` arithmetic, integer-only dimensions/counts, upward rounding, and no unchecked intermediate; -3. `TextureChargeInput` is the sole canonical base-byte contract and its vectors cover format/mip/layer/face/sample cases; -4. admission is atomic across class/domain/owner/global/unified/parent counters, with no partial mutation, and composite callers visibly rollback in reverse order; -5. rejection ordering is deterministic and reports complete simultaneous breaches at the first level; -6. A’s `(load_epoch, scene_generation)` identity maps to B reservation lifetimes, including stale load, preview, replacement, cancellation, and disposal; -7. the real worker boundaries prove reservation-before-WASM-growth/context/canvas/GPU/scene/preview/resize operations and no real allocation call on rejection, with prior live state preserved; -8. concurrency, nesting, transfer, replacement, cleanup, browser rejection, and two-load/shared-worker cases are covered by tests; -9. the leaf remains std-only/dependency-light and the exact cargo-tree and renderer split checks pass; -10. no generated files, unrelated worker/UI changes, or AI/process residue are present. - -Load `reviewing-code-for-shipping-quality` for the final implementation-quality pass. No `frontend-design` skill is needed; PM-88 has no UI surface. - -## Commit and parent handoff - -Commit on `epic-86-task-B` with a concise message such as `PM-88: add renderer reservation ledger` and trailers: - -```text -Epic: PM-86 -Task: PM-88 -``` - -Before returning to the parent, provide: - -- task bookmark and commit/change ID; -- complete list of changed files, including the new leaf crate, docs, and justfile recipe; -- exact focused, property/concurrency, wasm, cargo-tree, renderer-split, and full-test commands with pass/fail results; -- confirmation that no forbidden renderer/parser dependency entered app WASM or the ledger leaf; -- confirmation that all ten class ceilings, domain/unified limits, formulas, identity scopes, composite rollback, rejection ordering, and allocation audit seams are covered; -- `jj diff`/review result showing no unrelated changes; -- any blocker stated as an observed command/tool failure, not an assumption. - -The isolated workspace must be merge-ready with the complete ledger API, frozen constants, checked formulas, canonical texture contract, reservation transactions, explicit scope/identity semantics, composite rollback contract, real worker allocation bindings, browser admission tests, and audit documentation. It must not be a scaffold waiting for later tickets. - -## Prior finding dispositions (planner-B4 revision) - -- **B-plan-2-F1 — fixed and retained:** The complete PM-75 §5.6 class table remains explicit, including all ten class ceilings, the 64 MiB per-texture decoded-pixel ceiling, and exact/limit+1 coverage. -- **B-plan-2-F2 — fixed and retained:** Owner semantics remain class-specific: per-load, per-load/viewer, worker-wide shared WASM, per-viewer display, global aggregation, and two-load/shared-worker tests. -- **B-plan-2-F3 — fixed and strengthened:** The plan now binds reservations to real worker operations at `init_renderer`, interactive resize, preview `OffscreenCanvas::new`/WebGL/`RenderTarget::screen`, and live `build_scene`/`three_d::Model::new`; browser tests prove rejection prevents each operation and preserves state. -- **B-plan-2-F4 — fixed and retained:** `worker_id + load_epoch + scene_generation + scope_kind` remains the reservation identity, with A lifecycle, preview scopes, stale generations, and disposal cleanup explicitly mapped. -- **B-plan-2-F5 — fixed and retained:** Caller-orchestrated deterministic class order and reverse rollback remain mandatory, with no production allocation before all required reservations commit and exact snapshot restoration on failure. -- **B-plan-2-F6 — fixed and retained:** `TextureChargeInput` is still the sole canonical typed base-byte contract with pure-std mip/block computation and canonical vectors. -- **B-plan-2-F7 — fixed and strengthened:** The plan names `just test-renderer-ledger`, the exact ledger cargo-tree command, `just test-renderer` for worker browser tests, `just e2e` for Playwright, and requires focused coverage in `test-all`. -- **B-plan-2-F8 — fixed and retained:** Fixed rejection-level ordering, complete same-level breach sets, stable sorting, and corresponding tests remain explicit. - -- **B-plan-3 skeptic rejection — fixed:** Removed the prior conditional worker-integration, abstract seam completion, and conditional browser-test wording. Production integration is mandatory at every current representative canvas/context/GPU/scene/preview/resize boundary named in `worker.rs`, and the WASM-growth path now requires a documented implementation attempt for allocator integration, imported-memory/maximum-memory control, and Wasm instrumentation/runtime modification, followed—if interception fails—by equivalent hard pre-allocation/enforcement. A deferral is allowed only for an unobservable low-level instruction when both interception and equivalent enforcement are blocked by a named immutable external constraint, with reproducible evidence and no waiver of task-B line-45 rejection before representative WASM allocation; this cannot reduce the mandatory current worker scope. diff --git a/.current-epic/plan-C.md b/.current-epic/plan-C.md deleted file mode 100644 index ce5b7d9..0000000 --- a/.current-epic/plan-C.md +++ /dev/null @@ -1,335 +0,0 @@ -# PM-76 — LDraw library projection and ATProto dependency manifest - -## Task identity - -- **Epic:** PM-86 — LDraw/LEGO model support and flexible renderer -- **Epic URL:** https://radiant-industries.atlassian.net/browse/PM-86 -- **Task:** PM-76 — LDraw library projection and ATProto dependency manifest -- **Task URL:** https://radiant-industries.atlassian.net/browse/PM-76 -- **Task prefix:** C -- **Task cache:** `.current-epic/task-C.md` -- **Design source:** [PM-75 LDraw/LEGO support and flexible renderer architecture](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) -- **Dependency position:** no predecessors; blocks G/PM-80 and J/PM-83 - -## Workspace and safety - -- **Epic workspace:** `../epic-86` -- **Base bookmark:** `main` -- **Task workspace:** `../epic-86-task-C` -- **Task bookmark expectation:** `epic-86-task-C` -- **Expected task change:** `PM-76: LDraw library projection and ATProto dependency manifest` -- **Required commit trailers:** - - ```text - Epic: PM-86 - Task: PM-76 - ``` - -Before editing, run: - -```sh -jj workspace root -jj workspace list -jj status -jj log -r @ -n 1 -``` - -Stop immediately when the current workspace is the default workspace or when `@` is on `main`; do not edit or commit there. The implementor must work only in `../epic-86-task-C` / `epic-86-task-C`, start from a fresh change, and use jj for status, diff, commit, and handoff. Do not create or modify the Epic state files, task state, merge gate, manifest, dependency graph, or event log. - -## Context - -PM-76 is the server/schema foundation for the settled PM-75 LDraw resource contract. It adds authored ATProto lexicons, a local SQLite projection and resolver index, pinned official-library metadata, exact-byte integrity, authorization-bound opaque same-origin resource routes, and a crash-safe verification lease state machine; it does not implement an LDraw parser, scene IR, worker protocol, or renderer. Existing `src/indexing/projection.rs` is the transactional Hydrant projection dispatcher, `src/appview/mod.rs` is the explicit XRPC router, `src/appview/downloads.rs` is the current PDS-backed part stream, and `src/appview/writes.rs` is the existing owner-authenticated blob upload/hash seam. - -## Grounded implementation surface - -The implementor should inspect these files again in the task workspace before changing them: - -- `lexicons.kdl`, `lexicons/library/*.json`, `lexicons/actor/*.json`, `lexicons/graph/*.json`: authored schema conventions and generated namespace layout. -- `justfile:21-56,109-111`: `check`, `fix`, `test-server`, `test-all`, `sqlx-prepare`, and `generate-api` commands. -- `migrations/0001_initial_schema.sql`, `migrations/006_drafts.sql`, `migrations/007_follows.sql`, `migrations/008_lists.sql`: additive migration style, owner/DID keys, timestamps, and current upload/pending state. -- `src/indexing/projection.rs:100-304,346-645`: transaction/cursor handling, collection dispatch, typed decode, idempotent upsert/delete, and pending-write convergence. -- `src/indexing/db.rs:20-35,50-84`: pool/migration startup and checked cursor queries. -- `src/appview/mod.rs:66-148`: generated XRPC mounting and plain same-origin `/app/*` route mounting. -- `src/appview/proxy.rs:1-24,65-357,379-412`: structural non-Polymodel allowlist, OAuth extraction, eager projection, and read-your-writes behavior. -- `src/appview/writes.rs:90-127,481-520,1381-1529`: authenticated DID extraction, PDS-first writes, streamed SHA-256, blob/chunk manifests, and owner-scoped staging queries. -- `src/appview/downloads.rs:36-217`: current URI-based part lookup, PDS `getBlob`, streaming, and manifest preflight; this is not a sufficient implementation of PM-75 §5.5 because it performs client-side rather than server-side verification and accepts an AT URI rather than a server-derived opaque route. -- `src/appview/drafts.rs:42-296`: same-origin cookie-authenticated endpoint pattern. -- `src/appview/tests.rs`, `src/indexing/projection.rs` tests, and module-local SQLite helpers: in-memory migration setup, router tests, projection fixtures, and the rule that production SQL is checked while test seeding may use runtime SQL. -- `.sqlx/`: committed SQLx offline query metadata; generated changes must be reviewed and committed. - -ATProto identities must remain typed at request and domain boundaries (`Did`, `AtUri`, `Cid`, `BlobRef`, `StrongRef` where applicable), converting to SQLite text or response text only at system boundaries. Define source identity as `(resource_identity, content_hash)`: the canonical DID-authoritative resource AT URI plus a canonical complete-content hash (ordered blob-CID set together with declared SHA-256 and exact length). A record CID is provenance/version metadata, not the source identity; re-putting unchanged bytes must not reset rejection or expiry state. File bytes are identified by blob CIDs and independently verified with exact length and SHA-256. - -## Replace-vs-edit assessment - -| Area | Assessment | Plan decision | -| --- | --- | --- | -| Authored lexicons | Existing library lexicons model generic `thing/model/part/file`, not LDraw manifests, roots, resource provenance, verification, or route minting. Adding those fields to the generic `File` would make the existing public part contract carry a different trust/state model. | **Add dedicated authored lexicons** for the settled LDraw resource/manifest/dependency surface. Extend an existing lexicon only where PM-75 explicitly requires a compatible view/reference; do not alter generated files by hand. | -| Generated `crates/polymodel-api` | Entirely generated and currently regenerated from `lexicons.kdl`. | **Regenerate, never edit.** Review generated diff for only the authored PM-76 additions and required upstream dependencies. | -| SQLite schema | `parts`, `upload_staging`, and `pending_*` do not represent virtual roots, canonical path indexes, source identity, verification attempts, revocation, or authorization partitions. | **Add an additive migration with dedicated tables/indexes**, retaining legacy rows for migration handling. Do not rewrite `0001_initial_schema.sql` or overload unrelated tables. | -| Projection dispatcher | The existing match is small and stable, but PM-76 needs several collection-specific projections, collision checks, legacy classification, and transactional update rules. | **Add a dedicated PM-76 projection module** called from the dispatcher. Keep generic record decoding/cursor/pending semantics; replace no unrelated collection handlers. If the PM-76 branch would make the central match or helper exceed its existing responsibility, dispatch to the new module rather than adding branches to complex upload/download code. | -| Canonical path/resolution logic | No existing helper satisfies NFC, literal-percent rejection, root precedence, collision, and case-policy requirements. This is a new domain, not a safe extension of `polymodel-mesh::companions`. | **Create a dedicated pure resolver/canonicalization module** with explicit typed inputs/outputs and vector tests. Do not patch the mesh companion URI guard into a manifest resolver. | -| Verification state machine | No existing state machine has attempt tokens, source-version CAS, DB-clock leases, stale-writer rejection, expiry counting, or revocation. | **Create a dedicated state-machine/repository module** with pure transition decisions plus checked transactional SQL. Do not distribute state transitions across route handlers. | -| Existing `getPartFile` route | It is an AT-URI route that streams PDS blobs and relies on browser verification. It cannot satisfy opaque route binding, verified-only reads, authorization at mint/fetch, or cache partitioning. | **Add a dedicated opaque LDraw resource route and harden the legacy boundary.** Before any lookup/blob access, if the parsed AT-URI collection is exactly `space.polymodel.library.ldrawResource`, hard-block with the defined 404/403 policy and never fall through to generic `getPartFile`. Preserve generic behavior only for exact legacy `space.polymodel.library.part` records that are not PM-76 resources; no LDraw resource may have an integrity-free path. | -| Same-origin authorization | Existing OAuth extraction/authenticated DID helpers are reusable, but PM-76 needs mint-time and fetch-time checks for principal/project/expiry/revocation. | **Reuse authentication primitives; add a dedicated authorization/token service** rather than embedding authorization in Axum handlers. | -| Tests | Existing projection/router tests are useful infrastructure but do not cover PM-75’s canonical paths, route substitution, state races, or migration conversion. | **Add focused pure unit tests, migration-backed projection tests, and router/integration tests.** Do not replace existing tests or weaken current generic download/proxy assertions. | -| Official-library ingestion | There is no current pinned-library ingestion boundary. | **Add an explicit snapshot/configuration and ingestion boundary** that records snapshot identifier, digest, notices/licence, and canonical collision outcome. Do not silently fetch an unpinned or publisher-supplied library. | - -## Consumption contract for PM-80 (G) - -PM-76 is the provider of the server-side resolver consumed by PM-80. Freeze these names and semantics before implementation; PM-80 must not reverse-engineer SQLite tables or call an HTTP endpoint. - -- **Boundary:** an in-process Rust trait/service shared by the server-side resolver. Browser-facing opaque routes are adapters over it; PM-80 does not depend on HTTP. -- **Stable module/re-export:** define the public contract in the PM-76 resolver module (for example `src/ldraw/resolve.rs`) and publicly re-export `CacheKey` from that module. PM-80 imports that re-export rather than defining another key; the final module path must be recorded in the handoff. -- **Resolver signature:** - - ```rust - #[async_trait] - pub trait LdrawResourceResolver: Send + Sync { - async fn resolve( - &self, - principal: Option<&Did>, - project: &AtUri, - path: &CanonicalPath, - ) -> Result; - } - ``` - - The concrete service is injected into `AppState`. It receives an authenticated principal (or `None` only for a PM-75-declared public resource), a typed project/resource identity, and an already canonicalized path. It never accepts or returns a publisher URL. -- **`ResolvedResource`:** a stable public struct containing `cache_key: CacheKey`, `resource: ResourceIdentity` (DID-authoritative AT URI plus content identity), `root: RootId`, `path: CanonicalPath`, `target: TargetIdentity` (record/blob CID set), `mime_type`, `byte_length`, `sha256`, and an authorized byte-source handle/stream descriptor. It represents verified content only. -- **One `CacheKey`:** define one struct in the canonicalization/resolver module and use it verbatim for route-token binding, resolver output, projection invalidation, cache indexes, and PM-80. Fields: `principal: Option`, `project: AtUri`, `source: SourceIdentity`, `root: RootId`, `path: CanonicalPath`, and `target: TargetIdentity`. `SourceIdentity` is resource identity plus content hash, never raw record CID alone. No other cache tuple or ad hoc key is permitted. -- **`ResolveError`:** freeze `NotFound`, `Unauthorized`, `Unverified`, `Verifying`, `Rejected { diagnostic: DiagnosticCode }`, `Revoked`, `SourceChanged`, `InvalidPath { diagnostic: PathDiagnostic }`, `Collision { diagnostic: CollisionDiagnostic }`, `Integrity { diagnostic: IntegrityDiagnostic }`, and `Unavailable { diagnostic: AvailabilityDiagnostic }`. PM-80 may map them to protocol errors but must preserve authorization, trust-state, path, collision, and integrity distinctions. -- **Token/cache semantics:** opaque browser routes use stateful DB-backed token-digest lookup with persisted bindings and immediate revocation. `CacheKey` includes principal/project and source identity so public and principal-bound data cannot cross-contaminate. PM-80’s resolver does not mint or parse browser tokens. - -**Done when:** PM-80 can import one named trait, `ResolvedResource`, `ResolveError`, and re-exported `CacheKey` with no HTTP coupling; resolver output is verified-only and carries the same key used for invalidation; compile-time/use-site coverage proves the contract. - -## Implementation steps - -### 1. Establish the authored PM-76 schema contract - -1. Re-read PM-75 §3.3 and §5.5 and express only settled v1 behavior in new authored JSON under the appropriate `lexicons/` namespace. The schema must make the ATProto dependency manifest/resource identity explicit: DID-authority URI, record CID/version, blob CID(s), canonical virtual path/root, exact byte length, SHA-256, MIME, provenance, licence/notice data, verification/revocation metadata required by the appview contract, and server-derived resource reference/output shape. -2. Model references as filenames/manifest identities, never fetch URLs. Keep principal/authorization and route material server-derived; they must not be publisher-controlled record fields. -3. Use existing ATProto schema types where they express the contract (`format: did`, `at-uri`, `cid`, `blob`, `datetime`, and `com.atproto.repo.strongRef` where a record reference is required). Do not invent a second string encoding of a typed ATProto identity. -4. Keep generated API code out of authored changes. Run `just generate-api` only after the JSON is complete, then inspect the generated diff for namespace, required-field, bytes/blob, and XRPC encoding correctness. - -**Done when:** authored lexicons fully describe the PM-76 request/response/record contracts without URLs; `just generate-api` succeeds; generated bindings expose the exact types needed by server code; no generated file was manually edited; generated diff contains no unrelated namespace churn. - -### 2. Add additive storage with a pinned migration number - -1. Add exactly `migrations/009_ldraw_resources.sql` for PM-76. Do not edit or renumber earlier migrations. If `009` already exists in the task workspace, stop and coordinate a reserved PM-76 number before editing rather than silently colliding. -2. Store resource/manifest identity separately from entries. Include DID-authority URI/rkey, record CID/version, blob CID set, exact byte length, SHA-256 bytes, MIME, provenance/licence/notices, canonical path/root, official snapshot identity, and current/legacy classification. -3. Define `SourceIdentity` as `(resource_identity, content_hash)`, where resource identity is the canonical DID-authority resource AT URI and content hash is the canonical complete-content identity (ordered blob-CID set plus declared SHA-256/length as PM-75 requires). A record-CID-only change with unchanged content does not reset verification. A changed blob-CID set, SHA-256, or exact length creates a new identity and resets verification. Never use raw record CID as the sole source identity. -4. Add MPD virtual files, uploaded manifest files, uploaded LDraw root files, official snapshot/root rows, and canonical root/path indexes sufficient to form the single `CacheKey`. -5. Add verification state exactly `unverified`, `verifying`, `verified`, `rejected`, with attempt token, `SourceIdentity`, DB-clock lease expiry, expired-attempt count, stable diagnostic, and revocation marker/time. Prevent an active lease from being replaced. -6. Add stateful opaque route records: random token digest, full `CacheKey` binding, source/manifest version, root/path, target, principal/project, expiry, and revocation. Persist enough state for fetch-time lookup and immediate revocation; do not describe these as stateless signed tokens. -7. Add indexes for resource/DID, root/path, source identity, state/lease expiry, revocation, and token authorization. SQLite currently has no FK enforcement and `PRAGMA foreign_keys` is off; do not rely on FKs or cascades. Every replacement, delete, source change, and revocation must clean or invalidate dependent entries, routes, and cache indexes in the same application-level SQL transaction. -8. Preserve legacy rows and migration state; migrated rows start `unverified` and cannot be returned by resolver/resource reads. - -**Done when:** a fresh in-memory database and an upgraded database both apply the migration; schema constraints reject duplicate resource identities, duplicate canonical paths, and route/token collisions; legacy data remains represented but cannot be read as verified PM-76 content; all production SQL introduced in this step uses `query!`, `query_as!`, or checked scalar macros. - -### 3. Implement pure canonicalization, root policy, and manifest validation - -Create a focused module (for example `src/ldraw/paths.rs` or an equivalently named PM-76 domain module) that has no filesystem, network, Axum, or database dependency. - -1. Validate UTF-8 and NFC, rejecting non-NFC spellings instead of silently rewriting them. Reject literal `%` without percent-decoding, backslash, empty/dot/dot-dot segments, leading/trailing slash, controls, colon, query/fragment syntax, and any path that escapes its root. -2. Preserve canonical spelling and avoid Unicode case folding. Use ASCII case-insensitive indexing only for uploaded LDraw roots and the official library as specified by PM-75. -3. Encode fixed lookup precedence: current MPD virtual-file table exact-case first; uploaded manifest relative directory then manifest root exact-case; uploaded roots MODELS → PARTS → P with explicit `s/`, `8/`, and `48/` staying within their standard root; pinned official library PARTS → P including standard subroots. -4. Reject duplicate MPD names, duplicate canonical manifest keys, and folded collisions in uploaded/official roots. Reject ambiguous or out-of-root results. Explicit root prefixes must not fall through into another root. -5. Produce a typed resolution containing canonical spelling, source/root identity, target record/blob CID, and cache identity. Never return a network URL or accept a filename as a URI/network request. -6. Validate manifest source records for exact byte length, SHA-256 shape, CID validity, MIME, root/path consistency, and required provenance/licence/notice metadata before indexing. - -**Done when:** pure tests cover every rejection rule, NFC acceptance/rejection, exact-case and ASCII-case behavior, MPD shadowing, relative-directory lookup, MODELS/PARTS/P precedence, explicit-root isolation, duplicate/collision rejection, out-of-root rejection, cache identity, and “filename never activates URI/network access.” - -### 4. Implement transactional projection and pinned-library ingestion - -1. Extend `ProjectionInput` dispatch for `space.polymodel.library.ldrawResource` and associated PM-76 collections while retaining existing transaction, durable cursor, typed decode, idempotence, and pending-write convergence. -2. Validate the complete create/update before changing storage. In one transaction, application-cascade old dependent indexes/routes/cache rows, write new resource/index rows, then apply source semantics: unchanged content preserves verification despite record-CID replacement; changed content atomically transitions `verified|verifying → unverified`, resets `expired_attempts` to zero, clears token/lease as appropriate, invalidates every affected `CacheKey` partition, and requires re-verification before reads. -3. Project delete/revocation as one atomic application-level cascade/invalidation transaction; no route, cache, dependent file, or resolver lookup remains readable. Retain auditable rejected/revoked identity when required. Replay is idempotent and stale events cannot overwrite newer source identity. -4. Add an official snapshot loader with pinned identifier, exact digest, licence/notices, deterministic collisions, and offline reproducibility. An updated snapshot is a new source identity and follows the same demotion/reset/invalidation rule. -5. Keep this independent of PM-78 parsing or scene compilation. - -**Done when:** Hydrant-style create/update/delete events produce the expected rows atomically; malformed, duplicate, collision, stale, and revoked inputs produce no readable partial projection; replay is idempotent; pinned official-library ingestion records digest/licence/notices and supports deterministic offline validation; source changes invalidate/rebuild the correct indexes. - -### 5. Implement verification state machine and repository CAS - -1. Define versioned lease duration, maximum expired attempts `3`, and stable diagnostics as code constants, not request inputs. -2. Provide pure transition decisions and checked repository transactions for claim, completion, rejection, source reset, expiry reconciliation, and revocation. Production uses repository/database time; tests inject a DB clock. -3. Pin the trigger to **lazy claim on first resolver/resource read or route mint** for `unverified`. There is no eager sweep in PM-76. Concurrent claims for the same resource/source coalesce onto the active lease; losing callers receive `ResolveError::Verifying` and never replace the winner token. -4. Claim atomically writes a unique token, current `SourceIdentity`, and DB-clock expiry. Completion CAS requires current token, unchanged resource-plus-content identity, no revocation, and unexpired lease. Stale workers never publish `verified`. -5. Expiry with unchanged source returns to `unverified` while count `< 3`; the next claim gets a new token. The third expiry becomes `rejected` with stable diagnostic. A changed source atomically demotes to `unverified`, resets count to zero, clears prior lease/token, and invalidates route/cache state. Revocation becomes `rejected` and invalidates routes/caches. -6. Only `verified` entries are resolver-readable or byte-serving; every other state maps to the frozen error contract. - -**Done when:** unit and SQLite transaction tests prove claim exclusivity, token uniqueness, source-version checks, lease expiry boundaries, stale-writer rejection, three-expiry rejection, source-change reset/retry, revocation races, and verified-only reads without wall-clock sleeps. - -### 6. Implement PM-80 resolver and authorization service - -1. Implement and re-export the frozen `LdrawResourceResolver`, `ResolvedResource`, `CacheKey`, and `ResolveError` from the documented resolver module. Add a compile-time/use-site test for PM-80 consumption without HTTP. -2. Reuse `ExtractOAuthSession` and `authenticated_did`; resolve handles to DID before local lookup. Apply only PM-75-declared public policy; all principal/project bindings are server-derived and typed. -3. Resolver flow is canonicalize → form the one `CacheKey` → authorize → inspect source/revocation/state → lazily claim if unverified → fetch and verify bytes → CAS publish verified → return `ResolvedResource`. It returns no bytes/handle for non-verified states. -4. Partition every in-memory/persisted cache by the exact `CacheKey`; invalidation uses the same fields for source/resource, root/path, target, principal/project, and revocation. No alternate key definition is allowed. - -**Done when:** route adapters and PM-80 share one implementation; PM-80 imports stable names with no HTTP coupling; resolver results are verified-only; cache hits cannot cross principals/projects/sources. - -### 7. Add stateful opaque routes and harden `getPartFile` - -1. Add mint/fetch handlers over the in-process resolver. Mint stores a random opaque token digest and full `CacheKey`/authorization/expiry/revocation binding; fetch performs DB lookup, constant-time digest comparison, expiry/revocation/principal checks, canonical resolution, and verified-only serving every time. -2. Test token path/root/target/source/principal/project/expiry substitution, replay, cross-principal reuse, expiration, revocation, source-content changes, and cache invalidation. Publisher fields never supply fetch URLs. -3. In `getPartFile`, check `req.uri.path().collection.as_str()` before manifest/blob access. If it equals exactly `space.polymodel.library.ldrawResource`, **hard-block** with the defined 404/403 response and never fall through. Only exact legacy `space.polymodel.library.part` may use compatibility behavior, and PM-76 LDraw resources must not be represented as that type. -4. Preserve generic non-LDraw behavior/tests while ensuring every PM-76 resource uses the verified opaque service; no LDraw integrity-free path exists. -5. After authorization/verification, stream bytes and verify each chunk’s blob CID multihash against the complete received chunk bytes. Then verify ordered reassembly exact length and SHA-256 over raw bytes. Corruption, truncation, reorder, CID mismatch, length mismatch, or digest mismatch never publishes `verified` or serves a verified response. Do not normalize/decompress before hashing. -6. Mount only required same-origin routes; retain structural proxy allowlist and 404/405 unsupported publisher-controlled routes. - -**Done when:** authenticated/public mint and fetch cases match PM-75; all substitution/replay/expiry/revocation/cross-principal/source-change cases fail safely; no response path serves an unverified or integrity-free resource; no route accepts a publisher-controlled fetch URL; successful responses have stable content type/length behavior and route/cache invalidation is observable in tests. - -### 8. Integrate legacy migration and protect existing APIs - -1. Define the legacy migration classification and conversion path for existing `parts`/`upload_staging` records. Preserve record/blob identities and exact file metadata where available, mark migrated resources `unverified`, and require the PM-76 verification state machine before a read is permitted. -2. Ensure legacy resources with absent/invalid digest, length, source identity, or collision metadata are rejected or remain unreadable with stable diagnostics rather than being promoted based on client-side checks. -3. Keep the existing generic `space.polymodel.library.getPartFile` contract and current non-LDraw tests explicit. The exact collection discriminator `space.polymodel.library.ldrawResource` must hard-block this legacy endpoint before lookup/blob access; only non-LDraw `space.polymodel.library.part` records may use compatibility behavior. -4. Do not change the existing proxy allowlist. Confirm `applyWrites`, `importRepo`, `listMissingBlobs`, arbitrary NSIDs, and direct publisher URL patterns remain unavailable. - -**Done when:** an upgraded database migration test demonstrates legacy rows are retained but unreadable until verified; all legacy invalidity cases have stable outcomes; existing generic library/proxy behavior remains green; every PM-76 resource access path goes through authorization and verification. - -### 9. Regenerate caches, validate, review, and commit - -After implementation, inspect `jj diff` and run the commands in the test strategy in the stated order. When production SQL or migrations changed, run: - -```sh -just sqlx-prepare -``` - -When authored lexicons changed, run: - -```sh -just generate-api -``` - -Review the generated `crates/polymodel-api` and `.sqlx` diffs for unrelated churn, then run the full gates. Commit only after the task workspace is independently green, with the required trailers: - -```text -Epic: PM-86 -Task: PM-76 -``` - -**Done when:** `jj diff` contains only PM-76 work, generated API/SQLx artifacts are present and reviewed, all required commands pass, no required test is ignored, and the task commit is on `epic-86-task-C` rather than `main`. - -## Test strategy - -Tests must map each contract to a concrete tier and infrastructure. The PM-76 task is server/persistence work; renderer-worker tests are not a substitute for these tests. - -### Tier 1 — pure unit tests - -Add tests in the dedicated PM-76 domain modules for: - -- UTF-8 NFC canonical path acceptance and non-NFC rejection. -- Rejection of literal `%`, backslash, empty/dot/dot-dot segments, controls, colon, query/fragment, leading/trailing slash, and root escape. -- Exact-case behavior, ASCII-only case-insensitive behavior, no Unicode case folding, and explicit-root isolation. -- MPD/manifest/uploaded-root/official-library precedence, MPD shadowing, relative-directory lookup, MODELS → PARTS → P order, standard `s/`, `8/`, `48/` roots, and missing-resource diagnostics. -- Duplicate exact names, duplicate canonical keys, and ASCII-folded collisions. -- The single `CacheKey` struct, including principal/project, `SourceIdentity`, canonical spelling/root/path, and target identity; every field substitution changes the key and no second tuple is accepted. -- `SourceIdentity` equality: record-CID-only changes preserve identity; blob-CID-set, SHA-256, or exact-length changes create a new identity. Assert atomic demotion, counter reset, lease/token clear, and route/cache invalidation. -- Exact SHA-256 vectors over raw bytes, exact byte-length checks, lower-case digest serialization, per-chunk blob-CID multihash verification against complete chunk bytes, independent CID/digest checks, and malformed digest/CID rejection. -- Stateful opaque token digest lookup and binding: `CacheKey`, source/manifest version, root/path, target CID, principal/project, expiry, and revocation all affect the binding; substitution/replay fails. -- Frozen PM-80 contract compile/use-site checks for the in-process resolver signature, verified-only `ResolvedResource`, all `ResolveError` variants, and the single `CacheKey` re-export. -- Verification transition table with injected timestamps: lazy claim, coalesced active lease, completion, stale token/source, changed source, expiry 1/2/3, retry after reset, and revocation. Cover lease/attempt boundaries and `ResolveError::Verifying` for losing claimants. -- Stable diagnostic code mapping and legacy classification. - -Each pure behavior has happy, error, and boundary/malformed vectors; no test uses wall-clock sleep or network I/O. - -### Tier 2-equivalent SQLite projection/repository tests - -The project’s existing server tests use `sqlite::memory:` with `SqlitePoolOptions::max_connections(1)` and `sqlx::migrate!("./migrations")`; follow that pattern for migration-backed PM-76 tests. Production SQL remains checked macros; test-only setup/assertion queries may use runtime SQL as existing projection tests do. - -Cover: - -- Fresh and upgrade migration with exact `009_ldraw_resources.sql`, legacy rows retained/marked unreadable, and application-level dependent cleanup (no FK/PRAGMA assumption). -- Authoritative DID/AT-URI/source-content/blob identity projection and typed generated-record decoding; record-CID-only replacement preserves identity while content changes create a new `SourceIdentity`. -- Atomic manifest replacement: valid create/update, malformed record, duplicate/collision rejection, stale source rejection, delete/revocation, rollback/no-partial-rows behavior, and no readable orphan route/cache/index rows. -- Idempotent Hydrant replay and cursor advancement in the same transaction; projection failure must not advance the cursor. -- Official snapshot digest/notice/licence pinning, offline re-ingestion, changed-snapshot invalidation, and deterministic collision rejection. -- Verification claim/completion/rejection/revocation CAS transactions under concurrent tasks, including lazy trigger, coalesced losing claimants returning `ResolveError::Verifying`, stale-writer rejection, expired lease reconciliation, third-expiry rejection, and changed-source demotion from `verified|verifying` to `unverified` with counter reset, token/lease reset, and route/cache invalidation. -- Verified-only resolver/read queries using the one `CacheKey`; cache/route invalidation on source replacement/revocation and principal/project partition isolation. -- Production query compilation through `just sqlx-prepare`; inspect that all newly introduced production SQL has corresponding `.sqlx` entries. - -### Tier 3 — router and server integration tests - -Use the existing Axum router/state helpers in `src/appview/tests.rs` and the real SQLite migrations. Use authenticated session fixtures for principal-bound operations; do not test authorization by calling only an internal helper when the route behavior is under test. - -Cover: - -- Mint requires the correct authenticated session/principal and refuses unauthorized projects/resources. -- Public and authenticated fetch cases, including public-resource policy, owner access, cross-principal denial, missing/expired/revoked stateful token, and source-content mismatch. -- Route substitution/replay: replace `CacheKey` fields, path/root/target, source/manifest version, principal/project, or expiry and assert denial. -- Unverified/verifying/rejected resources never return bytes; lazy claims coalesce correctly; only an exact-byte fetch through repository CAS becomes readable. -- Exact `getPartFile` request with collection `space.polymodel.library.ldrawResource` hard-blocks before manifest/blob lookup; legacy `space.polymodel.library.part` compatibility remains green. Wrong path, publisher URL, filename-to-network attempt, arbitrary NSID, and proxy-denied batch/admin routes return expected 4xx/404/405. -- Successful opaque responses have expected MIME, exact content length, and exact bytes; each wrong/mismatched chunk blob CID, corrupted/truncated/reordered body, whole-file length mismatch, or SHA-256 mismatch fails before verified state and never becomes readable. -- Revocation/source-content update invalidates previously minted routes and all affected `CacheKey` cache partitions. -- Existing `getPartFile`, drafts, OAuth session gating, proxy error forwarding, and read-your-own-writes tests remain green. - -### Required command sequence - -From the isolated task workspace, after the initial safety check and before handoff: - -```sh -just check -just fix -just sqlx-prepare # required when production SQL or migrations changed -just generate-api # required when authored lexicons changed -just test-server -cargo nextest run -p polymodel --features server --test # or the discovered focused target -just test-all -``` - -The implementor must discover the exact focused test target with `cargo nextest list -p polymodel --features server` and run the PM-76 module tests directly before the full suite. `just test-all` is mandatory because this task changes schema, persistence, generated interfaces, and server routes. If `just check` initially needs to generate `src/env.rs`, run it before `just fix` as required by `AGENTS.md`. - -## Implementation skills to load - -- `jira-epic-task-execute` for the task-workspace lifecycle, commit trailers, jj isolation, and handoff protocol. -- `polytoken:investigating-a-codebase` before modifying unfamiliar projection/router surfaces. -- `reviewing-code-for-shipping-quality` before the final diff review, especially to remove process residue and ensure the new security boundary is legible. - -Do not load a renderer/front-end skill for this task: PM-76 has no Dioxus, CSS, worker, parser, or scene-IR surface. - -## Review expectations - -Implementation reviewers must inspect the whole PM-76 diff, not only newly added tests. At minimum, verify: - -1. Authored lexicons are the sole API source; generated code is reproducible and hand edits are absent. -2. Exact `space.polymodel.library.ldrawResource` is consistently discriminated and hard-blocked by legacy `getPartFile` before lookup/blob access. -3. No publisher URL, arbitrary remote URL, filename-to-network activation, or integrity-free read path exists. -4. Typed DID/AT-URI/CID/BlobRef values survive to DB/serialization boundaries; source identity is resource-plus-content, not raw record CID. -5. Canonical paths implement PM-75 NFC, percent, root precedence, case, and collision rules. -6. One `CacheKey` is used by canonicalization, resolver, route bindings, invalidation, cache indexes, and PM-80; no duplicate tuple exists. -7. PM-80’s in-process trait, resolver signature, `ResolvedResource`, `ResolveError`, and `CacheKey` re-export compile without HTTP coupling. -8. Verification is atomic, DB-clock driven, lazy-triggered, token/source/lease/revocation guarded, and stale workers cannot publish. -9. Source changes demote `verified|verifying` to `unverified`, reset counters and token/lease state, and invalidate routes/cache in one transaction; record-CID-only changes do not reset. -10. Every chunk blob CID multihash is checked against complete chunk bytes before whole-file exact length/SHA-256 verification. -11. Migration `009_ldraw_resources.sql` is additive and dependent cleanup is application-level in one transaction, not FK/PRAGMA-dependent. -12. Production SQL uses checked macros and `.sqlx` matches actual SQL; happy/error/edge/concurrency tests and `just test-all` pass. - -Address Critical/High review findings before the parent merge handoff. Do not hide failing tests, leave required tests ignored, or introduce TODOs for PM-76 acceptance criteria. - -## Ready-for-parent-merge handoff - -Return to the parent with: - -- task workspace/bookmark: `../epic-86-task-C`, `epic-86-task-C`; -- final jj commit/change ID and `jj diff --stat`/`jj diff` review result; -- list of authored lexicons, migrations, projection/resolver/verification/route modules, tests, and docs changed; -- confirmation that generated API and `.sqlx` artifacts were regenerated and reviewed; -- exact results of `just check`, `just fix`, `just sqlx-prepare` when applicable, `just generate-api` when applicable, `just test-server`, focused PM-76 tests, and `just test-all`; -- review findings and their dispositions; -- explicit confirmation that the commit contains `Epic: PM-86` and `Task: PM-76` trailers and is not on `main`; -- any remaining risk stated as a concrete observed failure or environmental blocker, never as a deferred acceptance item. - -## Prior finding dispositions (planner-C2) - -- **F1 — fixed:** Source changes use content-based `SourceIdentity` and explicitly perform one atomic `verified|verifying → unverified` transition, reset `expired_attempts` to zero, clear lease/token state, and invalidate affected route/cache partitions. A record-CID-only change with unchanged content preserves identity and does not reset verification. -- **F2 — fixed:** The plan defines exactly one `CacheKey` struct with principal/project/source/root/path/target fields. It is used verbatim by canonicalization, resolver output, route bindings, projection invalidation, cache indexes, and PM-80; no competing cache tuple remains. -- **F3 — fixed:** “Consumption contract for PM-80 (G)” freezes the in-process trait and `resolve` signature, verified-only `ResolvedResource`, `ResolveError` variants, stable `CacheKey` re-export, and no-HTTP boundary. -- **F4 — fixed:** The exact discriminator `space.polymodel.library.ldrawResource` is mandatory. `getPartFile` hard-blocks it before manifest/blob lookup with the defined 404/403 policy; it never falls through, and router tests are required. -- **F5 — fixed:** Source identity is resource identity plus complete content hash (ordered blob-CID set plus declared SHA-256/exact length), never raw record CID. Re-putting identical bytes cannot reset rejection or expiry state. -- **F6 — fixed:** FK/cascade assumptions are removed. Dependent entries, route bindings, and cache indexes are explicitly deleted or invalidated in the same application-level transaction, matching SQLite’s current no-FK/`foreign_keys`-off behavior. -- **F7 — fixed:** Opaque routes are explicitly stateful DB-backed token-digest bindings with persisted claims and immediate revocation; the plan does not describe them as stateless signed tokens. -- **F8 — fixed:** Verification is lazy on first resolver/resource read or route mint; concurrent claimants coalesce on the active lease and losers receive `ResolveError::Verifying`; no eager sweep is left unspecified. -- **F9 — fixed:** Every fetched chunk’s blob CID multihash is checked against the complete chunk bytes before whole-file exact length and SHA-256 checks; failures cannot publish verified state. -- **F10 — fixed:** PM-76’s migration is pinned to `migrations/009_ldraw_resources.sql`; an existing number collision requires coordination rather than silently selecting a parallel number. diff --git a/.current-epic/plan-D.md b/.current-epic/plan-D.md deleted file mode 100644 index fbc5263..0000000 --- a/.current-epic/plan-D.md +++ /dev/null @@ -1,300 +0,0 @@ -# PM-77 Plan — LDraw corpus and compatibility harness (generation 3) - -## Task - -- **Epic:** PM-86 — [LDraw/LEGO model support and flexible renderer](https://radiant-industries.atlassian.net/browse/PM-86) -- **Task:** PM-77 — [LDraw corpus and compatibility harness](https://radiant-industries.atlassian.net/browse/PM-77) -- **Task prefix:** D -- **Task cache:** `.current-epic/task-D.md` -- **Epic workspace:** `../epic-86` -- **Base bookmark:** `main` -- **Task workspace expectation:** `../epic-86-task-D` -- **Task bookmark expectation:** `epic-86-task-D` -- **Plan output:** `.current-epic/plan-D.md` -- **Dependencies:** none -- **Downstream blockers:** E/PM-78, F/PM-79, K/PM-84 -- **Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Context and intent - -PM-77 is the reproducible test foundation for the LDraw semantic and renderer work. It adds a dependency-light, target-safe Rust testkit, authored specification corpus, versioned canonical records, and a native-only compatibility harness. The corpus is normative only where expectations are derived from ratified LDraw/OMR requirements or an explicit Polymodel safety decision; outputs from LDParse, Thomas fixtures, weldr, ldr_tools, OMR, and official-library artifacts remain observations until a compatibility-ledger decision adopts them. - -The plan is written so E can implement its semantic core against a machine-checkable gate contract and so F/H/K can consume stable v1 records without depending on native parser tooling. - -## Safety, workspace, and implementation skills - -1. In the task workspace, run `jj workspace root`, `jj status`, and `jj bookmark list`. Stop immediately if the workspace is the default workspace or the checked-out bookmark is `main`. -2. Confirm the workspace is `../epic-86-task-D` and the bookmark is `epic-86-task-D`. Do not create workspaces or bookmarks from this plan. -3. After the parent copies the task cache into the implementation workspace, read `.current-task/task.md` and `.current-task/plan.md`; the cached task contract is authoritative. -4. Do not edit `.current-epic/manifest.txt`, `.current-epic/dependencies.dot`, `.current-epic/task-state.json`, `.current-epic/merge-gate.json`, `.current-epic/events.log`, generated `crates/polymodel-api`, or renderer production behavior. -5. Load `polytoken:investigating-a-codebase` before implementation and use the repository's existing test/just conventions. No frontend-design skill applies. Load `reviewing-code-for-shipping-quality` before the final implementation review. - -## Replace-vs-edit assessment - -| Area | Decision | Reason and boundary | -|---|---|---| -| `crates/polymodel-ldraw-testkit` | **Create** | No existing crate owns cross-parser fixtures, provenance, profiles, canonical records, or E-facing gates. Keep it independent of parser/renderer implementations and compileable for wasm. | -| Corpus, expected records, ledger, and provenance files | **Create** | Existing `public/models/` files are STL/STEP assets and must not be relabeled as LDraw fixtures. Add behavior-named, exact-byte files under the new testkit-owned corpus. | -| `tools/ldraw-compat-harness` | **Create** | No existing compatibility tool exists. A standalone native tool is structurally safer than adding Python/process dependencies to the renderer or app. | -| LDParse driver/environment | **Create** | LDParse is a Python library, not a binary. Commit a hash-pinned driver and lockfile/environment rather than patching an assumed executable. | -| Fuzz/property smoke harness | **Create** | No existing LDraw fuzz surface exists. Add a bounded deterministic command at the test boundary; do not add branches to unrelated production code. | -| Root `Cargo.toml` and `justfile` | **Edit minimally** | The workspace already uses `members = [".", "crates/*"]`; the new crate is auto-included, so add no explicit member entry. Add only required dependencies and recipes, including new recipes as `test-all` prerequisites. Preserve existing commands and renderer split checks. | -| `polymodel-renderer-protocol`, `polymodel-mesh`, app, generated API | **Do not edit** | PM-77 supplies test contracts and fixtures; it must not introduce a heavy dependency path or production parser behavior. | - -## Required deliverable layout - -The implementor may choose equivalent module names, but the following boundaries and artifacts are mandatory: - -- `crates/polymodel-ldraw-testkit/`: dependency-light Rust crate with embedded/compile-time fixture inventory, public profiles, expected/canonical records, provenance validation, matrix validation, corpus gates, and deterministic serialization. It must not depend on `three-d`, `three-d-asset`, `stl_io`, `polymodel-mesh`, filesystem, network, archive, process, or parser implementation crates. -- `crates/polymodel-ldraw-testkit/corpus/` (or a documented testkit-owned `testdata/ldraw/`): authored inputs, expected records, matrix, ledger, manifests, provenance/license records, semantic-limit vectors, and the H-owned conditional-line family. -- `tools/ldraw-compat-harness/`: native-only Rust harness and adapter modules, plus `ldparse_driver.py`, `.python-version`, `pyproject.toml`, `uv.lock`, vendored/pinned LDParse source or verified source archive, and README/build evidence. Tests are offline and deterministic. -- `fuzz/` or a testkit-owned property target: bounded seeded smoke/regression command with committed malformed/adversarial inputs and reports. -- `justfile`: `ldraw-corpus`, `ldraw-differential`, `ldraw-fuzz-smoke`, and `ldraw-wasm-check`; all four are prerequisites of `test-all`. -- Root `Cargo.toml`: only dependency changes required by the new crate/tool. Do not add a redundant workspace-member line because `crates/*` already includes it. - -## Cross-task contracts that this task must publish - -### 1. Versioned canonical record contract - -D commits canonical syntax, semantic, and scene record schema version `v1`, with stable field ordering, explicit integer widths, canonical decimal/float/non-finite tokens, normalized separators, raw bytes/tails/spans, diagnostic order, provenance, and schema compatibility rules. E and F may add fields only through a documented compatible extension; they must not change committed v1 golden meaning or expected records without a schema-version bump and a reviewed migration. The v1 schema and representative golden snapshots are therefore a cross-task artifact, not disposable test scaffolding. - -### 2. Machine-checkable semantic corpus gates - -A **corpus gate** is a named, profile-scoped Rust assertion over one fixture's authored expected record and the adapter-produced actual record. The testkit exposes a wasm-safe trait and result types, for example: - -```rust -pub trait CorpusGate { - fn name(&self) -> &'static str; - fn profile(&self) -> Profile; - fn assert_case( - &self, - fixture: &FixtureCase, - expected: &ExpectedRecord, - actual: &CanonicalRecord, - ) -> Result<(), GateFailure>; -} -``` - -The testkit also exposes a `CorpusGateSet` that rejects duplicate gate names, missing profile coverage, or a fixture with no applicable gate. E implements the adapter/semantic side of this contract in its wasm-compatible crate and runs the gates against its parsed records; the testkit owns the assertion definitions and fixture expectations. Process execution and oracle differential reporting are separate native-only code and are never required by E's gate evaluation. - -The minimum v1 gate set is named and profile-scoped as follows: - -1. `syntax_types_0_to_5`: each primitive/meta line is classified correctly, with raw tail, source span, line-ending, and malformed-field behavior checked for strict, compatibility, and lossless rows where applicable. -2. `profile_acceptance_and_diagnostics`: strict/compatibility/lossless accept, reject, preserve, diagnostic code, severity, and deterministic diagnostic-order expectations are exact. -3. `mpd_and_data_boundaries`: `FILE`/`NOFILE`, `!DATA`, duplicate/missing terminators, payload concatenation/base64, unreachable blocks, and decoded limits match the expected record. -4. `path_and_resolution_independence`: canonical names, forbidden syntax, root identity, shadowing, and unresolved-resource behavior are represented without requiring a filesystem or resolver side effect; E's parser result remains deterministic when resolver availability changes. -5. `bfc_state_and_invertnext`: file-local certification, winding, XOR inversion, one-shot `INVERTNEXT`, blanks, intervening commands, malformed references, EOF, and stable diagnostic codes match the profile row. -6. `colours_and_ldconfig`: colour 16/24 semantics, direct colours, alpha/luminance/finish/material fields, unknown colours, and scoped `!COLOUR` output are preserved or diagnosed exactly. -7. `graph_identity_and_instances`: iterative include traversal, stable file/model IDs, cycle detection, cache reuse, repeated-model parse-once behavior, and distinct instance identity match expected graph records. -8. `steps_and_transforms`: STEP boundaries, finite right-handed LDU transforms, reflection state, bounds inputs, and no-early-scaling expectations match canonical records. -9. `numeric_finiteness_and_raw_preservation`: signed zero, decimal/exponent spelling, finite extremes, overflow/underflow, non-finite rejection, invalid counts, and lossless raw representation match profile expectations. -10. `semantic_limits_and_cancellation`: each D-owned LDraw semantic limit has limit and limit+1 expected outcomes, deterministic diagnostics, cancellation checkpoints, and post-cancellation cleanup. - -Each gate has at least one happy, error, and boundary/adversarial fixture where the profile permits it. Gate IDs and profile names are serialized in failure reports so E's acceptance claim “all PM-77 semantic corpus gates” is mechanically checkable rather than a prose promise. Gate definitions, their pure evaluation tests, and E-facing traits must compile with `cargo check --tests --target wasm32-unknown-unknown`; only native oracle/process tests are target-gated. - -### 3. Ownership of limits - -D owns **LDraw-semantic** limits and vectors: maximum resource/file bytes (100 MiB/resource), line length (1 MiB/line), files (16,384), include depth (256), commands (5,000,000), instances (5,000,000), triangles (20,000,000), lines (20,000,000), textures (1,024), fetches (8), diagnostics (10,000), plus malformed/overflow and cleanup behavior at each limit and limit+1. D does not duplicate reservation formulas. - -B/PM-88 owns **reservation-arithmetic** vectors: every class/domain/load/global/unified ceiling, checked u64 overflow, `ceil_div`, `align_up`, WASM/GPU/texture/canvas formulas, invalid dimensions, and resize old+new overlap. D's adapter contract can carry reservation outcomes, but B's implementation and arithmetic corpus remain authoritative. - -## Implementation steps - -### 1. Establish inventory, profiles, records, and gate API - -Define fixture identity, exact bytes, relative path, line-ending mode, behavior tags, authority class, profile (`strict`, `compatibility`, `lossless`), expected outcome, source spans/raw tails, diagnostics, semantic/scene records, adapter observations, and provenance. Embed the inventory and fixture bytes or use another target-safe representation; runtime filesystem/network access is forbidden in the reusable crate. - -Implement the `CorpusGate`/`CorpusGateSet` contract above, the ten named v1 gates, profile applicability validation, deterministic `GateFailure`, and machine-readable coverage reporting. Keep expected records separate from observations. Reject duplicate IDs, duplicate gate/profile rows, missing expected records, missing required categories, unreferenced files, and incomplete provenance. - -**Done when:** `cargo test -p polymodel-ldraw-testkit` validates the inventory and gate set, intentionally malformed inventory/gate cases fail, all ten gates have named profile rows, and `cargo check --tests -p polymodel-ldraw-testkit --target wasm32-unknown-unknown` passes without forbidden dependencies. - -### 2. Author the complete ratified-spec corpus - -Create exact-byte, behavior-named fixtures and independently reviewed expected records for: - -- types 0–5, comments/meta, subfile references, lines, triangles, quads, conditional syntax, whitespace, CRLF/LF, blank lines, BOM/UTF-8, unterminated final lines, raw tails, byte/line spans, and malformed boundaries; -- MPD `FILE`/`NOFILE`, `!DATA`, duplicate names, missing terminators, unreachable blocks, payload decode and embedded-resource limits; -- BFC `CERTIFY`, `NOCERTIFY`, `CCW`, `CW`, `INVERTNEXT`, nested/subfile state, blanks/intervening commands, malformed references, EOF, and exact diagnostic outcomes; -- colour definitions, colour 16/24, direct colours, alpha/material/finish/luminance, unknown colours, and LDConfig-derived values; -- `!TEXMAP` start/next/fallback/stop, points, explicit edges, and conditional-line syntax; -- numeric spelling and finiteness boundaries, invalid counts, overflow/underflow, and stable diagnostics; -- semantic limit and limit+1 cases from the ownership list, cancellation checkpoints, and cleanup after aborted work. - -Hand-author expectations from ratified specifications and PM-75; an upstream output must never rewrite an expected file. Record fixture hashes and authority/source citations. - -**Done when:** the inventory report shows every acceptance category with happy, error, boundary, and adversarial cases; every fixture has an independently reviewed expected record and exact input-byte hash; all ten gate families can select at least one fixture. - -### 3. Add all four canonical path/root contexts - -Build a dedicated path-policy fixture family for PM-75 §3.3. For **each** context below, include happy resolution, shadowing/precedence, collision, out-of-root, and forbidden-syntax cases (literal `%`, backslash, empty/dot/dot-dot segment, leading/trailing slash, controls, colon, query, and fragment): - -1. current MPD virtual-file table: exact-case lookup, duplicate names, and MPD shadowing of external roots; -2. uploaded manifest: relative directory then manifest root, exact-case keys, duplicate canonical keys, and explicit-prefix behavior; -3. uploaded LDraw roots: `MODELS → PARTS → P`, with explicit `s/`, `8/`, and `48/`, ASCII-fold lookup/collision, and no fallback after an explicit prefix; -4. pinned official library: `PARTS → P`, explicit `s/`, `8/`, and `48/`, canonical spelling, ASCII-fold collision, and ingestion invalidation. - -Expected records must include canonical spelling, root identity, target identity, cache identity, and resolver-independent parser behavior. Include repeated-reference and cycle cases that exercise canonical identity without using a real filesystem. - -**Done when:** matrix validation proves all four contexts and all five sub-families are represented, MPD shadowing and explicit-prefix no-fallback are asserted, and no fixture can activate URI/network access. - -### 4. Add H-owned conditional-line classifier fixtures - -Reserve a complete `h-conditional-*` fixture family in the D corpus and mark H/PM-81 as the owner of the hand-computed coordinates and expected packed outcomes. D supplies the fixture schema, hashes, profile/matrix rows, and expected-record versioning; H consumes these vectors without redefining the classifier contract. - -Include at minimum one hand-computed vector for each of the six PM-75 §3.5 outcomes: - -- `Visible` — same-side controls; -- `SuppressedInterior` — strictly opposite-side controls; -- `SuppressedOffscreen` — endpoint segment has no surviving clip interval; -- `SuppressedAmbiguous` — projectable control has unsafe/non-positive `w`; -- `SuppressedDegenerate` — clipped endpoint segment is shorter than epsilon; -- `Invalid` — non-finite homogeneous input with the stable diagnostic. - -Also include epsilon boundary vectors immediately below, exactly at, and immediately above segment `epsilon` and oriented-area `±epsilon`; `w_epsilon` below/exactly/above and `w <= 0`; near/far/side-plane clipping; off-frustum but projectable controls; unsafe control `w`; behind-camera endpoint; reflection; perspective and orthographic projections; same/opposite/collinear controls. Expected records must assert that only `Visible` emits a segment and suppressed outcomes emit no diagnostic unless the contract says otherwise. - -**Done when:** all six outcomes and every epsilon/`w_epsilon` boundary have committed coordinates, hand-computed expected records, owner metadata `H/PM-81`, and matrix rows; H can consume them through the v1 record format without adding a new corpus schema. - -### 5. Pin external artifacts and provenance - -Add minimized, legally attributable artifacts for Thomas fixtures, OMR and official-library examples, weldr, and ldr_tools. Every non-authored artifact records source URL, exact commit/blob/archive, SHA-256, license or operator-attested permission, original test identity, modifications, and authority class. Keep selected files and notices in the repository; acquisition is a separate maintenance operation and normal tests are offline. - -Use PM-75's exact LDParse revisions: - -- master: `877667417d6cab57c04aed9cb3d5bfe7efa0589c`; -- modernization: `0fe78dcee22982f26cceced1b483340faa4d04e9`. - -Record both revisions, their source URLs, source/archive SHA-256, and license evidence in the ledger. The provenance validator recomputes every committed artifact hash and rejects altered, unlisted, or under-attributed files. - -**Done when:** all selected artifacts and permission/license records are committed, provenance tests pass offline, both exact LDParse revisions are source-verified, and a clean checkout can reproduce the pinned inputs without floating branches or network access. - -### 6. Build the reproducible native LDParse and oracle adapters - -Model adapter classes explicitly: - -- `InProcessRustAdapter`: crate-pinned, target-safe Rust implementation adapter for E/F/K records; -- `OutOfProcessAdapter`: subprocess adapter with explicit executable/driver identity, environment, timeout, termination, stdout/stderr, exit status, and version evidence. - -LDParse is **only** an `OutOfProcessAdapter`; it is not treated as a binary. Commit `tools/ldraw-compat-harness/ldparse_driver.py`, a pinned `.python-version` specifying **CPython 3.12.8**, `pyproject.toml`, and `uv.lock` with hashes. Vendor the two source revisions or commit verified source archives and their SHA-256; the driver must import only the source matching the selected revision and print a deterministic JSON record. The README must document `uv sync --locked` and the offline invocation. The harness must reject a wrong interpreter, lockfile drift, source hash/revision mismatch, missing version evidence, network-enabled acquisition, timeout, crash, malformed driver output, or normalization failure. - -The required LDParse oracle surface is fixed, not discovered at implementation time: the driver and corpus must exercise line types 0–5 with raw tails/spans, MPD `FILE`/`NOFILE` boundaries, include/model graph and cache/instance identity, BFC certification/winding/`INVERTNEXT`, STEP boundaries, finite numeric/diagnostic outcomes, and deterministic acceptance/rejection for those cases. Every invocation maps to a fixture and expected record. TEXMAP, conditional-line classification, renderer batches, and any other feature not in this minimum surface are not silently claimed as LDParse coverage; they are marked `unsupported-by-ldparse` in the ledger and tested through authored expectations or another independently pinned adapter. - -For all process adapters, load only inventory-listed fixtures, enforce wall-clock/input/resource budgets, capture complete evidence, normalize without erasing raw tails/spans/order/diagnostics/BFC/paths/colours/geometry distinctions, and emit machine-readable mismatches linked to fixture and ledger IDs. weldr/ldr_tools expected values must be independently derived, never copied from one another. - -**Done when:** `uv sync --locked` succeeds under CPython 3.12.8 offline with the committed source pins; the native driver produces deterministic JSON for every required LDParse fixture; the harness tests wrong pin/interpreter, timeout, crash, unsupported-surface, and malformed-output failures; no required surface is covered by an escape clause. - -### 7. Implement canonical v1 serialization and compatibility ledger - -Serialize syntax/semantic/scene records with stable field order, stable non-semantic collection ordering only, explicit integer widths, canonical finite decimal/float formatting, explicit non-finite tokens, lowercase hex digests, normalized path separators, and explicit spans/raw bytes/diagnostics/provenance. Do not sort semantically ordered statements, geometry indices, BFC transitions, or diagnostic order. - -Add golden snapshots for syntax, MPD graph, BFC/colour state, points/edges/conditional geometry, scene hierarchy/instances/bounds, all ten gate families, and representative limit/cancellation outcomes. Add a compatibility ledger row for each disagreement with specification citation, observations, selected behavior, rationale, owner/date, and regression fixture; validation requires every oracle output and disagreement to link to a row. - -**Done when:** repeated serialization is byte-identical, valid insertion-order independence is tested, ordered data remains ordered, v1 schema/version rules are documented, and no upstream observation is accepted as normative without a ledger decision. - -### 8. Add bounded fuzz/property/adversarial smoke - -Create a deterministic native smoke command with committed malformed inputs and minimized regressions, fixed seeds, fixed iteration/case budgets, byte/depth/time limits, and killable process execution. Cover whitespace/line endings, malformed numerics, path traversal and collisions, MPD duplicates/cycles/deep chains, DATA/TEXMAP truncation, BFC transitions, semantic limits and limit+1, cancellation/cleanup, and canonicalization stability. Reports record seed, iterations, budgets, toolchain, adapter identity, and pass/fail. - -Expose the adapter seam for E's in-process parser and K's later expansion without making the testkit native-only. Add no unbounded or networked fuzz job. - -**Done when:** `just ldraw-fuzz-smoke` runs from a clean checkout with fixed seeds and deterministic output, every named adversarial class has a committed regression input, and cancellation/limit behavior cannot hang or panic. - -### 9. Wire commands, target checks, documentation, and merge gate - -Add these commands: - -- `ldraw-corpus`: inventory, provenance, matrix, gate, and snapshot tests; -- `ldraw-wasm-check`: `cargo check --tests -p polymodel-ldraw-testkit --target wasm32-unknown-unknown` plus the reusable E-facing gate path; -- `ldraw-fuzz-smoke`: bounded property/adversarial regression; -- `ldraw-differential`: offline native harness with `uv run --locked` and pinned source/toolchain evidence. - -Update `test-all` so these four recipes are explicit prerequisites in addition to the existing `fix check lint test test-server test-renderer`. This makes corpus, fuzz, differential, and wasm checks part of the ordinary merge gate rather than documentation-only commands. Keep native-only process work out of wasm recipes and preserve `verify-renderer-split`. - -Document fixture authoring, expected-record review, gate implementation for E, H vector ownership, provenance/permission review, pin updates, ledger decisions, Python setup, offline commands, and schema versioning. - -**Done when:** every recipe is runnable and documented; `just test-all` actually invokes all four new recipes; wasm compilation succeeds for testkit/gate code; native-only boundaries are explicit; no app dependency tree gains forbidden renderer/parser crates. - -## Test strategy - -This task has no provider, daemon, TUI, browser, or UI behavior. Tier 2 TestProvider, Tier 3 daemon integration, and Tier 4 TestBackend do not apply. - -| Behavior | Tier and infrastructure | Required cases | -|---|---|---| -| Inventory, profiles, and named corpus gates | Tier 1 pure Rust tests in `polymodel-ldraw-testkit` over embedded bytes/records | happy path; duplicate/missing gate; missing profile row; missing expected record; every one of ten gates; strict/compatibility/lossless applicability | -| E-facing gate evaluation and wasm safety | Tier 1 pure gate tests plus `cargo check --tests --target wasm32-unknown-unknown` for testkit and E adapter path | all gate names; no filesystem/process/network; wasm compile; expected gate failure details | -| Authored syntax/semantic expectations | Tier 1 corpus tests through adapter trait | types 0–5; raw tails/spans; MPD/DATA; BFC; colours; four path contexts; cycles; TEXMAP; numeric boundaries; diagnostics | -| Path/root policy | Tier 1 table-driven fixture tests | all four contexts, each with happy/shadow/collision/out-of-root/forbidden syntax; exact-case, ASCII-fold collision, explicit-prefix no-fallback, MPD shadow | -| H conditional-line family | Tier 1 fixture/schema validation in D; H-owned downstream classifier tests consume v1 records | six outcomes; epsilon and `w_epsilon` below/exact/above; clip planes; unsafe controls; reflection; perspective/orthographic; hand-computed coordinates | -| Provenance and integrity | Tier 1 deterministic hash tests | valid artifact; altered bytes; missing source/revision/license; modification/authority mismatch; unlisted file | -| Canonical v1 records | Tier 1 golden/snapshot tests | repeated serialization; valid insertion-order independence; ordered BFC/diagnostics/geometry preservation; raw bytes/spans; floats/non-finite; schema version | -| Semantic limits and cancellation | Tier 1 bounded adapter/property tests | each D-owned semantic limit and limit+1; malformed/overflow; depth/cycles; cancellation; cleanup; no hang/panic. Reservation arithmetic is covered by B/PM-88, not duplicated here | -| LDParse differential | Native standalone integration command, Tier outside Tiers 2–4 | CPython 3.12.8/uv.lock; both exact revisions; required minimum surface; deterministic JSON; wrong pin/interpreter; source mismatch; timeout; crash; unsupported surface; malformed output | -| Thomas/weldr/ldr_tools/OMR evidence | Native standalone integration plus Tier 1 ledger/provenance tests | attribution; independent expected values; source/hash; disagreement requires ledger decision; no oracle becomes normative implicitly | -| Fuzz/property/adversarial | deterministic seeded native smoke command | committed regressions for whitespace, numerics, paths, cycles, MPD/DATA/TEXMAP, BFC, limits, cancellation, serialization; fixed budget/timeout/kill path | -| Workspace and renderer split | target checks and existing recipes | `just check`; wasm testkit check; `just verify-renderer-split`; app tree remains free of `three-d`, `three-d-asset`, `stl_io`, `polymodel-mesh` | - -Run focused validation in this order, then the complete merge gate: - -```text -just check -just ldraw-corpus -just ldraw-wasm-check -just ldraw-fuzz-smoke -just ldraw-differential -just fix -just test -just test-server -just test-renderer -just test-all -``` - -`just test-all` is the final merge gate and must visibly execute `ldraw-corpus`, `ldraw-wasm-check`, `ldraw-fuzz-smoke`, and `ldraw-differential`. A failure must be fixed in the task diff or reported with the exact failing fixture/tool and reproducible command; no undocumented assumption is an acceptable result. - -## Finding dispositions from planner-D2 - -- **D-plan-2-C1 (critical): fixed.** The plan defines `CorpusGate`, named/profile-scoped assertions over fixture expected and actual canonical records, a gate set, wasm-safe E implementation boundary, and ten minimum v1 gates. -- **D-plan-2-C2 (critical): fixed.** PM-75's exact master and modernization revisions are named; the required LDParse surface is enumerated; the plan does not defer build-surface discovery or offer a “fully exercised or limited” escape clause. -- **D-plan-2-H1 (high): fixed.** The `h-conditional-*` family covers all six outcomes, hand-computed coordinates, epsilon/`w_epsilon` boundaries, clipping, control, reflection, and projection cases and is owner-allocated to H. -- **D-plan-2-H2 (high): fixed.** Path fixtures explicitly cover all four PM-75 §3.3 contexts, with happy/shadow/collision/out-of-root/forbidden-syntax sub-families and precedence/no-fallback checks. -- **D-plan-2-H3 (high): fixed.** D's LDraw-semantic limits and limit+1 vectors are enumerated and explicitly separated from B's reservation-arithmetic boundaries. -- **D-plan-2-H4 (high): fixed.** LDParse is explicitly modeled as a Python library behind a committed `ldparse_driver.py`, not as a binary; source pins, driver, interpreter, and lockfile are part of the deliverable. -- **D-plan-2-H5 (high): fixed.** `test-all` is required to depend on all four new corpus/wasm/fuzz/differential recipes, and the plan requires verifying that they actually run. -- **D-plan-2-H6 (high): fixed.** CPython 3.12.8, `.python-version`, `pyproject.toml`, `uv.lock`, `uv sync --locked`, and offline/hash-pinned behavior are specified. -- **D-plan-2-M1 (medium): fixed.** Gate definitions/evaluation and E's adapter path are wasm-compileable; only native subprocess/oracle differential code is target-gated. -- **D-plan-2-M2 (medium): fixed.** Canonical syntax/semantic/scene records are explicitly a committed v1 cross-task contract with golden snapshots and schema-bump rules. -- **D-plan-2-M3 (medium): fixed.** In-process Rust and out-of-process adapters are separate abstractions; LDParse is only out-of-process. -- **D-plan-2-L1 (low): fixed.** The root uses `members = [".", "crates/*"]`; the plan tells the implementor not to add a redundant member declaration. - -## Commit and review requirements - -Commit the implementation on the task change with a concise PM-77 message and exactly these trailers: - -```text -Epic: PM-86 -Task: PM-77 -``` - -Implementation reviewers must verify: - -- every task acceptance category and every required happy/error/boundary/adversarial case has committed input and expected output; -- all ten named semantic corpus gates are machine-validated, profile-scoped, wasm-safe, and consumable by E; -- v1 canonical records, snapshots, and compatibility rules are stable for E/F/H/K; -- all four path contexts and the complete H conditional-line family are present with hand-computed expectations; -- D/B ownership of semantic versus reservation-arithmetic limits is not blurred; -- provenance is hash-validated, legally attributed, and records source/blob, original test, modifications, and authority; -- LDParse uses both exact PM-75 revisions, CPython 3.12.8, `uv.lock`, the committed driver, and the fixed minimum oracle surface; -- upstream observations are separated from authored expectations and every disagreement has a ledger decision; -- weldr/ldr_tools expectations are independently derived; -- the testkit remains dependency-light and wasm-compatible, and the renderer split is unchanged; -- fuzz/property execution is bounded, seeded, killable, and included in `just test-all`; -- `jj diff` contains fixture bytes, expected outputs, provenance/permission records, pins, commands, documentation, and the `test-all` wiring, with no generated API or unrelated production/UI files. - -## Ready-for-parent-merge handoff - -Before returning to the parent orchestrator, provide: - -1. task workspace/bookmark and commit ID; -2. changed-file summary, corpus byte/file counts, schema version, and all pinned upstream revisions/toolchain identifiers; -3. the verified LDParse minimum surface and the explicit ledger rows for features outside it; -4. exact focused command output, including `cargo check --tests --target wasm32-unknown-unknown` and `just test-all`, proving the four new recipes ran; -5. confirmation that `jj diff` was reviewed and no prohibited state/source/generated files were modified; -6. any remaining risk as a reproducible failing command or missing external permission, never as an undocumented assumption. diff --git a/.current-epic/plan-E.md b/.current-epic/plan-E.md deleted file mode 100644 index d38c042..0000000 --- a/.current-epic/plan-E.md +++ /dev/null @@ -1,388 +0,0 @@ -# PM-78 Plan — Thomas-compatible Rust LDraw semantic core (planner-E5 rework) - -> This is a from-scratch re-plan after the fictional/skeleton implementation. The required behavioral reference is the pinned C++ modernization branch at `/home/orual/Projects/LDParse-reference`. - -## Task identity - -- **Epic:** PM-86 — [LDraw/LEGO model support and flexible renderer](https://radiant-industries.atlassian.net/browse/PM-86) -- **Task:** PM-78 — [Thomas-compatible Rust LDraw semantic core](https://radiant-industries.atlassian.net/browse/PM-78) -- **Task prefix:** E -- **Task cache:** `.current-epic/task-E.md` -- **Epic workspace:** `../epic-86` -- **Epic bookmark:** `epic-86` -- **Base bookmark:** `main` -- **Task workspace:** `../epic-86-task-E` -- **Task bookmark:** `epic-86-task-E` -- **Plan output:** `.current-epic/plan-E.md` -- **Dependencies:** B/PM-88 reservation ledger; D/PM-77 corpus and compatibility harness -- **Downstream blockers:** F/PM-79, H/PM-81, K/PM-84 -- **Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Context and intent - -PM-78 creates the reusable pure Rust/WASM LDraw semantic core by porting the actual Thomas Dickerson LDParse architecture, not by extending the prior text heuristic. The required pipeline is line scanner → MPD virtual-file splitter → callback/event parser → model arena/cache builder → iterative graph traversal and canonical projection, with lossless syntax, typed semantics, diagnostics, transforms, BFC, colours, TEXMAP, steps, all hard limits, and reservation-owned lifetime. - -Before drafting this plan, the planner read `.current-epic/ldparse-architecture.md`, `.current-epic/root-cause-ldparse-fiction.md`, and every requested reference header: `Lex.hpp` (exact-case token map, CRLF-safe scanner, MPD boundary/discard state), `Parse.hpp` (typed arity validation, callbacks, `Action`, explicit switch stack), `Model.hpp` (flattened mesh and child-instance state), `ModelBuilderImpl.hpp` (FILE/NOFILE, BFC state machine, STEP, colours, includes), `Cache.hpp` (case-insensitive name cache), `Geom.hpp` (translation + 3×3 matrix and mesh merge), and `Color.hpp` (layered scope and 512-slot/local colour table). The prior failure—no real MPD partitioning, graph traversal, cache, transforms, cycle detection, TEXMAP transitions, reflection, or five limits—is therefore a hard corrective constraint. - -## Safety, workspace, and required skills - -1. In `../epic-86-task-E`, run `jj workspace root`, `jj status`, and `jj bookmark list`. Stop immediately when the workspace is the default workspace or the checked-out bookmark is `main`; the expected bookmark is `epic-86-task-E`. -2. After the parent copies task context, read `.current-task/task.md` and `.current-task/plan.md`; `.current-epic/task-E.md` remains the authoritative contract for this task. -3. Load `polytoken:investigating-a-codebase` before implementation and `reviewing-code-for-shipping-quality` before final review. -4. Re-read the cited C++ headers during implementation before choosing Rust behavior; do not infer semantics from this plan alone. Do not edit `.current-epic/manifest.txt`, `.current-epic/dependencies.dot`, `.current-epic/task-state.json`, `.current-epic/merge-gate.json`, generated `crates/polymodel-api`, or unrelated app/UI/renderer behavior. -5. Keep the core `#![forbid(unsafe_code)]`, target-safe, synchronous, deterministic, and free of filesystem, network, archive, process, Rayon, random/time/pointer identity, GPU, `three-d`, `three-d-asset`, `stl_io`, and `polymodel-mesh` dependencies. The core must not depend on the native LDParse harness. - -## Replace-vs-edit assessment - -| Area | Decision | Boundary and rationale | -|---|---|---| -| `crates/polymodel-ldraw-core/` | **Create** | No existing reusable owner covers the typed scanner, MPD table, graph/cache identity, BFC, colours, TEXMAP, diagnostics, profiles, canonical projection, or reservation-owned arenas. Create cohesive modules behind one small public API. | -| `crates/polymodel-ldraw-testkit/src/lib.rs` oracle and expected loader | **Replace the oracle path; edit the gate/schema boundary** | Delete the heuristic Rust `canonical()`/`parse_syntax()` authority. Load deterministic records generated exclusively by the pinned out-of-process LDParse oracle plus versioned mechanical projection rules; keep the Rust adapter solely as the candidate under test. | -| `crates/polymodel-ldraw-testkit/corpus/expected/*.expected.json` | **Regenerate all authoritative records from the pinned oracle** | Oracle-representable semantic payload is generated-only and byte-compared in CI. Humans author provenance and versioned deterministic rules only for demonstrably non-oracle fields; handwritten oracle-representable values and Rust-derived values are both critical defects. | -| Existing 28 fixture bytes/inventory | **Edit deliberately, preserving the authoritative set** | Keep exactly the current authoritative 28 rows and enforce exact inventory↔fixture↔expected equality. The named external row is `omr-10030-1`, sourced from the OMR catalogue entry `https://library.ldraw.org/omr/sets/308` and MPD `https://library.ldraw.org/library/omr/10030-1.mpd` (10030-1 Imperial Star Destroyer, Roland Dahl, CCAL 2.0 notice; retrieved SHA-256 recorded in provenance). Extend `steps-transforms.ldr` with complete `!TEXMAP START`, spec-shaped `NEXT`, fallback geometry, and `END` coverage. Use `END` in every valid fixture; retain literal `STOP` only in a lossless-profile unknown-META negative fixture, with no pre-parse vocabulary rewriting. Include the explicit malformed/unclosed profile rows below and update the matrix, tags, provenance, and expected record atomically. | -| `tools/ldraw-compat-harness/src/main.rs` | **Edit minimally** | Replace every in-process fuzz/parser call to `canonical()` with the real core `InProcessRustAdapter`/byte-request entry point. Keep LDParse differential execution native and out-of-process. | -| `crates/polymodel-renderer-ledger` | **Do not edit** | Consume B’s `ReservationLedger::reserve`, `reserve_child`, `Reservation`, and `ResourceClass::SemanticArenas`; do not duplicate arithmetic or change ledger ownership semantics. | -| `justfile` and repository checks | **Edit minimally** | Add focused core/corpus/WASM/oracle-integrity checks while preserving existing `ldraw-corpus`, `ldraw-wasm-check`, renderer split, and `test-all` gates. | -| `src/ldraw/*`, app, generated API, `polymodel-mesh`, renderer worker | **Do not edit** | Resolver lifecycle, scene IR, and viewer integration remain downstream tasks. | - -## Public boundary and fixed semantic contracts - -Create `polymodel-ldraw-core` with these public concepts: - -- `ParserProfile` and `ParseOptions` containing strict/compatibility/lossless policy, typed `LdrawLimits`, cancellation/checkpoint policy, root/source context, and an explicit caller-supplied `ReservationOwner` plus `ReservationLedger`. -- `LdrawParser::parse_bytes` returning `OwnedParseResult` or a typed parse error. The owned result retains semantic arenas and can be borrowed for projection without releasing retained-memory admission. -- A target-safe `InProcessRustAdapter` contract shared by core and testkit. It must have a fixture convenience method and a byte-request method carrying fixture identity, bytes, declared profile, authority/provenance metadata, and deterministic test owner; mutated harness bytes must be parsed by the real core, never by expected-record helpers. -- Internal typed structures for raw line spans/tails/line endings, commands/primitives, virtual files, paths, transforms, BFC, colours, TEXMAP events/state, graph/cache/instances, diagnostics, ordered steps, and arena ownership. Projection to PM-77 `CanonicalRecord` occurs at one boundary. - -### Reservation lifetime and topology (normative) - -The parser performs one root reservation for `ResourceClass::SemanticArenas` before creating any semantic arena, intern table, graph/cache table, decoded DATA/TEXMAP payload, or instance storage. The root admission is a **caller-configured memory budget** for semantic arena overhead. The default estimate is `bytes.len().checked_mul(8).and_then(|n| n.checked_add(8192)).ok_or(Overflow)` (8× input expansion plus an 8 KiB floor), which is sufficient for typical inputs. The caller may supply a larger budget for unusually dense valid inputs (e.g., many short FILE boundaries or minimal type-1 lines). The `SEMANTIC_ARENA_RESERVATION_LIMIT` diagnostic means the caller's configured budget was exceeded, not that the input is syntactically invalid — this is a resource limit enforced by B's ledger, analogous to WebGL or WASM memory limits. The corpus adapter uses a generous fixed budget (exactly 16 MiB) to avoid false rejections during testing. The returned `Reservation` is stored in the `OwnedParseResult` (or in a result-owned `SemanticArenas` owner) and is not released at successful parse return. Per-file and per-arena growth uses `ledger.reserve_child(&root_reservation, owner, ResourceClass::SemanticArenas, bytes)`, and the aggregate child amount must never exceed the root’s admitted envelope. If a child request would exceed that admitted amount, reconcile by rejecting the parse with the stable `SEMANTIC_ARENA_RESERVATION_LIMIT` limit diagnostic; do not resize, silently over-admit, or publish partial state. The result’s retained semantic data and the root charge therefore have identical ownership lifetime. - -Every bounded per-file, payload, or child allocation uses `ledger.reserve_child(&root_reservation, owner, ResourceClass::SemanticArenas, bytes)`. The tree is exactly one root `SemanticArenas` reservation with child reservations attached directly to that root; there are no sibling roots and no child-of-child reservations. `SemanticArenas` owns the child handles and drops them before the result drops the root handle. Borrowed projection does not release either. An explicit consuming transfer may move the owner/result to a downstream owner; otherwise dropping the owned result/arena releases children first and root last. Tests must inspect ledger snapshots after successful parse while the result is alive and after drop, proving the charge remains until drop and then returns to zero. - -**Per-allocation charging model:** The `bytes` argument to each `reserve_child` call is determined as follows: (1) raw source line text, token spans, and file partition boundaries are borrowed zero-copy from the input slice and are NOT charged; (2) per model arena entry: 256 bytes (name, BFC state, default colour, step-boundary vector header); (3) per type-1 instance: 128 bytes (12-float transform, BFC status, child ModelId, colour code); (4) per graph/cache table entry: 64 bytes (key fields + ModelId + visit state); (5) per local colour allocation: 32 bytes (code, slot, scope metadata); (6) per TEXMAP event record: 48 bytes. These fixed estimates are deliberately generous relative to the actual Rust struct sizes. The default budget (`bytes.len() * 8 + 8192`) is sufficient for typical inputs, but for unusually dense valid inputs (e.g., many short FILE boundaries at 9 bytes each charging 256 bytes per model), the caller may supply a larger budget. The corpus adapter uses a exactly 16 MiB fixed budget to avoid false rejections. - -The parser receives the exact caller owner and never derives one from bytes, paths, clocks, random values, pointer addresses, or a global singleton. Production callers construct `ReservationOwner::interactive(worker_id, load_epoch, scene_generation)`; the isolated corpus adapter uses the fixed `ReservationOwner::preview(1, 1, 1)` per isolated parse. Retired owners are rejected. All failure, limit, cancellation, and ownership-transfer paths have explicit drop/transfer tests. - -### BFC transition contract (normative) - -The implementation and expected records must distinguish **observed LDParse behavior** from **PM-75 corrections**. The observed column below is grounded in `/home/orual/Projects/LDParse-reference/include/LDParse/ModelBuilder/ModelBuilderImpl.hpp` (especially `handleMetaCommand` lines 89–143, `handleInclude` lines 147–165, geometry handlers lines 168–193, and `handleMPDCommand`/`handleEOF` lines 16–33 and 179–182). The corrected column is the PM-78 contract and is what the Rust core exposes; compatibility fixtures may record the observation but may not silently turn it into the normative rule. - -BFC certification is file-local and starts `Unknown` (the Rust equivalent of C++ indeterminate). Geometry and include events force `Unknown → Uncertified` in the observed builder. PM-75 §3.4 additionally makes the one-shot and file-boundary behavior explicit and corrects the unsafe leakage described below. `Certified` carries clipping and winding; `Uncertified` and `Unknown` do not. - -| Current state / input | Observed C++ LDParse | PM-78 normative correction / output | -|---|---|---| -| `Unknown` + `CERTIFY` | Accepts certification (`indeterminate(...) || certified`), enables clipping, defaults winding CCW; optional `CW`/`CCW` is parsed. | `Unknown → Certified`; enable clipping and default CCW, then apply one optional orientation. This is the only certification entry from `Unknown`. | -| `Certified` + `CERTIFY` | Accepts and reinitializes clipping/winding. | Remains `Certified`; re-enable clipping and apply the directive’s optional orientation. | -| `Uncertified` + `CERTIFY` | Rejects/stops parsing because the C++ condition disallows recertification. | Remains `Uncertified`; emit the PM-75/PM-78 diagnostic and do not enable clipping. | -| Any state + `NOCERTIFY` | Sets `mCertify = false`. | `Unknown` or `Certified → Uncertified`; `Uncertified` remains `Uncertified`; clear clipping, winding override, and pending inversion. | -| `Certified` + `CLIP` / `NOCLIP` | Toggles clipping only while certified. | Remains `Certified`; toggle clipping. `Unknown`/`Uncertified` ignore the toggle and emit a gated invalid-context diagnostic (no culling change). | -| `Certified` + `CW` / `CCW` | Changes winding only while certified. | Remains `Certified`; change winding. `Unknown`/`Uncertified` ignore it and emit the same invalid-context diagnostic. | -| `Unknown`/`Uncertified` + geometry (types 2–5) or include (type 1) | Geometry handlers and `handleInclude` force certification false. | `Unknown → Uncertified`; `Uncertified` remains so. A type-1 still creates/records its instance even if unresolved. | -| `Certified` + geometry/include | Does not demote certification in the reference. | Remains `Certified`; evaluate the primitive/include under current clip/winding state. | -| `Certified` + `INVERTNEXT` | Adds a per-model pending marker only when certified. | Set one pending bit only while `Certified`; a second `INVERTNEXT` re-arms the same bit, never a stack. | -| `Unknown`/`Uncertified` + `INVERTNEXT` | Silently ignores the directive because `target.mCertify` is false/indeterminate; no pending marker or error is produced. | State unchanged, no pending bit, and no diagnostic in the strict C++-compatible behavior; compatibility/lossless retain the raw command and may record only the frozen low-severity informational tuple below, never a state transition. | - -`INVERTNEXT` is consumed by the **next valid type-1 include** and applies inversion to that instance (including a cache hit); it is **not** consumed by a geometry primitive. A geometry primitive (type 2–5), any other non-BFC command, a `STEP`, a colour/meta command, malformed type-1, or a BFC command other than `INVERTNEXT` clears the pending bit before normal handling. Blank lines and BFC comments that do not constitute a command do not clear it. This makes the PM-75 §3.4 rule “only the immediately following valid type-1 subfile line” executable and explicitly answers the primitive case. - -The pending bit is part of the source-file BFC frame. In the observed C++ implementation, `handleInclude` erases `mInvertNext` only when no `SwitchFile` action is returned (`ModelBuilderImpl.hpp` lines 147–165), so a file switch can leave the source model’s marker live; MPD `FILE`/`NOFILE` creates/retargets models without an explicit marker reset. PM-75 §3.4 corrects that leakage: a file switch/include descent does not transfer pending state to the child frame and clears it at the switch boundary; a return cannot resurrect it. An MPD `FILE`/`NOFILE` transition creates/ends a file-local frame and clears pending state and file-local certification bookkeeping. A completed cache hit still consumes/applies the pending bit and receives a fresh occurrence/instance ID. EOF has no consumer: discard the pending bit while closing the frame, with no synthetic inversion and no additional diagnostic. Reflection is composed separately by XOR with the source-instance determinant, as required by PM-75 §3.4. - -Direct Tier 1 transition-table tests must cover every row, including `Unknown`/`Uncertified + INVERTNEXT` asserting unchanged state, no pending bit, and no strict diagnostic; compatibility/lossless info tuples must be asserted where selected. Cover both directive orders, geometry/include/blank/intervening-command clearing, file and MPD switches, cache hit, malformed include, EOF, and reflection XOR. Expected records must label C++ observations and PM-75 corrections separately. - -### Explicit malformed-directive diagnostic contract (normative) - -The following tuples are frozen as `(profile, code, severity, result)`; there is no caller-selected variation. `reject` means no `OwnedParseResult` is published; `preserve-and-continue` means the raw command/span remains in lossless syntax, semantic state is unchanged, and parsing continues; `preserve-without-transition` has the same retention rule and explicitly forbids a BFC/TEXMAP state change. `none`, `info`, `warning`, and `error` are stored diagnostic severities, not prose labels; `none` means no diagnostic record is emitted. - -| Malformed BFC case | Strict tuple | Compatibility tuple | Lossless tuple | -|---|---|---|---| -| BFC directive has unknown keyword, missing keyword, wrong arity, or malformed token | `(strict, BFC_INVALID_DIRECTIVE, error, reject)` | `(compatibility, BFC_INVALID_DIRECTIVE, warning, preserve-and-continue)` | `(lossless, BFC_INVALID_DIRECTIVE, warning, preserve-and-continue)` | -| `CERTIFY` has an invalid orientation/extra token | `(strict, BFC_INVALID_CERTIFY_ARGUMENT, error, reject)` | `(compatibility, BFC_INVALID_CERTIFY_ARGUMENT, warning, preserve-without-transition)` | `(lossless, BFC_INVALID_CERTIFY_ARGUMENT, warning, preserve-without-transition)` | -| `CLIP`, `NOCLIP`, `CW`, or `CCW` occurs while state is `Unknown`/`Uncertified` | `(strict, BFC_INVALID_CONTEXT, error, reject)` | `(compatibility, BFC_INVALID_CONTEXT, warning, preserve-without-transition)` | `(lossless, BFC_INVALID_CONTEXT, warning, preserve-without-transition)` | -| `CERTIFY` occurs while state is `Uncertified` | `(strict, BFC_RECERTIFY_UNCERTIFIED, error, reject)` | `(compatibility, BFC_RECERTIFY_UNCERTIFIED, warning, preserve-without-transition)` | `(lossless, BFC_RECERTIFY_UNCERTIFIED, warning, preserve-without-transition)` | -| BFC numeric/enum field is non-finite or otherwise not representable | `(strict, BFC_INVALID_VALUE, error, reject)` | `(compatibility, BFC_INVALID_VALUE, warning, preserve-without-transition)` | `(lossless, BFC_INVALID_VALUE, warning, preserve-without-transition)` | -| `INVERTNEXT` while BFC state is `Unknown` or `Uncertified` | `(strict, BFC_INVERTNEXT_OUTSIDE_CERTIFIED, none, preserve-without-transition)` — no diagnostic is emitted, matching C++ | `(compatibility, BFC_INVERTNEXT_OUTSIDE_CERTIFIED, info, preserve-without-transition)` | `(lossless, BFC_INVERTNEXT_OUTSIDE_CERTIFIED, info, preserve-without-transition)` | - -| Malformed TEXMAP case | Strict tuple | Compatibility tuple | Lossless tuple | -|---|---|---|---| -| START/NEXT is missing method, required parameters, or pngfile, has extra tokens, or has invalid GLOSSMAP shape | `(strict, TEXMAP_INVALID_ARITY, error, reject)` | `(compatibility, TEXMAP_INVALID_ARITY, warning, preserve-without-transition)` | `(lossless, TEXMAP_INVALID_ARITY, warning, preserve-without-transition)` | -| START/NEXT names a method outside PLANAR/CYLINDRICAL/SPHERICAL | `(strict, TEXMAP_INVALID_METHOD, error, reject)` | `(compatibility, TEXMAP_INVALID_METHOD, warning, preserve-without-transition)` | `(lossless, TEXMAP_INVALID_METHOD, warning, preserve-without-transition)` | -| START/NEXT has missing, non-finite, or invalid numeric parameters | `(strict, TEXMAP_INVALID_VALUE, error, reject)` | `(compatibility, TEXMAP_INVALID_VALUE, warning, preserve-without-transition)` | `(lossless, TEXMAP_INVALID_VALUE, warning, preserve-without-transition)` | -| NEXT’s first nonblank line is type 0 | `(strict, TEXMAP_NEXT_TYPE0, error, reject)` | `(compatibility, TEXMAP_NEXT_TYPE0, warning, preserve-without-transition)` | `(lossless, TEXMAP_NEXT_TYPE0, warning, preserve-without-transition)` | -| NEXT occurs while START/FALLBACK is active | `(strict, TEXMAP_NEXT_NESTED, error, reject)` | `(compatibility, TEXMAP_NEXT_NESTED, warning, preserve-without-transition)` | `(lossless, TEXMAP_NEXT_NESTED, warning, preserve-without-transition)` | -| `0 !:` is nested inside another `0 !:` geometry command | `(strict, TEXMAP_NESTED_BANG_COLON, error, reject)` | `(compatibility, TEXMAP_NESTED_BANG_COLON, warning, preserve-without-transition)` | `(lossless, TEXMAP_NESTED_BANG_COLON, warning, preserve-without-transition)` | -| FALLBACK occurs without an active same-file START | `(strict, TEXMAP_FALLBACK_OUT_OF_SCOPE, error, reject)` | `(compatibility, TEXMAP_FALLBACK_OUT_OF_SCOPE, warning, preserve-without-transition)` | `(lossless, TEXMAP_FALLBACK_OUT_OF_SCOPE, warning, preserve-without-transition)` | -| FALLBACK occurs a second time for the same START | `(strict, TEXMAP_DUPLICATE_FALLBACK, error, reject)` | `(compatibility, TEXMAP_DUPLICATE_FALLBACK, warning, preserve-without-transition)` | `(lossless, TEXMAP_DUPLICATE_FALLBACK, warning, preserve-without-transition)` | -| END has extra/malformed tokens | `(strict, TEXMAP_INVALID_END, error, reject)` | `(compatibility, TEXMAP_INVALID_END, warning, preserve-without-transition)` | `(lossless, TEXMAP_INVALID_END, warning, preserve-without-transition)` | -| END occurs in a different file from its START | `(strict, TEXMAP_CROSS_FILE_END, error, reject)` | `(compatibility, TEXMAP_CROSS_FILE_END, warning, preserve-without-transition)` | `(lossless, TEXMAP_CROSS_FILE_END, warning, preserve-without-transition)` | -| `0 !:` has malformed or missing type 1–5 geometry | `(strict, TEXMAP_INVALID_BANG_COLON_GEOMETRY, error, reject)` | `(compatibility, TEXMAP_INVALID_BANG_COLON_GEOMETRY, warning, preserve-without-transition)` | `(lossless, TEXMAP_INVALID_BANG_COLON_GEOMETRY, warning, preserve-without-transition)` | -| Literal STOP is encountered as unknown META (never rewritten to END) | `(strict, TEXMAP_UNKNOWN_META_STOP, error, reject)` | `(compatibility, TEXMAP_UNKNOWN_META_STOP, warning, preserve-and-continue)` | `(lossless, TEXMAP_UNKNOWN_META_STOP, warning, preserve-and-continue)` | - -The `TEXMAP_NEXT_TYPE0` diagnostic span targets the `NEXT` command line, not the type-0 line that cancelled it. Missing END at EOF/file end is **not** a malformed case and therefore has no diagnostic tuple: it closes the active effect, retains ordered events, and emits no synthetic END. The table is covered by Tier 1 tests asserting exact profile, code, severity, result, span, and diagnostic order for every row. - -### TEXMAP state machine matrix (normative) - -TEXMAP is **not present in the cited C++ reference**; it is LDraw-spec/PM-75 behavior only. The core recognizes the official grammar `0 !TEXMAP (START | NEXT) [GLOSSMAP pngfile]`, plus `FALLBACK` and `END`. `NEXT` has no standalone count token: it applies to the immediately following nonblank valid type 1–5 geometry line, and a first nonblank type-0 line invalidates/cancels it. Valid fixtures use `END`; literal `STOP` is not grammar and is retained only as lossless unknown-META negative coverage. The parser never rewrites `STOP` to `END` before parsing. - -The four states are `Inactive`, `AwaitingNext`, `Active`, and `Fallback`. A frame also stores the owning file, previous texture for nested START/END, mapping mode, finite parameters, and the fallback association. The transition matrix is: - -| State | Input | Next state | Required behavior and PM-75/spec anchor | -|---|---|---|---| -| `Inactive` | valid `START` | `Active` | Push no prior texture, install the new texture immediately, and associate subsequent pre-FALLBACK geometry. PM-75 §3.5 and official START semantics. | -| `Inactive` | valid `NEXT` with spec-shaped method/parameters/pngfile | `AwaitingNext` | Store the texture descriptor; do not activate it until the immediately following nonblank line is inspected. No standalone count token is accepted. PM-75 §3.5 and official NEXT grammar. | -| `Inactive` | `FALLBACK` | `Inactive` | Diagnose with `TEXMAP_FALLBACK_OUT_OF_SCOPE` per the frozen tuple above (strict rejects; compatibility/lossless warn and preserve without transition). State remains Inactive. | -| `Inactive` | `END` | `Inactive` | Silent no-op: no diagnostic and no state change because there is no active same-file START. Official END semantics. | -| `AwaitingNext` | blank | `AwaitingNext` | Blank lines are skipped while looking for the immediately following nonblank line. Official NEXT semantics. | -| `AwaitingNext` | first nonblank type 0, including META/STEP/END | `Inactive` | Cancel NEXT, retain the raw command, and emit `TEXMAP_NEXT_TYPE0` whose span targets the prior `NEXT` command line; NEXT cannot seek past a type-0 line. Official NEXT semantics. | -| `AwaitingNext` | first nonblank valid type 1–5 geometry | `Active` for that geometry, then `Inactive` | Apply the stored texture to that immediately following valid geometry line only; no FALLBACK is legal for NEXT, and the temporary association ends after that geometry. Official NEXT semantics. | -| `AwaitingNext` | first nonblank malformed type 1–5 line | `AwaitingNext` | Keep NEXT pending and do not consume/cancel it; emit the geometry line’s own parse diagnostic, then continue waiting for the next valid geometry. The malformed line is not treated as the first valid geometry or as type 0. PM-75 recovery rule: the official spec does not define malformed-geometry handling during NEXT; PM-75 §3.5 prescribes this recovery behaviour. | - -| `AwaitingNext` | EOF/file end | `Inactive` | Cancel without synthetic geometry or END; no missing-END diagnostic is emitted. PM-75 §3.5 file-end rule. | -| `Active` | `0 !:` geometry | `Active` | Associate `0 !:` geometry with the current texture until FALLBACK/END/STEP/file end; it is texture geometry, not fallback. Official FALLBACK semantics. | -| `Active` | standard type 1–5 geometry | `Active` | Associate standard type 1–5 geometry with the current texture until FALLBACK/END/STEP/file end. Official FALLBACK semantics. | -| `Active` | valid `FALLBACK` | `Fallback` | Freeze geometry already seen as textured; subsequent fallback geometry is associated with this START and ignored by TEXMAP-aware projection. Official FALLBACK semantics. | -| `Active` or `Fallback` | valid `START` | `Active` | Legal nested START; push current texture and install the nested one. Nested texture frames are permitted. Official START nesting semantics. | -| `Active` or `Fallback` | valid `NEXT` | unchanged | NEXT is not nested into an existing START/FALLBACK frame: reject/diagnose it, preserve the current texture and fallback association, and do not create a temporary scope. PM-75 §3.5 NEXT. | -| `Fallback` | geometry | `Fallback` | Associate with the current START’s fallback section; do not emit it as textured geometry in the TEXMAP-aware view. Official FALLBACK semantics. | -| `Active` or `Fallback` | same-file `END` | previous texture’s `Active`/`Fallback`, or `Inactive` | Pop the matching START frame and restore the previous texture. A START after END is legal and starts a fresh frame. Official END semantics. | -| any state | cross-file `END` | unchanged | END cannot close a START from another file; apply the explicit cross-file diagnostic row below and preserve the current frame. Official END semantics. | -| any active state | `STEP` or owning-file end | prior texture frame or `Inactive` | Terminate the frame at STEP/file end; do not carry it into a new MPD file. PM-75 §3.5 lifetime rule. | -| any state | START/NEXT with missing, non-finite, or method-invalid parameters | unchanged | Retain raw syntax, never create a texture association from invalid values, and apply the explicit diagnostic row below. Official method grammar plus PM-75 §2 safety. | -| `Fallback` | second `FALLBACK` | `Fallback` | Retain the first fallback association and apply the explicit duplicate-marker diagnostic row below. Official FALLBACK section model. | - -Missing END at EOF/file end closes the effect as specified, preserves ordered START/fallback events, and emits no synthetic END. A malformed/nested `!:` command is retained losslessly and diagnosed; it must not silently become fallback geometry. Resolver availability never changes this state machine: the adapter supplies texture descriptors/payloads (or an unavailable marker), and the event remains in the projection either way. - -`SemanticRecord` carries additive `texmap_events: Vec`. Each event contains kind (`start`, `next`, `fallback`, `end`—not `stop`), source span, owning-file identity, mapping mode, finite canonical parameters, and associated textured/fallback geometry or texture reference where applicable. Non-TEXMAP records carry an empty list. The `texmap_projection_v1` gate compares ordered events, spans, transitions, parameters, file ownership, fallback association, and unavailable-payload retention. Direct Tier 1 tests cover every matrix cell, nested START/END, START after END, literal STOP, invalid numerics, EOF/missing END, cross-file END, STEP, and resolver-present/absent invariance. - -### PM-77 limits accounting (normative) - -The following are the eleven PM-77 logical limits (values from PM-75 §5.6 and `.current-epic/plan-D.md` §3.9). `LdrawLimits` exposes all eleven as typed `u64` ceilings. Every increment is a checked `current.checked_add(delta)` performed before mutating the counter or allocating. A checked overflow is the same fail-closed `LimitExceeded` outcome as crossing the configured ceiling; parse-owned graph/cache/arena state is unpublished and dropped, and cancellation uses the same cleanup path. Fetches and textures are resolver-independent: the core receives caller-supplied descriptors/bytes and a caller-supplied fetch count at the adapter boundary; it never performs fetches or discovers textures itself. - -| Limit (default) | Exact unit | Increment point | Overflow / rejection behavior | Resolver independence | -|---|---|---|---|---| -| Resource bytes (100 MiB) | input byte count (`u64` bytes) | Once per `parse_bytes` request, before scanning; DATA decoded bytes are charged at decode output as resource bytes, not twice as source text | Checked total and ceiling admission before retaining bytes; reject request and publish nothing | Caller supplies the byte request; no resolver | -| Line bytes (1 MiB) | one raw physical line’s bytes, excluding the line terminator but including raw fields/tail | At each line boundary after locating the terminator and before retaining line data | Checked line-length arithmetic; reject that line/request with stable diagnostic; no truncation | Caller-supplied bytes only | -| Files (16,384) | count of virtual MPD files, including root | When a validated `FILE` creates a new virtual-file entry; root is counted once | Checked count before insertion; reject MPD and discard partial table | File partition is input-only; no resolver | -| Include depth (256) | active type-1 descent depth, not total references | Before pushing a child frame for a type-1 include; root depth is 0 | Checked `depth + 1` and ceiling check before push; reject/suppress descent, preserve reference and diagnostic | Target resolution is supplied by caller/cache; no fetch | -| Commands (5,000,000) | count of parsed semantic command events (type 0–5 plus recognized META/DATA/TEXMAP controls) | Once per accepted line event, before event storage/dispatch; blank physical lines do not count | Checked increment before event mutation; reject/cancel parse on overflow/ceiling | No resolver | -| Instances (5,000,000) | count of emitted type-1 occurrence/instance IDs, including unresolved and cycle-cut occurrences | Before assigning each occurrence ID, including cache hits and cycle edges | Checked increment before ID publication; reject/cancel if exhausted | Caller supplies target identity/cache result | -| Triangles (20,000,000) | emitted type-3 triangles plus type-4 quads after deterministic triangulation (two triangles per quad) | Before appending each resulting triangle to semantic geometry | Checked count and capacity estimate before append/reservation; reject/cancel with no partial publication | Geometry is caller-supplied bytes | -| Lines (20,000,000) | emitted type-2 lines plus type-5 conditional-line segments | Before appending each line/conditional primitive to semantic geometry | Checked count/capacity before append; reject/cancel without silently dropping the primitive | Geometry is caller-supplied bytes | -| Textures (1,024) | distinct texture descriptors/texture identities retained in the parse result, including unresolved descriptors; repeated references reuse one identity | At first insertion into the per-result texture table, before descriptor/event retention | Checked count before insertion; reject the new association/parse with diagnostic and no partial publication | Descriptor and optional payload are caller-supplied; lookup/fetch is never performed by core | -| Fetches (8) | count of resolver/fetch attempts requested by the adapter for external targets or texture payloads | At the adapter boundary immediately before each caller-issued fetch; never incremented by core cache hits | Checked increment and ceiling check; caller cancels/rejects the fetch and reports the bounded diagnostic; core remains deterministic | Entirely caller-supplied; core has no network/filesystem resolver | -| Diagnostics (10,000) | count of stored diagnostic records, including limit/cancellation/invalid-input diagnostics | Immediately before each diagnostic append; suppressed duplicate diagnostics do not bypass the counter | Checked increment before append; when exhausted, emit/return the single bounded `DiagnosticLimitExceeded` terminal error without unbounded additional diagnostics | No resolver | - -Tier 1 tests must instantiate each limit at a tiny value, prove exact success and limit+1 rejection, exercise `u64` arithmetic overflow at each increment/estimate, and verify no counter or reservation mutation after rejection. Separate tests prove resolver-present/absent equal-byte requests have equal syntax, identities, state transitions, limits, and diagnostics; only caller-provided fetch/texture payload metadata may differ. - -### Expected-record authority and independence (normative) - -The pinned external Thomas Dickerson LDParse implementation is the semantic authority for this compatibility contract. Every expected semantic value that LDParse emits, or that can be mechanically projected from its emitted model/mesh/child/tokenized/resolved data, is generated deterministically from the pinned out-of-process oracle. Such values MUST NOT be handwritten and MUST NOT be sourced from `polymodel-ldraw-core`, `InProcessRustAdapter`, runtime actual records, snapshots of Rust output, or existing expected payloads. - -Human-authored material is limited to: (1) provenance identifying fixture bytes, oracle revision, wrapper/schema revision, and source clauses; and (2) explicit versioned deterministic projection rules for fields that LDParse demonstrably cannot emit. Each canonical field is classified in a checked-in field-authority table as `oracle_direct`, `oracle_mechanical`, `input_mechanical`, or `polymodel_policy`. There is no `human_authored_semantic` class. Before classifying a field as unavailable, inspect the LDParse API and preserve all values exposed by model mesh, children/submodels, winding, default/local colours, model boundaries, tokenized geometry, and resolved geometry. - -The oracle-only generator runs the pinned binary out of process, writes to a temporary directory by default, fails on unknown/missing oracle fields, runs twice byte-identically, and byte-compares generated records against committed expected records. It must not link/import/call the Rust semantic core. A deliberate update command may replace committed generated records only from this oracle-only pipeline. CI/source-integrity checks reject both Rust-derived expectations and handwritten changes to oracle-representable generated fields. - -`ExpectedRecord` carries generated semantic payload plus human provenance/projection-rule metadata (`DerivationRef` or its replacement). The loader verifies fixture hash, oracle revision, wrapper/schema/rule versions, field-authority classification, and complete traceability. The gate integration independently parses fixture bytes through the real Rust core adapter and compares the candidate projection to the committed oracle-generated expectation while the owned reservation remains live. - -## Implementation steps - -### 1. Establish the crate, typed boundary, and workspace checks - -Create the core crate and modules for the reference-shaped scanner/token map, MPD/DATA splitter, callback parser/actions, model arena/cache, iterative traversal, transforms/geometry, BFC, colours, TEXMAP, diagnostics/profiles, limits, reservations, and projection. Add one target-safe adapter request/result seam and make the harness consume the real core adapter. Add a repository oracle-integrity check that searches expected/gate/harness/fuzz source for calls matching `canonical(` or `parse_syntax(` and fails on any match; remove the public `canonical` and `parse_syntax` functions. Keep the native LDParse command only as an out-of-process differential boundary. - -**Done when:** core, testkit, and harness compile against one adapter definition; no `canonical(` or `parse_syntax(` call exists in expected/gate/harness/fuzz paths; the harness fuzz loop parses mutated bytes through the core; dependency inspection shows no forbidden renderer/parser dependency. - -### 2. Build oracle-backed corpus records and integrity validation - -Expand the pinned LDParse wrapper and projection to preserve every authoritative value the library exposes. Implement the oracle-only deterministic generator and checked-in field-authority/projection-rule table described above. Regenerate all 28 expected records from exact fixture bytes and pinned oracle output; mechanically derive AABBs, graph/mesh/child relationships, BFC/winding, colours, steps, geometry and other fields wherever the emitted data supports them. Apply explicit versioned input/spec/Polymodel rules only for genuinely unavailable fields. Preserve the authoritative 28-row inventory and assert exact inventory↔fixture↔expected equality. - -**Done when:** two temporary generations are byte-identical; generated output byte-matches all 28 committed records; every field has an authority classification and trace; fixture hashes, oracle/wrapper/schema/rule revisions, provenance, matrix and tags validate; the generator structurally cannot call the Rust core; unknown oracle fields fail closed; handwritten or Rust-derived oracle-representable values fail integrity checks. - -### 3. Implement lossless scanner and typed primitive syntax - -Scan raw bytes line locally, retaining raw tails, fields, LF/CRLF/no-ending mode, byte spans, one-based line/column, and first type token. Recognize types 0–5 and typed comments/meta/directives, subfile references, lines, triangles, quadrilaterals, and conditional lines. Retain unknown/malformed type-0 text and diagnose invalid UTF-8, malformed counts/coordinates, short fields, BOM/blank/whitespace boundaries, and unterminated final lines under the selected profile. Keep original numeric spelling separate from finite numeric values; reject NaN/infinity from geometry. - -**Done when:** Tier 1 table-driven scanner tests match authored syntax for applicable fixtures; repeated scans are byte-identical; strict, compatibility, and lossless profile outcomes have stable diagnostic code/severity/span/order; no whole-file substring search determines semantics. - -### 4. Implement profiles, diagnostics, limits, cancellation, and reservation-owned growth - -Define stable diagnostics and profile policy tables. Enforce every PM-77 limit with checked arithmetic and deterministic limit/limit+1 outcomes for bytes, lines, files, include depth, commands, instances, triangles, lines, textures, fetches, and diagnostics. Check cancellation at line scan, file partition, include traversal, TEXMAP payload handling, and projection boundaries; errors/cancellation discard unpublished graph/cache state. - -Before every semantic growth, calculate the checked estimate, reserve against the exact caller owner, then allocate. Create exactly one root `SemanticArenas` reservation with initial admitted amount the caller-configured SemanticArenas budget (default: `bytes.len().checked_mul(8).and_then(|n| n.checked_add(8192))`) (checked before parsing) and attach every per-file/per-arena child through `reserve_child(&root, ...)`; the aggregate child charge must not exceed the root admission. If a child would exceed it, reject the parse with `SEMANTIC_ARENA_RESERVATION_LIMIT` and publish no partial result; do not silently resize or over-admit. Children are stored in `SemanticArenas` and dropped before the root handle in `OwnedParseResult`. Keep the successful result alive through projection tests. Never release the root merely because parsing returned `Ok`. - -**Done when:** Tier 1 tests cover every limit and limit+1 vector, overflow, cancellation checkpoint, rejection, cleanup, and stale-owner case; a successful parse snapshot shows the SemanticArenas charge and reservation count still present until `OwnedParseResult` drop, then zero; child handles cannot outlive root; no partial publication, panic, hang, or leak occurs. - -### 5. Implement MPD/DATA and resolver-independent path identity - -Partition `0 FILE`/`0 NOFILE` into virtual files while preserving DATA payload lines and boundaries. Handle duplicate/missing terminators, unreachable files, malformed/truncated DATA/base64, and decoded-size limits without filesystem/archive access. Implement canonical path normalization and the four PM-75 root contexts (current MPD table, uploaded manifest, uploaded LDraw roots, pinned official library), including precedence, shadowing, exact/ASCII-fold collisions, explicit-prefix no-fallback, forbidden syntax, and out-of-root diagnostics. - -A type-1 reference always retains its canonical target and unresolved status. Resolver availability cannot change syntax, identities, or diagnostics for equal bytes and root context. - -**Done when:** MPD/DATA/path gates pass all applicable rows; malformed DATA and decoded limits are diagnosed; all four roots have deterministic happy/shadow/collision/out-of-root/forbidden tests; dependency checks show no filesystem/network/process/archive symbol in core. - -### 6. Implement iterative graph/cache identity, transforms, and instances - -Traverse type-1 references with an explicit stack containing source-file identity, canonical target, transform, BFC context, TEXMAP context, and depth. Freeze the graph/cache key exactly as `(canonical_path: NormalizedPath, resolved_root: RootId, content_hash: Sha256)`—no additional source-context category, profile field, resolver address, pointer, random value, or fetch timing participates in the key. Allocate `ModelId` sequentially in first-seen order during deterministic traversal, starting at `model-00000001`; serialize it as exactly `model-<8 decimal digits>`. Allocate a fresh `InstanceId` sequentially for every type-1 occurrence in that same traversal order, starting at `instance-00000001`; serialize it as exactly `instance-<8 decimal digits>`, including unresolved references, cache hits, and cycle-cut edges. A completed cache entry permits parse-once reuse, but every reference occurrence allocates its own instance ID before resolution/descent decisions, subject to the instances limit. - -The operational cycle invariant is: when a frame is about to descend, construct its full graph identity key and insert it into `visiting`; the key remains present for the entire frame lifetime; on normal completion, limit rejection, cancellation, or error unwind, remove it exactly once before popping the frame. If a child key is already in `visiting`, emit the deterministic cycle diagnostic, retain the reference and its newly allocated instance ID (with cycle-cut metadata), and suppress only descent into that child; do not recurse, duplicate the active frame, or discard the edge. A cache hit outside `visiting` reuses completed semantic contents while still creating an occurrence instance; a cache hit whose key is active is a cycle and follows the same diagnostic/suppression rule. Include depth is checked before frame push. This invariant is required for MPD self-cycles, mutual cycles, repeated references, cache hits, resolver-unavailable targets, and every cancellation/error path. - -Apply finite right-handed 3×3-plus-translation transforms with checked numerics, determinant/reflection tracking, ordered steps, primitive counts, and true per-axis bounds. Carry BFC, colour, and TEXMAP state explicitly into each instance. - -**Done when:** Tier 1 graph tests assert `visiting` membership at push and absence after every completion/error/cancel path; assert the exact key `(canonical_path: NormalizedPath, resolved_root: RootId, content_hash: Sha256)` and no other key fields; assert `ModelId` allocation is first-seen sequential `model-00000001` and `InstanceId` allocation is occurrence-order sequential `instance-00000001`, both serialized with eight decimal digits; assert cycle edges receive IDs and diagnostics while descent is suppressed; and cover repeated references, parse-once reuse, distinct instances, cache hits, self/mutual cycles, depth, unresolved/malformed refs, and deep non-recursive chains. Transform/bounds/step outputs are deterministic and match re-authored expected records. - -### 7. Implement BFC, colours, TEXMAP state, and v1 projection - -Implement file-local BFC certification/winding and exact one-shot `INVERTNEXT`: only the next valid type-1 include consumes and applies it; a type-2–5 geometry primitive and every intervening command clear it without consuming/applying it, while blank lines do not clear it. Cover nested transitions, malformed refs, EOF, file/MPD switches, cache hits, reflection, and PM-75 XOR semantics. Implement colour 16/current, 24/edge/direct encodings, scoped `!COLOUR` definitions, alpha/luminance/finish/material fields, unknown-colour diagnostics, and deterministic LDConfig state. - -Implement the fixed TEXMAP state machine (`Inactive`, `AwaitingNext`, `Active`, `Fallback`) from the matrix above. Parse START/NEXT mode and finite parameters, accept NEXT only from `Inactive`, associate fallback geometry with the owning START frame, terminate only on same-file END/STEP/file end, and emit the concrete ordered `TexmapEvent` projection described above. Literal STOP is not a state transition. Unavailable texture resolution preserves event state. The `texmap_projection_v1` gate compares all projected event data, spans, file ownership, and fallback association. - -Project syntax, semantic identities, BFC/colour/TEXMAP state, steps/limits, graph IDs, counts, six-axis bounds, reflection, diagnostics, provenance, and canonical decimal spelling in declaration order. - -**Done when:** BFC, colour, TEXMAP, steps/transform, projection, and all applicable corpus gates pass; direct tests cover every BFC/TEXMAP transition/error including silent `Unknown`/`Uncertified + INVERTNEXT`, inactive `END`, nested `0 !:`, NEXT span targeting, malformed type 1–5 pending behavior, and separate `0 !:` versus standard type 1–5 active geometry; every expected record serializes the additive event list; repeated serialization is byte-identical; no recognized construct disappears without a diagnostic. - -### 8. Wire focused commands, WASM checks, and documentation - -Add adapter-to-gate tests that parse exactly all 28 authoritative embedded fixtures under their declared profiles and report fixture/gate/field failures. Add oracle-generation tests that run independently of the Rust core, generate twice to temporary directories, and byte-compare against committed records. Extend `just ldraw-wasm-check` to compile core and testkit for `wasm32-unknown-unknown`. Add the oracle-integrity check to the repository validation surface and ensure `ldraw-fuzz-smoke` uses the real core adapter. Document the API, profiles, resolver-independent identity, exact TEXMAP projection, derivation-note workflow, reservation owner source, root/child lifetime topology, and downstream boundaries. - -**Done when:** one focused command exercises exactly 28 fixtures and all named gates; oracle-only generation is deterministic and byte-matches committed records without importing/calling the Rust core; core/testkit compile for WASM; fuzz smoke tests the Rust candidate against oracle-backed expectations; documentation states the authority, ownership, and projection contracts. - -### 9. Review, validate, and hand off - -Load `reviewing-code-for-shipping-quality`, inspect every expected JSON diff as semantic data, and remove process residue, duplicate parsing, unbranded values where a branded type exists, unsafe/hidden fallback, generated churn, and comments that only narrate code. Run the exact validation sequence below, inspect `jj diff`, fix all Critical/High findings in follow-up commits, and commit the task change with the required trailers exactly once. - -**Done when:** every acceptance item and all ten prior findings map to code plus a concrete test; `jj diff` contains only PM-78-owned files; the change is on `epic-86-task-E`; the parent receives commit ID, changed-file summary, exact validation output, and reproducible remaining risks. - -## Test strategy - -This task has no provider, daemon, browser, TUI, or UI behavior. Tier 1 pure Rust tests cover semantic-core, ledger-backed ownership, corpus, and projection behavior; the native compatibility harness is a separate native integration boundary. Tier 2 TestProvider, Tier 3 daemon subprocess, and Tier 4 TestBackend do not apply. - -| Behavior | Tier/infrastructure | Required cases | -|---|---|---| -| Workspace/oracle safety | Tier 1 repository check plus workspace commands | Stop on default workspace/main; oracle generator links/calls only the pinned external wrapper and mechanical rule code; reject any Rust-core/adapter/actual-record dependency; one real in-process adapter exists only for candidate evaluation. | -| Oracle-generated expected integrity | Tier 1 generator/golden/testkit tests | Exactly the authoritative 28 inventory rows; exact inventory↔fixture↔expected equality; fixture/oracle/wrapper/schema/rule revisions; field-authority coverage; generate twice byte-identically; temporary generation byte-matches committed records; handwritten or Rust-derived oracle-representable fields fail. | -| Line syntax types 0–5 | Tier 1 scanner tables and syntax gate | Raw tails/spans; line endings; BOM/blank/whitespace/UTF-8; malformed counts/primitive fields; stable diagnostics. Include exact-case meta-keyword fixtures proving `STEP` maps to the keyword while lowercase `step` and mixed-case `Step` remain identifiers/garbage, plus quoted-identifier fixtures asserting one quoted identifier token with its contents/span preserved. | -| Numeric handling | Tier 1 unit/property tests | Signed zero, decimal/exponent spelling, finite extremes, overflow/underflow, NaN/infinity, canonical decimal output and diagnostic order. | -| Profiles/diagnostics | Tier 1 profile matrix | Strict rejection, compatibility acceptance, lossless preservation, code/severity/span/message/order, happy/error/boundary/adversarial rows. | -| MPD/DATA | Tier 1 virtual-file tests and MPD gate | FILE/NOFILE, duplicate/missing terminators, unreachable blocks, payload/base64, malformed/truncated DATA, decoded limits. Add explicit discard-state fixtures asserting bytes before the first `FILE`, after `NOFILE`, and between malformed boundary markers are discarded and never assigned to a virtual file/model. | -| Paths/resolution | Tier 1 path tables and path gate | Four roots; precedence/shadowing; exact/ASCII-fold collisions; explicit prefixes/no fallback; forbidden/out-of-root; resolver present/absent invariance. | -| Graph/instances | Tier 1 iterative graph tests and graph gate | Parse-once repeated models, distinct stable instances, cache identities, cycles, depth, unresolved/malformed refs, deep chains. | -| BFC | Tier 1 transition tables and BFC gate | Observed-vs-corrected CERTIFY/NOCERTIFY/CW/CCW; certified gating; reflection XOR; one-shot INVERTNEXT consumed only by valid type-1 include; explicit `Unknown`/`Uncertified + INVERTNEXT` unchanged/no-pending/no-strict-diagnostic case plus compatibility/lossless info tuple; primitive/intervening-command clearing, blank preservation, malformed ref, file/MPD switch, cache hit, and EOF. | -| Colours/LDConfig | Tier 1 colour state tests and colour gate | 16/current, 24/edge/direct, direct encodings, alpha/luminance/finish/material, scoped definitions, unknown colours. Add 512-slot boundary and exhaustion fixtures, deterministic local-colour allocation-order assertions, and scope commit/rollback assertions (committed definitions survive scope close; rolled-back definitions do not). | -| TEXMAP state/projection | Tier 1 state tests plus `texmap_projection_v1` corpus gate | Official START/NEXT/FALLBACK/END grammar (NEXT has no count token and applies to the immediately following nonblank valid type 1–5 line), four-state transitions, mode/finite params/spans, every explicit malformed tuple by profile, `TEXMAP_NEXT_TYPE0` span on the NEXT line, nested `0 !:` tuple, inactive END silent no-op, malformed type 1–5 preserving pending NEXT, literal STOP unknown-META retention with no rewrite, missing END/EOF without diagnostic, cross-file END, unresolved payload retention, empty list for non-TEXMAP records. | -| Steps/transforms/bounds | Tier 1 matrix/geometry tests and steps gate | STEP/ROTSTEP boundaries, finite right-handed transforms, determinant/reflection, no early scaling, independent six-axis AABB, ordered steps. | -| Limits/cancellation | Tier 1 bounded PM-77 vectors | Every limit and limit+1; checked overflow; cancellation at all named checkpoints; no partial publication. | -| Reservation lifetime/topology | Tier 1 ledger-backed core tests | Exact caller owner; fixed adapter owner; root-before-growth; all children via `reserve_child(&root, ...)`; child drop before root; successful charge remains until result drop; error/limit/cancel cleanup; stale owner and transfer. Add a ledger snapshot assertion that the root’s admitted amount equals exactly the caller-configured SemanticArenas budget (default: `bytes.len().checked_mul(8).and_then(|n| n.checked_add(8192))`) for representative inputs, plus a checked-add overflow assertion and a child-over-admission rejection assertion with `SEMANTIC_ARENA_RESERVATION_LIMIT` and no published result. | -| Canonical projection | Tier 1 golden/serialization tests | `ldraw-canonical-v1`, provenance, declaration order, canonical decimals, BFC/colour/TEXMAP/steps/diagnostics, byte-identical repeat serialization, no recognized loss. | -| Full PM-77/PM-78 corpus | Tier 1 adapter-to-gate integration | Parse exactly 28 authoritative fixtures under declared profiles; all applicable named gates; exact field failures; expected semantics generated from pinned oracle independently of the Rust candidate. | -| WASM/dependency safety | `cargo check` target plus dependency-tree assertions | Core/testkit tests compile for `wasm32-unknown-unknown`; no forbidden renderer/parser dependencies or host APIs; renderer split remains green. | -| Native differential boundary | Existing standalone native harness | LDParse stays out-of-process, offline, and separate from core; fuzz smoke invokes only the real in-process adapter. | - -Run focused validation in this order: - -```text -jj workspace root -jj status -jj bookmark list -cargo test -p polymodel-ldraw-testkit -cargo test -p polymodel-ldraw-core -cargo test -p polymodel-ldraw-core --tests -- --nocapture -cargo check --tests -p polymodel-ldraw-core --target wasm32-unknown-unknown -cargo check --tests -p polymodel-ldraw-testkit --target wasm32-unknown-unknown -just ldraw-corpus -just ldraw-wasm-check -just ldraw-fuzz-smoke -just verify-renderer-split -just fix -just check -just test-all -``` - -`just test-all` is the final merge gate and must visibly execute corpus, WASM, fuzz, differential, renderer-ledger, renderer, and workspace checks. Report the exact command and output for any environment failure; do not replace a failed gate with a weaker assertion. - -## Rework-specific implementation guardrails - -The prior implementation must not be patched by adding a few branches. Replace the semantic parser/model-builder path where its structure is fundamentally incompatible with the requirements (whole-line BFC matching, single hardcoded instance, no virtual-file table, no active-path state, no transform propagation, or silent TEXMAP transitions). Edit only stable testkit schema/provenance and command seams where their ownership is correct. - -The implementation review must reject a result that has any of these observable symptoms: `0 FILE` merely stripped instead of partitioned; content outside active MPD files assigned to a model; one fixed instance ID; recursive include descent; no `visiting`/active-path cycle state; cache lookup absent or case behavior accidental; type-1 transform values discarded; determinant/reflection always false; BFC determined by substring search; `INVERTNEXT` consumed by blanks or silently ignored; `CERTIFY`/winding transitions accepted while uncertified; TEXMAP transitions ignored; fewer than all 11 limits; unchecked count/byte arithmetic; or a successful parse whose semantic reservation is released before its owned result drops. - -The actual adapter-to-gate test is mandatory: it must feed each final fixture’s bytes and profile into the Rust core candidate, project the returned owned result while its reservation remains alive, and compare it with the independently generated pinned-oracle expected record. A test that passes expected data as both arguments, generates expected values from Rust actuals, or substitutes handwritten values for oracle-representable fields is invalid. - -## Required commit trailers - -Commit the implementation on the task change with a concise PM-78 message and exactly these trailers: - -```text -Epic: PM-86 -Task: PM-78 -``` - -## Review expectations - -Implementation reviewers must verify: - -- The implementation covers the complete task cache and all 28 authoritative fixtures. -- Every expected semantic payload is reproducibly generated from the pinned out-of-process LDParse oracle plus classified versioned mechanical rules; human edits are limited to provenance/rules, and no oracle generator path calls the Rust core/adapter/actual-record code. -- Reviewers must reject both Rust-derived expectations and handwritten oracle-representable values. Temporary generation runs twice byte-identically and byte-matches committed records. -- The harness fuzz path uses the real core candidate and LDParse remains the independent out-of-process authority. -- BFC expected outcomes and bounds are sourced from oracle-direct/mechanical data where exposed, with explicit PM-75/Polymodel rule classification only for genuine reference gaps. -- Exactly the authoritative 28 inventory rows exist and match fixture/expected sets; TEXMAP coverage is present without dropping another required category. -- TEXMAP is a typed state machine and its exact ordered `texmap_events` projection is checked by `texmap_projection_v1`. -- The shared adapter has one target-safe definition and the core remains independent of native harness code. -- Raw spans/tails/line endings, numeric spelling, diagnostic order, canonical decimals, schema, and provenance are stable. -- MPD/DATA boundaries, all four path contexts, resolver independence, cycles, limits, and iterative traversal are correct. -- Repeated models parse once while every occurrence has a distinct stable identity. -- `ReservationOwner` is explicit, caller-derived, and the deterministic adapter owner is documented and tested. -- There is exactly one root `SemanticArenas` reservation; all children use `reserve_child` against it; children drop before root; a successful parse retains the reservation until owned-result/arena drop or explicit transfer. -- No panic, silent recognized-construct loss, filesystem/network/process/archive access, recursion-based include overflow, random identity, or hidden fallback exists. -- The core WASM dependency tree stays free of `three-d`, `three-d-asset`, `stl_io`, and `polymodel-mesh`, and `just verify-renderer-split` is green. -- `jj diff` contains only PM-78-owned crate/API seam, testkit schema/expected/corpus updates, harness adapter change, justfile, tests, and documentation; generated API and unrelated app/UI files are absent. -- Critical/High findings are fixed before handoff and trailers are present. - -## Ready-for-parent-merge handoff - -Before returning to the parent orchestrator, provide: - -1. Task workspace `../epic-86-task-E`, bookmark `epic-86-task-E`, and committed change ID. -2. Changed-file summary; public crate/API names; profile, TEXMAP, field-authority/projection-rule, and reservation configuration; exact authoritative fixture count `28`; oracle/wrapper/schema/rule revisions; confirmation that temporary oracle-only generation ran twice byte-identically and byte-matched every committed expected record. -3. Exact focused output for testkit/core tests, projection/corpus gates, core/testkit WASM checks, fuzz smoke, and `just verify-renderer-split`. -4. Exact `just test-all` output proving the full merge gate ran, or the reproducible environment failure if it did not. -5. Confirmation that `jj diff` was reviewed and no prohibited state/source/generated files were modified. -6. A finding-disposition summary and any remaining risk stated as a reproducible command/failure, never an undocumented assumption. - -## Finding dispositions - -- **E-plan-1-F1 — superseded by operator authority correction.** All 28 expected semantic records are generated deterministically from the pinned external LDParse oracle. Humans author only provenance and versioned mechanical rules for demonstrably unavailable fields. Both Rust-derived expectations and handwritten oracle-representable values are prohibited and structurally tested. -- **E-plan-1-F2 — fixed and clarified in gen-3.** TEXMAP is a PM-75/LDraw-spec-only four-state machine (`Inactive`, `AwaitingNext`, `Active`, `Fallback`) with START/NEXT/FALLBACK/END, malformed/EOF behavior, literal-STOP diagnostics, ordered events, a concrete projection field, and a named `texmap_projection_v1` gate. -- **E-plan-1-F3 — fixed.** Expected bounds and projection use independent per-axis `[min_x,min_y,min_z,max_x,max_y,max_z]` values, with direct non-collapsing AABB tests. -- **E-plan-1-F4 — superseded by intentional corpus expansion.** The authoritative inventory is exactly 28, enforced by exact inventory↔fixture↔expected equality and exact-28 adapter/corpus commands; useful coverage is not deleted to satisfy the stale 24 numeral. -- **E-plan-1-F5 — fixed.** `ReservationOwner` is caller-supplied from worker/load/scene identity; production uses `interactive(...)`, the isolated adapter uses documented `preview(1,1,1)`, and the parser never invents owner identity. -- **E-plan-2-F1 — fixed.** `OwnedParseResult`/`SemanticArenas` owns the root reservation through successful parse and projection; release occurs only at owned-result/arena drop or explicit transfer. A snapshot test proves the charge remains after successful parse until drop. -- **E-plan-2-F2 — superseded by operator authority correction.** Heuristic/Rust-backed expected helpers are deleted, but a deterministic oracle-only generator is mandatory. It invokes pinned LDParse out of process, cannot depend on Rust core/adapter actuals, generates twice identically to a temporary directory, and byte-compares with committed expectations. -- **E-plan-2-F3 — fixed.** TEXMAP uses the exact additive `SemanticRecord.texmap_events: Vec` representation and the named `texmap_projection_v1` gate, with field contents and non-TEXMAP empty behavior fixed here. -- **E-plan-2-F4 — superseded by field-authority model.** Every field across all 28 records is classified as oracle-direct, oracle-mechanical, input-mechanical, or Polymodel-policy and carries exact oracle/rule revision traceability. There is no human-authored semantic class. -- **E-plan-2-F5 — fixed.** The reservation tree is one root `SemanticArenas` reservation; every child attaches directly through `reserve_child(&root, ...)`, children are stored/dropped before root, and topology is tested. -- **E-impl-1-F1 (Critical): superseded by operator authority correction.** A real adapter-to-gate integration test parses every fixture and evaluates the Rust candidate against independently generated pinned-oracle expectations; expected-as-actual, Rust-derived expected values, and handwritten oracle-representable values are explicitly invalid. -- **E-impl-1-F2 (Critical): fixed by plan.** The implementation steps require real MPD/DATA partitioning, normalized paths, iterative graph/cache traversal, active-path cycles, cancellation, LDConfig, reflection, all 11 limits, and cleanup; the guardrails reject the old skeleton symptoms. -- **E-impl-1-F3 (Critical): fixed by plan.** BFC is token/event-driven with explicit `Unknown`/`Certified`/`Uncertified` state, legal transitions, one-shot `INVERTNEXT`, and determinant reflection XOR; whole-line substring matching is prohibited. -- **E-impl-1-F4 (Critical): fixed and clarified in gen-3.** TEXMAP is a validated PM-75/LDraw-spec-only four-state machine with ordered typed START/NEXT/FALLBACK/END events, explicit nested/cross-file/EOF/invalid-numeric behavior, literal-STOP diagnostics, and deterministic unavailable-resolution retention; silent transition loss is prohibited. -- **E-impl-1-F5 (Critical): fixed by plan.** `LdrawLimits` names and tests all 11 PM-77 limits with checked arithmetic, limit/limit+1, overflow, cancellation checkpoints, and no partial publication. -- **E-impl-1-F6 (High): fixed by plan.** Type-1 transforms, stable model/instance IDs, normalized cache, explicit traversal stack, visiting cycle detection, completed parse-once reuse, and depth limits are mandatory. -- **E-impl-1-F7 (High): fixed by plan.** `SemanticArenas` stores child reservations so they drop before the retained root; ledger snapshots test successful retention, cleanup, and transfer/drop ordering. -- **E2rw-B-1 (High — BFC transition table): fixed in gen-3.** The normative table separates observed C++ behavior from PM-75 corrections, cites `ModelBuilderImpl.hpp`, defines `Unknown`/`Certified`/`Uncertified`, gates CLIP/NOCLIP/CW/CCW, and specifies INVERTNEXT consumption/clearing across primitives, commands, file/MPD switches, cache hits, and EOF. -- **E2rw-B-2 (High — TEXMAP matrix): fixed in gen-3.** The plan states TEXMAP is absent from the C++ reference, anchors transitions to PM-75 §3.5, and gives a total matrix for `Inactive`/`AwaitingNext`/`Active`/`Fallback`, START/NEXT/FALLBACK/END, nested START, invalid numerics, cross-file END, STEP, EOF, missing END, and literal STOP. -- **E2rw-B-3 (High — limits accounting): fixed in gen-3.** All eleven PM-77 limits have exact defaults, units, increment points, checked overflow/rejection behavior, and resolver-independent caller-supplied fetch/texture boundaries. -- **E2rw-B-4 (Medium — graph cycles): fixed in gen-3.** The graph step defines `visiting` insertion/removal on frame lifetime, the full cache identity key, cycle instance allocation and diagnostic preservation, and descent suppression without recursion. -- **E-plan-3-F1 (High — TEXMAP NEXT syntax): fixed in gen-4.** The plan now uses the official `0 !TEXMAP (START | NEXT) [GLOSSMAP pngfile]` grammar with no count token; NEXT applies to the immediately following nonblank valid type 1–5 geometry line, and a first nonblank type-0 line cancels it. Tests and fixtures assert this exact behavior. -- **E-plan-3-F2 (High — STOP pre-parse mapping): fixed in gen-4.** Valid fixtures use `END`; literal `STOP` appears only as lossless unknown-META negative coverage, and the parser performs no pre-parse vocabulary rewrite. -- **E-plan-3-F3 (High — graph/cache identity): fixed in gen-4.** The key is exactly `(canonical_path: NormalizedPath, resolved_root: RootId, content_hash: Sha256)`; model IDs are first-seen sequential `model-<8 decimal digits>` values and occurrence IDs are traversal-order sequential `instance-<8 decimal digits>` values. -- **E-plan-3-F4 (High — reservation root sizing): fixed in gen-4, revised gen-6.** The root SemanticArenas admission is a caller-configured memory budget. The default estimate is `bytes.len().checked_mul(8).and_then(|n| n.checked_add(8192))` — a policy default sufficient for typical inputs, not a mathematical proof of universal sufficiency. For unusually dense valid inputs, the caller supplies a larger budget. The corpus adapter budget is exactly 16 MiB. Exceeding the admitted amount rejects with `SEMANTIC_ARENA_RESERVATION_LIMIT` without partial publication. -- **E-plan-3-F5 (High — diagnostic tuples): fixed in gen-4.** Every malformed BFC/TEXMAP case has an explicit strict/compatibility/lossless `(profile, code, severity, result)` row, including NEXT type-0 invalidation, invalid method/value/arity, nested/cross-file/duplicate controls, malformed `!:` geometry, and literal STOP unknown META. -- **E4-BFC-001 (High — INVERTNEXT outside Certified): fixed in gen-5.** The BFC table explicitly defines `Unknown`/`Uncertified + INVERTNEXT` as unchanged state with no pending bit and no diagnostic in the strict C++-compatible behavior; compatibility/lossless have frozen low-severity `BFC_INVERTNEXT_OUTSIDE_CERTIFIED` info tuples that preserve syntax without transitioning state. Direct table-driven tests assert all profile outcomes. Verified against `/home/orual/Projects/LDParse-reference/include/LDParse/ModelBuilder/ModelBuilderImpl.hpp:89-97`. -- **E4-R1 (Medium — NEXT type-0 diagnostic span): fixed in gen-5.** The diagnostic contract and matrix state that `TEXMAP_NEXT_TYPE0` targets the `NEXT` command line, not the cancelling type-0 line; the test strategy asserts the exact span. -- **E4-R2 (Medium — nested `0 !:` tuple): fixed in gen-5.** The malformed-TEXMAP table freezes `TEXMAP_NESTED_BANG_COLON` as strict error/reject and compatibility/lossless warning/preserve-without-transition. -- **E4-TEXMAP-001 (Medium — inactive END): fixed in gen-5.** The matrix splits inactive FALLBACK and END; inactive END is an explicit silent no-op with no diagnostic and no state change, with malformed END still covered by `TEXMAP_INVALID_END`. -- **E4-TEXMAP-002 (Medium — malformed geometry after NEXT): fixed in gen-5.** The matrix adds `AwaitingNext + first nonblank malformed type 1–5` → `AwaitingNext`; the geometry diagnostic is emitted and NEXT remains pending for the next valid geometry. -- **E4-RESERVATION-001 (Medium — root sizing assertion): fixed in gen-5.** The reservation test strategy asserts exact root admission the caller-configured SemanticArenas budget (default: `bytes.len().checked_mul(8).and_then(|n| n.checked_add(8192))`), checked-add overflow, and child-over-admission rejection with no published result. -- **E4-SCANNER-001 (Medium — scanner casing/quotes): fixed in gen-5.** Scanner tests explicitly cover exact `STEP` versus lowercase/mixed-case forms and quoted-identifier token/span preservation. -- **E4-MPD-001 (Medium — MPD discard state): fixed in gen-5.** MPD tests explicitly cover and assert discarded content before first FILE, after NOFILE, and between malformed boundaries. -- **E4-COLOUR-001 (Medium — 512-slot colour boundaries): fixed in gen-5.** Colour tests cover 512-slot boundary/exhaustion, deterministic local allocation order, and scope commit/rollback. -- **E4-R6 (Low — split Active→Active geometry): fixed in gen-5.** The matrix has separate Active rows for `0 !:` geometry and standard type 1–5 geometry, both remaining Active and associating with the current texture. diff --git a/.current-epic/plan-F.md b/.current-epic/plan-F.md deleted file mode 100644 index 4d4bdc0..0000000 --- a/.current-epic/plan-F.md +++ /dev/null @@ -1,243 +0,0 @@ -# PM-79 / Task F — Minimal LDraw vertical slice and generic renderer scene IR - -## Contract and cold-start context - -- **Epic:** PM-86 — https://radiant-industries.atlassian.net/browse/PM-86 -- **Task:** PM-79 — https://radiant-industries.atlassian.net/browse/PM-79 -- **Prefix:** F -- **Task cache:** `.current-epic/task-F.md` -- **Manifest/dependencies/findings:** `.current-epic/manifest.txt`, `.current-epic/dependencies.dot`, `.current-epic/findings-F.jsonl` -- **Epic workspace/bookmark:** `/home/orual/Projects/epic-86`, `epic-86`; **base:** `main` -- **Task workspace/bookmark:** `/home/orual/Projects/epic-86-task-F`, `epic-86-task-F` -- **Parent truth:** consume the then-current `epic-86@` tip and reachable PM-86 reconciliation ancestry. Do not pin or revive `99436d09`; `.current-epic/task-state.json` is stale coordination metadata, not ancestry truth. -- **Oracle:** `/home/orual/Projects/three-d-reference`, detached clean tag `0.19.0`, commit `9b66a7459cb72bb317b58e791747871337bebb33`. - -F owns the dependency-light renderer scene contract and the sole LDraw-to-world boundary for one ordinary Thing → Model → Part → File compound flow. Keep one three-d context, camera/orbit/lights/target/readback/OffscreenCanvas lifecycle and current ordinary `Model` path. F adds the generic scene IR, E-owned semantic export consumption, custom world-space primitives, and shared Interactive/Preview output; it does not add a route, demo model, direct LDraw URL, upload flow, parser duplicate, production second renderer, or H’s complete classifier. - -## Safety, scope, and replace-vs-edit decisions - -Before source work, from the task workspace run `pwd`, `jj workspace root`, `jj workspace list`, `jj bookmark list`, `jj status`, and the one-line `jj log` checks for `epic-86@` and `main@`. Stop without editing if the root is not `/home/orual/Projects/epic-86-task-F`, the bookmark is not `epic-86-task-F`, this is the default workspace, the working copy is `main`, or the task change is not based on the then-current `epic-86@` tip. Do not edit manifests, dependency graphs, state, merge gates, event logs, branches, or workspaces. - -| Area | Decision | Frozen implementation shape | -|---|---|---| -| `crates/polymodel-renderer-protocol/src/lib.rs` and new `src/scene.rs` | Focused edit plus leaf module | Add branded IDs, units, provenance/diagnostics, deterministic scene tables/occurrences/primitives/bounds/stats, and appended `LengthUnit::LdrawUnit`; preserve ordinary wire fixtures and protocol independence from E/mesh/three-d. | -| `crates/polymodel-ldraw-core/src/{geom,model,types,resolve,lib}.rs` plus owned export module | Focused semantic extension | Retain scanner/parser/BFC/resolver/canonical projection. Add owned render export, explicit per-model LDMesh-compatible payload, and complete ordered primitive provenance; do not replace the parser or reparse bytes. | -| `crates/polymodel-renderer-worker/src/worker.rs` | Replace compound completion | Replace the complete `load_compound_source` → `finish_compound_source` chain and its byte-posting flow with worker-internal verified materialization → one E parse → one semantic export → one adaptation → pending resources → namespace output → metadata/readback observation → cleanup. `load_compound_source` must return a worker-internal verified materialization/source handle, never the removed protocol `CompoundSceneSource`. Do not add branches around the obsolete boundary. | -| `crates/polymodel-renderer-worker/src/main.rs` and target layout | Add reusable library, retain WASM bin, add native bin | Add `src/lib.rs` as the reusable worker/scene/render library; retain `src/main.rs` as the `renderer_worker` WASM bin; add exact native `src/bin/ldraw_preview.rs` binary named `ldraw_preview`; add integration test `crates/polymodel-renderer-worker/tests/ldraw_preview.rs`. | -| worker Cargo/Just surface | Focused build-system edit | In `crates/polymodel-renderer-worker/Cargo.toml`, add `polymodel-ldraw-core = { path = "../polymodel-ldraw-core" }` as a worker-only dependency alongside the existing mesh/protocol dependencies; app code remains protocol-only and `just verify-renderer-split` proves no `polymodel-ldraw-core`, mesh, three-d, or parser dependency enters the app WASM tree. Keep production WASM dependencies and add native-only `three-d` window support for the named preview target/test. Public Just recipes dispatch through `tools/in-dev-shell`; add private implementations behind them. | -| `src/viewer.rs`, `src/thing_detail.rs`, appview fixtures | Focused integration edit | Consume metadata-only events and the current-`LoadIdentity`-gated test readback hook through the existing normal Thing endpoint and Preview request path. | -| corpus/e2e fixtures | Focused fixture extension | Reuse current `/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/thing/parametric-enclosure` anchor and existing resolver keys; add only named real byte files/helper/test assertions below. | - -A module/function is replaced when it cannot express both namespaces without violating the worker-internal verified-byte boundary; `finish_compound_source` is therefore an explicit replacement. A three-d fork is permitted only on compile evidence from the pinned `0.19.0` source that a required generic public hook is inaccessible; isolate only that generic hook in a pinned sibling path and keep all Polymodel scene/classifier logic outside it. - -## Frozen contracts - -### Units and world boundary - -`1 LDU = 1/64 inch = 0.396875 mm = 0.000396875 m` exactly, represented as `Units::declared(LengthUnit::LdrawUnit)`. E parsing, inclusion, semantic export, instancing, BFC, bounds, and F adaptation remain finite `f64` LDU; no scaling occurs before the renderer/world boundary. Apply exactly once: - -```text -(x, y, z) -> (x, z, -y) -B = [[1, 0, 0], [0, 0, 1], [0, -1, 0]] -det(B) = +1 -``` - -Preserve winding; compute source normals before B, use inverse-transpose for non-rigid occurrence transforms, apply B after that, and compute bounds after primitive points, occurrence transforms, and B. Correct stale protocol sites `crates/polymodel-renderer-protocol/src/lib.rs:326` and `:1055-1056` from `1/64 inch`/`0.396875 mm` to the task value. Test exact stud/module vectors separately. - -### Generic scene IR - -Add dependency-light serde/postcard-safe `SceneId`, `NodeId`/`OccurrenceId`, `GeometryId`, `MaterialId`, `TextureId`, `ProvenanceId`, and `DiagnosticId` (opaque deterministic IDs, not vector indices). `Scene` owns declared units, source/provenance, diagnostics, root, ordered geometry/material/texture tables, flat occurrences, bounds, and checked stats. Geometry retains semantic points, indexed triangles, quads, explicit lines, and conditional endpoints/control points; GPU upload alone may triangulate quads. Occurrences retain source/target identity, source span, material/BFC state, composed transform, and separate identity for repeated child instances. Materials retain colour/edge/finish/local mapping; F does not sample TEXMAP. Counts derive from emitted transformed semantic geometry. Ordinary wire-compatible `MeshStats { vertices, triangles }` remains unchanged. - -### E export: two distinct structures - -The implementor must add and name a new per-model `LdMeshSemantic` structure; it does **not** already exist and must not be implied to be `GeometryRecord`. For every model, construct it during E semantic processing and compare it to the pinned C++ LDParse/LDMesh oracle: - -- `positions: Vec<[f64; 3]>` in native source LDU order; -- `normals: Option>`, populated only when the oracle exposes authoritative normals for that model, otherwise `None`; -- `indices: Vec` forward triangle indices; -- `bf_indices: Vec` reverse/back-face index stream exactly as oracle-observable; -- `step_offsets: Vec` direct-mesh index offsets in LDParse index space. - -Keep a separate ordered `Vec` with one source primitive per record, never grouped batches. Enumerate its fields explicitly: `line_type`, `colour`, ordered `vertices`, source model identity, exact source span, effective BFC state, winding/reversal/inversion state, TEXMAP association, and stable primitive/provenance identity. Type 2/3/4/5 roles remain exact; quads and source provenance remain semantic until GPU upload. Add every required field to all `GeometryRecord` constructors and serializers, update every `crates/polymodel-ldraw-core` test and `crates/polymodel-ldraw-testkit` fixture/snapshot that constructs or decodes it, and reject missing fields: no `serde(default)`, permissive defaults, compatibility shims, or hidden backcompat path. Also retain model identity/path/default colour/local colour mappings, child/include identity and spans, occurrence transforms/colour inheritance, sharing, and typed materialization provenance. Compare positions, authoritative normals, forward/reverse indices, step offsets, primitive order/counts/identity, BFC/material and source spans against `crates/polymodel-ldraw-testkit/corpus/expected/*.json` oracle fields; metadata counts alone are insufficient. - -The semantic export remains `f64` and must first reject non-finite coordinates/normals. For each oracle-comparable value, perform a checked finite `f64 -> f32` conversion, compare the exact f32-rounded representation (`to_bits`) with the decoded C++ oracle f32 encoding/fixture value, and separately retain/assert the original finite f64 semantic value; do not compare f64 directly to f32 or silently hide precision loss. If an oracle fixture is decimal-formatted rather than bit-encoded, use the named oracle float-format tolerance derived from that formatter and assert the rounded f32 bits separately. - -### Compound boundary and stale protocol cleanup - -Verified descriptor bytes stay inside the worker. Remove or replace `CompoundResourcesReady` at `crates/polymodel-renderer-protocol/src/lib.rs:684-690` with namespace success events and, if retained, a test/metadata-only identity/count/bounds event containing no bytes, vectors, raw text, or descriptors. Explicitly remove/relocate the obsolete byte transport at protocol `:611-632` (`CompoundSceneSource`, `handoff_compound_scene`) and update/remove its golden tests at `:997-1012`; delete worker uses at `worker.rs:1361`, `:1407-1412`, `:1421`, `:1448` and main-thread use at `src/viewer.rs:1806-1815`. Any genuinely worker-internal source container belongs in the worker library, not protocol. `InstallSucceeded` is Interactive readiness only; `PreviewImageRendered { request_id, png }` is Preview success only. No source bytes cross the worker bridge. - -### Operator-owned browser and ledger boundaries - -Do not add or restore `renderer-test-readback`, `RendererReadback`, `TestReadback`, `window.__POLYMODEL_TEST_READBACKS__`, `e2e-viewer`, or `serve-headless`. Do not add browser e2e to `test-all`. The existing compound browser test remains a transport test for the public plan and resource routes; do not turn it into renderer/readback acceptance or remove its transport assertions. - -Do not add reservation classes, charges, ownership, tests, or other behaviour to `polymodel-renderer-ledger`. PM-88 is frozen. F uses the existing ledger API where the existing implementation already does so, but this task does not expand or redesign ledger admission. Rendering evidence belongs to the shared native renderer/preview path; official-library evidence belongs to focused resolver/native integration coverage. - -## Numbered implementation steps - -### 1. Ground the live parent and load implementation skills - -Verify workspace/ancestry safety, inspect the then-current A/B/D/E/G symbols, and inspect the pinned three-d 0.19 public `Geometry`, `Material`, `Program`, `Gm`, buffer/context, window, and readback seams. Load the renderer/frontend skills applicable to worker protocol, native rendering, and browser tests. - -**Done when:** live parent tip and pinned oracle/reference are recorded in the task change; no work starts on `main` or stale `task-state`; required public three-d seams are identified from the exact pinned source. - -### 2. Add protocol scene IR, units, and byte-boundary cleanup - -Implement the scene module and appended LDU unit. Remove the stale `CompoundSceneSource`/`handoff_compound_scene`/byte-bearing event sites listed above and update main handling to metadata-only/current-identity events. Preserve ordinary wire golden values and protocol dependency split; do not add browser test-readback protocol. - -**Done when:** protocol native/WASM checks via Just pass; scene IDs/order/serde/postcard and exact LDU tests pass; no protocol event carries verified bytes; exact stale sites are corrected or removed; no browser readback feature or event exists. - -### 3. Extend E with oracle-matched semantic export - -Add `LdMeshSemantic` and the distinct ordered `GeometryRecord` fields, construct both once during E semantic processing, preserve type 1–5 provenance, BFC/material/TEXMAP state, models/children/occurrences, and exact step offsets. Add typed root mapping and materialization validation: app descriptor roots `mpd`, `manifest`, `models`/`parts`/`p`/`external`, `official-parts`/`official-p` map respectively to `CurrentMpd`, `UploadedManifest`, `UploadedLdraw`, `OfficialLibrary`; unknown roots, invalid paths, missing/duplicate primary, duplicate `(RootId, normalized path)`, route mismatch, length/digest mismatch, and descriptor identity mismatch fail before parse. - -**Done when:** `just test-ldraw-core`, `just ldraw-differential`, and `just ldraw-corpus` compare actual export fields against the pinned C++ oracle; positions, authoritative normals, indices, `bf_indices`, step offsets, ordered records/identity, BFC/material, and source spans are asserted, with checked finite f64 values and exact f32-rounded/oracle encoding comparisons, and semantic drops separately observed from worker ledger snapshots. In `src/appview/read_tests.rs`, named tests `ldraw_descriptor_root_mapping_accepts_supported_roots` and `ldraw_descriptor_rejects_malformed_plans_before_parse` use `e2e/fixtures/ldraw/descriptor-matrix.json` to cover `mpd`, `manifest`, `models`/`parts`/`p`/`external`, `official-parts`/`official-p`, unknown roots, invalid paths, missing/duplicate primary, duplicate `(RootId, normalized path)`, route mismatch, length/digest mismatch, and descriptor identity mismatch. - -### 4. Add reusable worker library and custom three-d objects - -Move reusable scene construction, adaptation, camera/draw/readback/PNG functions into `crates/polymodel-renderer-worker/src/lib.rs`. Retain `src/main.rs` as WASM `renderer_worker`; add `src/bin/ldraw_preview.rs` as native `ldraw_preview`; add `crates/polymodel-renderer-worker/tests/ldraw_preview.rs` as the automated named-target integration test. Use public three-d 0.19 `Geometry`, `Material`, `Program`, `Gm`, and low-level buffers/context for world-space surfaces, lines, portable points, and conditional endpoints/controls. Do not use built-in 2D `three_d::Line` or invent `three_d::Points`; keep ordinary `Model` and glTF hierarchy/PBR/textures unchanged. F owns only the minimal conditional visibility rule/seam; H owns the complete classifier. - -**Done when:** pure worker tests cover basis/determinant/winding/normals/bounds, finite/nonempty geometry, primitive objects, conditional controls/rule, pending/installed ownership, and ordinary format parity; the library is shared by WASM worker and native preview with one scene/render path. No ledger API or admission expansion is introduced. - -### 5. Replace compound completion and unify Interactive/Preview - -Replace the complete `load_compound_source` → `finish_compound_source` chain with verify → worker-internal typed materialization → parse once → export once → adapt once → build pending candidate → namespace output; remove the protocol `CompoundSceneSource` return type from that chain. The adapter maps E model identity/path/default and local colours to scene provenance/material tables; ordered `GeometryRecord` vertices/topology/BFC/winding/TEXMAP/provenance to scene geometry and material references; and instance source/target identity, composed transforms, inherited colour, inversion, and spans to distinct flat occurrences. It derives scene bounds/stats only from emitted transformed semantic geometry. Check session/namespace/load epoch/scene generation/acquisition/restart identity, reducer identity, abort, and disposal before/after each boundary. Interactive posts/accepts `InstallSucceeded` through the reducer before swapping candidate for old scene and then performs actual draw/readback. Preview renders the same candidate path and posts `PreviewImageRendered`; it never posts Interactive readiness. On error/cancel/stale/resize/dispose release pending semantic/GPU values and preserve the existing cancellation and cleanup behaviour without adding ledger classes or admission semantics. - -**Done when:** lifecycle tests match the finite table below; no stale/cancelled completion publishes success or replaces the old scene; parse/export/adapt counters are exactly once on successful Interactive and Preview; abort slots are empty after terminal cleanup. - -### 6. Ship optional official-library fallback and seed the pinned OMR product path - -Restore the historical pinned Nix `ldraw-parts` derivation (`2026-06`, `complete.zip`, fixed hash) as `packages.ldraw-parts`. Add optional server runtime configuration `POLYMODEL_LDRAW_LIBRARY_DIR` at the process environment boundary and carry it internally as a validated/branded immutable library root. A configured server serves only `official-parts` and `official-p` resources from that tree; MPD/manifest/models/parts/p/external project resources remain PDS/projection-backed. Preserve resolver precedence so MPD/project-provided resources override official fallback. Canonicalize paths beneath `parts/` and `p/`, reject traversal and symlink escapes, and include pinned library version/source identity, byte length, and digest in descriptors. When the package is not configured or a required official part is absent, resolution fails explicitly before rendering; never require publishers to upload official-library bytes to their PDS and never partially render. - -Keep the existing normal route and anchor `/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/thing/parametric-enclosure` and request body `at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/parametric-enclosure`. Use the existing accepted compatibility fixture `crates/polymodel-ldraw-testkit/corpus/omr-10030-1.mpd` (manifest SHA-256 `a7f56cfbd71445d6fa218a102161608d52f63cd0b3b55062276c7e0f54b03ebb`) and its authoritative `corpus/expected/omr-10030-1.expected.json`; do not create a smaller preview/product-only fixture. Dev/native Just recipes inject the restored Nix derivation root; server integration tests configure the same package through `POLYMODEL_LDRAW_LIBRARY_DIR`. - -Add `seed_ldraw_compound_fixture` beside the existing `seed_if_empty`/`seed` helpers in `src/indexing/sample_data.rs`, and make the appview fixture routes serve the exact OMR plus official-library fallback bytes with real CID, SHA-256, length, MIME/type, root/path, version, and source identity. Tests compare fetched bytes directly to corpus/library bytes and assert local official fallback without PDS uploads, project override precedence, missing-library and missing-part failure, traversal/symlink rejection, and the OMR's actual model graph, STEP offsets, repeated transformed occurrences, BFC/material state, ordered primitive identity/source spans, and nonempty bounds against its expected record. - -**Done when:** the existing `/app/ldraw/compound-resources` and resource routes deliver the pinned OMR primary plus required local official-library resources, the normal Thing detail viewer consumes them without an alternate route or PDS copies, project resources override official fallback, absent/malformed official resources fail closed, and root/integrity/identity tests reject malformed plans before E parse. - -### 7. Freeze native preview recipes and automated target - -Edit the Justfile public dispatch surface and private implementations with these exact recipes: - -- `just test-ldraw-core` — focused E/export/oracle unit and fixture tests; -- `just ldraw-wasm-check` — host-WASM compile/test gate for `polymodel-ldraw-core` and `polymodel-ldraw-testkit`; -- `just test-renderer-native` — under the recipe-owned headless GL/Xvfb environment, invokes `crates/polymodel-renderer-worker/tests/ldraw_preview.rs` against `crates/polymodel-ldraw-testkit/corpus/omr-10030-1.mpd` plus the pinned official-library resolver inputs, at fixed `1280x720`, and asserts readback/PNG/stats/bounds and external-part resolution; -- `just preview-ldraw *ARGS` — invokes the exact native binary `ldraw_preview` and forwards `ARGS` unchanged; with no arguments it uses the same pinned OMR fixture/resolver, fixed `1280x720` viewport, and deterministic output `target/ldraw-preview/omr-10030-1.png`; `--help`, input, resolver/library root, output PNG, width, and height are documented; - -The private implementations remain behind `tools/in-dev-shell`; public recipe names are the only repository command surface. The recipes own a deterministic headless GL environment (Xvfb with fixed screen/depth or the repository's equivalent proven headless three-d context) and fail when that environment, the OMR bytes, or any pinned official-library dependency is unavailable. `just preview-ldraw` and `just test-renderer-native` call the same library scene construction, resolver, camera, draw, real readback, and PNG encoder; add no second renderer backend. - -**Done when:** `just preview-ldraw --help`, zero-argument `just preview-ldraw`, and `just test-renderer-native` execute the named target without ambient display configuration; `target/ldraw-preview/omr-10030-1.png` has a valid PNG signature, exact `1280x720` dimensions, non-clear pixels, expected oracle-derived primitive counts/bounds, and evidence that official-library parts were resolved and rendered; missing official dependencies fail the test; `just build-renderer-worker` still builds the retained `renderer_worker` WASM bin; `just verify-renderer-split` proves the app remains protocol-only. - -### 8. Preserve browser transport coverage and add native rendering parity - -Keep the existing compound case in `e2e/tests/viewer.spec.ts` as a public plan/resource transport test with its current descriptor, HTTP, byte-stability, and compound-boundary assertions. Do not add browser readback, screenshot, Preview PNG, or renderer acceptance to that test. Add focused resolver/native integration coverage for a compact model that exercises the built-in official library, proving typed official roots, precedence, transitive resolution, exact bytes, and successful native rendering. Retain and strengthen ordinary STL/OBJ/glTF/3MF parity in the shared native scene/render path using the existing ordinary fixtures. - -**Done when:** `just e2e` preserves the compound transport test unchanged in purpose, and `just test-renderer` plus `just test-renderer-native` prove official-library resolution and actual rendering without browser readback infrastructure. - -### 9. Validate, review, and hand off - -Run the exact Just surface below, inspect `jj diff`/`jj status`, and commit only intended implementation, test, fixture, and recipe files with trailers: - -```text -PM-79: add minimal LDraw renderer scene boundary - -Epic: PM-86 -Task: PM-79 -``` - -Resolve critical/high implementation-review findings before parent handoff. Report the consumed parent tip, commit, file groups, exact recipe results, oracle evidence, fixture byte/CID/digest evidence, event-security evidence, lifecycle evidence, and clean task status. Parent merges only after all gates and review clearance. - -## Finite lifecycle acceptance table - -`P` means Preview. Successful rows require the listed success event exactly once; interrupted rows require zero success events. `parse/export/adapt` are exact invocation counts. `abort` is the namespace abort slot. `candidate/old` states are after cleanup. This table validates F lifecycle behaviour only; it does not add or alter PM-88 ledger requirements. - -| Case | Namespace/checkpoint | Expected event(s) | parse/export/adapt | abort | candidate / old scene | semantic drop | -|---|---|---|---:|---|---|---| -| `I-fetch-fail` | Interactive, fetch | one error; no readiness | `0/0/0` | empty | none / old retained | none | -| `P-fetch-cancel` | Preview, fetch | one cancellation; no PNG | `0/0/0` | empty | none / unchanged | fetched values dropped | -| `I-preparse-cancel` | Interactive, pre-parse | one cancellation; no readiness | `0/0/0` | empty | none / old retained | materializations dropped | -| `P-preparse-cancel` | Preview, pre-parse | one cancellation; no PNG | `0/0/0` | empty | none / unchanged | materializations dropped | -| `I-postparse-pre-adapt-stale` | Interactive, after parse before adapt | stale ignored; no readiness | `1/1/0` | empty | candidate absent / old retained | export dropped | -| `P-postparse-pre-adapt-stale` | Preview, after parse before adapt | stale ignored; no PNG | `1/1/0` | empty | candidate absent / unchanged | export dropped | -| `I-postadapt-pre-output-error` | Interactive, after adapt before install | one error; no readiness | `1/1/1` | empty | candidate dropped / old retained | candidate/export dropped | -| `P-postadapt-pre-output-error` | Preview, after adapt before PNG | one error; no PNG | `1/1/1` | empty | candidate dropped / unchanged | candidate/export dropped | -| `I-stale-completion` | Interactive, completion after newer identity | stale ignored; no readiness/swap | `1/1/1` | empty | stale candidate dropped / newer scene retained | candidate/export dropped | -| `P-stale-completion` | Preview, pre-publication after newer identity | stale ignored; no PNG | `1/1/1` | empty | stale candidate dropped / unchanged | candidate/export dropped | -| `I-success` | Interactive, reducer-accepted install then draw | one `InstallSucceeded` | `1/1/1` | empty | candidate installed / old released once | old scene released once | -| `P-success` | Preview, output publication | one `PreviewImageRendered` | `1/1/1` | empty | temporary candidate dropped / unchanged | candidate/export dropped after PNG | -| `I-resize` | Interactive, installed scene | no reload/readiness | unchanged `1/1/1` | empty | installed retained / same identity | none | -| `I-dispose` | Interactive, during fetch/parse/adapt/install | one disposal; no late readiness; late completion ignored | at-most `1/1/1`, never repeated | empty after disposal | pending candidate dropped / old scene retained | all temporary semantic/export/adapt values dropped | -| `P-resize` | Preview, before publication | no reload/PNG publication; resize is rejected as non-Interactive and late output is ignored | unchanged `1/1/1` | empty | temporary candidate dropped / unchanged | all temporary semantic/export/adapt values dropped | -| `P-dispose` | Preview, during/after output | one disposal; no late PNG | at-most `1/1/1`, never repeated | empty | candidate dropped / unchanged | all temporary values dropped | - -## Test strategy and exact validation order - -Tests are Tier 1 pure protocol/E/adapter/reducer/oracle tests, Tier 3 real appview/worker/browser integration, and Tier 4 native GL/readback tests; no provider-backed tests apply. Every behavior covers success, failure, cancellation/supersession, malformed input, and cleanup through the named recipe rather than raw build/test commands. - -Run in this order from `/home/orual/Projects/epic-86-task-F` after safety checks: - -```text -just check -just fix -just test-ldraw-core -just ldraw-wasm-check -just build-renderer-worker -just verify-renderer-split -just test-renderer-native -just preview-ldraw --help -just preview-ldraw -just test-renderer -just e2e -just test-all -jj diff --summary -jj diff -jj status -``` - -Do not add `e2e`, an `e2e-viewer` recipe, or new renderer/ledger gates to the `test-all` dependency list. Run `just e2e` explicitly to preserve the existing browser transport coverage. The public `just ldraw-wasm-check` recipe dispatches `_ldraw-wasm-check`; all other required F validation uses the existing Just surface without expanding PM-88 ledger gates. Do not write raw Cargo/npm/dx/nix/wasm-bindgen commands in this plan; all repository builds, tests, generation, browser, renderer, and oracle work is invoked through plain `just ` public dispatch. - -## Complete finding disposition — all 34 supplied IDs - -Round-two architecture review marked the architecture CLEAN, but medium findings are fixed by default. Parent-accepted rebuttals remain parent-owned and are preserved in substance. - -| Finding ID | Disposition | Required plan response | -|---|---|---| -| `F-plan-2-A1` | **Fixed** | Use current `epic-86@` ancestry; prohibit hidden `99436d09`. | -| `F-plan-2-A2` | **Fixed** | F supplies only the custom conditional primitive/minimal rule/seam; H owns the complete classifier. | -| `F-plan-2-A3` | **Corrected after operator clarification** | PM-79 task contract is authoritative: `1 LDU = 1/64 inch = 0.396875 mm = 0.000396875 m` exactly. Preserve the correct protocol comments and exact conversion tests. | -| `F-plan-2-A4` | **Fixed** | Ground completion on current `epic-86@` and live symbols, not stale baseline evidence. | -| `F-plan-2-A5` | **Fixed** | Keep app descriptor root strings distinct from typed E `RootId`; freeze the eight-to-four mapping and provenance. | -| `F-plan-2-C1` | **Fixed** | Use public three-d 0.19 custom `Geometry`/`Material`/`Program`/`Gm` and low-level seams; prohibit built-in 2D `Line` and nonexistent `Points`. | -| `F-plan-2-C2` | **Fixed** | Replace `finish_compound_source` with the complete worker-internal pipeline and cleanup checkpoints. | -| `F-plan-2-C3` | **Rebutted-accepted by parent; preserved** | Reachable PM-86/PM-78 trailers and parent validation are the completion evidence; stale portable state is not truth. | -| `F-plan-2-C4` | **Fixed** | Typed materialization/root/path/hash/length/span/source identity is required; no guessed free-form provenance. | -| `F-plan-2-C5` | **Fixed** | Add explicit per-model LDMesh payload, ordered flat `GeometryRecord`, forward/reverse topology, BFC/winding, colours, spans, steps, sharing, transforms, and TEXMAP identity. | -| `F-plan-2-C6` | **Fixed** | Freeze `src/lib.rs`, retained `src/main.rs`/`renderer_worker`, `src/bin/ldraw_preview.rs`/`ldraw_preview`, `tests/ldraw_preview.rs`, and exact Just recipes. | -| `F-plan-2-C7` | **Superseded in part by explicit operator direction** | E owns semantic data. Preserve existing PM-88 consumption, but do not add ledger classes, charges, reservation ownership, admission behaviour, or ledger tests in F. | -| `F-plan-2-B1` | **Fixed** | Remove byte-bearing `CompoundResourcesReady` and `CompoundSceneSource` bridge; bytes remain worker-internal. | -| `F-plan-2-B2` | **Fixed** | Exact-once Interactive/Preview event-order and stale/cancel tests are in the finite table. | -| `F-plan-2-B3` | **Fixed** | Named native binary/library/integration test share one scene/render/readback/PNG path and Just dispatch; zero-argument preview uses pinned `omr-10030-1.mpd`, official-library resolution, fixed headless GL/viewport/output, and fail-closed assertions. | -| `F-plan-2-B4` | **Fixed** | The normal Thing helper serves pinned `omr-10030-1.mpd` and required official-library bytes with real CIDs/digests/lengths; tests assert resolved external parts, model graph/STEP/repeated-transform/BFC/material semantics, primitive provenance, and bounds against the expected record. | -| `F-plan-2-B5` | **Fixed** | Preserve ordinary STL/OBJ/glTF/3MF path and test glTF hierarchy/bounds/PBR/textures plus common frame/Preview behavior. | -| `F-plan-2-B6` | **Superseded in part by explicit operator direction** | The lifecycle table covers F namespaces, checkpoints, stale/cancel/error/resize/dispose, abort, candidate/old, and semantic drops without adding ledger requirements. | -| `F-plan-2-B7` | **Fixed** | Every supported root plus unknown/path/primary/duplicate/integrity/route mismatch fails closed before parse. | -| `F-plan-2-B8` | **Superseded by explicit operator direction** | Do not restore browser test-readback features, events, globals, hooks, or dedicated recipes. Native rendering tests provide pixel evidence; browser compound coverage remains transport-only. | -| `F-plan-2-B9` | **Fixed** | Preserve semantic quads/provenance; triangulate only during GPU upload. | -| `F-plan-3-B1` | **Fixed** | Concrete `lib.rs`, retained WASM `main.rs`, named `ldraw_preview` bin, named integration test, exact public recipes, and argument forwarding are specified. | -| `F-plan-3-B2` | **Fixed** | Existing Thing/e2e resolver anchors reuse pinned `omr-10030-1.mpd`, its expected record, and required official-library bytes through `seed_ldraw_compound_fixture`; actual bytes, external resolution, and parsed export assertions are required without a duplicate product-only fixture. | -| `F-plan-3-B3` | **Superseded in part by explicit operator direction** | Lifecycle coverage retains namespace/checkpoint/outcome/event counts/exact-once counters/abort/scenes/drops without adding ledger requirements. | -| `F-plan-3-B4` | **Superseded by explicit operator direction** | Browser readback features, events, hooks, globals, and e2e assertions remain removed. | -| `F-plan-3-C1` | **Fixed** | `LdMeshSemantic` fields are explicit and separate from enumerated ordered `GeometryRecord`; construction is during E semantic processing and oracle comparison is required. | -| `F-plan-3-C2` | **Fixed** | Exact stale LDU comment/assertion sites `protocol/src/lib.rs:326` and `:1055-1056` are named. | -| `F-plan-3-C3` | **Fixed** | Exact `CompoundSceneSource`/`handoff_compound_scene` protocol, worker, viewer, and golden-test cleanup/relocation sites are named. | -| `F-plan-4-B1` | **Superseded in part by explicit operator direction** | The lifecycle table retains `I-dispose` and `P-resize` event/state/drop assertions without adding ledger requirements. | -| `F-plan-4-B2` | **Superseded by explicit operator direction** | Do not restore `renderer-test-readback`, `e2e-viewer`, `serve-headless`, browser readback wiring, or browser e2e in `test-all`. | -| `F-plan-4-B3` | **Superseded in part by explicit operator direction** | Retain appview mapping/malformed tests, official-library resolver/native coverage, ordinary format parity, and existing Just validation without expanding `test-all`. | -| `F-plan-4-C1` | **Fixed** | Semantic coordinates stay finite `f64`; checked conversion compares exact f32-rounded bits/oracle encoding (or the named oracle-format tolerance with separate rounded-bit assertion), preserving precision visibility and prohibiting naive f64 equality. | -| `F-plan-4-C2` | **Fixed** | `crates/polymodel-renderer-worker/Cargo.toml` explicitly adds worker-only `polymodel-ldraw-core`; the app remains protocol-only and `just verify-renderer-split` proves the dependency boundary. | -| `F-plan-4-C3` | **Fixed** | Every required `GeometryRecord` field is updated across constructors, serializers, tests, fixtures, and snapshots; missing fields are rejected without permissive serde defaults or compatibility shims. | - -## Review expectations and handoff - -Reviewers block hidden baseline pins, main/default-workspace edits, stale state as truth, a second production renderer/context/camera/readback path, wrong LDU/basis/winding/normal/bounds timing, parser duplication, lossy/grouped geometry, missing LDMesh fields or oracle comparison, byte-bearing protocol events, readiness before reducer acceptance, stale scene replacement, non-exact parse/export/adapt, weak resolver/native fixture tests, raw repository commands, a broad H classifier, a Polymodel-specific three-d fork, missing worker-only LDraw dependency, direct f64-to-f32 equality, or permissive GeometryRecord schema evolution. Reviewers must respect the operator-owned prohibitions on browser readback restoration, `e2e-viewer`, `test-all` expansion, compound browser transport-test mutation, and ledger changes. The implementation reviewer must verify `jj diff` contains only intended source/test/fixture/Just files and that all 34 finding dispositions remain satisfied. - -Ready-for-parent-merge handoff includes workspace/bookmark/commit and consumed parent tip; grouped files; exact `just` results including `just test-all`; clean `jj diff`/`jj status`; pinned-oracle semantic evidence for `LdMeshSemantic` plus ordered records; exact root mapping and real byte fixture CID/digest/length/type/BFC/material/repeated-child evidence; LDU/basis/no-scaling proof; proof bytes never crossed the worker bridge; reducer-accepted Interactive readiness; shared Preview/native/browser readback/PNG evidence; finite lifecycle ledger-zero/drop evidence; and confirmation that F’s conditional-line seam is ready for H without renderer surgery. diff --git a/.current-epic/plan-G.md b/.current-epic/plan-G.md deleted file mode 100644 index cdd6b89..0000000 --- a/.current-epic/plan-G.md +++ /dev/null @@ -1,237 +0,0 @@ -# PM-80 — Compound LDraw resolver and versioned worker protocol (planner-G4) - -## Task identity and source of truth - -- **Epic:** PM-86 — LDraw/LEGO model support and flexible renderer -- **Epic URL:** https://radiant-industries.atlassian.net/browse/PM-86 -- **Task:** PM-80 — Compound LDraw resolver and versioned worker protocol -- **Task URL:** https://radiant-industries.atlassian.net/browse/PM-80 -- **Task prefix:** G -- **Task cache:** `.current-epic/task-G.md` -- **Epic workspace:** `../epic-86` -- **Base bookmark:** `main` -- **Task workspace:** `../epic-86-task-G` -- **Task bookmark:** `epic-86-task-G` -- **Dependencies:** A/PM-87, B/PM-88, C/PM-76 — completed -- **Blocks:** H/PM-81, J/PM-83, K/PM-84 -- **Design source:** [PM-75 LDraw/LEGO model support and flexible renderer architecture](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -The cached task contract in `.current-epic/task-G.md` is authoritative. Jira is the workflow source of truth; this plan is the implementation handoff for the isolated task workspace. - -## Context - -G consumes PM-76’s typed verified resolver, PM-87’s identity/reducer state, and PM-88’s reservation ledger. It must provide a URL-free, principal-bound compound LDraw plan and replace the current bare postcard stream with a negotiated, versioned app↔worker protocol. PM-76 currently exposes `ResolvedResource::ByteSource { resource_uri, blob_cids }`; G must add its adjacent descriptor/route seam without duplicating PM-76’s schema, route table, or verification state machine. - -LDraw scale is exact: `1 LDU = 1/64 inch = 0.396875 mm = 0.000396875 m`. Convert exactly once at the worker scene/IR boundary. - -## Isolation and safety gate - -Before editing source, run: - -```sh -jj workspace root -jj workspace list -jj bookmark list -jj status -jj log -r @ -n 1 -``` - -Stop before editing when the root is not `../epic-86-task-G`, the workspace is the default/Epic workspace, `@` is on `main`, or the bookmark is not `epic-86-task-G`. Start a fresh change: - -```sh -jj new -m "PM-80: compound LDraw resolver and versioned worker protocol" -``` - -Do not edit or commit on `main` or `../epic-86`. Do not edit parent-owned `.current-epic/manifest.txt`, `dependencies.dot`, `task-state.json`, `merge-gate.json`, or runtime logs. The task workspace consumes the copied contract and plan through `.current-task/task.md` and `.current-task/plan.md`. - -## Frozen dependency contracts - -1. Reuse PM-76 `LdrawResourceResolver`, `SqliteResolver`, `ResolvedResource`, `ResolveError`, `CacheKey`, `SourceIdentity`, `TargetIdentity`, `CanonicalPath`, and `RootId`. Do not create another resolver trait, cache tuple, root taxonomy, or verification state machine. Replace PM-76's stateful public mint/token route seam with deterministic stateless content-bound routes; do not retain or add a public route-token table merely for compatibility. -2. Public projected resources are anonymously viewable and require neither OAuth nor a bearer capability. The compound plan returns deterministic same-origin resource routes bound to the complete immutable project/publisher/source/root/canonical-path/ordered-target-CID and digest identity. The GET route canonicalizes and re-resolves that identity, serves exactly the bound content, or fails closed if it is malformed, out of scope, changed, or unavailable. Authentication and capability issuance are outside this public-resource task and belong only to a future explicitly permissioned/account-scoped resource policy. -3. The compound endpoint is an app-internal anonymous route, not a new lexicon and not a replacement for the generated one-resource XRPC procedure: - `POST /app/ldraw/compound-resources` with `{"project":"at://...","path":"models/main.ldr"}`. -4. The endpoint returns one complete plan containing `version`, `primary`, and ordered descriptors with canonical `key`, deterministic relative same-origin `route`, `path`, PM-76 `root`, byte length, SHA-256, MIME, typed source/binding metadata, and ordered target CIDs. It returns no PDS/publisher URL and gives the worker no ability to invent or broaden resource identities. -5. The server enumerates the complete MPD/manifest/transitive companion set before responding. It resolves every path through PM-76, requires verified bytes, derives one deterministic content-bound route per descriptor, deduplicates by canonical key in first-seen order, terminates cycles, and enforces the 16,384 descriptor and byte/metadata bounds. Any failure returns no partial plan; no route rows, tokens, rollback, or revocation are involved. -6. The worker fetches only supplied relative routes. It never constructs an absolute URL, sends an authorization token, calls the acquisition endpoint, or performs filename-to-network lookup. -7. PM-87 remains the sole owner of `LoadIdentity { load_epoch, scene_generation }`. Worker restart invalidates the old protocol session, events, and reservations; retains only a typed logical intent; bumps the epoch; replaces/remounts the canvas; renegotiates/resizes; and may reuse or deterministically reacquire the stateless plan after the new session is usable. -8. PM-88 reservations are acquired in its defined order before response buffering or parser entry. Reservations are bound to worker/load epoch/generation and released or transferred on every success, error, cancellation, stale result, supersession, revocation, disposal, and restart. -9. `LengthUnit::LdrawUnit` is appended after `Unknown`; no existing postcard discriminant may move. LDU conversion is performed once at the worker scene/IR boundary. - -### G-owned security controls that must be implemented, not deferred - -**Per-chunk CID verification (skeptic G-plan-1).** PM-76’s aggregate length/SHA-256 check is not sufficient to claim blob-CID substitution protection. Add a direct server dependency on the already lockfile-present CID/multihash implementation (prefer the compatible `cid`/`multihash` versions after checking Jacquard’s API; keep it server-only and out of the leaf protocol/worker dependency tree). In the shared verified-byte fetch path used by G, decode each stored CID, read its multihash code and digest, compute the supported digest over that complete fetched chunk, and reject malformed, unsupported, or mismatched CIDs before appending the chunk. Then verify exact aggregate length and SHA-256. Do not claim CID protection until Tier-1 vectors and real route tests cover substitution, malformed CID, wrong codec, truncation, and reorder cases. - -**Explicit subroot parsing and containment (skeptic G-plan-2).** Reuse PM-76’s `RootId` for source identity, but add a typed compound path-policy seam for standard LDraw subroots. Parse `s/`, `8/`, and `48/` as explicit first-segment prefixes only where the selected PM-76 root permits them; preserve the prefix in canonical lookup identity; reject malformed/unknown prefixes; and enforce that the resolved entry remains inside the requested root/subroot scope. An explicit prefix must never fall through to another root or resolve an unprefixed sibling. Add tests for valid `s`, `8`, `48`, case policy, missing prefix, cross-root candidate, and path escape. Do not describe root isolation as covered by generic `CanonicalPath` validation alone. - -## Grounding files and implementation skills - -Re-read these before implementation: - -- `src/ldraw/paths.rs`, `src/ldraw/resolve.rs`, `src/ldraw/mod.rs`, `src/appview/ldraw.rs`, and `src/appview/mod.rs` — PM-76 paths, resolver/cache/error contracts, current verification/fetch behavior, route mounting. -- `src/appview/tests.rs`, `migrations/009_ldraw_resources.sql`, and existing auth/router helpers — real SQLite and session fixtures. -- `lexicons/library/ldrawManifest.json`, `lexicons/library/ldrawResource.json`, and generated mint types — authored/generated boundary; do not hand-edit generated code. -- `crates/polymodel-renderer-protocol/src/lib.rs`, `src/load_state.rs`, `src/viewer.rs`, and `crates/polymodel-renderer-worker/src/worker.rs` — bare postcard contract, PM-87 state, bridge lifecycle, direct URL behavior. -- `crates/polymodel-mesh/src/companions.rs`, parser/reference-walker APIs, and PM-78/PM-79 plans — safe MPD and type-1 reference enumeration. -- `.current-epic/plan-A.md`, `.current-epic/plan-B.md`, `.current-epic/plan-C.md`, `.current-epic/deferrals-C.jsonl`, `justfile`, and the relevant crate `AGENTS.md` files. - -Load `jira-epic-task-execute`, `polytoken:investigating-a-codebase`, and `reviewing-code-for-shipping-quality` during implementation. Do not load `frontend-design`; this task has no Dioxus layout/CSS surface. - -## Replace-vs-edit assessment - -| Area | Decision | Reason | -| --- | --- | --- | -| `src/ldraw/paths.rs` / `resolve.rs` | Edit minimally; add a typed subroot-policy/parser seam and expose the shared CID-verification helper only where PM-76’s verified-byte path needs it. | PM-76’s `RootId`, precedence, cache key, SQL, and verification state remain authoritative; replacing these modules would duplicate security policy. | -| `src/appview/ldraw.rs` | Replace the stateful public mint/token handlers with a stateless deterministic content-bound GET handler while retaining shared verified-byte streaming. | Public immutable resources do not need bearer capabilities; preserving token storage would add lifecycle failure modes without enforcing a product requirement. | -| `src/appview/mod.rs` | Narrow edit to mount the compound POST route. | No route architecture replacement is needed. | -| `crates/polymodel-renderer-protocol` | Replace the bare wire contract with one envelope/handshake module and migrate all consumers together. | Adding fields to `StartLoad` would preserve incompatible postcard bytes and cannot enforce negotiation. | -| `src/viewer.rs` / `load_state.rs` | Edit the bridge/reducer lifecycle. | Keep PM-87 identity allocation and reducer ownership; add session/plan/restart gates rather than a second state machine. | -| `crates/polymodel-renderer-worker/src/worker.rs` | Replace only direct-URL LDraw orchestration. | The lookup/fetch path is fundamentally incompatible with exact descriptor plans; retain renderer setup, scene installation, preview, input, and frame loop. | -| Tests/fixtures/justfile | Add focused fixtures/tests; edit justfile only if a required existing command is not exposed. | Avoid broad build-script or generated-code churn. | - -## Numbered implementation steps - -### 1. Verify workspace and dependency contracts - -Run the safety gate, inspect the copied task/plan, PM-76/87/88 contracts, current tests, route/auth helpers, generated boundary, and `cargo tree`. Add direct server dependency declarations for CID/multihash verification only after confirming the compatible API and feature flags; do not rely on transitive lockfile presence. Confirm the dependency does not enter `polymodel-renderer-protocol` or the app’s wasm dependency tree. - -**Done when:** the isolated workspace and fresh jj change are confirmed, `main` is untouched, exact symbols/fixtures are identified, and the dependency graph proves the CID implementation is server-only. - -### 2. Implement explicit LDraw subroot policy - -Add pure typed parsing for `s/`, `8/`, and `48/` prefixes and a containment check layered on PM-76 `RootId`/`CanonicalPath`. Define which PM-76 uploaded and official roots accept each prefix, preserve canonical prefix identity, reject unknown/malformed prefixes, reject cross-root fallback, and ensure relative companion joins cannot escape the containing root/subroot. Keep MPD/manifest exact-case and uploaded/official ASCII-case rules as PM-76 defines them. - -**Done when:** Tier-1 tests prove valid standard prefixes, case behavior, out-of-root and cross-root rejection, explicit-prefix no-fallback, collision handling, and relative-join containment without a parallel `RootId` taxonomy. - -### 3. Implement per-chunk CID and aggregate byte verification - -Refactor the shared verified-byte fetch path so each chunk is fully buffered only within PM-88’s reservation limits, decoded as the stored CID, and checked against its multihash code/digest using the direct CID/multihash dependency. Reject malformed/unsupported/wrong-codec/mismatched CID before reassembly; then assert exact total length and SHA-256 over the raw ordered bytes. Preserve PM-76 verification CAS semantics: no mismatch may publish `verified`, and failed verification transitions through the existing rejection path. - -**Done when:** pure vectors and server tests cover valid CID/hash combinations, malformed CIDs, unsupported codes, substituted chunks, truncation, reorder, aggregate length mismatch, aggregate SHA mismatch, and no verified-state publication after any failure. - -### 4. Build the versioned protocol envelope and handshake - -Implement postcard envelope/version range negotiation, required/optional capability rules, deterministic selection, session metadata, and `Created → Negotiating → Negotiated | Failed`. Reject malformed, duplicate, conflicting, stale-session, pre-negotiation, and out-of-order messages. Migrate every app/worker command/event, current formats, and preview to the envelope in one cutover; leave only the established worker-ready/canvas bootstrap outside postcard and reject old bare scene-message bytes. - -**Done when:** Tier-1 golden fixtures cover success, no-overlap, required/optional capability failure, malformed/duplicate/conflict, out-of-order/pre-negotiation, stale session, and old raw-message rejection; no scene-dependent command bypasses `Negotiated`. - -### 5. Freeze wire metadata and LDU semantics - -Append `LengthUnit::LdrawUnit` after `Unknown`; carry negotiated version, namespace, full PM-87 identity, units, and either current-format source or a complete compound plan. Add independent golden bytes for negotiation, current-format, LDraw, malformed, stale, and old-message cases. Implement exact LDU conversion only at worker scene/IR handoff and retain raw LDU values in parsing/protocol payloads. - -**Done when:** discriminants are fixture-protected; 0/1/64/1000 LDU vectors and no-double-conversion tests pass; current STL/OBJ/glTF/GLB/3MF envelope round trips remain green. - -### 6. Add anonymous-capable all-or-nothing compound acquisition - -Mount `POST /app/ldraw/compound-resources`; deserialize typed `AtUri` and immediately parse `CanonicalPath`. Public projected Things and their resources must resolve without an OAuth session. Implement complete MPD and manifest enumeration: primary resolution, verified MPD `FILE`/type-1 first-seen traversal, manifest ordinal universe, relative canonical joins, explicit subroot containment, PM-76 precedence, exact-key dedupe, cycle termination, descriptor/byte bounds, and deterministic content-bound route derivation. Return no partial plan on any failure; write no route rows and mint no tokens. - -**Done when:** Tier-1 enumeration/property tests and Tier-3 Axum/SQLite tests prove logged-out public success, deterministic complete descriptor order and routes, MPD/manifest transitive coverage, prefix containment, malformed/missing/collision/source-change failure, no partial response, and no publisher/PDS URL-bearing response field. - -### 7. Implement stateless content-bound GET routes - -Replace the public mint/token redemption flow at `/xrpc/space.polymodel.library.getLdrawResource` with a deterministic GET identity supplied by the plan. Parse and canonicalize project/resource/publisher/source/root/path plus immutable ordered target-CID/digest identity, re-resolve through PM-76, compare the complete binding, and either stream exactly those publisher-owned bytes or fail closed. Use the verified-byte path from step 3. Do not require OAuth, cookies, browser identity, bearer tokens, route rows, expiry, revocation, atomic consumption, or replay state. - -**Done when:** Tier-3 real route tests prove repeated logged-out public success, signed-in parity, deterministic route stability, publisher/source/root/path/target/project substitution rejection, stale-projection/content-change rejection, per-chunk CID substitution rejection, canonical containment, and uniform fail-closed responses. - -### 8. Integrate bridge acquisition, negotiation, restart, retry, and cancellation - -Add handshake/session gates, typed pending logical intent, complete-plan acquisition before worker load, one fresh-batch retry for transport/5xx acquisition failures, typed route-failure handling, and PM-87 restart sequencing. Discard all old descriptors on retry/restart; release reservations; replace/remount canvas; renegotiate and resize; then acquire/reissue with a newer identity. Check cancellation before every fetch/parser/publication step and preserve preview/interactive namespace isolation. - -**Done when:** bridge/reducer tests prove bootstrap → negotiation → resize → complete plan → load ordering, stale-event rejection, fresh epoch and deterministic stateless-plan reuse/reacquisition after restart, deterministic canvas failure, cancellation, and reservation release. - -### 9. Replace worker LDraw filename lookup with exact plan loading - -Make the negotiated LDraw payload carry the complete plan. Validate descriptor key/route/metadata, look up primary and companions by exact canonical key, fetch only relative same-origin routes with credentials, verify response length/SHA-256 and target metadata, and pass in-memory bytes to the parser. Remove filename-to-URL construction, route minting, publisher/PDS access, and arbitrary parser network callbacks. Keep non-LDraw formats under the negotiated envelope and convert LDU exactly once at scene/IR handoff. - -**Done when:** Tier-4 wasm/worker tests prove unknown keys, absolute/publisher URLs, route substitution, missing companions, mismatched bytes, malformed metadata, arbitrary filename activation, and pre-negotiation loads fail closed before parser/scene publication. - -### 10. Integrate PM-88 admission and run cross-boundary coverage - -Reserve the full primary/companion workload before buffering or parsing in deterministic PM-88 order. Release/transfer on success, failure, cancellation, stale identity, supersession, revocation, disposal, and restart. Run configured browser/e2e coverage for MPD, manifest, prefixes, precedence, digest/CID/auth/replay/revocation, stale routes, protocol mismatch, cancellation, reservations, preview/interactive parity, LDU conversion, and current-format parity using deterministic interception/checkpoints rather than sleeps. - -**Done when:** Tier-1 ledger tests, Tier-3 route tests, Tier-4 worker/browser tests, and final renderer split checks assert zero premature parser entry, zero reservation leaks, exact route decisions, final reducer state, scene readiness, and complete-plan reacquisition. - -### 11. Validate, review, and commit - -Run from the isolated task workspace: - -```sh -just check -just fix -cargo test -p polymodel-renderer-protocol -cargo test -p polymodel-mesh -cargo test -p polymodel --lib -cargo nextest list -p polymodel --features server -just test-server -just build-renderer-worker -just verify-renderer-split -just test-renderer -cd e2e && npm test -- tests/viewer.spec.ts -cd .. && just test-all -``` - -Run `just sqlx-prepare` if production SQL/migrations changed. Review only necessary generated/SQLx changes. Load `reviewing-code-for-shipping-quality`, inspect `jj diff`, `jj diff --stat`, and `jj status`, and resolve all Critical/High findings. - -**Done when:** all relevant commands pass; direct CID/multihash verification is tested; root prefix/containment tests pass; the worker dependency split contains no `three-d`, `three-d-asset`, `stl_io`, or `polymodel-mesh` in the app target; browser coverage passes; and the task commit is on `epic-86-task-G` with exact trailers: - -```text -Epic: PM-86 -Task: PM-80 -``` - -## Test strategy by behavior - -| Behavior | Tier and concrete infrastructure | -| --- | --- | -| Envelope/handshake/wire bytes | Tier 1 protocol unit/state tests with committed independent postcard fixtures for success and every malformed, capability, ordering, stale, and old-byte failure. | -| LDU semantics | Tier 1 exact enum/discriminant/conversion vectors; Tier 4 worker scene test proves one conversion and no double conversion. | -| PM-87 identity/restart | Tier 1 bridge/reducer tests for stale session/epoch/generation, cancellation, supersession, and fresh identity; Tier 4 delayed browser test proves complete-plan reacquisition after worker replacement. | -| Canonical path and subroot policy | Tier 1 pure table/property tests for NFC, traversal, percent/backslash/control rejection, `s/`/`8/`/`48/`, root permission, containment, collisions, case rules, and explicit-prefix no-fallback. | -| MPD/manifest enumeration | Tier 1 parser/reference-walker tests for FILE/type-1 transitive traversal, ordinal order, relative joins, dedupe, cycles, out-of-universe references, bounds, and root containment; Tier 3 endpoint tests use real projected SQLite data. | -| Batch descriptors/routes | Tier 1 descriptor tests prove required binding metadata, deterministic content-bound relative routes, exact key map, verified-only construction, and no partial plan; Tier 3 tests prove repeated stateless GET resolution against PM-76 identity/verification. | -| CID and byte integrity | Tier 1 known multihash/CID vectors, malformed/unsupported/wrong-codec and chunk substitution/truncation/reorder tests; Tier 3 route/verification tests prove no bytes after per-chunk CID, length, SHA, or stale-binding failure. | -| Public route/substitution | Tier 3 real Axum/SQLite tests prove repeated logged-out public viewing, signed-in parity without identity dependence, deterministic route stability, publisher/source binding, complete project/root/path/target substitution rejection, and stale projection/content rejection. | -| Worker no-lookup boundary | Tier 4 wasm/bridge tests prove only supplied relative routes are fetched, no absolute/publisher URL is accepted, exact keys are required, and missing companions never trigger fallback lookup. | -| PM-88 admission/cleanup | Tier 1 deterministic ledger/rollback tests plus Tier 4 worker/browser tests for reserve-before-buffer/parse and zero leaks across success/error/cancel/stale/revocation/supersession/disposal/restart. | -| Current formats and preview | Tier 1 envelope round trips; Tier 4 configured renderer/browser tests for STL, OBJ, glTF/GLB, 3MF, preview/interactive separation, and readiness ordering. | -| Renderer split/regression | `just verify-renderer-split`, explicit wasm cargo-tree assertion, and final `just test-all`. | - -No provider/daemon or TUI tier applies to this task: its behavior is protocol, appview, resolver, wasm-worker, and browser behavior. - -## Implementation review expectations - -Reviewers must verify: - -1. PM-76 `RootId`, resolver, frozen errors, cache identity, and verification semantics are reused; the public token/route-row lifecycle is removed rather than duplicated. -2. The compound POST route supports logged-out public viewing and remains complete, deterministic, bounded, explicit-prefix contained, and no-partial-result. -3. The worker receives only exact-key deterministic content-bound same-origin route descriptors and cannot synthesize URLs, broaden resource identities, or access publisher/PDS URLs. -4. GET publisher/source resolution, canonical identity parsing, complete immutable binding comparison, stale-content rejection, and uniform fail-closed errors are tested at the real Axum boundary. -5. Every fetched chunk’s CID multihash is decoded and compared with the complete chunk bytes before aggregate length/SHA verification; no CID-substitution protection claim rests on a string parse alone. -6. Protocol envelope cutover is mandatory, old bare postcard bytes are rejected, and exact golden bytes are committed. -7. `LengthUnit::LdrawUnit` follows `Unknown`, exact LDU semantics are tested, and conversion occurs once. -8. Restart invalidates old sessions/routes/events, releases work, bumps the epoch, reacquires a complete plan, renegotiates/resizes, and deterministically reissues or fails. -9. PM-88 reservations precede buffering/parser entry and clean up on every terminal/race path; preview remains separate. -10. Generated APIs/assets and the app/worker dependency split remain clean. - -Resolve all Critical and High findings before parent handoff. Remaining risk must be an observed command/tool failure, not an accepted CID-verification or root-isolation gap. - -## Ready-for-parent-merge handoff - -Return the task workspace/bookmark and final jj change ID; `jj status`; `jj diff --stat`; complete diff-review result; changed-file list; exact results for every validation command; golden-wire, path/subroot/precedence, batch enumeration, CID/byte-integrity, deterministic stateless route, substitution/stale-content, restart, cancellation, reservation-cleanup, preview/interactive, logged-out browser, and ordinary-format results; confirmation that PM-76 resolver/cache/verification semantics were reused while its public token lifecycle was removed; reviewer findings/dispositions; and concrete remaining risks. Confirm the commit message contains: - -```text -Epic: PM-86 -Task: PM-80 -``` - -## Prior finding dispositions - -- **G-plan-1-F1 — fixed and retained:** The plan distinguishes PM-76’s `ResolvedResource::ByteSource` from G’s adjacent typed descriptor/route seam, specifies the concrete POST response, relative opaque route, no-URL worker rule, and tests. -- **G-plan-1-F2 — superseded by operator product correction:** Public Things must remain viewable while logged out and public immutable files need no bearer capability. The plan replaces token mint/redemption with deterministic stateless content-bound routes, complete publisher/project/source/root/path/target binding re-resolution, stale-content and substitution rejection, and repeated anonymous GET tests at the real route boundary. -- **G-plan-1-F3 — fixed and retained:** The plan mandates one versioned envelope cutover, migrates all app/worker postcard consumers together, rejects old raw scene bytes, and freezes independent golden fixtures. -- **G-plan-1-F4 — fixed and retained:** The plan defines exact LDU physical scale, display-rounding distinction, one scene/IR conversion boundary, and exact/no-double-conversion tests. -- **G-plan-1-F5 — fixed and retained:** The plan reuses PM-76 `RootId`; the new subroot policy is a path-containment policy, not a parallel LDraw source/tier taxonomy. -- **G-plan-1-F6 — fixed and retained:** The plan requires appending `LengthUnit::LdrawUnit` after `Unknown` and protects existing postcard discriminants with fixtures. -- **G-plan-1-F7 — fixed with stateless-route simplification:** The plan specifies logical-intent retention, reservation cleanup, fresh epoch/session, old-event invalidation, deterministic stateless-plan reuse/reacquisition, replacement canvas/resize, renegotiation, and deterministic failure on restart; no token revocation or route reissuance lifecycle remains. -- **G-plan-2-F1 — fixed with operator product correction:** The plan names `POST /app/ldraw/compound-resources`, typed request/response semantics, anonymous public acquisition, deterministic content-bound descriptor routes, six-stage complete enumeration behavior, ordering/dedupe/cycle/bounds, no-partial-result behavior, retry behavior, and worker no-lookup rules. -- **G-plan-skeptic-1 — fixed:** G now explicitly implements per-chunk CID multihash decoding and digest verification with a direct server dependency, tests malformed/unsupported/substituted/truncated/reordered chunks, and forbids claiming CID protection from aggregate SHA alone. -- **G-plan-skeptic-2 — fixed:** G now explicitly parses `s/`, `8/`, and `48/` prefixes, enforces permitted-root/subroot containment, rejects cross-root fallback, and includes pure, route, and browser acceptance vectors; generic path validation is not treated as root isolation. diff --git a/.current-epic/plan-L.md b/.current-epic/plan-L.md deleted file mode 100644 index 639f54e..0000000 --- a/.current-epic/plan-L.md +++ /dev/null @@ -1,151 +0,0 @@ -# PM-90 / Task L — Correct LDraw face lighting and top/bottom brightness - -## Metadata and cold-start context - -- **Epic:** PM-86 — [Jira](https://radiant-industries.atlassian.net/browse/PM-86) -- **Task:** PM-90 — [Jira](https://radiant-industries.atlassian.net/browse/PM-90) -- **Prefix:** L -- **Task cache:** `.current-epic/task-L.md` (authoritative and read-only) -- **Planning inputs:** `.current-epic/manifest.txt`, `.current-epic/dependencies.dot`, `.current-epic/task-state.json` -- **Epic workspace/bookmark:** `/home/orual/Projects/epic-86` / `epic-86` -- **Base bookmark:** `main` -- **Expected task workspace/bookmark:** `/home/orual/Projects/epic-86-task-L` / `epic-86-task-L` -- **Dependency:** F / PM-79 only; consume the integrated F parent tip. G / PM-80 is not required. -- **Implementor skills:** load `polytoken:investigating-a-codebase`, `jira-epic-task-execute`, and `reviewing-code-for-shipping-quality`. - -PM-90 is a diagnosis-first, native-only renderer task. The reported top/bottom brightness reversal must be reproduced, the first incorrect production boundary identified, and the approved lighting/material direction applied without changing scene architecture, culling, depth, alpha, colour, edges, picker behaviour, or ordinary-format materials. The operator is the visual authority; native production readback is the visual evidence path. - -## Safety and scope - -The implementor starts only in `/home/orual/Projects/epic-86-task-L`, never in `/home/orual/Projects/epic-86` or `main`. Before reading task-local material or editing source, run: - -```sh -jj workspace root -jj bookmark list -jj status --no-pager -jj log -r @ -T 'change_id ++ " " ++ bookmarks ++ "\\n"' -``` - -Stop on a wrong workspace root, the default/Epic workspace, `@` on `main`, or a bookmark other than `epic-86-task-L`. Then create a fresh task change: - -```sh -jj new -m "PM-90: add neutral lighting diagnosis captures" -``` - -Do not edit `.current-epic/manifest.txt`, `.current-epic/dependencies.dot`, `.current-epic/task-state.json`, task caches, source branches, or parent coordination state. Do not use browser/Playwright visual tests, routes, Preview plumbing, wasm browser runners, canvas hooks, `TestReadback`, hidden readback APIs, a second renderer/context, or pixel/luminance/region thresholds as visual truth. Existing native production readback is required. - -## Grounded implementation surface - -Re-check these seams at the integrated F parent tip before editing: `crates/polymodel-renderer-worker/src/lib.rs` camera functions around 923–985, `NativeRenderOptions` around 1406–1425, topology around 1457–1513, scene batching/material construction around 2429–2514 and 3196–3285, and native lights around 2517–2568; `crates/polymodel-renderer-worker/src/worker.rs` `standard_lights` around 1014–1036 and compound Preview camera construction around 1796–1852; native CLI/readback in `crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs` around 107–180, 203–210, 327–349, and 692–860; native harness in `crates/polymodel-renderer-worker/src/bin/ldraw_preview_harness.rs`; and existing Just owners in `justfile` (`test-renderer-worker-native`, `test-renderer-native`, `test-all`, `verify-renderer-split`, `preview-ldraw`). Confirm pinned `three-d` 0.19.0 and its checked-in directional-light implementation, which passes `-direction` to `calculate_light`; analytic light signs must use that exact vector. - -## Replace-versus-edit decisions - -| Area | Decision | Boundary | -|---|---|---| -| `crates/polymodel-renderer-worker/src/lib.rs` camera/topology/material/native-light code | Focused edit; add a small shared camera/policy seam only where it removes duplication | Keep camera fit, topology, model-build, and readback lifecycle. Replace a helper only when more than 60% changes or it cannot express the frozen contract; never replace all of `lib.rs`. | -| `crates/polymodel-renderer-worker/src/worker.rs` | Focused edit | Route worker LDraw lighting/material construction through the shared policy; preserve lifecycle/loading/cancellation and ordinary mesh paths. | -| `crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs` and `crates/polymodel-renderer-worker/src/bin/ldraw_preview_harness.rs` | Focused edit | Add case/variant selection, metadata, and explicit dimensions; retain native readback, picker, persistent event loop, and one harness. | -| `crates/polymodel-renderer-worker/tests/ldraw_preview.rs` and existing unit tests | Focused extension | Add the minimal camera/variant/policy/index/material assertions beside official 3021/OMR coverage; do not expand ordinary-format parser coverage. | -| `justfile` / `tools/in-dev-shell` | Focused edit to existing owners only | Pass capture arguments and enforce the 2560×1440 display contract; no PM-90 public recipe or aggregate restructuring. | -| Protocol/app/routes/generated code | No change | A protocol change requires a first-wrong-boundary report naming the changed contract; web visual scope remains excluded. | - -## Frozen shared camera contract owned by L - -L owns exactly one generic, versioned `CameraCase`; M may consume it but may not add task-specific scene, control, depth, clipping, or diagnostic fields. Schema identity is `pm-camera-case-v1`. Fields are only `case_id`, `schema_version`, yaw/azimuth, pitch/elevation, roll, target/center `[f64;3]`, fit or explicit finite distance, FOV, viewport width/height, and `FitBounds` near/far policy. - -Serialization is deterministic with stable field order. Reject unknown schema, zero dimensions, non-finite angles/target/distance/FOV, invalid distance/FOV, and conflicting CLI sources. Defaults preserve the current camera: fitted `scene_camera_fit` center, `normalize([1.8,-2.4,1.5])`, 45° FOV, fitted distance, zero yaw/pitch/roll, and 1280×720 when no case is supplied. Apply right-handed yaw, then pitch, then roll around target, using the same fit and clip policy on both native paths. - -The six named cases remain `identity_front`, `identity_back`, `reflected_front`, `reflected_back`, `top`, and `bottom`, with the existing yaw/pitch values and 2560×1440 capture viewport. Do not encode reflected-vs-identity in `CameraCase`: add a generic `SceneVariantBinding` selected by the case manifest/CLI. It binds each reflected case to a specific fixture occurrence/scene variant with canonical occurrence ID, definition/source span, occurrence index, transform determinant/parity, and expected topology/material metadata. Identity cases bind the identity occurrence; reflected cases bind the determinant−1 occurrence; top/bottom bind explicit control occurrences. Fixture bytes do not change between cases. - -Analytic tests must assert that the selected occurrence and determinant differ for identity versus reflected cases, that front/back camera transforms are distinct, and that the capture metadata reports the selected binding. Legacy `--yaw-degrees` is accepted only without `--camera-case`; both sources are a hard parse error. Native persistent and static readback consume the same case plus binding. No web/browser visual path is added. - -## Fixture and diagnosis contract - -Before any corrective edit, add bounded environment-gated diagnostics such as `POLYMODEL_RENDERER_LIGHTING_DIAGNOSTICS=1`. Diagnostics do not mutate render state and record one first-mismatch row per occurrence/case: case/revision, fixture/scene hash, native path, source span/definition, transform determinant, direct/effective/primitive colour and `MaterialId`, authored BFC state, source/generated/upload indices, local/world normals, camera position/target/up, actual light-facing vector `-DirectionalLight.direction` and normal dot/sign, cull, depth test/write, alpha/blend, and `PhysicalMaterial` policy. - -Add the smallest test-owned fixture under existing renderer ownership before changing shared policy. It contains colour 4 and contrasting colour 14 on parallel visible `+Z`/`−Z` faces; certified CCW and CW primitives; `INVERTNEXT`/source inversion; identity and `diag(-1,1,1)` determinant −1 occurrences; and a closed/two-sided arrangement with culling enabled. Record the fixture hash and the limited metadata needed by the camera/variant binding and analytic tests: source span, occurrence ID/index, determinant/parity, expected topology indices/normals, direct/effective colour, `MaterialId`, cull state, and light-facing sign. Official `parts/3021.dat` and `crates/polymodel-ldraw-testkit/corpus/omr-10030-1.mpd` remain production regressions after the minimal fixture. - -The focused matrix remains named `I-F`, `I-B`, `R-F`, `R-B`, `W-CW`, `BFC-I`, `R×BFC`, `OMR`, and `3021`. Assert the first-wrong-boundary candidates analytically—authored/generated/upload topology, normals, occurrence parity, material identity, culling, and light-facing sign—without requiring a giant per-case freeze table. Reflection remains consumed at one named boundary only; do not apply determinant parity twice. - -## Numbered execution plan: two sequential changes - -1. **Safety and grounding.** Verify workspace/bookmark safety, integrated F parent revision, `epic-86@`, listed seams, native owner, official library, software-GL/Xvfb environment, and `three-d` light convention. Start the fresh task change named above. **Done when:** the evidence header records exact parent revision and environment, and no source or coordination file changed before the fresh change. - -2. **Change 1 — behaviour-neutral camera/variant/capture tooling and fixture only.** Add `pm-camera-case-v1`, the six cases, shared camera construction, exclusive CLI parsing, generic `SceneVariantBinding`, native static/persistent capture metadata, first-mismatch diagnostics, and the minimal fixture. Preserve current camera defaults, topology, BFC/parity behaviour, materials, light constants, culling, depth, alpha, edges, picker, lifecycle, and ordinary formats. Add only sufficient Tier 1 tests for CameraCase serde/validation/CLI conflicts, variant selection/metadata, fixture hash/required occurrence metadata, and unchanged production capture trace. This change must not correct lighting/material policy or topology. **Done when:** the new plumbing is tested, the reflected cases select and report the reflected occurrence, both native paths accept explicit 2560×1440 dimensions, Gate-A artifacts contain command/case/variant/fixture metadata plus valid dimension-checked PNGs, and `jj diff` shows only camera/variant/capture/fixture/diagnostic plumbing. - -3. **Interactive stop gate A — unchanged appearance.** Commit Change 1 with exactly `Epic: PM-86` and `Task: PM-90` trailers. Report the change ID and staged native capture command, then stop. The operator inspects the staged native production case and confirms live that current appearance is unchanged from before the tooling change. **Done when:** the parent records explicit live confirmation; no confirmation means the second change cannot begin. - -4. **Hard reproduction gate.** After gate A, run the staged native case over the minimal fixture and official 3021/OMR matrix. Use `/home/orual/Projects/epic-86/lighting-shading-error.png` as the canonical historical failure-class reference. The operator must confirm live that the staged native output exhibits the same top/bottom brightness/face-lighting error class. Record control cases that show no defect as evidence. A failed reproduction blocks implementation and returns to fixture/camera/environment diagnosis; it is not a completion state. **Done when:** the defect class is explicitly reproduced and operator-confirmed, or the task is blocked with the diagnosis evidence. - -5. **Pre-fix matched capture.** Only after reproduction, capture `identity_front`, `identity_back`, `reflected_front`, `reflected_back`, `top`, and `bottom` through native persistent and native static production readback. Use identical fixture bytes, case serialization, dimensions, library revision/hash, light/material/cull/depth environment, and command shape reserved for the post-fix capture. Store separate pre-fix PNGs, metadata, and contact sheet for human inspection. **Done when:** every case has valid nonempty PNGs and exact dimensions, and the operator can inspect the complete pre-fix set; no pixel/luminance/region threshold is used. - -6. **Change 2 — diagnose and correct the shared production boundary.** Start a fresh change only after reproduction and pre-fix artifacts exist. Identify the first mismatch across authored BFC winding, generated/upload indices, local/world normals, determinant/reflection composition, culling, effective colour/`MaterialId`, `PhysicalMaterial`, and the `three-d` light convention. Add one small, behaviour-neutral `LdrawLightingMaterialPolicy` descriptor in `crates/polymodel-renderer-worker/src/lib.rs` for the approved LDraw ambient/key/fill energies, colours, direction vectors, albedo/roughness/metallic, and render-state expectations. Make native `NativeLights::new` and `crates/polymodel-renderer-worker/src/worker.rs` `standard_lights` consume that same descriptor; there must be no duplicated native/worker constants or different policy. Apply the correction only at the diagnosed boundary. Preserve `PhysicalMaterial`, culling, depth test/write, alpha/colour/edges, picker, ordinary-format materials, and no-double-reflection. Do not disable lighting/culling/depth or substitute unlit materials. **Done when:** the report names the first wrong boundary and ruled-out hypotheses, native and worker LDraw paths use the same testable policy seam, and the focused diff preserves the surrounding lifecycle. - -7. **Worker/native analytic proof and matched post-fix capture.** Add focused host assertions for exact indices, normals, determinant parity, `-DirectionalLight.direction` signs, material IDs/albedo, cull/depth/alpha, CameraCase errors, variant binding metadata, and only the negative mutations needed to distinguish the diagnosed boundary. Add an explicit worker-target analytic test for the shared policy descriptor, compiled through the worker wasm test path; assert policy version/values, light-facing vector convention, material mapping, and render-state expectations without browser/canvas visual truth. Capture the identical six cases and both native production paths with unchanged fixture/case/variant/dimension/library/environment metadata. Store separate post-fix artifacts. **Done when:** the worker-target test fails if native/worker policy values or sign convention diverge, reflected metadata remains bound to the reflected occurrence, PNGs are valid/nonempty/dimension-checked, LDraw geometry/picker/official regressions remain green, and the operator explicitly accepts the matched visual result at stop gate B. - -8. **Review, repository gates, and handoff.** Load `reviewing-code-for-shipping-quality`, inspect `jj diff`, and remove noisy diagnostics/process residue. Run the required public `just fix` and record its status. Run the repository-required `just test-all` and record its status; do not claim that its internal `_fix` is the explicit `just fix` invocation. Run `just verify-renderer-split` explicitly; it is absent from the current `test-all` DAG. Keep the persistent CLI capture invocation separate from the static harness proof and require operator inspection/approval of its output; do not create an automated persistent visual oracle. Do not add a PM-90 public recipe or restructure the validation DAG. **Done when:** no critical/high review finding remains, both implementation changes have exact trailers, all evidence and command statuses are recorded, and the parent receives a merge-ready handoff. - -## Test strategy and executable capture policy - -| Behaviour | Concrete proof and tier | -|---|---| -| Camera schema, defaults, rotation order, fit/clip, finite/degenerate inputs, stable serialization, CLI conflicts | Focused Tier 1 tests in the camera seam and native parser; exact serialized bytes and error variants sufficient for the new plumbing. | -| Scene variants and reflected binding | Focused Tier 1 tests select identity versus determinant−1 occurrence bindings, assert metadata/occurrence IDs/determinants, and prove front/back camera transforms are distinct; no task-specific fields enter `CameraCase`. | -| BFC, source/generated/upload indices, normals, inversion, determinant parity, no-double-reflection | Existing focused native worker tests plus the minimal fixture matrix; exact indices/vector components/signs, never pixels. | -| Shared native/worker lighting/material policy | Host analytic tests compare native and worker descriptors; an explicit worker-target test compiled through `just test-renderer` asserts exact energies, colours, directions, `-direction` convention, material mapping, and render-state expectations. No browser visual test. | -| Native static and persistent production readback | Static evidence uses `ldraw_preview_harness` and `render_scene_window_options_traced`; persistent evidence uses the real `ldraw_preview` binary/event loop with the command below. Assert command success, selected case/variant metadata, requested viewport, PNG validity/dimensions, and path-specific logs. These are evidence/trace checks, not image or luminance oracles. | -| Defect reproduction and visual acceptance | Operator inspects native staged output against `/home/orual/Projects/epic-86/lighting-shading-error.png`, confirms reproduction, confirms Gate A unchanged appearance, and approves matched pre/post contact sheets. No automated visual threshold. | -| LDraw regressions and renderer split | Existing official 3021/OMR, geometry-count, picker, and aggregate coverage remain green; run `just verify-renderer-split` explicitly if absent from `just test-all`. Ordinary-format parser/material expansion is not part of this focused LDraw correction. | - -The persistent command is a concrete invocation through the real binary and is inspected by the operator, not replaced by a new automated event-loop visual oracle: - -```sh -Xvfb :99 -screen 0 2560x1440x24 -ac +extension GLX +render -noreset >/tmp/pm90-xvfb.log 2>&1 & -DISPLAY=:99 WINIT_UNIX_BACKEND=x11 WAYLAND_DISPLAY= XDG_RUNTIME_DIR= LIBGL_ALWAYS_SOFTWARE=1 \\ - LDRAWDIR="$LDRAWDIR" timeout --signal=TERM --kill-after=5s 180s \\ - just preview-ldraw "$FIXTURE" --library-root "$LDRAWDIR" \\ - --width 2560 --height 1440 --camera-case "$CASE" --scene-variant "$VARIANT" \\ - --readback-prefix "$OUT/persistent" --proof-ms 1500 -``` - -The static path uses the existing `just test-renderer-native` owner/harness with the same `--width 2560 --height 1440`, case, variant, fixture, and display. The implementor must thread those arguments through the existing binaries/owner and fail rather than silently falling back to 1280×720; logs must show the requested and actual viewport. Use Just recipes for repository build/test work; keep explicit capture commands separate from `just test-all`. Run public `just fix` as required, then public `just test-all`, and explicitly run `just verify-renderer-split` because the current DAG does not include it. Record each status honestly; do not optimize or restructure the repository DAG. - -## Review expectations - -Reviewers verify the two-change ordering and hard gates: Change 1 contains only behaviour-neutral camera/variant/capture/fixture/diagnostic work; the operator confirmed unchanged appearance; reproduction was operator-confirmed against the canonical failure class; pre-fix cases were captured before Change 2; and post-fix cases are identical in case, fixture, variant, dimensions, and environment. They verify the first-wrong-boundary report, shared native/worker policy seam, independent worker-target analytic proof, reflected occurrence bindings/metadata, executable 2560×1440 Xvfb/display plumbing, native static versus real persistent CLI invocation, preserved cull/depth/alpha/colour/edges/material semantics, official LDraw regressions, no browser/hidden readback/pixel oracle, no generated churn, and no recipe proliferation. Persistent output is operator evidence, not an automated visual oracle. - -## Ready-for-parent-merge handoff - -Report `/home/orual/Projects/epic-86-task-L`, bookmark `epic-86-task-L`, both change IDs, consumed F parent revision, `jj status --no-pager`, `jj diff --stat`, and complete diff review. Include grouped changed files and replace-versus-edit decisions; fixture/library/case hashes; exact native capture commands and separate pre/post artifact paths; canonical reproduction and operator decisions at gates A and B; first-wrong-boundary diagnosis and ruled-out hypotheses; analytic mutation outcomes; exact command exit statuses; and concrete environment blockers. Both implementation commits contain exactly: - -```text -Epic: PM-86 -Task: PM-90 -``` - -The parent owns Jira workflow, rebasing onto the current PM-86 Epic tip, merge gates, and final integration. The parent verifies analytic/state evidence, native production readback, both trailers, and both operator visual checkpoints before merging. - -## Prior-review and coverage-finding dispositions - -- **L6 two-change sequence / circular baseline — fixed.** Change 1 is behaviour-neutral, Gate A is hard and operator-confirmed, reproduction is hard, pre-fix evidence precedes Change 2, and post-fix evidence is command/case/variant/fixture/display matched. -- **L6 correction without reproduction — fixed.** The canonical `/home/orual/Projects/epic-86/lighting-shading-error.png` is retained as the historical reference and the operator must confirm the reproduced failure class before correction. -- **Coverage 1 shared native-worker policy seam — fixed.** A single testable descriptor feeds native `NativeLights::new` and `crates/polymodel-renderer-worker/src/worker.rs` `standard_lights`; an explicit worker-target analytic test protects equivalence without browser visual truth. -- **Coverage 3 persistent CLI evidence — partially fixed per operator direction.** The plan names an executable `just preview-ldraw` invocation through the real `crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs` binary, with operator inspection/approval. It deliberately does not invent an automated persistent event-loop visual oracle; static harness evidence remains distinct. -- **Coverage 4 reflected/front-back identity — fixed.** A generic `SceneVariantBinding` selects concrete identity/reflected occurrences, records occurrence/determinant/source metadata, and is asserted independently of generic `CameraCase` fields. -- **Coverage 5 2560×1440 execution — fixed.** The exact Xvfb screen, `preview-ldraw` arguments, static-path dimensions, requested/actual viewport logging, and fail-on-fallback contract are explicit. -- **Coverage 2 ordinary-format production expansion — rebutted-accepted by explicit operator decision.** PM-90 is a focused LDraw lighting correction; expanding STL/OBJ/glTF/GLB/3MF parser/material production assertions is disproportionate once the shared policy is analytically protected. Existing ordinary-format behavior remains in the repository’s existing regression scope and is not narrowed by this plan. -- **Coverage 6 giant freeze table — rebutted-accepted by explicit operator decision.** The focused case matrix, minimal fixture metadata, analytic indices/normals/material/sign assertions, hard reproduction gate, matched evidence, and operator authority provide sufficient PM-90 control; a giant per-case source/generated/upload freeze table is disproportionate. -- **Coverage 7 command-DAG formatting semantics — rebutted-accepted by explicit operator decision.** Redundant formatter ownership is harmless to plan correctness. The plan preserves required public `just fix`, `just test-all`, and explicit `just verify-renderer-split` statuses without restructuring the repository DAG. -- **Coverage 8 maximal independent Change-1 taxonomy — rebutted-accepted by explicit operator decision.** Change 1 adds sufficient tests for the actual CameraCase/variant/capture plumbing and Gate A; an independent maximal host-test taxonomy is disproportionate and not required for acceptance. -- **Earlier architecture screenshot-path MEDIUM — rebutted-accepted.** Both paths exist; `/home/orual/Projects/epic-86/lighting-shading-error.png` is the stable project-root canonical evidence copy. The harmless LOW prefix inconsistency is corrected everywhere to `crates/polymodel-renderer-worker/src/worker.rs` and `crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs`. - -## Definition of done - -- The reported defect is reproduced and operator-confirmed before correction, and the first wrong production boundary is identified. -- Change 1 is behaviour-neutral camera/variant/capture/fixture/diagnostic tooling, with live unchanged-appearance confirmation and pre-fix captures. -- Change 2 applies the diagnosed approved lighting/material correction and produces identical post-fix native cases. -- One generic `pm-camera-case-v1` controls all six native poses with frozen serialization, defaults, equations, handedness, and CLI conflict behaviour; M can consume it without M-specific fields. -- Native persistent/static paths share LDraw camera/material/light/cull/depth semantics; numeric tests cover indices, normals, light signs, and material identity. -- Existing LDraw counts, picker behaviour, native readback, renderer split, and final aggregate remain green; ordinary-format behavior is not narrowed, but no new ordinary-format production test expansion is required for this focused task. -- Human operator accepts the matched native visual result; automation validates state, metadata, hashes, PNG validity/dimensions, and regressions only. -- No browser/Playwright/route/Preview plumbing, pixel threshold, unlit material, disabled culling/depth, hidden readback hook, second renderer, generated churn, unrelated refactor, or duplicate validation recipe exists. diff --git a/.current-epic/root-cause-ldparse-fiction.md b/.current-epic/root-cause-ldparse-fiction.md deleted file mode 100644 index 9cd5daa..0000000 --- a/.current-epic/root-cause-ldparse-fiction.md +++ /dev/null @@ -1,44 +0,0 @@ -# Root Cause: LDParse Fiction (2026-08-02) - -## What happened - -PM-77 (Task D) and PM-78 (Task E) were planned, implemented, reviewed, and merged/progressed on a false premise: that Thomas Dickerson's LDParse was a Python library. In reality, LDParse is a C++ library at `elfprince13/LDParse`. - -## Failure chain - -1. **PM-75 design** (Confluence) correctly references `elfprince13/LDParse` with two pinned revisions. No language claim. -2. **D's planner** assumed Python: built `ldparse_driver.py`, `uv.lock`, `pyproject.toml`, CPython 3.12.8 pin. No verification that the referenced library was Python. -3. **Provenance** used wrong GitHub username (`taliesin` instead of `elfprince13`) → HTTP 404. -4. **"Blocked" status** in `artifact-provenance.json` was accepted by every gate: plan review (3 reviewers), plan deferral skeptic, D implementation review (4 reviewers), D completeness skeptic, orchestrator test gate, and parent merge. -5. **Expected records** were generated from a naive heuristic `canonical()` function using substring matching, not from LDraw spec or reference behavior. -6. **D merged** with fictional harness and tautological expected records. -7. **E's planner** built on D's broken foundation. E's implementation was a skeleton parser built from plan text without studying any reference source. -8. **Multiple E review cycles** (3 plan generations, 4 implementation reviewers) identified symptoms (parser incomplete, BFC wrong, no graph traversal) but never traced the root cause to the missing reference. - -## What would prevent this - -### 1. External reference verification gate -Before a plan references an external library/repository, the planner must: -- Fetch or clone the referenced source -- Cite specific files, types, and functions that inform the plan -- If the reference cannot be accessed, mark the task `blocked-failure`, not "blocked" with a silent skip - -The plan deferral skeptic should verify that cited external references resolve. A 404 on a core reference is not an acceptable "blocked" status. - -### 2. Provenance integrity -A "blocked" acquisition in provenance metadata should be a hard blocker, not a note that gets accepted. If a pinned reference returns 404, the task cannot proceed until the reference is resolved or explicitly re-scoped by the operator. - -### 3. Reference study requirement for port/mirror tasks -When a task says "mirror architecture/behaviour of X", the planner's first step must be to study X and produce an architecture summary citing specific source files. This summary becomes a required plan input. If the planner cannot study X, the task is blocked. - -### 4. Foundation verification for dependent tasks -When task E depends on task D's corpus/gates, E's planner should verify D's corpus was built from real reference data, not from a heuristic generator. The plan review should check that expected records trace to spec/reference, not to a function in the same crate. - -## Corrective actions taken - -- PM-77 reopened in Jira with corrected description citing C++ reference -- PM-78 updated with C++ source file list and local clone path -- PM-80 commented noting dependency on E rework -- LDParse cloned to `/home/orual/Projects/LDParse-reference` (modernization branch `0fe78dc`) -- D, E, G reset to pending in task-state -- C++ source study dispatched to inform new plans diff --git a/.current-epic/task-A.md b/.current-epic/task-A.md deleted file mode 100644 index 48c860d..0000000 --- a/.current-epic/task-A.md +++ /dev/null @@ -1,44 +0,0 @@ -# Task A — PM-87: Fix: authoritative viewer load/readiness state machine - -**Jira:** [PM-87](https://radiant-industries.atlassian.net/browse/PM-87) -**Status:** To Do -**Priority:** High -**Labels:** bug, implement, renderer, state-machine, viewer -**Execution Prefix:** A | Sort Order: 10 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Blocks: F (PM-79), G (PM-80), J (PM-83) -No predecessors. - -## Goal - -Fix the current-format viewer readiness/stale-scene defect independently of LDraw and establish the authoritative state machine. - -## State and linearization - -State is keyed by monotonically increasing `(load_epoch, scene_generation)`: `Idle | Loading(epoch) | Installing(epoch,generation) | Ready(epoch,generation) | Failed(epoch,code) | Cancelled(epoch) | Superseded(epoch) | Disposed`. - -Every event compare-and-swaps the exact expected state/epoch/generation. Only current `Installing + InstallSucceeded → Ready` publishes a scene. Cancellation, supersession, failure and disposal win once committed; later messages discard and dispose their resources. StartLoad supersedes prior work; resize does not change load state; preview and interactive viewers use separate epoch namespaces. Prior pixels during Loading/Installing are explicitly stale and never current. - -## Acceptance - -Commit the transition/event table and CAS implementation. Deterministic tests cover both orders of install-vs-cancel, install-vs-supersede, install-vs-dispose, success-vs-error, late messages, resize during install, stale prior scene and preview/interactive isolation. Existing STL/OBJ/glTF/3MF browser/e2e behaviour remains green. - -## Merge-ready tests - -* `just check` -* `just fix` -* focused renderer protocol/worker unit tests -* `just build-renderer-worker` -* configured-browser `just test-renderer` -* relevant viewer e2e tests -* `just test-all` before handoff if shared protocol or route state changes are substantial - -The isolated jj workspace must be independently merge-ready; no readiness stub, duplicate epoch model, ignored required test, or deferred route wiring remains. - -## Comments - -None. diff --git a/.current-epic/task-B.md b/.current-epic/task-B.md deleted file mode 100644 index 8d27188..0000000 --- a/.current-epic/task-B.md +++ /dev/null @@ -1,61 +0,0 @@ -# Task B — PM-88: Implement: shared renderer load reservation ledger and hard budgets - -**Jira:** [PM-88](https://radiant-industries.atlassian.net/browse/PM-88) -**Status:** To Do -**Priority:** High -**Labels:** budgets, implement, memory, renderer, security -**Execution Prefix:** B | Sort Order: 20 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Blocks: E (PM-78), F (PM-79), G (PM-80), H (PM-81), I (PM-82), K (PM-84) -No predecessors. - -## Goal - -Implement the shared reservation ledger and frozen initial hard budgets before compound resolver/scene/GPU/texture work. - -## Admission hierarchy - -Every reservation charges one resource class, one memory domain, the load/viewer aggregate, and global aggregates with checked u64 arithmetic. Admission succeeds only when ALL applicable ceilings remain satisfied; class ceilings are maxima, not additive entitlements. - -| Domain | Classes | Per load/viewer | Global | -| --- | --- | --- | --- | -| Network | network in flight; retained fetched bytes | 192 MiB | 320 MiB | -| WasM/CPU | decoded source/DATA; semantic arenas; scene CPU; decoded texture pixels; WASM committed pages | 768 MiB per worker | 1 GiB | -| GPU | GPU buffers; GPU textures | 768 MiB | 1 GiB | -| Display | canvas attachments/backbuffers/presentation | 192 MiB per viewer | 320 MiB | - -Keep PM-75's class ceilings and unified estimated-live ceilings of 1.25 GiB per load/viewer and 1.75 GiB global. Preview and interactive viewers are separate loads but share global domain/unified totals. A resource belongs to exactly one domain; ownership transfer changes class/domain ownership atomically without double-counting only when the source reservation is removed in the same transaction. Otherwise unified totals sum charged domains without deduplication. - -Parent/child reservations cannot exceed parents; ownership transfers atomically; release is deterministic. Measurement never substitutes for admission control. - -## Exact formulas - -Every formula uses checked integer arithmetic and rounds upward. `ceil_div(n,d) = n/d + (n%d != 0)` with checked result. `align_up(n,a) = ceil_div(n,a) * a`; `a` must be nonzero. WASM charges `align_up(requested_capacity, 65_536)`. GPU buffers charge `align_up(requested_capacity, 256) + ceil_div(align_up(requested_capacity,256), 8)`. - -Texture `base` is the checked sum of compressed/uncompressed format block bytes across all mip levels, layers, faces and samples; charge `base + ceil_div(base,4)`. Canvas attachment bytes are `width × height × 4 × attachment_count × sample_count`. Presentation-buffer bytes are separately `width × height × 4` (one unsampled RGBA8-equivalent presentation buffer). Canvas `base = attachment_bytes + presentation_bytes`; charge `base + ceil_div(base,4)`. On resize, old and new full canvas charges coexist until the swap commits. Any overflow or zero/invalid dimension supplied at the system boundary rejects admission. - -Provide dependency-light types that PM-78/79/80/81/82 can consume without violating the renderer split. The ledger itself must not pull renderer/GPU/parser dependencies into app WASM. - -## Acceptance - -Versioned class/domain/unified constants, mappings and formulas; canonical limit/limit+1 vectors for every class, shared domain, load aggregate, global aggregate, unified total, resize overlap and overflow; proof that independently legal classes reject when their shared domain or unified ceiling is exceeded; nesting/capacity-growth/transfer/cleanup/concurrency property tests; rejection before representative WASM/GPU/canvas allocation; allocation-path audit schema/checklist; documented ownership integration surface for downstream tickets. - -## Merge-ready tests - -* `just check` -* `just fix` -* focused reservation-ledger unit/property tests -* wasm32 check for the owning leaf crate -* `just verify-renderer-split` -* relevant browser allocation-admission tests -* `just test-all` before handoff because this is cross-cutting infrastructure - -The isolated jj workspace must be independently merge-ready with the complete ledger API/constants/tests—not a scaffold waiting for downstream tasks. - -## Comments - -None. diff --git a/.current-epic/task-C.md b/.current-epic/task-C.md deleted file mode 100644 index 766728f..0000000 --- a/.current-epic/task-C.md +++ /dev/null @@ -1,54 +0,0 @@ -# Task C — PM-76: Implement: LDraw library projection and ATProto dependency manifest - -**Jira:** [PM-76](https://radiant-industries.atlassian.net/browse/PM-76) -**Status:** To Do -**Priority:** Medium -**Labels:** atproto, design, ldraw, library, resolver -**Execution Prefix:** C | Sort Order: 30 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Blocks: G (PM-80), J (PM-83) -No predecessors. - -## Goal - -Implement the settled v1 same-origin LDraw library/manifest service, including authored lexicons, projection/storage, canonical resolution indexes, authorization, integrity metadata, legacy migration, and opaque resource endpoints. This issue produces the server/schema foundation consumed by PM-80 and PM-83; it does not reopen design choices. - -## Contract - -Implement PM-75 §3.3 and §5.5 exactly: UTF-8 NFC virtual paths; fixed MPD/manifest/uploaded-root/official-library precedence and case rules; DID-authority strong identities; SHA-256 over exact bytes; server-derived routes bound to manifest/root/path/target/principal/expiry/revocation; authorization at mint and fetch; partitioned verified caches; and crash-safe `unverified → verifying → verified | rejected` leases/attempt tokens with stale-writer rejection. - -Entering `verifying` atomically writes a unique attempt token, source CID/version and database-clock lease expiry. Completion CAS requires the current token, unchanged source identity, no revocation and an unexpired lease. An expired lease with unchanged source reconciles deterministically to `unverified` while `expired_attempts < 3`; the next claimant may retry with a new token. The third expired attempt transitions to `rejected` with a stable diagnostic. A changed source resets the counter as a new verification identity. Revocation transitions to `rejected` and invalidates routes/caches. Stale workers can never publish. Lease duration and diagnostic codes are versioned constants, not per-request choices; tests use an injected database clock rather than wall-clock sleeps. - -Pin the official-library snapshot, digest and notices. New records require exact byte length and SHA-256. Legacy records remain unreadable until verified. Production SQL uses SQLx checked macros and the `.sqlx` cache is regenerated. - -## Non-goals - -No worker protocol, LDraw parser, scene IR, TEXMAP renderer, future META syntax, fabrication calibration, or printable-part conversion. - -## Affected areas - -Authored `lexicons/`; generated `crates/polymodel-api` only through `just generate-api`; server projection/routes/storage/migrations; SQLx cache; server and integration tests; relevant operator/developer documentation. The task planner must grep exact construction/query/route sites before freezing its plan. - -## Acceptance - -Canonical/root-policy vectors; duplicate/collision rejection; route substitution/replay/revocation/cross-principal tests; exact digest vectors; crash/timeout/stale-writer/three-expiry/source-change/retry/revocation migration races; pinned-library ingestion; offline/cache/update behaviour; authenticated and public access cases. No publisher-controlled fetch URL or integrity-free read path exists. - -## Merge-ready tests - -* `just check` -* `just fix` -* `just sqlx-prepare` when production SQL/migrations change -* `just generate-api` when authored lexicons change, with generated diff reviewed -* `just test-server` -* relevant focused integration tests -* `just test-all` before handoff because this changes schema, persistence, and server interfaces - -The isolated jj workspace must be green and independently merge-ready; no TODOs, ignored required tests, or deferred plumbing. - -## Comments - -None. diff --git a/.current-epic/task-D.md b/.current-epic/task-D.md deleted file mode 100644 index f04af2f..0000000 --- a/.current-epic/task-D.md +++ /dev/null @@ -1,43 +0,0 @@ -# Task D — PM-77: Implement: LDraw corpus and compatibility harness - -**Jira:** [PM-77](https://radiant-industries.atlassian.net/browse/PM-77) -**Status:** To Do -**Priority:** Medium -**Labels:** compatibility, fixtures, implement, ldraw, tests -**Execution Prefix:** D | Sort Order: 40 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Blocks: E (PM-78), F (PM-79), K (PM-84) -No predecessors. - -## Goal - -Create the reproducible parser/semantic/visual test foundation before production implementation. - -## Scope - -Authored ratified-spec micro-fixtures and strict/compatibility/lossless matrix; buildable pinned LDParse differential harness; attributed Thomas fixtures; pinned OMR/official-library artifacts; minimized weldr/ldr_tools parser/MPD/resolver/BFC/geometry tests with independently derived expectations; fuzz/property/adversarial budget harness; deterministic semantic/scene serialization. - -Fixture provenance records source URL, exact commit/blob, SHA-256, licence or operator-attested permission, original test, modifications and authority class. Upstream implementation output is never normative without a compatibility-ledger decision. - -## Acceptance - -Coverage includes types 0–5, raw tails/spans, MPD/DATA, exact BFC state, colours, canonical/root path vectors, cycles, TEXMAP, points/edges, diagnostics, numerical boundaries, resource limits and cancellation. The LDParse oracle is reproducibly buildable in its pinned environment or explicitly limited to the verified buildable surface with fixtures proving each oracle use. - -## Merge-ready tests - -* `just check` -* `just fix` -* focused corpus/harness tests -* fuzz/property corpus smoke/regression command established by this task -* wasm32 check for reusable harness/core fixtures where applicable -* `just test-all` before handoff - -The isolated jj workspace must be green and independently merge-ready; fixtures, permissions, expected outputs and harness invocation are committed, not described for later. - -## Comments - -None. diff --git a/.current-epic/task-E.md b/.current-epic/task-E.md deleted file mode 100644 index 9cb954a..0000000 --- a/.current-epic/task-E.md +++ /dev/null @@ -1,35 +0,0 @@ -# Task E — PM-78: Implement: Thomas-compatible Rust LDraw semantic core - -**Jira:** [PM-78](https://radiant-industries.atlassian.net/browse/PM-78) -**Status:** To Do | **Priority:** Medium -**Labels:** implement, ldraw, parser, rust, wasm -**Execution Prefix:** E | Sort Order: 50 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Depends on: B (PM-88), D (PM-77) -Blocks: F (PM-79), H (PM-81), K (PM-84) - -## Goal - -Build the lean pure-Rust/WASM LDraw semantic core pinned by PM-75, closely mirroring Thomas Dickerson's parser/model architecture while correcting unsafe mechanics and completing ratified semantics. - -## Scope - -Line-local typed syntax with raw bytes/tails/spans; MPD virtual files; iterative include stack; stable IDs/graph/caches; typed primitives 0–5; strict/compatibility/lossless profiles; structured diagnostics; colours/LDConfig; finite right-handed source transforms; exact BFC including PM-75/PM-78 INVERTNEXT outcomes/codes; MPD/DATA; steps; cycles/limits. Integrate PM-88 reservations before arena/intern/cache growth. No filesystem/network/archive/Rayon/random/GPU/three-d dependency. - -## Acceptance - -All PM-77 semantic corpus gates; deterministic canonical output; parser independent of resolver availability; repeated models parse once with distinct instances; table-driven BFC/profile/code outcomes; limit/limit+1, cycle, non-finite and cleanup tests; reserve-before-growth; no panic or silent recognized-construct loss. - -## Merge-ready tests - -* `just check` / `just fix` -* focused semantic-core unit/property/corpus tests -* wasm32 check for the new core crate -* `just verify-renderer-split` -* `just test-all` before handoff - -Independently merge-ready and exposes the complete tested semantic API needed by PM-79/81, with no parser or budget plumbing deferred. diff --git a/.current-epic/task-F.md b/.current-epic/task-F.md deleted file mode 100644 index 8b21f3a..0000000 --- a/.current-epic/task-F.md +++ /dev/null @@ -1,39 +0,0 @@ -# Task F — PM-79: Implement: minimal LDraw vertical slice and generic scene IR contract - -**Jira:** [PM-79](https://radiant-industries.atlassian.net/browse/PM-79) -**Status:** To Do | **Priority:** Medium -**Labels:** ldraw, renderer, scene-ir, spike, wasm -**Execution Prefix:** F | Sort Order: 60 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Depends on: A (PM-87), B (PM-88), D (PM-77), E (PM-78) -Blocks: H (PM-81), J (PM-83), K (PM-84) - -## Goal - -Implement and freeze the smallest renderer-owned scene IR required by one LDraw vertical slice and one glTF hierarchy, without speculative generalization. - -## Scope - -Dependency-light leaf types with branded IDs for Scene/Node/Geometry/Material/Texture/provenance/diagnostics; points, indexed triangles, explicit lines and conditional lines; deterministic hierarchy/instances/bounds/statistics. Add `LengthUnit::LdrawUnit` to the renderer protocol and represent source units as `Units::declared(LengthUnit::LdrawUnit)`. Preserve semantic coordinates, transforms, hierarchy, bounds, BFC evaluation and geometry calculations in native LDU with no parser-side or inclusion-side scaling. - -Own the sole LDraw→Polymodel renderer/world boundary: `(x,y,z) LDU → (x,z,-y) LDU`, det=+1, no winding flip. Metric: `1 LDU := 1/2500 m := 2/5 mm`. Fabrication calibration must not alter LDU or viewer scale. - -Consume PM-87's state machine and PM-88 reservations. Extend `verify-renderer-split`; app remains protocol-only. - -## Acceptance - -Canonical LDraw/glTF IR snapshots; protocol round trips for `LengthUnit::LdrawUnit`; point/triangle/line/conditional coverage; exact stud-module vectors; basis-matrix/determinant/winding/normal/bounds/reflection tests; proof that parsing/inclusion/instancing/BFC do not scale; state compatibility; reserve-before-allocation and resize cleanup; existing STL/OBJ/glTF/3MF output equivalence; measured three-d viability. - -## Merge-ready tests - -* `just check` / `just fix` -* focused scene-IR/adapter/unit tests -* wasm32 check / `just build-renderer-worker` -* `just verify-renderer-split` -* configured-browser `just test-renderer` -* relevant e2e tests -* `just test-all` before handoff diff --git a/.current-epic/task-G.md b/.current-epic/task-G.md deleted file mode 100644 index f9d34f5..0000000 --- a/.current-epic/task-G.md +++ /dev/null @@ -1,35 +0,0 @@ -# Task G — PM-80: Implement: compound LDraw resolver and versioned worker protocol - -**Jira:** [PM-80](https://radiant-industries.atlassian.net/browse/PM-80) -**Status:** To Do | **Priority:** Medium -**Labels:** implement, ldraw, renderer-protocol, resolver, security -**Execution Prefix:** G | Sort Order: 70 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Depends on: A (PM-87), B (PM-88), C (PM-76) -Blocks: H (PM-81), J (PM-83), K (PM-84) - -## Goal - -Consume PM-76's shipped verified-resource service to implement compound client/worker resolution and the versioned app↔worker protocol. - -## Scope - -Postcard envelope and `Created → Negotiating → Negotiated | Failed` handshake with version-range and required/optional capability rules; golden wire fixtures; PM-87 post-negotiation epochs. Implement app/worker compound resolution over PM-76's canonical paths, opaque authorized routes, SHA-256/CID verification and partitioned atomic cache contract. Integrate PM-88 network/decode reservations and cancellation. Do not duplicate PM-76's server schema, route minting, ingestion, or migration state machine. - -## Acceptance - -Golden protocol success/error/out-of-order/pre-negotiation tests; cross-boundary tests for every PM-76 root/path vector and digest/substitution/auth/replay/revocation/cross-principal outcome; stale-route/cache and cancellation races; reserve-before-buffering and cleanup; interactive/preview parity; existing current-format loads green. - -## Merge-ready tests - -* `just check` / `just fix` -* focused protocol/resolver unit/property tests -* `just test-server` -* `just build-renderer-worker` / `just verify-renderer-split` -* configured-browser `just test-renderer` -* compound-load e2e/integration tests -* `just test-all` before handoff diff --git a/.current-epic/task-H.md b/.current-epic/task-H.md deleted file mode 100644 index 4555493..0000000 --- a/.current-epic/task-H.md +++ /dev/null @@ -1,34 +0,0 @@ -# Task H — PM-81: Implement: LDraw points, surfaces, explicit edges, and conditional lines - -**Jira:** [PM-81](https://radiant-industries.atlassian.net/browse/PM-81) -**Status:** To Do | **Priority:** Medium -**Labels:** edges, geometry, implement, ldraw, renderer -**Execution Prefix:** H | Sort Order: 80 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Depends on: B (PM-88), E (PM-78), F (PM-79), G (PM-80) -Blocks: I (PM-82), J (PM-83), K (PM-84) - -## Goal - -Compile deterministic production renderer batches for LDraw points, surfaces, explicit edges and conditional lines. - -## Conditional-line classifier - -Shared dependency-light Rust classifier. Inputs: endpoint segment p1→p2, controls c1,c2, view-projection matrix. Algorithm: transform to homogeneous clip coords → clip endpoint segment against frustum → project controls (when safe w) → perspective-divide → oriented-area side predicate. Packed outcome: `Visible | SuppressedInterior | SuppressedOffscreen | SuppressedAmbiguous | SuppressedDegenerate | Invalid`. Only `Visible` emits the segment. Full algorithm details in task description and PM-75 design. - -## Acceptance - -Thomas/OMR ladder and authored point/BFC/colour/edge fixtures; visible production point batches; numeric packed-outcome fixtures for all six outcomes; same/opposite/collinear/near-epsilon/near-plane/far-plane/side-plane/off-frustum-control/unsafe-control-w/behind-camera-endpoint/reflection/perspective/orthographic vectors; deterministic structural and visual browser baselines; nested-instance correctness; reserve-before-GPU allocation and cleanup; no full default mesh copy; current formats equivalent. - -## Merge-ready tests - -* `just check` / `just fix` -* focused geometry/BFC/edge unit and corpus tests -* `just build-renderer-worker` / `just verify-renderer-split` -* configured-browser `just test-renderer` -* relevant visual/e2e tests -* `just test-all` before handoff diff --git a/.current-epic/task-I.md b/.current-epic/task-I.md deleted file mode 100644 index ea3712e..0000000 --- a/.current-epic/task-I.md +++ /dev/null @@ -1,34 +0,0 @@ -# Task I — PM-82: Implement: LDraw TEXMAP and embedded texture pipeline - -**Jira:** [PM-82](https://radiant-industries.atlassian.net/browse/PM-82) -**Status:** To Do | **Priority:** Medium -**Labels:** implement, ldraw, renderer, texmap, textures -**Execution Prefix:** I | Sort Order: 90 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Depends on: B (PM-88), H (PM-81) -Blocks: K (PM-84) - -## Goal - -Implement ratified TEXMAP semantics and integrity-aware embedded/external texture rendering. - -## Scope - -START/NEXT/FALLBACK/END across includes; same-file termination/STEP; `!:` geometry; PLANAR/CYLINDRICAL/SPHERICAL; GLOSSMAP; quoted paths; texture lookup; MPD DATA decode; resource IDs/dedup/material bindings. Integrate PM-88 reservations before encoded buffering, decode, pixel retention and GPU upload. Apply PM-75's aware/unaware fallback and structured-failure matrix. Studio PE support is separately identified, never confused with ratified TEXMAP. - -## Acceptance - -Authored scope/projection/fallback fixtures; aware/unaware render modes; invalid DATA/path/size/digest/missing-resource outcomes; deterministic visual baselines; reserve-before-decode/upload and cancellation/replacement cleanup; current glTF texture path unchanged. - -## Merge-ready tests - -* `just check` / `just fix` -* focused TEXMAP/DATA/unit/corpus tests -* `just build-renderer-worker` / `just verify-renderer-split` -* configured-browser `just test-renderer` -* texture visual/e2e tests -* `just test-all` before handoff diff --git a/.current-epic/task-J.md b/.current-epic/task-J.md deleted file mode 100644 index c0596bf..0000000 --- a/.current-epic/task-J.md +++ /dev/null @@ -1,36 +0,0 @@ -# Task J — PM-83: Implement: format registry and LDraw publishing/viewer integration - -**Jira:** [PM-83](https://radiant-industries.atlassian.net/browse/PM-83) -**Status:** To Do | **Priority:** Medium -**Labels:** formats, implement, ldraw, publishing, viewer -**Execution Prefix:** J | Sort Order: 100 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Depends on: A (PM-87), C (PM-76), F (PM-79), G (PM-80) -Blocks: K (PM-84) - -## Goal - -Integrate LDraw publishing/viewing through one canonical format-capability contract while fixing existing format drift. - -## Scope - -Canonical IDs/extensions/MIME aliases/sniffing/units/parser location/compound/preview/download/conversion capabilities; `.ldr/.mpd/.dat`; authored lexicon accepts/API generation; missing glTF MIME; picker/staging/detail/viewer/preview/errors/download. Consume PM-87 state and PM-80 negotiation. Ready is only the current installed scene; stale pixels remain explicitly Loading and terminal states never present old content as current. - -## Acceptance - -Registry/lexicon consistency checks; every MIME/extension/octet-stream case through upload/viewer/preview/download; exact route-state races and negotiation failures; interactive/preview compound loads; current formats green; generated API only from authored lexicons; observable docs agree. - -## Merge-ready tests - -* `just check` / `just fix` -* `just generate-api` for authored lexicon changes, with generated diff reviewed -* focused registry/upload/server/unit tests -* `just test-server` / `just build-renderer-worker` -* configured-browser `just test-renderer` -* publish/viewer/preview e2e tests -* design-detector checks for changed UI surfaces -* `just test-all` before handoff diff --git a/.current-epic/task-K.md b/.current-epic/task-K.md deleted file mode 100644 index a5cb4d5..0000000 --- a/.current-epic/task-K.md +++ /dev/null @@ -1,34 +0,0 @@ -# Task K — PM-84: Validate: LDraw interoperability, fuzzing, performance, and visual baselines - -**Jira:** [PM-84](https://radiant-industries.atlassian.net/browse/PM-84) -**Status:** To Do | **Priority:** Medium -**Labels:** fuzzing, ldraw, performance, validate, visual -**Execution Prefix:** K | Sort Order: 110 -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) -**Design source:** [PM-75 design](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561) - -## Dependencies - -Depends on: B (PM-88), D (PM-77), E (PM-78), F (PM-79), G (PM-80), H (PM-81), I (PM-82), J (PM-83) -Blocks: none - -## Goal - -Run and commit the complete Epic acceptance envelope, validate behaviour against PM-88 hard budgets, and fix any failures found. Measurements may support a separately reviewed budget change; this task does not silently raise limits. - -## Scope - -Full authored/compatibility/Thomas/OMR/pinned-library corpus; fuzz/property/adversarial suite; deterministic structural/visual comparison; parser/graph/instances/geometry/upload/WASM/memory/frame benchmarks; reservation allocation-path audit; exact native-LDU and renderer-boundary audit; current-format regression sweep. Fix defects discovered by these gates within this merge-ready task rather than merely reporting them. - -## Acceptance - -No Critical/High corpus defect, panic, path escape, cycle hang, limit bypass, non-finite upload, unbudgeted allocation, stale-scene race, resolver substitution, or untested required behaviour. Verify `LengthUnit::LdrawUnit`, no scaling before the renderer/world boundary, exact `1 LDU = 1/2500 m`, exact stud/brick/plate vectors, basis `(x,y,z)→(x,z,-y)`, determinant/winding/reflection behaviour, mandatory production point display and shared-Rust conditional-line classification. Verify all PM-88 integer-rounding/domain/unified admission boundaries and PM-76 deterministic lease recovery. Machine-readable benchmark/budget reports pin schema, environment, thresholds and pass/fail. - -## Merge-ready tests - -* `just check` / `just fix` -* all focused corpus/fuzz/property/benchmark validation commands established by predecessors -* `just test-server` / `just build-renderer-worker` / `just verify-renderer-split` -* configured-browser `just test-renderer` -* complete relevant e2e/visual suite -* `just test-all` diff --git a/.current-epic/task-L.md b/.current-epic/task-L.md deleted file mode 100644 index b9ac70b..0000000 --- a/.current-epic/task-L.md +++ /dev/null @@ -1,37 +0,0 @@ -# Task L — PM-90: Correct LDraw face lighting and top/bottom brightness - -**Jira:** [PM-90](https://radiant-industries.atlassian.net/browse/PM-90) -**Status:** To Do | **Issue type:** Bug -**Execution prefix:** L -**Epic:** [PM-86](https://radiant-industries.atlassian.net/browse/PM-86) - -## Origin and approval - -Operator-approved dynamic Epic task created during PM-79 visual validation on 2026-08-06. PM-79 fixed missing/invisible LDraw geometry and delivered an operator-verified native picker; the operator accepted separating the remaining face-lighting discrepancy into this task. - -## Dependency - -Depends on F (PM-79) completion and integration into the PM-86 Epic branch. - -## Problem - -Known-colour LDraw parts can show top and bottom faces with opposite brightness/colour from the expected result. Existing numeric topology checks found no material colour swap, but visual production-path evidence remains unresolved. - -## Scope - -Diagnose matched identity/reflected and front/back production renders across authored BFC winding, generated triangle order, local/world normals, reflection/inversion composition, culling, effective LDraw colour, PhysicalMaterial properties, light direction conventions, and native/static/web parity. - -Fix shared production rendering policy. Do not disable depth testing, culling, or lighting, and do not replace affected materials with unlit materials. - -## Acceptance criteria - -- Matched front/back and identity/reflected renders identify the first incorrect boundary. -- Numeric tests cover indices, normals, light-facing sign, and material identity. -- Native persistent, static readback, and web worker use equivalent lighting/material semantics. -- Known-colour top and bottom faces render with expected relative appearance under controlled lights. -- Existing LDraw geometry counts, picker behaviour, ordinary format parity, and renderer split remain green. -- Run the complete jira-epic-run dynamic-task pipeline. - -## Model routing override - -While ZAI is rate-limited, use `neuralwatt/glm-5.2-flex(medium)` for architecture and long-context GLM review lanes. Preserve standard Codex correctness and test-quality lanes. diff --git a/.current-epic/task-state.json b/.current-epic/task-state.json deleted file mode 100644 index 25b9e06..0000000 --- a/.current-epic/task-state.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "schema_version": 1, - "updated_at": "2026-08-07T20:47:00Z", - "updated_by": "polytoken", - "epic": { - "jira_key": "PM-86", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-86", - "workspace": "../epic-86", - "bookmark": "epic-86", - "base_bookmark": "main" - }, - "tasks": { - "A": { - "phase": "completed", - "job_id": null, - "change_id": "mnnrlsvq", - "jira_key": "PM-87", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-87", - "title": "Authoritative viewer load/readiness state machine", - "workspace": "../epic-86-task-A", - "bookmark": "epic-86-task-A", - "task_cache": ".current-epic/task-A.md", - "plan_path": ".current-epic/plan-A.md", - "task_context_dir": "../epic-86-task-A/.current-task", - "job_id": null, - "change_id": null, - "last_error": null - }, - "B": { - "phase": "completed", - "job_id": "general-purpose:implementor-B3", - "jira_key": "PM-88", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-88", - "title": "Shared renderer load reservation ledger and hard budgets", - "workspace": "../epic-86-task-B", - "bookmark": "epic-86-task-B", - "task_cache": ".current-epic/task-B.md", - "plan_path": ".current-epic/plan-B.md", - "task_context_dir": "../epic-86-task-B/.current-task", - "job_id": null, - "change_id": null, - "last_error": null - }, - "C": { - "phase": "completed", - "jira_key": "PM-76", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-76", - "title": "LDraw library projection and ATProto dependency manifest", - "workspace": "../epic-86-task-C", - "bookmark": "epic-86-task-C", - "task_cache": ".current-epic/task-C.md", - "plan_path": ".current-epic/plan-C.md", - "task_context_dir": "../epic-86-task-C/.current-task", - "job_id": null, - "change_id": "65f00d02", - "last_error": null - }, - "D": { - "phase": "completed", - "job_id": null, - "change_id": "951ab847", - "jira_key": "PM-77", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-77", - "title": "LDraw corpus and compatibility harness", - "workspace": "../epic-86-task-D", - "bookmark": "epic-86-task-D", - "task_cache": ".current-epic/task-D.md", - "plan_path": ".current-epic/plan-D.md", - "task_context_dir": "../epic-86-task-D/.current-task", - "job_id": null, - "change_id": null, - "last_error": null - }, - "E": { - "phase": "completed", - "jira_key": "PM-78", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-78", - "title": "Thomas-compatible Rust LDraw semantic core", - "workspace": "../epic-86-task-E", - "bookmark": "epic-86-task-E", - "task_cache": ".current-epic/task-E.md", - "plan_path": ".current-epic/plan-E.md", - "task_context_dir": "../epic-86-task-E/.current-task", - "job_id": null, - "change_id": "ukyukrormltsqqtuoussstzyzqvwtnnv", - "last_error": null - }, - "F": { - "phase": "completed", - "jira_key": "PM-79", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-79", - "title": "Minimal LDraw vertical slice and generic scene IR contract", - "workspace": "../epic-86-task-F", - "bookmark": "epic-86-task-F", - "task_cache": ".current-epic/task-F.md", - "plan_path": ".current-epic/plan-F.md", - "task_context_dir": "../epic-86-task-F/.current-task", - "job_id": null, - "change_id": "svzwtnklsrpkqqnnotrolqxrnlzlrrwu", - "last_error": null - }, - "G": { - "phase": "completed", - "jira_key": "PM-80", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-80", - "title": "Compound LDraw resolver and versioned worker protocol", - "workspace": "../epic-86-task-G", - "bookmark": "epic-86-task-G", - "task_cache": ".current-epic/task-G.md", - "plan_path": ".current-epic/plan-G.md", - "task_context_dir": "../epic-86-task-G/.current-task", - "job_id": null, - "change_id": "yysxzxzkzpzzvwuxspnnzutmuopknzmm", - "last_error": null - }, - "H": { - "phase": "completed", - "jira_key": "PM-81", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-81", - "title": "LDraw points, surfaces, explicit edges, and conditional lines", - "workspace": "../epic-86-task-H", - "bookmark": "epic-86-task-H", - "task_cache": ".current-epic/task-H.md", - "plan_path": ".current-epic/plan-H.md", - "task_context_dir": "../epic-86-task-H/.current-task", - "job_id": null, - "change_id": "sqxozxwrkrzokkmpslnwkvtpvnzkosox", - "last_error": null - }, - "I": { - "phase": "planner-spawned", - "jira_key": "PM-82", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-82", - "title": "LDraw TEXMAP and embedded texture pipeline", - "workspace": "../epic-86-task-I", - "bookmark": "epic-86-task-I", - "task_cache": ".current-epic/task-I.md", - "plan_path": ".current-epic/plan-I.md", - "task_context_dir": "../epic-86-task-I/.current-task", - "job_id": "general-purpose:planner-i1", - "change_id": null, - "last_error": null - }, - "J": { - "phase": "pending", - "jira_key": "PM-83", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-83", - "title": "Format registry and LDraw publishing/viewer integration", - "workspace": "../epic-86-task-J", - "bookmark": "epic-86-task-J", - "task_cache": ".current-epic/task-J.md", - "plan_path": ".current-epic/plan-J.md", - "task_context_dir": "../epic-86-task-J/.current-task", - "job_id": null, - "change_id": null, - "last_error": null - }, - "K": { - "phase": "pending", - "jira_key": "PM-84", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-84", - "title": "Interoperability, fuzzing, performance, and visual baselines", - "workspace": "../epic-86-task-K", - "bookmark": "epic-86-task-K", - "task_cache": ".current-epic/task-K.md", - "plan_path": ".current-epic/plan-K.md", - "task_context_dir": "../epic-86-task-K/.current-task", - "job_id": null, - "change_id": null, - "last_error": null - }, - "L": { - "phase": "plan-authored", - "jira_key": "PM-90", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-90", - "title": "Correct LDraw face lighting and top/bottom brightness", - "workspace": "../epic-86-task-L", - "bookmark": "epic-86-task-L", - "task_cache": ".current-epic/task-L.md", - "plan_path": ".current-epic/plan-L.md", - "task_context_dir": "../epic-86-task-L/.current-task", - "job_id": null, - "change_id": null, - "last_error": "L7 accepted by explicit operator disposition. Skeptic finding L-plan-7-S1 rebutted-accepted: canonical reference images are the established visual baseline; planner-L8 cancelled." - }, - "M": { - "phase": "plan-authored", - "jira_key": "PM-91", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-91", - "title": "Fix close-range renderer depth corruption and camera clipping precision", - "workspace": "../epic-86-task-M", - "bookmark": "epic-86-task-M", - "task_cache": ".current-epic/task-M.md", - "plan_path": ".current-epic/plan-M.md", - "task_context_dir": "../epic-86-task-M/.current-task", - "job_id": null, - "change_id": null, - "last_error": "M4 remains unchanged and accepted by explicit operator decision. All M skeptic objections are recorded as rebutted-accepted in .current-epic/findings-M.jsonl; implementation eligibility is blocked only by dependencies.dot L -> M until L / PM-90 completes." - }, - "N": { - "phase": "planner-spawned", - "jira_key": "PM-92", - "jira_url": "https://radiant-industries.atlassian.net/browse/PM-92", - "title": "Use cached part edges for logical-part selection highlight", - "workspace": "../epic-86-task-N", - "bookmark": "epic-86-task-N", - "task_cache": ".current-epic/task-N.md", - "plan_path": ".current-epic/plan-N.md", - "task_context_dir": "../epic-86-task-N/.current-task", - "job_id": "general-purpose:planner-n1", - "change_id": null, - "last_error": null - } - } -} diff --git a/.current-epic/workflow.dot b/.current-epic/workflow.dot deleted file mode 100644 index 84fceb3..0000000 --- a/.current-epic/workflow.dot +++ /dev/null @@ -1,72 +0,0 @@ -digraph epic_workflow { - rankdir=TB; - node [fontname="Helvetica"]; - edge [fontname="Helvetica", fontsize=10]; - - // ── Per-task lifecycle ── - pending [label="pending", shape=box]; - planner [label="planner-spawned\n(planner-A writes plan-.md)\nmodel: default_model:full", shape=box]; - plan_review [label="plan review\n3-model panel:\nzai/glm-5.2(high)\ncodex/gpt-5.6-luna(high)\nzai/glm-5.2(max)", shape=diamond]; - plan_skeptic [label="plan deferral skeptic\ncodex/gpt-5.6-sol(medium)\nreads plan + deferrals\nfor escape hatches", shape=diamond]; - plan_authored [label="plan-authored", shape=box]; - implementor [label="implementor-spawned\n(implementor-A commits on task branch)\nmodel: default_model:full", shape=box]; - impl_review [label="implementation review\n4-model panel:\nzai/glm-5.2(high)\ncodex/gpt-5.6-sol(medium)\nzai/glm-5.2(max)\ncodex/gpt-5.6-luna(high)", shape=diamond]; - impl_skeptic [label="completeness/correctness skeptic\ncodex/gpt-5.6-sol(medium)\nreads impl diff for stubs,\nignored tests, unwired code", shape=diamond]; - orch_test [label="orchestrator test gate\nparent runs just check + just fix + relevant tests\nin task workspace\n(not delegated)", shape=diamond]; - awaiting_merge [label="awaiting-merge", shape=box]; - merging [label="integrating\nparent: jj rebase + described change\nverify Task metadata on Epic bookmark", shape=box]; - completed [label="completed\n(Task metadata verified\nin Epic workspace)", shape=box, style=bold]; - blocked [label="blocked-failure\n(stop dispatch, surface\nexact task + error)", shape=octagon, style=filled, fillcolor=lightcoral]; - - // ── Per-task transitions ── - pending -> planner [label="all predecessors completed\nby jj Task metadata"]; - planner -> plan_review [label="plan file written + verified\n(Epic key, Task key, steps,\ntests, trailers, handoff)"]; - plan_review -> planner [label="critical/high open\n→ next planner generation\n(feed full finding log)"]; - plan_review -> plan_skeptic [label="EXIT: no critical/high +\nall prior findings dispositioned\n(fixed / rebutted-accepted /\naccepted-risk with operator note)"]; - plan_skeptic -> planner [label="REJECT: escape hatch or\nsolvable deferral found\n→ planner revises,\nFULL review re-runs"]; - plan_skeptic -> plan_authored [label="PASS: no escape hatches,\nno unacceptable deferrals"]; - plan_authored -> implementor [label="all predecessors completed +\nno open critical/high plan findings"]; - implementor -> impl_review [label="implementor reports ready\n(parent verifies jj + tests,\ndoes NOT trust the claim)"]; - implementor -> blocked [label="implementor reports blocked"]; - impl_review -> implementor [label="critical/high open\n→ next implementor generation\n(feed finding log + branch state)"]; - impl_review -> impl_skeptic [label="EXIT: no critical/high +\nall prior findings dispositioned"]; - impl_skeptic -> implementor [label="REJECT: stubs, ignored tests,\nunwired code found\n→ implementor fixes,\nFULL review re-runs"]; - impl_skeptic -> orch_test [label="PASS: implementation complete,\nno stubs or gaps"]; - orch_test -> implementor [label="FAIL: just test fails\n→ dispatch implementor+\nreviewer subagents to fix,\nthen re-run skeptic + test"]; - orch_test -> awaiting_merge [label="PASS: just check + just fix + tests\nclean in task workspace"]; - awaiting_merge -> merging [label="merge-gate.json.holder acquired"]; - merging -> completed [label="Task metadata verified\non Epic bookmark +\ngate released"]; - - // ── Review loop exit condition (applies to both plan and impl review) ── - // - // 1. Latest panel reports no critical/high findings. - // 2. Every finding from every prior pass has a disposition: - // fixed, rebutted-accepted, or accepted-risk. - // Critical/high accepted-risk requires operator acceptance note. - // 3. Medium findings fixed by default unless parent accepts risk. - // 4. Low findings fixed when cheap, rebutted, or carried as accepted risk. - // 5. Parent independently verifies artifact, tests, jj state, and change metadata. - // - // A later clean review does NOT erase unresolved earlier findings. - // The parent maintains findings-.jsonl as the source of truth. - - // ── Epic-level flow (after ALL tasks completed) ── - test_sweep [label="test-adherence sweep\nunit + TestProvider + integration + TUI\nresearch gaps; synthesize sweep tasks", shape=diamond]; - epic_skeptic [label="epic closeout skeptic\ncodex/gpt-5.6-luna(high)\nholistic pass across all tasks\nfor integration gaps, ignored tests,\nstubs, unwired code", shape=diamond]; - epic_review [label="final epic review\n3-model panel on epic branch\nvs base branch", shape=diamond]; - docs [label="documentation work\n(linear: one lane at a time)\nmodel: default_model:full", shape=box]; - docs_review [label="docs review\n3-model panel per lane", shape=diamond]; - closeout [label="closeout\nJira summary comment\nleave .current-epic/ in place\ndo NOT merge to main\nunless operator instructs", shape=box, style=bold]; - - completed -> test_sweep [label="ALL manifest tasks have\nverified Task metadata +\nmerge-gate.holder is null", style=dashed]; - test_sweep -> pending [label="mandatory gap found →\nsynthesize sweep task\n(dynamic task creation,\nfull pipeline with all gates)"]; - test_sweep -> epic_skeptic [label="coverage gaps fixed"]; - epic_skeptic -> pending [label="REJECT: integration gap or\nstub found → create new task\n(dynamic task creation,\nfull pipeline with all gates)"]; - epic_skeptic -> epic_review [label="PASS: no integration gaps"]; - epic_review -> epic_review [label="critical/high → fix\non epic branch\n(Epic: trailer only)"]; - epic_review -> docs [label="code review gate clean"]; - docs -> docs_review [label="docs lane written"]; - docs_review -> docs [label="critical/high →\nnext docs generation\n(same lane)"]; - docs_review -> docs [label="next lane\n(after current clean)", style=dashed]; - docs_review -> closeout [label="all lanes clean"]; -} diff --git a/.gitignore b/.gitignore index 4db5ea5..682b3f5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ node_modules/ /e2e/playwright-report/** /docs/** .scratch +# Stray process artifacts. +.pm*-* +result +findings-*.jsonl # Runtime coordination state for the event-sourced GC executor. /.current-gc/ # Generated renderer worker artifacts (built by `just build-renderer-worker`). diff --git a/.pm78-core-test-status b/.pm78-core-test-status deleted file mode 100644 index 472bf85..0000000 --- a/.pm78-core-test-status +++ /dev/null @@ -1 +0,0 @@ -status=101 diff --git a/Cargo.lock b/Cargo.lock index 244e6e3..79b6136 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6788,7 +6788,6 @@ dependencies = [ "base64 0.22.1", "miette", "polymodel-ldraw-testkit", - "polymodel-renderer-ledger", "polymodel-renderer-protocol", "serde", "serde_json", @@ -6821,10 +6820,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "polymodel-renderer-ledger" -version = "0.1.0" - [[package]] name = "polymodel-renderer-protocol" version = "0.1.0" @@ -6846,7 +6841,6 @@ dependencies = [ "png 0.18.1", "polymodel-ldraw-core", "polymodel-mesh", - "polymodel-renderer-ledger", "polymodel-renderer-protocol", "postcard", "serde", diff --git a/crates/polymodel-ldraw-core/Cargo.toml b/crates/polymodel-ldraw-core/Cargo.toml index 27518b1..825f3eb 100644 --- a/crates/polymodel-ldraw-core/Cargo.toml +++ b/crates/polymodel-ldraw-core/Cargo.toml @@ -8,7 +8,6 @@ description = "Pure Rust/WASM LDraw syntax and semantic core." [dependencies] base64 = { version = "0.22" } polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" } -polymodel-renderer-ledger = { path = "../polymodel-renderer-ledger" } serde = { workspace = true } smol_str = { workspace = true } miette = { workspace = true } diff --git a/crates/polymodel-ldraw-core/src/adapter.rs b/crates/polymodel-ldraw-core/src/adapter.rs index 4100bb1..f4acb02 100644 --- a/crates/polymodel-ldraw-core/src/adapter.rs +++ b/crates/polymodel-ldraw-core/src/adapter.rs @@ -2,8 +2,7 @@ use crate::{ CancellationPolicy, LdrawLimits, LdrawParser, Materialization, OwnedParseResult, ParseError, ParseOptions, ParserProfile, RootId, }; -use polymodel_renderer_ledger::{ReservationLedger, ReservationOwner}; -use std::borrow::Cow; +use std::{borrow::Cow, marker::PhantomData}; /// Host-facing adapter: host code performs discovery/fetching and supplies the /// complete immutable materialization table to the synchronous core. @@ -14,14 +13,13 @@ pub struct AdapterRequest<'src, 'ledger> { pub bytes: &'src [u8], pub profile: ParserProfile, pub provenance_id: &'src str, - pub owner: ReservationOwner, - pub ledger: &'ledger ReservationLedger, pub semantic_budget: Option, pub root_name: &'src str, pub root: RootId, pub materializations: &'src [Materialization], pub target_selections: &'src [crate::TargetSelection], pub cancellation: CancellationPolicy, + placeholder: PhantomData<&'ledger ()>, } impl InProcessRustAdapter { pub fn parse<'src, 'ledger>( @@ -34,7 +32,7 @@ impl InProcessRustAdapter { request: AdapterRequest<'src, 'ledger>, limits: LdrawLimits, ) -> Result { - let mut options = ParseOptions::new(request.owner, request.ledger); + let mut options = ParseOptions::new(); options.profile = request.profile; options.limits = limits; options.semantic_budget = request.semantic_budget; @@ -52,22 +50,19 @@ impl InProcessRustAdapter { fixture_id: &'a str, bytes: &'a [u8], profile: ParserProfile, - owner: ReservationOwner, - ledger: &'a ReservationLedger, ) -> Result { Self::parse(AdapterRequest { fixture_id, bytes, profile, provenance_id: fixture_id, - owner, - ledger, semantic_budget: Some(16 * 1024 * 1024), root_name: fixture_id, root: RootId::UploadedManifest, materializations: &[], target_selections: &[], cancellation: CancellationPolicy::default(), + placeholder: PhantomData, }) } } diff --git a/crates/polymodel-ldraw-core/src/geom.rs b/crates/polymodel-ldraw-core/src/geom.rs index 153fa36..599543a 100644 --- a/crates/polymodel-ldraw-core/src/geom.rs +++ b/crates/polymodel-ldraw-core/src/geom.rs @@ -4,8 +4,8 @@ use crate::model::{Include, ModelData}; use crate::scanner::{LineType, ScannedLine, Token}; use crate::texmap::TexmapAssociation; use crate::types::{ - Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, ParseError, ParseOptions, - ParserProfile, SemanticArenas, Span, add_diag, + Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, ParseError, ParserProfile, Span, + add_diag, }; use serde::{Deserialize, Deserializer, Serialize}; use std::borrow::Cow; @@ -346,8 +346,6 @@ pub(crate) fn process_geometry<'src>( diagnostics: &mut Vec, counters: &mut LimitCounters, limits: &LdrawLimits, - arenas: &mut SemanticArenas, - options: &ParseOptions<'_>, ) -> Result<(), ParseError> { match line.line_type { Some(LineType::One) => { @@ -461,7 +459,6 @@ pub(crate) fn process_geometry<'src>( next_bounds = Some(Bounds3::from_point(point)); } } - arenas.charge_geometry_record(options)?; let texmap = TexmapAssociation::from_state(model.texmap_state, model.texmap_descriptor.as_ref()); let primitive_id = u32::try_from(model.geometry.len()) diff --git a/crates/polymodel-ldraw-core/src/lib.rs b/crates/polymodel-ldraw-core/src/lib.rs index 793f6c1..ebcfa1e 100644 --- a/crates/polymodel-ldraw-core/src/lib.rs +++ b/crates/polymodel-ldraw-core/src/lib.rs @@ -12,7 +12,6 @@ mod scanner; mod texmap; mod traversal; mod types; -mod util; pub use adapter::*; pub use bfc::*; @@ -29,9 +28,8 @@ pub use types::*; #[cfg(test)] mod tests { use super::*; - use polymodel_renderer_ledger::{ReservationLedger, ReservationOwner, ResourceClass}; - fn options<'a>(ledger: &'a ReservationLedger) -> ParseOptions<'a> { - ParseOptions::new(ReservationOwner::preview(1, 1, 1), ledger) + fn options<'a>() -> ParseOptions<'a> { + ParseOptions::new() } #[test] fn normalized_paths_reject_rooted_include_forms() { @@ -116,8 +114,7 @@ mod tests { #[test] fn invalid_geometry_colour_does_not_publish_geometry_or_counts() { - let ledger = ReservationLedger::new(); - let mut parse_options = options(&ledger); + let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes( @@ -137,7 +134,6 @@ mod tests { let child_path = NormalizedPath::new("child.dat").unwrap(); let _materialization_key = CacheKey::new(child_path.clone(), RootId::OfficialLibrary, child); - let ledger = ReservationLedger::new(); let owned_materialization = Materialization::new( child_path.clone(), RootId::OfficialLibrary, diff --git a/crates/polymodel-ldraw-core/src/model.rs b/crates/polymodel-ldraw-core/src/model.rs index 6214d41..eaa7e6f 100644 --- a/crates/polymodel-ldraw-core/src/model.rs +++ b/crates/polymodel-ldraw-core/src/model.rs @@ -4,7 +4,7 @@ use crate::geom::{Bounds3, ColourCode, GeometryRecord, Transform, deserialize_op use crate::mpd::OwnedVirtualFile; use crate::scanner::{LineType, Token, TokenKind}; use crate::texmap::{TexmapAssociation, TexmapEvent, TexmapState, TextureDescriptor}; -use crate::types::{Diagnostic, LimitCounters, SCHEMA_VERSION, SemanticArenas, Span}; +use crate::types::{Diagnostic, LimitCounters, SCHEMA_VERSION, Span}; use serde::{Deserialize, Serialize}; use smol_str::SmolStr; use std::borrow::Cow; @@ -778,7 +778,6 @@ pub struct ParseResult<'src> { pub files: Vec, pub models: Vec, pub colours: ColourTable<'src>, - pub arenas: SemanticArenas, pub provenance_id: Cow<'src, str>, } @@ -793,7 +792,6 @@ impl fmt::Debug for OwnedParseResult { formatter .debug_struct("OwnedParseResult") .field("schema_version", &SCHEMA_VERSION) - .field("reservation_id", &self.result.arenas.reservation_id()) .finish() } } @@ -817,22 +815,16 @@ impl OwnedParseResult { self.result.project() } - pub fn ledger_reservation_id(&self) -> u64 { - self.result.arenas.reservation_id() - } - /// Move both the canonical projection and its retained arena owner together. pub fn into_projection(self) -> OwnedProjection { OwnedProjection { canonical: self.result.project(), - arenas: self.result.arenas, } } } pub struct OwnedProjection { pub canonical: CanonicalRecord<'static>, - pub arenas: SemanticArenas, } impl OwnedProjection { @@ -916,12 +908,7 @@ impl<'src> ParseResult<'src> { files: self.files, models: self.models, colours: self.colours.into_owned(), - arenas: self.arenas, provenance_id: Cow::Owned(self.provenance_id.into_owned()), } } - - pub fn ledger_reservation_id(&self) -> u64 { - self.arenas.reservation_id() - } } diff --git a/crates/polymodel-ldraw-core/src/parser.rs b/crates/polymodel-ldraw-core/src/parser.rs index 0cc7661..13e209d 100644 --- a/crates/polymodel-ldraw-core/src/parser.rs +++ b/crates/polymodel-ldraw-core/src/parser.rs @@ -14,8 +14,8 @@ use crate::texmap::{TexmapState, TextureDescriptor, texmap}; use crate::traversal::traverse; use crate::types::{ DEFAULT_COLOUR, DEFAULT_PROVENANCE, Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, - LimitKind, Materialization, ParseError, ParseOptions, ParserProfile, SemanticArenas, Span, - add_diag, limit_error, + LimitKind, Materialization, ParseError, ParseOptions, ParserProfile, Span, add_diag, + limit_error, }; use std::borrow::Cow; use std::collections::BTreeSet; @@ -98,8 +98,6 @@ impl LdrawParser { if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); } - let budget = options.budget(bytes.len())?; - let mut arenas = SemanticArenas::new(&options, budget)?; let scanned = scan_lines(bytes, &options.limits)?; for line in &scanned { if options.cancellation.checkpoint() { @@ -170,7 +168,6 @@ impl LdrawParser { &options, &mut counters, &mut diagnostics, - &mut arenas, &mut scope, &mut texture_ids, ); @@ -256,7 +253,6 @@ impl LdrawParser { &options, &mut counters, &mut diagnostics, - &mut arenas, &mut scope, &mut texture_ids, ) { @@ -287,7 +283,6 @@ impl LdrawParser { &options, &mut counters, &mut diagnostics, - &mut arenas, &mut metrics, )?; metrics.report(&started, "traversed"); @@ -329,7 +324,6 @@ impl LdrawParser { files: files.into_iter().map(OwnedVirtualFile::from).collect(), models: summaries, colours: colours.into_owned(), - arenas, provenance_id: Cow::Borrowed(DEFAULT_PROVENANCE), })) } @@ -340,7 +334,6 @@ fn parse_model<'src>( options: &ParseOptions<'_>, counters: &mut LimitCounters, diagnostics: &mut Vec, - arenas: &mut SemanticArenas, colours: &mut ColourScope<'src>, texture_ids: &mut BTreeSet>, ) -> Result, ParseError> { @@ -358,8 +351,6 @@ fn parse_model<'src>( let line_count = u64::try_from(file.lines.len()).map_err(|_| ParseError::Overflow("model line count"))?; let _ = line_count; - arenas.charge_model(options)?; - arenas.charge_cache_entry(options)?; let mut model = ModelData { file_id: file.id, path, @@ -407,7 +398,6 @@ fn parse_model<'src>( options, diagnostics, counters, - arenas, colours, texture_ids, ); @@ -444,14 +434,7 @@ fn parse_model<'src>( } } if model.data_payload.is_some() { - finalize_data( - &mut model, - arenas, - options, - diagnostics, - counters, - file.source_span, - )?; + finalize_data(&mut model, options, diagnostics, counters, file.source_span)?; } Ok(model) } @@ -564,7 +547,6 @@ fn project_ldparse_step_ends(models: &mut [ModelData<'_>], source_model_count: u fn finalize_data<'src>( model: &mut ModelData<'src>, - arenas: &mut SemanticArenas, options: &ParseOptions<'_>, diagnostics: &mut Vec, counters: &mut LimitCounters, @@ -613,7 +595,6 @@ fn finalize_data<'src>( counters .add(LimitKind::ResourceBytes, decoded_len, &options.limits) .map_err(|kind| limit_error(kind, Some(payload.span)))?; - arenas.charge_data_bytes(options, decoded.len())?; model.data_payloads.push(DataPayload { filename: payload.filename, bytes: decoded, @@ -643,7 +624,6 @@ fn process_line<'src>( options: &ParseOptions<'_>, diagnostics: &mut Vec, counters: &mut LimitCounters, - arenas: &mut SemanticArenas, colours: &mut ColourScope<'src>, texture_ids: &mut BTreeSet>, ) -> Result<(), ParseError> { @@ -665,7 +645,7 @@ fn process_line<'src>( payload.encoded.push_str(&encoded); return Ok(()); } - finalize_data(model, arenas, options, diagnostics, counters, line.span)?; + finalize_data(model, options, diagnostics, counters, line.span)?; } if model.texmap_state == TexmapState::AwaitingNext && !line.blank { if line.line_type == Some(LineType::Zero) { @@ -699,8 +679,6 @@ fn process_line<'src>( diagnostics, counters, limits, - arenas, - options, )?; if diagnostics.len() != diagnostic_count { model.texmap_state = TexmapState::AwaitingNext; @@ -717,16 +695,7 @@ fn process_line<'src>( } } if bang_colon { - return process_bang_colon( - model, - line, - profile, - diagnostics, - counters, - limits, - arenas, - options, - ); + return process_bang_colon(model, line, profile, diagnostics, counters, limits); } if line.line_type == Some(LineType::Zero) { let preserves_invert = line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::Bfc) @@ -741,7 +710,6 @@ fn process_line<'src>( options, diagnostics, counters, - arenas, colours, texture_ids, ); @@ -754,8 +722,6 @@ fn process_line<'src>( diagnostics, counters, limits, - arenas, - options, ) } @@ -766,8 +732,6 @@ fn process_bang_colon<'src>( diagnostics: &mut Vec, counters: &mut LimitCounters, limits: &LdrawLimits, - arenas: &mut SemanticArenas, - options: &ParseOptions<'_>, ) -> Result<(), ParseError> { if !matches!( model.texmap_state, @@ -860,8 +824,6 @@ fn process_bang_colon<'src>( diagnostics, counters, limits, - arenas, - options, ) } @@ -871,7 +833,6 @@ fn process_meta<'src>( options: &ParseOptions<'_>, diagnostics: &mut Vec, counters: &mut LimitCounters, - arenas: &mut SemanticArenas, colours: &mut ColourScope<'src>, texture_ids: &mut BTreeSet>, ) -> Result<(), ParseError> { @@ -904,7 +865,6 @@ fn process_meta<'src>( diagnostics, counters, limits, - arenas, options, texture_ids, ); @@ -967,9 +927,6 @@ fn process_meta<'src>( } if let Some(colour) = parse_colour(tokens) { let code = colour.code; - if colours.local_slot(code).is_none() { - arenas.charge_local_colour(options)?; - } if colours.record(colour).is_err() { add_diag( profile, diff --git a/crates/polymodel-ldraw-core/src/texmap.rs b/crates/polymodel-ldraw-core/src/texmap.rs index 9c2d545..64b6b1a 100644 --- a/crates/polymodel-ldraw-core/src/texmap.rs +++ b/crates/polymodel-ldraw-core/src/texmap.rs @@ -132,7 +132,6 @@ impl<'src> TexmapEvent<'src> { use crate::model::ModelData; use crate::scanner::{ScannedLine, TokenKind}; -use crate::types::SemanticArenas; use crate::types::{ Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, LimitKind, ParseError, ParserProfile, add_diag, limit_error, @@ -147,7 +146,6 @@ pub(crate) fn texmap<'src>( diagnostics: &mut Vec, counters: &mut LimitCounters, limits: &LdrawLimits, - arenas: &mut SemanticArenas, options: &crate::types::ParseOptions<'_>, texture_ids: &mut BTreeSet>, ) -> Result<(), ParseError> { @@ -298,7 +296,6 @@ pub(crate) fn texmap<'src>( .map_err(|kind| limit_error(kind, Some(line.span)))?; } let _ = texture_ids.insert(descriptor.clone()); - arenas.charge_texmap_event(options)?; if kind == TokenKind::Start { model.texmap_stack.push(( model.texmap_state, @@ -363,7 +360,6 @@ pub(crate) fn texmap<'src>( true, ); } - arenas.charge_texmap_event(options)?; model.texmap_state = TexmapState::Fallback; model.texmap_fallback_seen = true; model.texmap_events.push(TexmapEvent { @@ -407,7 +403,6 @@ pub(crate) fn texmap<'src>( } else { model.texmap_descriptor.clone() }; - arenas.charge_texmap_event(options)?; model.texmap_events.push(TexmapEvent { kind: TexmapKind::End, span: line.span, diff --git a/crates/polymodel-ldraw-core/src/traversal.rs b/crates/polymodel-ldraw-core/src/traversal.rs index c5b88ef..36c56ce 100644 --- a/crates/polymodel-ldraw-core/src/traversal.rs +++ b/crates/polymodel-ldraw-core/src/traversal.rs @@ -2,8 +2,8 @@ use crate::cache::CacheKey; use crate::geom::{Bounds3, Transform}; use crate::model::{InstanceRecord, LdMeshSemantic, ModelData, ModelId, ModelSummary, SceneRecord}; use crate::types::{ - Diagnostic, DiagnosticCode, LimitCounters, LimitKind, ParseError, ParseOptions, SemanticArenas, - add_diag, limit_error, + Diagnostic, DiagnosticCode, LimitCounters, LimitKind, ParseError, ParseOptions, add_diag, + limit_error, }; use std::collections::{HashMap, HashSet, VecDeque}; @@ -128,7 +128,6 @@ pub(crate) fn traverse( options: &ParseOptions<'_>, counters: &mut LimitCounters, diagnostics: &mut Vec, - arenas: &mut SemanticArenas, metrics: &mut super::parser::ParseMetrics, ) -> Result<(SceneRecord, Vec), ParseError> { if models.is_empty() { @@ -489,7 +488,6 @@ pub(crate) fn traverse( } } } - arenas.charge_instances(options, charged_instances)?; let scene = SceneRecord { model_id: ids .get(&root_definition_id) diff --git a/crates/polymodel-ldraw-core/src/types.rs b/crates/polymodel-ldraw-core/src/types.rs index 78bf2cd..8cd2d47 100644 --- a/crates/polymodel-ldraw-core/src/types.rs +++ b/crates/polymodel-ldraw-core/src/types.rs @@ -2,10 +2,6 @@ use crate::cache::NormalizedPath; use crate::cache::{CacheKey, Sha256Hash}; use crate::geom::ColourCode; use miette::SourceSpan; -use polymodel_renderer_ledger::{ - AdmissionError, RejectionReason, Reservation, ReservationLedger, ReservationOwner, - ResourceClass, -}; use polymodel_renderer_protocol::{ResolverPathError, RootId}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -452,8 +448,6 @@ pub use polymodel_renderer_protocol::TargetSelection; pub struct ParseOptions<'a> { pub profile: ParserProfile, pub limits: LdrawLimits, - pub owner: ReservationOwner, - pub ledger: &'a ReservationLedger, pub semantic_budget: Option, pub root_name: String, pub resolved_root: RootId, @@ -495,12 +489,10 @@ impl Default for ByteBank { } } impl<'a> ParseOptions<'a> { - pub fn new(owner: ReservationOwner, ledger: &'a ReservationLedger) -> Self { + pub fn new() -> Self { Self { profile: ParserProfile::Strict, limits: LdrawLimits::default(), - owner, - ledger, semantic_budget: None, root_name: DEFAULT_ROOT_NAME.into(), resolved_root: RootId::UploadedManifest, @@ -607,116 +599,3 @@ pub(crate) fn add_diag( } Ok(()) } - -pub(crate) fn reservation_error(error: AdmissionError) -> ParseError { - let message = match error.reason { - RejectionReason::ParentCapacity | RejectionReason::Overflow => { - "semantic arena child reservation exceeded the admitted budget" - } - _ => "semantic arena reservation was rejected by the ledger", - }; - ParseError::Diagnostic(Diagnostic { - code: DiagnosticCode::SemanticArenaReservationLimit, - severity: Severity::Error, - message: message.into(), - span: None, - }) -} - -pub struct SemanticArenas { - pub admitted_budget: u64, - pub(crate) children: Vec, - pub(crate) root: Reservation, -} -impl SemanticArenas { - pub(crate) fn new(options: &ParseOptions<'_>, budget: u64) -> Result { - let root = options - .ledger - .reserve(options.owner, ResourceClass::SemanticArenas, budget) - .map_err(reservation_error)?; - Ok(Self { - admitted_budget: budget, - children: Vec::new(), - root, - }) - } - pub(crate) fn child( - &mut self, - options: &ParseOptions<'_>, - bytes: u64, - ) -> Result<(), ParseError> { - let child = options - .ledger - .reserve_child( - &self.root, - options.owner, - ResourceClass::SemanticArenas, - bytes, - ) - .map_err(reservation_error)?; - self.children.push(child); - Ok(()) - } - - pub(crate) fn charge_model(&mut self, options: &ParseOptions<'_>) -> Result<(), ParseError> { - self.child(options, 256) - } - - pub(crate) fn charge_instances( - &mut self, - options: &ParseOptions<'_>, - count: u64, - ) -> Result<(), ParseError> { - let bytes = count - .checked_mul(128) - .ok_or(ParseError::Overflow("instance semantic reservation"))?; - if bytes == 0 { - Ok(()) - } else { - self.child(options, bytes) - } - } - - pub(crate) fn charge_geometry_record( - &mut self, - options: &ParseOptions<'_>, - ) -> Result<(), ParseError> { - self.child(options, 256) - } - - pub(crate) fn charge_cache_entry( - &mut self, - options: &ParseOptions<'_>, - ) -> Result<(), ParseError> { - self.child(options, 64) - } - - pub(crate) fn charge_local_colour( - &mut self, - options: &ParseOptions<'_>, - ) -> Result<(), ParseError> { - self.child(options, 32) - } - - pub(crate) fn charge_texmap_event( - &mut self, - options: &ParseOptions<'_>, - ) -> Result<(), ParseError> { - self.child(options, 48) - } - - pub(crate) fn charge_data_bytes( - &mut self, - options: &ParseOptions<'_>, - bytes: usize, - ) -> Result<(), ParseError> { - self.child( - options, - u64::try_from(bytes).map_err(|_| ParseError::Overflow("DATA reservation bytes"))?, - ) - } - - pub fn reservation_id(&self) -> u64 { - self.root.id() - } -} diff --git a/crates/polymodel-ldraw-core/src/util.rs b/crates/polymodel-ldraw-core/src/util.rs deleted file mode 100644 index 593bd74..0000000 --- a/crates/polymodel-ldraw-core/src/util.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} diff --git a/crates/polymodel-renderer-ledger/Cargo.toml b/crates/polymodel-renderer-ledger/Cargo.toml deleted file mode 100644 index 1af7996..0000000 --- a/crates/polymodel-renderer-ledger/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "polymodel-renderer-ledger" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Dependency-light hard admission ledger for renderer allocations." - -[dependencies] diff --git a/crates/polymodel-renderer-ledger/README.md b/crates/polymodel-renderer-ledger/README.md deleted file mode 100644 index 41909c5..0000000 --- a/crates/polymodel-renderer-ledger/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Renderer reservation ledger - -`polymodel-renderer-ledger` is the dependency-light admission boundary for renderer allocations. It has no browser, protocol, parser, or GPU dependencies and compiles for native and `wasm32-unknown-unknown` targets. - -## Caller contract - -Every real allocation follows this sequence: - -1. Construct a checked estimate with the canonical formula helpers (`canvas_charge`, `gpu_buffer_charge`, `wasm_charge`, and `texture_charge`). -2. Reserve the appropriate `ResourceClass` with an immutable `ReservationOwner` containing `worker_id`, `load_epoch`, `scene_generation`, and `scope_kind`. -3. Only after every class in a composite reservation commits, call the browser/parser/GPU operation. -4. Release every reservation on success, error, cancellation, stale-epoch cleanup, context loss, and disposal. Composite handles roll back in reverse order. - -The owner identity is a snapshot. A new load uses a new epoch and a scene replacement uses a new generation. Preview uses a separate ephemeral scope and never borrows the interactive viewer bucket. WASM pages are charged to the shared worker bucket, so multiple loads cannot create multiple worker entitlements. - -## WASM growth strategy record - -The worker currently uses `talc::WasmGrowAndClaim` as its custom global allocator. This is the allocator-integration attempt: talc owns the `memory.grow` path and claims pages through its growth policy. PM-88 admission is placed before every representative worker/parser/scene allocation entry point and reserves aligned 64 KiB pages in the worker-wide `WasmPages` class. - -The imported-memory/maximum-memory attempt was evaluated against the current `wasm-bindgen --target web` worker build. The generated worker has an exported runtime memory and no repository-owned imported `WebAssembly.Memory` construction or linker maximum hook; adding an imported memory would change the generated loader ABI and is not supported by the current target. The worker therefore uses hard pre-allocation admission at representative entry points rather than treating allocator statistics as enforcement. - -The instrumentation/runtime attempt was evaluated against the current Rust nightly + wasm-bindgen toolchain. There is no stable supported hook to rewrite or intercept the generated `memory.grow` instruction from this crate without changing the runtime/toolchain. The equivalent enforcement is the explicit aligned `WasmPages` reservation in the load, scene, preview, and renderer initialization composites; rejected representative work returns before its allocation entry point, and accepted work cannot exceed the worker ledger ceiling. - -These constraints cover only an unobservable low-level grow instruction. They do not waive admission before fetch/parser/scene/GPU/canvas/preview/resize operations. - -## Allocation audit - -| Boundary | Reservation | Operation after commit | -| --- | --- | --- | -| Canvas transfer/context | Display canvas + baseline GPU | `get_context_with_context_options`, three-d context | -| Fetch/parser | Network, retained, decoded source, worker pages | `load_mesh_with_companions` | -| Scene replacement | Scene CPU, GPU buffers, GPU textures | `build_scene` / `three_d::Model::new` | -| Preview | Ephemeral Display/GPU/scene/WASM composite | `OffscreenCanvas::new`, WebGL, render target, PNG | -| Resize | New Display canvas charge overlaps old | `set_width`, `set_height`, state swap | diff --git a/crates/polymodel-renderer-ledger/src/lib.rs b/crates/polymodel-renderer-ledger/src/lib.rs deleted file mode 100644 index 74ed495..0000000 --- a/crates/polymodel-renderer-ledger/src/lib.rs +++ /dev/null @@ -1,1176 +0,0 @@ -#![forbid(unsafe_code)] -#![doc = r#" -The renderer reservation ledger is the admission boundary for every allocation owned by -an interactive load or an ephemeral preview. It is intentionally `std`-only: browser, -parser, protocol, and GPU adapters must calculate a checked estimate, reserve it here, -perform the real operation, and release the returned handle on every completion path. - -A reservation is bound to an immutable [`ReservationOwner`] snapshot. Callers must pass -`worker_id`, `load_epoch`, `scene_generation`, and [`ScopeKind`] explicitly; stale work -cannot silently charge the current load. Composite callers use [`CompositeReservation`], -which reserves classes in the documented policy order and rolls back in reverse order. -"#] - -use std::collections::HashMap; -use std::fmt; -use std::sync::{Arc, Mutex, MutexGuard}; - -pub const BUDGET_SCHEMA_VERSION: u32 = 1; -pub const MIB: u64 = 1024 * 1024; -pub const GIB: u64 = 1024 * MIB; -/// Maximum decoded allocation admitted for one texture, independent of the load budget. -pub const MAX_TEXTURE_BYTES: u64 = 64 * MIB; - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -#[repr(u8)] -pub enum ResourceClass { - NetworkInFlight = 0, - RetainedFetchedBytes = 1, - DecodedSourceData = 2, - SemanticArenas = 3, - SceneCpu = 4, - DecodedTexturePixels = 5, - WasmPages = 6, - GpuBuffers = 7, - GpuTextures = 8, - CanvasBackbuffers = 9, -} - -impl ResourceClass { - pub const ALL: [Self; 10] = [ - Self::NetworkInFlight, - Self::RetainedFetchedBytes, - Self::DecodedSourceData, - Self::SemanticArenas, - Self::SceneCpu, - Self::DecodedTexturePixels, - Self::WasmPages, - Self::GpuBuffers, - Self::GpuTextures, - Self::CanvasBackbuffers, - ]; - - pub const fn domain(self) -> MemoryDomain { - match self { - Self::NetworkInFlight | Self::RetainedFetchedBytes => MemoryDomain::Network, - Self::DecodedSourceData - | Self::SemanticArenas - | Self::SceneCpu - | Self::DecodedTexturePixels - | Self::WasmPages => MemoryDomain::WasmCpu, - Self::GpuBuffers | Self::GpuTextures => MemoryDomain::Gpu, - Self::CanvasBackbuffers => MemoryDomain::Display, - } - } - - pub const fn owner_kind(self) -> OwnerKind { - match self { - Self::WasmPages => OwnerKind::Worker, - Self::CanvasBackbuffers => OwnerKind::Viewer, - Self::NetworkInFlight - | Self::RetainedFetchedBytes - | Self::DecodedSourceData - | Self::SemanticArenas - | Self::SceneCpu - | Self::DecodedTexturePixels - | Self::GpuBuffers - | Self::GpuTextures => OwnerKind::LoadViewer, - } - } - - pub const fn per_owner_limit(self) -> u64 { - match self { - Self::NetworkInFlight | Self::RetainedFetchedBytes => 128 * MIB, - Self::DecodedSourceData | Self::SemanticArenas => 256 * MIB, - Self::SceneCpu => 384 * MIB, - Self::DecodedTexturePixels => 256 * MIB, - Self::WasmPages => 768 * MIB, - Self::GpuBuffers | Self::GpuTextures => 512 * MIB, - Self::CanvasBackbuffers => 128 * MIB, - } - } - - pub const fn global_limit(self) -> u64 { - match self { - Self::NetworkInFlight | Self::RetainedFetchedBytes => 256 * MIB, - Self::DecodedSourceData | Self::SemanticArenas => 384 * MIB, - Self::SceneCpu => 512 * MIB, - Self::DecodedTexturePixels => 384 * MIB, - Self::WasmPages => GIB, - Self::GpuBuffers | Self::GpuTextures => 768 * MIB, - Self::CanvasBackbuffers => 256 * MIB, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum MemoryDomain { - Network, - WasmCpu, - Gpu, - Display, -} - -impl MemoryDomain { - pub const ALL: [Self; 4] = [Self::Network, Self::WasmCpu, Self::Gpu, Self::Display]; - - pub const fn per_owner_limit(self) -> u64 { - match self { - Self::Network => 192 * MIB, - Self::WasmCpu => 768 * MIB, - Self::Gpu => 768 * MIB, - Self::Display => 192 * MIB, - } - } - - pub const fn global_limit(self) -> u64 { - match self { - Self::Network => 320 * MIB, - Self::WasmCpu | Self::Gpu => GIB, - Self::Display => 320 * MIB, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum ScopeKind { - Interactive, - Preview, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct ReservationOwner { - pub worker_id: u64, - pub load_epoch: u64, - pub scene_generation: u64, - pub scope_kind: ScopeKind, -} - -impl ReservationOwner { - pub const fn interactive(worker_id: u64, load_epoch: u64, scene_generation: u64) -> Self { - Self { - worker_id, - load_epoch, - scene_generation, - scope_kind: ScopeKind::Interactive, - } - } - - pub const fn preview(worker_id: u64, load_epoch: u64, scene_generation: u64) -> Self { - Self { - worker_id, - load_epoch, - scene_generation, - scope_kind: ScopeKind::Preview, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum OwnerBucket { - Worker(u64), - Viewer(ReservationOwner), - LoadViewer(ReservationOwner), - Texture(ReservationOwner, u64), -} - -impl OwnerBucket { - pub const fn for_class(class: ResourceClass, owner: ReservationOwner, texture_id: u64) -> Self { - match class.owner_kind() { - OwnerKind::Worker => Self::Worker(owner.worker_id), - OwnerKind::Viewer => Self::Viewer(owner), - OwnerKind::LoadViewer => { - if matches!(class, ResourceClass::DecodedTexturePixels) { - Self::Texture(owner, texture_id) - } else { - Self::LoadViewer(owner) - } - } - } - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum OwnerKind { - Worker, - Viewer, - LoadViewer, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ClassLimit { - pub class: ResourceClass, - pub per_owner: u64, - pub global: u64, -} - -impl ResourceClass { - pub const fn limit(self) -> ClassLimit { - ClassLimit { - class: self, - per_owner: self.per_owner_limit(), - global: self.global_limit(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct BudgetLimits { - pub schema_version: u32, - pub classes: [ClassLimit; 10], - pub domains: [(MemoryDomain, u64, u64); 4], - pub unified_per_owner: u64, - pub unified_global: u64, -} - -impl Default for BudgetLimits { - fn default() -> Self { - Self { - schema_version: BUDGET_SCHEMA_VERSION, - classes: ResourceClass::ALL.map(ResourceClass::limit), - domains: MemoryDomain::ALL.map(|d| (d, d.per_owner_limit(), d.global_limit())), - unified_per_owner: 1_280 * MIB, - unified_global: 1_792 * MIB, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ChargeError { - ZeroDivisor, - ZeroDimension, - InvalidFormat, - TooManyMipLevels, - Overflow, -} - -impl fmt::Display for ChargeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::ZeroDivisor => "division/alignment must be nonzero", - Self::ZeroDimension => "dimensions and counts must be nonzero", - Self::InvalidFormat => "texture format metadata is invalid", - Self::TooManyMipLevels => "mip count exceeds representable texture levels", - Self::Overflow => "checked charge arithmetic overflowed", - }) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum RejectionReason { - Class(Vec), - Domain(Vec), - Owner(Vec), - GlobalClass(Vec), - GlobalDomain(Vec), - Texture(Vec), - UnifiedOwner, - UnifiedGlobal, - /// Multiple ceilings were exceeded. Fields are reported in stable policy order. - Ceilings { - class: Vec, - domain: Vec, - owner: Vec, - global_class: Vec, - global_domain: Vec, - texture: Vec, - unified_owner: bool, - unified_global: bool, - }, - ParentCapacity, - StaleIdentity, - Invalid(ChargeError), - Overflow, -} - -impl fmt::Display for RejectionReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "reservation rejected: {self:?}") - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdmissionError { - pub bytes: u64, - pub reason: RejectionReason, -} - -impl fmt::Display for AdmissionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} bytes: {}", self.bytes, self.reason) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TextureFormat { - pub block_width: u64, - pub block_height: u64, - pub bytes_per_block: u64, -} - -impl TextureFormat { - pub const RGBA8: Self = Self { - block_width: 1, - block_height: 1, - bytes_per_block: 4, - }; - pub const BC1: Self = Self { - block_width: 4, - block_height: 4, - bytes_per_block: 8, - }; - pub const BC3: Self = Self { - block_width: 4, - block_height: 4, - bytes_per_block: 16, - }; -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TextureChargeInput { - pub format: TextureFormat, - pub width: u64, - pub height: u64, - pub mip_count: u32, - pub layers: u32, - pub faces: u32, - pub samples: u32, -} - -pub fn ceil_div(n: u64, d: u64) -> Result { - if d == 0 { - return Err(ChargeError::ZeroDivisor); - } - let extra = u64::from(!n.is_multiple_of(d)); - n.checked_div(d) - .and_then(|v| v.checked_add(extra)) - .ok_or(ChargeError::Overflow) -} - -pub fn align_up(n: u64, alignment: u64) -> Result { - ceil_div(n, alignment)? - .checked_mul(alignment) - .ok_or(ChargeError::Overflow) -} - -pub fn wasm_charge(requested_capacity: u64) -> Result { - align_up(requested_capacity, 65_536) -} - -pub fn gpu_buffer_charge(requested_capacity: u64) -> Result { - let aligned = align_up(requested_capacity, 256)?; - aligned - .checked_add(ceil_div(aligned, 8)?) - .ok_or(ChargeError::Overflow) -} - -pub fn canvas_charge( - width: u64, - height: u64, - attachments: u64, - samples: u64, -) -> Result { - if width == 0 || height == 0 || attachments == 0 || samples == 0 { - return Err(ChargeError::ZeroDimension); - } - let pixels = width.checked_mul(height).ok_or(ChargeError::Overflow)?; - let attachment = pixels - .checked_mul(4) - .and_then(|v| v.checked_mul(attachments)) - .and_then(|v| v.checked_mul(samples)) - .ok_or(ChargeError::Overflow)?; - let presentation = pixels.checked_mul(4).ok_or(ChargeError::Overflow)?; - let base = attachment - .checked_add(presentation) - .ok_or(ChargeError::Overflow)?; - base.checked_add(ceil_div(base, 4)?) - .ok_or(ChargeError::Overflow) -} - -pub fn texture_base_bytes(input: TextureChargeInput) -> Result { - let fmt = input.format; - if input.width == 0 - || input.height == 0 - || input.mip_count == 0 - || input.layers == 0 - || input.faces == 0 - || input.samples == 0 - { - return Err(ChargeError::ZeroDimension); - } - if fmt.block_width == 0 || fmt.block_height == 0 || fmt.bytes_per_block == 0 { - return Err(ChargeError::InvalidFormat); - } - if input.mip_count > 64 { - return Err(ChargeError::TooManyMipLevels); - } - let mut total = 0u64; - for level in 0..input.mip_count { - let w = input.width.checked_shr(level).unwrap_or(0).max(1); - let h = input.height.checked_shr(level).unwrap_or(0).max(1); - let blocks_w = ceil_div(w, fmt.block_width)?; - let blocks_h = ceil_div(h, fmt.block_height)?; - let level_bytes = blocks_w - .checked_mul(blocks_h) - .and_then(|v| v.checked_mul(fmt.bytes_per_block)) - .and_then(|v| v.checked_mul(u64::from(input.layers))) - .and_then(|v| v.checked_mul(u64::from(input.faces))) - .and_then(|v| v.checked_mul(u64::from(input.samples))) - .ok_or(ChargeError::Overflow)?; - total = total - .checked_add(level_bytes) - .ok_or(ChargeError::Overflow)?; - } - Ok(total) -} - -pub fn texture_charge(input: TextureChargeInput) -> Result { - let base = texture_base_bytes(input)?; - base.checked_add(ceil_div(base, 4)?) - .ok_or(ChargeError::Overflow) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LedgerSnapshot { - pub schema_version: u32, - pub class_totals: [u64; 10], - pub domain_totals: [u64; 4], - pub owner_totals: HashMap, - pub owner_class_totals: HashMap<(ReservationOwner, ResourceClass), u64>, - pub owner_domain_totals: HashMap<(ReservationOwner, MemoryDomain), u64>, - pub unified_totals: HashMap, - pub global_unified: u64, - pub reservations: usize, -} - -#[derive(Clone)] -struct Entry { - owner: ReservationOwner, - class: ResourceClass, - bucket: OwnerBucket, - bytes: u64, - child_bytes: u64, - parent: Option, -} - -#[derive(Clone)] -struct State { - limits: BudgetLimits, - class_totals: [u64; 10], - domain_totals: [u64; 4], - owner_totals: HashMap, - owner_class_totals: HashMap<(ReservationOwner, ResourceClass), u64>, - owner_domain_totals: HashMap<(ReservationOwner, MemoryDomain), u64>, - unified_totals: HashMap, - global_unified: u64, - reservations: HashMap, - retired: std::collections::BTreeSet, - next_id: u64, -} - -struct Inner { - state: Mutex, -} - -#[derive(Clone)] -pub struct ReservationLedger { - inner: Arc, -} - -pub struct Reservation { - inner: Arc, - id: u64, -} - -impl fmt::Debug for Reservation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Reservation").field("id", &self.id).finish() - } -} - -impl Reservation { - pub fn id(&self) -> u64 { - self.id - } - pub fn release(mut self) { - self.release_inner(); - self.id = 0; - } - fn release_inner(&mut self) { - if self.id == 0 { - return; - } - let mut state = lock(&self.inner.state); - if let Some(entry) = state.reservations.remove(&self.id) { - if let Some(parent_id) = entry.parent - && let Some(parent) = state.reservations.get_mut(&parent_id) - { - parent.child_bytes = parent.child_bytes.saturating_sub(entry.bytes); - } - state.class_totals[entry.class as usize] -= entry.bytes; - state.domain_totals[entry.class.domain() as usize] -= entry.bytes; - subtract(&mut state.owner_totals, entry.bucket, entry.bytes); - subtract( - &mut state.owner_class_totals, - (entry.owner, entry.class), - entry.bytes, - ); - subtract( - &mut state.owner_domain_totals, - (entry.owner, entry.class.domain()), - entry.bytes, - ); - subtract(&mut state.unified_totals, entry.owner, entry.bytes); - state.global_unified -= entry.bytes; - } - self.id = 0; - } -} - -impl Drop for Reservation { - fn drop(&mut self) { - self.release_inner(); - } -} - -#[allow(clippy::result_large_err)] -impl ReservationLedger { - pub fn new() -> Self { - Self { - inner: Arc::new(Inner { - state: Mutex::new(State { - limits: BudgetLimits::default(), - class_totals: [0; 10], - domain_totals: [0; 4], - owner_totals: HashMap::new(), - owner_class_totals: HashMap::new(), - owner_domain_totals: HashMap::new(), - unified_totals: HashMap::new(), - global_unified: 0, - reservations: HashMap::new(), - retired: Default::default(), - next_id: 1, - }), - }), - } - } - - pub fn with_limits(limits: BudgetLimits) -> Self { - let ledger = Self::new(); - lock(&ledger.inner.state).limits = limits; - ledger - } - - pub fn limits(&self) -> BudgetLimits { - lock(&self.inner.state).limits - } - pub fn snapshot(&self) -> LedgerSnapshot { - let s = lock(&self.inner.state); - LedgerSnapshot { - schema_version: s.limits.schema_version, - class_totals: s.class_totals, - domain_totals: s.domain_totals, - owner_totals: s.owner_totals.clone(), - owner_class_totals: s.owner_class_totals.clone(), - owner_domain_totals: s.owner_domain_totals.clone(), - unified_totals: s.unified_totals.clone(), - global_unified: s.global_unified, - reservations: s.reservations.len(), - } - } - - pub fn retire(&self, owner: ReservationOwner) { - lock(&self.inner.state).retired.insert(owner); - } - - pub fn reserve( - &self, - owner: ReservationOwner, - class: ResourceClass, - bytes: u64, - ) -> Result { - self.reserve_with_texture(owner, class, bytes, 0) - } - - pub fn reserve_texture( - &self, - owner: ReservationOwner, - bytes: u64, - texture_id: u64, - ) -> Result { - self.reserve_with_texture( - owner, - ResourceClass::DecodedTexturePixels, - bytes, - texture_id, - ) - } - - fn reserve_with_texture( - &self, - owner: ReservationOwner, - class: ResourceClass, - bytes: u64, - texture_id: u64, - ) -> Result { - let mut state = lock(&self.inner.state); - if state.retired.contains(&owner) { - return Err(AdmissionError { - bytes, - reason: RejectionReason::StaleIdentity, - }); - } - let bucket = OwnerBucket::for_class(class, owner, texture_id); - check_policy(&state, owner, class, bucket, bytes)?; - let id = state.next_id; - state.next_id = state.next_id.checked_add(1).ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - apply(&mut state, owner, class, bucket, bytes); - state.reservations.insert( - id, - Entry { - owner, - class, - bucket, - bytes, - child_bytes: 0, - parent: None, - }, - ); - Ok(Reservation { - inner: self.inner.clone(), - id, - }) - } - - pub fn reserve_child( - &self, - parent: &Reservation, - owner: ReservationOwner, - class: ResourceClass, - bytes: u64, - ) -> Result { - let mut state = lock(&self.inner.state); - let Some(parent_entry) = state.reservations.get(&parent.id) else { - return Err(AdmissionError { - bytes, - reason: RejectionReason::ParentCapacity, - }); - }; - if parent_entry.owner != owner { - return Err(AdmissionError { - bytes, - reason: RejectionReason::ParentCapacity, - }); - } - let used = parent_entry.child_bytes; - if bytes > parent_entry.bytes.saturating_sub(used) { - return Err(AdmissionError { - bytes, - reason: RejectionReason::ParentCapacity, - }); - } - let bucket = OwnerBucket::for_class(class, owner, 0); - check_policy(&state, owner, class, bucket, bytes)?; - let parent_entry = state - .reservations - .get_mut(&parent.id) - .expect("parent reservation remains present"); - parent_entry.child_bytes = - parent_entry - .child_bytes - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - let id = state.next_id; - state.next_id = state.next_id.checked_add(1).ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - apply(&mut state, owner, class, bucket, bytes); - state.reservations.insert( - id, - Entry { - owner, - class, - bucket, - bytes, - child_bytes: 0, - parent: Some(parent.id), - }, - ); - Ok(Reservation { - inner: self.inner.clone(), - id, - }) - } - - pub fn transfer( - &self, - reservation: &Reservation, - destination: ReservationOwner, - ) -> Result<(), AdmissionError> { - let mut state = lock(&self.inner.state); - let Some(entry) = state.reservations.get(&reservation.id).cloned() else { - return Err(AdmissionError { - bytes: 0, - reason: RejectionReason::StaleIdentity, - }); - }; - if state.retired.contains(&destination) { - return Err(AdmissionError { - bytes: entry.bytes, - reason: RejectionReason::StaleIdentity, - }); - } - let new_bucket = OwnerBucket::for_class(entry.class, destination, 0); - remove_apply(&mut state, &entry); - let result = check_policy(&state, destination, entry.class, new_bucket, entry.bytes); - if let Err(error) = result { - apply( - &mut state, - entry.owner, - entry.class, - entry.bucket, - entry.bytes, - ); - return Err(error); - } - apply( - &mut state, - destination, - entry.class, - new_bucket, - entry.bytes, - ); - if let Some(current) = state.reservations.get_mut(&reservation.id) { - current.owner = destination; - current.bucket = new_bucket; - } - Ok(()) - } - - pub fn composite( - &self, - owner: ReservationOwner, - requests: &[(ResourceClass, u64)], - ) -> Result { - let mut ordered = requests.to_vec(); - ordered.sort_by_key(|(class, _)| *class as u8); - let mut reservations = Vec::with_capacity(ordered.len()); - for (class, bytes) in ordered { - match self.reserve(owner, class, bytes) { - Ok(r) => reservations.push(r), - Err(error) => { - while let Some(r) = reservations.pop() { - r.release(); - } - return Err(error); - } - } - } - Ok(CompositeReservation { reservations }) - } -} - -impl Default for ReservationLedger { - fn default() -> Self { - Self::new() - } -} - -pub struct CompositeReservation { - reservations: Vec, -} -impl CompositeReservation { - pub fn len(&self) -> usize { - self.reservations.len() - } - pub fn is_empty(&self) -> bool { - self.reservations.is_empty() - } - pub fn release(mut self) { - while let Some(r) = self.reservations.pop() { - r.release(); - } - } -} -impl Drop for CompositeReservation { - fn drop(&mut self) { - while let Some(r) = self.reservations.pop() { - r.release(); - } - } -} - -fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { - mutex - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} -fn subtract(map: &mut HashMap, key: K, bytes: u64) { - if let Some(value) = map.get_mut(&key) { - *value -= bytes; - if *value == 0 { - map.remove(&key); - } - } -} -fn apply( - state: &mut State, - owner: ReservationOwner, - class: ResourceClass, - bucket: OwnerBucket, - bytes: u64, -) { - state.class_totals[class as usize] += bytes; - state.domain_totals[class.domain() as usize] += bytes; - *state.owner_totals.entry(bucket).or_default() += bytes; - *state.owner_class_totals.entry((owner, class)).or_default() += bytes; - *state - .owner_domain_totals - .entry((owner, class.domain())) - .or_default() += bytes; - *state.unified_totals.entry(owner).or_default() += bytes; - state.global_unified += bytes; -} -fn remove_apply(state: &mut State, entry: &Entry) { - state.class_totals[entry.class as usize] -= entry.bytes; - state.domain_totals[entry.class.domain() as usize] -= entry.bytes; - subtract(&mut state.owner_totals, entry.bucket, entry.bytes); - subtract( - &mut state.owner_class_totals, - (entry.owner, entry.class), - entry.bytes, - ); - subtract( - &mut state.owner_domain_totals, - (entry.owner, entry.class.domain()), - entry.bytes, - ); - subtract(&mut state.unified_totals, entry.owner, entry.bytes); - state.global_unified -= entry.bytes; -} - -#[allow(clippy::result_large_err)] -fn check_policy( - state: &State, - owner: ReservationOwner, - class: ResourceClass, - bucket: OwnerBucket, - bytes: u64, -) -> Result<(), AdmissionError> { - let domain = class.domain(); - let prospective_class = state.class_totals[class as usize] - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - let prospective_domain = state.domain_totals[domain as usize] - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - let prospective_owner = state - .owner_totals - .get(&bucket) - .copied() - .unwrap_or(0) - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - let prospective_owner_class = state - .owner_class_totals - .get(&(owner, class)) - .copied() - .unwrap_or(0) - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - let prospective_owner_domain = state - .owner_domain_totals - .get(&(owner, domain)) - .copied() - .unwrap_or(0) - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - let prospective_unified = state - .unified_totals - .get(&owner) - .copied() - .unwrap_or(0) - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - let prospective_global = state - .global_unified - .checked_add(bytes) - .ok_or(AdmissionError { - bytes, - reason: RejectionReason::Overflow, - })?; - - let class_breach = prospective_owner_class > class.per_owner_limit(); - let domain_breach = prospective_owner_domain > domain.per_owner_limit(); - let owner_breach = prospective_owner > class.per_owner_limit(); - let global_class_breach = prospective_class > class.global_limit(); - let global_domain_breach = prospective_domain > domain.global_limit(); - let texture_breach = matches!(class, ResourceClass::DecodedTexturePixels) - && prospective_owner > MAX_TEXTURE_BYTES; - let unified_owner_breach = prospective_unified > state.limits.unified_per_owner; - let unified_global_breach = prospective_global > state.limits.unified_global; - - let classes = class_breach - .then_some(class) - .into_iter() - .collect::>(); - let domains = domain_breach - .then_some(domain) - .into_iter() - .collect::>(); - let owners = owner_breach - .then_some(bucket) - .into_iter() - .collect::>(); - let global_classes = global_class_breach - .then_some(class) - .into_iter() - .collect::>(); - let global_domains = global_domain_breach - .then_some(domain) - .into_iter() - .collect::>(); - let textures = texture_breach - .then_some(bucket) - .into_iter() - .collect::>(); - let breach_count = classes.len() - + domains.len() - + owners.len() - + global_classes.len() - + global_domains.len() - + textures.len() - + usize::from(unified_owner_breach) - + usize::from(unified_global_breach); - if breach_count == 0 { - return Ok(()); - } - - let reason = if breach_count == 1 { - if class_breach { - RejectionReason::Class(classes) - } else if domain_breach { - RejectionReason::Domain(domains) - } else if owner_breach { - RejectionReason::Owner(owners) - } else if global_class_breach { - RejectionReason::GlobalClass(global_classes) - } else if global_domain_breach { - RejectionReason::GlobalDomain(global_domains) - } else if texture_breach { - RejectionReason::Texture(textures) - } else if unified_owner_breach { - RejectionReason::UnifiedOwner - } else { - RejectionReason::UnifiedGlobal - } - } else { - RejectionReason::Ceilings { - class: classes, - domain: domains, - owner: owners, - global_class: global_classes, - global_domain: global_domains, - texture: textures, - unified_owner: unified_owner_breach, - unified_global: unified_global_breach, - } - }; - Err(AdmissionError { bytes, reason }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_classes_have_frozen_limits_and_domains() { - assert_eq!(ResourceClass::ALL.len(), 10); - for class in ResourceClass::ALL { - assert!(class.per_owner_limit() > 0 && class.global_limit() >= class.per_owner_limit()); - } - assert_eq!(MemoryDomain::ALL.len(), 4); - assert_eq!( - ResourceClass::DecodedTexturePixels.per_owner_limit(), - 256 * MIB - ); - assert_eq!(ResourceClass::WasmPages.per_owner_limit(), 768 * MIB); - } - - #[test] - fn checked_formulas_and_texture_vectors() { - assert_eq!(ceil_div(10, 3).unwrap(), 4); - assert_eq!(align_up(257, 256).unwrap(), 512); - assert_eq!(wasm_charge(1).unwrap(), 65_536); - assert_eq!(gpu_buffer_charge(257).unwrap(), 576); - assert_eq!(canvas_charge(1, 1, 1, 1).unwrap(), 10); - let one = TextureChargeInput { - format: TextureFormat::RGBA8, - width: 1, - height: 1, - mip_count: 1, - layers: 1, - faces: 1, - samples: 1, - }; - assert_eq!(texture_base_bytes(one).unwrap(), 4); - assert_eq!(texture_charge(one).unwrap(), 5); - let compressed = TextureChargeInput { - format: TextureFormat::BC1, - width: 5, - height: 5, - mip_count: 1, - layers: 1, - faces: 1, - samples: 1, - }; - assert_eq!(texture_base_bytes(compressed).unwrap(), 32); - assert_eq!( - texture_base_bytes(TextureChargeInput { - width: 1, - height: 1, - mip_count: 1, - layers: 1, - faces: 1, - samples: 1, - format: TextureFormat { - block_width: 0, - block_height: 1, - bytes_per_block: 4 - } - }), - Err(ChargeError::InvalidFormat) - ); - } - - #[test] - fn rejection_is_atomic_and_drop_is_idempotent() { - let ledger = ReservationLedger::new(); - let owner = ReservationOwner::interactive(1, 1, 1); - let reservation = ledger - .reserve(owner, ResourceClass::NetworkInFlight, 128 * MIB) - .unwrap(); - let before = ledger.snapshot(); - let rejected = ledger - .reserve(owner, ResourceClass::NetworkInFlight, 1) - .unwrap_err(); - assert_eq!( - rejected.reason, - RejectionReason::Ceilings { - class: vec![ResourceClass::NetworkInFlight], - domain: vec![], - owner: vec![OwnerBucket::LoadViewer(owner)], - global_class: vec![], - global_domain: vec![], - texture: vec![], - unified_owner: false, - unified_global: false, - } - ); - assert_eq!(before, ledger.snapshot()); - reservation.release(); - assert_eq!(ledger.snapshot().global_unified, 0); - } - - #[test] - fn parent_child_and_composite_roll_back() { - let ledger = ReservationLedger::new(); - let owner = ReservationOwner::interactive(1, 2, 3); - let parent = ledger - .reserve(owner, ResourceClass::SemanticArenas, 10) - .unwrap(); - let child = ledger - .reserve_child(&parent, owner, ResourceClass::SceneCpu, 10) - .unwrap(); - assert!( - ledger - .reserve_child(&parent, owner, ResourceClass::SceneCpu, 1) - .is_err() - ); - child.release(); - let before = ledger.snapshot(); - assert!( - ledger - .composite( - owner, - &[ - (ResourceClass::NetworkInFlight, 1), - (ResourceClass::RetainedFetchedBytes, 256 * MIB) - ] - ) - .is_err() - ); - assert_eq!(before, ledger.snapshot()); - } - - #[test] - fn stale_identity_and_transfer_are_atomic() { - let ledger = ReservationLedger::new(); - let source = ReservationOwner::interactive(7, 1, 1); - let destination = ReservationOwner::interactive(7, 2, 1); - let reservation = ledger.reserve(source, ResourceClass::SceneCpu, 10).unwrap(); - ledger.retire(destination); - let before = ledger.snapshot(); - assert!(ledger.transfer(&reservation, destination).is_err()); - assert_eq!(before, ledger.snapshot()); - ledger - .transfer(&reservation, ReservationOwner::interactive(8, 1, 1)) - .unwrap(); - } - - #[test] - fn concurrent_admission_never_oversubscribes() { - let ledger = Arc::new(ReservationLedger::new()); - let mut threads = Vec::new(); - for i in 0..8 { - let l = ledger.clone(); - threads.push(std::thread::spawn(move || { - l.reserve( - ReservationOwner::interactive(i, 1, 1), - ResourceClass::NetworkInFlight, - 40 * MIB, - ) - .ok() - })); - } - let reservations: Vec<_> = threads - .into_iter() - .filter_map(|t| t.join().unwrap()) - .collect(); - assert_eq!(reservations.len(), 6); - assert_eq!( - ledger.snapshot().class_totals[ResourceClass::NetworkInFlight as usize], - 240 * MIB - ); - drop(reservations); - assert_eq!( - ledger.snapshot().class_totals[ResourceClass::NetworkInFlight as usize], - 0 - ); - } -} diff --git a/crates/polymodel-renderer-protocol/src/scene.rs b/crates/polymodel-renderer-protocol/src/scene.rs index 2f9c0be..ba849aa 100644 --- a/crates/polymodel-renderer-protocol/src/scene.rs +++ b/crates/polymodel-renderer-protocol/src/scene.rs @@ -116,6 +116,9 @@ fn opaque_alpha() -> u8 { pub struct SceneMaterial { pub id: MaterialId, pub colour: u32, + /// Resolved LDraw colour RGB; omitted by older scene payloads. + #[serde(default)] + pub colour_rgb: Option<[u8; 3]>, /// Resolved LDraw colour alpha; omitted by older scene payloads. #[serde(default = "opaque_alpha")] pub alpha: u8, diff --git a/crates/polymodel-renderer-worker/Cargo.toml b/crates/polymodel-renderer-worker/Cargo.toml index 9ee723b..5168102 100644 --- a/crates/polymodel-renderer-worker/Cargo.toml +++ b/crates/polymodel-renderer-worker/Cargo.toml @@ -19,7 +19,6 @@ default = [] [dependencies] polymodel-ldraw-core = { path = "../polymodel-ldraw-core" } polymodel-mesh = { path = "../polymodel-mesh" } -polymodel-renderer-ledger = { path = "../polymodel-renderer-ledger" } polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" } smol_str = { workspace = true } postcard = { workspace = true } diff --git a/crates/polymodel-renderer-worker/src/admission.rs b/crates/polymodel-renderer-worker/src/admission.rs index 377f7b4..b2e541c 100644 --- a/crates/polymodel-renderer-worker/src/admission.rs +++ b/crates/polymodel-renderer-worker/src/admission.rs @@ -1,9 +1,4 @@ use polymodel_mesh::contract::ModelMesh; -use polymodel_renderer_ledger::{ - CompositeReservation, Reservation, ReservationLedger, ReservationOwner, ResourceClass, - TextureChargeInput, TextureFormat, gpu_buffer_charge, texture_base_bytes, texture_charge, - wasm_charge, -}; use three_d_asset::{Geometry, Texture2D, TextureData}; const GPU_BUFFER_BYTES_PER_VERTEX: u64 = 48; diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index 3674690..fcd4599 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -27,10 +27,10 @@ use three_d::{ #[cfg(not(target_arch = "wasm32"))] use three_d_asset::ProjectionType; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(target_arch = "wasm32"))] s use winit::{ event::{ - ElementState, Event, KeyboardInput, ModifiersState, MouseButton, VirtualKeyCode, + ElementState, Event, KeyboardInput, Modifiers sState, MouseButton, VirtualKeyCode, WindowEvent, }, event_loop::{ControlFlow, EventLoopBuilder}, diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index b72e766..f02c174 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -1,8 +1,10 @@ //! Shared renderer scene boundary for the WASM worker and native preview. -use polymodel_ldraw_core::{LdMeshSemantic, ModelSummary, OwnedParseResult}; #[cfg(not(target_arch = "wasm32"))] -use polymodel_ldraw_core::{Materialization, NormalizedPath, RootId}; +use polymodel_ldraw_core::Materialization; +use polymodel_ldraw_core::{ + LdMeshSemantic, ModelSummary, NormalizedPath, OwnedParseResult, RootId, +}; use polymodel_renderer_protocol::scene::{ GeometryId, MaterialId, NodeId, OccurrenceId, ProvenanceId, Scene, SceneBounds, SceneGeometry, SceneId, SceneMaterial, SceneOccurrence, ScenePrimitiveRange, SceneStats, @@ -162,6 +164,22 @@ pub fn adapt_ldraw_parse(result: &OwnedParseResult) -> Result( + resources: &'a [ModelResource], + root: RootId, + path: &NormalizedPath, +) -> Option<&'a ModelResource> { + let root_matches = |label: &str| match root { + RootId::CurrentMpd => label == "mpd", + RootId::UploadedManifest => label == "manifest", + RootId::UploadedLdraw => matches!(label, "models" | "parts" | "p" | "external"), + RootId::OfficialLibrary => matches!(label, "official-parts" | "official-p"), + }; + resources + .iter() + .find(|resource| root_matches(&resource.root) && resource.path == path.as_str()) +} + pub fn adapt_ldraw_parse_with_resources( result: &OwnedParseResult, resources: Option<&[polymodel_renderer_protocol::ModelResource]>, @@ -245,38 +263,45 @@ fn adapt_ldraw_assembly( } let mut geometries = Vec::with_capacity(models.len()); let mut provenance = Vec::with_capacity(models.len()); - let mut texture_ids = HashMap::::new(); + let mut texture_ids = HashMap::<(RootId, String, String), TextureId>::new(); let mut textures = Vec::::new(); - let mut texture_for = |reference: &str, role: &str| -> Result { - let key = format!("{role}:{reference}"); - if let Some(id) = texture_ids.get(&key) { - return Ok(*id); - } - let resource = resources - .and_then(|resources| { - resources - .iter() - .find(|resource| resource.path == reference || resource.key == reference) - }) - .ok_or("TEXMAP texture is not present in the verified resource bundle")?; - resource - .validate() - .map_err(|_| "TEXMAP texture resource failed verification")?; - let id = TextureId((textures.len() + 1) as u64); - textures.push(SceneTexture { - id, - provenance: ProvenanceId(1), - uri: reference.into(), - resource_key: Some(resource.key.as_str().into()), - sha256: Some(resource.sha256.clone()), - byte_length: Some(resource.byte_length), - role: Some(role.into()), - }); - texture_ids.insert(key, id); - Ok(id) - }; - let mut material_ids = - HashMap::<(u32, u8, Option, Option), MaterialId>::new(); + let mut texture_for = + |root: RootId, reference: &str, role: &str| -> Result { + let path = NormalizedPath::new(reference) + .map_err(|_| "TEXMAP texture reference is not a valid canonical path")?; + let key = (root, path.as_str().to_owned(), role.to_owned()); + if let Some(id) = texture_ids.get(&key) { + return Ok(*id); + } + let resource = resources + .and_then(|resources| texture_resource_for(resources, root, &path)) + .ok_or("TEXMAP texture is not present in the referencing part's canonical root")?; + resource + .validate() + .map_err(|_| "TEXMAP texture resource failed verification")?; + let id = TextureId((textures.len() + 1) as u64); + textures.push(SceneTexture { + id, + provenance: ProvenanceId(1), + uri: reference.into(), + resource_key: Some(resource.key.as_str().into()), + sha256: Some(resource.sha256.clone()), + byte_length: Some(resource.byte_length), + role: Some(role.into()), + }); + texture_ids.insert(key, id); + Ok(id) + }; + let mut material_ids = HashMap::< + ( + u32, + Option<[u8; 3]>, + u8, + Option, + Option, + ), + MaterialId, + >::new(); let mut materials = Vec::new(); for model in models { let provenance_id = ProvenanceId((provenance.len() + 1) as u64); @@ -449,39 +474,40 @@ fn adapt_ldraw_assembly( }) .unwrap_or(0) }; - let colour_alpha = |colour: u32| { - parsed - .and_then(|parsed| { - parsed - .result - .colours - .get(polymodel_ldraw_core::ColourCode(colour)) - }) - .and_then(|colour| colour.data.alpha) - .unwrap_or(u8::MAX) + let colour_data = |colour: u32| { + parsed.and_then(|parsed| { + parsed + .result + .colours + .get(polymodel_ldraw_core::ColourCode(colour)) + .map(|colour| colour.data) + }) }; - let mut material_for = |colour: u32, texmap: Option<&SceneTexmapAssociation>| { + let mut material_for = |root: RootId, colour: u32, texmap: Option<&SceneTexmapAssociation>| { let textures = &mut texture_for; - let alpha = colour_alpha(colour); + let colour_data = colour_data(colour); + let colour_rgb = colour_data.map(|data| [data.r, data.g, data.b]); + let alpha = colour_data.and_then(|data| data.alpha).unwrap_or(u8::MAX); let albedo = resources .is_some() .then_some(texmap) .flatten() - .map(|texmap| textures(texmap.pngfile.as_str(), "albedo")) + .map(|texmap| textures(root, texmap.pngfile.as_str(), "albedo")) .transpose()?; let roughness = resources .is_some() .then_some(texmap) .flatten() .and_then(|texmap| texmap.glossmap.as_deref()) - .map(|reference| textures(reference, "metallic_roughness")) + .map(|reference| textures(root, reference, "metallic_roughness")) .transpose()?; - let key = (colour, alpha, albedo, roughness); + let key = (colour, colour_rgb, alpha, albedo, roughness); Ok::(*material_ids.entry(key).or_insert_with(|| { let id = MaterialId((materials.len() + 1) as u64); materials.push(SceneMaterial { id, colour, + colour_rgb, alpha, edge_colour: None, finish: None, @@ -561,7 +587,11 @@ fn adapt_ldraw_assembly( direct_colour: u32::from(polymodel_ldraw_core::DEFAULT_COLOUR), effective_colour, primitive_colour: primitive.colour, - material: Some(material_for(effective_colour, primitive.texmap.as_ref())?), + material: Some(material_for( + models[0].cache_key.resolved_root, + effective_colour, + primitive.texmap.as_ref(), + )?), bfc_state: smol_str::format_smolstr!("{:?}", primitive.bfc_state), bfc_cull: primitive.bfc_cull, bfc_ccw: primitive.bfc_ccw, @@ -650,7 +680,11 @@ fn adapt_ldraw_assembly( direct_colour: occurrence.direct_colour.map(u32::from).unwrap_or(16), effective_colour, primitive_colour: primitive.colour, - material: Some(material_for(effective_colour, primitive.texmap.as_ref())?), + material: Some(material_for( + target_key.resolved_root, + effective_colour, + primitive.texmap.as_ref(), + )?), bfc_state: smol_str::format_smolstr!("{:?}", primitive.bfc_state), bfc_cull: primitive.bfc_cull, bfc_ccw: primitive.bfc_ccw, @@ -1503,13 +1537,8 @@ fn precision_aware_clip_planes( near = near.max(far / CAMERA_DEPTH_RATIO_LIMIT); let near_f32 = finite_f32(near).map_err(|_| CameraDepthError::F32Conversion)?; let far_f32 = finite_f32(far).map_err(|_| CameraDepthError::F32Conversion)?; - let min_f32 = finite_f32(min_depth).map_err(|_| CameraDepthError::F32Conversion)?; let max_f32 = finite_f32(max_depth).map_err(|_| CameraDepthError::F32Conversion)?; - if near_f32 <= 0.0 - || far_f32 <= near_f32 - || (min_depth > 0.0 && near_f32 > min_f32) - || far_f32 < max_f32 - { + if near_f32 <= 0.0 || far_f32 <= near_f32 || far_f32 < max_f32 { return Err(CameraDepthError::F32Conversion); } Ok((near_f32, far_f32)) @@ -3452,28 +3481,15 @@ impl NativeLights { } } -fn ldraw_colour(code: u32) -> Srgba { - ldraw_colour_with_policy(code, LdrawLightingMaterialPolicy::ldraw(), u8::MAX) -} - -fn ldraw_colour_with_policy(code: u32, policy: LdrawLightingMaterialPolicy, alpha: u8) -> Srgba { - let [r, g, b] = match code { - 0 => policy.black_albedo, - 1 => [0, 85, 191], - 2 => [37, 122, 62], - 4 => [201, 26, 9], - 7 => [160, 165, 169], - 8 => [109, 110, 108], - 9 => [180, 210, 227], - 10 => [75, 151, 74], - 14 => [242, 205, 55], - 15 => [255, 255, 255], - 16 => [160, 165, 169], - 19 => [228, 173, 200], - 25 => [254, 138, 24], - 71 => [100, 105, 104], - 72 => [108, 110, 104], - _ => [160, 165, 169], +fn ldraw_colour(rgb: [u8; 3]) -> Srgba { + ldraw_colour_with_policy(rgb, LdrawLightingMaterialPolicy::ldraw(), u8::MAX) +} + +fn ldraw_colour_with_policy(rgb: [u8; 3], policy: LdrawLightingMaterialPolicy, alpha: u8) -> Srgba { + let [r, g, b] = if rgb == [0, 0, 0] { + policy.black_albedo + } else { + rgb }; Srgba::new(r, g, b, alpha) } @@ -4578,12 +4594,12 @@ pub fn build_conditional_edge_topology_for_scene( .collect()) } -fn compact_segment_material(colour: u32, selected: bool) -> CompactSegmentMaterial { +fn compact_segment_material(_colour: u32, selected: bool) -> CompactSegmentMaterial { CompactSegmentMaterial { color: if selected { Srgba::new(116, 180, 255, 200) } else { - ldraw_colour(colour) + ldraw_colour([160, 165, 169]) }, render_states: if selected { RenderStates { @@ -4716,12 +4732,12 @@ fn build_scene_model_from_batches( .map(|instance| scene_transform_to_mat4(instance.transform).map_err(str::to_owned)) .collect::, _>>()?; let scene_material = index.material(scene, batch.material)?; - let colour = scene_material.colour; + let colour_rgb = scene_material.colour_rgb.unwrap_or([160, 165, 169]); let policy = LdrawLightingMaterialPolicy::ldraw(); let alpha = scene_material.alpha; let mut cpu_material = CpuMaterial { - albedo: ldraw_colour_with_policy(colour, policy, alpha), - roughness: if colour == 0 { + albedo: ldraw_colour_with_policy(colour_rgb, policy, alpha), + roughness: if colour_rgb == [0, 0, 0] { policy.black_roughness } else { policy.roughness diff --git a/crates/polymodel-renderer-worker/src/tests.rs b/crates/polymodel-renderer-worker/src/tests.rs index 1d0ee49..8cdc626 100644 --- a/crates/polymodel-renderer-worker/src/tests.rs +++ b/crates/polymodel-renderer-worker/src/tests.rs @@ -35,6 +35,50 @@ fn glossmap_decode_uses_alpha_not_rgb() { assert_eq!(pixels.packed_rgb, vec![[0, 255, 0], [0, 0, 0]]); } +#[test] +fn texture_resource_selection_uses_canonical_root_and_path() { + let path = polymodel_ldraw_core::NormalizedPath::new("textures/decal.png").unwrap(); + let resource = |key: &str, root: &str, marker: u8| ModelResource { + key: key.into(), + path: path.as_str().into(), + root: root.into(), + byte_length: 1, + sha256: vec![marker; 32], + mime_type: "image/png".into(), + target_cids: Vec::new(), + bytes: vec![marker], + }; + let resources = [ + resource("official-texture", "official-parts", 1), + resource("mpd-texture", "mpd", 2), + ]; + + assert_eq!( + texture_resource_for(&resources, polymodel_ldraw_core::RootId::CurrentMpd, &path) + .unwrap() + .key, + "mpd-texture" + ); + assert_eq!( + texture_resource_for( + &resources, + polymodel_ldraw_core::RootId::OfficialLibrary, + &path, + ) + .unwrap() + .key, + "official-texture" + ); + assert!( + texture_resource_for( + &resources, + polymodel_ldraw_core::RootId::UploadedManifest, + &path, + ) + .is_none() + ); +} + #[test] fn texmap_planar_uvs_follow_basis_and_clip_to_texture() { let uvs = texmap_uvs( @@ -619,6 +663,7 @@ fn picker_scene() -> Scene { materials: vec![SceneMaterial { id: MaterialId(1), colour: 4, + colour_rgb: Some([201, 26, 9]), alpha: 255, edge_colour: None, finish: None, @@ -660,6 +705,7 @@ fn scene_material_alpha_selects_transparent_color_only_material_state() { let material = SceneMaterial { id: MaterialId(1), colour: 4, + colour_rgb: Some([201, 26, 9]), alpha: 128, edge_colour: None, finish: None, @@ -1798,12 +1844,16 @@ fn ldraw_black_material_is_readable_but_remains_black() { assert_eq!(policy.metallic, 0.0); assert!(policy.black_roughness > policy.roughness); assert_eq!( - ldraw_colour_with_policy(0, policy, u8::MAX), + ldraw_colour_with_policy([0, 0, 0], policy, u8::MAX), three_d::Srgba::new_opaque(28, 38, 40) ); assert_eq!( - ldraw_colour(0), - ldraw_colour_with_policy(0, policy, u8::MAX) + ldraw_colour([0, 0, 0]), + ldraw_colour_with_policy([0, 0, 0], policy, u8::MAX) + ); + assert_eq!( + ldraw_colour_with_policy([12, 34, 56], policy, 128), + three_d::Srgba::new(12, 34, 56, 128) ); } diff --git a/findings-J.jsonl b/findings-J.jsonl deleted file mode 100644 index 215a132..0000000 --- a/findings-J.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"review":"J4-implementation-reviewer-A","epic":"PM-86","task":"PM-83","target_commit":"66015acd","parent_commit":"bc35ccf9","verdict":"CLEAN","summary":"Architecture/cross-task/replace-vs-edit review of J4 commit 'harden generated preview lifecycle'. Single registry authority preserved. Metadata-only draft persistence with verified server-side re-acquisition. No second authority, resolver, route (beyond app-internal /app/staged), or state machine. J2 fixes intact. No browser/renderer duplication. Sibling task boundaries (A/C/F/G/H/I) respected. No critical/high/medium findings."} -{"id":"J4-A-01","severity":"low","category":"architecture","subject":"Preview generation Map replaces monotonic counter with content-addressed identity","evidence":["src/publish.rs:186 preview_generations: Signal>","src/publish.rs:2585-2620 preview_identity hashes draft_scope+model_id+primary_id+sorted resource metadata","src/publish.rs:2286-2291 preview_is_current checks identity match before installing"],"assessment":"The old preview_generation: Signal was a simple monotonic counter that could be satisfied by stale bytes if the counter happened to align. The new HashMap uses a SHA-256 identity over resource_id, source_name, byte_length, and digest for all staged resources — a content-addressed staleness check. This is a well-justified hardening that prevents stale-pixel races. The approach is proportionate, not over-engineered: it reuses sha2 already in the dependency tree and does not introduce a new state machine or lifecycle suite.","recommendation":"None — correct and proportionate."} -{"id":"J4-A-02","severity":"low","category":"replace-vs-edit","subject":"DraftStagedResource correctly replaced: bytes removed, verified metadata retained","evidence":["Old (removed): DraftStagedResource { name, mime_type, bytes, digest: None, byte_length }","New: DraftStagedResource { resource_id, upload_id, source_name, mime_type, digest (32-byte required), byte_length, format }","src/publish/draft.rs:104-119","src/publish.rs:2574-2582 staged_resource() requires 32-byte digest"],"assessment":"The old struct serialized geometry bytes into draft JSON with an unverified digest (None). The new struct is metadata-only: bytes are reacquired through the authenticated /app/staged boundary and re-verified client-side via acquire_preview_resources(). This is a correct replace decision — the ad-hoc bytes-in-draft pattern was a security/integrity gap. The new AcquiredDraftResource ephemeral pairing preserves verified bytes only in memory.","recommendation":"None — correct replacement."} -{"id":"J4-A-03","severity":"low","category":"ownership-boundaries","subject":"New /app/staged/{upload_id} route is app-internal, authenticated, owner-scoped","evidence":["src/appview/mod.rs:159-162: .route(\"/app/staged/{upload_id}\", axum::routing::get(drafts::get_staged_resource))","src/appview/drafts.rs:255-293: ExtractOAuthSession + authenticated_did + load_upload_file (owner-scoped) + validate_file_manifest + fetch_staged_bytes","src/publish/draft_client.rs:153-160: wasm client gated to target_family=wasm"],"assessment":"The new route follows the existing /app/drafts* and /app/images pattern: app-internal, not federated, OAuth-session-authenticated, owner-scoped. It validates the file manifest before streaming, enforces MAX_PREVIEW_RESOURCE_BYTES, and uses the existing CID/SHA-256 verification path. No new XRPC, no new lexicon, no security boundary expansion. Consistent with the plan's 'server-side drafts are app-internal' convention.","recommendation":"None — correct ownership."} -{"id":"J4-A-04","severity":"low","category":"cross-task-overlap","subject":"fetch_staged_bytes shares pattern with fetch_verified_bytes but is not problematic duplication","evidence":["src/appview/ldraw.rs:506-586 fetch_verified_bytes (C/G compound load from published records)","src/appview/ldraw.rs:588-664 fetch_staged_bytes (draft preview re-acquisition from staging metadata)"],"assessment":"Both functions resolve DID doc → PDS endpoint → GetBlob → stream chunks → CID verify → SHA-256 verify. However, their inputs differ fundamentally: fetch_verified_bytes takes a ResolvedResource (from published ATProto records), while fetch_staged_bytes takes raw blob_cids/length/digest (from upload_staging). Factoring out the shared streaming core would over-abstract given the different ownership semantics. The ~20 lines of shared streaming logic are small enough to be acceptable.","recommendation":"None — acceptable design choice."} -{"id":"J4-A-05","severity":"low","category":"cross-task-overlap","subject":"Sibling task boundaries A/C/F/G/H/I respected — no new resolver, state machine, or second authority","evidence":["No new LoadReducer, epoch counter, or readiness route (A preserved)","No new routes beyond /app/staged for C/G existing compound/load bundle paths (C/G preserved)","MeshFormat enum order unchanged, LengthUnit::LdrawUnit still appended after Unknown (F preserved)","plan_draft_ldraw_bundle uses ModelLoadBundle + polymodel_ldraw_core::extract_references (G/H consumed, not reimplemented)","No TEXMAP/Studio PE assumptions imported (I seam preserved)","thing_detail.rs unchanged in J4 — already registry-backed from prior J commits"],"assessment":"J4 is a focused hardening commit that replaces the browser-local bytes-in-draft pattern with server-verified re-acquisition. It does not alter the registry, lexicons, generated API, thing_detail dispatch, viewer bridge, download paths, or compound load infrastructure. The only new server surface is /app/staged, which is app-internal draft infrastructure.","recommendation":"None — boundaries clean."} diff --git a/justfile b/justfile index b981d8e..e898ed0 100644 --- a/justfile +++ b/justfile @@ -294,7 +294,7 @@ _require-nix: _ldraw-differential: _require-nix cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- differential -_test-all: _require-nix _fix _check _lint _test _test-server _test-renderer-ledger _test-renderer _test-ldraw-core _ldraw-wasm-check _test-renderer-native _ldraw-corpus _ldraw-fuzz-smoke _ldraw-differential +_test-all: _require-nix _fix _check _lint _test _test-server _test-renderer-ledger _test-renderer _test-ldraw-core _ldraw-wasm-check _test-renderer-native _e2e: cd e2e && npm test diff --git a/result b/result deleted file mode 120000 index 9d900f4..0000000 --- a/result +++ /dev/null @@ -1 +0,0 @@ -/nix/store/894d50i4r1k9a49pw80ri1jy1jdh5ccw-ldraw-oracle-0fe78dcee22982f26cceced1b483340faa4d04e9 \ No newline at end of file -- knotmirror 0.0.1