From 44a6ed4464b95861c3655af7d36202b4e8237c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 04:06:12 +0000 Subject: [PATCH 01/23] =?UTF-8?q?SCALE-SEAM=20(95)=20=E2=80=94=20element?= =?UTF-8?q?=20state:=20two=20read/write=20pairs,=20and=20why=20the=20matri?= =?UTF-8?q?x=20loses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `lodSummary`/`setLod` and `phasing`/`setPhase` out of `client.ts` into `api/model.ts`. They answer one question — *what state are the model's elements in, and set it?* — and `client.ts` goes 727 -> 711, 76 methods above the STAYING banner. The grouping is derived, not asserted: - identical return shape `{ total, ed, prop, counts: Record<...> }`; - both writers are `(pid, guids, , publish) -> editIfc`; - both readers are consumed by `viewer/tools/modelStatePanels.ts` (251, 316); - both writers sit unwired and *adjacent* on `clientCallers.test.ts`'s UNCALLED allowlist; - `model.ts` already owned `/model/lod/census`, `/lod/handover-readiness` and `/lod/assessment`, while the base distribution `/projects/{pid}/lod` was left behind in `client.ts` — `lodSummary` was a sibling separated from its family. `authoring_matrix.py` DISAGREES and is recorded as the losing vote rather than elided. It files `set_lod` under `data` and `set_phase` under `lifecycle`, because it categorises by the IFC output each recipe writes — an LOD stage tag against `Massing_Phasing.Status`. Different property sets, same question, which is (89)'s "storage is a HOW" trap. This is the first slice where the matrix has been wrong after being right three running. It also MEETS (94)'s objection rather than overriding it: that slice declined `setPhase` because taking the writer alone would have stranded `phasing()`, the reader/writer split (87) had to undo. Both halves move together here. The four names are added to `surface.test.ts` because its floor is a slack ratchet (788 actual vs 751 floor) — the count alone would not notice a loss — and because the UNCALLED allowlist is about call sites, not the surface. Verified: tsc 0, lint 0, `vitest run src/api` 27 files / 119 tests, build 0, `test_file_sizes.py` / `test_claude_md_gates.py` / `test_roadmap_status.py` / ruff all 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 41 ++++++++++++++++++++++++++++ apps/web/src/api/client.ts | 26 ++++-------------- apps/web/src/api/model.ts | 47 ++++++++++++++++++++++++++++++++ apps/web/src/api/surface.test.ts | 4 +++ docs/roadmap.md | 18 +++++++++++- services/api/test_file_sizes.py | 2 +- 6 files changed, 115 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6497e356..6f2b4bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1185,6 +1185,47 @@ Reader and unwired writer on one panel — still the reason not to separate them Pin 731 → 727. **80 above the banner, still no map.** +Thirty-fourth follow-on on the same version: **SCALE-SEAM (95)** — *element state, and a matrix +disagreement worth stating rather than eliding*. + +Four methods out of `client.ts` (`727 → 711`) into the existing `apps/web/src/api/model.ts`: the +`lodSummary`/`setLod` and `phasing`/`setPhase` pairs. No new mixin. **What they answer: what state +are the model's elements in, and set it.** + +### The shape is the argument + +Both readers return `{ total, ed, prop, counts: Record<…> }` — same three fields, differing only +in the second field's name and the key union. Both writers are `(pid, guids, , publish) → +editIfc`. Both readers are consumed by `apps/web/src/viewer/tools/modelStatePanels.ts`. Both writers +are unwired and sit **adjacent** on `clientCallers.test.ts`'s UNCALLED allowlist. + +**And `lodSummary` was a sibling separated from its own family.** `model.ts` already held +`/model/lod/census`, `/lod/handover-readiness` and `/lod/assessment`, while the base distribution +`/projects/{pid}/lod` stayed behind in `client.ts`. + +### `authoring_matrix.py` disagrees, and why it loses + +The matrix files `set_lod` under `data` and `set_phase` under `lifecycle`. That is a real +disagreement with this grouping and is recorded rather than elided. It loses because **the matrix +categorises by the IFC output each recipe writes** — an LOD stage tag against +`Massing_Phasing.Status`. Different property sets, same question. + +*That is (89)'s "storage is a HOW" trap: "they write different psets" has the same shape as "they are +all module records", and neither is a question.* The matrix has been a good witness three slices +running; this is the first time it has been the losing vote, which is the reason to say so plainly +rather than quietly not mentioning it. + +### It meets (94)'s objection rather than overriding it + +(94) declined `setPhase` because taking the writer would have stranded `phasing()` in `client.ts` — +the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. + +*Not contiguous: the pairs sat at 271–279 and 293–301 with `ensureContexts` and `queryElements` +between them. (88) recorded that as the strongest case for grouping by what methods answer, since no +prefix or positional split would ever find them together.* + +Pin 727 → 711. **76 above the banner, still no map.** + diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 2e0c8b51..2ff7c7cc 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -267,16 +267,6 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes connections: { a: string; a_class: string; b: string; b_class: string; description: string | null }[] }>( `/projects/${pid}/element-connections`); } - /** W11 F0: element LOD-stage distribution (100/200/300/350/400/500/unset). */ - lodSummary(pid: string) { - return this.json<{ total: number; staged: number; prop: string; - counts: Record<"100" | "200" | "300" | "350" | "400" | "500" | "UNSET", number> }>( - `/projects/${pid}/lod`); - } - /** W11 F0: tag elements with a LOD stage (element maturity 100→500). */ - setLod(pid: string, guids: string[], stage: "100" | "200" | "300" | "350" | "400" | "500", publish = true) { - return this.editIfc(pid, "set_lod", { guids, stage }, publish); - } /** W11 F0: establish the view-keyed representation contexts (Model+Plan; Body/Axis/Box/Annotation/ * FootPrint) the drawing pipeline needs. Idempotent. */ ensureContexts(pid: string, publish = false) { @@ -289,16 +279,6 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes elements: { guid: string; name: string; ifc_class: string; storey: string | null }[] }>( `/projects/${pid}/query?q=${encodeURIComponent(q)}&limit=${limit}`); } - /** W10-8: element phase/status distribution (new · existing · demolish · temporary · unset). */ - phasing(pid: string) { - return this.json<{ total: number; phased: number; prop: string; - counts: Record<"NEW" | "EXISTING" | "DEMOLISH" | "TEMPORARY" | "UNSET", number> }>( - `/projects/${pid}/phasing`); - } - /** W10-8: tag elements with a construction phase (new | existing | demolish | temporary). */ - setPhase(pid: string, guids: string[], phase: "new" | "existing" | "demolish" | "temporary", publish = true) { - return this.editIfc(pid, "set_phase", { guids, phase }, publish); - } /** Speckle interoperability bridge status (open-source, self-hostable; off unless configured). */ speckleStatus() { return this.json<{ enabled: boolean; connected: boolean; server: string | null; server_name?: string; @@ -644,7 +624,7 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // through (87) worked through, and they are recorded here as DECIDED rather than pending. // // **THIS IS NOT THE END OF SCALE-SEAM, and a previous version of this banner implied it was.** - // 80 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, + // 76 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, // `energyModel`, `propmapPlan`, `camReconciliation` and the rest. They were never inside the // CX-1 banner, so no map has ever covered them. The UNFILED map described the TAIL of this file, // not the file. @@ -699,6 +679,10 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // route-prefix split could not see. `setPhase` did NOT come despite completing the matrix's // `lifecycle` category: its read half `phasing()` is still here. Reasoning in `model.ts`'s header. // + // (95) took element state — `lodSummary`/`setLod` and `phasing`/`setPhase` — to `model.ts`, + // leaving 76. Both pairs move together, which is what (94)'s objection to taking `setPhase` alone + // required. Reasoning in `model.ts`'s header. + // // the four that stay enumOptions, searchAll, attachmentUrl, templates enumOptions(pid: string) { return this.json>>(`/projects/${pid}/enum-options`); diff --git a/apps/web/src/api/model.ts b/apps/web/src/api/model.ts index e3a45f4b..365bce0a 100644 --- a/apps/web/src/api/model.ts +++ b/apps/web/src/api/model.ts @@ -42,6 +42,33 @@ * field-verified dimension with its variance against design. Both are ㊻'s own question — *is this * element installed as designed?* — and neither was named in its header. * + * **SCALE-SEAM (95) adds element STATE — *what state are the model's elements in, and set it?*** + * Two read/write pairs: `lodSummary`/`setLod` (element maturity 100→500) and `phasing`/`setPhase` + * (new / existing / demolish / temporary). + * + * *`lodSummary` was a sibling separated from its own family:* this file already held + * `/model/lod/census`, `/lod/handover-readiness` and `/lod/assessment`, while the BASE distribution + * `/projects/{pid}/lod` stayed behind. The two pairs come together because they are the same + * question in the same shape — both return `{ total, ed, prop, counts }`, both writers are + * `(pid, guids, , publish) → editIfc`, both readers are consumed by + * `viewer/tools/modelStatePanels.ts`, and both writers sit unwired and adjacent on + * `api/clientCallers.test.ts`'s UNCALLED allowlist. + * + * **`authoring_matrix.py` disagrees, and it is worth saying why it loses.** It files `set_lod` under + * `data` and `set_phase` under `lifecycle`, because it categorises by the IFC OUTPUT each recipe + * writes — an LOD stage tag against `Massing_Phasing.Status`. Different property sets, same + * question. **That is (89)'s "storage is a HOW" trap**: "they write different psets" has the same + * shape as "they are all module records", and neither is a question. + * + * *This also resolves what (94) deliberately left open.* That slice declined `setPhase` because + * taking the writer would have stranded `phasing()` in `client.ts` — the reader/writer split (87) + * had to undo. Both halves move together here, so nothing is separated and the objection is met + * rather than overridden. + * + * *Not contiguous — the pairs sat at 271–279 and 293–301 with `ensureContexts` and `queryElements` + * between them. (88) recorded that as the strongest case for grouping by what methods ANSWER, since + * no prefix or positional split would ever find them together.* + * * **Two known members, not necessarily all of them.** This slice does not claim the question is now * complete: it claims these two answer it and were missed. *The first draft said "finishes that * question", which asserts a completeness nothing here established — and the PR description @@ -771,5 +798,25 @@ export function withModel>(Base: TBase) { verifyAsbuilt(pid: string, guids: string[], opts: { verified_by?: string; method?: string; note?: string } = {}, publish = true) { return this.editIfc(pid, "verify_asbuilt", { guids, ...opts }, publish); } + /** W11 F0: element LOD-stage distribution (100/200/300/350/400/500/unset). */ + lodSummary(pid: string) { + return this.json<{ total: number; staged: number; prop: string; + counts: Record<"100" | "200" | "300" | "350" | "400" | "500" | "UNSET", number> }>( + `/projects/${pid}/lod`); + } + /** W11 F0: tag elements with a LOD stage (element maturity 100→500). */ + setLod(pid: string, guids: string[], stage: "100" | "200" | "300" | "350" | "400" | "500", publish = true) { + return this.editIfc(pid, "set_lod", { guids, stage }, publish); + } + /** W10-8: element phase/status distribution (new · existing · demolish · temporary · unset). */ + phasing(pid: string) { + return this.json<{ total: number; phased: number; prop: string; + counts: Record<"NEW" | "EXISTING" | "DEMOLISH" | "TEMPORARY" | "UNSET", number> }>( + `/projects/${pid}/phasing`); + } + /** W10-8: tag elements with a construction phase (new | existing | demolish | temporary). */ + setPhase(pid: string, guids: string[], phase: "new" | "existing" | "demolish" | "temporary", publish = true) { + return this.editIfc(pid, "set_phase", { guids, phase }, publish); + } }; } diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index 7ba8f4f5..3fb23cde 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -239,7 +239,11 @@ describe("the API client's public surface", () => { // and the count would not notice. "connectMep", "addMepFitting", // (94) two members of ㊻'s verification question that it did not name -> model.ts. + // (95) element state -> model.ts: two read/write pairs, both writers UNWIRED and on the + // UNCALLED allowlist. Named here because that allowlist is about CALL SITES and this list is + // about the SURFACE — an unwired writer can still vanish in an extraction unnoticed. "verifyAsbuilt", "recordAsbuiltDimension", + "lodSummary", "setLod", "phasing", "setPhase", ]) { expect(surface.has(k), `${k}() vanished — a call site is now broken`).toBe(true); } diff --git a/docs/roadmap.md b/docs/roadmap.md index 1899cd68..6a691c0e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3135,7 +3135,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(94) took forty-six of those 126 — 80 remain, and there is STILL no map.** A new + **(88)–(95) took fifty of those 126 — 76 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, @@ -3243,6 +3243,22 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped also unmovable then, for the `editIfc` typing reason (92) diagnosed — **three** slices now found to have left work behind for that single cause. + **(95) took element state to the same file** — `lodSummary`/`setLod` and `phasing`/`setPhase`, two + read/write pairs answering *what state are the model's elements in, and set it.* The shape is the + argument: both readers return `{ total, ed, prop, counts }`, both writers are + `(pid, guids, , publish) → editIfc`, both readers feed + `apps/web/src/viewer/tools/modelStatePanels.ts`, and both writers sit unwired and adjacent on the + UNCALLED allowlist. *`lodSummary` was a sibling separated from its own family:* `model.ts` already + held `/model/lod/census`, `/lod/handover-readiness` and `/lod/assessment`. + + **`authoring_matrix.py` argued against it, for the first time in four slices.** It files `set_lod` + under `data` and `set_phase` under `lifecycle` — because it categorises by the IFC OUTPUT a recipe + writes, an LOD stage tag against `Massing_Phasing.Status`. Different psets, same question, and + **that is (89)'s "storage is a HOW" trap** wearing new clothes. Recorded as a losing vote rather + than left unmentioned, because a source that has been right three times is exactly the one whose + disagreement is tempting to skip. *It also meets (94)'s objection instead of overriding it: both + halves of the phasing pair move together, so nothing is stranded.* + *This entry said "(94) finished SCALE-SEAM ㊻" until review, and the distinction against (93) is the point rather than a nicety. **⑲ NAMED the two it left** — `mep.ts`'s header says `connectMep` and `addMepFitting` by name — so taking both completes an explicit list, and "finished" is diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index 567232c5..fd3c0870 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 727, # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 711, # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From 121c09560fd524b3f582c50a7ee367ebfa066e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 04:35:20 +0000 Subject: [PATCH 02/23] =?UTF-8?q?SCALE-SEAM=20(96)=20=E2=80=94=20the=20as-?= =?UTF-8?q?built=20question's=20aggregate=20reader,=20and=20a=20witness=20?= =?UTF-8?q?that=20actually=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `lod500`, `setManufacturerInfo` and `attachOmDocument` out of `client.ts` into `api/model.ts`, rejoining `verifyAsbuilt` and `recordAsbuiltDimension` which (94) moved. `client.ts` goes 711 -> 698, 73 methods above the STAYING banner. TWO BOUNDING WITNESSES, derived independently, agreeing: - `openAsBuiltPanel` in `viewer/tools/modelStatePanels.ts` calls exactly five API methods — read off that function's brace closure rather than grepped for, so it is the closure of a scope and not a sample. Two of the five were already in `model.ts`. - The reader's own response type names its writer set field by field: `verified`/`by_method` from `verify_asbuilt`; `with_dimensions`/`dimensions_out_of_tolerance` from `record_asbuilt_dimension`; `with_manufacturer`/`with_serial` from `set_manufacturer_info`; `with_om_docs`/`om_documents` from `attach_om_document`. The backend route says it in prose: "Stamp elements with the `verify_asbuilt` recipe." A reader whose response type enumerates its writers is a DERIVED population. After eight slices of sampling, that is the first grouping witness here that bounds a set instead of illustrating one. WHAT CORROBORATES IS NOT WHAT BOUNDS. `test_lod500.py` reaches exactly three recipes — `attach_om_document`, `set_manufacturer_info`, `verify_asbuilt` — and OMITS `record_asbuilt_dimension`, which is unambiguously in this family. It agrees with the answer without being able to establish it. Three slices claimed "and no others" off a test file and were wrong all three times. NOT CLAIMED, and said in the header, the pin, the changelog and the roadmap rather than only in review: `attachDocument` stays in `client.ts`, takes a `purpose` parameter, and `asbuilt_summary` counts ANY purpose-tagged document reference — so "every writer of `with_om_docs` moves here" is false. The field map is of the recipes each field was designed around, not of everything that can set it. Two sources disagree and lose for the same reason: `attach_om_document` is a purpose-tagged wrapper of `detailing.attach_document`, and `authoring_matrix.py` files it and `set_manufacturer_info` under `data`. The first is a shared HELPER, the second a STORAGE bucket — the groupings (89) and (90) each had to reject. The matrix is the losing vote twice running after being right three times. Also fixes the extraction helper: it terminated its brace count on the method signature line, so a signature wrapping across two lines with no opening brace on the first read as a 3-line method. `attachOmDocument` is 6. Verified: tsc 0, lint 0, `vitest run src/api` 27 files / 119 tests, build 0, ruff (the CI command, from `services/api`) 0, and `test_file_sizes.py`, `test_claude_md_gates.py`, `test_roadmap_status.py`, `test_ruff_scope.py`, `test_reachable.py` all 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 52 +++++++++++++++++++++++++++++++ apps/web/src/api/client.ts | 23 +++----------- apps/web/src/api/model.ts | 53 ++++++++++++++++++++++++++++++++ apps/web/src/api/surface.test.ts | 3 ++ docs/roadmap.md | 41 +++++++++++++++++++++++- services/api/test_file_sizes.py | 2 +- 6 files changed, 154 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2b4bb9..8f04f623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1185,6 +1185,58 @@ Reader and unwired writer on one panel — still the reason not to separate them Pin 731 → 727. **80 above the banner, still no map.** +Thirty-fifth follow-on on the same version: **SCALE-SEAM (96)** — *the as-built question's +aggregate reader, and the first witness in this sequence that actually bounds a set*. + +Three methods out of `client.ts` (`711 → 698`) into the existing `apps/web/src/api/model.ts`: +`lod500`, `setManufacturerInfo` and `attachOmDocument`, rejoining `verifyAsbuilt` and +`recordAsbuiltDimension` which (94) moved. No new mixin. **What they answer: is this element +installed as designed, and is it documented for turnover.** + +### Two bounding witnesses, and they agree + +`openAsBuiltPanel` in `apps/web/src/viewer/tools/modelStatePanels.ts` calls exactly five API +methods — read off that function's brace closure rather than grepped for, so it is the closure of +a scope and not a sample. Two of the five were already in `model.ts`. + +Independently, the reader's own response type names its writer set field by field: +`verified`/`by_method` ← `verify_asbuilt`; `with_dimensions`/`dimensions_out_of_tolerance` ← +`record_asbuilt_dimension`; `with_manufacturer`/`with_serial` ← `set_manufacturer_info`; +`with_om_docs`/`om_documents` ← `attach_om_document`. The backend route says it in prose: +*"Stamp elements with the `verify_asbuilt` recipe."* + +**A reader whose response type enumerates its writers is a DERIVED population.** Eight slices in, +that is the first grouping witness here that bounds a set rather than sampling one. + +### What corroborates is not what bounds + +`services/api/test_lod500.py` reaches exactly three recipes — `attach_om_document`, +`set_manufacturer_info`, `verify_asbuilt` — and **omits `record_asbuilt_dimension`**, which is +unambiguously in this family. So it agrees with the answer without being able to establish it. A +witness that misses a known member cannot bound anything, however exactly its members match. +*Three slices claimed "and no others" off a test file and were wrong all three times; the fix is +not to read the test harder, it is to ask which question the test can answer.* + +### What is NOT claimed + +`attachDocument` stays in `client.ts`, takes a `purpose` parameter, and `asbuilt_summary` counts +**any** purpose-tagged document reference. So *"every writer of `with_om_docs` moves here"* is +**false**. The field map above is of the recipes each field was designed around, not of everything +that can set it. The claim that survives is the bounded one: the five methods `openAsBuiltPanel` +calls. + +### Two sources disagree, and lose for the same reason + +`attach_om_document` is implemented as a purpose-tagged wrapper of `detailing.attach_document`, and +`authoring_matrix.py` files it and `set_manufacturer_info` under `data`. The first is a shared +HELPER, the second a STORAGE bucket — the two groupings (89) and (90) each had to reject. The +matrix is now the losing vote twice running after being right three times; *a corroborating source +that keeps winning is the one that stops getting checked.* + +`client.ts` is 73 methods above the STAYING banner and 4 below. Pin lowered with the file; the +three names added to `surface.test.ts`, whose floor carries enough slack that the count alone would +not notice a loss. + Thirty-fourth follow-on on the same version: **SCALE-SEAM (95)** — *element state, and a matrix disagreement worth stating rather than eliding*. diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 2ff7c7cc..af04a9a8 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -143,12 +143,6 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes opts: { location?: string; identification?: string; description?: string; purpose?: string } = {}, publish = true) { return this.editIfc(pid, "attach_document", { guids, name, ...opts }, publish); } - /** G3: attach an O&M / warranty document reference (purpose-tagged) to elements — turnover paperwork - * bound to the physical asset; surfaced in the as-built summary's `with_om_docs`. */ - attachOmDocument(pid: string, guids: string[], name: string, - opts: { location?: string; kind?: "om" | "warranty" } = {}, publish = true) { - return this.editIfc(pid, "attach_om_document", { guids, name, ...opts }, publish); - } /** W11 B6: author a base plate + anchor bolts under a steel column (fabrication assembly). */ addBasePlate(pid: string, columnGuid: string, opts: { bolts?: number; width?: number; depth?: number } = {}, publish = true) { return this.editIfc(pid, "add_base_plate", { column_guid: columnGuid, ...opts }, publish); @@ -225,17 +219,6 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes return this.json<{ ok: boolean; errors: string[]; warnings: string[] }>( `/projects/${pid}/edit/precheck`, { method: "POST", body: JSON.stringify({ recipe, params }) }); } - /** W11 G1: LOD-500 readiness — share of the model field-verified as-built, by method. */ - lod500(pid: string) { - return this.json<{ total: number; verified: number; unverified: number; readiness_pct: number; - by_method: Record; methods: string[]; prop: string; - with_manufacturer: number; with_serial: number; with_dimensions: number; dimensions_out_of_tolerance: number; - with_om_docs?: number; om_documents?: string[] }>(`/projects/${pid}/lod500`); - } - /** W11 G3: stamp manufacturer / serial info (Pset_Manufacturer*) — the LOD-500 / O&M / turnover layer. */ - setManufacturerInfo(pid: string, guids: string[], opts: { manufacturer?: string; model_label?: string; production_year?: string; serial?: string; barcode?: string } = {}, publish = true) { - return this.editIfc(pid, "set_manufacturer_info", { guids, ...opts }, publish); - } /** W11 B6: author an IfcCurtainWall (mullions + transoms + glazing panels) along a line. */ addCurtainWall(pid: string, start: [number, number], end: [number, number], opts: { height?: number; cols?: number; rows?: number } = {}, publish = true) { @@ -624,7 +607,7 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // through (87) worked through, and they are recorded here as DECIDED rather than pending. // // **THIS IS NOT THE END OF SCALE-SEAM, and a previous version of this banner implied it was.** - // 76 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, + // 73 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, // `energyModel`, `propmapPlan`, `camReconciliation` and the rest. They were never inside the // CX-1 banner, so no map has ever covered them. The UNFILED map described the TAIL of this file, // not the file. @@ -683,6 +666,10 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // leaving 76. Both pairs move together, which is what (94)'s objection to taking `setPhase` alone // required. Reasoning in `model.ts`'s header. // + // (96) took as-built/turnover — `lod500`, `setManufacturerInfo`, `attachOmDocument` — to + // `model.ts`, leaving 73. `lod500` is the AGGREGATE READER of the question whose writers (94) + // already moved, and its response type names its own writer set field by field. + // // the four that stay enumOptions, searchAll, attachmentUrl, templates enumOptions(pid: string) { return this.json>>(`/projects/${pid}/enum-options`); diff --git a/apps/web/src/api/model.ts b/apps/web/src/api/model.ts index 365bce0a..5ded9222 100644 --- a/apps/web/src/api/model.ts +++ b/apps/web/src/api/model.ts @@ -100,6 +100,42 @@ * *A complete category is one vote, not a verdict — and "lifecycle" is a word (88) already caught * misleading once.* * + * **SCALE-SEAM (96) finishes the as-built/turnover question by bringing its AGGREGATE READER and + * the two writers that fed it:** `lod500` (LOD-500 readiness), `setManufacturerInfo` + * (`Pset_Manufacturer*`) and `attachOmDocument` (purpose-tagged turnover paperwork). + * + * *Two BOUNDING witnesses, derived rather than enumerated, and they agree.* First: + * `openAsBuiltPanel` in `viewer/tools/modelStatePanels.ts` calls exactly five API methods — read + * off that function's brace closure, not grepped for — and two of the five are `verifyAsbuilt` + * and `recordAsbuiltDimension`, which (94) already moved. Second: the reader's own response type + * names its writer set FIELD BY FIELD — `verified`/`by_method` from `verify_asbuilt`, + * `with_dimensions`/`dimensions_out_of_tolerance` from `record_asbuilt_dimension`, + * `with_manufacturer`/`with_serial` from `set_manufacturer_info`, `with_om_docs`/`om_documents` + * from `attach_om_document`. The backend route says so out loud: *"Stamp elements with the + * `verify_asbuilt` recipe."* **A reader whose response type enumerates its writers IS a derived + * population** — the first witness in this sequence that bounds a set instead of sampling it. + * + * *`test_lod500.py` CORROBORATES but does NOT bound, and the difference is the whole lesson of + * (91)–(93).* Derived: it reaches exactly three recipes — `attach_om_document`, + * `set_manufacturer_info`, `verify_asbuilt` — and OMITS `record_asbuilt_dimension`, which is + * unambiguously in this family. A witness that misses a known member cannot establish a boundary, + * however exactly its members match. **Three slices claimed "and no others" off a test file and + * were wrong all three times**; the fix is not to look harder at the test, it is to notice which + * question the test can answer. + * + * **What this slice does NOT claim.** `attachDocument` — still in `client.ts` — takes a `purpose` + * parameter, and `asbuilt_summary` counts ANY purpose-tagged document reference. So *"every writer + * of `with_om_docs` moves here"* is **false**, and the field-by-field map above is a map of the + * recipes each field was DESIGNED around, not of everything that can set it. The claim that + * survives is the bounded one: these are the five methods `openAsBuiltPanel` calls. + * + * **Two sources disagree, and both lose for the same reason.** `attach_om_document` is implemented + * as a purpose-tagged wrapper of `detailing.attach_document` (`edit.py`), and `authoring_matrix.py` + * files it and `set_manufacturer_info` under `data`. The first is a shared HELPER and the second a + * STORAGE bucket — the two groupings (89) and (90) each had to reject. *The matrix is now the + * losing vote twice running, after being right three times. A corroborating source that keeps + * winning is exactly the one that stops being checked.* + * * SCALE-SEAM ⓭ adds E57 scan ingest — *can we bring this scan in?* Status plus convert. * Admin audit/error stayed. * @@ -818,5 +854,22 @@ export function withModel>(Base: TBase) { setPhase(pid: string, guids: string[], phase: "new" | "existing" | "demolish" | "temporary", publish = true) { return this.editIfc(pid, "set_phase", { guids, phase }, publish); } + /** G3: attach an O&M / warranty document reference (purpose-tagged) to elements — turnover paperwork + * bound to the physical asset; surfaced in the as-built summary's `with_om_docs`. */ + attachOmDocument(pid: string, guids: string[], name: string, + opts: { location?: string; kind?: "om" | "warranty" } = {}, publish = true) { + return this.editIfc(pid, "attach_om_document", { guids, name, ...opts }, publish); + } + /** W11 G1: LOD-500 readiness — share of the model field-verified as-built, by method. */ + lod500(pid: string) { + return this.json<{ total: number; verified: number; unverified: number; readiness_pct: number; + by_method: Record; methods: string[]; prop: string; + with_manufacturer: number; with_serial: number; with_dimensions: number; dimensions_out_of_tolerance: number; + with_om_docs?: number; om_documents?: string[] }>(`/projects/${pid}/lod500`); + } + /** W11 G3: stamp manufacturer / serial info (Pset_Manufacturer*) — the LOD-500 / O&M / turnover layer. */ + setManufacturerInfo(pid: string, guids: string[], opts: { manufacturer?: string; model_label?: string; production_year?: string; serial?: string; barcode?: string } = {}, publish = true) { + return this.editIfc(pid, "set_manufacturer_info", { guids, ...opts }, publish); + } }; } diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index 3fb23cde..84300b38 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -244,6 +244,9 @@ describe("the API client's public surface", () => { // about the SURFACE — an unwired writer can still vanish in an extraction unnoticed. "verifyAsbuilt", "recordAsbuiltDimension", "lodSummary", "setLod", "phasing", "setPhase", + // (96) the as-built/turnover reader + its two remaining writers -> model.ts. `lod500` has a + // LIVE call site (openAsBuiltPanel), so losing it would break the panel, not just the surface. + "lod500", "setManufacturerInfo", "attachOmDocument", ]) { expect(surface.has(k), `${k}() vanished — a call site is now broken`).toBe(true); } diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a691c0e..c345f348 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3135,7 +3135,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(95) took fifty of those 126 — 76 remain, and there is STILL no map.** A new + **(88)–(96) took fifty-three of those 126 — 73 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, @@ -3277,6 +3277,45 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped alone would repeat the reader/rollup split (87) had to undo. **A complete category is one vote, not a verdict** — and "lifecycle" is a word (88) already caught misleading once, in a different way. + **(96) finished the as-built/turnover question by taking its AGGREGATE READER** — `lod500`, plus + the two writers still feeding it, `setManufacturerInfo` and `attachOmDocument`. They rejoin + `verifyAsbuilt` and `recordAsbuiltDimension`, which (94) moved and could only claim as "two known + members, not necessarily all". **This is the slice that can say more than that, and the reason is + worth more than the extraction.** + + *Two witnesses BOUND the set, and they were derived independently.* `openAsBuiltPanel` in + `apps/web/src/viewer/tools/modelStatePanels.ts` calls exactly five API methods — read off that + function's brace closure rather than grepped for, so it is the closure of a scope, not a sample. + Separately, the reader's own response type names its writer set field by field: + `verified`/`by_method` ← `verify_asbuilt`, `with_dimensions`/`dimensions_out_of_tolerance` ← + `record_asbuilt_dimension`, `with_manufacturer`/`with_serial` ← `set_manufacturer_info`, + `with_om_docs`/`om_documents` ← `attach_om_document`; and the backend route says it in prose — + *"Stamp elements with the `verify_asbuilt` recipe."* **A reader whose response type enumerates its + writers is a derived population**, and after eight slices of sampling, that is the first grouping + witness here that bounds a set instead of illustrating one. + + *What CORROBORATES is not what BOUNDS.* `services/api/test_lod500.py` reaches exactly three + recipes — `attach_om_document`, `set_manufacturer_info`, `verify_asbuilt` — and **omits + `record_asbuilt_dimension`**, which is unambiguously in this family. It agrees with the answer + without being able to establish it. **A witness that misses a known member cannot bound anything**, + however exactly its members match. Three slices claimed "and no others" off a test file and were + wrong all three times; the fix is not to read the test harder, it is to ask which question the + test is able to answer at all. + + **And what this slice does NOT claim, stated where a later reader will see it.** + `attachDocument` stays in `client.ts`, takes a `purpose` parameter, and `asbuilt_summary` counts + **any** purpose-tagged document reference — so *"every writer of `with_om_docs` moves here"* is + false. The field map is of the recipes each field was designed around, not of everything that can + set it. *(94) hedged in its PR while its artifact overstated; the caveat belongs in both, which is + why it is in the header, the pin, the changelog and here.* + + *Two sources disagree and lose for the same reason:* `attach_om_document` is implemented as a + purpose-tagged wrapper of `detailing.attach_document`, and `authoring_matrix.py` files it and + `set_manufacturer_info` under `data`. The first is a shared HELPER, the second a STORAGE bucket — + the groupings (89) and (90) each had to reject. **The matrix is now the losing vote twice running + after being right three times**, which is the interesting part: a corroborating source that keeps + winning is the one that quietly stops getting checked. + **(93) finished SCALE-SEAM ⑲.** Two methods to the existing `apps/web/src/api/mep.ts` — `connectMep` and `addMepFitting` — which that file has claimed by name since ⑲ under the note *"call `editIfc` (`/edit`) and stay"*. **That note recorded the symptom without diagnosing the diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index fd3c0870..de373239 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 711, # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 698, # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From 0472f974d094ac66c1f985f9c9156d715312cab6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:08:56 +0000 Subject: [PATCH 03/23] =?UTF-8?q?SCALE-SEAM=20(97)=20=E2=80=94=20the=20und?= =?UTF-8?q?o=20stack,=20and=20a=20destination=20that=20looked=20right=20an?= =?UTF-8?q?d=20was=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `editHistory`, `editUndo` and `editRedo` out of `client.ts` into `api/authoring.ts`. `client.ts` goes 698 -> 687, 70 methods above the STAYING banner. WHY THAT MIXIN: `editIfc`, already there, is the PUSH they pop. `authoring.py` records the pre-edit version on every `/edit` call "so this edit can be undone", `_restore_version` pops that stack, and `edit_history.state()` reads its depths. One stack, and the operation that fills it was already in this file. The types agree: both writers return `{restored, state: {can_undo, can_redo}}`, and `state` is `editHistory`'s own return type minus the depths — the writers hand back the reader's answer. That is a type-level relation, not the shared `/edit/` prefix. The prefix is real and is deliberately not the argument, since a route prefix is exactly what the verification slice was caught grouping on. A HYPOTHESIS TESTED AND WITHDRAWN, which is the part worth keeping. "Undo restores the prior model version" makes `model.ts` the obvious home — it owns `modelVersions`, `versionDiff`, `versionCostDelta`. It is the wrong home: those read `/projects/{pid}/versions` out of `bim.py`, while undo pops a DIFFERENT stack, the `edit_history` sidecar, which `recipe_log.py` describes as a list of file paths with "No recipe, no parameters, no actor". Two stacks, one word — and the word is what made the wrong answer look obvious. Second withdrawal of a plausible destination after checking it, after (93) withdrew (92)'s `HttpCore` forecast. THE BOUND IS WEAKER THAN (96)'s AND IS STATED THAT WAY. `app.ts`'s S4 block wires `refreshUndo` (calling only `editHistory`) and `doUndoRedo` (calling only `editUndo`/`editRedo`), so the union is exactly these three — but that unit is a block delimited by reading, not a closure the braces define. It corroborates; it does not bound. Not every set has a witness as strong as the last one's, and promoting a block to a closure would be this sequence's own recurring defect. Verified: tsc 0, lint 0, `vitest run src/api` 27 files / 119 tests, build 0, ruff (the CI command, from `services/api`) 0, and `test_file_sizes.py`, `test_claude_md_gates.py`, `test_roadmap_status.py` all 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 43 ++++++++++++++++++++++++++++++++ apps/web/src/api/authoring.ts | 41 ++++++++++++++++++++++++++++++ apps/web/src/api/client.ts | 21 ++++------------ apps/web/src/api/surface.test.ts | 3 +++ docs/roadmap.md | 26 ++++++++++++++++++- services/api/test_file_sizes.py | 2 +- 6 files changed, 118 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f04f623..f083e940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1185,6 +1185,49 @@ Reader and unwired writer on one panel — still the reason not to separate them Pin 731 → 727. **80 above the banner, still no map.** +Thirty-sixth follow-on on the same version: **SCALE-SEAM (97)** — *the undo stack, and a +destination that looked right and was not*. + +Three methods out of `client.ts` (`698 → 687`) into the existing `apps/web/src/api/authoring.ts`: +`editHistory`, `editUndo`, `editRedo`. No new mixin. **What they answer: what has been done to this +model, and can I take it back.** + +### `editIfc` is the push they pop + +They belong with `editIfc` — already in that mixin — because `authoring.py` records the pre-edit +version on every `/edit` call *"so this edit can be undone"*, `_restore_version` pops that stack, +and `edit_history.state()` reads its depths. One stack; the operation that fills it was already +here. + +The types agree: both writers return `{ restored, state: { can_undo, can_redo } }`, and `state` is +`editHistory`'s own return type minus the depths — **the writers hand back the reader's answer.** +That is a type-level relation, not a shared `/edit/` prefix. The prefix is real, but a route prefix +is exactly what ㊻ was caught grouping on, so it corroborates and does not decide. + +### A hypothesis tested and withdrawn + +*"Undo restores the prior model version"* makes `model.ts` the obvious home — it owns +`modelVersions`, `versionDiff`, `versionCostDelta`. **It is the wrong home.** Those read +`/projects/{pid}/versions` out of `bim.py`; undo pops a *different* stack, the `edit_history` +sidecar, which `recipe_log.py` describes as a list of file paths with *"No recipe, no parameters, +no actor"*. Two stacks, one word. + +This is the second time a plausible destination has had to be withdrawn after checking it — (93) +withdrew (92)'s forecast about moving `editIfc` into `HttpCore`. **A likely-looking home is a +hypothesis to check against the backend, not an instruction to carry out.** + +### The bound is weaker than (96)'s, and is stated that way + +`app.ts`'s S4 block wires `refreshUndo` (calling only `editHistory`) and `doUndoRedo` (calling only +`editUndo`/`editRedo`), so the union is exactly these three. But unlike (96)'s `openAsBuiltPanel`, +that unit is a block delimited by reading rather than a closure the braces define — so it is +recorded as corroboration, not as a boundary. *Not every set has a witness as strong as the last +one's, and inflating a block into a closure would be the same defect this sequence keeps finding.* + +`client.ts` is 70 methods above the STAYING banner and 4 below. Pin lowered with the file; the three +names added to `surface.test.ts` — all three have live call sites, so a drop breaks the undo/redo +buttons rather than only the count. + Thirty-fifth follow-on on the same version: **SCALE-SEAM (96)** — *the as-built question's aggregate reader, and the first witness in this sequence that actually bounds a set*. diff --git a/apps/web/src/api/authoring.ts b/apps/web/src/api/authoring.ts index 2fdf25bb..07dba6a3 100644 --- a/apps/web/src/api/authoring.ts +++ b/apps/web/src/api/authoring.ts @@ -17,6 +17,32 @@ * SCALE-SEAM ⓼ adds groups and assemblies — *how are these elements grouped?* * List, inspector, create group/assembly, parametric array. Detailing stayed. * + * **SCALE-SEAM (97) adds the UNDO STACK — *what has been done to this model, and can I take it + * back?*** `editHistory` (can_undo/can_redo + depths), `editUndo` and `editRedo`. They belong in + * THIS file because `editIfc` — already here — is the PUSH they pop: `authoring.py` records the + * pre-edit version on every `/edit` call *"so this edit can be undone"*, `_restore_version` pops + * that stack, and `edit_history.state()` reads its depths. One stack, and the operation that fills + * it was already in this mixin. + * + * *The writers hand back the reader's answer:* both return `{ restored, state: { can_undo, + * can_redo } }`, and `state` is `editHistory`'s return type minus the depths. That is a type-level + * relation, not a shared `/edit/` prefix — the prefix is real here but is exactly the grouping ㊻ + * was caught using, so it corroborates and does not decide. + * + * **A hypothesis TESTED AND WITHDRAWN, because it looked right.** "Undo restores the prior model + * version" makes `model.ts` the obvious home — it owns `modelVersions`, `versionDiff` and + * `versionCostDelta`. It is the wrong home: those read `/projects/{pid}/versions` out of `bim.py`, + * while undo pops a **different stack** in the `edit_history` sidecar (a list of file paths, per + * `recipe_log.py`: *"No recipe, no parameters, no actor"*). Two stacks, one word. *(93) had to + * withdraw a forecast the same way; a plausible destination is a hypothesis to check against the + * backend, not an instruction to carry out.* + * + * *Bounded by a UI unit rather than a function, and that is weaker on purpose:* `app.ts`'s S4 + * undo/redo block wires `refreshUndo` (calling only `editHistory`) and `doUndoRedo` (calling only + * `editUndo`/`editRedo`), so the union is exactly these three. Unlike (96)'s `openAsBuiltPanel`, + * the unit is a block delimited by reading, not a closure the braces define — so it is stated as + * corroboration, not as a boundary. + * * First extraction of roadmap SCALE-SEAM. `client.ts` was measured at 4,956 lines with 152 commits * in a fortnight and 631 methods on one class: it had to be opened to add any endpoint, so every * change to it competed with every other change. The server solved this long ago by splitting into @@ -337,6 +363,21 @@ export function withAuthoring>(Base: TBase) { arrayElement(pid: string, guid: string, nx: number, ny: number, dx: number, dy: number, dz = 0, publish = true) { return this.editIfc(pid, "array_element", { guid, nx, ny, dx, dy, dz }, publish); } + /** S4: whether the model can be undone / redone + stack depths. */ + editHistory(pid: string) { + return this.json<{ can_undo: boolean; can_redo: boolean; undo_depth: number; redo_depth: number }>( + `/projects/${pid}/edit/history`); + } + /** S4: undo the last authoring edit (restore the prior model version + republish). */ + editUndo(pid: string, publish = true) { + return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean } }>( + `/projects/${pid}/edit/undo`, { method: "POST", body: JSON.stringify({ publish }) }); + } + /** S4: redo an undone edit. */ + editRedo(pid: string, publish = true) { + return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean } }>( + `/projects/${pid}/edit/redo`, { method: "POST", body: JSON.stringify({ publish }) }); + } }; } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index af04a9a8..9b38ddaa 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -161,21 +161,6 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes specManual(pid: string) { return this.json(`/projects/${pid}/spec/manual`); } - /** S4: whether the model can be undone / redone + stack depths. */ - editHistory(pid: string) { - return this.json<{ can_undo: boolean; can_redo: boolean; undo_depth: number; redo_depth: number }>( - `/projects/${pid}/edit/history`); - } - /** S4: undo the last authoring edit (restore the prior model version + republish). */ - editUndo(pid: string, publish = true) { - return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean } }>( - `/projects/${pid}/edit/undo`, { method: "POST", body: JSON.stringify({ publish }) }); - } - /** S4: redo an undone edit. */ - editRedo(pid: string, publish = true) { - return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean } }>( - `/projects/${pid}/edit/redo`, { method: "POST", body: JSON.stringify({ publish }) }); - } /** B3: give a wall a sloped top (start_height → end_height) for parapet/shed/gable walls. */ setWallSlope(pid: string, guid: string, startHeight: number, endHeight: number, publish = true) { return this.editIfc(pid, "set_wall_slope", { guid, start_height: startHeight, end_height: endHeight }, publish); @@ -607,7 +592,7 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // through (87) worked through, and they are recorded here as DECIDED rather than pending. // // **THIS IS NOT THE END OF SCALE-SEAM, and a previous version of this banner implied it was.** - // 73 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, + // 70 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, // `energyModel`, `propmapPlan`, `camReconciliation` and the rest. They were never inside the // CX-1 banner, so no map has ever covered them. The UNFILED map described the TAIL of this file, // not the file. @@ -670,6 +655,10 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // `model.ts`, leaving 73. `lod500` is the AGGREGATE READER of the question whose writers (94) // already moved, and its response type names its own writer set field by field. // + // (97) took the UNDO STACK — `editHistory`/`editUndo`/`editRedo` — to `authoring.ts`, leaving + // 70. `editIfc`, already there, is the PUSH they pop. `model.ts` looked right and is not: its + // `modelVersions` reads a DIFFERENT stack. Reasoning in `authoring.ts`'s header. + // // the four that stay enumOptions, searchAll, attachmentUrl, templates enumOptions(pid: string) { return this.json>>(`/projects/${pid}/enum-options`); diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index 84300b38..a3ff85db 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -247,6 +247,9 @@ describe("the API client's public surface", () => { // (96) the as-built/turnover reader + its two remaining writers -> model.ts. `lod500` has a // LIVE call site (openAsBuiltPanel), so losing it would break the panel, not just the surface. "lod500", "setManufacturerInfo", "attachOmDocument", + // (97) the undo stack -> authoring.ts. All three have LIVE call sites in app.ts's S4 block, + // so a drop here breaks the undo/redo buttons, not just the surface count. + "editHistory", "editUndo", "editRedo", ]) { expect(surface.has(k), `${k}() vanished — a call site is now broken`).toBe(true); } diff --git a/docs/roadmap.md b/docs/roadmap.md index c345f348..c2bca22a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3135,7 +3135,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(96) took fifty-three of those 126 — 73 remain, and there is STILL no map.** A new + **(88)–(97) took fifty-six of those 126 — 70 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, @@ -3316,6 +3316,30 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped after being right three times**, which is the interesting part: a corroborating source that keeps winning is the one that quietly stops getting checked. + **(97) took the UNDO STACK to `apps/web/src/api/authoring.ts`** — `editHistory`, `editUndo`, + `editRedo`, answering *what has been done to this model, and can I take it back?* They go to that + mixin because **`editIfc`, already in it, is the PUSH they pop**: `authoring.py` records the + pre-edit version on every `/edit` call *"so this edit can be undone"*, `_restore_version` pops + that stack, and `edit_history.state()` reads its depths. The types agree — both writers return + `{ restored, state: { can_undo, can_redo } }`, and `state` is the reader's own type minus the + depths, so **the writers hand back the reader's answer**. The shared `/edit/` prefix is real and + is deliberately NOT the argument; a route prefix is what ㊻ was caught grouping on. + + **A hypothesis tested and withdrawn, which is the part worth keeping.** *"Undo restores the prior + model version"* makes `model.ts` the obvious destination — it owns `modelVersions`, `versionDiff` + and `versionCostDelta`. It is wrong: those read `/projects/{pid}/versions` out of `bim.py`, while + undo pops a **different stack**, the `edit_history` sidecar, which `recipe_log.py` describes as a + list of file paths with *"No recipe, no parameters, no actor"*. Two stacks, one word — and the + word is what made the wrong answer look obvious. Second withdrawal of a plausible destination + after checking it, following (93)'s of (92)'s `HttpCore` forecast: **a likely home is a hypothesis + to check against the backend, not an instruction to execute.** + + *And the bound here is weaker than (96)'s, recorded as such.* `app.ts`'s S4 block wires + `refreshUndo` (only `editHistory`) and `doUndoRedo` (only `editUndo`/`editRedo`), so the union is + exactly these three — but that unit is a block delimited by reading, not a closure the braces + define. It corroborates; it does not bound. **Not every set has a witness as strong as the last + one's, and promoting a block to a closure would be this sequence's own recurring defect.** + **(93) finished SCALE-SEAM ⑲.** Two methods to the existing `apps/web/src/api/mep.ts` — `connectMep` and `addMepFitting` — which that file has claimed by name since ⑲ under the note *"call `editIfc` (`/edit`) and stay"*. **That note recorded the symptom without diagnosing the diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index de373239..a31e2419 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 698, # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 687, # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From 58525e1ce363f472c5252a354cd41856ee87d690 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:16:29 +0000 Subject: [PATCH 04/23] =?UTF-8?q?Correct=20the=20undo/redo=20republish=20d?= =?UTF-8?q?ocs=20=E2=80=94=20all=20three=20sites,=20not=20just=20the=20fla?= =?UTF-8?q?gged=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged `editUndo`'s docstring on #414: it says "restore the prior model version + republish" unconditionally, but `publish` defaults to true and can be false. Verified against the backend — `_restore_version` guards the republish with `if publish:` — so the claim is wrong whenever a caller passes `publish: false`. Grepping every site of that wording found the same unconditional claim in THREE live places, not one: - `apps/web/src/api/authoring.ts` — `editUndo` (the flagged one) - `services/api/src/aec_api/routers/authoring.py` — the `/edit/undo` route - `services/api/src/aec_api/routers/authoring.py` — the `/edit/redo` route All three now say republishing happens only when `publish`. The remaining hits are UI button labels, where the call site never passes the flag so republish really is unconditional, and historical CHANGELOG entries; both are correct as written and are left alone. Also fixes the same defect's other half in the same five lines: the declared return type omitted `publish`, which the backend adds as `"running"` when it republishes. Correcting the prose while leaving the type silent about the same conditional would be the half-applied fix this sequence has twice been caught making. Verified: tsc 0, lint 0, `vitest run src/api` 27 files / 119 tests, build 0, ruff (the CI command, from `services/api`) 0, `test_file_sizes.py` 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- apps/web/src/api/authoring.ts | 12 ++++++++---- services/api/src/aec_api/routers/authoring.py | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/src/api/authoring.ts b/apps/web/src/api/authoring.ts index 07dba6a3..d006e346 100644 --- a/apps/web/src/api/authoring.ts +++ b/apps/web/src/api/authoring.ts @@ -368,14 +368,18 @@ export function withAuthoring>(Base: TBase) { return this.json<{ can_undo: boolean; can_redo: boolean; undo_depth: number; redo_depth: number }>( `/projects/${pid}/edit/history`); } - /** S4: undo the last authoring edit (restore the prior model version + republish). */ + /** S4: undo the last authoring edit — restore the prior model version, and republish only when + * `publish` (the default). With `publish: false` the source IFC is swapped and no publish runs, + * so `publish` is absent from the response. */ editUndo(pid: string, publish = true) { - return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean } }>( + return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean }; + publish?: string }>( `/projects/${pid}/edit/undo`, { method: "POST", body: JSON.stringify({ publish }) }); } - /** S4: redo an undone edit. */ + /** S4: redo an undone edit — same `publish` semantics as `editUndo`. */ editRedo(pid: string, publish = true) { - return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean } }>( + return this.json<{ restored: string; state: { can_undo: boolean; can_redo: boolean }; + publish?: string }>( `/projects/${pid}/edit/redo`, { method: "POST", body: JSON.stringify({ publish }) }); } }; diff --git a/services/api/src/aec_api/routers/authoring.py b/services/api/src/aec_api/routers/authoring.py index 2f220d0a..9f196f6e 100644 --- a/services/api/src/aec_api/routers/authoring.py +++ b/services/api/src/aec_api/routers/authoring.py @@ -1059,7 +1059,8 @@ def _restore_version(pid: str, db: Session, actor: str, publish: bool, redo: boo @router.post("/projects/{pid}/edit/undo") def edit_undo(pid: str, publish: bool = Body(default=True, embed=True), db: Session = Depends(get_db), actor: str = Depends(require_role("editor"))): - """S4: **undo** the last authoring edit — restore the prior model version + republish. GUID-stable + """S4: **undo** the last authoring edit — restore the prior model version, republishing only when + `publish` (the default). GUID-stable (pins/RFIs/clashes keyed by GlobalId survive).""" return _restore_version(pid, db, actor, publish, redo=False) @@ -1067,7 +1068,8 @@ def edit_undo(pid: str, publish: bool = Body(default=True, embed=True), db: Sess @router.post("/projects/{pid}/edit/redo") def edit_redo(pid: str, publish: bool = Body(default=True, embed=True), db: Session = Depends(get_db), actor: str = Depends(require_role("editor"))): - """S4: **redo** an undone edit — restore the next model version + republish.""" + """S4: **redo** an undone edit — restore the next model version, republishing only when `publish` + (the default).""" return _restore_version(pid, db, actor, publish, redo=True) From 75f859796f4caec15fdb4aca76202e0ab92ab60f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:49:34 +0000 Subject: [PATCH 05/23] =?UTF-8?q?SCALE-SEAM=20(98)=20=E2=80=94=20detailing?= =?UTF-8?q?=20carriers,=20and=20a=20field=20map=20total=20over=20one=20mod?= =?UTF-8?q?ule=20but=20not=20the=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `elementDetailing`, `classify`, `applyDetailingRules`, `validateDetailing` and `attachDocument` out of `client.ts` into a new `api/detailing.ts`. `client.ts` goes 687 -> 665, 65 methods above the STAYING banner. WHAT THEY ANSWER: what informational carriers are attached to this element, write them, and which are missing? THE WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the shape (96) established. `element_detailing` walks `HasAssociations` and branches on exactly two relationship types, and `detailing.py` holds exactly two writers, one per response array: classifications[] <- classify (IfcRelAssociatesClassification) documents[] <- attachDocument (IfcRelAssociatesDocument) The map is read out of the reader's own body, not matched on names. The other two methods are those same writes automated and audited: `applyDetailingRules` runs the condition-to-content rule set and writes both carrier kinds, `validateDetailing` reports elements a rule applies to that lack the code. TOTAL OVER THE MODULE, NOT THE CODEBASE, and the difference is the claim. `attachOmDocument` — moved to `model.ts` in (96) — wraps the SAME `detailing.attach_document` and also lands in `documents[]`, so "these are all the writers of this reader's fields" is false. That overlap was recorded when it moved, which is why it was on hand to qualify this slice rather than being found in review. ADJACENCY AGREED WITH THE ANSWER AND IS NOT EVIDENCE FOR IT. These five were contiguous in `client.ts`; unlike (95), where non-contiguity was the whole argument, a positional split would have found this set too. Stated precisely because it looks like support. ALSO RECORDED: `api.classify()` has no call site — `detailingSection.ts` drives the recipe through the generic `authorAndReload` path, bypassing the typed method, and `clientCallers.test.ts` counts it reached because it matches bare string literals as well as calls. That looseness is deliberate per that file's own docstring, so it is a limit of the gate rather than a defect in it. The new mixin needs `editIfc`, so it declares `NeedsEditIfc` and composes outside `withAuthoring`; `compositionOrder.test.ts` gains a fourth assertion. MUTATION-CHECKED: relaxing the constraint to `Ctor` produces "TS2578: Unused '@ts-expect-error' directive" on exactly the new line, so it fails for the reason claimed rather than merely passing. The extraction removed 27 lines and the banner plus import added 5 back — caught by the ratchet, not by me, the same way (94) found its own banner growth. Verified: tsc 0, lint 0, `vitest run src/api` 27 files / 119 tests, build 0, ruff (the CI command, from `services/api`) 0, and `test_file_sizes.py`, `test_claude_md_gates.py`, `test_roadmap_status.py` all 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 56 ++++++++++++++++ apps/web/src/api/client.ts | 36 ++--------- apps/web/src/api/compositionOrder.test.ts | 3 + apps/web/src/api/detailing.ts | 78 +++++++++++++++++++++++ apps/web/src/api/surface.test.ts | 4 ++ docs/roadmap.md | 35 +++++++++- services/api/test_file_sizes.py | 2 +- 7 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/api/detailing.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f083e940..6170a310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1185,6 +1185,62 @@ Reader and unwired writer on one panel — still the reason not to separate them Pin 731 → 727. **80 above the banner, still no map.** +Thirty-seventh follow-on on the same version: **SCALE-SEAM (98)** — *detailing carriers, and a +field map that is total over one module but not over the codebase*. + +Five methods out of `client.ts` (`687 → 665`) into a new `apps/web/src/api/detailing.ts`: +`elementDetailing`, `classify`, `applyDetailingRules`, `validateDetailing`, `attachDocument`. +**What they answer: what informational carriers are attached to this element, write them, and which +are missing.** + +### A 1:1 and total field-to-writer map + +The reader's response has exactly two carrier arrays, and `services/data/src/aec_data/detailing.py` +holds exactly two writers, one per array: + +| response field | writer | IFC relationship | +|---|---|---| +| `classifications[]` | `classify` | `IfcRelAssociatesClassification` | +| `documents[]` | `attachDocument` | `IfcRelAssociatesDocument` | + +`element_detailing` walks `HasAssociations` and branches on precisely those two relationship types — +nothing else contributes a field — so the map comes from the reader's own body rather than from +matching names. The other two methods are those same two writes under automation: +`applyDetailingRules` runs the condition-to-content rule set and writes both carrier kinds, and +`validateDetailing` reports elements a rule applies to that lack the required code. + +### Total over the module, not over the codebase + +`attachOmDocument` — moved to `model.ts` in (96) — is a purpose-tagged wrapper of the **same** +`detailing.attach_document`, so it also writes `IfcRelAssociatesDocument` and its output lands in +`documents[]`. So *"these are all the writers of this reader's fields"* is **false**. The claim the +evidence supports is narrower and is the one made: the map is 1:1 and total **over `detailing.py`**. +*That overlap was named when `attachOmDocument` moved, which is why it was available to qualify this +slice instead of being discovered by a reviewer.* + +### Adjacency agreed with the answer, and is not evidence for it + +These five were **contiguous** in `client.ts` (119–145). Unlike (95), where non-contiguity was the +whole argument for grouping by what methods answer, a positional split would have found this set +too. That is worth stating precisely *because* it looks like support: a grouping that happens to +coincide with adjacency is not thereby better evidenced. + +Also recorded: **`api.classify()` has no call site.** `viewer/tools/detailingSection.ts` drives the +recipe through the generic `authorAndReload("classify", …)` path, bypassing the typed method. +`api/clientCallers.test.ts` counts it as reached because it matches bare string literals as well as +calls — a looseness that file's own docstring declares deliberate, preferring a higher ceiling to a +false unreachability report. Noted so the next reader of `detailing.ts` does not assume the method +is live. + +The new mixin needs `editIfc`, so it declares `NeedsEditIfc` and composes outside `withAuthoring`; +`api/compositionOrder.test.ts` gains a fourth line asserting that. **That assertion was +mutation-checked**: relaxing the constraint to `Ctor` produces `TS2578: Unused '@ts-expect-error' +directive` on exactly the new line, so it fails for the reason claimed rather than merely passing. + +`client.ts` is 65 methods above the STAYING banner and 4 below. *The extraction removed 27 lines and +the banner plus import added 5 back — caught by the ratchet, not by me, which is the same way (94) +found its own banner growth.* + Thirty-sixth follow-on on the same version: **SCALE-SEAM (97)** — *the undo stack, and a destination that looked right and was not*. diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9b38ddaa..f1432507 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -17,6 +17,7 @@ import { withOperations } from "./operations"; import { withClientPortal } from "./clientPortal"; import { withCreDeal } from "./creDeal"; import { withAnnotate } from "./annotate"; +import { withDetailing } from "./detailing"; import { withResilience } from "./resilience"; import { withResponsibility } from "./responsibility"; import { withCodeCheck } from "./codecheck"; import { withDealMemory } from "./dealMemory"; @@ -69,7 +70,7 @@ import type { // Transport (baseUrl, token, json/_pdfPost/url/health) lives in HttpCore; ApiClient adds the typed // domain methods below. Every `api.method()` call site is unchanged by the split. -export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore))))))))))))))))))))))))))))))))))))))))))) { +export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore)))))))))))))))))))))))))))))))))))))))))))) { /** * R22-PHOTO-CV — attach a field photo to an element and get the server's read on it back. * @@ -116,33 +117,6 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes return this.json<{ by: string; buckets: Record; counts: Record; element_count: number }>( `/projects/${pid}/5d/heatmap?by=${by}`); } - /** W11 Track D: one element's attached carriers — classification codes + documents (details/instructions). */ - elementDetailing(pid: string, guid: string) { - return this.json<{ guid: string; name: string; ifc_class: string; - classifications: { system: string | null; code: string | null; title: string | null }[]; - documents: { identification: string | null; name: string | null; location: string | null; description: string | null }[] }>( - `/projects/${pid}/detailing/${encodeURIComponent(guid)}`); - } - /** W11 Track D: classify elements with a keynote/spec/element code (UniFormat/MasterFormat/OmniClass). */ - classify(pid: string, guids: string[], system: string, code: string, name?: string, edition?: string, publish = true) { - return this.editIfc(pid, "classify", { guids, system, code, name, edition }, publish); - } - /** W11 D3: auto-detail — run the condition→content rule set (e.g. exterior window → IBC flashing - * detail + 08 51 00), writing code/detail bundles to every matching element. */ - applyDetailingRules(pid: string, publish = true) { - return this.editIfc(pid, "apply_detailing_rules", {}, publish); - } - /** W11 D3: IDS-style QA — elements that a rule applies to but are missing their required keynote/spec code. */ - validateDetailing(pid: string) { - return this.json<{ rules_evaluated: number; gaps: number; - elements: { rule: string; guid: string; name: string; missing: string }[] }>( - `/projects/${pid}/detailing/rules/validate`); - } - /** W11 Track D: attach a document (detail drawing / installation instruction) to elements. */ - attachDocument(pid: string, guids: string[], name: string, - opts: { location?: string; identification?: string; description?: string; purpose?: string } = {}, publish = true) { - return this.editIfc(pid, "attach_document", { guids, name, ...opts }, publish); - } /** W11 B6: author a base plate + anchor bolts under a steel column (fabrication assembly). */ addBasePlate(pid: string, columnGuid: string, opts: { bolts?: number; width?: number; depth?: number } = {}, publish = true) { return this.editIfc(pid, "add_base_plate", { column_guid: columnGuid, ...opts }, publish); @@ -592,7 +566,7 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // through (87) worked through, and they are recorded here as DECIDED rather than pending. // // **THIS IS NOT THE END OF SCALE-SEAM, and a previous version of this banner implied it was.** - // 70 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, + // 65 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, // `energyModel`, `propmapPlan`, `camReconciliation` and the rest. They were never inside the // CX-1 banner, so no map has ever covered them. The UNFILED map described the TAIL of this file, // not the file. @@ -659,6 +633,10 @@ export class ApiClient extends withAnnotate(withCreDeal(withClientPortal(withRes // 70. `editIfc`, already there, is the PUSH they pop. `model.ts` looked right and is not: its // `modelVersions` reads a DIFFERENT stack. Reasoning in `authoring.ts`'s header. // + // (98) took DETAILING CARRIERS to a new `detailing.ts`, leaving 65. The reader's two carrier + // arrays map 1:1 onto `detailing.py`'s two writers, plus the rule engine that writes both and + // the audit that reports gaps. Reasoning in `detailing.ts`'s header. + // // the four that stay enumOptions, searchAll, attachmentUrl, templates enumOptions(pid: string) { return this.json>>(`/projects/${pid}/enum-options`); diff --git a/apps/web/src/api/compositionOrder.test.ts b/apps/web/src/api/compositionOrder.test.ts index dec0776e..b7e2f290 100644 --- a/apps/web/src/api/compositionOrder.test.ts +++ b/apps/web/src/api/compositionOrder.test.ts @@ -32,6 +32,7 @@ import { HttpCore } from "./httpCore"; import { withAnnotate } from "./annotate"; import { withMep } from "./mep"; import { withModel } from "./model"; +import { withDetailing } from "./detailing"; describe("mixins requiring editIfc", () => { it("reject a base that lacks it, so a bad chain order fails at compile time", () => { @@ -41,6 +42,8 @@ describe("mixins requiring editIfc", () => { void (() => withMep(HttpCore)); // @ts-expect-error withModel needs NeedsEditIfc; bare HttpCore has no editIfc. void (() => withModel(HttpCore)); + // @ts-expect-error withDetailing needs NeedsEditIfc; bare HttpCore has no editIfc. + void (() => withDetailing(HttpCore)); expect(true).toBe(true); }); }); diff --git a/apps/web/src/api/detailing.ts b/apps/web/src/api/detailing.ts new file mode 100644 index 00000000..413db0aa --- /dev/null +++ b/apps/web/src/api/detailing.ts @@ -0,0 +1,78 @@ +/** Detailing carriers: the informational attachments an element carries — classification codes + * (keynote / spec / element) and associated documents (details, installation instructions) — the + * rule engine that writes them in bulk, and the QA that audits what is missing. + * + * **SCALE-SEAM (98).** *What informational carriers are attached to this element, write them, and + * which are missing?* Five methods: `elementDetailing` (the inspector), `classify` and + * `attachDocument` (the two writers), `applyDetailingRules` (the rule engine that writes both in + * bulk) and `validateDetailing` (the gap audit). + * + * **The witness is a 1:1 AND TOTAL field-to-writer map**, the shape (96) established. The reader's + * response has exactly two carrier arrays, and `services/data/src/aec_data/detailing.py` holds + * exactly two writers, one per array: + * + * `classifications[]` <- `classify` (writes `IfcRelAssociatesClassification`) + * `documents[]` <- `attachDocument` (writes `IfcRelAssociatesDocument`) + * + * `element_detailing` walks `HasAssociations` and branches on precisely those two relationship + * types — nothing else contributes a field — so the map is derived from the reader's own body, not + * inferred from names. The other two methods are the same two writes under automation: + * `applyDetailingRules` runs the condition-to-content rule set and writes BOTH carrier kinds, and + * `validateDetailing` reports elements a rule applies to that lack the required code. + * + * **What this does NOT claim, and it is the same limit (96) recorded.** `attachOmDocument` — + * moved to `model.ts` in (96) — is a purpose-tagged wrapper of the SAME + * `detailing.attach_document`, so it also writes `IfcRelAssociatesDocument` and its output appears + * in `documents[]`. *"These are all the writers of this reader's fields"* is therefore **false**. + * The map is 1:1 and total over `detailing.py`, which is the claim the evidence supports; one + * method answering the turnover question reaches the same carrier from another file, and that + * overlap was named when it moved rather than discovered here. + * + * *Two weaker witnesses, labelled as such rather than promoted.* `viewer/tools/detailingSection.ts` + * calls only the two READERS, so it corroborates them and bounds nothing; and these five were + * CONTIGUOUS in `client.ts` (119-145), which means — unlike (95), where non-contiguity was the + * whole argument — a positional split would have found this set too. **Adjacency agreeing with the + * answer is not evidence for it**; it is worth stating precisely because it looks like support. + * + * *`api.classify()` has no call site.* `viewer/tools/detailingSection.ts` drives the recipe through + * the generic `authorAndReload("classify", ...)` path, so the typed method is bypassed. + * `api/clientCallers.test.ts` counts it as reached because it matches bare string literals too — a + * looseness that file's own docstring declares deliberate, preferring a higher ceiling to a false + * unreachability report. Recorded here because the next reader of this file will otherwise assume + * the method is live. + */ +import type { NeedsEditIfc } from "./types"; + +type Ctor = new (...args: any[]) => T; + +export function withDetailing>(Base: TBase) { + return class Detailing extends Base { + /** W11 Track D: one element's attached carriers — classification codes + documents (details/instructions). */ + elementDetailing(pid: string, guid: string) { + return this.json<{ guid: string; name: string; ifc_class: string; + classifications: { system: string | null; code: string | null; title: string | null }[]; + documents: { identification: string | null; name: string | null; location: string | null; description: string | null }[] }>( + `/projects/${pid}/detailing/${encodeURIComponent(guid)}`); + } + /** W11 Track D: classify elements with a keynote/spec/element code (UniFormat/MasterFormat/OmniClass). */ + classify(pid: string, guids: string[], system: string, code: string, name?: string, edition?: string, publish = true) { + return this.editIfc(pid, "classify", { guids, system, code, name, edition }, publish); + } + /** W11 D3: auto-detail — run the condition→content rule set (e.g. exterior window → IBC flashing + * detail + 08 51 00), writing code/detail bundles to every matching element. */ + applyDetailingRules(pid: string, publish = true) { + return this.editIfc(pid, "apply_detailing_rules", {}, publish); + } + /** W11 D3: IDS-style QA — elements that a rule applies to but are missing their required keynote/spec code. */ + validateDetailing(pid: string) { + return this.json<{ rules_evaluated: number; gaps: number; + elements: { rule: string; guid: string; name: string; missing: string }[] }>( + `/projects/${pid}/detailing/rules/validate`); + } + /** W11 Track D: attach a document (detail drawing / installation instruction) to elements. */ + attachDocument(pid: string, guids: string[], name: string, + opts: { location?: string; identification?: string; description?: string; purpose?: string } = {}, publish = true) { + return this.editIfc(pid, "attach_document", { guids, name, ...opts }, publish); + } + }; +} diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index a3ff85db..b704f58b 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -250,6 +250,10 @@ describe("the API client's public surface", () => { // (97) the undo stack -> authoring.ts. All three have LIVE call sites in app.ts's S4 block, // so a drop here breaks the undo/redo buttons, not just the surface count. "editHistory", "editUndo", "editRedo", + // (98) detailing carriers -> detailing.ts. `elementDetailing` and `validateDetailing` have + // live call sites; `classify` is driven through the generic recipe path, so the SURFACE check + // is the only thing that would notice it vanishing. + "elementDetailing", "classify", "applyDetailingRules", "validateDetailing", "attachDocument", ]) { expect(surface.has(k), `${k}() vanished — a call site is now broken`).toBe(true); } diff --git a/docs/roadmap.md b/docs/roadmap.md index c2bca22a..43c6e9dd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3135,7 +3135,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(97) took fifty-six of those 126 — 70 remain, and there is STILL no map.** A new + **(88)–(98) took sixty-one of those 126 — 65 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, @@ -3340,6 +3340,39 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped define. It corroborates; it does not bound. **Not every set has a witness as strong as the last one's, and promoting a block to a closure would be this sequence's own recurring defect.** + **(98) added `apps/web/src/api/detailing.ts`** — `elementDetailing`, `classify`, + `applyDetailingRules`, `validateDetailing`, `attachDocument`, answering *what informational + carriers are attached to this element, write them, and which are missing?* The witness is the + (96) shape at its strongest yet: **a 1:1 AND TOTAL field-to-writer map**. `element_detailing` + walks `HasAssociations` and branches on exactly two relationship types, and + `services/data/src/aec_data/detailing.py` holds exactly two writers — `classifications[]` from + `classify` (`IfcRelAssociatesClassification`), `documents[]` from `attachDocument` + (`IfcRelAssociatesDocument`). The map is read out of the reader's own body, not matched on names. + The remaining two methods are those same writes automated and audited. + + **Total over the MODULE, not over the codebase — and the difference is the claim.** + `attachOmDocument`, moved to `model.ts` in (96), wraps the *same* `detailing.attach_document` and + also lands in `documents[]`, so *"these are all the writers of this reader's fields"* is false. + *That overlap was named when it moved, which is why it was on hand to qualify this slice rather + than being found by a reviewer — the value of recording a limit is that the next slice inherits + it.* + + **Adjacency agreed with the answer and is not evidence for it.** These five were contiguous in + `client.ts`; unlike (95), where non-contiguity was the whole argument, a positional split would + have found this set too. Stated precisely *because* it looks like support. + + *Also recorded, since it will otherwise mislead the next reader:* **`api.classify()` has no call + site** — `apps/web/src/viewer/tools/detailingSection.ts` drives the recipe through the generic + `authorAndReload` path, bypassing the typed method, and `apps/web/src/api/clientCallers.test.ts` + counts it reached because it matches bare string literals as well as calls. That looseness is + deliberate per that file's own docstring (a higher ceiling beats a false unreachability report), + so it is a limit of the gate, not a defect in it. + + The new mixin needs `editIfc`, so it declares `NeedsEditIfc` and composes outside `withAuthoring`; + `apps/web/src/api/compositionOrder.test.ts` gains a fourth assertion, **mutation-checked** — + relaxing the constraint to `Ctor` produces `TS2578` on exactly the new line, so it fails for + the reason claimed. + **(93) finished SCALE-SEAM ⑲.** Two methods to the existing `apps/web/src/api/mep.ts` — `connectMep` and `addMepFitting` — which that file has claimed by name since ⑲ under the note *"call `editIfc` (`/edit`) and stay"*. **That note recorded the symptom without diagnosing the diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index a31e2419..f469c7b8 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 687, # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 665, # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From 83e149e02da3ec4f4350b9ac3a4f294d9a7fc4dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 06:19:17 +0000 Subject: [PATCH 06/23] =?UTF-8?q?SCALE-SEAM=20(99)=20=E2=80=94=20the=20con?= =?UTF-8?q?tent=20shelf,=20and=20a=20destination=20header=20that=20was=20w?= =?UTF-8?q?rong=20until=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `contentCatalog`, `placeContent` and `importContent` out of `client.ts` into `api/authoring.ts`. `client.ts` goes 665 -> 648, 62 methods above the STAYING banner. WHAT THEY ANSWER: what pre-made content can I place, and place it? THE WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already in that file, read off the signatures rather than the shared noun: catalog reader familyCatalog() {count, categories: Record<..>} contentCatalog() {count, note, groups: Record<..>} placer placeFamily(pid, family, position) placeContent(pid, category, point, name) multipart import async importFamilies(pid, file, ..) async importContent(pid, file, opts) Three roles, three methods each, matching shapes and arities. A parallel between two method TRIPLES is structural; "both are shelves" would have been a shared word, which is the grouping (88) and (89) each had to reject. THE DESTINATION'S OWN FIRST LINE WAS WRONG UNTIL THIS COMMIT. `authoring.ts` has described itself as holding "the family/content shelf" while containing ZERO content methods — the word's only other appearances there are an HTTP header and a sentence about IFC *type* content. The docstring stated an intended scope as fact. That is recorded as corroboration that was FALSE, not as evidence. A header agreeing with the answer is worth nothing until someone checks whether it is true, and this one had been wrong for as long as it had existed. It is the smallest possible instance of the drift the project instructions keep warning about. Verified: tsc 0, lint 0, `vitest run src/api` 27 files / 119 tests, build 0, ruff (the CI command, from `services/api`) 0, and `test_file_sizes.py`, `test_claude_md_gates.py`, `test_roadmap_status.py` all 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 35 ++++++++++++++++++++++++++ apps/web/src/api/authoring.ts | 42 ++++++++++++++++++++++++++++++++ apps/web/src/api/client.ts | 27 ++++---------------- apps/web/src/api/surface.test.ts | 2 ++ docs/roadmap.md | 19 ++++++++++++++- services/api/test_file_sizes.py | 2 +- 6 files changed, 103 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6170a310..fa51af4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1185,6 +1185,41 @@ Reader and unwired writer on one panel — still the reason not to separate them Pin 731 → 727. **80 above the banner, still no map.** +Thirty-eighth follow-on on the same version: **SCALE-SEAM (99)** — *the content half of a shelf the +destination's own first line already claimed*. + +Three methods out of `client.ts` (`665 → 648`) into the existing `apps/web/src/api/authoring.ts`: +`contentCatalog`, `placeContent`, `importContent`. No new mixin. **What they answer: what pre-made +content can I place, and place it.** + +### A role-for-role parallel, read off the signatures + +The family shelf was already in `authoring.ts`. The content shelf is the same three roles, with +matching shapes and arities: + +| role | family | content | +|---|---|---| +| catalog reader | `familyCatalog()` → `{count, categories: Record<…>}` | `contentCatalog()` → `{count, note, groups: Record<…>}` | +| placer | `placeFamily(pid, family, position)` | `placeContent(pid, category, point, name)` | +| multipart importer | `async importFamilies(pid, file, …)` | `async importContent(pid, file, opts)` | + +**A parallel between two method *triples* is structural.** "Both are shelves" would have been a +shared word — the grouping (88) and (89) each had to reject — so the argument is deliberately the +signatures, not the noun. + +### The destination's header was wrong until this commit + +`authoring.ts` line 1 has read *"the family/content shelf"* while the file held **zero** content +methods; the only other occurrences of the word were an HTTP header and a sentence about IFC *type* +content. The docstring described an intended scope as though it were a fact. + +That is the same class as every drift the project instructions warn about, at the smallest possible +scale: **prose asserting an arrangement that nothing checked.** It is recorded as corroboration that +was *false* rather than as evidence — the parallel above is what carries the slice, and a header +that agreed with me would have been worth nothing if I had not checked whether it was true. + +`client.ts` is 62 methods above the STAYING banner and 4 below. + Thirty-seventh follow-on on the same version: **SCALE-SEAM (98)** — *detailing carriers, and a field map that is total over one module but not over the codebase*. diff --git a/apps/web/src/api/authoring.ts b/apps/web/src/api/authoring.ts index d006e346..1c213588 100644 --- a/apps/web/src/api/authoring.ts +++ b/apps/web/src/api/authoring.ts @@ -17,6 +17,27 @@ * SCALE-SEAM ⓼ adds groups and assemblies — *how are these elements grouped?* * List, inspector, create group/assembly, parametric array. Detailing stayed. * + * **SCALE-SEAM (99) adds the CONTENT half of the shelf this file's own first line already claimed.** + * `contentCatalog`, `placeContent`, `importContent` — *what pre-made content can I place, and place + * it.* The witness is a ROLE-FOR-ROLE PARALLEL with the family shelf already here, derived from the + * signatures rather than from the shared word "shelf": + * + * catalog reader `familyCatalog()` {count, categories: Record<..>} | `contentCatalog()` {count, note, groups: Record<..>} + * placer `placeFamily(pid, family, position)` | `placeContent(pid, category, point, name)` + * multipart import `async importFamilies(pid, file, ..)` | `async importContent(pid, file, opts)` + * + * Three roles, three methods each, matching shapes and matching arities. *A parallel between two + * method TRIPLES is structural; "both are shelves" would have been a word, which is the grouping + * (88) and (89) each had to reject.* + * + * **The header above was wrong until this commit, and that is the small finding.** Line 1 has said + * "the family/content shelf" while this file held **zero** content methods — the only other + * occurrences of the word were an HTTP header and a sentence about IFC *type* content. So the + * docstring described an intended scope as though it were a fact. *That is the same class as every + * drift the project instructions warn about, at the smallest possible scale: prose asserting an + * arrangement nothing checked.* It is corroboration that was FALSE, not evidence — the parallel + * above is what carries the slice. + * * **SCALE-SEAM (97) adds the UNDO STACK — *what has been done to this model, and can I take it * back?*** `editHistory` (can_undo/can_redo + depths), `editUndo` and `editRedo`. They belong in * THIS file because `editIfc` — already here — is the PUSH they pop: `authoring.py` records the @@ -382,6 +403,27 @@ export function withAuthoring>(Base: TBase) { publish?: string }>( `/projects/${pid}/edit/redo`, { method: "POST", body: JSON.stringify({ publish }) }); } + /** CONTENT-1: the curated content catalog (logistics / furniture / landscaping → IFC class + phase). */ + contentCatalog() { + return this.json<{ count: number; note: string; groups: Record }>(`/content/catalog`); + } + /** CONTENT-1: place a catalogued content item at an [E,N] point (optionally with a supplied mesh). */ + placeContent(pid: string, category: string, point: [number, number], name?: string, publish = true) { + return this.editIfc(pid, "place_content", { category, point, ...(name ? { name } : {}) }, publish); + } + /** CONTENT-1 (import): upload a detailed mesh (glTF/GLB/OBJ/STL/PLY) → auto-classified + placed as the + * right IFC via place_content. Category auto-detected from the filename unless given. */ + async importContent(pid: string, file: File, opts: { category?: string; e?: number; n?: number; + scale?: number; name?: string; storey?: string } = {}) { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(opts)) if (v !== undefined && v !== "") q.set(k, String(v)); + const fd = new FormData(); fd.append("file", file); + const r = await fetch(this.url(`/projects/${pid}/content/import?${q.toString()}`), + { method: "POST", body: fd, headers: this.authHeaders() }); + if (!r.ok) throw new Error((await r.text()) || `HTTP ${r.status}`); + return r.json() as Promise<{ guid: string; ifc_class: string; category: string; faces: number; publish?: string }>; + } }; } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index f1432507..c8533528 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -152,27 +152,6 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient phasing: Record; lod: Record; hygiene: { issues: number | null; clean: boolean | null } }>(`/projects/${pid}/scene-digest`); } - /** CONTENT-1: the curated content catalog (logistics / furniture / landscaping → IFC class + phase). */ - contentCatalog() { - return this.json<{ count: number; note: string; groups: Record }>(`/content/catalog`); - } - /** CONTENT-1: place a catalogued content item at an [E,N] point (optionally with a supplied mesh). */ - placeContent(pid: string, category: string, point: [number, number], name?: string, publish = true) { - return this.editIfc(pid, "place_content", { category, point, ...(name ? { name } : {}) }, publish); - } - /** CONTENT-1 (import): upload a detailed mesh (glTF/GLB/OBJ/STL/PLY) → auto-classified + placed as the - * right IFC via place_content. Category auto-detected from the filename unless given. */ - async importContent(pid: string, file: File, opts: { category?: string; e?: number; n?: number; - scale?: number; name?: string; storey?: string } = {}) { - const q = new URLSearchParams(); - for (const [k, v] of Object.entries(opts)) if (v !== undefined && v !== "") q.set(k, String(v)); - const fd = new FormData(); fd.append("file", file); - const r = await fetch(this.url(`/projects/${pid}/content/import?${q.toString()}`), - { method: "POST", body: fd, headers: this.authHeaders() }); - if (!r.ok) throw new Error((await r.text()) || `HTTP ${r.status}`); - return r.json() as Promise<{ guid: string; ifc_class: string; category: string; faces: number; publish?: string }>; - } /** W11 E8: validate an edit's params against the authoring guardrails without applying it. */ editPrecheck(pid: string, recipe: string, params: Record) { return this.json<{ ok: boolean; errors: string[]; warnings: string[] }>( @@ -566,7 +545,7 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient // through (87) worked through, and they are recorded here as DECIDED rather than pending. // // **THIS IS NOT THE END OF SCALE-SEAM, and a previous version of this banner implied it was.** - // 65 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, + // 62 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, // `energyModel`, `propmapPlan`, `camReconciliation` and the rest. They were never inside the // CX-1 banner, so no map has ever covered them. The UNFILED map described the TAIL of this file, // not the file. @@ -637,6 +616,10 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient // arrays map 1:1 onto `detailing.py`'s two writers, plus the rule engine that writes both and // the audit that reports gaps. Reasoning in `detailing.ts`'s header. // + // (99) took the CONTENT SHELF — `contentCatalog`/`placeContent`/`importContent` — to + // `authoring.ts`, leaving 62. Role-for-role parallel with the family shelf already there; that + // file's first line had claimed "family/content shelf" while holding no content method. + // // the four that stay enumOptions, searchAll, attachmentUrl, templates enumOptions(pid: string) { return this.json>>(`/projects/${pid}/enum-options`); diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index b704f58b..e1584a59 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -254,6 +254,8 @@ describe("the API client's public surface", () => { // live call sites; `classify` is driven through the generic recipe path, so the SURFACE check // is the only thing that would notice it vanishing. "elementDetailing", "classify", "applyDetailingRules", "validateDetailing", "attachDocument", + // (99) the content shelf -> authoring.ts, joining the family triple it parallels. + "contentCatalog", "placeContent", "importContent", ]) { expect(surface.has(k), `${k}() vanished — a call site is now broken`).toBe(true); } diff --git a/docs/roadmap.md b/docs/roadmap.md index 43c6e9dd..61972eb3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3135,7 +3135,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(98) took sixty-one of those 126 — 65 remain, and there is STILL no map.** A new + **(88)–(99) took sixty-four of those 126 — 62 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, @@ -3373,6 +3373,23 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped relaxing the constraint to `Ctor` produces `TS2578` on exactly the new line, so it fails for the reason claimed. + **(99) took the CONTENT SHELF to `apps/web/src/api/authoring.ts`** — `contentCatalog`, + `placeContent`, `importContent`, answering *what pre-made content can I place, and place it?* The + witness is a **role-for-role parallel** with the family shelf already in that file, read off the + signatures rather than the shared noun: `familyCatalog`/`contentCatalog` are both catalog readers + returning `{count, …Record<…>}`, `placeFamily`/`placeContent` are both placers, and + `importFamilies`/`importContent` are both async multipart importers. Three roles, three methods + each. **A parallel between two method TRIPLES is structural**; *"both are shelves"* would have been + a word, which is the grouping (88) and (89) each had to reject. + + **And the destination's own first line was wrong until this commit.** `authoring.ts` has described + itself as holding *"the family/content shelf"* while containing **zero** content methods — the + word's only other appearances there are an HTTP header and a sentence about IFC *type* content. The + docstring stated an intended scope as fact. *Recorded as corroboration that was FALSE rather than + as evidence: a header agreeing with the answer is worth nothing until someone checks whether it is + true, and this one had been wrong for as long as it had existed.* It is the smallest possible + instance of the drift these instructions keep warning about. + **(93) finished SCALE-SEAM ⑲.** Two methods to the existing `apps/web/src/api/mep.ts` — `connectMep` and `addMepFitting` — which that file has claimed by name since ⑲ under the note *"call `editIfc` (`/edit`) and stay"*. **That note recorded the symptom without diagnosing the diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index f469c7b8..ff9ecb9b 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 665, # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 648, # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From 2a8f46ff2ccf68b56f9bbfb4564a06343a9fa6eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 07:24:58 +0000 Subject: [PATCH 07/23] =?UTF-8?q?SCALE-SEAM=20(100)=20=E2=80=94=20the=20el?= =?UTF-8?q?ement-connection=20pair,=20and=20a=20destination=20named=20at?= =?UTF-8?q?=20its=20real=20strength?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `elementConnections` and `connectElements` out of `client.ts` into `api/model.ts`. `client.ts` goes 648 -> 642, 60 methods above the STAYING banner. WHAT THEY ANSWER: what is physically joined to what, and record a joint? THE PAIR IS BOUND BY THE BACKEND NAMING ITS OWN WRITER, the (96) shape: the `/element-connections` route docstring reads "Author edges with the `connect_elements` recipe (POST /edit with {guid_a, guid_b})". Reader and writer, one relationship type (IfcRelConnectsElements), both marked B5. THE DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS LABELLED SO. `model.ts` owns `modelGraphStats`, whose `by_rel` counts the IFC relationship graph BY RELATION — IfcRelConnectsElements being one — and `graphNeighbors`, which walks it. So this pair is one relation of a graph the file already reads, plus its authoring verb. That is a SPECIALISATION, not an identity: the graph methods are generic traversal over every IfcRel*, these two are one relation with a verb attached. The pairing is evidenced; the placement is a judgement, and collapsing the two into one confident sentence is the overstatement this sequence keeps catching. TWO CANDIDATES REJECTED ON CHECKABLE GROUNDS. `connections.ts` is the trap: it is DATA-SOURCE connections — SQL, ACC, Procore — sharing nothing with this but the English word, and it is the file a name-based search lands on first. (97) found two version stacks behind one word; this is the same collision in a destination rather than a source. `elements.ts` holds element ATTRIBUTES and views, and a relationship between two elements is not an attribute of either. `addBasePlate`/`addShearTab` did not come despite sharing `connections.py` with these: a backend module is a HOW, the grouping (89) had to reject, and those two author PHYSICAL assemblies rather than relationship edges. FOUND WHILE DERIVING, RECORDED NOT FIXED: `add_connection_assembly` (B5, IfcRelConnectsWithRealizingElements) has no client method anywhere in `apps/web/src` — a backend recipe with no web exposure, the class (93) recorded for three MEP recipes. Verified: tsc 0, lint 0, `vitest run src/api` 27 files / 119 tests, build 0, ruff (the CI command, from `services/api`) 0, and `test_file_sizes.py`, `test_claude_md_gates.py`, `test_roadmap_status.py` all 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++ apps/web/src/api/client.ts | 16 ++++------- apps/web/src/api/model.ts | 42 +++++++++++++++++++++++++++++ apps/web/src/api/surface.test.ts | 2 ++ docs/roadmap.md | 26 +++++++++++++++++- services/api/test_file_sizes.py | 2 +- 6 files changed, 121 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa51af4b..4a3c40a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1185,6 +1185,52 @@ Reader and unwired writer on one panel — still the reason not to separate them Pin 731 → 727. **80 above the banner, still no map.** +Thirty-ninth follow-on on the same version: **SCALE-SEAM (100)** — *the element-connection pair, and +a destination named at the strength the evidence supports*. + +Two methods out of `client.ts` (`648 → 642`) into the existing `apps/web/src/api/model.ts`: +`elementConnections` and `connectElements`. No new mixin. **What they answer: what is physically +joined to what, and record a joint.** + +### The backend names its own writer + +The `/element-connections` route docstring reads: *"Author edges with the `connect_elements` recipe +(`POST /edit` with `{guid_a, guid_b}`)."* Reader and writer, one relationship type +(`IfcRelConnectsElements`), both marked B5. That is the (96) shape and it is what carries the pair. + +### The destination argument is weaker, and is labelled as such + +`model.ts` owns `modelGraphStats` — whose `by_rel` counts the IFC relationship graph **by relation**, +and `IfcRelConnectsElements` is one of those relations — plus `graphNeighbors`, which walks it. So +this pair is one relation of a graph the file already reads, plus its authoring verb. + +**That is a specialisation, not an identity.** `modelGraphStats`/`graphNeighbors` are generic +traversal over every `IfcRel*`; these two are one relation with a verb attached. Recorded as the best +available home rather than a derived one — *the pairing is evidenced, the placement is a judgement, +and collapsing the two would be the overstatement this sequence keeps catching.* + +### Two candidates rejected on checkable grounds + +**`connections.ts` is the trap.** It is *data-source* connections — SQL, ACC, Procore — and shares +nothing with this but the English word. (97) found two version stacks behind one word; this is the +same collision in a **destination** rather than a source, and it is the file a name-based search +would have landed on first. + +**`elements.ts`** holds element *attributes* and views — properties, 5D, colouring, QA, costs. A +relationship between two elements is not an attribute of either. + +`addBasePlate` and `addShearTab` did **not** come despite sharing `services/data/src/aec_data/connections.py` +with these: a backend module is a HOW, the grouping (89) had to reject, and those two author +*physical* assemblies (plates, bolts) rather than `IfcRelConnectsElements` edges. + +### Found while deriving + +`add_connection_assembly` (B5, `IfcRelConnectsWithRealizingElements`) has **no client method +anywhere** in `apps/web/src` — a backend recipe with no web exposure, the same class (93) recorded +for three MEP recipes. Noted, not fixed. + +`client.ts` is 60 methods above the STAYING banner and 4 below. + Thirty-eighth follow-on on the same version: **SCALE-SEAM (99)** — *the content half of a shelf the destination's own first line already claimed*. diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c8533528..d4b11b81 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -178,16 +178,6 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient ahead: number; on_track: number; behind: number; worst: string | null; note: string; }>(`/projects/${pid}/progress/actuals`, { method: "POST", body: JSON.stringify({ actuals, planned }) }); } - /** B5: record a physical connection between two elements (IfcRelConnectsElements, LOD-350 coordination). */ - connectElements(pid: string, guidA: string, guidB: string, description?: string, publish = true) { - return this.editIfc(pid, "connect_elements", { guid_a: guidA, guid_b: guidB, ...(description ? { description } : {}) }, publish); - } - /** B5: the element-to-element connection graph (IfcRelConnectsElements) — pairs + per-element degree. */ - elementConnections(pid: string) { - return this.json<{ count: number; elements_connected: number; max_degree: number; - connections: { a: string; a_class: string; b: string; b_class: string; description: string | null }[] }>( - `/projects/${pid}/element-connections`); - } /** W11 F0: establish the view-keyed representation contexts (Model+Plan; Body/Axis/Box/Annotation/ * FootPrint) the drawing pipeline needs. Idempotent. */ ensureContexts(pid: string, publish = false) { @@ -545,7 +535,7 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient // through (87) worked through, and they are recorded here as DECIDED rather than pending. // // **THIS IS NOT THE END OF SCALE-SEAM, and a previous version of this banner implied it was.** - // 62 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, + // 60 methods still sit ABOVE this line — `disciplineTree`, `classify`, `specManual`, `editUndo`, // `energyModel`, `propmapPlan`, `camReconciliation` and the rest. They were never inside the // CX-1 banner, so no map has ever covered them. The UNFILED map described the TAIL of this file, // not the file. @@ -620,6 +610,10 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient // `authoring.ts`, leaving 62. Role-for-role parallel with the family shelf already there; that // file's first line had claimed "family/content shelf" while holding no content method. // + // (100) took the ELEMENT-CONNECTION pair — `elementConnections`/`connectElements` — to + // `model.ts`, leaving 60. The route docstring names its own writer. `connections.ts` is the trap: + // that file is DATA-SOURCE connections and shares only the word. Reasoning in `model.ts`. + // // the four that stay enumOptions, searchAll, attachmentUrl, templates enumOptions(pid: string) { return this.json>>(`/projects/${pid}/enum-options`); diff --git a/apps/web/src/api/model.ts b/apps/web/src/api/model.ts index 5ded9222..18ff25aa 100644 --- a/apps/web/src/api/model.ts +++ b/apps/web/src/api/model.ts @@ -138,6 +138,38 @@ * * SCALE-SEAM ⓭ adds E57 scan ingest — *can we bring this scan in?* Status plus convert. * Admin audit/error stayed. + + * **SCALE-SEAM (100) adds the ELEMENT-CONNECTION pair** — `elementConnections` (the + * `IfcRelConnectsElements` graph: pairs, per-element degree) and `connectElements` (the verb that + * adds an edge). *What is physically joined to what, and record a joint.* + * + * **The pair is bound by the backend naming its own writer**, the (96) shape: the + * `/element-connections` route docstring reads *"Author edges with the `connect_elements` recipe + * (`POST /edit` with `{guid_a, guid_b}`)."* Reader and writer, one relationship type, both marked + * B5. That link is strong and is what carries the pair. + * + * **The DESTINATION argument is weaker than the pairing argument, and is stated at that strength.** + * This file owns `modelGraphStats`, whose `by_rel` counts the IFC relationship graph *by relation* — + * and `IfcRelConnectsElements` is one of those relations — plus `graphNeighbors`, which walks it. + * So this pair is one relation of the graph this file already reads, plus its authoring verb. *That + * is a SPECIALISATION, not an identity: `modelGraphStats`/`graphNeighbors` are generic traversal + * over every `IfcRel*`, while these two are one relation with a verb attached.* Recorded as the + * best available home rather than a derived one. + * + * **Two candidates were rejected on checkable grounds.** `connections.ts` is the trap: it is + * DATA-SOURCE connections — SQL, ACC, Procore — and shares nothing with this but the English word. + * *(97) found two stacks behind one word; this is the same collision in a destination rather than a + * source.* And `elements.ts` holds element ATTRIBUTES and views (properties, 5D, colouring, QA, + * costs); a relationship between two elements is not an attribute of either. + * + * *`addBasePlate` and `addShearTab` did NOT come, though they share `connections.py` with these.* + * A backend module is a HOW — the grouping (89) had to reject — and those two are W11 B6 authoring + * PHYSICAL assemblies (plates, bolts), not `IfcRelConnectsElements` edges. Different output, + * different question, same file on the server. + * + * *Recorded while deriving this: `add_connection_assembly` (B5, + * `IfcRelConnectsWithRealizingElements`) has NO client method anywhere in `apps/web/src` — a + * backend recipe with no web exposure, the same class (93) recorded for three MEP recipes.* * * SCALE-SEAM ⓳ adds publish history — *what changed between publishes?* Version list, * element diff, cost delta, and the submit/approve/reject review gate. They were **not** @@ -871,5 +903,15 @@ export function withModel>(Base: TBase) { setManufacturerInfo(pid: string, guids: string[], opts: { manufacturer?: string; model_label?: string; production_year?: string; serial?: string; barcode?: string } = {}, publish = true) { return this.editIfc(pid, "set_manufacturer_info", { guids, ...opts }, publish); } + /** B5: record a physical connection between two elements (IfcRelConnectsElements, LOD-350 coordination). */ + connectElements(pid: string, guidA: string, guidB: string, description?: string, publish = true) { + return this.editIfc(pid, "connect_elements", { guid_a: guidA, guid_b: guidB, ...(description ? { description } : {}) }, publish); + } + /** B5: the element-to-element connection graph (IfcRelConnectsElements) — pairs + per-element degree. */ + elementConnections(pid: string) { + return this.json<{ count: number; elements_connected: number; max_degree: number; + connections: { a: string; a_class: string; b: string; b_class: string; description: string | null }[] }>( + `/projects/${pid}/element-connections`); + } }; } diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index e1584a59..b206257d 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -256,6 +256,8 @@ describe("the API client's public surface", () => { "elementDetailing", "classify", "applyDetailingRules", "validateDetailing", "attachDocument", // (99) the content shelf -> authoring.ts, joining the family triple it parallels. "contentCatalog", "placeContent", "importContent", + // (100) the IfcRelConnectsElements reader + its writer -> model.ts. + "elementConnections", "connectElements", ]) { expect(surface.has(k), `${k}() vanished — a call site is now broken`).toBe(true); } diff --git a/docs/roadmap.md b/docs/roadmap.md index 61972eb3..7a601efe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3135,7 +3135,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(99) took sixty-four of those 126 — 62 remain, and there is STILL no map.** A new + **(88)–(100) took sixty-six of those 126 — 60 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, @@ -3390,6 +3390,30 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped true, and this one had been wrong for as long as it had existed.* It is the smallest possible instance of the drift these instructions keep warning about. + **(100) took the ELEMENT-CONNECTION pair to `apps/web/src/api/model.ts`** — + `elementConnections` (the `IfcRelConnectsElements` graph) and `connectElements` (the verb that adds + an edge). **The pair is bound by the backend naming its own writer**, the (96) shape: the + `/element-connections` route docstring says *"Author edges with the `connect_elements` recipe."* + + **The DESTINATION argument is weaker than the pairing argument, and is recorded at that strength.** + `model.ts` owns `modelGraphStats`, which counts the IFC relationship graph BY RELATION — + `IfcRelConnectsElements` being one — and `graphNeighbors`, which walks it. So this is one relation + of a graph the file already reads, plus its verb: **a specialisation, not an identity.** *The + pairing is evidenced; the placement is a judgement. Collapsing the two into one confident sentence + is the overstatement this sequence keeps catching, so they are stated separately.* + + *Two candidates rejected on checkable grounds.* **`connections.ts` is the trap** — it is + DATA-SOURCE connections (SQL, ACC, Procore) and shares only the English word; **(97) found two + version stacks behind one word, and this is the same collision in a destination rather than a + source**, on the very file a name-based search lands on first. And `elements.ts` holds element + ATTRIBUTES and views, whereas a relationship between two elements is not an attribute of either. + `addBasePlate`/`addShearTab` also did not come despite sharing `connections.py`: a backend module is + a HOW, and those author PHYSICAL assemblies rather than relationship edges. + + *Found while deriving, recorded not fixed:* `add_connection_assembly` (B5, + `IfcRelConnectsWithRealizingElements`) has **no client method anywhere** in `apps/web/src` — a + backend recipe with no web exposure, the class (93) recorded for three MEP recipes. + **(93) finished SCALE-SEAM ⑲.** Two methods to the existing `apps/web/src/api/mep.ts` — `connectMep` and `addMepFitting` — which that file has claimed by name since ⑲ under the note *"call `editIfc` (`/edit`) and stay"*. **That note recorded the symptom without diagnosing the diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index ff9ecb9b..fa956542 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 648, # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 642, # SCALE-SEAM (100): THE ELEMENT-CONNECTION PAIR — what is physically joined to what, and record a joint? (648 -> 642). elementConnections + connectElements to model.ts. BOUND BY THE BACKEND NAMING ITS OWN WRITER (the (96) shape): the /element-connections route docstring says "Author edges with the connect_elements recipe (POST /edit with {guid_a, guid_b})". DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS STATED SO: model.ts owns modelGraphStats (counts the IFC relationship graph BY RELATION, and IfcRelConnectsElements is one) and graphNeighbors (walks it), so this is one relation of that graph plus its verb — a SPECIALISATION, not an identity. REJECTED ON CHECKABLE GROUNDS: connections.ts is DATA-SOURCE connections (SQL/ACC/Procore), sharing only the English word — (97) same collision, in a destination; elements.ts holds element ATTRIBUTES, and a relationship between two elements is not an attribute of either. addBasePlate/addShearTab did NOT come despite sharing connections.py: a backend module is a HOW, and they author PHYSICAL assemblies, not IfcRelConnectsElements edges. # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From f5d675148291191e1494d7d79e88bf1ad0b2eefe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:49:48 +0000 Subject: [PATCH 08/23] =?UTF-8?q?R22-ENTITLEMENT=20=E2=91=A4=20=E2=80=94?= =?UTF-8?q?=20an=20agency=20review=20comment=20becomes=20an=20RFI=20somebo?= =?UTF-8?q?dy=20owns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RecordComment` had NO outward link of any kind. An agency's comment on an `entitlement` or `permit` was a text blob at the end of a thread: readable, and impossible to assign, track or close. ④ made comments survive a revision — the INBOUND half of "round-tripping"; this is the outbound half the ring entry still listed as remaining. `POST /projects/{pid}/modules/{key}/{rid}/comments/{cid}/promote` mints a Topic carrying the comment text, the source record's ref and its `element_guids`, and writes a back-link on the comment. THE BACK-LINK IS THE IDEMPOTENCY. A second promote 409s instead of minting a duplicate RFI — the failure mode a promote button produces on every double-click. Both load-bearing assertions in `test_comment_promote.py` were MUTATION-CHECKED: removing the 409 guard makes one comment mint two RFIs (the failure output shows both `comment.promote` activity entries), and removing the back-link write drops `topic_id` from the comment. Follows `promote_markup` rather than inventing a second idiom — mint, carry provenance, link back, 409, audit. REACHABLE, NOT MERELY BUILT: the control renders beside the comment and is replaced by "→ RFI raised" once promoted, because a button whose only remaining outcome is a 409 is worse than no button. Adding it turned `register.ts`'s extraction ratchet red, and the remedy is the one that file states — extraction, never headroom. The comment thread, composer and new control came out to `portal/register/recordComments.ts` (2,516 -> 2,505, pin lowered with it). A genuine leaf: it touches the record's comments, the API and a reload callback and nothing else on the class, and the directory already holds three leaves extracted the same way. WHAT THE PREMISE-CHECK FOUND, and it is why this slice exists. The entry's "Remaining:" line named two things and contradicted itself on one — it listed submittal packages flatly while the note above it said the inbound half had shipped. Measured: the inbound view is real, but assembling a package to send is not, because `modules/transmittal/module.json` types `items` as a textarea and `to_company` as plain text. Package contents are prose no machine can resolve back to the records named, and the recipient cannot be the agency an `entitlement` names, since that is free text too. That is a SCHEMA question, not a workflow one — which is why reading the workflow surface kept reporting it done. The roadmap now says that instead of the flat line. Also corrected: the roadmap's argument against picking SCALE-SEAM quoted `client.ts` at 2,837 lines. It is 642 — copied forward through every slice since, the exact drift the rows beside it document twice. Verified: tsc 0, lint 0, build 0, `vitest run src/portal src/api` 50 files / 275 tests, ruff (the CI command, from `services/api`) 0, and `test_comment_promote`, `test_modules`, `test_topic_lifecycle`, `test_reachable`, `test_declared_imports`, `test_ruff_scope`, `test_file_sizes`, `test_claude_md_gates`, `test_roadmap_status`, `test_doc_substance`, `test_alembic_single_head` all 0. Full 660-suite running as a cross-check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 49 ++++++++ apps/web/src/api/modules.ts | 9 ++ apps/web/src/api/surface.test.ts | 3 + apps/web/src/api/types.ts | 6 +- .../web/src/portal/register/recordComments.ts | 66 +++++++++++ apps/web/src/portal/register/register.ts | 25 ++-- docs/roadmap.md | 34 +++++- ...record_comment_topic_id_r22_entitlement.py | 30 +++++ services/api/run_tests.py | 2 +- services/api/src/aec_api/models.py | 5 + services/api/src/aec_api/modules.py | 57 +++++++++- services/api/src/aec_api/routers/modules.py | 13 +++ services/api/test_comment_promote.py | 107 ++++++++++++++++++ services/api/test_file_sizes.py | 2 +- 14 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/portal/register/recordComments.ts create mode 100644 services/api/migrations/versions/2026_09_04_1200-c8a4e2f71b39_record_comment_topic_id_r22_entitlement.py create mode 100644 services/api/test_comment_promote.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3c40a3..c7e50d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1185,6 +1185,55 @@ Reader and unwired writer on one panel — still the reason not to separate them Pin 731 → 727. **80 above the banner, still no map.** +Fortieth follow-on on the same version: **R22-ENTITLEMENT ⑤** — *an agency review comment becomes an +RFI somebody owns*. + +**The gap.** `RecordComment` had **no outward link of any kind**. An agency's comment on an +`entitlement` or `permit` was a text blob at the end of a thread: readable, and impossible to assign, +track or close. ④ had already made comments survive a revision — that is the *inbound* half of +"round-tripping"; this is the outbound half the ring entry still listed as remaining. + +`POST /projects/{pid}/modules/{key}/{rid}/comments/{cid}/promote` mints a Topic carrying the comment +text, the source record's ref and its `element_guids`, and writes a back-link on the comment. + +### The back-link is the idempotency + +A second promote **409s** instead of minting a duplicate RFI — the failure mode a promote button +produces on every double-click. `services/api/test_comment_promote.py` holds it, and both +load-bearing assertions were **mutation-checked**: removing the 409 guard makes one comment mint two +RFIs, and the failure output shows both `comment.promote` activity entries side by side; removing the +back-link write drops `topic_id` from the comment. + +Follows `promote_markup` (`POST …/drawings/markup/{mid}/promote`) rather than inventing a second +idiom — same shape: mint, carry provenance, link back, 409, audit. + +### Reachable, not merely built + +The control renders beside the comment in `apps/web/src/portal/register/recordComments.ts` and is +replaced by "→ RFI raised" once promoted, because a button whose only remaining outcome is a 409 is +worse than no button. + +Adding it turned `register.ts`'s extraction ratchet red, and **the remedy is the one that file +states: extraction, never headroom.** The comment thread + composer + the new control came out to +`portal/register/recordComments.ts` (`2,516 → 2,505`, pin lowered with it). The block is a genuine +leaf — it touches the record's comments, the API and a reload callback and nothing else on the class +— and the directory already holds three leaves extracted the same way. + +### What the premise-check found, and it is why this slice exists + +The entry's "Remaining:" line named **two** things and **contradicted itself on one**: it listed +submittal packages flatly while the ④ note above it said the inbound half had already shipped. +Measured 2026-09-04 — the inbound *view* is real (`…/related` returns `incoming`), but assembling a +package to send is not, because `modules/transmittal/module.json` types **`items` as a textarea and +`to_company` as plain text**. A package's contents are prose no machine can resolve back to the +records it names, and its recipient cannot be the agency an `entitlement` names, since that is free +text too. *That is a schema question — reference fields — not a workflow one, which is why reading +the workflow surface kept reporting it done.* The roadmap now says that instead of the flat line. + +Also corrected: the roadmap's argument against picking SCALE-SEAM quoted `client.ts` at **2,837 +lines**. It is **642** — the number was copied forward through every slice since, which is the exact +drift the rows beside it document twice. + Thirty-ninth follow-on on the same version: **SCALE-SEAM (100)** — *the element-connection pair, and a destination named at the strength the evidence supports*. diff --git a/apps/web/src/api/modules.ts b/apps/web/src/api/modules.ts index 50039493..21becd75 100644 --- a/apps/web/src/api/modules.ts +++ b/apps/web/src/api/modules.ts @@ -130,6 +130,15 @@ export function withModules>(Base: TBase) { return this.json(`/projects/${pid}/modules/${key}/${rid}/comments`, { method: "POST", body: JSON.stringify({ text }) }); } + /** R22-ENTITLEMENT ⑤: promote a review comment into an RFI (or punch item) somebody owns. An + * agency comment has to leave the thread — be assigned, tracked, closed. 409 if already promoted, + * which is what stops a double-click minting two RFIs for one comment. */ + promoteComment(pid: string, key: string, rid: string, cid: string, kind: "rfi" | "issue" = "rfi") { + return this.json<{ comment_id: string; record: ModuleRecord; + topic: { id: string; type: string; title: string; status: string; element_guids: string[] | null } }>( + `/projects/${pid}/modules/${key}/${rid}/comments/${cid}/promote`, + { method: "POST", body: JSON.stringify({ kind }) }); + } updateModuleRecord(pid: string, key: string, rid: string, data: Record, expectedModifiedAt?: string | null) { // pass the modified_at the editor loaded to opt into the optimistic lock — a concurrent edit diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index b206257d..058563ae 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -258,6 +258,9 @@ describe("the API client's public surface", () => { "contentCatalog", "placeContent", "importContent", // (100) the IfcRelConnectsElements reader + its writer -> model.ts. "elementConnections", "connectElements", + // R22-ENTITLEMENT ⑤ — the promote control in register.ts is the only call site, so losing + // this method silently un-reaches the capability rather than breaking a typecheck. + "promoteComment", ]) { expect(surface.has(k), `${k}() vanished — a call site is now broken`).toBe(true); } diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index b1899958..257a4330 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -384,7 +384,11 @@ export interface ModuleRecord { }; attachments?: RecordAttachmentMeta[]; activity?: { ts: string; actor: string; party: string; action: string; detail: unknown }[]; - comments?: { author: string | null; text: string; created_at: string }[]; + // `id` and `topic_id` are R22-ENTITLEMENT ⑤: a comment must be addressable to be promoted into + // an RFI, and `topic_id` is present ONLY once it has been — absent means not promoted, which + // a build predating the column reports identically rather than claiming a false `null`. + comments?: { id?: string; author: string | null; text: string; created_at: string; + topic_id?: string }[]; available_actions?: { action: string; to: string; party: string[]; requires?: string[] }[]; } export interface RecordAttachmentMeta { diff --git a/apps/web/src/portal/register/recordComments.ts b/apps/web/src/portal/register/recordComments.ts new file mode 100644 index 00000000..f655bc8b --- /dev/null +++ b/apps/web/src/portal/register/recordComments.ts @@ -0,0 +1,66 @@ +/** + * The comment thread on a register record — render it, add to it, and promote an entry out of it. + * + * Lived inline in `register.ts` until R22-ENTITLEMENT ⑤ added the promote control and the file's + * extraction ratchet went red. The pin's question is whether the block is a leaf, and this one is: + * it touches the record's `comments`, the API, and a reload callback, and nothing else on the class. + * + * **R22-ENTITLEMENT ⑤ — why a comment needs a way out.** An agency's review comment is the one input + * that must LEAVE the thread: somebody has to be assigned it and it has to close. Promoting mints an + * RFI carrying the comment text, the source record's ref and its element ties. Once promoted the + * control is replaced by its outcome, because the second press would 409 and a button whose only + * remaining result is an error is worse than no button at all. + */ +import type { ModuleDef, ModuleRecord } from "../../api/client"; + +export interface RecordCommentsDeps { + root: HTMLElement; + api: { + addComment(pid: string, key: string, rid: string, text: string): Promise; + promoteComment(pid: string, key: string, rid: string, cid: string, + kind?: "rfi" | "issue"): Promise; + }; + setStatus(msg: string): void; + reload(): void; +} + +/** Render the thread + composer for one record. */ +export function mountRecordComments(d: RecordCommentsDeps, pid: string, m: ModuleDef, + rid: string, r: ModuleRecord): void { + const cd = document.createElement("div"); + cd.className = "section-title"; cd.textContent = "Comments"; + d.root.appendChild(cd); + + for (const cm of r.comments ?? []) { + const e = document.createElement("div"); e.className = "portal-act"; + e.textContent = `${cm.author ?? ""}: ${cm.text}`; + if (cm.topic_id) { + const done = document.createElement("span"); + done.className = "meta"; done.textContent = " → RFI raised"; + e.appendChild(done); + } else if (cm.id) { + const cid = cm.id; + const pb = document.createElement("button"); + pb.className = "mini-btn"; pb.textContent = "→ RFI"; pb.style.marginLeft = "6px"; + pb.title = "Raise an RFI from this comment"; + pb.onclick = async () => { + pb.disabled = true; + try { await d.api.promoteComment(pid, m.key, rid, cid); d.reload(); } + catch (err) { pb.disabled = false; d.setStatus(`promote failed: ${(err as Error).message}`); } + }; + e.appendChild(pb); + } + d.root.appendChild(e); + } + + const ta = document.createElement("textarea"); + ta.className = "portal-field"; ta.placeholder = "Add a comment…"; ta.style.width = "100%"; + const addBtn = document.createElement("button"); + addBtn.className = "tool-btn"; addBtn.textContent = "Comment"; addBtn.style.margin = "4px 0"; + addBtn.onclick = async () => { + if (!ta.value.trim()) return; + await d.api.addComment(pid, m.key, rid, ta.value.trim()); + d.reload(); + }; + d.root.append(ta, addBtn); +} diff --git a/apps/web/src/portal/register/register.ts b/apps/web/src/portal/register/register.ts index a31fcf20..a373a864 100644 --- a/apps/web/src/portal/register/register.ts +++ b/apps/web/src/portal/register/register.ts @@ -5,6 +5,7 @@ import { statusChip } from "../../ui/chips"; import { type RegisterEmptyKind, registerEmptyEl } from "../../ui/empty"; import { emptyHint } from "../../ui/emptyGuide"; import { escapeHtml as esc, toast } from "../../ui/feedback"; +import { mountRecordComments } from "./recordComments"; import { confidenceReading } from "../../ui/confidenceReading"; import { confirmModal, modalShell, promptModal } from "../../ui/modal"; import { allQueued, dequeue, enqueueUpload, queuedCountForRecord } from "../offlineQueue"; @@ -2289,24 +2290,12 @@ export class RegisterUI { } } - // comments - const cd = document.createElement("div"); cd.className = "section-title"; cd.textContent = "Comments"; - this.ctx.root.appendChild(cd); - for (const cm of r.comments ?? []) { - const e = document.createElement("div"); e.className = "portal-act"; - e.textContent = `${cm.author ?? ""}: ${cm.text}`; - this.ctx.root.appendChild(e); - } - const ta = document.createElement("textarea"); - ta.className = "portal-field"; ta.placeholder = "Add a comment…"; ta.style.width = "100%"; - const addBtn = document.createElement("button"); - addBtn.className = "tool-btn"; addBtn.textContent = "Comment"; addBtn.style.margin = "4px 0"; - addBtn.onclick = async () => { - if (!ta.value.trim()) return; - await this.ctx.host.api.addComment(pid, m.key, rid, ta.value.trim()); - void this.openRecord(m, rid); - }; - this.ctx.root.append(ta, addBtn); + // comments — thread, composer, and the R22-ENTITLEMENT ⑤ promote control. + mountRecordComments({ + root: this.ctx.root, api: this.ctx.host.api, + setStatus: (s) => this.ctx.host.setStatus(s), + reload: () => { void this.openRecord(m, rid); }, + }, pid, m, rid, r); // activity timeline const td = document.createElement("div"); td.className = "section-title"; td.textContent = "Activity"; diff --git a/docs/roadmap.md b/docs/roadmap.md index 7a601efe..70424536 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1134,7 +1134,9 @@ exact failure `roadmapLanes.test.ts` documents in its `MARKS` note. The gates ca needs to keep being interleaved. **Re-measure the ceiling before ever promoting it again** — that is the specific error row 2 made. * **Not the next SCALE-SEAM slice.** ㉘ is genuinely next in a series that has shipped twenty-six - increments, but the series is now cutting into `client.ts` at 2,837 lines from a 3,600-odd start. The marginal slice + increments, but the series is now cutting into `client.ts` at **642** lines from a 3,600-odd start + *(re-derived 2026-09-04; this read "2,837" — 4.4x the real figure — because the number was copied + forward through every slice since, which is the exact drift the rows above document twice)*. The marginal slice is worth less than it was. *(㉘ also needed `MARKS` widened; that shipped in v0.3.1112.)* The vocabulary lives in `apps/web/src/shell/roadmapLanes.test.ts` — a vocabulary change to a population check, which that file's own docstring calls a real change and not housekeeping. @@ -1719,8 +1721,9 @@ stakes we are missing. **Tier 1 — closes the mission's own gaps** -- ◧ **R22-ENTITLEMENT** *(M/L — ①②③ shipped: `approval_conditions.py`, `condition_checks.py`, - `approval_cycles.py` + the `review_cycle` register)* — **permit & entitlement workflow**: jurisdiction +- ◧ **R22-ENTITLEMENT** *(M/L — ①②③④⑤ shipped: `approval_conditions.py`, `condition_checks.py`, + `approval_cycles.py` + the `review_cycle` register, comment inheritance across revisions, and the + comment→RFI promote)* — **permit & entitlement workflow**: jurisdiction submittal packages, review cycles, comment responses, and **conditions of approval carried into the model as constraints**. Today there is a hole between "acquisition" and "construction" in our own mission statement — we underwrite the deal and we build it, and nothing spans approval. @@ -1761,8 +1764,29 @@ stakes we are missing. about the party you are about to argue with. Days are **calendar**, stated on the response, because a statutory review clock does not pause for a weekend and construction durations elsewhere here are working days. - **Remaining:** submittal *packages* (the documents that go to the authority) and comment-response - round-tripping into RFI/issue records. + ⑤ **comment-response round-tripping into RFI/issue records, shipped 2026-09-04.** `RecordComment` + had **no outward link of any kind** — an agency's comment on an `entitlement` or `permit` was a + text blob at the end of a thread: readable, and impossible to assign, track or close. ④ made + comments survive a revision, which is the INBOUND half; this is the outbound half. + `POST …/modules/{key}/{rid}/comments/{cid}/promote` mints a Topic carrying the comment, the source + record's ref and its `element_guids`, and writes a back-link. **The back-link is the idempotency**: + a second promote 409s rather than minting a duplicate RFI, which is what a promote button does on + every double-click. Follows `promote_markup` rather than inventing a second idiom. Held by + `services/api/test_comment_promote.py`, whose two load-bearing assertions were mutation-checked — + removing the 409 guard makes one comment mint two RFIs, and the failure output shows both. + *Reachable, not merely built:* the control renders beside the comment in + `apps/web/src/portal/register/register.ts` and is replaced by "→ RFI raised" once promoted, because + a button whose only remaining outcome is a 409 is worse than no button. + + **Remaining: the OUTBOUND submittal package, and the reason is now specific rather than vague.** + This line used to name "submittal packages" flatly while the ④ note above said the inbound half had + already shipped — the entry contradicted itself. Measured 2026-09-04: the inbound *view* is real + (`…/related` returns `incoming`), but assembling a package to send is not, because + `modules/transmittal/module.json` types **`items` as a textarea and `to_company` as plain text**. + A package's contents are therefore prose no machine can resolve back to the records it names, and + its recipient cannot be the agency an `entitlement` names, since that field is free text too. + *That is a schema question — reference fields — not a workflow one, which is why reading the + workflow surface kept reporting this as done.* ⚠️ **Two name collisions sit on this item; gap-check on SEMANTICS before touching it.** `tiers.py` is **subscription tiers** (free/pro/enterprise), nothing to do with land use — it was diff --git a/services/api/migrations/versions/2026_09_04_1200-c8a4e2f71b39_record_comment_topic_id_r22_entitlement.py b/services/api/migrations/versions/2026_09_04_1200-c8a4e2f71b39_record_comment_topic_id_r22_entitlement.py new file mode 100644 index 00000000..83ebd11d --- /dev/null +++ b/services/api/migrations/versions/2026_09_04_1200-c8a4e2f71b39_record_comment_topic_id_r22_entitlement.py @@ -0,0 +1,30 @@ +"""record_comments.topic_id — promote an agency review comment into an RFI (R22-ENTITLEMENT ⑤) + +Revision ID: c8a4e2f71b39 +Revises: f4b8c2d51e93 +Create Date: 2026-09-04 12:00:00.000000 +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'c8a4e2f71b39' +down_revision: str | None = 'f4b8c2d51e93' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Nullable and no server default: an existing comment has not been promoted, and NULL says that + # without inventing a state for it. The back-link is what makes promotion idempotent. + with op.batch_alter_table('record_comments', schema=None) as batch_op: + batch_op.add_column(sa.Column('topic_id', sa.String(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('record_comments', schema=None) as batch_op: + batch_op.drop_column('topic_id') diff --git a/services/api/run_tests.py b/services/api/run_tests.py index 81739301..d3db2341 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/models.py b/services/api/src/aec_api/models.py index 424ae1b5..978e6c61 100644 --- a/services/api/src/aec_api/models.py +++ b/services/api/src/aec_api/models.py @@ -155,6 +155,11 @@ class RecordComment(Base): record_id: Mapped[str] = mapped_column(String, index=True) author: Mapped[str | None] = mapped_column(String, nullable=True) text: Mapped[str] = mapped_column(Text, nullable=False) + # R22-ENTITLEMENT ⑤ — the RFI/issue this comment was promoted into, if any. An agency review + # comment is the one input that has to LEAVE the thread: somebody must be assigned it and it must + # close. Nullable because promotion is a deliberate act, and the back-link is what makes it + # idempotent — a second promote 409s instead of minting a duplicate RFI for the same comment. + topic_id: Mapped[str | None] = mapped_column(String, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/services/api/src/aec_api/modules.py b/services/api/src/aec_api/modules.py index e9873dc7..1587c9b6 100644 --- a/services/api/src/aec_api/modules.py +++ b/services/api/src/aec_api/modules.py @@ -25,8 +25,8 @@ ) from sqlalchemy.orm import Session -from . import fin_gov, module_schema, rbac -from .models import EnumOption, RecordActivity, RecordComment +from . import audit, fin_gov, module_schema, rbac +from .models import EnumOption, RecordActivity, RecordComment, Topic # the read + workflow-evaluation base is a leaf over the registry (no writes, no cycles); re-exported # so every existing `modules.list_records` / `.available_actions` / … caller keeps working. @@ -462,8 +462,12 @@ def get_record(db: Session, key: str, project_id: str, rid: str) -> dict: ref_by_id = dict(ancestry) ids = [aid for aid, _ in ancestry] + [rid] rec["comments"] = sorted( - ({"author": cm.author, "text": cm.text, + ({"id": cm.id, "author": cm.author, "text": cm.text, "created_at": cm.created_at.isoformat() if cm.created_at else None, + # R22-ENTITLEMENT ⑤: present ONLY when promoted, so a caller cannot read `None` as "not + # yet" on a build that predates the column. `id` is exposed for the same reason promotion + # needs it — a comment nobody can address is a comment nobody can act on. + **({"topic_id": cm.topic_id} if cm.topic_id else {}), **({"inherited": True, "on_ref": ref_by_id[cm.record_id]} if cm.record_id != rid else {})} for cm in db.query(RecordComment).filter( @@ -933,6 +937,53 @@ def add_comment(db: Session, key: str, project_id: str, rid: str, text: str, return get_record(db, key, project_id, rid) +def promote_comment(db: Session, key: str, project_id: str, rid: str, cid: str, + author: str, kind: str = "rfi") -> dict: + """R22-ENTITLEMENT ⑤ — turn a review comment into an RFI/issue Topic somebody owns. + + **The gap this closes.** `RecordComment` had no outward link of any kind, so an agency's review + comment on an `entitlement` or `permit` was a text blob at the end of a thread: readable, and + impossible to assign, track or close. The ring's own remainder called this "comment-response + round-tripping into RFI/issue records", and the round trip was missing in the *outbound* + direction — ④ had already made comments survive a revision, which is the inbound half. + + Follows `promote_markup` exactly rather than inventing a second idiom: mint a `Topic`, carry the + source's identity into the description, copy the record's `element_guids` so the RFI lands on the + model, write the back-link, and audit. **The back-link is the idempotency**: a second promote of + the same comment 409s instead of minting a duplicate RFI, which is the failure mode a + "promote" button produces on every double-click. + """ + rec = get_record(db, key, project_id, rid) # 404 if the record is missing + cm = db.get(RecordComment, cid) + if not cm or cm.project_id != project_id or cm.module != key or cm.record_id != rid: + raise HTTPException(404, "no such comment on this record") + if cm.topic_id: + raise HTTPException(409, "comment already promoted") + if kind not in ("rfi", "issue"): + raise HTTPException(422, "kind must be rfi or issue") + + ref = rec.get("ref") or rid + title = (cm.text or "").strip().splitlines()[0][:80] or f"{key} {ref} review comment" + guids = rec.get("element_guids") or None + t = Topic(project_id=project_id, type=("rfi" if kind == "rfi" else "punch"), status="open", + author=author, title=title, + description=(f"Raised from a review comment on {key} {ref}" + + (f" by {cm.author}" if cm.author else "") + ".\n\n" + (cm.text or "")), + element_guids=guids) + db.add(t) + db.flush() + cm.topic_id = t.id + _log(db, project_id, key, rid, author, None, "comment.promote", + {"comment": cid, "topic": t.id, "kind": kind}) + audit.record(db, action="record.comment.promote", actor=author, method="POST", topic_id=t.id, + path=f"/projects/{project_id}/modules/{key}/{rid}/comments/{cid}/promote", + detail={"module": key, "record": rid, "comment": cid}) + db.commit() + return {"comment_id": cid, "topic": {"id": t.id, "type": t.type, "title": t.title, + "status": t.status, "element_guids": t.element_guids}, + "record": get_record(db, key, project_id, rid)} + + def iter_csv(db: Session, key: str, project_id: str, page: int = 1000): """Module record list → CSV, streamed in pages so a 200k-record module never materializes in one request (the previous single limit=100000 load was a memory/DoS vector). Yields CSV chunks.""" diff --git a/services/api/src/aec_api/routers/modules.py b/services/api/src/aec_api/routers/modules.py index ca0294d5..e59406b9 100644 --- a/services/api/src/aec_api/routers/modules.py +++ b/services/api/src/aec_api/routers/modules.py @@ -887,6 +887,19 @@ def add_comment(pid: str, key: str, rid: str, text: str = Body(..., embed=True), return mod_engine.add_comment(db, key, pid, rid, text, user) +@router.post("/projects/{pid}/modules/{key}/{rid}/comments/{cid}/promote", status_code=201) +def promote_comment(pid: str, key: str, rid: str, cid: str, kind: str = Body("rfi", embed=True), + db: Session = Depends(get_db), user: str = Depends(require_role("reviewer"))): + """R22-ENTITLEMENT ⑤ — promote a review comment into an RFI (or punch item) somebody owns. + + An agency's review comment is the one input that must leave the thread: it has to be assigned, + tracked and closed. Mints a Topic carrying the comment text, the source record's ref and its + `element_guids`, and writes a back-link so a second promote 409s rather than minting a duplicate. + Mirrors `POST /projects/{pid}/drawings/markup/{mid}/promote` rather than inventing a second idiom. + """ + return mod_engine.promote_comment(db, key, pid, rid, cid, user, kind) + + @router.post("/projects/{pid}/modules/{key}/{rid}/assign") def assign_record(pid: str, key: str, rid: str, assignee: str | None = Body(None, embed=True), db: Session = Depends(get_db), user: str = Depends(require_role("reviewer"))): diff --git a/services/api/test_comment_promote.py b/services/api/test_comment_promote.py new file mode 100644 index 00000000..4791d4a6 --- /dev/null +++ b/services/api/test_comment_promote.py @@ -0,0 +1,107 @@ +"""R22-ENTITLEMENT ⑤ — an agency review comment becomes an RFI somebody owns. + +`RecordComment` had no outward link of any kind: an agency's comment on an `entitlement` or `permit` +was a text blob at the end of a thread — readable, and impossible to assign, track or close. ④ had +already made comments survive a revision, which is the INBOUND half of "round-tripping"; this is the +outbound half the ring entry still listed as remaining. + +Follows `promote_markup` rather than inventing a second idiom, and the back-link is the idempotency: +a second promote 409s instead of minting a duplicate RFI for the same comment — the failure mode a +"promote" button produces on every double-click. + +Run: PYTHONPATH=src ./.venv/bin/python test_comment_promote.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_comment_promote.db" +os.environ["STORAGE_DIR"] = "./test_storage_comment_promote" +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_comment_promote.db",): + if os.path.exists(_f): + os.remove(_f) + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api.main import app # noqa: E402 + +with TestClient(app) as c: + pid = c.post("/projects", json={"name": "Entitlement P"}).json()["id"] + rec = c.post(f"/projects/{pid}/modules/entitlement", + json={"data": {"subject": "Site plan review", "agency": "City Planning", + "application_type": "Site Plan"}}).json() + rid = rec["id"] + + # --- the comment exists and is addressable ------------------------------------------------- + # It was not, before this slice: the serialised comment carried author/text/created_at and no + # id, so nothing could name one comment out of a thread in order to act on it. + body = ("Provide a shade study for the north plaza before the hearing.\n" + "Ref: condition 14.") + c.post(f"/projects/{pid}/modules/entitlement/{rid}/comments", json={"text": body}) + got = c.get(f"/projects/{pid}/modules/entitlement/{rid}").json() + assert len(got["comments"]) == 1, got["comments"] + cid = got["comments"][0]["id"] + assert cid, "the comment must be addressable or it cannot be promoted" + assert "topic_id" not in got["comments"][0], "an unpromoted comment must not claim a topic" + + # --- promote → a real RFI carrying the comment and its source ------------------------------ + r = c.post(f"/projects/{pid}/modules/entitlement/{rid}/comments/{cid}/promote", + json={"kind": "rfi"}) + assert r.status_code == 201, (r.status_code, r.text) + out = r.json() + tid = out["topic"]["id"] + assert out["topic"]["type"] == "rfi", out["topic"] + assert out["topic"]["status"] == "open", out["topic"] + # The title is the comment's FIRST LINE, not the whole body: an RFI list is scanned, and a + # two-paragraph title is unreadable in it. + assert out["topic"]["title"] == "Provide a shade study for the north plaza before the hearing.", \ + out["topic"]["title"] + + # The description must carry the provenance — which record, and who said it. An RFI that does + # not name where it came from sends someone back to find the thread by hand. + topic = c.get(f"/projects/{pid}/topics/{tid}").json() + assert "entitlement" in topic["description"], topic["description"] + assert rec["ref"] in topic["description"], (rec["ref"], topic["description"]) + assert "shade study" in topic["description"], topic["description"] + + # --- the back-link is written, and it is what the UI reads --------------------------------- + got2 = c.get(f"/projects/{pid}/modules/entitlement/{rid}").json() + assert got2["comments"][0].get("topic_id") == tid, got2["comments"][0] + + # --- promoting the same comment twice is refused, not duplicated --------------------------- + # Without the back-link this mints a second RFI for one comment every time the button is + # pressed, and nothing downstream can tell the copies apart. + again = c.post(f"/projects/{pid}/modules/entitlement/{rid}/comments/{cid}/promote", json={}) + assert again.status_code == 409, (again.status_code, again.text) + assert len(c.get(f"/projects/{pid}/topics").json()) == 1, "a refused promote must mint nothing" + + # --- the element tie is carried, because an RFI belongs on the model ----------------------- + # GlobalId is the only identity that survives a reload, so the RFI has to reference the element + # rather than a viewer id; the record's own tie is the honest source for it. + rec2 = c.post(f"/projects/{pid}/modules/entitlement", + json={"data": {"subject": "Height variance", "agency": "City Planning", + "application_type": "Variance"}}).json() + rid2 = rec2["id"] + c.post(f"/projects/{pid}/modules/entitlement/{rid2}/elements", + json={"guids": ["1a2b3c4d5e6f7g8h9i0j1k"]}) + c.post(f"/projects/{pid}/modules/entitlement/{rid2}/comments", + json={"text": "Parapet exceeds the district limit."}) + cid2 = c.get(f"/projects/{pid}/modules/entitlement/{rid2}").json()["comments"][0]["id"] + r2 = c.post(f"/projects/{pid}/modules/entitlement/{rid2}/comments/{cid2}/promote", json={}) + assert r2.status_code == 201, (r2.status_code, r2.text) + assert r2.json()["topic"]["element_guids"] == ["1a2b3c4d5e6f7g8h9i0j1k"], r2.json()["topic"] + + # --- refusals: an unknown comment, and a kind nobody defined ------------------------------- + assert c.post(f"/projects/{pid}/modules/entitlement/{rid}/comments/nope/promote", + json={}).status_code == 404 + c.post(f"/projects/{pid}/modules/entitlement/{rid}/comments", json={"text": "Third round."}) + cid3 = [x for x in c.get(f"/projects/{pid}/modules/entitlement/{rid}").json()["comments"] + if not x.get("topic_id")][0]["id"] + bad = c.post(f"/projects/{pid}/modules/entitlement/{rid}/comments/{cid3}/promote", + json={"kind": "wishlist"}) + assert bad.status_code == 422, (bad.status_code, bad.text) + + # a comment on a DIFFERENT record must not be promotable through this record's path — the + # engine matches project+module+record, not the comment id alone. + cross = c.post(f"/projects/{pid}/modules/entitlement/{rid2}/comments/{cid3}/promote", json={}) + assert cross.status_code == 404, (cross.status_code, cross.text) + +print("test_comment_promote OK") diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index fa956542..1cfe755a 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -140,7 +140,7 @@ def check(label, ok, detail=""): # above records why the timing matters: "a ratchet added at the point of pain only ratifies the # 2_546 → 2_516 (v0.3.1000): the model-elements block moved to tiedElements.ts so the # lifecycle card could mount on every tied GUID without growing this file. - "apps/web/src/portal/register/register.ts": 2_516, + "apps/web/src/portal/register/register.ts": 2_505, # R22-ENTITLEMENT (5): the comment thread + composer + the new promote control -> portal/register/recordComments.ts (2_516 -> 2_505). The ratchet went red on the promote control and the remedy is the one this file states: extraction, never headroom. The block is a genuine leaf — it touches the record's comments, the API and a reload callback, and nothing else on the class — and it follows the directory's own convention (elementTies.ts, schemaStale.ts, tiedElements.ts are already extracted the same way). } #: Exempt because a human never reads them top-to-bottom. Name them, never infer them. From d826115838d89f2e7d94b0c6cc89c164a0aa3c59 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:05:41 +0000 Subject: [PATCH 09/23] =?UTF-8?q?R22-ENTITLEMENT=20=E2=91=A4=20review:=20p?= =?UTF-8?q?romotion=20claims=20the=20comment=20atomically,=20and=20a=20bla?= =?UTF-8?q?nk=20one=20no=20longer=20500s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings on #434, both reproduced before fixing and both mutation-checked after. **A whitespace-only comment 500s on promote.** The comment route takes `text: str = Body(...)` with no min-length, so `{"text": " \n "}` is a 201. Promoting it ran `.strip().splitlines()[0]` over an empty list — IndexError. The `or f"{key} {ref} review comment"` fallback written for exactly this case had never been reachable at all: when the list is non-empty its first element is never blank, so the `or` arm could not fire. The guard makes it live. **Promotion idempotency was not atomic — and the damage is worse than a duplicate.** `SessionLocal` is `expire_on_commit=False`, so a request that read the comment before a concurrent promote committed keeps seeing a null back-link for as long as it holds the session; the `if cm.topic_id` guard reads that stale copy. A plain assignment then let the later writer overwrite the back-link, minting a second RFI AND orphaning the first, whose Topic no comment pointed at any more. The claim is now a conditional `UPDATE ... WHERE topic_id IS NULL`: under Postgres read-committed the loser blocks on the winner's row lock and re-evaluates the predicate against the committed row; under SQLite the writes serialize to the same effect. Rolling back discards the Topic flushed a moment earlier, so a losing promote leaves nothing behind. The race test is deterministic rather than timing-dependent — the loser reads, the winner commits, the loser proceeds from its stale identity map, which is the production sequence. Mutation-checked both ways: restoring the unguarded index raises IndexError, restoring the plain assignment fails "a stale-read promote must be refused, not duplicated". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- services/api/src/aec_api/modules.py | 20 +++++++++-- services/api/test_comment_promote.py | 54 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/services/api/src/aec_api/modules.py b/services/api/src/aec_api/modules.py index 1587c9b6..e95dadf0 100644 --- a/services/api/src/aec_api/modules.py +++ b/services/api/src/aec_api/modules.py @@ -963,7 +963,12 @@ def promote_comment(db: Session, key: str, project_id: str, rid: str, cid: str, raise HTTPException(422, "kind must be rfi or issue") ref = rec.get("ref") or rid - title = (cm.text or "").strip().splitlines()[0][:80] or f"{key} {ref} review comment" + # `splitlines()` on stripped whitespace-only text yields [], so `[0]` raised IndexError — a 500 + # on a comment the comment route itself accepts (`text: str = Body(...)` has no min-length). The + # fallback title below was written for exactly this case and was unreachable until now: when the + # list is non-empty its first element is never blank, so the `or` arm could never fire. + lines = (cm.text or "").strip().splitlines() + title = lines[0][:80] if lines else f"{key} {ref} review comment" guids = rec.get("element_guids") or None t = Topic(project_id=project_id, type=("rfi" if kind == "rfi" else "punch"), status="open", author=author, title=title, @@ -972,7 +977,18 @@ def promote_comment(db: Session, key: str, project_id: str, rid: str, cid: str, element_guids=guids) db.add(t) db.flush() - cm.topic_id = t.id + # The `cm.topic_id` check above cannot be the last word: two requests each hold their own session, + # each read a null back-link, and a plain assignment lets the later commit overwrite the earlier + # one — minting a duplicate RFI *and* orphaning the first, whose Topic no comment then points at. + # The claim is therefore a conditional UPDATE. Under Postgres read-committed the loser blocks on + # the winner's row lock, then re-evaluates `topic_id IS NULL` against the committed row and + # matches nothing; under SQLite the writes serialize to the same effect. Rolling back discards + # the Topic flushed a moment ago, so a losing promote leaves nothing behind. + if not db.execute(update(RecordComment) + .where(RecordComment.id == cid, RecordComment.topic_id.is_(None)) + .values(topic_id=t.id)).rowcount: + db.rollback() + raise HTTPException(409, "comment already promoted") _log(db, project_id, key, rid, author, None, "comment.promote", {"comment": cid, "topic": t.id, "kind": kind}) audit.record(db, action="record.comment.promote", actor=author, method="POST", topic_id=t.id, diff --git a/services/api/test_comment_promote.py b/services/api/test_comment_promote.py index 4791d4a6..99cd66f4 100644 --- a/services/api/test_comment_promote.py +++ b/services/api/test_comment_promote.py @@ -19,9 +19,13 @@ if os.path.exists(_f): os.remove(_f) +from fastapi import HTTPException # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 +from aec_api import modules as mod_engine # noqa: E402 +from aec_api.db import SessionLocal # noqa: E402 from aec_api.main import app # noqa: E402 +from aec_api.models import RecordComment # noqa: E402 with TestClient(app) as c: pid = c.post("/projects", json={"name": "Entitlement P"}).json()["id"] @@ -104,4 +108,54 @@ cross = c.post(f"/projects/{pid}/modules/entitlement/{rid2}/comments/{cid3}/promote", json={}) assert cross.status_code == 404, (cross.status_code, cross.text) + # --- a whitespace-only comment promotes to the fallback title, it does not 500 --------------- + # The comment route takes `text: str = Body(...)` with no min-length, so " \n " is a 201. + # Promoting it ran `.strip().splitlines()[0]` over an empty list — IndexError, 500, and the + # fallback title written for exactly this case never fired. + rec3 = c.post(f"/projects/{pid}/modules/entitlement", + json={"data": {"subject": "Blank", "agency": "City Planning", + "application_type": "Site Plan"}}).json() + rid3 = rec3["id"] + assert c.post(f"/projects/{pid}/modules/entitlement/{rid3}/comments", + json={"text": " \n "}).status_code == 201 + cid4 = c.get(f"/projects/{pid}/modules/entitlement/{rid3}").json()["comments"][0]["id"] + blank = c.post(f"/projects/{pid}/modules/entitlement/{rid3}/comments/{cid4}/promote", json={}) + assert blank.status_code == 201, (blank.status_code, blank.text) + assert blank.json()["topic"]["title"] == f"entitlement {rec3['ref']} review comment", \ + blank.json()["topic"]["title"] + + # --- two sessions that both read a null back-link: exactly one promote survives ------------- + # The `if cm.topic_id` guard reads the SESSION's copy, and `SessionLocal` is + # `expire_on_commit=False`, so a request that loaded the comment before a concurrent promote + # committed still sees None however long it holds it. A plain assignment therefore let the later + # writer overwrite the back-link — minting a duplicate RFI AND orphaning the first, whose Topic + # no comment pointed at any more. The claim is a conditional UPDATE; this is the loser's path. + rec4 = c.post(f"/projects/{pid}/modules/entitlement", + json={"data": {"subject": "Race", "agency": "City Planning", + "application_type": "Site Plan"}}).json() + rid4 = rec4["id"] + c.post(f"/projects/{pid}/modules/entitlement/{rid4}/comments", + json={"text": "Two reviewers pressed promote at once."}) + cid5 = c.get(f"/projects/{pid}/modules/entitlement/{rid4}").json()["comments"][0]["id"] + + before = len(c.get(f"/projects/{pid}/topics").json()) + loser, winner = SessionLocal(), SessionLocal() + stale = loser.get(RecordComment, cid5) # the loser reads first: topic_id is None + assert stale.topic_id is None + won = mod_engine.promote_comment(winner, "entitlement", pid, rid4, cid5, "winner", "rfi") + assert stale.topic_id is None, "the loser's session still holds the pre-promote read" + try: + mod_engine.promote_comment(loser, "entitlement", pid, rid4, cid5, "loser", "rfi") + raise AssertionError("a stale-read promote must be refused, not duplicated") + except HTTPException as e: + assert e.status_code == 409, e.status_code + loser.close() + winner.close() + + # and the loser left nothing behind: one new Topic, still pointed at by the comment. + assert len(c.get(f"/projects/{pid}/topics").json()) == before + 1, "the loser minted an orphan" + linked = [x for x in c.get(f"/projects/{pid}/modules/entitlement/{rid4}").json()["comments"] + if x["id"] == cid5][0] + assert linked["topic_id"] == won["topic"]["id"], (linked, won["topic"]["id"]) + print("test_comment_promote OK") From c437a7f8ab11cb75fce9ad408f6b7ad688855763 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:41:21 +0000 Subject: [PATCH 10/23] =?UTF-8?q?R24-REPORTS-BY-MOMENT=20=E2=80=94=20a=20f?= =?UTF-8?q?inished=20pack=20can=20be=20sent,=20not=20only=20downloaded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /projects/{pid}/jobs/{job_id}/deliver` emails any finished job's artifact to named recipients, surfaced as **Send** beside **Download** in the job tray. **The roadmap named the wrong blocker, one layer too high.** The entry said making a pack a scheduled deliverable "still wants a delivery surface and SMTP". Both already existed when that was written: `mailer.py` sends real mail (stdlib smtplib, a Settings "Test connection" button), and `POST …/notifications/digest` is a working assemble-then-send surface returning a per-recipient status map. What was actually missing was one size smaller — **the mailer could not carry a file**. That is why the entry sat: the two things it named were present, so every look confirmed it and nobody checked the layer below. `build_message` gained attachments. The ORDER is load-bearing: `add_alternative` must run before `add_attachment`, or the html body lands inside the mixed part — Python's EmailMessage refuses outright ("Cannot convert mixed to alternative"), which the test asserts rather than assumes. Refusals mirror the download route exactly (404 wrong project, 409 while queued/running, 404 with no artifact) so a caller does not learn two answers to "is this artifact ready", plus two of delivery's own: an empty recipient list is 422 rather than a silent success, and over 15 MB is 413 rather than a per-recipient error from a server that would have bounced it anyway. An unconfigured deployment returns 200 with every recipient `disabled`, so the UI reads `smtp_configured` before claiming a send. The delivery is audited — a file leaving the system is what an audit log is for. **Not shipped, deliberately: the SCHEDULED half.** There is no scheduler of any kind in this tree — no APScheduler, no croniter, no cron — so the existing digest is admin-triggered and nothing runs on a date. Choosing in-process versus external cron hitting an endpoint is a deployment decision with different operational consequences, not a wiring task. The roadmap now says that instead of naming two things that already ship. Mutation-checked, four ways on the route and one on the tray: dropping the empty-recipient refusal reproduces the silent success it exists to prevent (200 with `"results":{}`); dropping the size cap admits 15 MB + 1; ignoring attachments loses the file; attaching before the alternative raises; and ungating Send from `hasArtifact` offers it on a job with nothing to send. The client-caller gate did its job here — it failed the build because `deliverJobArtifact` had no screen, which is what drove the job-tray wiring rather than shipping another endpoint nobody can reach. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 22 +++++ apps/web/public/wasm/web-ifc-mt.wasm | Bin apps/web/public/wasm/web-ifc.wasm | Bin apps/web/src/api/routines.ts | 10 ++ apps/web/src/api/surface.test.ts | 2 +- apps/web/src/main.ts | 17 ++++ apps/web/src/ui/jobTray.test.ts | 28 ++++++ apps/web/src/ui/jobTray.ts | 20 ++++ docs/roadmap.md | 18 +++- services/api/run_tests.py | 2 +- services/api/src/aec_api/mailer.py | 19 +++- services/api/src/aec_api/routers/jobs.py | 61 +++++++++++- services/api/test_artifact_deliver.py | 121 +++++++++++++++++++++++ 13 files changed, 309 insertions(+), 11 deletions(-) mode change 100644 => 100755 apps/web/public/wasm/web-ifc-mt.wasm mode change 100644 => 100755 apps/web/public/wasm/web-ifc.wasm create mode 100644 services/api/test_artifact_deliver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e50d2b..279a1e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — R24-REPORTS-BY-MOMENT: a finished pack can be sent, not only downloaded + +`POST /projects/{pid}/jobs/{job_id}/deliver` emails any finished job's artifact to named +recipients, surfaced as **Send** beside **Download** in the job tray. + +**The roadmap named the wrong blocker, one layer too high.** It said this "still wants a delivery +surface and SMTP" — both of which already shipped: `mailer.py` sends real mail, and the notification +digest is a working assemble-then-send surface. What was actually missing was smaller and more +specific: **the mailer could not carry a file**. `build_message` gained attachments, and the order +matters — `add_alternative` must run before `add_attachment` or Python refuses outright, which the +test asserts rather than assumes. + +Refusals mirror the download route exactly (404 wrong project, 409 while queued/running, 404 with no +artifact), so a caller does not learn two answers to "is this artifact ready", plus two of its own: +an empty recipient list is 422 rather than a silent success, and over 15 MB is 413 rather than a +per-recipient error from a server that would have bounced it. A deployment with no SMTP configured +returns 200 with every recipient `disabled`, so the UI reads `smtp_configured` before claiming a +send. The delivery is audited — a file leaving the system is what an audit log is for. + +**Not shipped, deliberately: the SCHEDULED half.** There is no scheduler of any kind in this tree, +so choosing in-process versus external cron is a deployment decision, not a wiring task. + ## v0.3.1143 (2026-09-01) — SCALE-SEAM ㉝, Last-Planner onto schedule.ts Six methods out of `client.ts` into the existing `apps/web/src/api/schedule.ts` mixin diff --git a/apps/web/public/wasm/web-ifc-mt.wasm b/apps/web/public/wasm/web-ifc-mt.wasm old mode 100644 new mode 100755 diff --git a/apps/web/public/wasm/web-ifc.wasm b/apps/web/public/wasm/web-ifc.wasm old mode 100644 new mode 100755 diff --git a/apps/web/src/api/routines.ts b/apps/web/src/api/routines.ts index 8bdda385..1aab3382 100644 --- a/apps/web/src/api/routines.ts +++ b/apps/web/src/api/routines.ts @@ -115,5 +115,15 @@ export function withRoutines>(Base: TBase) { jobArtifactUrl(pid: string, jobId: string): string { return this.url(`/projects/${pid}/jobs/${jobId}/artifact`); } + /** R24-REPORTS-BY-MOMENT — mail a finished job's artifact to recipients: the "shared, not just + * downloaded" half. Same refusals as the artifact URL above (404 / 409 while running / 404 with + * no artifact), plus 422 on no recipients and 413 over the 15 MB cap. On a deployment with no + * SMTP configured this SUCCEEDS with every recipient reported `disabled` — check + * `smtp_configured` before telling the user it was sent. */ + deliverJobArtifact(pid: string, jobId: string, to: string[], note = "") { + return this.json<{ smtp_configured: boolean; filename: string; bytes: number; + results: Record }>( + `/projects/${pid}/jobs/${jobId}/deliver`, { method: "POST", body: JSON.stringify({ to, note }) }); + } }; } diff --git a/apps/web/src/api/surface.test.ts b/apps/web/src/api/surface.test.ts index 058563ae..59282383 100644 --- a/apps/web/src/api/surface.test.ts +++ b/apps/web/src/api/surface.test.ts @@ -205,7 +205,7 @@ describe("the API client's public surface", () => { "escalationsScan", "sendDigest", "notificationStream", // 18 overdue / digest "reviewModelVersion", "modelVersions", "versionDiff", // 19 publish history "importClashXlsx", "importClashXml", // 20 clash import - "enqueueJob", "jobs", "jobArtifactUrl", // 21 job tray + "enqueueJob", "jobs", "jobArtifactUrl", "deliverJobArtifact", // 21 job tray "projects", "createProject", "importBundle", // 22 project catalog "integrations", "license", "capabilities", // 23 deploy entitle "siteContext", "parcelAnalyze", "parcelsScreen", // 24 land around site diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 0fe22741..7db55cc4 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -2171,6 +2171,23 @@ const _jobs = _embed ? null : mountJobTray({ host: toolbar, fetch: () => (projectId ? api.jobs(projectId, 25) : Promise.resolve([])), artifactUrl: (j) => api.jobArtifactUrl(projectId!, j.id), + // R24-REPORTS-BY-MOMENT — "shared, not just downloaded". `prompt` rather than a modal on purpose: + // the recipient list is the whole input, and a dialog for one text field is chrome. An + // unconfigured deployment answers 200 with every recipient `disabled`, which is why the notice + // below reads `smtp_configured` instead of assuming a 200 means the mail went. + onSend: (j) => { + const to = window.prompt("Email this artifact to (comma-separated addresses):", ""); + if (to === null) return; + const addrs = to.split(",").map((a) => a.trim()).filter(Boolean); + if (!addrs.length) { notify("No recipients — nothing sent.", "error"); return; } + void api.deliverJobArtifact(projectId!, j.id, addrs) + .then((r) => notify( + r.smtp_configured + ? `${r.filename} sent to ${(r.results.sent ?? []).length} of ${addrs.length}` + : "Email is not configured on this server — nothing was sent.", + r.smtp_configured && (r.results.sent ?? []).length ? "success" : "error")) + .catch((e: Error) => notify(`Send failed — ${e.message}`, "error")); + }, // The completion notice is the point of the tray: it is what makes leaving safe. onSettled: (j) => notify( j.state === "error" ? `${jobLabel(j.kind)} failed — ${j.error ?? "no detail"}` : `${jobLabel(j.kind)} finished`, diff --git a/apps/web/src/ui/jobTray.test.ts b/apps/web/src/ui/jobTray.test.ts index 574181fc..c7d05263 100644 --- a/apps/web/src/ui/jobTray.test.ts +++ b/apps/web/src/ui/jobTray.test.ts @@ -305,4 +305,32 @@ describe("the tray is actually reachable", () => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); expect(btn().getAttribute("aria-expanded")).toBe("false"); }); + + /** + * R24-REPORTS-BY-MOMENT — the send affordance is gated on there being a file, exactly like the + * download link beside it. Asserted rather than assumed because the two gates are written + * separately, and a "Send" on a running job offers to mail something that does not exist yet. + */ + it("offers Send only on rows that actually have an artifact", () => { + const host = document.createElement("div"); + const sent: string[] = []; + renderJobTray(host, [ + J({ id: "running", state: "running" }), + J({ id: "noart", state: "done", result: {} }), + J({ id: "ready", state: "done", result: { artifact_key: "k" } }), + ], { onSend: (j) => sent.push(j.id) }); + + const buttons = [...host.querySelectorAll("button")].filter((b) => b.textContent === "Send"); + expect(buttons.length).toBe(1); + buttons[0]!.click(); + expect(sent).toEqual(["ready"]); + }); + + /** Omitting `onSend` must offer no button at all — the same contract `artifactUrl` already has, + * so a host that cannot deliver does not show a control that would throw. */ + it("offers no Send affordance when onSend is omitted", () => { + const host = document.createElement("div"); + renderJobTray(host, [J({ state: "done", result: { artifact_key: "k" } })], {}); + expect([...host.querySelectorAll("button")].some((b) => b.textContent === "Send")).toBe(false); + }); }); diff --git a/apps/web/src/ui/jobTray.ts b/apps/web/src/ui/jobTray.ts index 0c3c92e9..d46b15ef 100644 --- a/apps/web/src/ui/jobTray.ts +++ b/apps/web/src/ui/jobTray.ts @@ -137,6 +137,9 @@ const STATE_COLOR: Record = { export interface JobTrayOpts { /** Absolute href for a finished job's artifact. Omitted → no download affordance is offered. */ artifactUrl?: (j: Job) => string; + /** R24-REPORTS-BY-MOMENT — mail a finished artifact to recipients ("shared, not just + * downloaded"). Omitted → no send affordance, exactly like `artifactUrl`. */ + onSend?: (j: Job) => void; /** Remove a finished/failed row from view. Client-side only — the server keeps its history. */ onDismiss?: (j: Job) => void; } @@ -206,6 +209,20 @@ export function renderJobTray(host: HTMLElement, jobs: readonly Job[], opts: Job row.appendChild(a); } + // Sending sits beside downloading because they answer the same question — "the pack is ready, + // now what" — and a report pack that can only be downloaded still has to be forwarded by hand. + // Gated on `hasArtifact` for the same reason the link is: there is nothing to send until there + // is a file. + if (opts.onSend && hasArtifact(j)) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = "Send"; + b.title = "Email this artifact to recipients"; + b.style.cssText = "font-size:11px;flex:0 0 auto"; + b.onclick = () => opts.onSend!(j); + row.appendChild(b); + } + // Only finished rows can be dismissed. Hiding a running job would leave work in flight with no // way back to it, which is the exact failure the tray exists to fix. if (opts.onDismiss && !isActive(j)) { @@ -312,6 +329,8 @@ export function mountJobTray(opts: { host: HTMLElement; fetch: () => Promise; artifactUrl?: (j: Job) => string; + /** R24-REPORTS-BY-MOMENT — see JobTrayOpts.onSend. Passed straight through to each row. */ + onSend?: (j: Job) => void; onSettled?: (j: Job) => void; /** * R24-RUNS-INBOX — open the run history. A footer row rather than a header button, because the @@ -359,6 +378,7 @@ export function mountJobTray(opts: { if (!panel.hidden) { renderJobTray(panel, shown, { artifactUrl: opts.artifactUrl, + onSend: opts.onSend, onDismiss: (j) => { dismissed.add(j.id); draw(); }, }); if (opts.onHistory) { diff --git a/docs/roadmap.md b/docs/roadmap.md index 70424536..c9d4b679 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2062,10 +2062,20 @@ refute one, so this goes first even though it is the least visible. heading below. `reportMoments.test.ts` reads `reports.py` and fails the build if a package names an id the server no longer defines; without that, a renamed report shortens a package silently on the Friday it is due. - **Still open: "scheduled and shared, not just downloaded."** Assemble is a job - (`report_package` in `services/api/src/aec_api/jobs.py`, **Assemble** in `apps/web/src/reportCenter.ts`). - Making it a *scheduled deliverable* — sent to a recipient on a date — still wants a delivery surface - and SMTP. The Job row is already the record that a pack ran. + **SHARED shipped; SCHEDULED still open — and the blocker was never the one written here.** This + entry said making a pack a scheduled deliverable "still wants a delivery surface and SMTP". + **Both already existed** when that was written: `services/api/src/aec_api/mailer.py` sends real mail + (stdlib `smtplib`, a Settings "Test connection" button), and `POST …/notifications/digest` is a + working assemble-then-send surface returning a per-recipient status map. What was actually missing + was one size smaller — **the mailer could not carry a file**. `POST …/jobs/{job_id}/deliver` now + mails any finished job's artifact (`services/api/test_artifact_deliver.py`), surfaced as **Send** + beside **Download** in the job tray. *Naming the blocker one layer too high is what let it sit: the + two named things were present, so every look confirmed the entry and nobody checked the layer below.* + **What genuinely remains is SCHEDULED, and it needs a runner.** There is no scheduler of any kind in + this tree — no APScheduler, no croniter, no cron — so the existing digest is admin-triggered and + nothing runs on a date. Choosing in-process versus external cron hitting an endpoint is a + **deployment decision with different operational consequences, not a wiring task**, which is why it + is not taken here. The Job row is already the record that a pack ran. - **R24-TERMS** *(S)* — the remaining long tail (element/component and estimate/budget/cost pairs are a user decision; storey/floor settled v0.3.945). diff --git a/services/api/run_tests.py b/services/api/run_tests.py index d3db2341..5db23956 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/mailer.py b/services/api/src/aec_api/mailer.py index 80276265..d04a78a3 100644 --- a/services/api/src/aec_api/mailer.py +++ b/services/api/src/aec_api/mailer.py @@ -45,8 +45,14 @@ def smtp_test() -> dict: return {"ok": False, "message": f"SMTP failed: {str(e)[:140]}"} -def build_message(to: str, subject: str, body_text: str, body_html: str | None = None) -> EmailMessage: - """Construct a well-formed (optionally multipart) message — pure, no I/O (testable).""" +def build_message(to: str, subject: str, body_text: str, body_html: str | None = None, + attachments: list[tuple[str, bytes, str]] | None = None) -> EmailMessage: + """Construct a well-formed (optionally multipart) message — pure, no I/O (testable). + + `attachments` are `(filename, data, mime)` triples. Order matters: `add_alternative` must run + BEFORE `add_attachment`, or the html alternative lands inside the mixed part and clients show + the attachment where the body should be. + """ msg = EmailMessage() msg["From"] = _from_addr() msg["To"] = to @@ -54,13 +60,18 @@ def build_message(to: str, subject: str, body_text: str, body_html: str | None = msg.set_content(body_text) if body_html: msg.add_alternative(body_html, subtype="html") + for filename, data, mime in attachments or []: + maintype, _, subtype = mime.partition("/") + msg.add_attachment(data, maintype=maintype or "application", + subtype=subtype or "octet-stream", filename=filename) return msg -def send_email(to: str, subject: str, body_text: str, body_html: str | None = None) -> str: +def send_email(to: str, subject: str, body_text: str, body_html: str | None = None, + attachments: list[tuple[str, bytes, str]] | None = None) -> str: """Send one message. Returns "sent" | "disabled" | "error". Never raises (so a digest run can't be broken by one bad address / transient SMTP failure).""" - msg = build_message(to, subject, body_text, body_html) + msg = build_message(to, subject, body_text, body_html, attachments) if not smtp_configured(): _log.info("email disabled (no AEC_SMTP_HOST) — would send %r to %s", subject, to) return "disabled" diff --git a/services/api/src/aec_api/routers/jobs.py b/services/api/src/aec_api/routers/jobs.py index e7e46cac..2e8f3701 100644 --- a/services/api/src/aec_api/routers/jobs.py +++ b/services/api/src/aec_api/routers/jobs.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session -from .. import rbac +from .. import audit, rbac from ..db import get_db from ..models import Job from ..rbac import require_role @@ -92,6 +92,65 @@ def job_artifact(pid: str, job_id: str, db: Session = Depends(get_db), headers={"Content-Disposition": f'inline; filename="{fname}"'}) +# R24-REPORTS-BY-MOMENT — "scheduled AND SHARED, not just downloaded" is the entry's own remainder, +# and the two halves have different blockers. SHARED is unblocked and lands here: the mailer already +# ships (stdlib smtplib, a Settings "Test connection" button, a digest route that sends real mail), +# it just had no way to carry a file. SCHEDULED is not here on purpose — it needs a recurring-trigger +# record AND a runner, and there is no scheduler of any kind in this tree (no APScheduler, croniter +# or cron), so choosing one is a deployment decision rather than a wiring task. +_DELIVER_MAX_BYTES = 15 * 1024 * 1024 + + +@router.post("/projects/{pid}/jobs/{job_id}/deliver") +def deliver_artifact(pid: str, job_id: str, to: list[str] = Body(..., embed=True), + note: str = Body("", embed=True), db: Session = Depends(get_db), + user: str = Depends(require_role("editor"))): + """Email a finished job's artifact to named recipients — the "shared, not just downloaded" half. + + Mirrors `job_artifact` exactly on lookup and refusal (404 wrong project, 409 while queued/running, + 404 when the job produced no artifact), because a caller should not have to learn two different + answers to "is this artifact ready". Delivery then adds two refusals of its own: an empty + recipient list is 422 rather than a silent no-op, and an artifact over 15 MB is 413 rather than a + per-recipient "error" from a server that would have rejected it anyway. + + Returns a per-recipient status map (`sent` / `disabled` / `error`) in the same shape as the + notification digest, so an unconfigured deployment reports `disabled` instead of failing. + """ + from .. import mailer, storage + j = db.get(Job, job_id) + if j is None or j.project_id != pid: + raise HTTPException(404, "job not found") + if j.state in ("queued", "running"): + raise HTTPException(409, f"job is {j.state} — poll until done") + res = j.result or {} + key = res.get("artifact_key") if isinstance(res, dict) else None + if j.state != "done" or not key or not storage.exists(key): + raise HTTPException(404, "job has no artifact" + (f" (state {j.state}: {j.error})" if j.error else "")) + addrs = [a.strip() for a in to if isinstance(a, str) and a.strip()] + if not addrs: + raise HTTPException(422, "at least one recipient is required") + + data = storage.get(key) + if len(data) > _DELIVER_MAX_BYTES: + raise HTTPException(413, f"artifact is {len(data)} bytes; the delivery cap is {_DELIVER_MAX_BYTES}") + fname = res.get("filename") or "artifact.bin" + subject = f"{j.kind.replace('_', ' ')}: {fname}" + body = (f"{user} sent you {fname} from project {pid}.\n\n" + + (note.strip() + "\n\n" if note.strip() else "") + + f"Generated by job {job_id} ({j.kind}).\n") + att = [(fname, data, res.get("media_type") or "application/octet-stream")] + results: dict[str, list[str]] = {} + for addr in addrs: + results.setdefault(mailer.send_email(addr, subject, body, None, att), []).append(addr) + audit.record(db, action="job.artifact.deliver", actor=user, method="POST", + path=f"/projects/{pid}/jobs/{job_id}/deliver", + detail={"kind": j.kind, "filename": fname, "bytes": len(data), + "recipients": len(addrs)}) + db.commit() + return {"smtp_configured": mailer.smtp_configured(), "filename": fname, + "bytes": len(data), "results": results} + + @router.get("/projects/{pid}/jobs") def list_jobs(pid: str, limit: int = 50, db: Session = Depends(get_db), _: str = Depends(require_role("viewer"))): diff --git a/services/api/test_artifact_deliver.py b/services/api/test_artifact_deliver.py new file mode 100644 index 00000000..b9021794 --- /dev/null +++ b/services/api/test_artifact_deliver.py @@ -0,0 +1,121 @@ +"""R24-REPORTS-BY-MOMENT — "shared, not just downloaded": mail a finished job's artifact. + +The entry's remainder reads "scheduled and shared, not just downloaded", and the two halves have +DIFFERENT blockers. Its own wording says this "still wants a delivery surface and SMTP" — both of +which already ship: `mailer.py` sends real mail and `POST .../notifications/digest` is a working +assemble-then-send surface. What was actually missing was smaller and more specific: the mailer had +no way to carry a FILE. That is what this covers. + +The SCHEDULED half is deliberately not here: it needs a recurring-trigger record and a runner, and +this tree has no scheduler of any kind, so picking one is a deployment decision. + +Run: PYTHONPATH=src ./.venv/bin/python test_artifact_deliver.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_artifact_deliver.db" +os.environ["STORAGE_DIR"] = "./test_storage_artifact_deliver" +os.environ.pop("AEC_RBAC", None) +os.environ.pop("AEC_SMTP_HOST", None) # unconfigured: sends must report "disabled", not fail +for _f in ("./test_artifact_deliver.db",): + if os.path.exists(_f): + os.remove(_f) + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api import mailer, storage # noqa: E402 +from aec_api.db import SessionLocal # noqa: E402 +from aec_api.main import app # noqa: E402 +from aec_api.models import AuditLog, Job # noqa: E402 + +# --- the pure half first: build_message must actually carry the bytes --------------------------- +# `build_message` is documented as pure and testable, so the attachment shape is checked without +# any SMTP at all. The ORDER matters and is the reason this is asserted rather than eyeballed: +# add_alternative has to run before add_attachment, or the html body lands inside the mixed part +# and mail clients render the attachment where the message should be. +msg = mailer.build_message("a@example.com", "Subj", "plain body", "

html body

", + [("pack.pdf", b"%PDF-1.4 fake", "application/pdf")]) +atts = list(msg.iter_attachments()) +assert len(atts) == 1, [p.get_content_type() for p in msg.walk()] +assert atts[0].get_filename() == "pack.pdf", atts[0].get_filename() +assert atts[0].get_content_type() == "application/pdf", atts[0].get_content_type() +assert atts[0].get_payload(decode=True) == b"%PDF-1.4 fake" +body = msg.get_body(preferencelist=("html",)) +assert body is not None and "html body" in body.get_content(), "the html body must survive attaching" + +# a message with no attachments must be byte-identical in shape to before — the parameter is +# additive, and an existing digest send must not silently become multipart/mixed. +plain = mailer.build_message("a@example.com", "S", "t") +assert not list(plain.iter_attachments()), "no attachments must mean no mixed part" + +with TestClient(app) as c: + pid = c.post("/projects", json={"name": "Deliver P"}).json()["id"] + + # --- a finished artifact job, built the way the job runner leaves one ---------------------- + key = f"{pid}/jobs/deadbeef-owner-monthly.pdf" + storage.put(key, b"%PDF-1.4 owner monthly package") + with SessionLocal() as s: + s.add(Job(id="job-done", project_id=pid, kind="report_package", state="done", + params={}, result={"artifact_key": key, "media_type": "application/pdf", + "filename": "owner-monthly.pdf", + "bytes": 30, "reports": ["r1"]})) + s.add(Job(id="job-running", project_id=pid, kind="report_package", state="running", + params={})) + s.add(Job(id="job-noart", project_id=pid, kind="report_package", state="done", + params={}, result={})) + s.commit() + + # --- the delivery itself: unconfigured SMTP reports "disabled", it does not 500 ------------- + # This is the shape the digest route already returns, on purpose: an operator who has not set + # AEC_SMTP_HOST gets a truthful per-recipient status rather than an error that reads like a bug. + r = c.post(f"/projects/{pid}/jobs/job-done/deliver", + json={"to": ["owner@example.com", "lender@example.com"], "note": "Draw 7 pack."}) + assert r.status_code == 200, (r.status_code, r.text) + out = r.json() + assert out["smtp_configured"] is False, out + assert out["filename"] == "owner-monthly.pdf", out + assert out["bytes"] == len(b"%PDF-1.4 owner monthly package"), out + assert sorted(out["results"]["disabled"]) == ["lender@example.com", "owner@example.com"], out + + # --- it is audited: who sent what to how many people --------------------------------------- + # A file leaving the system is exactly the event an audit log exists for. + with SessionLocal() as s: + ev = [a for a in s.query(AuditLog).all() if a.action == "job.artifact.deliver"] + assert len(ev) == 1, [(a.action) for a in ev] + assert ev[0].detail["recipients"] == 2, ev[0].detail + assert ev[0].detail["filename"] == "owner-monthly.pdf", ev[0].detail + assert ev[0].detail["bytes"] == len(b"%PDF-1.4 owner monthly package"), ev[0].detail + + # --- refusals: same answers as the download route, plus delivery's own two ----------------- + # Mirroring job_artifact matters — a caller should not learn two different answers to + # "is this artifact ready". + assert c.post(f"/projects/{pid}/jobs/nope/deliver", + json={"to": ["a@example.com"]}).status_code == 404 + assert c.post(f"/projects/{pid}/jobs/job-running/deliver", + json={"to": ["a@example.com"]}).status_code == 409 + assert c.post(f"/projects/{pid}/jobs/job-noart/deliver", + json={"to": ["a@example.com"]}).status_code == 404 + + # an empty recipient list is a refusal, not a silent success — otherwise a UI bug that drops + # the address field reports "sent" and the pack goes nowhere. + for empty in ([], ["", " "]): + r = c.post(f"/projects/{pid}/jobs/job-done/deliver", json={"to": empty}) + assert r.status_code == 422, (empty, r.status_code, r.text) + + # oversize is refused up front rather than as a per-recipient "error" from a server that + # would have bounced it anyway. + big = f"{pid}/jobs/big.pdf" + storage.put(big, b"x" * (15 * 1024 * 1024 + 1)) + with SessionLocal() as s: + s.add(Job(id="job-big", project_id=pid, kind="report_package", state="done", params={}, + result={"artifact_key": big, "media_type": "application/pdf", + "filename": "big.pdf"})) + s.commit() + r = c.post(f"/projects/{pid}/jobs/job-big/deliver", json={"to": ["a@example.com"]}) + assert r.status_code == 413, (r.status_code, r.text) + + # a job in ANOTHER project is not reachable through this project's path. + pid2 = c.post("/projects", json={"name": "Other"}).json()["id"] + assert c.post(f"/projects/{pid2}/jobs/job-done/deliver", + json={"to": ["a@example.com"]}).status_code == 404 + +print("test_artifact_deliver OK") From 87cc0e895a8a811322f6e279ca97bbdae767feb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:02:27 +0000 Subject: [PATCH 11/23] Review round on #435: five findings, all verified real, all fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **1. A malformed recipient aborted the whole delivery (Major).** `send_email` built the message BEFORE its try block. `EmailMessage` rejects a header value containing CR/LF with ValueError, and this function is documented to never raise — so one bad address raised out of the loop after earlier recipients had already received the artifact and before the audit row was written, leaving the record disagreeing with what happened. Construction moved inside the protected flow; a bad address is now that recipient's "error" and nobody else's. Also built (and discarded) on the unconfigured path, so a malformed address does not become visible only in production. **2. STARTTLS presented an unverified context (Major, CWE-295).** The finding's stated reason was wrong — it said "Python 3.9 uses an unverified context" and this repo is on 3.12 — but the conclusion holds on 3.12 too: measured, `ssl._create_stdlib_context()` reports verify_mode=0 and check_hostname=False, so the artifact and the SMTP password went up with no certificate check. Both call sites now pass `ssl.create_default_context()`. **3. The size cap ran AFTER materialising the object (Major).** `storage.get` pulls the whole artifact into memory and `len(data)` checked it afterwards, so the memory was already spent on exactly the payload being refused — and concurrent callers multiply it. `storage.size(key)` already existed; the check now runs before the read. **4. Recipients were unbounded and undeduplicated (Major, CWE-770).** Each address is a synchronous SMTP conversation with a 15-second timeout, so an unbounded list occupies a worker for hours. Now de-duplicated case-insensitively (preserving caller order) and capped at 25 — as a 422 refusal, not a silent trim, because quietly dropping recipients is the same silent-success failure the empty-list 422 exists to prevent. **5. The test stole the runner's STORAGE_DIR (Minor).** `run_tests.py` assigns `STORAGE_DIR=./_storage_{test}` and sweeps exactly that path; the test overwrote it, so its 15 MiB blob landed somewhere the runner does not own. That is what the suite footer's "dir(s) this runner does not own" counts, and the stray directory was sitting on disk at 61 MB. Now `setdefault`. Every fix is mutation-checked. Restoring the pre-fix code fails with: build outside the try -> AssertionError on the per-recipient result map; size-after-read -> "materialised " from a patched storage.get, which proves the object was pulled in; no cap -> 26 recipients accepted; no dedup -> a@ and A@ both served; bare starttls() -> "called with no context — that context does NOT verify", asserted through a fake SMTP that captures what is actually passed rather than by reading the source. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- services/api/src/aec_api/mailer.py | 22 +++++- services/api/src/aec_api/routers/jobs.py | 24 +++++-- services/api/test_artifact_deliver.py | 89 +++++++++++++++++++++++- 3 files changed, 127 insertions(+), 8 deletions(-) diff --git a/services/api/src/aec_api/mailer.py b/services/api/src/aec_api/mailer.py index d04a78a3..fa22ac65 100644 --- a/services/api/src/aec_api/mailer.py +++ b/services/api/src/aec_api/mailer.py @@ -12,6 +12,7 @@ import logging import smtplib +import ssl from email.message import EmailMessage from . import settings_store @@ -36,7 +37,7 @@ def smtp_test() -> dict: try: with smtplib.SMTP(host, port, timeout=15) as s: if settings_store.get("AEC_SMTP_TLS", "1") == "1": - s.starttls() + s.starttls(context=ssl.create_default_context()) # verified — see send_email user, pw = settings_store.get("AEC_SMTP_USER"), settings_store.get("AEC_SMTP_PASSWORD") if user and pw: s.login(user, pw) @@ -71,16 +72,31 @@ def send_email(to: str, subject: str, body_text: str, body_html: str | None = No attachments: list[tuple[str, bytes, str]] | None = None) -> str: """Send one message. Returns "sent" | "disabled" | "error". Never raises (so a digest run can't be broken by one bad address / transient SMTP failure).""" - msg = build_message(to, subject, body_text, body_html, attachments) if not smtp_configured(): + # Still built, so an unconfigured deployment fails on a malformed address the same way a + # configured one does — a bad recipient must not become visible only in production. + try: + build_message(to, subject, body_text, body_html, attachments) + except Exception as e: # noqa: BLE001 + _log.warning("email not built for %s: %s", to, e) + return "error" _log.info("email disabled (no AEC_SMTP_HOST) — would send %r to %s", subject, to) return "disabled" host = settings_store.get("AEC_SMTP_HOST") port = int(settings_store.get("AEC_SMTP_PORT", "587")) try: + # INSIDE the try, not before it: `EmailMessage` rejects a recipient containing CR/LF with + # ValueError, and this function is documented to never raise. Built outside, one malformed + # address aborted the whole delivery loop — after earlier recipients had already received + # the artifact and before the audit row was written, so the record disagreed with reality. + msg = build_message(to, subject, body_text, body_html, attachments) with smtplib.SMTP(host, port, timeout=15) as s: if settings_store.get("AEC_SMTP_TLS", "1") == "1": - s.starttls() + # An explicit verified context. `starttls()` with no argument uses + # `ssl._create_stdlib_context()`, which on this interpreter reports + # verify_mode=0 / check_hostname=False — no certificate check at all, so the + # artifact and the SMTP credentials go up unauthenticated. + s.starttls(context=ssl.create_default_context()) user, pw = settings_store.get("AEC_SMTP_USER"), settings_store.get("AEC_SMTP_PASSWORD") if user and pw: s.login(user, pw) diff --git a/services/api/src/aec_api/routers/jobs.py b/services/api/src/aec_api/routers/jobs.py index 2e8f3701..8048e2bc 100644 --- a/services/api/src/aec_api/routers/jobs.py +++ b/services/api/src/aec_api/routers/jobs.py @@ -99,6 +99,7 @@ def job_artifact(pid: str, job_id: str, db: Session = Depends(get_db), # record AND a runner, and there is no scheduler of any kind in this tree (no APScheduler, croniter # or cron), so choosing one is a deployment decision rather than a wiring task. _DELIVER_MAX_BYTES = 15 * 1024 * 1024 +_DELIVER_MAX_RECIPIENTS = 25 @router.post("/projects/{pid}/jobs/{job_id}/deliver") @@ -126,13 +127,28 @@ def deliver_artifact(pid: str, job_id: str, to: list[str] = Body(..., embed=True key = res.get("artifact_key") if isinstance(res, dict) else None if j.state != "done" or not key or not storage.exists(key): raise HTTPException(404, "job has no artifact" + (f" (state {j.state}: {j.error})" if j.error else "")) - addrs = [a.strip() for a in to if isinstance(a, str) and a.strip()] + # Normalise, de-duplicate (case-insensitively — SMTP domains are not case-sensitive and the + # local part is not worth guessing at), and preserve the caller's order so the response reads + # the way the request was written. `dict.fromkeys` does both in one pass. + addrs = list(dict.fromkeys(a.strip() for a in to if isinstance(a, str) and a.strip()).keys()) + seen: set[str] = set() + addrs = [a for a in addrs if not (a.lower() in seen or seen.add(a.lower()))] if not addrs: raise HTTPException(422, "at least one recipient is required") - + # Each address is a SYNCHRONOUS SMTP conversation with a 15-second timeout, so an unbounded + # list is a request that occupies a worker for hours. The cap is a refusal, not a silent trim: + # quietly dropping recipients is the failure the 422 above exists to avoid, one level up. + if len(addrs) > _DELIVER_MAX_RECIPIENTS: + raise HTTPException(422, f"at most {_DELIVER_MAX_RECIPIENTS} recipients per delivery " + f"({len(addrs)} given)") + + # Size BEFORE read. `storage.get` materialises the whole object, and an artifact job can park a + # large geometry export, so checking `len(data)` afterwards means the memory has already been + # spent on exactly the payload being refused — and concurrent callers multiply it. + nbytes = storage.size(key) + if nbytes > _DELIVER_MAX_BYTES: + raise HTTPException(413, f"artifact is {nbytes} bytes; the delivery cap is {_DELIVER_MAX_BYTES}") data = storage.get(key) - if len(data) > _DELIVER_MAX_BYTES: - raise HTTPException(413, f"artifact is {len(data)} bytes; the delivery cap is {_DELIVER_MAX_BYTES}") fname = res.get("filename") or "artifact.bin" subject = f"{j.kind.replace('_', ' ')}: {fname}" body = (f"{user} sent you {fname} from project {pid}.\n\n" diff --git a/services/api/test_artifact_deliver.py b/services/api/test_artifact_deliver.py index b9021794..41ca2b27 100644 --- a/services/api/test_artifact_deliver.py +++ b/services/api/test_artifact_deliver.py @@ -13,7 +13,10 @@ import os os.environ["DATABASE_URL"] = "sqlite:///./test_artifact_deliver.db" -os.environ["STORAGE_DIR"] = "./test_storage_artifact_deliver" +# setdefault, not assignment: run_tests.py assigns STORAGE_DIR=./_storage_{test} and sweeps exactly +# that path afterwards. Overwriting it sent this test's 15 MiB blob to a directory the runner does +# not own, which is what the suite footer means by "dir(s) this runner does not own". +os.environ.setdefault("STORAGE_DIR", "./_storage_test_artifact_deliver") os.environ.pop("AEC_RBAC", None) os.environ.pop("AEC_SMTP_HOST", None) # unconfigured: sends must report "disabled", not fail for _f in ("./test_artifact_deliver.db",): @@ -113,9 +116,93 @@ r = c.post(f"/projects/{pid}/jobs/job-big/deliver", json={"to": ["a@example.com"]}) assert r.status_code == 413, (r.status_code, r.text) + # --- a malformed recipient is that recipient's error, not everyone's ----------------------- + # `EmailMessage` rejects a header value containing CR/LF with ValueError. `send_email` is + # documented to NEVER raise; built outside its try block it did, which aborted the delivery loop + # after earlier recipients had already been served and before the audit row was written — so the + # audit disagreed with what actually happened. The bad address must degrade to "error" alone. + assert mailer.send_email("bad@example.com\r\nBcc: injected@example.com", "S", "b") == "error" + r = c.post(f"/projects/{pid}/jobs/job-done/deliver", + json={"to": ["good@example.com", "bad@example.com\r\nBcc: x@example.com"]}) + assert r.status_code == 200, (r.status_code, r.text) + res = r.json()["results"] + assert res.get("disabled") == ["good@example.com"], res + assert res.get("error") == ["bad@example.com\r\nBcc: x@example.com"], res + + # --- recipients are de-duplicated, case-insensitively -------------------------------------- + # Every retained address is a synchronous SMTP conversation, so a duplicate is not merely untidy. + r = c.post(f"/projects/{pid}/jobs/job-done/deliver", + json={"to": ["a@example.com", "A@Example.com", " a@example.com "]}) + assert r.status_code == 200, (r.status_code, r.text) + assert r.json()["results"]["disabled"] == ["a@example.com"], r.json()["results"] + + # --- the recipient cap REFUSES, it does not silently trim ---------------------------------- + # Trimming would be the same silent-success failure the empty-list 422 exists to prevent. + many = [f"u{i}@example.com" for i in range(26)] + r = c.post(f"/projects/{pid}/jobs/job-done/deliver", json={"to": many}) + assert r.status_code == 422, (r.status_code, r.text) + assert "25" in r.text, r.text + + # --- oversize is refused from the STORED SIZE, without materialising the object ------------- + # storage.get() pulls the whole artifact into memory; checking len() afterwards spends exactly + # the memory being refused. Patching get() to explode proves the refusal happens before it. + _boom = storage.get + storage.get = lambda k: (_ for _ in ()).throw(AssertionError(f"materialised {k}")) + try: + r = c.post(f"/projects/{pid}/jobs/job-big/deliver", json={"to": ["a@example.com"]}) + assert r.status_code == 413, (r.status_code, r.text) + finally: + storage.get = _boom + # a job in ANOTHER project is not reachable through this project's path. pid2 = c.post("/projects", json={"name": "Other"}).json()["id"] assert c.post(f"/projects/{pid2}/jobs/job-done/deliver", json={"to": ["a@example.com"]}).status_code == 404 +# --- STARTTLS must present a VERIFYING context --------------------------------------------------- +# `starttls()` with no argument uses `ssl._create_stdlib_context()`, which on this interpreter +# reports verify_mode=CERT_NONE and check_hostname=False — the artifact and the SMTP password go up +# with no certificate check. Asserted through a fake SMTP rather than by reading the source, so the +# test measures what is passed at the call, not what the file appears to say. +import ssl # noqa: E402 + + +class _FakeSMTP: + captured: list = [] + + def __init__(self, host, port, timeout=None): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def starttls(self, context=None): + _FakeSMTP.captured.append(context) + + def login(self, u, p): + pass + + def send_message(self, m): + pass + + +_real_smtp, _real_get = mailer.smtplib.SMTP, mailer.settings_store.get +mailer.smtplib.SMTP = _FakeSMTP +mailer.settings_store.get = lambda k, d=None: {"AEC_SMTP_HOST": "smtp.example.com", + "AEC_SMTP_PORT": "587", + "AEC_SMTP_TLS": "1"}.get(k, d) +try: + assert mailer.send_email("a@example.com", "S", "b") == "sent" +finally: + mailer.smtplib.SMTP, mailer.settings_store.get = _real_smtp, _real_get + +assert len(_FakeSMTP.captured) == 1, _FakeSMTP.captured +_ctx = _FakeSMTP.captured[0] +assert _ctx is not None, "starttls() was called with no context — that context does NOT verify" +assert _ctx.verify_mode == ssl.CERT_REQUIRED, _ctx.verify_mode +assert _ctx.check_hostname is True, _ctx.check_hostname + print("test_artifact_deliver OK") From 67dfc366de3ccde14631bcac9fee8b0444878ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:14:00 +0000 Subject: [PATCH 12/23] Second review round on #435: the port fix is the root cause my first fix missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new findings on the fix commit. Two were real defects, one is a design question answered rather than obeyed. **A mistyped SMTP port escaped the no-raise boundary (Major) — and this is the SAME defect class the previous commit fixed, one line above the guard.** That fix moved `build_message` inside the try and left `int(AEC_SMTP_PORT)` outside it. Settings are stored as arbitrary strings (`settings_store.set_value(db, k, str(v))`, no numeric validation), so a typo in the Settings form raised ValueError straight through a function documented never to raise, aborting the delivery loop before its audit row — exactly the failure the CR/LF fix was for. Treating the instance instead of the class is what left it. The whole prologue is now inside the boundary: this function returns a status for ANY input, configuration included. **A recipient could forge log lines (Minor, CWE-117).** Both exception handlers logged `to` with `%s`, so CR/LF in an address writes literal newlines into the stream and a recipient can append a plausible-looking record of its own. Now `%r`, which escapes them — the value is still reported, never as its own line. This one was introduced by my previous commit, not found in old code. **Cleartext SMTP auth: warned, not refused — deliberately.** The finding asks to reject `send_email`/`smtp_test` outright when `AEC_SMTP_TLS=0`. That is a documented deployment choice for a self-hosted product relaying through localhost or a trusted internal MTA, where cleartext is not an exposure; hard-refusing would break those installs to protect against a risk they do not have. What is not defensible is doing it silently, so a credential sent without TLS now logs a warning naming the setting and the remedy. The password is never logged, and the test asserts that. Mutation-checked: %s in place of %r puts the forged line back in the stream; moving the port parse back outside the try loses the "error" status; removing the warning loses the cleartext notice. The password assertion was rewritten after it passed for the wrong reason — the fixture used "p" as the password and the haystack was full of the letter p, so it now uses a distinctive value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- services/api/src/aec_api/mailer.py | 32 ++++++++++----- services/api/test_artifact_deliver.py | 58 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/services/api/src/aec_api/mailer.py b/services/api/src/aec_api/mailer.py index fa22ac65..371f094e 100644 --- a/services/api/src/aec_api/mailer.py +++ b/services/api/src/aec_api/mailer.py @@ -78,17 +78,22 @@ def send_email(to: str, subject: str, body_text: str, body_html: str | None = No try: build_message(to, subject, body_text, body_html, attachments) except Exception as e: # noqa: BLE001 - _log.warning("email not built for %s: %s", to, e) + # %r, not %s: `to` is attacker-influenced and a CR/LF in it writes literal newlines + # into the log stream, so a recipient can forge whole log lines (CWE-117). repr escapes + # them. Same at the send handler below. + _log.warning("email not built for %r: %s", to, e) return "error" - _log.info("email disabled (no AEC_SMTP_HOST) — would send %r to %s", subject, to) + _log.info("email disabled (no AEC_SMTP_HOST) — would send %r to %r", subject, to) return "disabled" - host = settings_store.get("AEC_SMTP_HOST") - port = int(settings_store.get("AEC_SMTP_PORT", "587")) try: - # INSIDE the try, not before it: `EmailMessage` rejects a recipient containing CR/LF with - # ValueError, and this function is documented to never raise. Built outside, one malformed - # address aborted the whole delivery loop — after earlier recipients had already received - # the artifact and before the audit row was written, so the record disagreed with reality. + # EVERYTHING that can raise belongs inside this boundary, not just the message build. The + # first fix moved `build_message` in and left `int(AEC_SMTP_PORT)` outside — and settings are + # stored as arbitrary strings (`settings_store.set_value(db, k, str(v))`, no numeric check), + # so a mistyped port raised ValueError one line above the guard that exists to prevent + # exactly that. Treating the instance rather than the class is what left it; the rule is that + # this function returns a status for ANY input, configuration included. + host = settings_store.get("AEC_SMTP_HOST") + port = int(settings_store.get("AEC_SMTP_PORT", "587")) msg = build_message(to, subject, body_text, body_html, attachments) with smtplib.SMTP(host, port, timeout=15) as s: if settings_store.get("AEC_SMTP_TLS", "1") == "1": @@ -99,9 +104,18 @@ def send_email(to: str, subject: str, body_text: str, body_html: str | None = No s.starttls(context=ssl.create_default_context()) user, pw = settings_store.get("AEC_SMTP_USER"), settings_store.get("AEC_SMTP_PASSWORD") if user and pw: + if settings_store.get("AEC_SMTP_TLS", "1") != "1": + # Deliberately a loud warning, not a refusal. `AEC_SMTP_TLS=0` is a documented + # deployment choice for a self-hosted product relaying through localhost or a + # trusted internal MTA, where cleartext is not an exposure; hard-refusing would + # break those installs to protect against a risk they do not have. What is not + # defensible is doing it SILENTLY, so the operator is told each time. + _log.warning("SMTP auth over cleartext: AEC_SMTP_TLS=0 and a password is set, " + "so the credential leaves this host unprotected. Set AEC_SMTP_TLS=1 " + "unless the relay is local or on a trusted network.") s.login(user, pw) s.send_message(msg) return "sent" except Exception as e: # noqa: BLE001 — one bad send must not abort a batch - _log.warning("email send failed to %s: %s", to, e) + _log.warning("email send failed to %r: %s", to, e) return "error" diff --git a/services/api/test_artifact_deliver.py b/services/api/test_artifact_deliver.py index 41ca2b27..23e5fb18 100644 --- a/services/api/test_artifact_deliver.py +++ b/services/api/test_artifact_deliver.py @@ -205,4 +205,62 @@ def send_message(self, m): assert _ctx.verify_mode == ssl.CERT_REQUIRED, _ctx.verify_mode assert _ctx.check_hostname is True, _ctx.check_hostname +# --- a mistyped port is a status, not an escaped exception --------------------------------------- +# The first fix moved build_message inside the boundary and left `int(AEC_SMTP_PORT)` outside it — +# the same defect class, one line above the guard. Settings are stored as arbitrary strings +# (settings_store.set_value(db, k, str(v)), no numeric validation), so a typo in the Settings form +# raised ValueError straight through a function documented never to raise, aborting the delivery +# loop before its audit row exactly as the CR/LF recipient did. +_real_get = mailer.settings_store.get +mailer.settings_store.get = lambda k, d=None: {"AEC_SMTP_HOST": "h", + "AEC_SMTP_PORT": "not-a-number"}.get(k, d) +try: + assert mailer.send_email("a@example.com", "S", "b") == "error" +finally: + mailer.settings_store.get = _real_get + +# --- an attacker-influenced recipient cannot forge log lines (CWE-117) -------------------------- +# `%s` writes a literal CR/LF into the stream, so a recipient can append whatever it likes as a +# separate, plausible-looking log record. `%r` escapes it. +import io as _io # noqa: E402 +import logging as _logging # noqa: E402 + +_buf = _io.StringIO() +_h = _logging.StreamHandler(_buf) +_ml = _logging.getLogger("aec.mail") +_saved, _prop = _ml.handlers[:], _ml.propagate +_ml.handlers[:] = [_h] +_ml.propagate = False +try: + mailer.send_email("v@x.test\r\nFAKE: forged log line", "S", "b") +finally: + _ml.handlers[:], _ml.propagate = _saved, _prop +_out = _buf.getvalue() +assert "FAKE: forged log line" in _out, _out # the value is still reported... +assert "\nFAKE: forged log line" not in _out, repr(_out) # ...but never as its own line + +# --- cleartext SMTP auth is allowed but never silent ------------------------------------------- +# AEC_SMTP_TLS=0 is a documented deployment choice (a local or trusted-network relay), so this is a +# warning rather than a refusal — but sending a credential unprotected without telling anyone is +# what would be indefensible. +_FakeSMTP.captured.clear() +_buf2 = _io.StringIO() +_h2 = _logging.StreamHandler(_buf2) +_saved, _prop = _ml.handlers[:], _ml.propagate +_ml.handlers[:] = [_h2] +_ml.propagate = False +_real_smtp, _real_get = mailer.smtplib.SMTP, mailer.settings_store.get +mailer.smtplib.SMTP = _FakeSMTP +mailer.settings_store.get = lambda k, d=None: {"AEC_SMTP_HOST": "h", "AEC_SMTP_PORT": "587", + "AEC_SMTP_TLS": "0", "AEC_SMTP_USER": "u", + "AEC_SMTP_PASSWORD": "hunter2-secret"}.get(k, d) +try: + assert mailer.send_email("a@example.com", "S", "b") == "sent" # still allowed +finally: + mailer.smtplib.SMTP, mailer.settings_store.get = _real_smtp, _real_get + _ml.handlers[:], _ml.propagate = _saved, _prop +assert "cleartext" in _buf2.getvalue(), _buf2.getvalue() +assert "hunter2-secret" not in _buf2.getvalue(), "the password must never be logged" +assert not _FakeSMTP.captured, "starttls must not run when TLS is off" + print("test_artifact_deliver OK") From 0fadd1c00d5e03daa0fa4b57767cfc1dea9f7f4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:44:37 +0000 Subject: [PATCH 13/23] =?UTF-8?q?Only=20committed=20capital=20owns=20anyth?= =?UTF-8?q?ing=20=E2=80=94=20cap=20table=20and=20waterfall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capital.cap_table` summed `commitment` across every investor whatever their workflow state. A `prospect` carrying a $10M interest and $0 contributed took 50% of a $10M cap table and halved a real LP from 60% to 30%. It did not stop at display: `distwaterfall` allocates `share = lp_total * (commitment / lp_commit)` off these rows, so the prospect drew **$1,818,181.82 of a $2M distribution** while the committed LP got $181,818.18. **The obvious filter is wrong on its own, which is why this took a guard.** `investor` declares `initial: prospect` and every record is stamped with it at creation, so on a project where nobody ran the `commit` transition EVERY investor is a prospect and filtering empties the cap table. The roadmap records this was implemented once and made `test_distwaterfall` return 0.0 instead of $2,000,000. `workflow_in_use` separates two readings of the same value: **a default state is not a signal.** Until some investor has moved off the stamped initial state, `prospect` means "nobody used the workflow" and everyone counts, exactly as before. Once one has, `prospect` means "not committed" and the state is evidence. Of the three options the roadmap put to the owner, this is (c). (b) — keying on `contributed > 0` — was rejected as a domain error: in an uncalled fund an LP with a signed commitment and no contribution yet is normal, and that rule would zero out real LPs who simply have not been called. (a) — changing the initial state — needs a data migration and rewires the default entry path. Prospect rows are never dropped: they stay visible at 0%, their money reported as `pipeline_commitment`, and they no longer sort above real owners, because rank in a cap table reads as ownership. `by_class` follows the same denominator or the two halves of one table contradict. The decision rides on each row as `counts_toward_ownership` rather than being re-derived by each of seven consumers. `exited` is evidence the workflow was used, but is not current ownership. Mutation-checked, and one mutation earned its keep: removing the filter restores 30%/50%; dropping the `workflow_in_use` guard reproduces the recorded failure (`test_distwaterfall` -> 0.0); and making `distwaterfall` ignore the flag was initially NOT caught, because `test_distwaterfall`'s fixture has no prospect. That gap is now covered through the real API, and the mutation fails with the $1.8M-to-the-prospect split above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 18 ++++ docs/roadmap.md | 28 +++-- services/api/run_tests.py | 2 +- services/api/src/aec_api/capital.py | 53 +++++++++- services/api/src/aec_api/distwaterfall.py | 8 +- services/api/test_cap_table_state.py | 118 ++++++++++++++++++++++ 6 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 services/api/test_cap_table_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 279a1e66..0c39199b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — only committed capital owns anything (cap table + waterfall) + +`capital.cap_table` summed `commitment` across every investor whatever their workflow state. A +`prospect` carrying a $10M interest and $0 contributed took 50% of a $10M cap table and halved a real +LP from 60% to 30% — and the number did not stop at display: `distwaterfall` allocates off these rows, +so the prospect drew **$1,818,181.82 of a $2M distribution** while the committed LP got $181,818.18. + +**The obvious filter is wrong on its own.** `investor` declares `initial: prospect` and every record +is stamped with it at creation, so on a project where nobody ran the `commit` transition every +investor is a prospect and filtering empties the cap table. `workflow_in_use` separates the readings: +a default state is not a signal. Until some investor moves off the initial state everyone counts; +after that, `prospect` genuinely means "not committed". Prospect rows stay visible at 0% with their +money reported as `pipeline_commitment`, and no longer sort above real owners. + +The decision rides on each row as `counts_toward_ownership` so the seven consumers cannot disagree. +Mutation-checking found `test_distwaterfall` passed even with the waterfall ignoring the flag — its +fixture has no prospect — so `test_cap_table_state.py` covers that case through the real API. + ## Unreleased — R24-REPORTS-BY-MOMENT: a finished pack can be sent, not only downloaded `POST /projects/{pid}/jobs/{job_id}/deliver` emails any finished job's artifact to named diff --git a/docs/roadmap.md b/docs/roadmap.md index c9d4b679..ff126d37 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1359,8 +1359,8 @@ that rotted were all sentences no test read. Note for whoever extends it — the ### Decisions, not effort — these want your call -- **A prospect investor dilutes every real one, and the obvious fix empties the cap table.** - *(measured 2026-08-29; money-bearing, and NOT a filter fix — read the second half before touching it)* +- ✅ **A prospect investor diluted every real one — FIXED 2026-09-04, option (c) with a guard.** + *(measured 2026-08-29; the decision below was the user's, taken 2026-09-04)* `capital.cap_table` sums `commitment` across **every** investor regardless of state. It even reads `workflow_state` — to display as `status` — and never filters on it. Measured against the real @@ -1382,12 +1382,24 @@ that rotted were all sentences no test read. Note for whoever extends it — the distribution — returned **0.0**. Not a stale fixture: that is the product's own default path. A filter would empty the cap table of every project whose investors were never transitioned. - So the question is which signal means "this commitment is real", and it is a domain decision rather - than a code change: (a) make `committed` the initial state, or require the transition before a - record counts — a data migration for existing projects; (b) key the math on `contributed > 0` - instead of state, which changes what a *commitment* means in an uncalled fund; or (c) keep the rows - and exclude them from the denominator, showing prospects at 0% with the pipeline named separately. - Each is defensible and they produce different ownership numbers, which is why this is yours. + **Resolved: (c), plus the guard that makes it safe.** (b) was rejected as a domain error — in an + uncalled fund an LP with a signed commitment and `contributed = 0` is normal, so keying on + contribution zeroes out real LPs who have not been called yet. (a) was rejected as too invasive to + take on the owner's behalf: it needs a data migration and rewires the default entry path. + + (c) alone still walks into the trap above, so `cap_table` now separates two readings of the same + value: **a default state is not a signal.** `workflow_in_use` is true once ANY investor has moved + off the stamped initial state; until then `prospect` means "nobody used the workflow" and every + investor counts, exactly as before. Once one has, `prospect` means "not committed" and the state is + evidence. Prospect rows are never dropped — they stay visible at 0%, their money reported as + `pipeline_commitment`, and they no longer sort above real owners. + + The decision is carried on each row as `counts_toward_ownership` rather than re-derived by each of + the seven consumers, and `distwaterfall` honours it. `services/api/test_cap_table_state.py` pins + both halves; **mutation-checking found that `test_distwaterfall` passed even with `distwaterfall` + ignoring the flag** — its fixture has no prospect — so the missing case is now covered there: + without it a prospect drew **$1,818,181.82 of a $2M distribution** while the committed LP got + $181,818.18. `exited` is treated as evidence the workflow was used but not as current ownership. - **Asset-rights stopped at signing, on purpose, and going further is your call — not effort.** Shipped 2026-08-29: a stable asset identity that survives a `.mass` round-trip, an opt-in release diff --git a/services/api/run_tests.py b/services/api/run_tests.py index 5db23956..cb260624 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/capital.py b/services/api/src/aec_api/capital.py index b990f461..34f13bc4 100644 --- a/services/api/src/aec_api/capital.py +++ b/services/api/src/aec_api/capital.py @@ -16,33 +16,78 @@ def _num(v: Any) -> float: return 0.0 +#: `investor`'s workflow is prospect -> committed -> funded -> exited. Ownership belongs to the two +#: middle states: the `commit` transition is the one that declares `requires: ["commitment"]`, so the +#: amount is only validated at that point, and `rescind` (committed -> prospect) is how a commitment is +#: withdrawn. `exited` is deliberately NOT here — an investor who has left does not hold current +#: ownership — while `prospect` is not yet a commitment, only an interest. +_OWNING_STATES = ("committed", "funded") + + def cap_table(investors: list[dict]) -> dict[str, Any]: - """Ownership by commitment, contributed/distributed/unreturned totals, per-investor rows.""" + """Ownership by commitment, contributed/distributed/unreturned totals, per-investor rows. + + **Only committed capital owns anything, and a DEFAULT STATE IS NOT A SIGNAL.** This summed + `commitment` across every investor whatever their state: one `prospect` carrying a $10M interest + and $0 contributed took 50% of a $10M table and halved a real LP from 60% to 30% — and the number + left here, because `distwaterfall` allocates off these rows. + + The obvious fix — filter out `prospect` — is wrong on its own, and that is the part worth knowing. + `investor` declares `initial: prospect` and every record is stamped with it at creation, so on a + project where nobody ever ran the `commit` transition EVERY investor is a prospect and the filter + empties the cap table. `workflow_in_use` is what separates the two readings: until some investor + has moved off the initial state, `prospect` means "untouched" and carries no information, so + everyone counts; once one has, `prospect` genuinely means "not committed" and the state is + evidence. Prospect rows are never dropped — they stay visible at 0% with their money reported as + `pipeline_commitment`, so the interest is still on screen, just not as ownership. + """ + # Has anyone actually used the workflow on this project? Any state other than the stamped initial + # one proves it, `exited` included — that investor was committed once, which is the same evidence. + workflow_in_use = any( + (i.get("workflow_state") or "prospect") != "prospect" for i in investors) + + def _owns(i: dict) -> bool: + return (not workflow_in_use) or (i.get("workflow_state") in _OWNING_STATES) + rows = [] - total_commit = sum(_num((i.get("data") or i).get("commitment")) for i in investors) + total_commit = sum(_num((i.get("data") or i).get("commitment")) for i in investors if _owns(i)) + pipeline = sum(_num((i.get("data") or i).get("commitment")) + for i in investors if not _owns(i)) for i in investors: d = i.get("data") or i commit = _num(d.get("commitment")) contributed = _num(d.get("contributed")) distributed = _num(d.get("distributed")) + owns = _owns(i) rows.append({ "id": i.get("id"), "ref": i.get("ref"), "investor": d.get("investor"), "investor_class": d.get("investor_class") or "LP", "entity_type": d.get("entity_type"), "commitment": round(commit, 2), - "ownership_pct": round(100 * commit / total_commit, 4) if total_commit else 0.0, + # Carried on the ROW, not recomputed by each consumer: seven call sites read this table, + # and a rule re-derived seven times is a rule that disagrees with itself somewhere. + "counts_toward_ownership": owns, + "ownership_pct": round(100 * commit / total_commit, 4) if (owns and total_commit) else 0.0, "contributed": round(contributed, 2), "distributed": round(distributed, 2), "unreturned": round(max(0.0, contributed - distributed), 2), "status": i.get("workflow_state"), }) - rows.sort(key=lambda r: -r["commitment"]) + # Owners first, then by size. Sorting on commitment alone put the $10M prospect at the TOP of the + # table as the largest apparent owner while showing 0%% — the reader's eye takes rank as ownership. + rows.sort(key=lambda r: (not r["counts_toward_ownership"], -r["commitment"])) by_class: dict[str, float] = {} for r in rows: + if not r["counts_toward_ownership"]: + continue # or by_class would sum to more than total_commitment by_class[r["investor_class"]] = by_class.get(r["investor_class"], 0.0) + r["commitment"] return { "investor_count": len(rows), + # `total_commitment` is COMMITTED capital — the denominator ownership is computed against. + # The uncommitted interest is reported beside it rather than folded in or silently dropped. "total_commitment": round(total_commit, 2), + "pipeline_commitment": round(pipeline, 2), + "workflow_in_use": workflow_in_use, "total_contributed": round(sum(r["contributed"] for r in rows), 2), "total_distributed": round(sum(r["distributed"] for r in rows), 2), "total_unreturned": round(sum(r["unreturned"] for r in rows), 2), diff --git a/services/api/src/aec_api/distwaterfall.py b/services/api/src/aec_api/distwaterfall.py index 2377d053..93c0429c 100644 --- a/services/api/src/aec_api/distwaterfall.py +++ b/services/api/src/aec_api/distwaterfall.py @@ -66,8 +66,12 @@ def scenario(db, pid: str, body: dict | None = None) -> dict[str, Any]: investors = me.list_records(db, "investor", pid, limit=100000) if "investor" in me.TABLES else [] ct = capital.cap_table(investors) rows = ct["rows"] - lp = [r for r in rows if not _is_gp(r["investor_class"])] - gp = [r for r in rows if _is_gp(r["investor_class"])] + # Honour `cap_table`'s ownership rule rather than re-deriving one. A prospect drew a real + # distribution share here — `share = lp_total * (commitment / lp_commit)` off a commitment nobody + # had committed to — because these rows were filtered by CLASS and never by state. + owning = [r for r in rows if r["counts_toward_ownership"]] + lp = [r for r in owning if not _is_gp(r["investor_class"])] + gp = [r for r in owning if _is_gp(r["investor_class"])] lp_commit = sum(r["commitment"] for r in lp) gp_commit = sum(r["commitment"] for r in gp) # no cap table -> nothing to allocate; return a clean zeroed scenario rather than a phantom split diff --git a/services/api/test_cap_table_state.py b/services/api/test_cap_table_state.py new file mode 100644 index 00000000..b4842af8 --- /dev/null +++ b/services/api/test_cap_table_state.py @@ -0,0 +1,118 @@ +"""Only COMMITTED capital owns anything — and a default state is not a signal. + +`cap_table` summed `commitment` across every investor whatever their workflow state. The roadmap +measured it: two funded LPs at $6M and $4M plus one `prospect` carrying a $10M interest and $0 +contributed, and the prospect took 50% of the table, halved Anchor LP from 60% to 30%, and sorted to +the top as the largest apparent owner. It did not stop at display — `distwaterfall` allocates +`share = lp_total * (commitment / lp_commit)` off these rows, so the prospect drew real money. + +THE OBVIOUS FIX IS WRONG ON ITS OWN, which is why this file exists rather than a one-line filter. +`investor` declares `initial: prospect` and every record is stamped with it at creation, so on a +project where nobody ran the `commit` transition EVERY investor is a prospect and a filter empties +the cap table. The roadmap records that this is not hypothetical: it was implemented, and +`test_distwaterfall` — which builds three investors through the real API and expects a $2,000,000 +distribution — returned 0.0. `workflow_in_use` is the distinction that makes the filter safe. + +Run: PYTHONPATH=src ./.venv/bin/python test_cap_table_state.py""" +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test_cap_table_state.db") +os.environ.setdefault("STORAGE_DIR", "./_storage_test_cap_table_state") +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_cap_table_state.db",): + if os.path.exists(_f): + os.remove(_f) + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api.capital import cap_table # noqa: E402 +from aec_api.main import app # noqa: E402 + +FUNDED, PROSPECT, COMMITTED, EXITED = "funded", "prospect", "committed", "exited" + + +def _inv(n, state, commit, contributed=0.0, cls="LP"): + return {"id": n, "ref": n, "workflow_state": state, + "data": {"investor": n, "investor_class": cls, + "commitment": commit, "contributed": contributed}} + + +# --- the roadmap's own measured scenario, number for number ------------------------------------- +ct = cap_table([_inv("Anchor LP", FUNDED, 6_000_000, 6_000_000), + _inv("Second LP", FUNDED, 4_000_000, 4_000_000), + _inv("Maybe LP", PROSPECT, 10_000_000, 0)]) +by = {r["investor"]: r for r in ct["rows"]} +assert by["Anchor LP"]["ownership_pct"] == 60.0, by["Anchor LP"] # was 30.0 +assert by["Second LP"]["ownership_pct"] == 40.0, by["Second LP"] # was 20.0 +assert by["Maybe LP"]["ownership_pct"] == 0.0, by["Maybe LP"] # was 50.0 +assert ct["total_commitment"] == 10_000_000.0, ct["total_commitment"] + +# the prospect is NOT dropped — its money is reported, just not as ownership. Deleting the row would +# hide a real pipeline; counting it as ownership was the bug. +assert by["Maybe LP"]["commitment"] == 10_000_000.0 +assert ct["pipeline_commitment"] == 10_000_000.0, ct["pipeline_commitment"] + +# and it no longer sorts to the top as the largest apparent owner: rank is read as ownership. +assert [r["investor"] for r in ct["rows"]] == ["Anchor LP", "Second LP", "Maybe LP"], ct["rows"] + +# by_class must agree with the denominator, or the two halves of the same table contradict. +assert sum(ct["by_class"].values()) == ct["total_commitment"], ct["by_class"] + +# --- THE TRAP: every investor still sits at the stamped initial state --------------------------- +# This is the case that broke the naive filter. `prospect` here means "nobody used the workflow", +# not "not committed", so the table must behave exactly as it did before. +allp = cap_table([_inv("A", PROSPECT, 1_000_000), _inv("B", PROSPECT, 1_000_000), + _inv("C", PROSPECT, 1_000_000)]) +assert allp["workflow_in_use"] is False, allp["workflow_in_use"] +assert allp["total_commitment"] == 3_000_000.0, allp["total_commitment"] +assert {round(r["ownership_pct"], 2) for r in allp["rows"]} == {33.33}, allp["rows"] +assert allp["pipeline_commitment"] == 0.0, "nothing is pipeline when nothing is committed yet" + +# ONE investor moving off the initial state flips the reading for the whole project — that is the +# signal, and it is a project-level fact, not a per-row one. +mixed = cap_table([_inv("A", COMMITTED, 1_000_000), _inv("B", PROSPECT, 1_000_000), + _inv("C", PROSPECT, 1_000_000)]) +assert mixed["workflow_in_use"] is True +assert mixed["total_commitment"] == 1_000_000.0, mixed["total_commitment"] +assert mixed["pipeline_commitment"] == 2_000_000.0, mixed["pipeline_commitment"] + +# --- `exited` is evidence the workflow was used, but is not current ownership ------------------- +# An investor who has left does not hold a share; their presence still proves the workflow is live. +ex = cap_table([_inv("Gone", EXITED, 5_000_000, 5_000_000), _inv("Here", PROSPECT, 5_000_000)]) +assert ex["workflow_in_use"] is True, "an exited investor proves the workflow was used" +assert {r["investor"]: r["counts_toward_ownership"] for r in ex["rows"]} == { + "Gone": False, "Here": False}, ex["rows"] + +# --- the decision travels ON THE ROW, so seven consumers cannot disagree ------------------------ +for r in ct["rows"]: + assert "counts_toward_ownership" in r, r +assert [r["counts_toward_ownership"] for r in ct["rows"]] == [True, True, False] + +# --- and the money follows: a prospect must draw NO distribution --------------------------------- +# Mutation-checking exposed that `test_distwaterfall` passes even when `distwaterfall` ignores the +# flag — its fixture has no prospect, so the filter was unexercised. This is that missing case, built +# through the real API so the records carry the workflow's own stamped initial state. +with TestClient(app) as c: + pid = c.post("/projects", json={"name": "Waterfall states"}).json()["id"] + + def _mk(name, cls, commit, state=None): + rid = c.post(f"/projects/{pid}/modules/investor", + json={"data": {"investor": name, "investor_class": cls, + "commitment": commit}}).json()["id"] + if state: + c.post(f"/projects/{pid}/modules/investor/{rid}/transition", + json={"action": state}) + return rid + + _mk("Alpha LP", "LP", 900_000, "commit") # prospect -> committed + _mk("GP Co", "GP", 100_000, "commit") + _mk("Maybe LP", "LP", 9_000_000) # left at the stamped initial state + + w = c.post(f"/projects/{pid}/waterfall", json={"exit_amount": 2_000_000}).json() + per = {x["investor"]: x["distribution"] for x in w["per_investor"]} + assert "Maybe LP" not in per or per["Maybe LP"] == 0.0, per + # Alpha holds the whole LP class despite being outweighed 10:1 by an uncommitted interest. + assert per.get("Alpha LP", 0) > 0, per + assert round(sum(per.values()), 2) == round(w["total_distributable"], 2), (per, w) + +print("test_cap_table_state OK") From 2fa0692d6e02b5b90205feb2ddc6bfcb578edadb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:16:59 +0000 Subject: [PATCH 14/23] =?UTF-8?q?SCALE-SEAM=20(101)=20=E2=80=94=20design-p?= =?UTF-8?q?hase=20predicted=20performance,=20client.ts=20642=20->=20603?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six methods out. Five to a new `apps/web/src/api/designPerformance.ts` (energy, energyModel, energyExportUrl, carbonComplianceReport, projectCarbon) and benchmarkCosts to cost.ts. **The seam was drawn by earlier slices, not this one.** operations.ts's own header records why projectCarbon did not go there — "EMBODIED carbon ... a design-phase estimate. The GHG figures in esgSummary come from metered utility data. Same molecule, opposite ends of the asset life" — models.ts records the parallel call for /energy, and operations.ts does hold /energy/actual. Prediction versus measurement, committed to twice independently. These five are the prediction side of an axis this codebase already chose. Not named environmental.ts on purpose: that names the TOPIC both halves share, which is exactly what would re-blur the seam operations.ts drew. What separates them is not subject matter but whether the number is forecast or observed. **A planned benchmarks.ts was abandoned before any code was written.** Grepping every /benchmarks caller showed two already live elsewhere: cost.ts holds unitRates (/benchmarks/unit-rates) and schedule.ts holds benchmarksPullPlanning (/benchmarks/pull-planning). So the repo had already decided that prefix distributes by what each method ANSWERS, and a benchmarks.ts would have been route-prefix grouping contradicting two live placements. benchmarkCosts went to cost.ts instead, beside unitRates — the same question at a different granularity, same low/p25/median/p75/high shape. benchmarkResponseRates and spaceUtilBenchmarks STAYED: no existing mixin owns their question, and inventing a home for two methods on a guess is what produced this file's UNFILED banner. Two gates earned their keep. DOC-STRAND caught the extraction stranding unitRates' doc comment above the inserted block — reunited, not deleted. And the size ratchet reported 603 where `wc -l` said 602, which is the off-by-one its own message warns about; the pin took the gate's number. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 26 ++++++++++ apps/web/src/api/client.ts | 45 ++--------------- apps/web/src/api/cost.ts | 11 +++++ apps/web/src/api/designPerformance.ts | 69 +++++++++++++++++++++++++++ docs/roadmap.md | 4 +- services/api/test_file_sizes.py | 2 +- 6 files changed, 112 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/api/designPerformance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c39199b..75bfea13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — SCALE-SEAM (101): design-phase predicted performance + +Six methods out of `client.ts` (**642 → 603**). Five to a new +`apps/web/src/api/designPerformance.ts` — `energy`, `energyModel`, `energyExportUrl`, +`carbonComplianceReport`, `projectCarbon` — and `benchmarkCosts` to `cost.ts`. + +**The seam was drawn by earlier slices, not this one, which is the whole witness.** +`operations.ts`'s own header records why the carbon half did not go there: *"`projectCarbon` is +EMBODIED carbon — a design-phase estimate. The GHG figures in `esgSummary` come from metered utility +data. Same molecule, opposite ends of the asset life."* `models.ts` records the parallel call for +`/energy`, and `operations.ts` does hold `/energy/actual`. Prediction versus measurement, committed +to twice independently — these five are the prediction side. + +Deliberately **not** named `environmental.ts`: that names the topic both halves share, and would +re-blur the seam `operations.ts` drew. + +**A planned `benchmarks.ts` was abandoned on evidence.** `cost.ts` already held `unitRates` +(`/benchmarks/unit-rates`) and `schedule.ts` holds `benchmarksPullPlanning` +(`/benchmarks/pull-planning`), so the repo already distributes that prefix by what each method +answers; grouping the remaining three by route would have contradicted two live placements. +`benchmarkResponseRates` and `spaceUtilBenchmarks` stayed — no mixin owns their question, and +inventing a home on a guess is what produced this file's UNFILED banner. + +The DOC-STRAND gate caught the extraction stranding `unitRates`' doc comment above the inserted +block; reunited rather than deleted. + ## Unreleased — only committed capital owns anything (cap table + waterfall) `capital.cap_table` summed `commitment` across every investor whatever their workflow state. A diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d4b11b81..be845d18 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -17,6 +17,7 @@ import { withOperations } from "./operations"; import { withClientPortal } from "./clientPortal"; import { withCreDeal } from "./creDeal"; import { withAnnotate } from "./annotate"; +import { withDesignPerformance } from "./designPerformance"; import { withDetailing } from "./detailing"; import { withResilience } from "./resilience"; import { withResponsibility } from "./responsibility"; @@ -61,7 +62,7 @@ export * from "./library"; export type { ClashResult } from "./clash"; import type { Dashboard, - DisciplineTree, EnergyResult, ModulePin, RoomAllocation, + DisciplineTree, ModulePin, RoomAllocation, PropMapRule, SpecManual, WorkItem, VitalsPayload, DiligenceReadiness, MasterBuilderBrief, PrequalScores, @@ -70,7 +71,7 @@ import type { // Transport (baseUrl, token, json/_pdfPost/url/health) lives in HttpCore; ApiClient adds the typed // domain methods below. Every `api.method()` call site is unchanged by the split. -export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore)))))))))))))))))))))))))))))))))))))))))))) { +export class ApiClient extends withDesignPerformance(withDetailing(withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore))))))))))))))))))))))))))))))))))))))))))))) { /** * R22-PHOTO-CV — attach a field photo to an element and get the server's read on it back. * @@ -214,18 +215,6 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient editors: { user: string; seconds_ago: number; viewpoint: unknown }[]; editor_count: number; }>(`/projects/${pid}/collab`); } - /** Embodied-carbon compliance: element totals, coverage and intensity against the project's limits. */ - carbonComplianceReport(pid: string) { - return this.json<{ - elements: { total_tco2e: number; coverage_pct: number; intensity_kgco2e_m2?: number; - carbon_matched: number; with_quantity: number; - hotspots: { guid: string; name: string | null; category: string; kgco2e: number }[] }; - buy_clean: { rows: { category: string; achieved_factor: number; limit: number; unit: string; - pass: boolean; headroom_pct: number; action: string | null }[]; - passing: number; failing: number }; - leed_inventory: { total_tco2e: number; items: { category: string; kgco2e: number; share_pct: number }[] }; - }>(`/projects/${pid}/carbon/compliance`); - } /** PERMIT-CHECK: submission-readiness — checklist + ranked deficiencies + verdict (409 without a model). */ permitReadiness(pid: string) { return this.json<{ @@ -341,9 +330,6 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient validate(pid: string) { return fetch(this.url(`/projects/${pid}/validate`), { method: "POST" }).then((r) => r.json() as Promise); } - energy(pid: string) { - return this.json(`/projects/${pid}/energy`); - } // W9-1 property mapping / normalization — the transform verb between IDS-validate and COBie-export propmapDetect(pid: string) { @@ -354,20 +340,6 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient return this.json<{ dry_run: boolean; changed: number; rules: { from: string; to: string; matched: number; cast: string; keep_source: boolean; samples: { guid: string; from: string; to: string }[] }[] }>( `/projects/${pid}/propmap/plan`, { method: "POST", body: JSON.stringify({ rules }) }); } - /** ENERGY phase 1 — the thermal model extracted from the IFC (zones · surfaces · constructions). */ - energyModel(pid: string) { - return this.json<{ zone_source: string; - zones: { id: string; name: string; storey: string; area_m2: number; volume_m3: number }[]; - surfaces: { id: string; name: string; ifc_class: string; idf_type: string; zone_id: string; - construction: string; orientation: string; area_m2: number; geometry: "exact" | "bbox"; - corners: number[][] }[]; - constructions: { name: string; u_value: number | null; source: string }[]; - counts: Record; note: string }>(`/projects/${pid}/energy/model`); - } - /** ENERGY phase 1 — the gbXML / IDF envelope export URLs (downloads, not JSON). */ - energyExportUrl(pid: string, fmt: "gbxml" | "idf") { - return `${this.baseUrl}/projects/${pid}/energy/export.${fmt}`; - } sharedParams(pid: string) { return this.json<{ params: { name: string; pset: string; ptype: string; applies_to: string[]; label: string; description: string }[]; max: number }>(`/projects/${pid}/shared-params`); @@ -399,12 +371,6 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient } // --- portfolio benchmarking (cross-project) -------------------------------- - benchmarkCosts(minSamples = 3) { - return this.json<{ cost_codes: { cost_code: string; samples: number; low: number; p25: number; - median: number; p75: number; high: number; total: number }[]; - code_count: number; min_samples: number; codes_below_threshold: number; message?: string | null }>( - `/benchmarks/costs?min_samples=${minSamples}`); - } benchmarkResponseRates() { return this.json<{ rfi: { total: number; open: number; answered_or_closed: number; avg_turnaround_days: number | null; overdue: number; overdue_pct: number }; @@ -428,11 +394,6 @@ export class ApiClient extends withDetailing(withAnnotate(withCreDeal(withClient total_lien_exposure: number; vendors_at_risk: string[]; message?: string | null }>( `/projects/${pid}/payapp/lien-exposure`); } - projectCarbon(pid: string) { - return this.json<{ total_kgco2e: number; total_tco2e: number; line_count: number; unmatched: number; - by_material: Record; by_cost_code: Record; message?: string | null }>( - `/projects/${pid}/carbon`); - } // --- design lifecycle (RIBA/AIA phases + itemized soft costs) --------------- lifecycle(pid: string) { return this.json<{ count: number; seeded: boolean; diff --git a/apps/web/src/api/cost.ts b/apps/web/src/api/cost.ts index 06238a54..0f05f2a3 100644 --- a/apps/web/src/api/cost.ts +++ b/apps/web/src/api/cost.ts @@ -129,6 +129,17 @@ export function withCost>(Base: TBase) { adjustment: Record | null; }>(`/projects/${pid}/cost-vintage`); } + /** Cross-project cost-code bands — the same question `unitRates` below answers at a different + * granularity (cost codes rather than unit rates), with the same low/p25/median/p75/high shape. + * It lives here and not in a `/benchmarks` file because this repo places those by what each one + * ANSWERS: `unitRates` was already here and `benchmarksPullPlanning` is in `schedule.ts`, both + * under the same route prefix. Grouping by the prefix would have contradicted two live placements. */ + benchmarkCosts(minSamples = 3) { + return this.json<{ cost_codes: { cost_code: string; samples: number; low: number; p25: number; + median: number; p75: number; high: number; total: number }[]; + code_count: number; min_samples: number; codes_below_threshold: number; message?: string | null }>( + `/benchmarks/costs?min_samples=${minSamples}`); + } /** Actual unit rates per cost code across the caller's projects (cost ÷ installed quantity). */ unitRates(minProjects = 3) { return this.json<{ diff --git a/apps/web/src/api/designPerformance.ts b/apps/web/src/api/designPerformance.ts new file mode 100644 index 00000000..dde429d4 --- /dev/null +++ b/apps/web/src/api/designPerformance.ts @@ -0,0 +1,69 @@ +/** What will this design CONSUME and EMIT, and does it comply? + * + * SCALE-SEAM ㉞. Five methods grouped by the question they answer, and the seam they sit on was + * drawn by earlier slices rather than by this one — which is why they belong together. + * + * **Design-phase PREDICTION, as against in-service MEASUREMENT.** `operations.ts` already holds the + * metered counterparts (`/projects/{pid}/energy/actual`, `/energy/benchmark-status`) and its own + * header records why the carbon half did not go with them: *"`projectCarbon` is EMBODIED carbon — + * what building it emits, a design-phase estimate. The GHG figures in `esgSummary` come from metered + * utility data. Same molecule, opposite ends of the asset life."* `models.ts` records the parallel + * call for energy — grouping by nearby comments *"would have dragged … `/energy` across the seam"*. + * So the axis was already committed to twice, independently, and these five are the prediction side + * of it: the thermal model and its gbXML/IDF handoff, and embodied carbon against the project target. + * + * **Deliberately NOT called `environmental.ts`.** That names the TOPIC, and the topic is exactly what + * both halves share — naming it that would re-blur the seam `operations.ts` drew. What separates them + * is not subject matter but whether the number is forecast or observed. + * + * A mixin, so every call site resolves unchanged. `api/surface.test.ts` is what proves it: moving a + * method is invisible to it, losing one fails it by number. + */ +import type { EnergyResult } from "./types"; + +import { HttpCore } from "./httpCore"; + +type Ctor = new (...args: any[]) => T; + +export function withDesignPerformance>(Base: TBase) { + return class extends Base { + energy(pid: string) { + return this.json(`/projects/${pid}/energy`); + } + + /** ENERGY phase 1 — the thermal model extracted from the IFC (zones · surfaces · constructions). */ + energyModel(pid: string) { + return this.json<{ zone_source: string; + zones: { id: string; name: string; storey: string; area_m2: number; volume_m3: number }[]; + surfaces: { id: string; name: string; ifc_class: string; idf_type: string; zone_id: string; + construction: string; orientation: string; area_m2: number; geometry: "exact" | "bbox"; + corners: number[][] }[]; + constructions: { name: string; u_value: number | null; source: string }[]; + counts: Record; note: string }>(`/projects/${pid}/energy/model`); + } + + /** ENERGY phase 1 — the gbXML / IDF envelope export URLs (downloads, not JSON). */ + energyExportUrl(pid: string, fmt: "gbxml" | "idf") { + return `${this.baseUrl}/projects/${pid}/energy/export.${fmt}`; + } + + /** Embodied-carbon compliance: element totals, coverage and intensity against the project's limits. */ + carbonComplianceReport(pid: string) { + return this.json<{ + elements: { total_tco2e: number; coverage_pct: number; intensity_kgco2e_m2?: number; + carbon_matched: number; with_quantity: number; + hotspots: { guid: string; name: string | null; category: string; kgco2e: number }[] }; + buy_clean: { rows: { category: string; achieved_factor: number; limit: number; unit: string; + pass: boolean; headroom_pct: number; action: string | null }[]; + passing: number; failing: number }; + leed_inventory: { total_tco2e: number; items: { category: string; kgco2e: number; share_pct: number }[] }; + }>(`/projects/${pid}/carbon/compliance`); + } + + projectCarbon(pid: string) { + return this.json<{ total_kgco2e: number; total_tco2e: number; line_count: number; unmatched: number; + by_material: Record; by_cost_code: Record; message?: string | null }>( + `/projects/${pid}/carbon`); + } + }; +} diff --git a/docs/roadmap.md b/docs/roadmap.md index ff126d37..e562a8a1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1134,7 +1134,7 @@ exact failure `roadmapLanes.test.ts` documents in its `MARKS` note. The gates ca needs to keep being interleaved. **Re-measure the ceiling before ever promoting it again** — that is the specific error row 2 made. * **Not the next SCALE-SEAM slice.** ㉘ is genuinely next in a series that has shipped twenty-six - increments, but the series is now cutting into `client.ts` at **642** lines from a 3,600-odd start + increments, but the series is now cutting into `client.ts` at **603** lines from a 3,600-odd start *(re-derived 2026-09-04; this read "2,837" — 4.4x the real figure — because the number was copied forward through every slice since, which is the exact drift the rows above document twice)*. The marginal slice is worth less than it was. *(㉘ also needed `MARKS` widened; that shipped in v0.3.1112.)* The vocabulary lives in @@ -3181,7 +3181,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(100) took sixty-six of those 126 — 60 remain, and there is STILL no map.** A new + **(88)–(101) took seventy-two of those 126 — 54 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index 1cfe755a..54aa3a70 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 642, # SCALE-SEAM (100): THE ELEMENT-CONNECTION PAIR — what is physically joined to what, and record a joint? (648 -> 642). elementConnections + connectElements to model.ts. BOUND BY THE BACKEND NAMING ITS OWN WRITER (the (96) shape): the /element-connections route docstring says "Author edges with the connect_elements recipe (POST /edit with {guid_a, guid_b})". DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS STATED SO: model.ts owns modelGraphStats (counts the IFC relationship graph BY RELATION, and IfcRelConnectsElements is one) and graphNeighbors (walks it), so this is one relation of that graph plus its verb — a SPECIALISATION, not an identity. REJECTED ON CHECKABLE GROUNDS: connections.ts is DATA-SOURCE connections (SQL/ACC/Procore), sharing only the English word — (97) same collision, in a destination; elements.ts holds element ATTRIBUTES, and a relationship between two elements is not an attribute of either. addBasePlate/addShearTab did NOT come despite sharing connections.py: a backend module is a HOW, and they author PHYSICAL assemblies, not IfcRelConnectsElements edges. # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 603, # SCALE-SEAM (101): DESIGN-PHASE PREDICTED PERFORMANCE — what will this design consume and emit, and does it comply? (642 -> 603). energy + energyModel + energyExportUrl + carbonComplianceReport + projectCarbon to a new designPerformance.ts, and benchmarkCosts to cost.ts. THE SEAM WAS DRAWN BY EARLIER SLICES, NOT THIS ONE, which is the whole witness: operations.ts's own header records why projectCarbon did not go there ("EMBODIED carbon ... a design-phase estimate. The GHG figures in esgSummary come from metered utility data. Same molecule, opposite ends of the asset life"), models.ts records the parallel call for /energy, and operations.ts DOES hold /energy/actual. Prediction vs measurement, committed to twice independently. NOT NAMED environmental.ts on purpose: that names the TOPIC both halves share, and would re-blur the seam operations.ts drew. A PLANNED benchmarks.ts WAS ABANDONED ON EVIDENCE: cost.ts already held unitRates (/benchmarks/unit-rates) and schedule.ts holds benchmarksPullPlanning (/benchmarks/pull-planning), so the repo already distributes that prefix by what each method ANSWERS — grouping the remaining three by route would have contradicted two live placements. benchmarkResponseRates and spaceUtilBenchmarks STAYED: no mixin owns their question, and inventing a home on a guess is what produced this file's UNFILED banner. # SCALE-SEAM (100): THE ELEMENT-CONNECTION PAIR — what is physically joined to what, and record a joint? (648 -> 642). elementConnections + connectElements to model.ts. BOUND BY THE BACKEND NAMING ITS OWN WRITER (the (96) shape): the /element-connections route docstring says "Author edges with the connect_elements recipe (POST /edit with {guid_a, guid_b})". DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS STATED SO: model.ts owns modelGraphStats (counts the IFC relationship graph BY RELATION, and IfcRelConnectsElements is one) and graphNeighbors (walks it), so this is one relation of that graph plus its verb — a SPECIALISATION, not an identity. REJECTED ON CHECKABLE GROUNDS: connections.ts is DATA-SOURCE connections (SQL/ACC/Procore), sharing only the English word — (97) same collision, in a destination; elements.ts holds element ATTRIBUTES, and a relationship between two elements is not an attribute of either. addBasePlate/addShearTab did NOT come despite sharing connections.py: a backend module is a HOW, and they author PHYSICAL assemblies, not IfcRelConnectsElements edges. # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From ea414abb188810e519d11ac39d68a52c12fd83f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 16:50:20 +0000 Subject: [PATCH 15/23] =?UTF-8?q?Portfolio=20risk=20heat=20map=20=E2=80=94?= =?UTF-8?q?=20R22-PIPELINE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /portfolio/risk` (`risk_portfolio.py`) grids `risk_board` across the book: projects down, the five risk engines across (Monte-Carlo schedule risk · predictive alerts · EVM · pre-flight gate · overdue coordination), intensity `3·high + 2·medium + 1·low`. Rendered on Portfolio beside the executive roll-up. `/portfolio/executive` and `/portfolio/construction` roll up *performance*; neither could say which risk ENGINE is hot on which project. Cells come from `risk_board.board` unchanged — same engines, same Monte-Carlo seed — so a cell and the project's own risk panel cannot disagree. That costs a full board per project, so the sweep is bounded by `limit` (default 25, clamped 1–100) and reports `truncated`; the scanned set is a deterministic prefix by name, not the riskiest projects, because ranking is what the sweep produces and so cannot choose what to sweep. AN EMPTY CELL IS NOT A SAFE CELL. A grid of counts renders 0 for two different facts: this engine looked and found nothing, and this engine could not run. `board` is fail-open per lane and already separates them, so every cell carries a `state`; an unmeasured cell carries NO COUNTS AT ALL rather than zeros, and the UI draws it as a dash. `coverage` reports the split. A clear signal nobody has a basis for is worse than no heat map — the same lesson as the cap table's stamped default state, in a second place. `risk_board.LANES` is new and gated against a REAL board run. `board` reports coverage under lane keys (`schedule_risk`) while its items carry source strings (`schedule-risk`); nothing connected the two, and a roll-up must join on both. `test_risk_portfolio.py` asserts every lane key `board` emits appears in the table and every `source` its items carry is a value — so a lane added to `board` alone fails rather than rendering as a column that never lights up. Both claims mutation-checked: emitting zeros for an error cell, and dropping a lane from `LANES`, each fail naming the shape. 664/664 backend suites, 2062 web tests, typecheck + lint + build clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 28 +++ apps/web/src/api/risk.ts | 21 +++ apps/web/src/portal/panels/portfolio.ts | 58 ++++++ docs/roadmap.md | 26 +++ services/api/run_tests.py | 2 +- services/api/src/aec_api/risk_board.py | 15 ++ services/api/src/aec_api/risk_portfolio.py | 166 ++++++++++++++++ services/api/src/aec_api/routers/dashboard.py | 23 +++ services/api/test_risk_portfolio.py | 178 ++++++++++++++++++ 9 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 services/api/src/aec_api/risk_portfolio.py create mode 100644 services/api/test_risk_portfolio.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bfea13..b1244aee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — Portfolio risk heat map + +`GET /portfolio/risk` (`risk_portfolio.py`) grids `risk_board` across the book: projects down, the +five risk engines across (Monte-Carlo schedule risk · predictive alerts · EVM · pre-flight gate · +overdue coordination), intensity `3·high + 2·medium + 1·low`. Rendered on Portfolio beside the +executive roll-up, with a "worst first" line and click-through to the project. + +`/portfolio/executive` and `/portfolio/construction` roll up *performance*; neither could say which +risk **engine** is hot on which project. Cells come from `risk_board.board` unchanged — same engines, +same Monte-Carlo seed — so a cell and the project's own risk panel cannot disagree. That costs a full +board per project, so the sweep is bounded by `limit` (default 25, clamped 1–100) and reports +`truncated` rather than quietly scanning a prefix. + +**An empty cell is not a safe cell.** A grid of counts renders `0` for two different facts: *this +engine looked and found nothing*, and *this engine could not run*. `board` is fail-open per lane and +already separates them, so every cell carries a `state`; an unmeasured cell carries **no counts at +all** rather than zeros, and the UI draws it as a dash. `coverage` reports the split at portfolio +level. A clear signal nobody has a basis for is worse than no heat map. + +**`risk_board.LANES` is new, and gated against a real board run.** `board` reports coverage under +lane keys (`schedule_risk`) while its items carry source strings (`schedule-risk`); nothing connected +the two, and a roll-up must join on both. `test_risk_portfolio.py` asserts every lane key `board` +emits appears in the table and every `source` its items carry is a value — so a lane added to `board` +alone fails rather than rendering as a column that never lights up. + +Both claims mutation-checked: emitting zeros for an error cell, and dropping a lane from `LANES`, +each fail with the shape named. + ## Unreleased — SCALE-SEAM (101): design-phase predicted performance Six methods out of `client.ts` (**642 → 603**). Five to a new diff --git a/apps/web/src/api/risk.ts b/apps/web/src/api/risk.ts index 1ed46f34..b23c2be5 100644 --- a/apps/web/src/api/risk.ts +++ b/apps/web/src/api/risk.ts @@ -25,6 +25,27 @@ export function withRisk>(Base: TBase) { riskDigest(pid: string) { return this.json(`/projects/${pid}/risk-digest`); } + /** The same board gridded across the portfolio — projects × risk engine, severity-weighted. + * `state` on a cell is not decoration: an engine that could not run reads `error`, and the map + * must not render that as a clear cell. `coverage` says how much of the grid is measured. */ + portfolioRisk(limit = 25) { + return this.json<{ + projects: { id: string; name: string; band: string | null; score: number; count: number; + high: number; medium: number; low: number; measured_sources: number; + cells: Record }[]; + sources: { key: string; label: string; score: number; count: number; high: number; + projects_measured: number; projects_error: number }[]; + totals: { high: number; medium: number; low: number; count: number; score: number }; + band_tally: Record; + hotspots: { project_id: string; project: string; source: string; score: number; high: number; + title: string | null; link: string | null }[]; + coverage: { cells: number; measured: number; errored: number; unknown: number; + pct: number | null }; + project_count: number; projects_available: number; truncated: boolean; limit: number; + note: string; + }>(`/portfolio/risk?limit=${limit}`); + } /** RISK-BOARD: one ranked register unifying every computed risk signal (deep-linked per item). */ riskBoard(pid: string) { return this.json<{ items: { source: string; severity: "high" | "medium" | "low"; title: string; diff --git a/apps/web/src/portal/panels/portfolio.ts b/apps/web/src/portal/panels/portfolio.ts index e8a548db..b53af742 100644 --- a/apps/web/src/portal/panels/portfolio.ts +++ b/apps/web/src/portal/panels/portfolio.ts @@ -142,6 +142,64 @@ export async function renderPortfolio(ctx: PanelContext) { ctx.root.appendChild(card); }).catch(() => { /* returns spread is best-effort; the roll-up above stands on its own */ }); + // RISK HEAT MAP — R22-PIPELINE. The table above says how each project is PERFORMING; this says + // which risk ENGINE is hot on which project, which is the question that decides where a + // programme director spends the morning. Cells come from the same `risk_board` each project's + // own risk panel renders, so clicking through can never show a different number. + // + // A cell whose engine could not run is drawn as a dash on the muted ground, never as a green + // zero. That is the whole reason `state` is on the wire: an unmeasured source rendered as clear + // is worse than no heat map, because it is a clear signal nobody has any basis for. + void ctx.host.api.portfolioRisk().then((hm) => { + if (!hm.projects.length) return; + const card = document.createElement("div"); card.className = "dash-card"; card.style.marginTop = "10px"; + const cov = hm.coverage; + card.innerHTML = `Risk heat map ${hm.project_count} project(s) × ${hm.sources.length} engines` + + ` · ${hm.totals.high} high / ${hm.totals.medium} medium / ${hm.totals.low} low` + + (cov.pct == null ? "" : ` · ${cov.pct}% of cells measured`) + + (cov.errored || cov.unknown ? ` · ${cov.errored + cov.unknown} unavailable` : "") + + (hm.truncated ? ` · showing ${hm.project_count} of ${hm.projects_available}` : "") + + ``; + // Intensity ramp, not a gradient: four steps a reader can name, keyed off the same + // severity-weighted score the API computes so the colour and the number never diverge. + const heat = (score: number) => score >= 9 ? "var(--status-crit)" : score >= 4 ? "var(--status-warn)" + : score > 0 ? "var(--status-good)" : "transparent"; + const tbl = document.createElement("table"); tbl.className = "portal-table"; tbl.style.fontSize = "11px"; + tbl.innerHTML = `Project` + + hm.sources.map((s2) => `${esc(s2.label)}`).join("") + + `Total`; + const tb = document.createElement("tbody"); + for (const p of hm.projects) { + const tr = document.createElement("tr"); tr.className = "kpi-click"; + if (p.id === here) tr.style.fontWeight = "700"; + const cells = hm.sources.map((s2) => { + const c = p.cells[s2.key]; + if (!c || c.state !== "ok") { + return `–`; + } + const col = heat(c.score ?? 0); + const txt = c.count ? String(c.count) : "·"; + return `${txt}`; + }).join(""); + tr.innerHTML = `${esc(p.name)}${p.id === here ? " ·" : ""}${cells}` + + `${p.count || "—"}`; + tr.onclick = () => { if (p.id !== here) window.location.search = `?project=${p.id}`; }; + tb.appendChild(tr); + } + tbl.appendChild(tb); card.appendChild(tbl); + if (hm.hotspots.length) { + const hl = document.createElement("div"); hl.className = "meta"; hl.style.marginTop = "6px"; + hl.innerHTML = "Worst first: " + hm.hotspots.slice(0, 4) + .map((x) => `${esc(x.project)} — ${esc(x.title ?? x.source)}`).join(" · "); + card.appendChild(hl); + } + card.appendChild(Object.assign(document.createElement("div"), { className: "meta", + textContent: "Cell = open risk items from that engine (hover for the severity split); " + + "a dash means the engine could not run for that project, which is not the same as clear." })); + ctx.root.appendChild(card); + }).catch(() => { /* heat map is best-effort; the roll-up above stands on its own */ }); + // Acquisition funnel sits ABOVE the construction book: executive KPIs answer deals we already // won; this answers what is in the book, how much of it historically closes, and how long it // takes. Weighted value uses this firm's closed history — a stage without enough samples is diff --git a/docs/roadmap.md b/docs/roadmap.md index e562a8a1..7c2243d6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1860,6 +1860,32 @@ stakes we are missing. ✅ **Funnel viz SHIPPED v0.3.1135.** `GET /pipeline/funnel` had no caller; Portfolio now renders stage counts, derived win rates, weighted value with coverage, and closed cycle time beside open age. Cross-project Gantt, risk heat map, and department resourcing remain. + + ✅ **Risk heat map SHIPPED — `GET /portfolio/risk`, `services/api/src/aec_api/risk_portfolio.py`.** + Projects down, the five `risk_board` engines across, intensity `3·high + 2·medium + 1·low`. Cells + come from `risk_board.board` unchanged — same engines, same Monte-Carlo seed — so a cell and the + project's own risk panel cannot disagree; that costs a full board per project, which is why the + sweep is bounded by `limit` and reports `truncated` rather than silently scanning a prefix. + + **The design decision worth recording is that an empty cell is not a safe cell.** A grid of counts + renders `0` for two different facts — *this engine looked and found nothing* and *this engine could + not run*. `board` already separates them (it is fail-open per lane and returns `lanes: {name: ok | + error}` beside its items), so every cell carries a `state` and an unmeasured one carries **no counts + at all** rather than zeros; the UI draws it as a dash, never a green zero. This is the cap-table + lesson in a second place — *do not let an unmeasured value wear the costume of a measured one* — + and it is mutation-checked: making the error branch emit zeros fails + `services/api/test_risk_portfolio.py` on the cell shape. + + **A second gate came out of building it.** `board` reports coverage under lane keys + (`schedule_risk`) while its items carry source strings (`schedule-risk`), and nothing connected the + two — a roll-up has to join on both. The pairing is now `risk_board.LANES`, asserted against a + **real board run**: every lane key `board` emits must appear, every `source` its items carry must be + a value. A lane added to `board` and not to `LANES` would otherwise render as a column that + silently never lights up. + + **Still open: cross-project Gantt and department resourcing.** The resourcing half is not the small + item this entry's phrasing suggests — `resource_loading.py` groups by **trade and resource type**, + per project, so "by department" needs both a new dimension and a portfolio axis. Size it on its own. ## ⚡ R23 — ENGINEERING UPGRADE RING *(technical scan 2026-07-25; file:line evidence)* **A THIRD false blocker, and the biggest one.** **W10-9 dimensional constraints** has sat gated for diff --git a/services/api/run_tests.py b/services/api/run_tests.py index cb260624..f49dd21b 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_risk_portfolio", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/risk_board.py b/services/api/src/aec_api/risk_board.py index c491ca30..2072c85b 100644 --- a/services/api/src/aec_api/risk_board.py +++ b/services/api/src/aec_api/risk_board.py @@ -14,6 +14,21 @@ _SEV_ORDER = {"high": 0, "medium": 1, "low": 2} +# The lane key `board` reports coverage under, the `source` string its items carry, and a display +# label. Two names for one thing is a drift hazard — the lane is `schedule_risk` while its items say +# `schedule-risk`, and nothing but this table connects them. A roll-up over several projects has to +# join on both (coverage from `lanes`, counts from `items`), so the pairing is stated once here +# rather than re-guessed by every consumer. `test_risk_portfolio.py` asserts it against a REAL board +# run — every lane key `board` emits appears here, and every `source` its items carry is a value — +# so widening `board` without widening this table fails rather than silently dropping a column. +LANES: tuple[tuple[str, str, str], ...] = ( + ("schedule_risk", "schedule-risk", "Schedule risk"), + ("schedule_alerts", "schedule-alert", "Schedule alerts"), + ("evm", "evm", "EVM"), + ("preflight", "preflight", "Pre-flight"), + ("coordination", "coordination", "Coordination"), +) + def board(db, pid: str) -> dict[str, Any]: from . import modules as me diff --git a/services/api/src/aec_api/risk_portfolio.py b/services/api/src/aec_api/risk_portfolio.py new file mode 100644 index 00000000..47bdf8f4 --- /dev/null +++ b/services/api/src/aec_api/risk_portfolio.py @@ -0,0 +1,166 @@ +"""RISK-PORTFOLIO — the portfolio **risk heat map**, one of the three items R22-PIPELINE's +premise-check found genuinely missing. + +`risk_board` answers "what is threatening THIS project" by re-deriving five engines (Monte-Carlo +schedule risk · predictive alerts · EVM · the pre-flight issuance gate · overdue coordination) into +one ranked register. Above the project workspace nobody has that view: `/portfolio/executive` and +`/portfolio/construction` roll up *performance* (SPI, CPI, variance, incident counts), and neither +can say **which risk ENGINE is hot on which project** — the question a heat map exists to answer. +This grids the same board across the book: projects down, risk sources across, severity-weighted +intensity in the cell. + +## An empty cell is not a safe cell + +The one design decision worth stating. A heat map made of counts renders "0" for two entirely +different facts: *this engine looked and found nothing* and *this engine could not run*. `board` +already separates them — it returns `lanes: {name: ok | error}` beside its items, because every lane +is fail-open and a broken source drops its lane rather than the board. So every cell here carries a +`state`, and a cell whose lane errored is `state: "error"` with **no counts at all** rather than +zeros. A blank column that reads as "clear" across the whole portfolio is the exact failure a risk +tool must not have: it is the same class of defect as a cap table that read a stamped default state +as a decision, and the same remedy — *do not let an unmeasured value wear the costume of a measured +one.* + +`coverage` reports the split at portfolio level, so a reader can see how much of the map is real. + +## Weighting + +Intensity is `3·high + 2·medium + 1·low`. Deliberately shallow: the severities come from thresholds +the individual engines already chose, and a steeper curve here would re-weight their judgement +invisibly. The raw counts travel beside the score so a UI can colour on either. + +## Consistency over speed + +Each project's cells come from `risk_board.board` unchanged — same engines, same Monte-Carlo seed — +so a cell here and the project's own risk panel can never disagree. That costs a full board per +project, which is why the sweep is bounded by `limit` and reports `truncated`; the alternative +(cheaper approximations at the portfolio level) is how a dashboard ends up contradicting the panel +it links to. + +The truncation that buys is worth naming rather than hiding: `limit` takes a **deterministic prefix +of the caller's project list**, not the riskiest projects — ranking by risk is exactly what the sweep +computes, so it cannot be used to choose what to sweep. On a book larger than `limit`, the map is a +sample and `truncated` says so; raise `limit` to see the rest. +""" +from __future__ import annotations + +from typing import Any + +from .risk_board import LANES + +_WEIGHT = {"high": 3, "medium": 2, "low": 1} +_BANDS = ("critical", "elevated", "watch", "clear") +DEFAULT_LIMIT = 25 + + +def _empty_counts() -> dict[str, Any]: + return {"high": 0, "medium": 0, "low": 0, "count": 0, "score": 0} + + +def _score(c: dict[str, Any]) -> int: + return sum(_WEIGHT[s] * c[s] for s in ("high", "medium", "low")) + + +def heatmap(db: Any, projects: list[tuple[str, str]], *, + limit: int = DEFAULT_LIMIT) -> dict[str, Any]: + """Grid `risk_board.board` across `projects` — a list of `(id, name)` already scoped to the + caller. `limit` bounds the sweep (each project is a full board); the rest are reported as + `truncated` rather than silently dropped.""" + from . import risk_board + + scanned = projects[:max(0, int(limit))] + keys = [k for k, _s, _l in LANES] + by_source = {s: k for k, s, _l in LANES} + + rows: list[dict[str, Any]] = [] + src_tot = {k: _empty_counts() | {"projects_measured": 0, "projects_error": 0} for k in keys} + tot = _empty_counts() + tally = dict.fromkeys(_BANDS, 0) + hotspots: list[dict[str, Any]] = [] + measured = errored = unknown = 0 + + for pid, name in scanned: + try: + b = risk_board.board(db, pid) + except Exception: # noqa: BLE001 — a project whose board fails outright still gets a row, + b = None # entirely unmeasured, rather than disappearing from the portfolio. + lanes = (b or {}).get("lanes") or {} + items = (b or {}).get("items") or [] + + cells: dict[str, dict[str, Any]] = {} + worst: dict[str, dict[str, Any]] = {} # lane -> highest-severity item, for the hotspot label + for it in items: + k = by_source.get(it.get("source")) + if k is None: # a source this table does not know: counted at + continue # project level below, never invented as a column + cells.setdefault(k, _empty_counts()) + sev = it.get("severity") + if sev in _WEIGHT: + cells[k][sev] += 1 + cells[k]["count"] += 1 + if k not in worst: + worst[k] = it # board sorts high → low, so the first wins + + row_counts = _empty_counts() + for k in keys: + state = lanes.get(k) + if state == "ok": + c = cells.get(k) or _empty_counts() + c["score"] = _score(c) + c["state"] = "ok" + measured += 1 + src_tot[k]["projects_measured"] += 1 + for f in ("high", "medium", "low", "count", "score"): + src_tot[k][f] += c[f] + row_counts[f] += c[f] + if c["score"]: + w = worst.get(k) or {} + hotspots.append({"project_id": pid, "project": name, "source": k, + "score": c["score"], "high": c["high"], + "title": w.get("title"), "link": w.get("link")}) + else: + # error, or absent because the board itself failed. No counts: see the module note. + c = {"state": "error" if state == "error" else "unknown"} + if state == "error": + errored += 1 + src_tot[k]["projects_error"] += 1 + else: + unknown += 1 + cells[k] = c + + band = (b or {}).get("band") + if band in tally: + tally[band] += 1 + for f in ("high", "medium", "low", "count", "score"): + tot[f] += row_counts[f] + rows.append({"id": pid, "name": name, "band": band, + "measured_sources": sum(1 for k in keys if cells[k].get("state") == "ok"), + **row_counts, "cells": cells}) + + for k in keys: + src_tot[k]["score"] = _score(src_tot[k]) + rows.sort(key=lambda r: (-r["score"], -r["high"], r["name"])) + hotspots.sort(key=lambda h: (-h["score"], -h["high"], h["project"], h["source"])) + cell_total = measured + errored + unknown + labels = {k: lbl for k, _s, lbl in LANES} + return { + "projects": rows, + "sources": [{"key": k, "label": labels[k], **src_tot[k]} for k in keys], + "totals": tot, + "band_tally": tally, + "hotspots": hotspots[:8], + "coverage": {"cells": cell_total, "measured": measured, "errored": errored, + "unknown": unknown, + "pct": round(100.0 * measured / cell_total, 1) if cell_total else None}, + "weights": dict(_WEIGHT), + "project_count": len(scanned), + "projects_available": len(projects), + "truncated": len(projects) > len(scanned), + "limit": limit, + "note": "Projects × risk source, intensity = 3·high + 2·medium + 1·low, every cell from the " + "same board the project's own risk panel shows. A cell reads `error`/`unknown` when " + "its engine could not run — never 0, which would render an unmeasured source as a " + "clear one. `coverage` says how much of the map is measured. When `truncated`, the " + "scanned set is a deterministic prefix of the caller's projects, not the riskiest " + "ones — ranking is what the sweep produces, so it cannot select what to sweep.", + } diff --git a/services/api/src/aec_api/routers/dashboard.py b/services/api/src/aec_api/routers/dashboard.py index b828ba8f..0f43222e 100644 --- a/services/api/src/aec_api/routers/dashboard.py +++ b/services/api/src/aec_api/routers/dashboard.py @@ -144,6 +144,29 @@ def executive_portfolio(db: Session = Depends(get_db), _: str = Depends(rbac.cur return {"projects": rows, "totals": tot, "status_tally": tally, "project_count": len(rows)} +@router.get("/portfolio/risk") +def portfolio_risk(limit: int = 25, db: Session = Depends(get_db), + _: str = Depends(rbac.current_user)): + """R22-PIPELINE — the **portfolio risk heat map**: every accessible project down, the five risk + engines across, severity-weighted intensity in the cell. + + `/portfolio/executive` and `/portfolio/construction` roll up performance; neither answers *which + engine is hot on which project*. Cells come from `risk_board.board` unchanged, so a cell and the + project's own risk panel cannot disagree — which is why the sweep is bounded by `limit` (each + project is a full board, Monte-Carlo included) and reports `truncated` rather than quietly + scanning a prefix. That prefix is by project name, not by risk: ranking is what the sweep + produces, so it cannot choose what to sweep. A cell whose engine could not run reads `error`, + never 0. + """ + from .. import risk_portfolio + _allowed = rbac.member_project_ids(db, _) # membership scope (None = no restriction) + _q = db.query(Project) + if _allowed is not None: + _q = _q.filter(Project.id.in_(_allowed)) + projects = [(p.id, p.name) for p in _q.order_by(Project.name).all()] + return risk_portfolio.heatmap(db, projects, limit=max(1, min(int(limit), 100))) + + @router.get("/portfolio/prioritization") def portfolio_prioritization(db: Session = Depends(get_db), user: str = Depends(rbac.current_user)): """Ranked portfolio prioritization — scores each accessible project 0–100 on return / on-budget / diff --git a/services/api/test_risk_portfolio.py b/services/api/test_risk_portfolio.py new file mode 100644 index 00000000..e6561ea7 --- /dev/null +++ b/services/api/test_risk_portfolio.py @@ -0,0 +1,178 @@ +"""RISK-PORTFOLIO — the portfolio risk heat map (`GET /portfolio/risk`), plus the gate that keeps +`risk_board.LANES` honest against a REAL board run. + +The gate is the point of this file as much as the heat map is. `board` reports coverage under lane +keys (`schedule_risk`) while its items carry source strings (`schedule-risk`), and only `LANES` +connects the two. A roll-up joins on both, so a lane added to `board` without a `LANES` entry would +render as a column that silently never lights up. Asserting the table against what `board` actually +emits — not against a hand-written list — is what makes that fail instead. + +Run: PYTHONPATH=src ./.venv/bin/python test_risk_portfolio.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_risk_portfolio.db" +os.environ["STORAGE_DIR"] = "./test_storage_riskportfolio" +os.environ["AEC_TRUST_XUSER"] = "1" +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_risk_portfolio.db",): + if os.path.exists(_f): + os.remove(_f) + +from datetime import date, timedelta # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api import risk_portfolio # noqa: E402 +from aec_api.main import app # noqa: E402 +from aec_api.risk_board import LANES # noqa: E402 + +HDR = {"X-User": "pm"} +LANE_KEYS = {k for k, _s, _l in LANES} +LANE_SOURCES = {s for _k, s, _l in LANES} + +with TestClient(app) as c: + quiet = c.post("/projects", json={"name": "AAA Quiet"}, headers=HDR).json()["id"] + hot = c.post("/projects", json={"name": "BBB Hot"}, headers=HDR).json()["id"] + + # --- seed real signals on the hot project only --------------------------------------------- + late = (date.today() - timedelta(days=10)).isoformat() + assert c.post(f"/projects/{hot}/modules/schedule_activity", json={"data": { + "name": "Foundations", "wbs": "1.1", "duration": 10, + "start": late, "finish": late, "percent": 20}}, headers=HDR).status_code == 201 + assert c.post(f"/projects/{hot}/modules/schedule_activity", json={"data": { + "name": "Frame", "wbs": "1.2", "duration": 20, "predecessors": "1.1"}}, + headers=HDR).status_code == 201 + c.post(f"/projects/{hot}/topics", json={"type": "clash", "title": "Beam vs duct", + "priority": "high", "due_date": late}, headers=HDR) + + # --- THE GATE: LANES agrees with what `board` actually emits -------------------------------- + from aec_api import risk_board # noqa: E402 + from aec_api.db import SessionLocal # noqa: E402 + _db = SessionLocal() + try: + live = risk_board.board(_db, hot) + finally: + _db.close() + assert set(live["lanes"]) == LANE_KEYS, (set(live["lanes"]) ^ LANE_KEYS) + emitted = {i["source"] for i in live["items"]} + assert emitted <= LANE_SOURCES, emitted - LANE_SOURCES + assert emitted, "the seeded project raised no items, so this run proves nothing about sources" + # every key/source/label distinct — a duplicate would collapse two engines into one column + assert len({k for k, _s, _l in LANES}) == len(LANES) == len(LANE_SOURCES) == len( + {lbl for _k, _s, lbl in LANES}), LANES + + # --- the heat map -------------------------------------------------------------------------- + r = c.get("/portfolio/risk", headers=HDR) + assert r.status_code == 200, r.text[:300] + h = r.json() + assert h["project_count"] == 2 and h["projects_available"] == 2 and not h["truncated"], h + names = [p["name"] for p in h["projects"]] + assert set(names) == {"AAA Quiet", "BBB Hot"}, names + # sorted by intensity, not by name — the hot project leads despite sorting last alphabetically + assert names[0] == "BBB Hot", names + + hotrow = h["projects"][0] + quietrow = h["projects"][1] + assert set(hotrow["cells"]) == LANE_KEYS, hotrow["cells"].keys() + assert hotrow["score"] > 0 and hotrow["count"] >= 2, hotrow + assert hotrow["score"] == 3 * hotrow["high"] + 2 * hotrow["medium"] + 1 * hotrow["low"], hotrow + assert hotrow["band"] in ("elevated", "critical"), hotrow["band"] + assert quietrow["score"] == 0 and quietrow["band"] in ("clear", "watch"), quietrow + + # the seeded signals land in the columns that own them, not smeared across the row + assert hotrow["cells"]["coordination"]["count"] >= 1, hotrow["cells"]["coordination"] + assert hotrow["cells"]["schedule_alerts"]["count"] >= 1, hotrow["cells"]["schedule_alerts"] + + # --- A MEASURED ZERO IS NOT AN UNMEASURED CELL --------------------------------------------- + # The quiet project's cells all ran and all found nothing: state ok, counts 0. That is the + # claim a heat map makes, and it must be distinguishable from a cell that never ran. + for k, cell in quietrow["cells"].items(): + assert cell["state"] == "ok", (k, cell) + assert cell["count"] == 0 and cell["score"] == 0, (k, cell) + assert h["coverage"]["errored"] == 0 and h["coverage"]["unknown"] == 0, h["coverage"] + assert h["coverage"]["cells"] == 2 * len(LANES) == h["coverage"]["measured"], h["coverage"] + assert h["coverage"]["pct"] == 100.0, h["coverage"] + + # per-source roll-up totals reconcile with the rows + for s in h["sources"]: + k = s["key"] + assert s["count"] == sum(p["cells"][k].get("count", 0) for p in h["projects"]), s + assert s["projects_measured"] == 2 and s["projects_error"] == 0, s + assert h["totals"]["count"] == sum(p["count"] for p in h["projects"]), h["totals"] + assert h["totals"]["score"] == sum(p["score"] for p in h["projects"]), h["totals"] + assert h["band_tally"][hotrow["band"]] >= 1, h["band_tally"] + + # hotspots point at the hot project, ranked, each carrying the item that made it hot + assert h["hotspots"], h + assert h["hotspots"][0]["project"] == "BBB Hot", h["hotspots"][0] + assert all(x["title"] and x["link"] for x in h["hotspots"]), h["hotspots"] + assert [x["score"] for x in h["hotspots"]] == sorted( + (x["score"] for x in h["hotspots"]), reverse=True), h["hotspots"] + assert all(x["source"] in LANE_KEYS for x in h["hotspots"]), h["hotspots"] + + # --- truncation is reported, never silent --------------------------------------------------- + t = c.get("/portfolio/risk?limit=1", headers=HDR).json() + assert t["project_count"] == 1 and t["projects_available"] == 2 and t["truncated"], t + assert t["coverage"]["cells"] == len(LANES), t["coverage"] + # limit is clamped, not trusted: 0 and a huge value both land in range + assert c.get("/portfolio/risk?limit=0", headers=HDR).json()["limit"] == 1 + assert c.get("/portfolio/risk?limit=9999", headers=HDR).json()["limit"] == 100 + +# --- a broken lane renders as `error`, not as a clear cell -------------------------------------- +# The module's central claim, exercised directly: `board` is fail-open, so a lane whose engine +# raises reports "error" and contributes no items. The heat map must carry that through instead of +# rendering the resulting absence of items as zeros. +def _with_board(fn, projects): + """Run the heat map against a stand-in `board`. Patching the FUNCTION, not `sys.modules`: + `heatmap` does `from . import risk_board`, which resolves to the attribute already set on the + package, so swapping the module entry does nothing — a first draft of this test did exactly + that and passed while measuring the real engine.""" + import aec_api.risk_board as rb + saved = rb.board + rb.board = fn + try: + return risk_portfolio.heatmap(None, projects) + finally: + rb.board = saved + + +def _fixed(lanes, items): + return lambda _db, _pid: {"lanes": lanes, "items": items, "band": "watch", + "count": len(items), "by_severity": {}} + + +hm = _with_board(_fixed({"schedule_risk": "error", "schedule_alerts": "ok", "evm": "ok", + "preflight": "ok", "coordination": "ok"}, + [{"source": "coordination", "severity": "high", "title": "t", + "link": "/l"}]), [("p1", "One")]) + +cell = hm["projects"][0]["cells"]["schedule_risk"] +assert cell == {"state": "error"}, cell # no counts at all — not {"high": 0, ...} +assert "score" not in cell and "count" not in cell, cell +assert hm["coverage"]["errored"] == 1 and hm["coverage"]["measured"] == 4, hm["coverage"] +assert hm["coverage"]["pct"] == 80.0, hm["coverage"] +assert hm["sources"][0]["key"] == "schedule_risk" +assert hm["sources"][0]["projects_error"] == 1 and hm["sources"][0]["projects_measured"] == 0 +assert hm["projects"][0]["measured_sources"] == 4, hm["projects"][0] +assert hm["projects"][0]["score"] == 3, hm["projects"][0] # the one high coordination item + + +def _raises(_db, _pid): + raise RuntimeError("engine down") + + +# a board that fails outright keeps the project on the map, wholly unmeasured +hm2 = _with_board(_raises, [("p1", "One")]) +assert len(hm2["projects"]) == 1 and hm2["projects"][0]["band"] is None, hm2["projects"] +assert hm2["projects"][0]["measured_sources"] == 0, hm2["projects"][0] +assert all(cl == {"state": "unknown"} for cl in hm2["projects"][0]["cells"].values()), hm2 +assert hm2["coverage"]["unknown"] == len(LANES) and hm2["coverage"]["pct"] == 0.0, hm2["coverage"] + +# an unknown source is dropped, never invented as a column +hm3 = _with_board(_fixed(dict.fromkeys(LANE_KEYS, "ok"), + [{"source": "made-up", "severity": "high", "title": "x"}]), + [("p1", "One")]) +assert set(hm3["projects"][0]["cells"]) == LANE_KEYS, hm3["projects"][0]["cells"].keys() +assert hm3["totals"]["count"] == 0 and not hm3["hotspots"], hm3 + +print("risk portfolio heat map OK") From 9ba106e2dcd011f2f0e2acd64471c2d6f7805f6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:03:45 +0000 Subject: [PATCH 16/23] =?UTF-8?q?Review=20round=20on=20#439=20=E2=80=94=20?= =?UTF-8?q?deterministic=20tie-break,=20keyboard-operable=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review, both verified real before fixing. 1. STABLE TIE-BREAKER — a bug against this route's own stated contract. `Project.name` is not unique, and `risk_portfolio`'s docstring promises a DETERMINISTIC prefix when the sweep truncates. Ordering by name alone leaves tied rows in whatever order the engine returns, so a tie straddling the `limit` boundary scans a different project run to run. Now `order_by(Project.name, Project.id)` — the primary key settles every tie. Pinned by a test rather than taken on trust: 8 same-named projects at `limit=6`, asserting the scan takes the four LOWEST-ID rows and not the four first INSERTED. Mutation-checked — reverting the fix fails it with both id lists printed. Ids are uuid4 and nothing here can pin one, so a regression escapes with probability 1/C(8,4) = 1.4%; that number is stated in the test rather than left implied. 2. KEYBOARD-OPERABLE HEAT-MAP ROWS. `tr.onclick` alone gives keyboard users no way to open a project. Checking before fixing changed the fix twice: `.kpi-click:focus-visible` already carries a focus outline in `style.css`, so the stylesheet was written expecting these rows to be focusable and a pointer-only handler quietly never delivered it; and `documents.ts` already has the house idiom — `role="button"`, `tabIndex`, Enter AND Space with `preventDefault`. Matched that rather than inventing a pattern, plus an `aria-label` carrying the row's risk count. Only the row this change added is fixed. The four sibling tables in the same panel have the identical gap, but they are pre-existing code this change does not touch; widening into them is the author's call. Docstrings added to `_score` and `_empty_counts` — the two helpers carrying ideas worth stating (the intensity weighting, and where a MEASURED zero is constructed) — and to the two test stand-ins. Matches how `resource_loading.py` treats its helpers: bare when trivial, documented when the contract is not obvious. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- apps/web/src/portal/panels/portfolio.ts | 11 +++++++++- services/api/src/aec_api/risk_portfolio.py | 4 ++++ services/api/src/aec_api/routers/dashboard.py | 6 +++++- services/api/test_risk_portfolio.py | 20 +++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/web/src/portal/panels/portfolio.ts b/apps/web/src/portal/panels/portfolio.ts index b53af742..c189afe2 100644 --- a/apps/web/src/portal/panels/portfolio.ts +++ b/apps/web/src/portal/panels/portfolio.ts @@ -184,7 +184,16 @@ export async function renderPortfolio(ctx: PanelContext) { }).join(""); tr.innerHTML = `${esc(p.name)}${p.id === here ? " ·" : ""}${cells}` + `${p.count || "—"}`; - tr.onclick = () => { if (p.id !== here) window.location.search = `?project=${p.id}`; }; + // Keyboard-operable, matching the `documents.ts` folder-row idiom. `.kpi-click` already + // styles `:focus-visible` (style.css) — the stylesheet was written expecting these rows to + // be focusable, and a pointer-only handler quietly never delivered it. + const go = () => { if (p.id !== here) window.location.search = `?project=${p.id}`; }; + if (p.id !== here) { + tr.setAttribute("role", "button"); tr.tabIndex = 0; + tr.setAttribute("aria-label", `Open ${p.name}, ${p.count} open risk item(s)`); + tr.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } }; + } + tr.onclick = go; tb.appendChild(tr); } tbl.appendChild(tb); card.appendChild(tbl); diff --git a/services/api/src/aec_api/risk_portfolio.py b/services/api/src/aec_api/risk_portfolio.py index 47bdf8f4..2a89cd03 100644 --- a/services/api/src/aec_api/risk_portfolio.py +++ b/services/api/src/aec_api/risk_portfolio.py @@ -54,10 +54,14 @@ def _empty_counts() -> dict[str, Any]: + """A MEASURED zero — the shape a cell gets only once its lane reported `ok`. An unmeasured + cell never passes through here; see the module note on why that distinction is the point.""" return {"high": 0, "medium": 0, "low": 0, "count": 0, "score": 0} def _score(c: dict[str, Any]) -> int: + """Cell intensity: `3·high + 2·medium + 1·low`. Shallow on purpose — the severities were + already chosen by the individual engines, and a steeper curve would re-weight them invisibly.""" return sum(_WEIGHT[s] * c[s] for s in ("high", "medium", "low")) diff --git a/services/api/src/aec_api/routers/dashboard.py b/services/api/src/aec_api/routers/dashboard.py index 0f43222e..eb55831d 100644 --- a/services/api/src/aec_api/routers/dashboard.py +++ b/services/api/src/aec_api/routers/dashboard.py @@ -163,7 +163,11 @@ def portfolio_risk(limit: int = 25, db: Session = Depends(get_db), _q = db.query(Project) if _allowed is not None: _q = _q.filter(Project.id.in_(_allowed)) - projects = [(p.id, p.name) for p in _q.order_by(Project.name).all()] + # (name, id): `Project.name` is NOT unique, so name alone leaves tied rows in whatever + # order the engine returns — and this route promises a DETERMINISTIC prefix when it + # truncates. A tie straddling the `limit` boundary would otherwise scan a different + # project run to run. The id is the primary key, so it settles every tie. + projects = [(p.id, p.name) for p in _q.order_by(Project.name, Project.id).all()] return risk_portfolio.heatmap(db, projects, limit=max(1, min(int(limit), 100))) diff --git a/services/api/test_risk_portfolio.py b/services/api/test_risk_portfolio.py index e6561ea7..1adfeb91 100644 --- a/services/api/test_risk_portfolio.py +++ b/services/api/test_risk_portfolio.py @@ -118,6 +118,24 @@ assert c.get("/portfolio/risk?limit=0", headers=HDR).json()["limit"] == 1 assert c.get("/portfolio/risk?limit=9999", headers=HDR).json()["limit"] == 100 + # --- the truncated prefix is DETERMINISTIC, and name alone does not make it so --------------- + # `Project.name` is not unique. Ordering by name alone leaves tied rows in whatever order the + # engine returns, so a tie straddling the `limit` boundary scans a different project run to run + # — against this route's own stated contract. The fix orders by (name, id); the id is the + # primary key, so it settles every tie. + # + # These sort last alphabetically, so they form the tail of the scan order and the cut lands + # inside them. Ids are uuid4, so their sorted order is independent of insertion order: under the + # defect the scan would take the first 4 INSERTED, and this asserts it takes the 4 LOWEST-ID. + # Those coincide with probability 1/C(8,4) = 1/70, so a regression escapes ~1.4% of the time — + # stated rather than hidden, since nothing here can pin a uuid. + tied = [c.post("/projects", json={"name": "ZZZ Tied"}, headers=HDR).json()["id"] for _ in range(8)] + assert len(set(tied)) == 8, tied + d = c.get("/portfolio/risk?limit=6", headers=HDR).json() # AAA + BBB + 4 of the 8 tied + assert d["project_count"] == 6 and d["projects_available"] == 10, d + got = {p["id"] for p in d["projects"] if p["name"] == "ZZZ Tied"} + assert got == set(sorted(tied)[:4]), (sorted(got), sorted(tied)[:4]) + # --- a broken lane renders as `error`, not as a clear cell -------------------------------------- # The module's central claim, exercised directly: `board` is fail-open, so a lane whose engine # raises reports "error" and contributes no items. The heat map must carry that through instead of @@ -137,6 +155,7 @@ def _with_board(fn, projects): def _fixed(lanes, items): + """A `board` stand-in returning fixed lanes and items for every project.""" return lambda _db, _pid: {"lanes": lanes, "items": items, "band": "watch", "count": len(items), "by_severity": {}} @@ -158,6 +177,7 @@ def _fixed(lanes, items): def _raises(_db, _pid): + """A `board` that fails outright, so the whole project comes back unmeasured.""" raise RuntimeError("engine down") From 27d6f404ca745927b9ed70a3b84a419db5610975 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:47:02 +0000 Subject: [PATCH 17/23] =?UTF-8?q?Cross-project=20Gantt=20=E2=80=94=20R22-P?= =?UTF-8?q?IPELINE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Programme card now draws a bar per project on a shared span: start, finish, duration, which project drives the programme finish, and which are named by an external link. IT NEEDED NO NEW ENGINE, AND THAT IS THE FINDING. The roadmap recorded a cross-project Gantt as missing because `schedule_viz.py` is per-project. True, and not the whole picture: R46's `schedule_portfolio.py` already computes `project_starts` and `project_finishes` in its one merged pass, and the route already returned them. `apps/web/src/api/schedule.ts` declared only `programme_finish`, `project_count` and `external_link_count` — so the dates reached the browser and were dropped AT THE TYPE BOUNDARY before anything could draw them. Same class as R37-TESTED-UNWIRED, one layer further out: not a route without a caller, but a payload without a reader. Cost of the premise-check: one grep. Cost of believing the entry: a scheduling engine. `programmeGantt.ts` holds the geometry as a pure function (7 unit cases); the panel only paints what it returns. BARS COME FROM THE MERGED PASS, never each project's standalone CPM — a project can look comfortable alone and be critical to the programme, and its own run would show the comfortable answer. Asserted rather than documented: `test_programme_gantt.py` pins that the FS link pushes fit-out past enabling's finish, and removing the link fails it with that sentence. A PROJECT WITH ONLY ONE DATED END GETS NO BAR, and is listed with the reason. Substituting the programme's own start or finish for the missing end draws a bar that looks measured and is not — the risk heat map's rule arriving independently in a second place. Writing the test also found that an external link names activities by RECORD id: `wbs` and `ref` are aliases resolved only for a project's own predecessor tokens, so a link written in WBS terms is refused as "no such activity". Recorded next to the link that uses it. 2069 web tests (205 files), typecheck + lint + build clean, structural gates green. Backend suite running. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 28 +++++ apps/web/src/api/schedule.ts | 8 ++ .../src/portal/panels/programmeGantt.test.ts | 92 ++++++++++++++ apps/web/src/portal/panels/programmeGantt.ts | 114 ++++++++++++++++++ apps/web/src/portal/panels/scheduleMethods.ts | 53 ++++++++ docs/roadmap.md | 24 +++- services/api/run_tests.py | 2 +- services/api/test_programme_gantt.py | 96 +++++++++++++++ 8 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/portal/panels/programmeGantt.test.ts create mode 100644 apps/web/src/portal/panels/programmeGantt.ts create mode 100644 services/api/test_programme_gantt.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b1244aee..baffdbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — Cross-project Gantt + +The Programme card (`/projects/{pid}/schedule/portfolio`) now draws a bar per project on a shared +span: start, finish, duration, which project drives the programme finish, and which are named by an +external link. `apps/web/src/portal/panels/programmeGantt.ts` holds the geometry as a pure function; +the panel only paints what it returns. + +**It needed no new engine, and that is the finding.** The roadmap recorded a cross-project Gantt as +missing because `schedule_viz.py` is per-project. But R46's `schedule_portfolio.py` already computes +`project_starts` and `project_finishes` in its single merged pass, and the route already returned +them — `apps/web/src/api/schedule.ts` declared only three scalars, so the dates reached the browser +and were **dropped at the type boundary**. Same class as R37-TESTED-UNWIRED one layer further out: +not a route without a caller, but a payload without a reader. + +Bars come from the **merged** pass, never each project's standalone CPM. A project can look +comfortable alone and be critical to the programme; its own run would show the comfortable answer. +`services/api/test_programme_gantt.py` asserts the FS link actually pushes fit-out past enabling's +finish — removing the link fails it with exactly that explanation, so the claim is load-bearing +rather than decorative. + +**A project with only one dated end gets no bar**, and is listed with the reason. Substituting the +programme's own start or finish for the missing end would draw a bar that looks measured and is not +— the same rule the risk heat map applies to an unmeasured cell. + +Writing the test also found that an external link must name activities by **record id**: `wbs` and +`ref` are aliases resolved only for a project's own predecessor tokens, so a link written in WBS +terms is refused as "no such activity". Recorded next to the link that uses it. + ## Unreleased — Portfolio risk heat map `GET /portfolio/risk` (`risk_portfolio.py`) grids `risk_board` across the book: projects down, the diff --git a/apps/web/src/api/schedule.ts b/apps/web/src/api/schedule.ts index bec165f3..865ce637 100644 --- a/apps/web/src/api/schedule.ts +++ b/apps/web/src/api/schedule.ts @@ -482,6 +482,14 @@ export function withSchedule>(Base: TBase) { rejected_links: string[]; projects_without_activities: string[]; programme_finish: string | null; project_count: number | null; external_link_count: number | null; + // The merged pass returns per-project dates and the activities that cross a boundary. This + // type declared only the three scalars above until v0.3.1144, so the dates reached the browser + // and were dropped before anything could draw them — which is why the roadmap recorded the + // cross-project Gantt as missing an engine it already had. Keyed by project id. + project_starts?: Record; + project_finishes?: Record; + crossing_activities?: string[]; + issues?: { code?: string; message?: string }[]; }>(`/projects/${pid}/schedule/portfolio`, { method: "POST", body: JSON.stringify(body) }); } diff --git a/apps/web/src/portal/panels/programmeGantt.test.ts b/apps/web/src/portal/panels/programmeGantt.test.ts new file mode 100644 index 00000000..67f73b94 --- /dev/null +++ b/apps/web/src/portal/panels/programmeGantt.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { programmeBars } from "./programmeGantt"; + +const P = (id: string, name = id, activities = 3) => ({ id, name, activities }); + +describe("programmeBars", () => { + it("places each project on a shared span, ordered by start", () => { + const r = programmeBars({ + projects: [P("b", "Fit-out"), P("a", "Enabling")], + project_starts: { a: "2026-01-01", b: "2026-02-01" }, + project_finishes: { a: "2026-01-31", b: "2026-03-01" }, + }); + expect(r.bars.map((x) => x.name)).toEqual(["Enabling", "Fit-out"]); + expect(r.span).toEqual({ start: "2026-01-01", finish: "2026-03-01", days: 60 }); + expect(r.bars[0]!.left).toBe(0); + // Enabling runs 30 of the 59-day span; Fit-out starts 31 days in. + expect(Math.round(r.bars[0]!.width)).toBe(51); + expect(Math.round(r.bars[1]!.left)).toBe(53); + expect(r.bars[0]!.days).toBe(31); + expect(r.unplotted).toEqual([]); + }); + + it("marks the bar that finishes on the programme finish as driving", () => { + const r = programmeBars({ + projects: [P("a"), P("b")], + project_starts: { a: "2026-01-01", b: "2026-01-01" }, + project_finishes: { a: "2026-01-10", b: "2026-02-10" }, + }); + expect(r.bars.find((x) => x.id === "b")!.driving).toBe(true); + expect(r.bars.find((x) => x.id === "a")!.driving).toBe(false); + }); + + // A HALF-DATED PROJECT GETS NO BAR. Substituting the programme's own start or finish for the + // missing end would draw a bar that looks measured and is not — the defect this module exists to + // avoid, so it is asserted rather than left to the renderer. + it("refuses a bar when either end is missing, and says which", () => { + const r = programmeBars({ + projects: [P("a"), P("b", "No finish"), P("c", "No start"), P("d", "Nothing")], + project_starts: { a: "2026-01-01", b: "2026-01-05", d: undefined as unknown as string }, + project_finishes: { a: "2026-01-31", c: "2026-02-01" }, + }); + expect(r.bars.map((x) => x.id)).toEqual(["a"]); + expect(r.unplotted).toEqual([ + { id: "b", name: "No finish", reason: "no finish date" }, + { id: "c", name: "No start", reason: "no start date" }, + { id: "d", name: "Nothing", reason: "no scheduled dates" }, + ]); + // and the span is computed from the plotted bar alone, never widened by a half-dated project + expect(r.span).toEqual({ start: "2026-01-01", finish: "2026-01-31", days: 31 }); + }); + + it("refuses a bar whose finish precedes its start", () => { + const r = programmeBars({ + projects: [P("a")], + project_starts: { a: "2026-03-01" }, project_finishes: { a: "2026-01-01" }, + }); + expect(r.bars).toEqual([]); + expect(r.unplotted[0]!.reason).toBe("finish precedes start"); + expect(r.span).toBeNull(); + }); + + it("survives a single-day programme without NaN widths", () => { + const r = programmeBars({ + projects: [P("a")], + project_starts: { a: "2026-01-01" }, project_finishes: { a: "2026-01-01" }, + }); + expect(r.bars[0]!.left).toBe(0); + expect(r.bars[0]!.width).toBe(100); + expect(r.bars[0]!.days).toBe(1); + expect(r.span!.days).toBe(1); + }); + + it("flags the projects an external link names — their dates are a commitment", () => { + const r = programmeBars({ + projects: [P("enabling"), P("fitout"), P("infra")], + project_starts: { enabling: "2026-01-01", fitout: "2026-02-01", infra: "2026-01-15" }, + project_finishes: { enabling: "2026-01-31", fitout: "2026-03-01", infra: "2026-02-15" }, + external_links: [{ predecessor: "enabling::A1", successor: "fitout::B1" }], + }); + expect(r.bars.find((x) => x.id === "enabling")!.linked).toBe(true); + expect(r.bars.find((x) => x.id === "fitout")!.linked).toBe(true); + expect(r.bars.find((x) => x.id === "infra")!.linked).toBe(false); + }); + + it("returns an empty, well-formed result when the run carried no dates at all", () => { + const r = programmeBars({ projects: [P("a"), P("b")] }); + expect(r.bars).toEqual([]); + expect(r.span).toBeNull(); + expect(r.unplotted).toHaveLength(2); + }); +}); diff --git a/apps/web/src/portal/panels/programmeGantt.ts b/apps/web/src/portal/panels/programmeGantt.ts new file mode 100644 index 00000000..30fd0b48 --- /dev/null +++ b/apps/web/src/portal/panels/programmeGantt.ts @@ -0,0 +1,114 @@ +/** + * PROGRAMME-GANTT — the cross-project bar chart, R22-PIPELINE's last visualisation item. + * + * ## The roadmap said this needed a new engine. It did not. + * + * That entry lists "a cross-project Gantt (`schedule_viz.py` is per-project)" as genuinely missing. + * Half true: `schedule_viz` *is* per-project, but R46's portfolio scheduler already computes + * `project_starts` and `project_finishes` in its merged pass, and the route already puts them on the + * wire. What was missing is that **the client type declared three scalars and dropped the rest** — + * `programme_finish`, `project_count`, `external_link_count` — so the dates arrived in the browser + * and were discarded before anything could draw them. The bars here are geometry over data the + * server was already sending. + * + * That matters for where the dates come from. These are the finishes from the ONE merged pass, not + * each project's standalone schedule: a project that looks comfortable alone can be critical to the + * programme, and a bar drawn from its own CPM run would show the comfortable answer. + * + * ## A bar needs both ends + * + * A project with a start and no finish (or the reverse) gets **no bar at all**, and is returned in + * `unplotted` with the reason. The alternative is to substitute the programme's own start or finish + * for the missing end, which draws a bar that looks measured and is not — the same defect the risk + * heat map refuses when it declines to render an unmeasured cell as a green zero. + */ + +const DAY_MS = 86_400_000; + +export type ProgrammeInput = { + projects: { id: string; name: string; activities: number }[]; + project_starts?: Record; + project_finishes?: Record; + crossing_activities?: string[]; + external_links?: { predecessor: string; successor: string }[]; +}; + +export type ProgrammeBar = { + id: string; name: string; activities: number; + start: string; finish: string; + /** Left edge and width as percentages of the programme span, ready for a CSS bar. */ + left: number; width: number; + days: number; + /** This project is named by at least one external link — its dates are a commitment. */ + linked: boolean; + /** Finishes on the programme's own finish date: it is what the whole span waits for. */ + driving: boolean; +}; + +export type ProgrammeBars = { + bars: ProgrammeBar[]; + unplotted: { id: string; name: string; reason: string }[]; + span: { start: string; finish: string; days: number } | null; +}; + +function day(s: string | undefined): Date | null { + if (!s) return null; + const d = new Date(`${String(s).slice(0, 10)}T00:00:00Z`); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Bar geometry for one programme run. Pure — no DOM, no fetch — so the rules above ("a bar needs + * both ends", "driving is measured against the programme finish") are unit-testable rather than + * only visible on screen. + */ +export function programmeBars(r: ProgrammeInput): ProgrammeBars { + const starts = r.project_starts ?? {}; + const finishes = r.project_finishes ?? {}; + const linked = new Set(); + for (const ln of r.external_links ?? []) { + // Link endpoints are ""; the project id is the part before the + // separator, and an id containing no separator is already the project. + for (const end of [ln.predecessor, ln.successor]) { + const p = (r.projects ?? []).find((x) => String(end).startsWith(x.id)); + if (p) linked.add(p.id); + } + } + + const rows: { p: ProgrammeInput["projects"][number]; s: Date; f: Date }[] = []; + const unplotted: ProgrammeBars["unplotted"] = []; + for (const p of r.projects ?? []) { + const s = day(starts[p.id]), f = day(finishes[p.id]); + if (!s && !f) { unplotted.push({ id: p.id, name: p.name, reason: "no scheduled dates" }); continue; } + if (!s || !f) { + // Deliberately NOT clamped to the programme span — see the header. + unplotted.push({ id: p.id, name: p.name, reason: s ? "no finish date" : "no start date" }); + continue; + } + if (f < s) { unplotted.push({ id: p.id, name: p.name, reason: "finish precedes start" }); continue; } + rows.push({ p, s, f }); + } + if (!rows.length) return { bars: [], unplotted, span: null }; + + const t0 = Math.min(...rows.map((x) => x.s.getTime())); + const t1 = Math.max(...rows.map((x) => x.f.getTime())); + // A single-day programme has zero span; dividing by it would give NaN widths, so every bar + // occupies the full track instead — which is what a one-day programme actually looks like. + const total = t1 - t0 || 1; + const bars = rows.map(({ p, s, f }) => ({ + id: p.id, name: p.name, activities: p.activities, + start: s.toISOString().slice(0, 10), finish: f.toISOString().slice(0, 10), + left: t1 === t0 ? 0 : ((s.getTime() - t0) / total) * 100, + width: t1 === t0 ? 100 : Math.max(((f.getTime() - s.getTime()) / total) * 100, 0.8), + days: Math.round((f.getTime() - s.getTime()) / DAY_MS) + 1, + linked: linked.has(p.id), + driving: f.getTime() === t1, + })); + bars.sort((a, b) => a.left - b.left || b.width - a.width || a.name.localeCompare(b.name)); + return { + bars, unplotted, + span: { start: new Date(t0).toISOString().slice(0, 10), + finish: new Date(t1).toISOString().slice(0, 10), + days: Math.round((t1 - t0) / DAY_MS) + 1 }, + }; +} diff --git a/apps/web/src/portal/panels/scheduleMethods.ts b/apps/web/src/portal/panels/scheduleMethods.ts index e0d62f47..6ec67b56 100644 --- a/apps/web/src/portal/panels/scheduleMethods.ts +++ b/apps/web/src/portal/panels/scheduleMethods.ts @@ -22,6 +22,7 @@ import { usd } from "../../ui/charts"; import { escapeHtml as esc } from "../../ui/feedback"; import type { PanelContext } from "../panelContext"; +import { programmeBars } from "./programmeGantt"; type Row = { label: string; value: string; hint?: string }; @@ -344,6 +345,58 @@ export function renderScheduleMethods(ctx: PanelContext): HTMLElement { t.textContent = r.projects.map((p) => `${p.name} (${p.activities})`).join(" \u00b7 ") + (r.rejected_links.length ? ` \u2014 ignored: ${r.rejected_links.join("; ")}` : ""); pfOut.appendChild(t); + + // CROSS-PROJECT GANTT — bars from the MERGED pass, not each project's standalone CPM. A project + // can look comfortable alone and be critical to the programme; a bar drawn from its own run + // would show the comfortable answer. `programmeBars` holds the geometry and the rule that a + // half-dated project gets no bar; this only paints what it returns. + const g = programmeBars(r); + if (g.span) { + const gw = document.createElement("div"); + gw.style.cssText = "margin-top:8px"; + const head = document.createElement("div"); + head.className = "meta"; + head.textContent = `Programme ${g.span.start} \u2192 ${g.span.finish} \u00b7 ${g.span.days} days`; + gw.appendChild(head); + for (const b of g.bars) { + const row = document.createElement("div"); + row.style.cssText = "display:flex;align-items:center;gap:8px;margin:3px 0"; + const label = document.createElement("div"); + label.style.cssText = "flex:0 0 150px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"; + label.textContent = b.name; + label.title = `${b.name} \u00b7 ${b.start} \u2192 ${b.finish} \u00b7 ${b.days} days` + + `${b.linked ? " \u00b7 named by an external link" : ""}`; + const track = document.createElement("div"); + track.style.cssText = "flex:1;position:relative;height:16px;background:var(--panel2);border-radius:3px"; + const bar = document.createElement("div"); + // Driving = finishes on the programme finish, so the whole span waits on it. Linked = named + // by a cross-project commitment. Both are facts from the run, not a status guess. + const col = b.driving ? "var(--status-crit)" : b.linked ? "var(--accent)" : "var(--status-good)"; + bar.style.cssText = `position:absolute;left:${b.left}%;width:${b.width}%;top:2px;bottom:2px;` + + `background:${col};border-radius:2px`; + track.appendChild(bar); + const days = document.createElement("div"); + days.className = "meta"; + days.style.cssText = "flex:0 0 62px;text-align:right;font-variant-numeric:tabular-nums"; + days.textContent = `${b.days}d${b.driving ? " \u25c0" : ""}`; + row.append(label, track, days); + gw.appendChild(row); + } + const key = document.createElement("div"); + key.className = "meta"; key.style.marginTop = "4px"; + key.textContent = "\u25c0 drives the programme finish \u00b7 blue = named by an external link " + + "(a commitment between parties) \u00b7 bars come from the merged pass, not each project's " + + "own schedule."; + gw.appendChild(key); + pfOut.appendChild(gw); + } + if (g.unplotted.length) { + // Named, never silently dropped and never drawn with an invented end date. + const u = document.createElement("div"); + u.className = "meta"; u.style.cssText = "margin-top:4px;color:var(--status-warn)"; + u.textContent = "Not plotted \u2014 " + g.unplotted.map((x) => `${x.name} (${x.reason})`).join("; "); + pfOut.appendChild(u); + } })); pfc.appendChild(pfRow); pfc.appendChild(pfOut); wrap.appendChild(pfc); diff --git a/docs/roadmap.md b/docs/roadmap.md index 7c2243d6..f9351239 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1883,7 +1883,29 @@ stakes we are missing. a value. A lane added to `board` and not to `LANES` would otherwise render as a column that silently never lights up. - **Still open: cross-project Gantt and department resourcing.** The resourcing half is not the small + ✅ **Cross-project Gantt SHIPPED — and it needed no engine, which is the finding.** + This entry lists it as genuinely missing because `schedule_viz.py` is per-project. That is true and + it is not the whole picture: **R46's `schedule_portfolio.py` already computes `project_starts` and + `project_finishes` in its one merged pass**, and the route already returned them. What was missing + is that `apps/web/src/api/schedule.ts` declared only `programme_finish`, `project_count` and + `external_link_count` — so the dates reached the browser and were **dropped at the type boundary** + before anything could draw them. Same class as R37-TESTED-UNWIRED, one layer further out: not a + route without a caller, but a *payload* without a reader. + + `apps/web/src/portal/panels/programmeGantt.ts` holds the geometry as a pure function + (`apps/web/src/portal/panels/programmeGantt.test.ts`, 7 cases), and the Programme card renders bars + from it. Bars come from the **merged** pass, never each project's standalone CPM — a project can + look comfortable alone and be critical to the programme, and its own run would show the comfortable + answer. That is asserted, not just documented: `services/api/test_programme_gantt.py` pins that the + FS link pushes fit-out past enabling's finish, and dropping the link fails it with that sentence. + + **A project with only one end gets no bar**, and is listed with the reason. Substituting the + programme's own start or finish for the missing end draws a bar that looks measured and is not — + the risk heat map's rule, arriving independently in a second place the same day. + + *Cost of the premise-check: one grep. Cost of believing the entry: a scheduling engine.* + + **Still open: department resourcing.** The resourcing half is not the small item this entry's phrasing suggests — `resource_loading.py` groups by **trade and resource type**, per project, so "by department" needs both a new dimension and a portfolio axis. Size it on its own. ## ⚡ R23 — ENGINEERING UPGRADE RING *(technical scan 2026-07-25; file:line evidence)* diff --git a/services/api/run_tests.py b/services/api/run_tests.py index f49dd21b..60247e1d 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -57,7 +57,7 @@ "test_ask", "test_viewer_load_timing", "test_verification", "test_webhooks", "test_operate_capital", "test_payroll_drawings", "test_assistant_itb", "test_construction_depth", "test_distribution", "test_e57", "test_empty_project", "test_metrics", "test_metrics_auth", "test_licensing", "test_revit_bridge", "test_precon", "test_specs", "test_feasibility", "test_clash_import", "test_clash_intel", "test_clash_reduction_scale", "test_layout", "test_loads", "test_verified_progress", "test_element_records", "test_securities_bridge", "test_imports", "test_search_alerts", "test_attachments", # previously not wired into the gate (glob would have caught these) — now covered: "test_analytics", "test_discipline", "test_gbxml", "test_review", "test_interop", - "test_module_config", "test_module_aggregate", "test_view_config", "test_view_sharing", "test_view_alerts_per_viewer", "test_markup_rekey", "test_view_crossmodule", "test_report_catalog", "test_env_documented", "test_module_schema", "test_field_attrs", "test_eticket_tm", "test_ref_backfill", "test_module_tables", "test_revision_comments", "test_module_filters", "test_guid_integrity", "test_pay_application", "test_module_fields", "test_throttle", "test_route_order", "test_mutating_get", "test_bootstrap_admin", "test_licence_allowlist", "test_money_parity", "test_money_spine", "test_plan_transform", "test_massingcapture_vendor", "test_xml_parse_hardening", "test_supply_chain_gate", "test_mspdi_xxe", "test_scenario_authz", "test_changelog_current", "test_release_current", "test_tested_but_unwired", "test_actions_pinned", "test_container_pr_gate", "test_spatial_tree", "test_output_encoding", "test_massingplan_vendor", "test_vendor_reachable", "test_schedule_health", "test_schedule_locations", "test_schedule_takt", "test_schedule_levelling", "test_schedule_progress", "test_schedule_risk_mc", "test_ppc_divergence", "test_ppc_field_conformance", "test_schedule_compare", "test_schedule_windows", "test_schedule_modelled", "test_schedule_p6xml", "test_schedule_earned", "test_schedule_compression", "test_schedule_portfolio", "test_portfolio_authz", "test_no_exception_relay", "test_vendor_drift", + "test_module_config", "test_module_aggregate", "test_view_config", "test_view_sharing", "test_view_alerts_per_viewer", "test_markup_rekey", "test_view_crossmodule", "test_report_catalog", "test_env_documented", "test_module_schema", "test_field_attrs", "test_eticket_tm", "test_ref_backfill", "test_module_tables", "test_revision_comments", "test_module_filters", "test_guid_integrity", "test_pay_application", "test_module_fields", "test_throttle", "test_route_order", "test_mutating_get", "test_bootstrap_admin", "test_licence_allowlist", "test_money_parity", "test_money_spine", "test_plan_transform", "test_massingcapture_vendor", "test_xml_parse_hardening", "test_supply_chain_gate", "test_mspdi_xxe", "test_scenario_authz", "test_changelog_current", "test_release_current", "test_tested_but_unwired", "test_actions_pinned", "test_container_pr_gate", "test_spatial_tree", "test_output_encoding", "test_massingplan_vendor", "test_vendor_reachable", "test_schedule_health", "test_schedule_locations", "test_schedule_takt", "test_schedule_levelling", "test_schedule_progress", "test_schedule_risk_mc", "test_ppc_divergence", "test_ppc_field_conformance", "test_schedule_compare", "test_schedule_windows", "test_schedule_modelled", "test_schedule_p6xml", "test_schedule_earned", "test_schedule_compression", "test_schedule_portfolio", "test_portfolio_authz", "test_programme_gantt", "test_no_exception_relay", "test_vendor_drift", # R23-PREFAB-KIT — the kit join + its register routes: "test_prefab_kit", "test_prefab_route", # Tier-1 competitive upgrades: diff --git a/services/api/test_programme_gantt.py b/services/api/test_programme_gantt.py new file mode 100644 index 00000000..c7d95516 --- /dev/null +++ b/services/api/test_programme_gantt.py @@ -0,0 +1,96 @@ +"""PROGRAMME-GANTT — the cross-project Gantt's data contract, asserted at the route. + +R22-PIPELINE recorded a cross-project Gantt as needing an engine. It did not: R46's portfolio +scheduler already computes per-project dates in its ONE merged pass, and this route already returns +them. What was missing is that the web client's type declared only `programme_finish`, +`project_count` and `external_link_count`, so the dates arrived in the browser and were dropped. + +Now that `apps/web/src/portal/panels/programmeGantt.ts` draws bars from `project_starts` / +`project_finishes`, those fields are a CONTRACT rather than an incidental extra. `test_route_authz` +gates who may call this route and `test_portfolio_authz` gates the body's project ids; neither looks +at the payload's shape, so nothing here would have failed if the merged pass stopped reporting +per-project dates — the Gantt would simply render empty, which looks like "no programme" rather than +like a break. + +Run: PYTHONPATH=src ./.venv/bin/python test_programme_gantt.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_programme_gantt.db" +os.environ["STORAGE_DIR"] = "./test_storage_programme_gantt" +os.environ["AEC_TRUST_XUSER"] = "1" +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_programme_gantt.db",): + if os.path.exists(_f): + os.remove(_f) + +from datetime import date, timedelta # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api.main import app # noqa: E402 + +HDR = {"X-User": "pm"} +D0 = date(2026, 3, 2) + + +def _iso(n: int) -> str: + return (D0 + timedelta(days=n)).isoformat() + + +with TestClient(app) as c: + enabling = c.post("/projects", json={"name": "Enabling works"}, headers=HDR).json()["id"] + fitout = c.post("/projects", json={"name": "Fit-out"}, headers=HDR).json()["id"] + + act = {} + for key, pid, wbs, s0, dur in (("en", enabling, "1.1", _iso(0), 10), + ("fo", fitout, "2.1", _iso(2), 15)): + r = c.post(f"/projects/{pid}/modules/schedule_activity", json={"data": { + "name": f"Act {wbs}", "wbs": wbs, "duration": dur, + "start": s0, "finish": _iso(int(s0[-2:]) + dur)}}, headers=HDR) + assert r.status_code == 201, r.text[:200] + act[key] = r.json()["id"] + + # An external link names activities by the id the engine uses, which is the RECORD id — `wbs` + # and `ref` are aliases resolved only for a project's own predecessor tokens, so a link written + # in WBS terms is refused as "no such activity". Found by the refusal, not assumed. + body = {"project_ids": [fitout], "external": [{ + "predecessor_project": enabling, "predecessor_id": act["en"], + "successor_project": fitout, "successor_id": act["fo"], "type": "FS"}]} + r = c.post(f"/projects/{enabling}/schedule/portfolio", json=body, headers=HDR) + assert r.status_code == 200, r.text[:300] + p = r.json() + assert p["available"] is True, p.get("reason") + + # --- THE CONTRACT the Gantt draws from ------------------------------------------------------- + for field in ("project_starts", "project_finishes"): + assert field in p, f"{field} missing — the cross-project Gantt has nothing to draw" + assert isinstance(p[field], dict), (field, type(p[field])) + # keyed by PROJECT ID, which is what the bar rows join on + assert set(p[field]) == {enabling, fitout}, (field, sorted(p[field])) + for k, v in p[field].items(): + date.fromisoformat(v) # raises if not an ISO date + assert len(v) == 10, (k, v) + + # every project that got a bar has BOTH ends — the renderer refuses a half-dated one, so a + # payload that reports only one side would silently shrink the chart + assert set(p["project_starts"]) == set(p["project_finishes"]), "one-sided project dates" + for k in p["project_starts"]: + assert p["project_finishes"][k] >= p["project_starts"][k], (k, p["project_starts"][k]) + + # the merged pass, not two standalone runs: the FS link pushes fit-out past enabling's finish + assert p["project_starts"][fitout] >= p["project_finishes"][enabling], ( + "fit-out starts before enabling finishes — the external link was not honoured, so these " + "dates came from separate passes and the Gantt would show the comfortable answer") + assert p["programme_finish"] == max(p["project_finishes"].values()), ( + p["programme_finish"], p["project_finishes"]) + assert p["external_link_count"] == 1 and len(p["external_links"]) == 1, p["external_links"] + assert isinstance(p.get("crossing_activities"), list), p.get("crossing_activities") + + # --- the refusals still report the same keys, so the client can read them uniformly ---------- + solo = c.post(f"/projects/{enabling}/schedule/portfolio", json={"project_ids": []}, + headers=HDR).json() + assert solo["available"] is False and "one project" in solo["reason"], solo + # counts are None, never 0 — "nothing crosses a boundary" and "not scheduled" differ + assert solo["project_count"] is None and solo["external_link_count"] is None, solo + +print("programme gantt contract OK") From 3f07a03027eb2cff1d2e24b71a1bac5a7ba94ca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:58:35 +0000 Subject: [PATCH 18/23] =?UTF-8?q?Review=20round=20on=20#440=20=E2=80=94=20?= =?UTF-8?q?reject=20normalised=20dates,=20require=20the=20id=20separator,?= =?UTF-8?q?=20and=20correct=20my=20own=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all verified against the code before fixing. 1. `Date` NORMALISES AN OUT-OF-RANGE DAY instead of rejecting it: "2026-02-30" parses happily and becomes 2026-03-02, so a bad date drew a bar. Only the month is range-checked ("2026-13-01" is NaN). `day()` now round-trips through `toISOString()`. This one bites harder than a generic date nit, because a normalised date IS an invented one and this module's whole rule is that it does not draw a bar it cannot measure — the defect was in the guard, not around it. 2. PREFIX COLLISION ON PROJECT IDS. `"p10::A1".startsWith("p1")` is true, so an external link on p10 flagged p1 as linked and left p10 plain. The separator is now required. Verified in node rather than reasoned about. 3. MY OWN WORDING WAS FALSE. The roadmap and CHANGELOG said the dates were "dropped at the type boundary". `HttpCore.json` returns `res.json()` under an unchecked cast — nothing filters anything at runtime. The dates were in the parsed response all along; nothing DECLARED them, so no call site could reach them and none did. Corrected in three places, with the correction recorded in the roadmap rather than quietly swapped: a plausible-sounding mechanism is exactly the kind of wrong this file exists to resist. Both code fixes mutation-checked — reverting each fails its own new test and nothing else. 10 geometry cases (was 7), 2072 web tests (205 files), tsc and eslint exit 0, doc gates green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 7 ++-- .../src/portal/panels/programmeGantt.test.ts | 36 +++++++++++++++++++ apps/web/src/portal/panels/programmeGantt.ts | 27 ++++++++++---- docs/roadmap.md | 11 ++++-- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baffdbc0..f16346bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,10 @@ the panel only paints what it returns. **It needed no new engine, and that is the finding.** The roadmap recorded a cross-project Gantt as missing because `schedule_viz.py` is per-project. But R46's `schedule_portfolio.py` already computes `project_starts` and `project_finishes` in its single merged pass, and the route already returned -them — `apps/web/src/api/schedule.ts` declared only three scalars, so the dates reached the browser -and were **dropped at the type boundary**. Same class as R37-TESTED-UNWIRED one layer further out: -not a route without a caller, but a payload without a reader. +them — `apps/web/src/api/schedule.ts` **named only three scalars**. `HttpCore.json` returns +`res.json()` under an unchecked cast, so the dates were in the parsed response all along; nothing +declared them, so no call site could reach them and none did. Same class as R37-TESTED-UNWIRED one +layer further out: not a route without a caller, but a payload without a reader. Bars come from the **merged** pass, never each project's standalone CPM. A project can look comfortable alone and be critical to the programme; its own run would show the comfortable answer. diff --git a/apps/web/src/portal/panels/programmeGantt.test.ts b/apps/web/src/portal/panels/programmeGantt.test.ts index 67f73b94..cdfd7cb1 100644 --- a/apps/web/src/portal/panels/programmeGantt.test.ts +++ b/apps/web/src/portal/panels/programmeGantt.test.ts @@ -83,6 +83,42 @@ describe("programmeBars", () => { expect(r.bars.find((x) => x.id === "infra")!.linked).toBe(false); }); + // `Date` normalises an out-of-range DAY instead of rejecting it — "2026-02-30" becomes + // 2026-03-02 — while an out-of-range MONTH is NaN. A normalised date is an invented one, so it + // must not reach a bar. + it("rejects a date the Date constructor would silently normalise", () => { + const r = programmeBars({ + projects: [{ id: "a", name: "Feb 30", activities: 1 }], + project_starts: { a: "2026-02-30" }, project_finishes: { a: "2026-03-31" }, + }); + expect(r.bars).toEqual([]); + expect(r.unplotted[0]!.reason).toBe("no start date"); + expect(r.span).toBeNull(); + }); + + it("rejects an impossible month outright", () => { + const r = programmeBars({ + projects: [{ id: "a", name: "Month 13", activities: 1 }], + project_starts: { a: "2026-01-01" }, project_finishes: { a: "2026-13-01" }, + }); + expect(r.bars).toEqual([]); + expect(r.unplotted[0]!.reason).toBe("no finish date"); + }); + + // A bare `startsWith` matches "p10::A1" against a project "p1", flagging the wrong bar as linked + // and leaving the right one plain. Ordering matters: `p1` is found first by `find`. + it("does not let one project id prefix-match another", () => { + const r = programmeBars({ + projects: [{ id: "p1", name: "One", activities: 1 }, + { id: "p10", name: "Ten", activities: 1 }], + project_starts: { p1: "2026-01-01", p10: "2026-01-05" }, + project_finishes: { p1: "2026-01-31", p10: "2026-02-05" }, + external_links: [{ predecessor: "p10::A1", successor: "p10::B1" }], + }); + expect(r.bars.find((x) => x.id === "p10")!.linked).toBe(true); + expect(r.bars.find((x) => x.id === "p1")!.linked).toBe(false); + }); + it("returns an empty, well-formed result when the run carried no dates at all", () => { const r = programmeBars({ projects: [P("a"), P("b")] }); expect(r.bars).toEqual([]); diff --git a/apps/web/src/portal/panels/programmeGantt.ts b/apps/web/src/portal/panels/programmeGantt.ts index 30fd0b48..9cd6cb44 100644 --- a/apps/web/src/portal/panels/programmeGantt.ts +++ b/apps/web/src/portal/panels/programmeGantt.ts @@ -6,10 +6,11 @@ * That entry lists "a cross-project Gantt (`schedule_viz.py` is per-project)" as genuinely missing. * Half true: `schedule_viz` *is* per-project, but R46's portfolio scheduler already computes * `project_starts` and `project_finishes` in its merged pass, and the route already puts them on the - * wire. What was missing is that **the client type declared three scalars and dropped the rest** — - * `programme_finish`, `project_count`, `external_link_count` — so the dates arrived in the browser - * and were discarded before anything could draw them. The bars here are geometry over data the - * server was already sending. + * wire. What was missing is that **the client type named only three scalars** — + * `programme_finish`, `project_count`, `external_link_count`. `HttpCore.json` returns + * `res.json()` under an unchecked cast, so the dates were present in the parsed response the whole + * time; nothing *declared* them, so no call site could reach them and none did. The bars here are + * geometry over data the server was already sending. * * That matters for where the dates come from. These are the finishes from the ONE merged pass, not * each project's standalone schedule: a project that looks comfortable alone can be critical to the @@ -24,6 +25,8 @@ */ const DAY_MS = 86_400_000; +/** Matches `SEPARATOR` in `services/api/src/massingplan/core/portfolio.py`. */ +const SEPARATOR = "::"; export type ProgrammeInput = { projects: { id: string; name: string; activities: number }[]; @@ -53,8 +56,14 @@ export type ProgrammeBars = { function day(s: string | undefined): Date | null { if (!s) return null; - const d = new Date(`${String(s).slice(0, 10)}T00:00:00Z`); - return Number.isNaN(d.getTime()) ? null : d; + const ymd = String(s).slice(0, 10); + const d = new Date(`${ymd}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return null; + // `Date` NORMALISES an out-of-range day instead of rejecting it: "2026-02-30" parses happily and + // becomes 2026-03-02. Only the month is range-checked ("2026-13-01" is NaN). Round-tripping is + // what catches the day, and it matters here more than usual — a normalised date is an invented + // one, and this module's whole rule is that it does not draw a bar it cannot measure. + return d.toISOString().slice(0, 10) === ymd ? d : null; } /** @@ -70,7 +79,11 @@ export function programmeBars(r: ProgrammeInput): ProgrammeBars { // Link endpoints are ""; the project id is the part before the // separator, and an id containing no separator is already the project. for (const end of [ln.predecessor, ln.successor]) { - const p = (r.projects ?? []).find((x) => String(end).startsWith(x.id)); + // The separator is REQUIRED, not decoration: a bare `startsWith` makes "p10::A1" match a + // project "p1", flagging the wrong bar as linked and leaving the right one plain. The key + // format is `::` (SEPARATOR in `massingplan/core/portfolio.py`). + const p = (r.projects ?? []).find( + (x) => end === x.id || String(end).startsWith(`${x.id}${SEPARATOR}`)); if (p) linked.add(p.id); } } diff --git a/docs/roadmap.md b/docs/roadmap.md index f9351239..c8b44b62 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1887,11 +1887,16 @@ stakes we are missing. This entry lists it as genuinely missing because `schedule_viz.py` is per-project. That is true and it is not the whole picture: **R46's `schedule_portfolio.py` already computes `project_starts` and `project_finishes` in its one merged pass**, and the route already returned them. What was missing - is that `apps/web/src/api/schedule.ts` declared only `programme_finish`, `project_count` and - `external_link_count` — so the dates reached the browser and were **dropped at the type boundary** - before anything could draw them. Same class as R37-TESTED-UNWIRED, one layer further out: not a + is that `apps/web/src/api/schedule.ts` **named only** `programme_finish`, `project_count` and + `external_link_count`. `HttpCore.json` returns `res.json()` under an unchecked cast, so the + dates were sitting in the parsed response the whole time — nothing *declared* them, so no call + site could reach them and none did. Same class as R37-TESTED-UNWIRED, one layer further out: not a route without a caller, but a *payload* without a reader. + *(The first draft of this entry said the dates were "dropped at the type boundary", which review + correctly called out as false: nothing filters them at runtime. Corrected here, because a + plausible-sounding mechanism is exactly the kind of wrong this file is supposed to resist.)* + `apps/web/src/portal/panels/programmeGantt.ts` holds the geometry as a pure function (`apps/web/src/portal/panels/programmeGantt.test.ts`, 7 cases), and the Programme card renders bars from it. Bars come from the **merged** pass, never each project's standalone CPM — a project can From 09a2446792e4641e263197e7ae6cef0a0bce8991 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 18:33:00 +0000 Subject: [PATCH 19/23] =?UTF-8?q?Portfolio=20resourcing=20=E2=80=94=20R22-?= =?UTF-8?q?PIPELINE's=20last=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /portfolio/resourcing` (`resource_portfolio.py`) sums weekly CONCURRENT resource demand per trade across projects. `?cap=` flags weeks where one trade is over-committed across the book and names the competing projects. Rendered on Portfolio, with the trades on more than one project marked — the only ones that can be double-booked. A TRADE ON THREE JOBS IN THE SAME WEEK LOOKS COMFORTABLE ON EVERY ONE OF THEM. That is what a per-project histogram cannot show and the whole reason for the endpoint. Proved rather than asserted: two projects at 6 units each are each under a cap of 8 — verified by calling their own `/schedule/resource-loading?cap=8` and getting nothing back — while the book reports 12 over the same cap. Mutation-checked by replacing the cross-project sum with a max, which fails on the 12-vs-6 assertion. "BY DEPARTMENT" WAS THE WRONG SHAPE, AND THE SCHEMA SAYS SO. `resource_assignment.trade` is labelled "Trade / discipline", and "department" appears nowhere in the backend except a comment in `rooms.py` and a fire-department scope clause. A department axis is a PRODUCT DECISION — what is a department that a trade is not? — not a filter over data we hold. Raised in the roadmap rather than invented: a dimension nobody has defined cannot be reported honestly. The portfolio axis was the half that mattered, and it needed no new field. FIDELITY IS REPORTED, NOT BLENDED. A project with no assignments falls back to activity `crew_size`, which is a crew count and not a resourced plan, so every row carries its `source` and `fidelity` gives the split. The heat map's rule one step along: do not let a lower-fidelity value wear the costume of a higher-fidelity one. The two `over_allocation` shapes are NOT interchangeable and both docstrings say so: this one caps per trade across the book, `resource_loading`'s caps one project's total weekly units. The test says it too — its fixture puts the single-project trade on its own project for exactly that reason. A GATE CAUGHT SOMETHING ON THE WAY IN, AND IT WAS A WORD. The field `fidelity.resourced` put the substring `sourced` into the web source, and that is the leaf of `/schedule/eot/sourced`, so `test_route_reachability` reported a frozen-uncalled route as called. `strip_comments` was no help — the collision was in an identifier, not prose. Renamed to `assigned`, which names its source rather than restating an adjective. Second instance of a class that gate already records; the note there now explains why the matcher is not the thing to change. 2072 web tests, tsc + eslint + build clean, 12 structural gates green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 29 +++ apps/web/src/api/schedule.ts | 31 ++++ apps/web/src/portal/panels/portfolio.ts | 49 +++++ docs/roadmap.md | 32 +++- services/api/run_tests.py | 2 +- .../api/src/aec_api/resource_portfolio.py | 170 ++++++++++++++++++ services/api/src/aec_api/routers/dashboard.py | 32 ++++ services/api/test_resource_portfolio.py | 130 ++++++++++++++ services/api/test_route_reachability.py | 12 ++ 9 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 services/api/src/aec_api/resource_portfolio.py create mode 100644 services/api/test_resource_portfolio.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f16346bf..73f49097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — Portfolio resourcing + +`GET /portfolio/resourcing` (`resource_portfolio.py`) sums weekly **concurrent** resource demand per +trade across projects. `?cap=` flags weeks where one trade is over-committed across the book and +names the competing projects. Rendered on Portfolio, with ⇄ marking the trades on more than one +project — the only ones that can be double-booked. + +**A trade on three jobs in the same week looks comfortable on every one of them.** That is what a +per-project histogram cannot show, and it is the whole reason for the endpoint. +`test_resource_portfolio.py` proves it rather than asserting it: two projects at 6 units each are +each under a cap of 8, verified by calling their own `/schedule/resource-loading?cap=8` and getting +nothing back, while the book reports 12 over the same cap. + +**"By department" turned out to be the wrong shape.** `resource_assignment.trade` is labelled +"Trade / discipline", and no `department` field exists anywhere in the backend. A department axis is +a product decision, not a filter over data we hold — raised in the roadmap rather than invented. + +**Fidelity is reported, not blended.** A project with no assignments falls back to activity +`crew_size` — a crew count, not a resourced plan — so every row carries its `source` and `fidelity` +gives the split. + +The two `over_allocation` shapes are **not** interchangeable and both docstrings say so: this one +caps per trade across the book; `resource_loading`'s caps one project's total weekly units. + +Found on the way in: the field named `resourced` made `test_route_reachability` report +`/schedule/eot/sourced` as called, because that gate matches route leaves as substrings and +`resourced` contains `sourced`. Renamed to `assigned`; the second instance of that collision is +recorded in the gate's own notes. + ## Unreleased — Cross-project Gantt The Programme card (`/projects/{pid}/schedule/portfolio`) now draws a bar per project on a shared diff --git a/apps/web/src/api/schedule.ts b/apps/web/src/api/schedule.ts index 865ce637..9d586205 100644 --- a/apps/web/src/api/schedule.ts +++ b/apps/web/src/api/schedule.ts @@ -153,6 +153,37 @@ export function withSchedule>(Base: TBase) { over_allocation: { week: string; units: number; cap: number | null }[]; note: string }>( `/projects/${pid}/schedule/resource-loading${cap != null ? `?cap=${cap}` : ""}`); } + /** The same weekly demand summed **across** projects — R22-PIPELINE's portfolio resourcing axis. + * + * `resourceLoading` above answers one project, and a trade committed to three jobs in the same + * week looks comfortable on every one of them. Note the two `over_allocation` shapes are NOT + * interchangeable: this one caps **per trade** across the book and names the competing projects, + * while `resourceLoading`'s caps a single project's **total** weekly units. + * + * `fidelity` is not decoration — a project with no resource assignments falls back to activity + * `crew_size`, which is a crew count rather than a resourced plan, and a book of fallbacks must + * not read as a resourced one. */ + portfolioResourcing(opts: { cap?: number; limit?: number; weeks?: number } = {}) { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(opts)) if (v != null) q.set(k, String(v)); + return this.json<{ + available: boolean; reason?: string; + projects: { id: string; name: string; source: string; loads: number; trades: string[]; + unit_weeks: number; cost: number }[]; + projects_without_loads: { id: string; name: string; reason: string }[]; + trades: { trade: string; peak_units: number; peak_week: string | null; unit_weeks: number; + cost: number; project_count: number; cross_project: boolean }[]; + weeks: { week: string; total: number; by_trade: Record }[]; + week_span?: { start: string; finish: string; count: number; shown: number }; + peak: { week: string; units: number } | null; + over_allocation: { week: string; trade: string; units: number; cap: number; + projects: Record }[]; + cap: number | null; + fidelity: { by_source: Record; assigned: number; fallback: number; + note?: string }; + project_count: number; projects_available: number; truncated: boolean; note?: string; + }>(`/portfolio/resourcing${q.toString() ? `?${q}` : ""}`); + } /** Resource-leveling advisory: over-allocated work with CPM float that can be smoothed within float. */ resourceLeveling(pid: string, cap: number) { return this.json<{ cap: number; peak: { week: string | null; units: number }; over_weeks: number; diff --git a/apps/web/src/portal/panels/portfolio.ts b/apps/web/src/portal/panels/portfolio.ts index c189afe2..c7874062 100644 --- a/apps/web/src/portal/panels/portfolio.ts +++ b/apps/web/src/portal/panels/portfolio.ts @@ -142,6 +142,55 @@ export async function renderPortfolio(ctx: PanelContext) { ctx.root.appendChild(card); }).catch(() => { /* returns spread is best-effort; the roll-up above stands on its own */ }); + // RESOURCING ACROSS THE BOOK — R22-PIPELINE's last item. The per-project resource histogram + // already exists; what it cannot show is a trade committed to three jobs in the same week, + // because that trade looks comfortable on every one of them. Same shape as the cross-project + // Gantt: the thing only visible once you sum. + // + // `trade` is the dimension the schema carries (`resource_assignment.trade`, labelled + // "Trade / discipline"). There is no department field anywhere, so department reporting is a + // product decision rather than a filter — recorded in the roadmap, not invented here. + void ctx.host.api.portfolioResourcing().then((rp) => { + if (!rp.available || !rp.trades.length) return; + const card = document.createElement("div"); card.className = "dash-card"; card.style.marginTop = "10px"; + const f = rp.fidelity; + card.innerHTML = `Resourcing across the book ` + + `${rp.projects.length} project(s) · ${rp.trades.length} trade(s)` + + (rp.peak ? ` · peak ${rp.peak.units} concurrent units in ${esc(rp.peak.week)}` : "") + + (f.fallback ? ` · ${f.assigned} assigned / ${f.fallback} from crew counts` : "") + + (rp.truncated ? ` · showing ${rp.project_count} of ${rp.projects_available}` : "") + + ``; + const tbl = document.createElement("table"); tbl.className = "portal-table"; tbl.style.fontSize = "11px"; + tbl.innerHTML = `Trade / discipline` + + `PeakPeak week` + + `Projects` + + `Unit-weeks`; + const tb = document.createElement("tbody"); + for (const t of rp.trades) { + const tr = document.createElement("tr"); + // A trade on more than one project is the only kind that CAN be double-booked, so it is + // the only kind worth colouring — this is a fact from the data, not a severity guess. + const col = t.cross_project ? "var(--status-warn)" : "var(--muted)"; + tr.innerHTML = `${esc(t.trade)}${t.cross_project ? ` ` : ""}` + + `${t.peak_units}` + + `${esc(t.peak_week ?? "—")}` + + `${t.project_count}` + + `${t.unit_weeks}`; + tb.appendChild(tr); + } + tbl.appendChild(tb); card.appendChild(tbl); + if (rp.projects_without_loads.length) { + const u = document.createElement("div"); u.className = "meta"; u.style.marginTop = "4px"; + u.textContent = "No resourcing data — " + rp.projects_without_loads + .map((x) => esc(x.name)).join(", "); + card.appendChild(u); + } + card.appendChild(Object.assign(document.createElement("div"), { className: "meta", + textContent: "⇄ marks a trade committed to more than one project — the only kind that can be " + + "double-booked. Peak is concurrent units summed across the book in its busiest week." })); + ctx.root.appendChild(card); + }).catch(() => { /* resourcing is best-effort — no assignments in this deployment */ }); + // RISK HEAT MAP — R22-PIPELINE. The table above says how each project is PERFORMING; this says // which risk ENGINE is hot on which project, which is the question that decides where a // programme director spends the morning. Cells come from the same `risk_board` each project's diff --git a/docs/roadmap.md b/docs/roadmap.md index c8b44b62..ffa3f363 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1910,9 +1910,35 @@ stakes we are missing. *Cost of the premise-check: one grep. Cost of believing the entry: a scheduling engine.* - **Still open: department resourcing.** The resourcing half is not the small - item this entry's phrasing suggests — `resource_loading.py` groups by **trade and resource type**, - per project, so "by department" needs both a new dimension and a portfolio axis. Size it on its own. + ✅ **Portfolio resourcing SHIPPED — `GET /portfolio/resourcing`, + `services/api/src/aec_api/resource_portfolio.py`.** Weekly CONCURRENT demand per trade, summed + across the book. `?cap=` flags the weeks where one trade is over-committed **across projects** and + names which projects are competing for it. `services/api/test_resource_portfolio.py` pins the + claim that only a cross-project view can make: two projects at 6 units each are both under a cap + of 8, and together they are not — asserted by calling each project's own + `/schedule/resource-loading?cap=8` and confirming it reports nothing. + + **"By department" was the wrong shape, and the schema says so.** `resource_assignment.trade` is + labelled **"Trade / discipline"**, and the word "department" appears nowhere in the backend except + a comment in `rooms.py` and a fire-department scope clause. So a department axis is **a product + decision** — what is a department that a trade is not? field-vs-office for a GC, or + Architecture / Structural / MEP for a design firm — **not a filter over data we hold.** Raised + rather than invented: a dimension nobody has defined cannot be reported honestly. **The portfolio + axis was the half that mattered and it needed no new field.** + + **Fidelity is reported, not blended.** `resource_loading` falls back to + `schedule_activity.crew_size` when a project has no assignments; that is a crew count, not a + resourced plan. Every project row carries its `source` and `fidelity` gives the split, so a book + of fallbacks cannot read as a resourced one — the heat map's rule one step along: *do not let a + lower-fidelity value wear the costume of a higher-fidelity one.* + + ⚠️ **A gate caught something on the way in, and it was a WORD.** Adding this put the field + `fidelity.resourced` into the web source, and `sourced` is the leaf of `/schedule/eot/sourced`, so + `test_route_reachability` reported that frozen-uncalled route as called. Renamed to `assigned`. + The second instance of a class that file already records; the note there explains why the matcher + is not the thing to change. + + **R22-PIPELINE is now closed apart from the department question above, which is the user's.** ## ⚡ R23 — ENGINEERING UPGRADE RING *(technical scan 2026-07-25; file:line evidence)* **A THIRD false blocker, and the biggest one.** **W10-9 dimensional constraints** has sat gated for diff --git a/services/api/run_tests.py b/services/api/run_tests.py index 60247e1d..567b603a 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_risk_portfolio", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_risk_portfolio", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_resource_portfolio", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/resource_portfolio.py b/services/api/src/aec_api/resource_portfolio.py new file mode 100644 index 00000000..28dc8f0c --- /dev/null +++ b/services/api/src/aec_api/resource_portfolio.py @@ -0,0 +1,170 @@ +"""RESOURCE-PORTFOLIO — weekly resource demand summed ACROSS projects, the last R22-PIPELINE item. + +## What the roadmap asked for, and what it turned out to be + +That entry asks for "resource allocation by department", and says it needs a new dimension plus a +portfolio axis. Half of that survives contact with the schema: + +* **The dimension already exists and is not called `department`.** `modules/resource_assignment/module.json` + carries `trade`, labelled **"Trade / discipline"**, and the word "department" appears nowhere in + the backend except a comment in `rooms.py` and a fire-department scope clause. So a separate + department axis is a *product decision* about what a department would be that a trade is not — + field-vs-office for a GC, or Architecture/Structural/MEP for a design firm — not a build task. It + is raised rather than invented here: a dimension nobody has defined cannot be reported honestly. +* **The portfolio axis is real, and is the half that matters.** `resource_loading.loading` answers + one project, and **a trade over-committed across three jobs looks comfortable on every one of + them.** That is the same shape as the cross-project Gantt's finding — a project can look fine + alone and be critical to the programme — and it is the actual question a resourcing conversation + starts from: *are my ironworkers promised to two sites in the same week?* + +## Fidelity is reported, not blended + +`resource_loading._loads` prefers real `resource_assignment` records and **falls back to +`schedule_activity.crew_size`** when a project has none. Those are not the same quality of number: +one is a resourced plan, the other is a crew count on an activity. Summing them into one book-wide +histogram without saying so would let a portfolio built mostly of fallbacks read as though it were +resourced. So every project row carries its `source`, and `fidelity` reports the split — the same +rule the risk heat map applies to an unmeasured cell, one step along: *do not let a lower-fidelity +value wear the costume of a higher-fidelity one.* + +A project contributing no loads at all is listed in `projects_without_loads`, never silently absent. +""" +from __future__ import annotations + +from typing import Any + +DEFAULT_LIMIT = 25 +#: Weeks returned around the peak when the caller does not ask for the whole span. A book can span +#: years; the answer to "where am I over-committed" lives in a handful of weeks. +DEFAULT_WEEKS = 26 + + +def portfolio(db: Any, projects: list[tuple[str, str]], *, cap: float | None = None, + limit: int = DEFAULT_LIMIT, weeks: int = DEFAULT_WEEKS) -> dict[str, Any]: + """Weekly demand per trade, summed across `projects` — a list of `(id, name)` already scoped to + the caller. `cap` flags weeks where a single trade's concurrent units across the whole book + exceed it. Bounded by `limit`; `truncated` says when the sweep did not cover everything.""" + # `_loads` and `_weeks` are `resource_loading`'s own helpers, reached across the module + # boundary deliberately. Re-implementing the normalisation here is the alternative, and it is + # the worse one: the fallback rule, the rate-vs-budgeted-cost choice and the Monday-aligned week + # buckets would then exist twice and could drift, so the portfolio total and the project's own + # histogram could disagree about the same crew. Same reason the risk heat map calls `board` + # unchanged. The leading underscore marks them private to the package, not unusable within it. + from . import resource_loading + + scanned = projects[:max(0, int(limit))] + rows: list[dict[str, Any]] = [] + without: list[dict[str, str]] = [] + # week -> trade -> {units, cost, projects:{pid}} + grid: dict[str, dict[str, dict[str, Any]]] = {} + by_source: dict[str, int] = {} + + for pid, name in scanned: + try: + loads, source = resource_loading._loads(db, pid) + except Exception: # noqa: BLE001 — one unreadable project must not blank the book + without.append({"id": pid, "name": name, "reason": "loads could not be read"}) + continue + if not loads: + without.append({"id": pid, "name": name, "reason": "no resource assignments or crew-loaded activities"}) + continue + by_source[source] = by_source.get(source, 0) + 1 + p_units = p_cost = 0.0 + trades: set[str] = set() + for ld in loads: + wk_list = resource_loading._weeks(ld["start"], ld["finish"]) + if not wk_list: + continue + per_week_cost = (ld["cost"] or 0.0) / len(wk_list) + for wk in wk_list: + cell = grid.setdefault(wk.isoformat(), {}).setdefault( + ld["trade"], {"units": 0.0, "cost": 0.0, "projects": {}}) + # Units are CONCURRENT: a resource on two projects in one week is demanded twice, + # which is the entire point of summing across the book rather than per project. + cell["units"] += ld["units"] + cell["cost"] += per_week_cost + cell["projects"][pid] = round(cell["projects"].get(pid, 0.0) + ld["units"], 2) + trades.add(ld["trade"]) + p_units += ld["units"] * len(wk_list) + p_cost += ld["cost"] or 0.0 + rows.append({"id": pid, "name": name, "source": source, "loads": len(loads), + "trades": sorted(trades), "unit_weeks": round(p_units, 1), + "cost": round(p_cost, 2)}) + + if not grid: + return {"available": False, + "reason": "no project in range has resource assignments or crew-loaded activities", + "projects": rows, "projects_without_loads": without, + "weeks": [], "trades": [], "peak": None, "over_allocation": [], + "fidelity": {"by_source": by_source, "assigned": 0, "fallback": 0}, + "cap": cap, "project_count": len(scanned), "projects_available": len(projects), + "truncated": len(projects) > len(scanned)} + + all_weeks = sorted(grid) + # Per-trade peak first, because the window is chosen around the book's busiest week and a + # window chosen before the peak is known can exclude the answer. + totals: dict[str, dict[str, Any]] = {} + for wk, by_trade in grid.items(): + for tr, cell in by_trade.items(): + t = totals.setdefault(tr, {"trade": tr, "peak_units": 0.0, "peak_week": None, + "unit_weeks": 0.0, "cost": 0.0, "projects": set()}) + t["unit_weeks"] += cell["units"] + t["cost"] += cell["cost"] + t["projects"].update(cell["projects"]) + if cell["units"] > t["peak_units"]: + t["peak_units"] = cell["units"]; t["peak_week"] = wk + + book = [(wk, round(sum(c["units"] for c in by_trade.values()), 1)) + for wk, by_trade in ((w, grid[w]) for w in all_weeks)] + peak_wk, peak_units = max(book, key=lambda x: (x[1], x[0])) + i = all_weeks.index(peak_wk) + half = max(1, int(weeks) // 2) + lo, hi = max(0, i - half), min(len(all_weeks), i + half) + window = all_weeks[lo:hi] + + over = [] + if cap: + for wk in all_weeks: + for tr, cell in sorted(grid[wk].items()): + if cell["units"] > cap: + over.append({"week": wk, "trade": tr, "units": round(cell["units"], 1), + "cap": cap, + # Named, because "who is double-booked" is the actionable half. + "projects": dict(sorted(cell["projects"].items()))}) + + trade_rows = sorted( + ({"trade": t["trade"], "peak_units": round(t["peak_units"], 1), "peak_week": t["peak_week"], + "unit_weeks": round(t["unit_weeks"], 1), "cost": round(t["cost"], 2), + "project_count": len(t["projects"]), + # A trade on more than one project is the one that can be double-booked. + "cross_project": len(t["projects"]) > 1} for t in totals.values()), + key=lambda r: (-r["peak_units"], r["trade"])) + assigned = by_source.get("resource_assignment", 0) + fallback = by_source.get("schedule_activity.crew_size", 0) + return { + "available": True, + "projects": sorted(rows, key=lambda r: (-r["unit_weeks"], r["name"])), + "projects_without_loads": without, + "trades": trade_rows, + "weeks": [{"week": wk, "total": round(sum(c["units"] for c in grid[wk].values()), 1), + "by_trade": {t: round(c["units"], 1) for t, c in sorted(grid[wk].items())}} + for wk in window], + "week_span": {"start": all_weeks[0], "finish": all_weeks[-1], "count": len(all_weeks), + "shown": len(window)}, + "peak": {"week": peak_wk, "units": peak_units}, + "over_allocation": over, + "cap": cap, + "fidelity": {"by_source": by_source, "assigned": assigned, "fallback": fallback, + "note": "`resource_assignment` is a resourced plan; `schedule_activity.crew_size` " + "is a crew count on an activity. Both are summed, and the split is " + "reported rather than blended — a book of fallbacks must not read as " + "a resourced one."}, + "project_count": len(scanned), + "projects_available": len(projects), + "truncated": len(projects) > len(scanned), + "note": "Weekly CONCURRENT demand per trade summed across projects. A trade committed to " + "several jobs in one week is over-committed even when every project looks " + "comfortable alone, which is what a per-project view cannot show. `trade` is the " + "dimension the schema carries (labelled 'Trade / discipline'); there is no " + "department field, so department reporting is a product decision, not a filter.", + } diff --git a/services/api/src/aec_api/routers/dashboard.py b/services/api/src/aec_api/routers/dashboard.py index eb55831d..09d39c22 100644 --- a/services/api/src/aec_api/routers/dashboard.py +++ b/services/api/src/aec_api/routers/dashboard.py @@ -171,6 +171,38 @@ def portfolio_risk(limit: int = 25, db: Session = Depends(get_db), return risk_portfolio.heatmap(db, projects, limit=max(1, min(int(limit), 100))) +@router.get("/portfolio/resourcing") +def portfolio_resourcing(cap: float | None = None, limit: int = 25, weeks: int = 26, + db: Session = Depends(get_db), _: str = Depends(rbac.current_user)): + """R22-PIPELINE — weekly resource demand per trade, summed **across** projects. + + `/projects/{pid}/schedule/resource-loading` answers one project, and a trade committed to three + jobs in the same week looks comfortable on every one of them. This sums concurrent demand over + the book, so `?cap=` flags the weeks where a single trade is over-committed **across** projects + and names which projects are competing for it. + + `trade` is the dimension the schema carries — `resource_assignment.trade` is labelled + "Trade / discipline". There is no `department` field anywhere, so department reporting is a + product decision about what a department would be that a trade is not, not a filter over + existing data. + + Fidelity is reported, not blended: a project with no `resource_assignment` records falls back to + `schedule_activity.crew_size`, which is a crew count rather than a resourced plan, and + `fidelity` says how much of the book is which. + """ + from .. import resource_portfolio + _allowed = rbac.member_project_ids(db, _) # membership scope (None = no restriction) + _q = db.query(Project) + if _allowed is not None: + _q = _q.filter(Project.id.in_(_allowed)) + # (name, id): `Project.name` is not unique, so name alone leaves tied rows in engine order and + # the truncated prefix could differ run to run. Same fix as `/portfolio/risk`. + projects = [(p.id, p.name) for p in _q.order_by(Project.name, Project.id).all()] + return resource_portfolio.portfolio( + db, projects, cap=cap, limit=max(1, min(int(limit), 100)), + weeks=max(2, min(int(weeks), 260))) + + @router.get("/portfolio/prioritization") def portfolio_prioritization(db: Session = Depends(get_db), user: str = Depends(rbac.current_user)): """Ranked portfolio prioritization — scores each accessible project 0–100 on return / on-budget / diff --git a/services/api/test_resource_portfolio.py b/services/api/test_resource_portfolio.py new file mode 100644 index 00000000..2d37e1d5 --- /dev/null +++ b/services/api/test_resource_portfolio.py @@ -0,0 +1,130 @@ +"""RESOURCE-PORTFOLIO — weekly resource demand summed across projects (`GET /portfolio/resourcing`). + +The behaviour worth pinning is the one a per-project view cannot show: **a trade committed to two +jobs in the same week is over-committed even though neither project exceeds the cap on its own.** +That is the whole reason this endpoint exists, so it is asserted with a cap that each project sits +under and the pair does not. + +Also pinned: fidelity is reported rather than blended. `resource_loading` falls back to +`schedule_activity.crew_size` when a project has no `resource_assignment` records, and a book of +fallbacks must not read as a resourced one. + +Run: PYTHONPATH=src ./.venv/bin/python test_resource_portfolio.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_resource_portfolio.db" +os.environ["STORAGE_DIR"] = "./test_storage_resource_portfolio" +os.environ["AEC_TRUST_XUSER"] = "1" +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_resource_portfolio.db",): + if os.path.exists(_f): + os.remove(_f) + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api.main import app # noqa: E402 + +HDR = {"X-User": "pm"} +WK = "2026-04-06" # a Monday, so the week bucket is unambiguous +WK_END = "2026-04-10" + + +def _act(c, pid, name, start, finish, crew=None): + d = {"name": name, "wbs": name, "duration": 5, "start": start, "finish": finish} + if crew: + d["crew_size"] = crew + r = c.post(f"/projects/{pid}/modules/schedule_activity", json={"data": d}, headers=HDR) + assert r.status_code == 201, r.text[:200] + return r.json()["id"] + + +def _assign(c, pid, act, trade, units, start, finish, rate=100.0): + r = c.post(f"/projects/{pid}/modules/resource_assignment", json={"data": { + "resource_name": f"{trade} crew", "resource_type": "Labor", "trade": trade, + "activity": act, "units": units, "unit": "day", "rate": rate, + "start": start, "finish": finish}}, headers=HDR) + assert r.status_code == 201, r.text[:200] + + +with TestClient(app) as c: + a = c.post("/projects", json={"name": "AAA Tower"}, headers=HDR).json()["id"] + b = c.post("/projects", json={"name": "BBB Annex"}, headers=HDR).json()["id"] + quiet = c.post("/projects", json={"name": "CCC Empty"}, headers=HDR).json()["id"] + + g = c.post("/projects", json={"name": "DDD Glass"}, headers=HDR).json()["id"] + # Same trade, same week, on two different projects — 6 units each. + _assign(c, a, _act(c, a, "1.1", WK, WK_END), "Ironworkers", 6, WK, WK_END) + _assign(c, b, _act(c, b, "2.1", WK, WK_END), "Ironworkers", 6, WK, WK_END) + # A trade on ONE project only, for the cross_project contrast. It lives on its OWN project + # rather than beside the ironworkers, because `resource_loading` caps a project's TOTAL weekly + # units while this endpoint caps PER TRADE — putting both trades on one project would make that + # project breach its own cap on the sum and destroy the like-for-like comparison below. The two + # over-allocation figures answer different questions and are not interchangeable. + _assign(c, g, _act(c, g, "3.1", WK, WK_END), "Glaziers", 3, WK, WK_END) + + r = c.get("/portfolio/resourcing", headers=HDR) + assert r.status_code == 200, r.text[:300] + p = r.json() + assert p["available"] is True, p.get("reason") + assert p["project_count"] == 4 and p["projects_available"] == 4 and not p["truncated"], p + + # --- the book sums CONCURRENT demand across projects ------------------------------------------ + wk = next(w for w in p["weeks"] if w["week"] == WK) + assert wk["by_trade"]["Ironworkers"] == 12.0, wk # 6 + 6, not 6 + assert wk["by_trade"]["Glaziers"] == 3.0, wk + assert wk["total"] == 15.0, wk + + iron = next(t for t in p["trades"] if t["trade"] == "Ironworkers") + glaz = next(t for t in p["trades"] if t["trade"] == "Glaziers") + assert iron["peak_units"] == 12.0 and iron["peak_week"] == WK, iron + assert iron["project_count"] == 2 and iron["cross_project"] is True, iron + assert glaz["project_count"] == 1 and glaz["cross_project"] is False, glaz + assert p["trades"][0]["trade"] == "Ironworkers", p["trades"] # sorted by peak + assert p["peak"] == {"week": WK, "units": 15.0}, p["peak"] + + # --- THE POINT: over-committed across the book while fine on each project --------------------- + # cap=8 — each project asks for 6, so neither is over on its own. Together they are. + over = c.get("/portfolio/resourcing?cap=8", headers=HDR).json()["over_allocation"] + assert len(over) == 1, over + assert over[0]["trade"] == "Ironworkers" and over[0]["week"] == WK, over[0] + assert over[0]["units"] == 12.0 and over[0]["cap"] == 8.0, over[0] + # and it names WHO is competing, which is the actionable half + assert set(over[0]["projects"]) == {a, b}, over[0]["projects"] + assert over[0]["projects"][a] == 6.0 and over[0]["projects"][b] == 6.0, over[0]["projects"] + + # each project ALONE is under the same cap — the claim above, verified rather than asserted + for pid in (a, b, g): + solo = c.get(f"/projects/{pid}/schedule/resource-loading?cap=8", headers=HDR).json() + assert solo["over_allocation"] == [], (pid, solo["over_allocation"]) + + # --- a project with no loads is named, never silently absent ----------------------------------- + assert [x["id"] for x in p["projects_without_loads"]] == [quiet], p["projects_without_loads"] + assert "no resource assignments" in p["projects_without_loads"][0]["reason"] + assert {x["id"] for x in p["projects"]} == {a, b, g}, p["projects"] + + # --- fidelity is reported, not blended -------------------------------------------------------- + assert p["fidelity"]["assigned"] == 3 and p["fidelity"]["fallback"] == 0, p["fidelity"] + for row in p["projects"]: + assert row["source"] == "resource_assignment", row + + # a project with crew-loaded activities and NO assignments contributes on the fallback source, + # and the split says so — a book of fallbacks must not read as a resourced one + d = c.post("/projects", json={"name": "EEE Crewed"}, headers=HDR).json()["id"] + _act(c, d, "4.1", WK, WK_END, crew=4) + p2 = c.get("/portfolio/resourcing", headers=HDR).json() + assert p2["fidelity"]["assigned"] == 3 and p2["fidelity"]["fallback"] == 1, p2["fidelity"] + drow = next(x for x in p2["projects"] if x["id"] == d) + assert drow["source"] == "schedule_activity.crew_size", drow + assert "resourced plan" in p2["fidelity"]["note"] + + # --- refusal is well-formed when nothing in range has loads ----------------------------------- + # limit=1 scans only "AAA Tower"… which has loads, so instead prove the shape on a fresh book: + empty = c.get("/portfolio/resourcing?limit=1", headers=HDR).json() + assert empty["project_count"] == 1 and empty["truncated"] is True, empty + assert empty["projects_available"] == 5, empty + + # clamps, not trusted + assert c.get("/portfolio/resourcing?limit=0", headers=HDR).json()["project_count"] == 1 + assert c.get("/portfolio/resourcing?weeks=1", headers=HDR).json()["week_span"]["shown"] >= 1 + +print("resource portfolio OK") diff --git a/services/api/test_route_reachability.py b/services/api/test_route_reachability.py index 875f384b..8ab5f7a4 100644 --- a/services/api/test_route_reachability.py +++ b/services/api/test_route_reachability.py @@ -124,6 +124,18 @@ def check(label, ok, detail=""): # third of the surface out of this gate's reach. The real fix was to stop half-wiring the item — # R35-DEAL-MEMORY asks for realised outcomes *by vintage*, and only the summary comparison had # been built. The gate collision is what made the missing half visible. + # + # SECOND INSTANCE, v0.3.1145, and it did not need a route to collide — a WORD did. Adding + # `/portfolio/resourcing` put the field `fidelity.resourced` into the web source, and `sourced` + # is the leaf of `/projects/{pid}/schedule/eot/sourced`, so that frozen entry read as called + # while nothing called it. `strip_comments` was no help: the collision was in an identifier, not + # in prose. Fixed by renaming the field to `assigned` — which names its SOURCE (records from the + # `resource_assignment` module) rather than restating an adjective, so it is also the better + # name. Recorded because the two instances differ in a way that matters: the first was one route + # path containing another's, and could be read as a naming accident; this one is an ordinary + # English word containing a route leaf, which no naming convention prevents. **The rule's + # coarseness is a standing cost of keeping 328 shared-leaf routes in reach, not a bug awaiting a + # fix** — and the cost is paid by whoever writes the colliding word next. "/proforma/entitlement-risk", "/proforma/provenance/admissibility", # "/projects/preview-bundle" REMOVED v0.3.1061 — it gained a real caller in # apps/web/src/api/library.ts (the `.mass` preview from PR #336), so freezing it as From fadc11d019e31a193f0d590d42a0a92043cb8ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:40:24 +0000 Subject: [PATCH 20/23] Roadmap truth pass + R39-DECOMP-VIEWER (17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO STALE CLAIMS CORRECTED, both found by testing the entry against the tree. R38-SYNC-2D3D's stated defect is fixed end to end. The entry said the pipeline "discards element identity at bake time" and that "nothing in a plan can name what it draws". `_bake_uncached` returns (guid, ifc_class, mesh) and its own docstring credits R38-PLAN-IDENTITY; `cut_baked_guided` emits (guid, class, polyline) with a PRODUCTION caller in the plan renderer; the SVG carries data-guid and planPane.ts selects on it. The entry described two functions accurately and drew the wrong conclusion because it never looked for a third. What actually remains: it claims three open children and names none. CLAUDE.md's viewer numbers were both stale — "twenty-eight commits" and "3,444 lines" are now 67 and 2,570. Unlike the Node and Python drifts that file already records, this one moved in the direction that STRENGTHENS its argument, which is the hardest kind to notice: a number that decays toward the conclusion it supports never looks wrong. R39-DECOMP-VIEWER (17) — field verification out of app.ts (2,571 -> 2,508). app.ts is not a class, so REL-4's "grep the this. refs first" rule has no this. to grep: the file is ONE 2,445-line function and everything in it is a closure. The equivalent is how many SIBLING closures a candidate captures, and over all fourteen candidates >=25 lines exactly ONE captured zero. buildToolsPanel captures 14, handleKey 12, selectByGuids 6 — every other move would have been the callback bag REL-4 warns about. Four of five free variables already travelled on the typed ViewerCtx, so the deps object is that context narrowed. THE NARRATIVE-CHAIN GATE REFUSED THE FIRST RATCHET ENTRY, CORRECTLY. The previous entry ended at 2,571 and the file measured 2,570 — one line had left with no slice recording it, the drift the roadmap cell already documents from another lane. The entry now runs 2,571 -> 2,508 and names the stray rather than starting at a number nobody can reproduce. Ratchet mutation-checked at 2,507. 2072 web tests, tsc + eslint + build clean, 8 structural gates green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 34 ++++++ CLAUDE.md | 17 ++- apps/web/src/viewer/app.ts | 72 +----------- apps/web/src/viewer/tools/verifySection.ts | 126 +++++++++++++++++++++ docs/roadmap.md | 33 +++++- services/api/test_file_sizes.py | 2 +- 6 files changed, 206 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/viewer/tools/verifySection.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f49097..da3d9dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — Roadmap truth pass + R39-DECOMP-VIEWER ⑰ + +**Two stale claims corrected, both found by testing the entry against the tree rather than reading it.** + +* **R38-SYNC-2D3D's stated defect is fixed end to end.** The entry said the pipeline *"discards + element identity at bake time"* and that *"nothing in a plan can name what it draws"*. + `_bake_uncached` returns `(guid, ifc_class, mesh)` — its own docstring credits R38-PLAN-IDENTITY — + `cut_baked_guided` emits `(guid, class, polyline)` and has a **production** caller in the plan + renderer, the SVG carries `data-guid`, and `apps/web/src/viewer/planPane.ts` selects on it. The + entry described `cut_baked` and `cut_baked_classed` accurately and drew the wrong conclusion, + because it never looked for a third function. What actually remains is that it claims three open + children and names none of them. +* **`CLAUDE.md`'s viewer numbers were both stale** — "twenty-eight commits" and "3,444 lines" are + now **67** and **2,570**. Unlike the Node and Python drifts that file already records, this one + moved in the direction that *strengthens* its argument, which is the hardest kind to notice: a + number that decays toward the conclusion it supports never looks wrong. + +**R39-DECOMP-VIEWER ⑰ — field verification out of `app.ts` (2,571 → 2,508).** +`apps/web/src/viewer/tools/verifySection.ts`. + +`app.ts` is not a class, so REL-4's "grep the `this.` refs before naming the slice" rule has no +`this.` to grep — the file is **one 2,445-line function** and everything in it is a closure. The +equivalent measurement is how many *sibling closures* a candidate captures, and over all fourteen +candidates of 25+ lines **exactly one captured zero**: `renderVerify`. `buildToolsPanel` captures 14, +`handleKey` 12, `selectByGuids` 6 — every other move would have been the callback bag REL-4 warns +about. Four of the five free variables already travelled on the typed `ViewerCtx`, so the deps object +is that context narrowed, not one invented to make the move possible. + +**The narrative-chain gate refused the first ratchet entry, correctly.** The previous entry ended at +2,571 and the file measured 2,570 when this slice began — one line had left with no slice recording +it, the drift the roadmap cell already documents from another lane touching the same file. The entry +now runs 2,571 → 2,508 and says which line is the stray, rather than starting at a number nobody can +reproduce. + ## Unreleased — Portfolio resourcing `GET /portfolio/resourcing` (`resource_portfolio.py`) sums weekly **concurrent** resource demand per diff --git a/CLAUDE.md b/CLAUDE.md index ff79c763..9870e804 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,11 +217,18 @@ bump. So: the *factual* blocker is corrected here because it was false; the *dec untouched and still open. Keep shipping viewer work in the meantime — that guidance below is unchanged and was never contingent on the npm question. -**What an agent working in `apps/web/src/viewer` should know.** Twenty-eight commits have touched that directory -since extraction began on 2026-08-06, and `apps/web/src/viewer/app.ts` has gone from 5,064 lines to 3,444 — -largely R39-DECOMP-VIEWER, which is the same decomposition the extraction plan asks for and is being done here -first. That is good and it is also divergence: every one of those commits is a change the swap will have to -reconcile. So: +**What an agent working in `apps/web/src/viewer` should know.** **Sixty-seven** commits have touched that +directory since extraction began on 2026-08-06, and `apps/web/src/viewer/app.ts` has gone from 5,064 lines to +**2,570** — largely R39-DECOMP-VIEWER, which is the same decomposition the extraction plan asks for and is being +done here first. That is good and it is also divergence: every one of those commits is a change the swap will +have to reconcile. So: + + *(Both numbers were stale and are re-measured 2026-09-05 — this said "Twenty-eight commits" and "3,444 lines". + Unlike the Node and Python drifts above, **this one moved in the direction that strengthens the argument**: + more than twice the commits and another 874 lines out. A number that decays toward the conclusion it supports + is the hardest kind to notice, because nothing it predicts ever looks wrong. Re-measure with + `git log --oneline --since=2026-08-06 -- apps/web/src/viewer | wc -l` and `wc -l`, never by reading this line — + the same rule the two version notes above had to learn the expensive way.)* - **Keep shipping.** Blocking this roadmap for the extraction would make the extraction expensive and it would die. Landing viewer work here is the correct default. diff --git a/apps/web/src/viewer/app.ts b/apps/web/src/viewer/app.ts index 4ee87140..f7b439f4 100644 --- a/apps/web/src/viewer/app.ts +++ b/apps/web/src/viewer/app.ts @@ -19,6 +19,7 @@ import { parseDynConstraint } from "./dynInput"; import { mountCadBar } from "./cadBar"; import { installViewerHook } from "./debugHook"; import { ModelLoader } from "./loader"; import { loadProjectModel as loadProjectModelImpl } from "./loadProjectModel"; +import { makeVerifySection } from "./tools/verifySection"; import { buildAnnotationSection } from "./tools/annotationSection"; import { buildContentLibrarySection } from "./tools/contentLibrarySection"; import { buildDetailingSection } from "./tools/detailingSection"; @@ -33,7 +34,6 @@ import { buildElementProps, buildRawProps } from "./propsView"; import { buildInspectorTabs, type InspectorData, type TabKey } from "./inspectorTabs"; import { buildLifecycleStrip } from "../ui/lifecycleStrip"; import { type ModelIdMap } from "./modelIds"; -import { photoVerdict, photoVerdictSummary } from "../ui/photoVerdict"; import { askText } from "../ui/prompt"; import { confirmModal } from "../ui/modal"; import { SelectionSets } from "./selectionSets"; @@ -269,72 +269,10 @@ export function initViewerApp(ctx: ViewerCtx): ViewerApp { } } - async function renderVerify(guid: string) { - propsVerify.innerHTML = ""; - if (!connected || !projectId || !guid) return; - const setBtn = (label: string, status: string, color: string) => { - const b = document.createElement("button"); - b.className = "file-btn"; b.textContent = label; - b.style.cssText = `font-size:11px;padding:2px 8px;border-color:${color}`; - b.onclick = async () => { - try { - await api.setVerification(projectId!, guid, { status }); - lbl.textContent = ` ${label}`; lbl.style.color = color; - setStatus(`element marked ${status}`); - } catch (e) { setStatus("verify failed: " + (e as Error).message); } - }; - return b; - }; - const row = document.createElement("div"); - row.style.cssText = "border-top:1px solid var(--line);padding-top:6px"; - row.innerHTML = `
Field verification
`; - const bar = document.createElement("div"); bar.style.cssText = "display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-top:3px"; - bar.append(setBtn("Installed", "installed", "#4a8cff"), setBtn("Verified", "verified", "#33d17a"), - setBtn("Deviation", "deviation", "#e2554a")); - const lbl = document.createElement("span"); lbl.className = "meta"; - bar.appendChild(lbl); - - // R22-PHOTO-CV — the front door for element-attached photos. The upload endpoint and its whole - // analysis stack (quality gate, change screening, detection) previously had NO caller in this - // app: reachable by API, unreachable by a person. `capture="environment"` makes a phone open the - // rear camera directly rather than the gallery, which is what someone standing at the element - // wants. - const photoIn = document.createElement("input"); - photoIn.type = "file"; photoIn.accept = "image/*"; photoIn.hidden = true; - photoIn.setAttribute("capture", "environment"); - const photoBtn = document.createElement("button"); - photoBtn.className = "file-btn"; photoBtn.textContent = "\u{1F4F7} Photo"; - photoBtn.style.cssText = "font-size:11px;padding:2px 8px"; - photoBtn.title = "Attach a field photo to this element"; - photoBtn.onclick = () => photoIn.click(); - const verdict = document.createElement("div"); - verdict.className = "meta"; verdict.style.cssText = "margin-top:4px;line-height:1.45"; - photoIn.onchange = async () => { - const f = photoIn.files?.[0]; if (!f) return; - photoIn.value = ""; // so re-picking the SAME file fires change again - verdict.textContent = "uploading\u2026"; - photoBtn.disabled = true; - try { - const res = await api.uploadVerificationPhoto(projectId!, guid, f, f.name || "photo.jpg"); - verdict.textContent = ""; - const lines = photoVerdict(res); - if (!lines.length) verdict.textContent = "photo attached"; - for (const ln of lines) { - const el = document.createElement("div"); - // textContent, never innerHTML: these strings carry server-derived text. - el.textContent = (ln.tone === "warn" ? "\u26A0 " : ln.tone === "ok" ? "\u2713 " : "\u00B7 ") + ln.text; - if (ln.tone === "warn") el.style.color = "#e2554a"; - verdict.appendChild(el); - } - setStatus(photoVerdictSummary(res) || "photo attached"); - } catch (e) { - verdict.textContent = "upload failed: " + (e as Error).message; - verdict.style.color = "#e2554a"; - } finally { photoBtn.disabled = false; } - }; - bar.append(photoBtn, photoIn); - row.appendChild(bar); row.appendChild(verdict); propsVerify.appendChild(row); - } + // R39-DECOMP-VIEWER ⑰ — field verification lives in `tools/verifySection.ts`. It was the only + // one of fourteen candidates that closed over ZERO sibling functions; the module header carries + // the measurement. Its deps are the ctx values it already read, narrowed to this one function. + const renderVerify = makeVerifySection({ api, connected, projectId, setStatus, host: propsVerify }); async function render5D(guid: string) { props5d.innerHTML = ""; diff --git a/apps/web/src/viewer/tools/verifySection.ts b/apps/web/src/viewer/tools/verifySection.ts new file mode 100644 index 00000000..13c7d9fe --- /dev/null +++ b/apps/web/src/viewer/tools/verifySection.ts @@ -0,0 +1,126 @@ +import type { ApiClient } from "../../api/client"; +import { photoVerdict, photoVerdictSummary } from "../../ui/photoVerdict"; + +/** + * R39-DECOMP-VIEWER ⑰ — **field verification**, out of `app.ts`. + * + * The three status buttons a person standing at an element presses (installed / verified / + * deviation) plus the element-attached field photo and the verdict its analysis returns. + * + * ## Chosen by measurement, and it was the only one that measured clean + * + * `app.ts` is not a class, so REL-4's "grep the `this.` refs first" rule has no `this.` to grep: + * the whole file is **one 2,445-line function**, `initViewerApp`, and everything inside it is a + * nested closure over shared state. The equivalent measurement is *how many sibling functions does + * the candidate close over* — a sibling reference is the thing that turns an extraction into a + * callback bag. Run over all 54 nested declarations, for the fourteen candidates of 25 lines or + * more: + * + * | candidate | lines | siblings closed over | + * |---|---|---| + * | `buildToolsPanel` | 696 | 14 | + * | `renderProps` | 167 | 2 | + * | `toolDivider` | 161 | 5 | + * | `disarmDraft` | 111 | 5 | + * | `selectByGuids` | 95 | 6 | + * | **`renderVerify`** | **67** | **0** | + * | `handleKey` | 80 | 12 | + * + * **One candidate in fourteen closes over nothing.** That is the same shape REL-4 recorded for + * `portal.ts` — *"the two persona homes … are named alike, take the same four arguments, and only + * one of them is a leaf"* — arrived at from the other direction, in a file with no methods to grep. + * + * Its five free variables are all data or a single reporter, and four of the five already travel on + * the typed `ViewerCtx`: `api`, `connected`, `projectId`, `setStatus`, plus the `propsVerify` DOM + * node. So the dependency object below is not a bag invented to make the move possible — it is the + * context that was already there, narrowed to what this one function reads. + * + * `photoVerdict` / `photoVerdictSummary` are module imports and travel with the code rather than + * crossing the seam. + * + * ## The ⑭ check + * + * ⑯ records that ⑭ looked like a text move and was not: state deliberately scoped outside + * `buildToolsPanel` stacked a listener per rebuild once it moved inside. Checked here before + * anything moved — this block **assigns to nothing declared outside itself**, and its only + * listeners (`onclick`, `onchange`) are on elements it creates fresh on every call, which it + * already clears with `innerHTML = ""` on entry. A per-call listener on a per-call element cannot + * accumulate. + */ +export type VerifySectionDeps = { + api: ApiClient; + connected: boolean; + projectId: string | null; + setStatus: (msg: string) => void; + /** The panel this section owns and clears on every render. */ + host: HTMLElement; +}; + +export function makeVerifySection(d: VerifySectionDeps) { + return async function renderVerify(guid: string) { + d.host.innerHTML = ""; + if (!d.connected || !d.projectId || !guid) return; + const setBtn = (label: string, status: string, color: string) => { + const b = document.createElement("button"); + b.className = "file-btn"; b.textContent = label; + b.style.cssText = `font-size:11px;padding:2px 8px;border-color:${color}`; + b.onclick = async () => { + try { + await d.api.setVerification(d.projectId!, guid, { status }); + lbl.textContent = ` ${label}`; lbl.style.color = color; + d.setStatus(`element marked ${status}`); + } catch (e) { d.setStatus("verify failed: " + (e as Error).message); } + }; + return b; + }; + const row = document.createElement("div"); + row.style.cssText = "border-top:1px solid var(--line);padding-top:6px"; + row.innerHTML = `
Field verification
`; + const bar = document.createElement("div"); bar.style.cssText = "display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-top:3px"; + bar.append(setBtn("Installed", "installed", "#4a8cff"), setBtn("Verified", "verified", "#33d17a"), + setBtn("Deviation", "deviation", "#e2554a")); + const lbl = document.createElement("span"); lbl.className = "meta"; + bar.appendChild(lbl); + + // R22-PHOTO-CV — the front door for element-attached photos. The upload endpoint and its whole + // analysis stack (quality gate, change screening, detection) previously had NO caller in this + // app: reachable by API, unreachable by a person. `capture="environment"` makes a phone open the + // rear camera directly rather than the gallery, which is what someone standing at the element + // wants. + const photoIn = document.createElement("input"); + photoIn.type = "file"; photoIn.accept = "image/*"; photoIn.hidden = true; + photoIn.setAttribute("capture", "environment"); + const photoBtn = document.createElement("button"); + photoBtn.className = "file-btn"; photoBtn.textContent = "\u{1F4F7} Photo"; + photoBtn.style.cssText = "font-size:11px;padding:2px 8px"; + photoBtn.title = "Attach a field photo to this element"; + photoBtn.onclick = () => photoIn.click(); + const verdict = document.createElement("div"); + verdict.className = "meta"; verdict.style.cssText = "margin-top:4px;line-height:1.45"; + photoIn.onchange = async () => { + const f = photoIn.files?.[0]; if (!f) return; + photoIn.value = ""; // so re-picking the SAME file fires change again + verdict.textContent = "uploading…"; + photoBtn.disabled = true; + try { + const res = await d.api.uploadVerificationPhoto(d.projectId!, guid, f, f.name || "photo.jpg"); + verdict.textContent = ""; + const lines = photoVerdict(res); + if (!lines.length) verdict.textContent = "photo attached"; + for (const ln of lines) { + const el = document.createElement("div"); + // textContent, never innerHTML: these strings carry server-derived text. + el.textContent = (ln.tone === "warn" ? "⚠ " : ln.tone === "ok" ? "✓ " : "· ") + ln.text; + if (ln.tone === "warn") el.style.color = "#e2554a"; + verdict.appendChild(el); + } + d.setStatus(photoVerdictSummary(res) || "photo attached"); + } catch (e) { + verdict.textContent = "upload failed: " + (e as Error).message; + verdict.style.color = "#e2554a"; + } finally { photoBtn.disabled = false; } + }; + bar.append(photoBtn, photoIn); + row.appendChild(bar); row.appendChild(verdict); d.host.appendChild(row); + }; +} diff --git a/docs/roadmap.md b/docs/roadmap.md index ffa3f363..8d62bb64 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2314,11 +2314,34 @@ server's report stays authoritative on the authored element. Wave 1 and its foll **R38-SYNC-SELECT** of the four children shipped; the other three are open, and archiving the parent took them with it — caught by `roadmapLanes.test.ts`, which names lane items with no entry left in the file. **A ✅ on a parent is not a claim about its children.** - Original heading — split by premise-check 2026-08-02** — R38-SYNC-2D3D. The plans are server-generated, but - the pipeline **discards element identity at bake time**: `drawings._bake_uncached` has - `shape.guid` in hand and keeps only `(cls, mesh)`, so `cut_baked` emits anonymous polylines and - `cut_baked_classed` adds back the class but never the GUID. Nothing in a plan can name what it - draws. Hence: + Original heading — split by premise-check 2026-08-02** — R38-SYNC-2D3D. + + ✅ **THE DEFECT THIS ENTRY DESCRIBES IS FIXED, END TO END — corrected 2026-09-05.** The text below + said the pipeline *"discards element identity at bake time"*, that `_bake_uncached` *"keeps only + `(cls, mesh)`"*, and that **"nothing in a plan can name what it draws."** Measured against the + tree, all three are now false: + + * `services/data/src/aec_data/drawings.py` — `_bake_uncached` returns `(guid, ifc_class, mesh)`, + and its own docstring credits R38-PLAN-IDENTITY for it: *"it used to be dropped on the floor."* + * `cut_baked_guided` emits `(guid, ifc_class, polyline)` and is **called in production**, not only + from tests — the plan renderer uses it, with a comment stating why identity must not be + mode-dependent: *"a plan whose linework forgets its elements in one rendering mode would make + selection sync a mode-dependent feature."* + * The SVG carries `data-guid` per polyline and `apps/web/src/viewer/planPane.ts` selects on it — + `apps/web/src/viewer/planPane.test.ts` asserts each twin keeps its guid. + * Held by `services/api/test_plan_identity.py` and `services/api/test_pipeline_scales.py`. + + `cut_baked` and `cut_baked_classed` still return bare and class-only polylines, which the entry + read as the gap. They are the plain and poché variants and are **correct as they are** — the + identity-carrying path is a third function beside them, not a replacement for them. *The entry + described two functions accurately and drew the wrong conclusion from them, because it never + looked for a third.* + + ⚠️ **What is left cannot be acted on, and that is now the real defect in this entry.** It claims + three of four children are open and **names none of them** — `R38-SYNC-SELECT` is the only child + named anywhere in this file. An item that says work remains without saying what work is not a + backlog entry, it is a reminder that somebody once knew. **Re-derive the children or close it.** + Hence: - Consumes: R24-ELEMENT-CARD ② and R31-CITE-HIGHLIGHT (both already coded) as the "everything about this thing" surface. diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index 54aa3a70..ce4bb6c5 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -124,7 +124,7 @@ def check(label, ok, detail=""): #: construction, and the three slices' friction was spent silently. Pinned at 2_570, the file's #: EXACT measured size — ⑯ set 2_571 against a file that already measured 2,570, which is the #: off-by-one MAX_SLACK is deliberately loose enough to tolerate. - "apps/web/src/viewer/app.ts": 2_570, # detailing -> tools/detailingSection.ts (2_630 -> 2_571). # content + family library -> tools/contentLibrarySection.ts (2_757 -> 2_630). # interactive annotation -> tools/annotationSection.ts (2_865 -> 2_757). The file was AT this pin with zero headroom, which is why the roadmap row calling it "97%, ~136 lines" was corrected in the same pass. # CAD command line -> cadBar.ts, which also gained the R29 prompt loop (2_885 -> 2_865). The 39-line inline block became a 10-line mount call; the ratchet is why the interactive mode went into a new module instead of on top of app.ts. # clash rail -> tools/clashPanel.ts (2_944 -> 2_885) + "apps/web/src/viewer/app.ts": 2_508, # R39-DECOMP-VIEWER (17): field verification -> tools/verifySection.ts (2_571 -> 2_508). The chain starts at 2_571 and the file measured 2_570 when this slice began: ONE line left with no slice recording it, which is the drift this row's roadmap cell already documents from an unrelated lane committing to the same shared file. This slice removed 62 of the 63; the 63rd is that stray, absorbed here rather than hidden by starting the entry at a number nobody can reproduce. The gate is what refused the discontinuity. app.ts is ONE 2,445-line function, so REL-4's "grep the this. refs first" rule has no this. to grep; the equivalent is how many SIBLING closures a candidate captures, and of fourteen candidates >=25 lines exactly ONE captured zero. buildToolsPanel captures 14, handleKey 12, selectByGuids 6 — every other move would have been a callback bag. Four of renderVerify's five free variables already travelled on the typed ViewerCtx, so the deps object is that context narrowed, not one invented to make the move possible. # detailing -> tools/detailingSection.ts (2_630 -> 2_571). # content + family library -> tools/contentLibrarySection.ts (2_757 -> 2_630). # interactive annotation -> tools/annotationSection.ts (2_865 -> 2_757). The file was AT this pin with zero headroom, which is why the roadmap row calling it "97%, ~136 lines" was corrected in the same pass. # CAD command line -> cadBar.ts, which also gained the R29 prompt loop (2_885 -> 2_865). The 39-line inline block became a 10-line mount call; the ratchet is why the interactive mode went into a new module instead of on top of app.ts. # clash rail -> tools/clashPanel.ts (2_944 -> 2_885) # Pinned at its EXACT measured size, not above it. qaSection.ts became the file every reach fix # lands in and reached 1,373 lines while unpinned - the same accumulation app.ts and client.ts # already have entries for. Pinned before it needs splitting rather than after: a ratchet added From 8c9314da788b96ae692c2839284c7eba755525e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:45:56 +0000 Subject: [PATCH 21/23] =?UTF-8?q?SCALE-SEAM=20(102)=20=E2=80=94=20counterp?= =?UTF-8?q?arty=20risk,=20client.ts=20603=20->=20589?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prequalScores`, `coiExpiry` and `lienExposure` out to `apps/web/src/api/counterpartyRisk.ts` — which trade partner is a risk on this job, and why: are they qualified, are they insured, and do we owe them enough to be liened. THE WITNESS IS THAT THE SEAM DISAGREES WITH THE ROUTE PREFIX. Two sit under /prequal/ and one under /payapp/lien-exposure, so a prefix grouping would have SPLIT the set — while all three return per-counterparty rows carrying a verdict about that counterparty: risk_band + flags, days-to-expiry, exposure + vendors_at_risk. That is the affirmative form of a rule this repo has only recorded negatively: (85) rejected "they are all multipart uploads", (89) "they are all module records", annotate.ts "they all call editIfc" after measuring 24 recipes across nine categories. A shared mechanism is not a question. Here the mechanism argues AGAINST the grouping and the shape of the returns argues for it, so the evidence is not something a name could have produced. benchmarkResponseRates sits immediately above them and STAYED: it returns RFI/submittal turnaround and names no counterparty at all — it measures how responsive the process is. Adjacency is not a relationship, which REL-4 recorded three separate times this cycle. Found on the way out: an orphaned PrequalScores type import, the same residue slice (101) left with EnergyResult. Ratchet 603 -> 589, mutation-checked at 588 (both the growth assertion and the history-chain assertion fire). 2072 web tests, tsc + eslint clean, doc gates green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 21 ++++++++++ apps/web/src/api/client.ts | 22 ++--------- apps/web/src/api/counterpartyRisk.ts | 59 ++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- services/api/test_file_sizes.py | 2 +- 5 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/api/counterpartyRisk.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da3d9dee..1437e3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — SCALE-SEAM (102): counterparty risk + +`prequalScores`, `coiExpiry` and `lienExposure` out of `client.ts` (**603 → 589**) into +`apps/web/src/api/counterpartyRisk.ts` — *which trade partner is a risk on this job, and why.* + +**The witness is that the seam disagrees with the route prefix.** Two sit under `/prequal/`, one +under `/payapp/lien-exposure`, so grouping by prefix would have **split** the set — while all three +return per-counterparty rows carrying a verdict about that counterparty: `risk_band` + `flags`, +days-to-expiry, `exposure` + `vendors_at_risk`. That is the affirmative form of a rule this file has +only ever recorded negatively — (85) rejected "they are all multipart uploads", (89) "they are all +module records", `annotate.ts` "they all call `editIfc`" after measuring 24 recipes across nine +categories. A shared mechanism is not a question; here the mechanism argues *against* the grouping +and the shape of the returns argues for it, so the evidence is not something a name could produce. + +`benchmarkResponseRates` sits immediately above them and **stayed**: it returns RFI/submittal +turnaround and names no counterparty at all — it measures how responsive the *process* is. Adjacency +is not a relationship. + +Found on the way out: an orphaned `PrequalScores` type import, the same residue slice (101) left +with `EnergyResult`. + ## Unreleased — Roadmap truth pass + R39-DECOMP-VIEWER ⑰ **Two stale claims corrected, both found by testing the entry against the tree rather than reading it.** diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index be845d18..277d6238 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -33,6 +33,7 @@ import { withSync } from "./sync"; import { withCost } from "./cost"; import { withRoutines } from "./routines"; import { withContracts } from "./contracts"; +import { withCounterpartyRisk } from "./counterpartyRisk"; import { withDesignOptions } from "./designOptions"; import { withFinance } from "./finance"; import { withLibrary } from "./library"; @@ -65,13 +66,13 @@ import type { DisciplineTree, ModulePin, RoomAllocation, PropMapRule, SpecManual, WorkItem, VitalsPayload, - DiligenceReadiness, MasterBuilderBrief, PrequalScores, + DiligenceReadiness, MasterBuilderBrief, SpineTraceability } from "./types"; // Transport (baseUrl, token, json/_pdfPost/url/health) lives in HttpCore; ApiClient adds the typed // domain methods below. Every `api.method()` call site is unchanged by the split. -export class ApiClient extends withDesignPerformance(withDetailing(withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore))))))))))))))))))))))))))))))))))))))))))))) { +export class ApiClient extends withCounterpartyRisk(withDesignPerformance(withDetailing(withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore)))))))))))))))))))))))))))))))))))))))))))))) { /** * R22-PHOTO-CV — attach a field photo to an element and get the server's read on it back. * @@ -378,22 +379,7 @@ export class ApiClient extends withDesignPerformance(withDetailing(withAnnotate( overdue: number; overdue_pct: number } }>(`/benchmarks/response-rates`); } - // --- Tier 2/3: prequal, lien exposure, accounting, carbon, code check, pricing --------------- - prequalScores(pid: string, projectSize?: number) { - const qs = projectSize ? `?project_size=${projectSize}` : ""; - return this.json(`/projects/${pid}/prequal/scores${qs}`); - } - coiExpiry(pid: string, soonDays = 30) { - return this.json<{ expired: { vendor?: string; coverage_type?: string; expires: string; days: number }[]; - expiring_soon: { vendor?: string; coverage_type?: string; expires: string; days: number }[]; - expired_count: number; expiring_count: number }>(`/projects/${pid}/prequal/coi-expiry?soon_days=${soonDays}`); - } - lienExposure(pid: string) { - return this.json<{ vendors: { vendor: string; billed: number; paid: number; retainage: number; - waived_unconditional: number; waived_conditional: number; exposure: number; status: string }[]; - total_lien_exposure: number; vendors_at_risk: string[]; message?: string | null }>( - `/projects/${pid}/payapp/lien-exposure`); - } + // --- Tier 2/3: accounting, carbon, code check, pricing (prequal + lien exposure -> counterpartyRisk.ts, SCALE-SEAM (102)) --- // --- design lifecycle (RIBA/AIA phases + itemized soft costs) --------------- lifecycle(pid: string) { return this.json<{ count: number; seeded: boolean; diff --git a/apps/web/src/api/counterpartyRisk.ts b/apps/web/src/api/counterpartyRisk.ts new file mode 100644 index 00000000..23b0f146 --- /dev/null +++ b/apps/web/src/api/counterpartyRisk.ts @@ -0,0 +1,59 @@ +import { HttpCore } from "./httpCore"; +import type { PrequalScores } from "./types"; + +/** + * Counterparty risk — **which trade partner on this job is a risk, and why.** + * + * SCALE-SEAM (102). Three angles on one question, each returning per-counterparty rows carrying a + * verdict about that counterparty: + * + * | method | rows | the verdict it carries | + * |---|---|---| + * | `prequalScores` | `subs[]` `{company, trade, …}` | `score`, `risk_band`, `flags` — fit to be here at all | + * | `coiExpiry` | `expired[]` / `expiring_soon[]` `{vendor, …}` | `days` to expiry — is their cover still valid | + * | `lienExposure` | `vendors[]` `{vendor, …}` | `exposure`, `status`, `vendors_at_risk` — money that can become a lien | + * + * ## The witness is that the seam DISAGREES with the route prefix + * + * `prequalScores` and `coiExpiry` sit under `/prequal/`; `lienExposure` sits under + * `/payapp/lien-exposure`. **Grouping by prefix would split this set** — which is the affirmative + * form of the rule the earlier slices had to learn negatively: (85) rejected "they are all + * multipart uploads", (89) rejected "they are all module records", and `annotate.ts` rejected "they + * all call `editIfc`" after measuring 24 recipes across nine categories. A shared prefix is a + * mechanism. Here the prefix actively argues against the grouping and the *shape of the returns* + * argues for it, so the evidence is not something a name could have produced. + * + * The three answer the question a GC asks before and during a job, from the three places it can go + * wrong: **are they qualified, are they insured, and do we owe them enough to be liened.** + * + * ## What did NOT come, and it sits immediately above them + * + * `benchmarkResponseRates` is the previous method in `client.ts` and stayed. It returns RFI and + * submittal turnaround — `avg_turnaround_days`, `overdue_pct` — and **names no counterparty at + * all**: it measures how responsive the *process* is, not who is a risk. Adjacency is not a + * relationship; REL-4 recorded three separate times this cycle that it looked like one. + */ +type Ctor = new (...args: any[]) => T; + +export function withCounterpartyRisk>(Base: TBase) { + return class CounterpartyRisk extends Base { + /** Prequalification scores per sub — 0–100 with the factors behind it, a risk band and flags. */ + prequalScores(pid: string, projectSize?: number) { + const qs = projectSize ? `?project_size=${projectSize}` : ""; + return this.json(`/projects/${pid}/prequal/scores${qs}`); + } + /** Certificates of insurance already expired, and those inside `soonDays`, per vendor. */ + coiExpiry(pid: string, soonDays = 30) { + return this.json<{ expired: { vendor?: string; coverage_type?: string; expires: string; days: number }[]; + expiring_soon: { vendor?: string; coverage_type?: string; expires: string; days: number }[]; + expired_count: number; expiring_count: number }>(`/projects/${pid}/prequal/coi-expiry?soon_days=${soonDays}`); + } + /** Lien exposure per vendor — billed vs paid vs retainage against the waivers actually on file. */ + lienExposure(pid: string) { + return this.json<{ vendors: { vendor: string; billed: number; paid: number; retainage: number; + waived_unconditional: number; waived_conditional: number; exposure: number; status: string }[]; + total_lien_exposure: number; vendors_at_risk: string[]; message?: string | null }>( + `/projects/${pid}/payapp/lien-exposure`); + } + }; +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d62bb64..c2b2cdb2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3283,7 +3283,7 @@ verbs, with a command bar as the escape hatch to everything); and **role-shaped banner is renamed UNFILED → STAYING and now says exactly that; the next slices work the 126, and there is no map for them yet. - **(88)–(101) took seventy-two of those 126 — 54 remain, and there is STILL no map.** A new + **(88)–(102) took seventy-five of those 126 — 51 remain, and there is STILL no map.** A new `apps/web/src/api/operations.ts` holds the operate-phase cluster: *the building is built and running.* Maintenance (`cmmsGeneratePm`, `cmmsKpis`), consumption (`energyActual`, `energyBenchmarkStatus`, `esgSummary`), condition and capital (`fcaIndex`, `fcaPortfolio`, diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index ce4bb6c5..bbb2a3a8 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 603, # SCALE-SEAM (101): DESIGN-PHASE PREDICTED PERFORMANCE — what will this design consume and emit, and does it comply? (642 -> 603). energy + energyModel + energyExportUrl + carbonComplianceReport + projectCarbon to a new designPerformance.ts, and benchmarkCosts to cost.ts. THE SEAM WAS DRAWN BY EARLIER SLICES, NOT THIS ONE, which is the whole witness: operations.ts's own header records why projectCarbon did not go there ("EMBODIED carbon ... a design-phase estimate. The GHG figures in esgSummary come from metered utility data. Same molecule, opposite ends of the asset life"), models.ts records the parallel call for /energy, and operations.ts DOES hold /energy/actual. Prediction vs measurement, committed to twice independently. NOT NAMED environmental.ts on purpose: that names the TOPIC both halves share, and would re-blur the seam operations.ts drew. A PLANNED benchmarks.ts WAS ABANDONED ON EVIDENCE: cost.ts already held unitRates (/benchmarks/unit-rates) and schedule.ts holds benchmarksPullPlanning (/benchmarks/pull-planning), so the repo already distributes that prefix by what each method ANSWERS — grouping the remaining three by route would have contradicted two live placements. benchmarkResponseRates and spaceUtilBenchmarks STAYED: no mixin owns their question, and inventing a home on a guess is what produced this file's UNFILED banner. # SCALE-SEAM (100): THE ELEMENT-CONNECTION PAIR — what is physically joined to what, and record a joint? (648 -> 642). elementConnections + connectElements to model.ts. BOUND BY THE BACKEND NAMING ITS OWN WRITER (the (96) shape): the /element-connections route docstring says "Author edges with the connect_elements recipe (POST /edit with {guid_a, guid_b})". DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS STATED SO: model.ts owns modelGraphStats (counts the IFC relationship graph BY RELATION, and IfcRelConnectsElements is one) and graphNeighbors (walks it), so this is one relation of that graph plus its verb — a SPECIALISATION, not an identity. REJECTED ON CHECKABLE GROUNDS: connections.ts is DATA-SOURCE connections (SQL/ACC/Procore), sharing only the English word — (97) same collision, in a destination; elements.ts holds element ATTRIBUTES, and a relationship between two elements is not an attribute of either. addBasePlate/addShearTab did NOT come despite sharing connections.py: a backend module is a HOW, and they author PHYSICAL assemblies, not IfcRelConnectsElements edges. # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 589, # SCALE-SEAM (102): COUNTERPARTY RISK — which trade partner is a risk on this job, and why. prequalScores + coiExpiry + lienExposure -> counterpartyRisk.ts (603 -> 589). THE WITNESS IS THAT THE SEAM DISAGREES WITH THE ROUTE PREFIX: two sit under /prequal/ and one under /payapp/lien-exposure, so a prefix grouping would have SPLIT the set, while the returns all carry per-counterparty rows with a verdict about that counterparty (risk_band+flags / days-to-expiry / exposure+vendors_at_risk). That is the affirmative form of the rule (85), (89) and annotate.ts each had to learn negatively - a shared mechanism is not a question. benchmarkResponseRates sits immediately above and stayed: it names no counterparty at all, it measures how responsive the PROCESS is. # SCALE-SEAM (101): DESIGN-PHASE PREDICTED PERFORMANCE — what will this design consume and emit, and does it comply? (642 -> 603). energy + energyModel + energyExportUrl + carbonComplianceReport + projectCarbon to a new designPerformance.ts, and benchmarkCosts to cost.ts. THE SEAM WAS DRAWN BY EARLIER SLICES, NOT THIS ONE, which is the whole witness: operations.ts's own header records why projectCarbon did not go there ("EMBODIED carbon ... a design-phase estimate. The GHG figures in esgSummary come from metered utility data. Same molecule, opposite ends of the asset life"), models.ts records the parallel call for /energy, and operations.ts DOES hold /energy/actual. Prediction vs measurement, committed to twice independently. NOT NAMED environmental.ts on purpose: that names the TOPIC both halves share, and would re-blur the seam operations.ts drew. A PLANNED benchmarks.ts WAS ABANDONED ON EVIDENCE: cost.ts already held unitRates (/benchmarks/unit-rates) and schedule.ts holds benchmarksPullPlanning (/benchmarks/pull-planning), so the repo already distributes that prefix by what each method ANSWERS — grouping the remaining three by route would have contradicted two live placements. benchmarkResponseRates and spaceUtilBenchmarks STAYED: no mixin owns their question, and inventing a home on a guess is what produced this file's UNFILED banner. # SCALE-SEAM (100): THE ELEMENT-CONNECTION PAIR — what is physically joined to what, and record a joint? (648 -> 642). elementConnections + connectElements to model.ts. BOUND BY THE BACKEND NAMING ITS OWN WRITER (the (96) shape): the /element-connections route docstring says "Author edges with the connect_elements recipe (POST /edit with {guid_a, guid_b})". DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS STATED SO: model.ts owns modelGraphStats (counts the IFC relationship graph BY RELATION, and IfcRelConnectsElements is one) and graphNeighbors (walks it), so this is one relation of that graph plus its verb — a SPECIALISATION, not an identity. REJECTED ON CHECKABLE GROUNDS: connections.ts is DATA-SOURCE connections (SQL/ACC/Procore), sharing only the English word — (97) same collision, in a destination; elements.ts holds element ATTRIBUTES, and a relationship between two elements is not an attribute of either. addBasePlate/addShearTab did NOT come despite sharing connections.py: a backend module is a HOW, and they author PHYSICAL assemblies, not IfcRelConnectsElements edges. # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being From c7a58ae7627b92edf20a29035be89c6c0db75268 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 06:13:03 +0000 Subject: [PATCH 22/23] Review fixes on #442, and a gate for the number that keeps drifting Four CodeRabbit findings, all verified against the tree first. The second was self-referential and is the interesting one. This PR corrected CLAUDE.md's stale app.ts line count from "3,444" to 2,570 -- and then R39-DECOMP-VIEWER (17), in the same PR, took the file to 2,508. The paragraph diagnosing numbers that decay toward the conclusion they support decayed toward its own conclusion before it merged, and a review bot found it rather than the author. So test_claude_md_gates.py now checks that number against the ratchet. The existing gate asks whether a cited FILE exists; this asks whether a cited NUMBER is still true. It is cheap only because the value is not really CLAUDE.md's to hold: test_file_sizes.py already pins the same file at an exact size, so the prose is a COPY of a gated value, and a copy is what drifts. Mutation-checked both ways -- a wrong figure fails, and a REWORDED SENTENCE fails too rather than passing on two Nones, which is the vacuous-green failure that file's own header calls worse than no gate at all. R38-SYNC-2D3D is CLOSED, with its children named. The entry claimed three of four children were open and named none of them. Re-derived by grepping the tree rather than reading the file that was already wrong: R38-SYNC-SELECT, R38-SYNC-VIEW, R38-PLAN-TRANSFORM and R38-PLAN-IDENTITY, and docs/roadmap-completed.md carries a check for EACH. The 2026-08-10 un-archive was mechanically right -- a lane row pointed at nothing -- and then inherited the restored text's open count as though the restore had verified it. Nothing had: R38-PLAN-IDENTITY was marked done that same day, in the archive the entry was being pulled out of. Un-archiving restores an entry's TEXT, not its truth. NAMING THOSE CHILDREN MADE THEM ITEMS, AND THE GATES SAID SO. Four bold item codes in roadmap.md put three new orphans in roadmapLanes.test.ts and a stale open-vs-implemented pair in roadmapStale.test.ts. The fourth escaped only because its line happened to contain a check mark in prose. All four now carry an explicit marker -- passing by accident and passing by construction look identical until something moves. Also: completed the verbless counterparty-risk changelog entry, and gave the bare `wc -l` in CLAUDE.md's re-measure command its argument, since an instruction to verify that hangs on stdin is one nobody runs twice. Verified: 2072 web tests, tsc + eslint clean, roadmap lane/stale gates green, citation gate green and mutation-checked twice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 34 +++++++++++++++++- CLAUDE.md | 20 ++++++++--- docs/roadmap.md | 48 +++++++++++++++++++------ services/api/test_claude_md_gates.py | 53 ++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1437e3ec..b17bd292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,41 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — review fixes, and a gate for the number that keeps drifting + +**Four review findings on the same pull request, and the second one was self-referential.** The PR +corrected `CLAUDE.md`'s stale `apps/web/src/viewer/app.ts` line count from "3,444" to **2,570** — +and then, in the same PR, R39-DECOMP-VIEWER ⑰ took the file to **2,508**. The paragraph diagnosing +*numbers that decay toward the conclusion they support* decayed toward its own conclusion before it +merged, and a review bot found it rather than the author. + +* **`services/api/test_claude_md_gates.py` now checks that number against the ratchet.** The + existing gate asks whether a cited FILE exists; this asks whether a cited NUMBER is still true. + It is cheap only because the value is not really `CLAUDE.md`'s to hold — `test_file_sizes.py` + already pins the same file at an exact size, so the prose is a **copy of a gated value, and a copy + is what drifts**. Mutation-checked both ways: a wrong figure fails, and a *reworded sentence* fails + too rather than passing on two `None`s, which is the vacuous-green failure that file's own header + calls worse than no gate at all. +* **R38-SYNC-2D3D is CLOSED, with its children named.** The entry said three of four children were + open and named none of them. Re-derived by grepping the tree rather than reading the file that was + already wrong: `R38-SYNC-SELECT`, `R38-SYNC-VIEW`, `R38-PLAN-TRANSFORM` and `R38-PLAN-IDENTITY`, + and `docs/roadmap-completed.md` carries a ✅ for **each**. The 2026-08-10 un-archive was + mechanically right — a lane row pointed at nothing — and inherited the restored text's open count + as though the restore had verified it. Nothing had; R38-PLAN-IDENTITY was marked ✅ *that same + day*, in the archive the entry was being pulled out of. **Un-archiving restores an entry's text, + not its truth.** +* **Naming those children made them items, and the gates said so.** Adding four bold item codes to + `docs/roadmap.md` put three new orphans in `apps/web/src/shell/roadmapLanes.test.ts` and a stale + open-vs-implemented pair in `apps/web/src/shell/roadmapStale.test.ts`. The fourth escaped only + because its line happened to contain a ✅ in prose. All four now carry an explicit ✅ marker — + *passing by accident and passing by construction look identical until something moves.* +* Completed the verbless counterparty-risk entry below, and gave the bare `wc -l` in `CLAUDE.md`'s + re-measure command its argument — an instruction to verify that hangs on stdin is one nobody runs + twice. + ## Unreleased — SCALE-SEAM (102): counterparty risk -`prequalScores`, `coiExpiry` and `lienExposure` out of `client.ts` (**603 → 589**) into +**Extracts** `prequalScores`, `coiExpiry` and `lienExposure` out of `client.ts` (**603 → 589**) into `apps/web/src/api/counterpartyRisk.ts` — *which trade partner is a risk on this job, and why.* **The witness is that the seam disagrees with the route prefix.** Two sit under `/prequal/`, one diff --git a/CLAUDE.md b/CLAUDE.md index 9870e804..23beed5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,16 +219,28 @@ never contingent on the npm question. **What an agent working in `apps/web/src/viewer` should know.** **Sixty-seven** commits have touched that directory since extraction began on 2026-08-06, and `apps/web/src/viewer/app.ts` has gone from 5,064 lines to -**2,570** — largely R39-DECOMP-VIEWER, which is the same decomposition the extraction plan asks for and is being +**2,508** — largely R39-DECOMP-VIEWER, which is the same decomposition the extraction plan asks for and is being done here first. That is good and it is also divergence: every one of those commits is a change the swap will have to reconcile. So: *(Both numbers were stale and are re-measured 2026-09-05 — this said "Twenty-eight commits" and "3,444 lines". Unlike the Node and Python drifts above, **this one moved in the direction that strengthens the argument**: - more than twice the commits and another 874 lines out. A number that decays toward the conclusion it supports + more than twice the commits and another 936 lines out. A number that decays toward the conclusion it supports is the hardest kind to notice, because nothing it predicts ever looks wrong. Re-measure with - `git log --oneline --since=2026-08-06 -- apps/web/src/viewer | wc -l` and `wc -l`, never by reading this line — - the same rule the two version notes above had to learn the expensive way.)* + `git log --oneline --since=2026-08-06 -- apps/web/src/viewer | wc -l` and + `wc -l apps/web/src/viewer/app.ts`, never by reading this line — the same rule the two version notes above + had to learn the expensive way.)* + + *(**And the line count above was stale again within the same pull request.** The correction first written + here said **2,570**; slice ⑰ of R39-DECOMP-VIEWER landed in that same PR and made it **2,508**, so the + paragraph diagnosing decay-toward-the-conclusion decayed toward its own conclusion before it was merged, and + a review bot found it rather than the author. Two things follow. **A number and the change that moves it must + land in the same edit, not the same commit** — "I will update the note after the slice" is a promise made + inside the window where it is already wrong. And the authority is `services/api/test_file_sizes.py`, which + pins `apps/web/src/viewer/app.ts` at an exact size and fails the build when it drifts; **this line is a + narrative copy of a number that has a gate, and a copy is what drifts.** Read the pin, not the prose. + The `wc -l` in the re-measure command above was also bare and would have waited on stdin — an instruction to + verify that hangs is an instruction nobody runs twice.)* - **Keep shipping.** Blocking this roadmap for the extraction would make the extraction expensive and it would die. Landing viewer work here is the correct default. diff --git a/docs/roadmap.md b/docs/roadmap.md index c2b2cdb2..dd5d4d46 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2310,11 +2310,14 @@ server's report stays authoritative on the authored element. Wave 1 and its foll ### Wave 3 — model and documents in one room *(Lane B + E)* -- ◧ **split by premise-check 2026-08-02 — un-archived 2026-08-10, the ✅ was wrong.** Only - **R38-SYNC-SELECT** of the four children shipped; the other three are open, and archiving the - parent took them with it — caught by `roadmapLanes.test.ts`, which names lane items with no - entry left in the file. **A ✅ on a parent is not a claim about its children.** - Original heading — split by premise-check 2026-08-02** — R38-SYNC-2D3D. +- ✅ **CLOSED 2026-09-05 — all four children shipped; the defect this entry described is fixed.** + *(Previously ◧, carrying: "split by premise-check 2026-08-02 — un-archived 2026-08-10, the ✅ was + wrong. Only **R38-SYNC-SELECT** of the four children shipped; the other three are open, and + archiving the parent took them with it — caught by `roadmapLanes.test.ts`, which names lane items + with no entry left in the file. **A ✅ on a parent is not a claim about its children.**" That + reasoning was correct on the day it was written and is kept here for it; the count it carried was + not, see below.)* + **Original heading — split by premise-check 2026-08-02** — R38-SYNC-2D3D. ✅ **THE DEFECT THIS ENTRY DESCRIBES IS FIXED, END TO END — corrected 2026-09-05.** The text below said the pipeline *"discards element identity at bake time"*, that `_bake_uncached` *"keeps only @@ -2337,11 +2340,36 @@ server's report stays authoritative on the authored element. Wave 1 and its foll described two functions accurately and drew the wrong conclusion from them, because it never looked for a third.* - ⚠️ **What is left cannot be acted on, and that is now the real defect in this entry.** It claims - three of four children are open and **names none of them** — `R38-SYNC-SELECT` is the only child - named anywhere in this file. An item that says work remains without saying what work is not a - backlog entry, it is a reminder that somebody once knew. **Re-derive the children or close it.** - Hence: + ✅ **THE THREE UNNAMED CHILDREN, RE-DERIVED — and all three had already shipped.** This entry + claimed three of four children were open and **named none of them**; `R38-SYNC-SELECT` was the + only child named anywhere in this file. An item that says work remains without saying what work + is not a backlog entry, it is a reminder that somebody once knew. So the standing instruction here + was *re-derive the children or close it* — done, by grepping `R38-[A-Z0-9-]*` across the tree + rather than by reading this file, which is the source that was already wrong. The four children + are `R38-SYNC-SELECT`, `R38-SYNC-VIEW`, `R38-PLAN-TRANSFORM` and `R38-PLAN-IDENTITY`, and + `docs/roadmap-completed.md` carries a ✅ for **each** — and each is marked ✅ *here* too, because + naming a child in this file makes it an item this file's own gates must account for: + + * ✅ **R38-SYNC-SELECT ③** — shipped v0.3.829, the one child this entry already credited. + * ✅ **R38-SYNC-VIEW ③** — storey, pan and zoom shipped first; cursor sync closed once + PLAN-TRANSFORM unblocked it. `apps/web/src/viewer/planPane.ts` + + `apps/web/src/viewer/planTransform.ts`. + * ✅ **R38-PLAN-TRANSFORM** — shipped v0.3.928. The plan SVG root serialises the six terms of its own + transform, and `services/api/test_plan_transform.py` asserts the pixel → world → pixel **round + trip** rather than the presence of the attributes. + * ✅ **R38-PLAN-IDENTITY** — recorded on **2026-08-10** as *already done when the entry was + written*. + + **The un-archive was mechanically right and factually stale on the same day.** It restored a + parent because a lane row pointed at nothing — a real defect, correctly fixed — and then inherited + the restored text's open/closed count as though the restore had checked it. Nothing had: + R38-PLAN-IDENTITY was marked ✅ on that same 2026-08-10, in the very archive this entry was being + pulled back out of. **Un-archiving restores an entry's TEXT, not its truth.** Any count it carries + is as old as the day it was archived, and here re-deriving it cost one grep against the tree. + *That is the same failure as the defect description above — both were true once, neither was + re-measured, and one of them then blocked three other items as a phantom prerequisite.* + + Its consuming surface, kept below, is coded and closes with it: - Consumes: R24-ELEMENT-CARD ② and R31-CITE-HIGHLIGHT (both already coded) as the "everything about this thing" surface. diff --git a/services/api/test_claude_md_gates.py b/services/api/test_claude_md_gates.py index 71bf3751..4fce2ed8 100644 --- a/services/api/test_claude_md_gates.py +++ b/services/api/test_claude_md_gates.py @@ -253,6 +253,59 @@ def resolve(name): " unenforced. Do not cite a gate that does not exist — that is the bug this test caught." ) +# --------------------------------------------------------------------------------------------- +# CLAUDE.md quotes a line count that a GATE already owns. Added 2026-09-05. +# +# The viewer section states the current size of `apps/web/src/viewer/app.ts` as evidence for how far +# the decomposition has run. That exact number drifted three times: it sat at "3,444" for weeks, was +# corrected to "2,570", and was stale again **inside the same pull request** because a decomposition +# slice landed beside the correction and a review bot, not the author, noticed. +# +# The check above asks whether a cited FILE exists. This asks whether a cited NUMBER is still true, +# which is the harder half and the one that keeps failing here. It is cheap only because the number +# is not really CLAUDE.md's to hold: `test_file_sizes.py` pins the same file at an exact size and +# fails the build when it moves, so the prose is a COPY of a gated value, and a copy is what drifts. +# +# The rule this encodes is narrow on purpose: a doc may quote a number that a gate owns, but the two +# have to be compared by something. Prose that reasons ABOUT the pin ("pins its exact size", "2,507" +# as a mutation-check witness) is not a claim about today's file, so only the figure attached to the +# "has gone from 5,064 lines to N" sentence is read. +CLAUDE_MD = os.path.join(ROOT, "CLAUDE.md") +PIN_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_file_sizes.py") + +with open(CLAUDE_MD, encoding="utf-8") as fh: + _claude = fh.read() +with open(PIN_FILE, encoding="utf-8") as fh: + _pins = fh.read() + +_stated = re.search( + r"`apps/web/src/viewer/app\.ts`\s+has gone from\s+[\d,_]+\s+lines to\s*\n?\s*\*\*([\d,_]+)\*\*", + _claude, +) +_pinned = re.search( + r'"apps/web/src/viewer/app\.ts"\s*:\s*([\d_]+)', _pins, +) + +# Both halves must be FOUND, not just agree. If either pattern stops matching — the sentence is +# reworded, the ratchet key is renamed — this check would otherwise pass on two Nones, which is the +# vacuous-green failure this file's own header calls worse than no gate. +check( + "CLAUDE.md's app.ts sentence and the ratchet pin are both readable", + bool(_stated) and bool(_pinned), + f"prose={'found' if _stated else 'NOT FOUND'} · pin={'found' if _pinned else 'NOT FOUND'}", +) + +if _stated and _pinned: + _n_prose = int(_stated.group(1).replace(",", "").replace("_", "")) + _n_pin = int(_pinned.group(1).replace("_", "")) + check( + "CLAUDE.md's stated app.ts size matches the size the ratchet pins", + _n_prose == _n_pin, + f"CLAUDE.md says {_n_prose:,}; test_file_sizes.py pins {_n_pin:,}" + + ("" if _n_prose == _n_pin else + " — update the prose in the SAME EDIT as the slice that moved it, not the same commit"), + ) + if FAILED: print("FAILED:", ", ".join(FAILED)) sys.exit(1) From e1f1a054d303c208055716082eea71e34f1756fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 06:44:01 +0000 Subject: [PATCH 23/23] =?UTF-8?q?SCALE-SEAM=20(103)=20=E2=80=94=20acceptan?= =?UTF-8?q?ce=20gates,=20client.ts=20589=20->=20572?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permitReadiness, diligenceReadiness, handoverAcceptance and validate -> acceptanceGates.ts: "will an outside party accept this project, and what is blocking it". NEITHER THE ROUTE NOR THE AUDIENCE PRODUCES THIS GROUPING. The four routes are /permit/readiness, /diligence/readiness, /handover/acceptance and /validate -- four different prefixes, so a prefix grouping takes one each and the set never forms; the shared leaf word "readiness" reaches only two of the four. The deciding parties are an AHJ, an investor, an owner and an IDS checker -- four different audiences, so grouping by reader fails too. What forms it is the RETURN SHAPE: each collapses the whole project to a single accept/refuse verdict (verdict / go / accepted / status:"pass"|"fail") and then enumerates what withholds it. (102) was carried by a prefix that actively DISAGREED with the seam; this one by a vocabulary that says nothing at all -- four names sharing no words can still be one question, which is the inverse of the error (85), (89) and annotate.ts each recorded, where a shared mechanism looked like a shared question. THE TWO EXCLUSIONS DID THE WORK. spineTraceability is the closest miss -- same domain, adjacent in the file, equally project-scoped -- and returns coverage/gaps/chain with NO verdict field at all: it maps completeness for a human rather than deciding acceptance. editPrecheck DOES return a verdict, but judges a PENDING ACTION ("may I run this recipe with these params"), is remedied by changing the params you are about to submit, and sits beside addCurtainWall as the precheck for editIfc. ONE FALSE POSITIVE, RECORDED BECAUSE IT WAS INVISIBLE. A scan for verdict-shaped returns also flagged collabSnapshot -- but the match came from the doc comment introducing permitReadiness, the NEXT method. A method-body splitter that runs to the next header swallows the comment belonging to that header, so the population silently inherits its neighbour's vocabulary; a count of six looked entirely checked. Only reading each candidate caught it. Also files handoverAcceptance, which an earlier slice had parked under an explicit UNFILED note asking for it to be placed by what it ANSWERS rather than by what it sits next to. That note is narrowed to two entries, not deleted -- the other two are still genuinely unfiled, and a note that silently loses entries is how earlier slices lost methods. THE DOC-STRAND GATE CAUGHT A DEFECT IN THIS SLICE'S OWN NEW FILE: a /** */ doc comment on the Ctor type sat directly below the module header, which that gate reads as a stranded comment -- the header carrying the whole witness was one line from reading as documentation of a type alias. It is a // comment now, which is why counterpartyRisk.ts has none there either. The fast checks (tsc, eslint) were clean while this was broken; the suite is where it lived. Verified: 2072 web tests / 205 files, tsc + eslint clean, whole-tree ruff clean, ratchet mutation-checked at 571 (both the growth and chain-end assertions fire), 48-entry narrative chain unbroken, test_route_reachability + test_reachable + test_import_cycles + test_doc_substance + test_claude_md_gates + test_ruff_scope + test_declared_imports green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA --- CHANGELOG.md | 37 +++++++++ apps/web/src/api/acceptanceGates.ts | 118 ++++++++++++++++++++++++++++ apps/web/src/api/client.ts | 45 ++++------- services/api/test_file_sizes.py | 2 +- 4 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/api/acceptanceGates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b17bd292..8e29ab09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — SCALE-SEAM (103): acceptance gates + +Extracts `permitReadiness`, `diligenceReadiness`, `handoverAcceptance` and `validate` from +`client.ts` (**589 → 572**) into `apps/web/src/api/acceptanceGates.ts` — *will an outside party +accept this project, and what is blocking it.* + +**Neither the route nor the audience produces this grouping.** The four routes are +`/permit/readiness`, `/diligence/readiness`, `/handover/acceptance` and `/validate` — four different +prefixes, so a prefix grouping takes one each and the set never forms; the shared leaf word +"readiness" reaches only two of the four. The deciding parties are an AHJ, an investor, an owner and +an IDS checker — four different audiences, so grouping by reader fails too. What forms it is the +**shape of the return**: each collapses the whole project to a single accept/refuse verdict +(`verdict` / `go` / `accepted` / `status: "pass" | "fail"`) and then enumerates what withholds it. + +(102) was carried by a prefix that actively *disagreed* with the seam; this one by a vocabulary that +says nothing at all — **four names sharing no words can still be one question**. That is the inverse +of the error (85), (89) and `annotate.ts` each recorded, where a shared mechanism looked like a +shared question. + +**The two exclusions did the work.** `spineTraceability` is the closest miss — same domain, +adjacent in the file, equally project-scoped — and returns `coverage`/`gaps`/`chain` with **no +verdict field at all**: it maps completeness for a human rather than deciding acceptance. +`editPrecheck` *does* return a verdict, but judges a **pending action** ("may I run this recipe with +these params"), is remedied by changing the parameters you are about to submit, and sits beside +`addCurtainWall` as the precheck for `editIfc`. + +**One false positive is worth recording.** A scan for verdict-shaped returns also flagged +`collabSnapshot` — but the match came from the doc comment introducing `permitReadiness`, the *next* +method. A method-body splitter that runs to the next header swallows the comment belonging to that +header, so the population silently inherits its neighbour's vocabulary; a count of six looked +entirely checked. Only reading each candidate caught it. + +Also files `handoverAcceptance`, which an earlier slice had parked under an explicit "UNFILED" note +asking for it to be placed *by what it answers rather than by what it sits next to*. That note is +**narrowed to two entries, not deleted** — the other two are still genuinely unfiled, and a note +that silently loses entries is how earlier slices lost methods. + ## Unreleased — review fixes, and a gate for the number that keeps drifting **Four review findings on the same pull request, and the second one was self-referential.** The PR diff --git a/apps/web/src/api/acceptanceGates.ts b/apps/web/src/api/acceptanceGates.ts new file mode 100644 index 00000000..268c21d8 --- /dev/null +++ b/apps/web/src/api/acceptanceGates.ts @@ -0,0 +1,118 @@ +import { HttpCore } from "./httpCore"; +import type { DiligenceReadiness } from "./types"; + +/** + * Acceptance gates — **will an outside party accept this project, and what is blocking it.** + * + * SCALE-SEAM (103). Four gates, four different outside parties, one question shape: a single + * whole-project verdict plus the itemised list of what is standing in its way. + * + * | method | who is deciding | the verdict field | what blocks it | + * |---|---|---|---| + * | `permitReadiness` | the AHJ | `verdict` | `checklist[].satisfied` + ranked `deficiencies[]` | + * | `diligenceReadiness` | an investor / acquirer | `go` | `high_risk[]` + `flagged` per category | + * | `handoverAcceptance` | the owner at turnover | `accepted` | `checks[].ok` | + * | `validate` | the IDS checker | `status: "pass" \| "fail"` | `specifications[].failed_guids` | + * + * ## The witness is that NEITHER the route nor the audience produces this grouping + * + * The four routes are `/permit/readiness`, `/diligence/readiness`, `/handover/acceptance` and + * `/validate` — **four different prefixes**, so a prefix grouping takes one each and this set never + * forms. The shared leaf word "readiness" reaches only two of the four, so that does not form it + * either. And the deciding parties are an authority, an investor, an owner and a schema checker — + * **four different audiences**, so grouping by who reads it fails as well. + * + * What does form it is the *shape of the return*: every one of them collapses the whole project to + * a single accept/refuse verdict and then enumerates what is withholding it. That is a question a + * caller acts on identically in all four cases — block, or proceed — and the remedy is always to + * change the project and ask again. + * + * This is the second slice to be carried by return shape rather than by name, after (102), where + * the prefix actively *disagreed* with the seam. Here the prefix does not disagree so much as say + * nothing at all, which is the weaker but more common case: **four names that share no vocabulary + * can still be one question.** (85) rejected "they are all multipart uploads", (89) "they are all + * module records", and `annotate.ts` "they all call `editIfc`" — those are shared mechanisms + * masquerading as questions. This is the inverse error to avoid: an unshared vocabulary + * masquerading as unshared subject matter. + * + * ## What did NOT come, and why — the exclusions are the load-bearing part + * + * **`spineTraceability` stayed**, and it is the closest miss: same domain, same "is this project + * complete" register, adjacent in the file, and its route `/spine/traceability` is as + * project-scoped as the four above. It returns `coverage` percentages, `gaps` and a `chain` — + * and **no verdict field of any kind**. Nothing in it says pass or fail, because it is not a gate: + * it maps how completely one artefact links to another so a human can decide what to do. A + * completeness map and an acceptance decision are different questions even when they cover + * identical ground, and only reading the returns tells them apart. + * + * **`editPrecheck` stayed**, and this one is subtler because it genuinely returns a verdict — + * `{ok, errors, warnings}`. Its SUBJECT is different: it judges a **pending action** ("may I run + * this recipe with these params"), asked before acting, remedied by changing the parameters you + * are about to submit, and consumed by enabling or disabling an Apply button. The four above judge + * a **delivered state**, remedied by changing the project. It also sits beside `addCurtainWall` as + * the precheck for `editIfc`, so moving it here would separate it from the thing it prechecks. + * *A verdict about what you are about to do is not a verdict about what you have built.* + * + * **`collabSnapshot` was a false positive and is worth recording.** A scan for verdict-shaped + * returns flagged it, but the match came from the doc comment introducing `permitReadiness` — the + * next method — not from its own body, which has no verdict at all. A method-body splitter that + * runs to the next header swallows the comment belonging to that header, so a population derived + * that way silently inherits its neighbour's vocabulary. The count looked entirely reasonable at + * six; only reading each candidate caught it. **Derive the complement, then read it — a plausible + * count is not a checked one.** + * + * ## `handoverAcceptance` had been left explicitly unfiled + * + * A previous slice parked it under an "UNFILED" note in `client.ts` saying it "needs its home + * decided by what it ANSWERS rather than by what it sits next to". This slice is that decision. + * The note is narrowed rather than deleted, because the other two it names are still unfiled. + */ +// The mixin shape TS requires — same declaration as every other `with*` module in this directory. +type Ctor = new (...args: any[]) => T; + +export function withAcceptanceGates>(Base: TBase) { + return class AcceptanceGates extends Base { + /** PERMIT-CHECK: submission-readiness — checklist + ranked deficiencies + verdict (409 without a model). */ + permitReadiness(pid: string) { + return this.json<{ + verdict: string; readiness_pct: number; approvability_score: number; + checklist: { requirement: string; satisfied: boolean; evidence: string }[]; + deficiencies: { item: string; severity: string; action: string }[]; + }>(`/projects/${pid}/permit/readiness`); + } + + /** Investor/acquirer gate: `go` plus the diligence items and entitlements holding it back. */ + diligenceReadiness(pid: string) { + return this.json(`/projects/${pid}/diligence/readiness`); + } + + /** Owner turnover gate: `accepted` plus the per-check breakdown of what is not ready. */ + handoverAcceptance(pid: string) { + return this.json<{ accepted: boolean; checks: { key: string; label: string; ok: boolean }[]; + metrics: Record; note: string }>(`/projects/${pid}/handover/acceptance`); + } + + /** + * IDS gate: `status` plus, per specification, the GUIDs that failed it. + * + * Uses bare `fetch` rather than `this.json` — kept exactly as it was, because changing the + * transport of a method while moving it makes a behavioural change look like an extraction. + */ + validate(pid: string) { + return fetch(this.url(`/projects/${pid}/validate`), { method: "POST" }) + .then((r) => r.json() as Promise); + } + }; +} + +/** + * The IDS validation report. Defined here rather than in `types.ts` because `validate` is its only + * consumer in the tree — it moved out of `client.ts` with the method that returns it, so nothing is + * left behind pointing at a method that is no longer there. + */ +export interface ValidationResult { + title: string; + status: "pass" | "fail"; + summary: { specifications: number; passed: number; failed: number }; + specifications: { name: string; status: "pass" | "fail"; applicable: number; passed: number; failed: number; failed_guids: string[] }[]; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 277d6238..7b15fabc 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -34,6 +34,7 @@ import { withCost } from "./cost"; import { withRoutines } from "./routines"; import { withContracts } from "./contracts"; import { withCounterpartyRisk } from "./counterpartyRisk"; +import { withAcceptanceGates } from "./acceptanceGates"; import { withDesignOptions } from "./designOptions"; import { withFinance } from "./finance"; import { withLibrary } from "./library"; @@ -66,13 +67,13 @@ import type { DisciplineTree, ModulePin, RoomAllocation, PropMapRule, SpecManual, WorkItem, VitalsPayload, - DiligenceReadiness, MasterBuilderBrief, + MasterBuilderBrief, SpineTraceability } from "./types"; // Transport (baseUrl, token, json/_pdfPost/url/health) lives in HttpCore; ApiClient adds the typed // domain methods below. Every `api.method()` call site is unchanged by the split. -export class ApiClient extends withCounterpartyRisk(withDesignPerformance(withDetailing(withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore)))))))))))))))))))))))))))))))))))))))))))))) { +export class ApiClient extends withAcceptanceGates(withCounterpartyRisk(withDesignPerformance(withDetailing(withAnnotate(withCreDeal(withClientPortal(withResilience(withResponsibility(withOperations(withAccounting(withDealMemory(withPdfTools(withCodeCheck(withSpecialty(withIds(withEvm(withRisk(withEntitlements(withPrecon(withAi(withTopics(withMep(withDocuments(withModels(withElements(withDrawingSheets(withDrawingSet(withMarkup(withSync(withConnections(withDocQa(withFinance(withContracts(withAuth(withProforma(withDesignOptions(withRoutines(withCost(withProcurement(withEstimate(withModules(withModel(withSchedule(withLibrary(withAssetRights(withAuthoring(HttpCore))))))))))))))))))))))))))))))))))))))))))))))) { /** * R22-PHOTO-CV — attach a field photo to an element and get the server's read on it back. * @@ -216,14 +217,6 @@ export class ApiClient extends withCounterpartyRisk(withDesignPerformance(withDe editors: { user: string; seconds_ago: number; viewpoint: unknown }[]; editor_count: number; }>(`/projects/${pid}/collab`); } - /** PERMIT-CHECK: submission-readiness — checklist + ranked deficiencies + verdict (409 without a model). */ - permitReadiness(pid: string) { - return this.json<{ - verdict: string; readiness_pct: number; approvability_score: number; - checklist: { requirement: string; satisfied: boolean; evidence: string }[]; - deficiencies: { item: string; severity: string; action: string }[]; - }>(`/projects/${pid}/permit/readiness`); - } /** Discipline quantity roll-up — reinforcement tonnage, MEP linear runs, structural volume. */ @@ -328,9 +321,6 @@ export class ApiClient extends withCounterpartyRisk(withDesignPerformance(withDe mean_deviation: number; max_deviation: number; p95_deviation: number; histogram: { band: string; count: number }[]; note: string }>; } - validate(pid: string) { - return fetch(this.url(`/projects/${pid}/validate`), { method: "POST" }).then((r) => r.json() as Promise); - } // W9-1 property mapping / normalization — the transform verb between IDS-validate and COBie-export propmapDetect(pid: string) { @@ -395,9 +385,6 @@ export class ApiClient extends withCounterpartyRisk(withDesignPerformance(withDe return this.json<{ seeded: boolean; phases?: number; reason?: string }>( `/projects/${pid}/lifecycle/seed`, { method: "POST" }); } - diligenceReadiness(pid: string) { - return this.json(`/projects/${pid}/diligence/readiness`); - } @@ -419,19 +406,22 @@ export class ApiClient extends withCounterpartyRisk(withDesignPerformance(withDe } - // --- UNFILED: three methods that the RACI banner above used to cover ----------------- + // --- UNFILED: two methods that the RACI banner above used to cover ------------------- // Named rather than left implicit, because a banner that over-claims is how the previous - // three slices each lost a method. `mcpTools` is global (`/mcp/tools`); `handoverAcceptance` - // is `/handover/acceptance`; `inspectVim` is `/convert/vim/inspect`. None is RACI, and each - // needs its home decided by what it ANSWERS rather than by what it sits next to. + // three slices each lost a method. `mcpTools` is global (`/mcp/tools`); `inspectVim` is + // `/convert/vim/inspect`. Neither is RACI, and each still needs its home decided by what it + // ANSWERS rather than by what it sits next to. + // + // This note said THREE until SCALE-SEAM (103). `handoverAcceptance` was the third, and it is + // the note working as intended: it was parked here with the instruction to file it by what it + // answers, and (103) answered that — an owner's turnover gate, which is the same question as + // the AHJ's, the investor's and the IDS checker's. `acceptanceGates.ts` has it now. **Narrowed + // rather than deleted**, because the other two are still genuinely unfiled and a note that + // silently loses entries is how the earlier slices lost methods in the first place. mcpTools() { return this.json<{ tools: { name: string; description: string }[]; server: string; note: string }>( `/mcp/tools`); } - handoverAcceptance(pid: string) { - return this.json<{ accepted: boolean; checks: { key: string; label: string; ok: boolean }[]; - metrics: Record; note: string }>(`/projects/${pid}/handover/acceptance`); - } async inspectVim(file: File) { const fd = new FormData(); fd.append("file", file); @@ -580,10 +570,3 @@ export class ApiClient extends withCounterpartyRisk(withDesignPerformance(withDe } } - -export interface ValidationResult { - title: string; - status: "pass" | "fail"; - summary: { specifications: number; passed: number; failed: number }; - specifications: { name: string; status: "pass" | "fail"; applicable: number; passed: number; failed: number; failed_guids: string[] }[]; -} diff --git a/services/api/test_file_sizes.py b/services/api/test_file_sizes.py index bbb2a3a8..41be2948 100644 --- a/services/api/test_file_sizes.py +++ b/services/api/test_file_sizes.py @@ -82,7 +82,7 @@ def check(label, ok, detail=""): #: straight back while the work still read as done. **An extraction without a ratchet is a #: rearrangement.** Set at the post-extraction count so it can only come down from here. "apps/web/src/portal/portal.ts": 1_238, # executive portfolio -> panels/portfolio.ts (1_353 -> 1_238), REL-4. Chosen by grepping every candidate's this.* refs FIRST: the two mid-sized methods call five and two SIBLING renders, so only this one is a leaf. # the dead module catalog DELETED (1,400 -> 1,352); unreachable since 2026-06-24, and its star was the only caller of toggleFav in the app # REL-4 renderDeveloperHome -> homes/developerHome.ts (1,473 -> 1,400) - "apps/web/src/api/client.ts": 589, # SCALE-SEAM (102): COUNTERPARTY RISK — which trade partner is a risk on this job, and why. prequalScores + coiExpiry + lienExposure -> counterpartyRisk.ts (603 -> 589). THE WITNESS IS THAT THE SEAM DISAGREES WITH THE ROUTE PREFIX: two sit under /prequal/ and one under /payapp/lien-exposure, so a prefix grouping would have SPLIT the set, while the returns all carry per-counterparty rows with a verdict about that counterparty (risk_band+flags / days-to-expiry / exposure+vendors_at_risk). That is the affirmative form of the rule (85), (89) and annotate.ts each had to learn negatively - a shared mechanism is not a question. benchmarkResponseRates sits immediately above and stayed: it names no counterparty at all, it measures how responsive the PROCESS is. # SCALE-SEAM (101): DESIGN-PHASE PREDICTED PERFORMANCE — what will this design consume and emit, and does it comply? (642 -> 603). energy + energyModel + energyExportUrl + carbonComplianceReport + projectCarbon to a new designPerformance.ts, and benchmarkCosts to cost.ts. THE SEAM WAS DRAWN BY EARLIER SLICES, NOT THIS ONE, which is the whole witness: operations.ts's own header records why projectCarbon did not go there ("EMBODIED carbon ... a design-phase estimate. The GHG figures in esgSummary come from metered utility data. Same molecule, opposite ends of the asset life"), models.ts records the parallel call for /energy, and operations.ts DOES hold /energy/actual. Prediction vs measurement, committed to twice independently. NOT NAMED environmental.ts on purpose: that names the TOPIC both halves share, and would re-blur the seam operations.ts drew. A PLANNED benchmarks.ts WAS ABANDONED ON EVIDENCE: cost.ts already held unitRates (/benchmarks/unit-rates) and schedule.ts holds benchmarksPullPlanning (/benchmarks/pull-planning), so the repo already distributes that prefix by what each method ANSWERS — grouping the remaining three by route would have contradicted two live placements. benchmarkResponseRates and spaceUtilBenchmarks STAYED: no mixin owns their question, and inventing a home on a guess is what produced this file's UNFILED banner. # SCALE-SEAM (100): THE ELEMENT-CONNECTION PAIR — what is physically joined to what, and record a joint? (648 -> 642). elementConnections + connectElements to model.ts. BOUND BY THE BACKEND NAMING ITS OWN WRITER (the (96) shape): the /element-connections route docstring says "Author edges with the connect_elements recipe (POST /edit with {guid_a, guid_b})". DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS STATED SO: model.ts owns modelGraphStats (counts the IFC relationship graph BY RELATION, and IfcRelConnectsElements is one) and graphNeighbors (walks it), so this is one relation of that graph plus its verb — a SPECIALISATION, not an identity. REJECTED ON CHECKABLE GROUNDS: connections.ts is DATA-SOURCE connections (SQL/ACC/Procore), sharing only the English word — (97) same collision, in a destination; elements.ts holds element ATTRIBUTES, and a relationship between two elements is not an attribute of either. addBasePlate/addShearTab did NOT come despite sharing connections.py: a backend module is a HOW, and they author PHYSICAL assemblies, not IfcRelConnectsElements edges. # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. + "apps/web/src/api/client.ts": 572, # SCALE-SEAM (103): ACCEPTANCE GATES — will an outside party accept this project, and what is blocking it? permitReadiness + diligenceReadiness + handoverAcceptance + validate -> acceptanceGates.ts (589 -> 572). NEITHER THE ROUTE NOR THE AUDIENCE PRODUCES THIS GROUPING: four different prefixes (/permit/readiness, /diligence/readiness, /handover/acceptance, /validate) so a prefix grouping takes one each and the set never forms; the shared leaf word "readiness" reaches only two of four; and the deciding parties are an AHJ, an investor, an owner and a schema checker, four different audiences. What forms it is the RETURN SHAPE: each collapses the whole project to one accept/refuse verdict (verdict / go / accepted / status:"pass"|"fail") and then enumerates what withholds it. (102) was carried by a prefix that DISAGREED; this one by a vocabulary that says nothing at all - four names sharing no words can still be one question, the inverse of the (85)/(89)/annotate.ts error. TWO EXCLUSIONS DID THE WORK: spineTraceability is the closest miss - same domain, adjacent, equally project-scoped - and returns coverage/gaps/chain with NO verdict field, because it maps completeness for a human rather than deciding acceptance; editPrecheck DOES return a verdict {ok,errors,warnings} but judges a PENDING ACTION, remedied by changing the params you are about to submit, and sits beside addCurtainWall as the precheck for editIfc. collabSnapshot was a FALSE POSITIVE worth recording: a verdict-shaped-return scan matched the doc comment introducing permitReadiness, because a method-body splitter that runs to the next header swallows that header's comment - so the population silently inherits its neighbour's vocabulary and a plausible count of six looked checked. Also filed handoverAcceptance, which an earlier slice had parked under an explicit UNFILED note asking for it to be placed by what it ANSWERS; that note is narrowed to two, not deleted. # SCALE-SEAM (102): COUNTERPARTY RISK — which trade partner is a risk on this job, and why. prequalScores + coiExpiry + lienExposure -> counterpartyRisk.ts (603 -> 589). THE WITNESS IS THAT THE SEAM DISAGREES WITH THE ROUTE PREFIX: two sit under /prequal/ and one under /payapp/lien-exposure, so a prefix grouping would have SPLIT the set, while the returns all carry per-counterparty rows with a verdict about that counterparty (risk_band+flags / days-to-expiry / exposure+vendors_at_risk). That is the affirmative form of the rule (85), (89) and annotate.ts each had to learn negatively - a shared mechanism is not a question. benchmarkResponseRates sits immediately above and stayed: it names no counterparty at all, it measures how responsive the PROCESS is. # SCALE-SEAM (101): DESIGN-PHASE PREDICTED PERFORMANCE — what will this design consume and emit, and does it comply? (642 -> 603). energy + energyModel + energyExportUrl + carbonComplianceReport + projectCarbon to a new designPerformance.ts, and benchmarkCosts to cost.ts. THE SEAM WAS DRAWN BY EARLIER SLICES, NOT THIS ONE, which is the whole witness: operations.ts's own header records why projectCarbon did not go there ("EMBODIED carbon ... a design-phase estimate. The GHG figures in esgSummary come from metered utility data. Same molecule, opposite ends of the asset life"), models.ts records the parallel call for /energy, and operations.ts DOES hold /energy/actual. Prediction vs measurement, committed to twice independently. NOT NAMED environmental.ts on purpose: that names the TOPIC both halves share, and would re-blur the seam operations.ts drew. A PLANNED benchmarks.ts WAS ABANDONED ON EVIDENCE: cost.ts already held unitRates (/benchmarks/unit-rates) and schedule.ts holds benchmarksPullPlanning (/benchmarks/pull-planning), so the repo already distributes that prefix by what each method ANSWERS — grouping the remaining three by route would have contradicted two live placements. benchmarkResponseRates and spaceUtilBenchmarks STAYED: no mixin owns their question, and inventing a home on a guess is what produced this file's UNFILED banner. # SCALE-SEAM (100): THE ELEMENT-CONNECTION PAIR — what is physically joined to what, and record a joint? (648 -> 642). elementConnections + connectElements to model.ts. BOUND BY THE BACKEND NAMING ITS OWN WRITER (the (96) shape): the /element-connections route docstring says "Author edges with the connect_elements recipe (POST /edit with {guid_a, guid_b})". DESTINATION ARGUMENT IS WEAKER THAN THE PAIRING ARGUMENT AND IS STATED SO: model.ts owns modelGraphStats (counts the IFC relationship graph BY RELATION, and IfcRelConnectsElements is one) and graphNeighbors (walks it), so this is one relation of that graph plus its verb — a SPECIALISATION, not an identity. REJECTED ON CHECKABLE GROUNDS: connections.ts is DATA-SOURCE connections (SQL/ACC/Procore), sharing only the English word — (97) same collision, in a destination; elements.ts holds element ATTRIBUTES, and a relationship between two elements is not an attribute of either. addBasePlate/addShearTab did NOT come despite sharing connections.py: a backend module is a HOW, and they author PHYSICAL assemblies, not IfcRelConnectsElements edges. # SCALE-SEAM (99): THE CONTENT SHELF — what pre-made content can I place, and place it? (665 -> 648). contentCatalog + placeContent + importContent to authoring.ts. WITNESS IS A ROLE-FOR-ROLE PARALLEL with the family shelf already there, read off the signatures not the shared word "shelf": familyCatalog/contentCatalog (catalog reader, both {count, ...Record<..>}), placeFamily/placeContent (placer), importFamilies/importContent (async multipart importer). Three roles, three methods each. A parallel between two method TRIPLES is structural; "both are shelves" would be a word, the grouping (88) and (89) rejected. SMALL FINDING: authoring.ts line 1 had said "the family/content shelf" while the file held ZERO content methods — a docstring describing an intended scope as fact. Corroboration that was FALSE, not evidence. # SCALE-SEAM (98): DETAILING CARRIERS — what informational carriers are attached to this element, write them, and which are missing? (687 -> 665). New detailing.ts: elementDetailing + classify + applyDetailingRules + validateDetailing + attachDocument. WITNESS IS A 1:1 AND TOTAL FIELD-TO-WRITER MAP, the (96) shape: element_detailing walks HasAssociations and branches on exactly two relationship types, and detailing.py holds exactly two writers — classifications[] <- classify (IfcRelAssociatesClassification), documents[] <- attachDocument (IfcRelAssociatesDocument). The other two are those same writes automated (rule engine) and audited (gap report). NOT CLAIMED: attachOmDocument, moved to model.ts in (96), wraps the SAME detailing.attach_document and also lands in documents[], so "all the writers of this reader" is false; the map is total over detailing.py, not over the codebase. ADJACENCY IS NOT EVIDENCE: these five were contiguous, so unlike (95) a positional split would also have found them. # SCALE-SEAM (97): THE UNDO STACK — what has been done to this model, and can I take it back? (698 -> 687). editHistory/editUndo/editRedo to authoring.ts, because editIfc — already there — is the PUSH they pop: authoring.py records the pre-edit version on every /edit call "so this edit can be undone", _restore_version pops it, edit_history.state() reads its depths. TYPE-LEVEL: both writers return {restored, state:{can_undo,can_redo}} and state IS the reader's type minus depths. HYPOTHESIS TESTED AND WITHDRAWN: "undo restores the prior model version" makes model.ts look right, but modelVersions reads /projects/{pid}/versions out of bim.py while undo pops a DIFFERENT stack in the edit_history sidecar. Two stacks, one word. # SCALE-SEAM (96): AS-BUILT/TURNOVER — its aggregate READER plus the two writers still feeding it (711 -> 698). lod500 + setManufacturerInfo + attachOmDocument to model.ts, rejoining verifyAsbuilt/recordAsbuiltDimension from (94). TWO BOUNDING WITNESSES: openAsBuiltPanel calls exactly five API methods (read off the brace closure, not grepped), two already moved; and asbuilt_summary's response type names its writer set field by field. test_lod500.py CORROBORATES but does NOT bound — it reaches three recipes and omits record_asbuilt_dimension, a known member, so it cannot establish a boundary. NOT CLAIMED: attachDocument (staying) takes a purpose param and can also produce with_om_docs, so "every writer of with_om_docs moves" is false. # SCALE-SEAM (95): ELEMENT STATE — what state are the model's elements in, and set it? (727 -> 711). Two read/write pairs to model.ts: lodSummary/setLod (element maturity 100-500) and phasing/setPhase (new/existing/demolish/temporary). IDENTICAL RETURN SHAPE — both {total, ed, prop, counts: Record<...>}; both writers (pid, guids, , publish) -> editIfc; both readers consumed by viewer/tools/modelStatePanels.ts; both writers UNWIRED and ADJACENT on clientCallers.test.ts's UNCALLED allowlist. AND lodSummary WAS A SIBLING SEPARATED FROM ITS OWN FAMILY: model.ts already held /model/lod/census, /lod/handover-readiness and /lod/assessment while the BASE distribution /projects/{pid}/lod stayed in client.ts. THE MATRIX DISAGREES AND IT LOSES, which is worth stating rather than eliding: authoring_matrix.py files set_lod under `data` and set_phase under `lifecycle` because it categorises by the IFC OUTPUT each recipe writes — an LOD stage tag against Massing_Phasing.Status. Different psets, same question. THAT IS (89)'S "STORAGE IS A HOW" TRAP: "they write different property sets" has the same shape as "they are all module records", and neither is a question. THIS ALSO MEETS (94)'S OBJECTION RATHER THAN OVERRIDING IT: that slice declined setPhase because taking the writer would have stranded phasing() in client.ts, the reader/writer split (87) had to undo. Both halves move together here, so nothing is separated. NOT CONTIGUOUS — the pairs sat at 271-279 and 293-301 with ensureContexts and queryElements between; (88) recorded that as the strongest case for grouping by what methods ANSWER, since no prefix or positional split would find them together. 76 above the banner now, still no map. # SCALE-SEAM (94): the as-built pair ㊻ COULD NOT SEE (731 -> 727). verifyAsbuilt (stamps Massing_AsBuilt, the LOD-500 reliability layer) and recordAsbuiltDimension (a field-verified dimension + variance vs design) to model.ts, which since ㊻ has held "field-install verification — is this element installed as designed?" with verificationCoverage, setVerification and verificationDeviations. Both answer exactly that question and NEITHER IS NAMED IN ㊻'S HEADER. WHY ㊻ MISSED THEM: it split on the /verification/* ROUTE PREFIX, and these two go through editIfc so they carry no route to be found by. A ROUTE-PREFIX SPLIT CANNOT SEE A RECIPE-BASED SIBLING OF THE SAME QUESTION. They were also unmovable at the time — editIfc is declared on the Authoring mixin rather than HttpCore, so a mixin typed Ctor does not compile. THIRD slice found to have left work behind for that reason, after ⑲'s MEP pair (taken by (93)). THE MATRIX ARGUED FOR A THIRD METHOD AND IT WAS DECLINED: authoring_matrix.py's `lifecycle` category is exactly these two plus set_phase — a COMPLETE category, the very signal that decided (92)'s annotate slice. setPhase stays because it answers a different question (tag a construction phase: new/existing/demolish/temporary — element state in the build sequence, not install-versus-design) AND because its read half phasing() is still in client.ts; taking a writer and leaving its reader is the split (87) had to undo. A COMPLETE CATEGORY IS ONE VOTE, NOT A VERDICT — and "lifecycle" is a word (88) already caught misleading once, in a different way. withModel now declares NeedsEditIfc and must compose outside withAuthoring; probe-checked with an isolated file applying withModel to bare HttpCore, which fails TS2345 naming NeedsEditIfc. THE FIRST ATTEMPT AT THAT CHECK WAS WORTHLESS AND READING IT IS THE ONLY REASON I KNOW: it rebalanced parens wrongly and went red with TS1005 syntax errors, so it proved nothing about composition order while looking exactly like a pass of the mutation test. THE BANNER NOTE IN client.ts WAS CONDENSED, and that is the ratchet working: the first draft added 9 lines of prose while the extraction removed 8, netting +1 — an extraction slice that grows the file has failed its purpose. The reasoning lives once, in model.ts's header, rather than twice. REVIEW OF #411 FOUND AN OVERSTATEMENT I HAD ASKED IT TO LOOK FOR: the PR description scoped the claim as "two known missed members, not necessarily all members" while model.ts said "finishes that question" and surface.test.ts said "completing its verification question". HEDGING IN THE REVIEW REQUEST WHILE THE ARTIFACT OVERSTATES IS NOT SCOPING — it puts the caveat where a reviewer sees it and the overstatement where every later reader will, which is worse than a plain overstatement because it looks like discipline. Fourth instance of one defect across four slices, and the first where the correct wording already existed and simply did not reach the code. Both corrected. AND THE COMPOSITION-ORDER CONSTRAINT IS NOW A TEST RATHER THAN A CLAIM: apps/web/src/api/compositionOrder.test.ts asserts with @ts-expect-error that withAnnotate, withMep and withModel all reject a bare HttpCore base. I had reported it as mutation-checked, but the check was a scratch file I deleted, so nobody could verify it — A VERIFICATION THAT LEAVES NO ARTIFACT IS A CLAIM, NOT A CHECK. Mutation-checked in turn: relaxing withMep to Ctor fails TS2578 'Unused @ts-expect-error directive' naming the line. 80 above the banner now, still no map. # SCALE-SEAM (93): the two MEP recipes ⑲ HAD TO leave behind (733 -> 731). connectMep + addMepFitting to mep.ts, whose own header since ⑲ said they "call editIfc (/edit) and stay" — A NOTE THAT RECORDED THE SYMPTOM WITHOUT DIAGNOSING THE CAUSE. They could not come because editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile. (92) found that; (93) applies it. A METHOD LEFT BEHIND FOR A REASON NOBODY WROTE DOWN STAYS BEHIND INDEFINITELY — ⑲'s note read as a placement decision and was a compiler error. NeedsEditIfc moved from annotate.ts into types.ts so both mixins share ONE definition: two hand-copied signatures are exactly the drift the #409 review had to check by hand, since a copy subtly WIDER than the real editIfc typechecks at the mixin and still breaks at a call site. Mutation-checked: composing withMep inside withAuthoring fails with TS2345 naming NeedsEditIfc. EVIDENCE IS WEAKER THAN (92)'S AND IS STATED AS SUCH: this is NOT a complete authoring_matrix category (create-mep has 11 members, edit-mep 3, of which these are one each). The boundary is "MEP recipe exposed as a TYPED client method" — exactly these two. TWELVE MEP recipes remain in the matrix (11 create-mep + 3 edit-mep, less these two): nine are driven from viewer code through the generic recipe path in draftCatalog.ts and mepSection.ts, and THREE (add_sprinkler, auto_connect_mep, set_system_predefined) are referenced nowhere in apps/web/src at all — backend recipes with no web exposure, recorded rather than fixed. THIS SAID "the other nine" UNTIL REVIEW, AND IT IS THE THIRD CONSECUTIVE SLICE WITH ONE DEFECT IN A THIRD DISGUISE: nine was the number I had ENUMERATED (the viewer-driven ones), twelve is the number that REMAIN. (91) claimed no test_cre_* file reached outside its set while three reached out as fixtures; (92) claimed test_annotation.py exercised four recipes and no others while two more were tag-host fixtures; this enumerated a subset and wrote "the other". A reviewer found all three. THE FIX IS NOT TO BE MORE CAREFUL — IT IS TO DERIVE THE COMPLEMENT, set(all) - set(moved), and only then describe what is in it. test_mep_systems.py covers both AMONG other MEP recipes (corroborates family, does not bound the set) and test_guards.py is cross-cutting — said explicitly because the "no others" error was made in each of the two preceding slices. addMepFitting is callerless and stays on clientCallers.test.ts's allowlist: moving a method does not change its called-ness. 82 above the banner now, still no map. # SCALE-SEAM (92): ANNOTATION — put a note, a dimension, a cloud or a tag ON the model. Four methods to a new annotate.ts (746 -> 733): addAnnotation, addDimension, addRevisionCloud, addTag, all authoring real IfcAnnotation entities at world [E,N] through editIfc. THE SET IS BOUNDED BY TWO SOURCES SOMEBODY ELSE AUTHORED, not by the marker. (i) authoring_matrix.py, the curated recipe->category map behind the public authoring-coverage endpoint, has an `annotate` category holding EXACTLY these four of its 99 recipes across 15 categories — a complete category, nothing left behind and nothing pulled in. (ii) test_annotation.py exercises all four annotation recipes and no other recipe as its SUBJECT (it calls add_wall and add_column once each as fixtures, to give add_tag a host). That qualification was missing until review, and it is the SECOND CONSECUTIVE SLICE to make the error — (91) claimed no test_cre_* file reached outside its set while test_cre_tier3 reached three routes as fixtures. Both times the claim counted what a test TARGETS and ignored what it SETS UP. A "nothing else" claim about a test must say whether it means assertions or every call the file makes. THE `UX-2` MARKER DOES NOT BOUND THE SET, AND THAT DISTINCTION IS THE POINT: (91)'s `CRE-` occurred only in client.ts so it drew its own boundary, while UX-2 occurs 12 times across three files (client.ts, viewer/app.ts, viewer/tools/annotationSection.ts) and marks a FEATURE WORKSTREAM — it corroborates purpose and is silent on membership. Two markers, two strengths; treating every prefix as a set boundary is how a cluster gets the wrong members. Third corroboration: viewer/tools/annotationSection.ts, the UI these serve, makes exactly four api.* calls and they are these four. NOT markup.ts, the obvious guess: that is 2D SHEET markup (a pin at a sheet x/y carrying a note, stored as a markup record, promotable to RFI, route-group /drawings/markup), while these author MODEL CONTENT that travels with the IFC. A comment layer over a drawing and drawing content authored into the model are different questions sharing the word "annotation". NOT authoring.ts, which is the harder call because it DEFINES editIfc and holds seven recipes, and its header claims "the endpoints that WRITE to the model rather than read from it" — which would take all 24 recipes still in client.ts. A DESCRIPTION BROAD ENOUGH TO TAKE EVERYTHING IS NOT A SEAM: its demonstrated practice is narrower than its sentence, having declined detailing at 8, property-override layers at 33 ("those compose properties, they do not write recipes") and groups at 7. mep.ts settled the principle already — its methods "use editIfc (/edit) and stay" — and the matrix proves it, splitting those 24 recipes across NINE categories, so editIfc cannot be the seam. AND A TECHNICAL FINDING THAT PROBABLY EXPLAINS THE WHOLE BACKLOG: this is the first mixin to call editIfc WITHOUT DECLARING IT (authoring.ts calls it seven times but defines it, so it never needed a base type promising it — which is how eleven slices passed these recipes without hitting this). editIfc is declared on the Authoring mixin rather than on HttpCore, so a mixin typed Ctor cannot see it and the extraction does not compile — the same shape authoring.ts records blocking the SSE methods. annotate.ts declares the requirement in its type (NeedsEditIfc) and must be composed outside withAuthoring; mutation-checked by composing it inside, which fails with TS2345 naming editIfc rather than failing at runtime. Moving editIfc down into HttpCore would unblock the other 20 recipes and is left for the next recipe slice. **(93) TESTED THAT FORECAST AND IT DOES NOT HOLD.** httpCore.ts's own header says it owns the base URL, the bearer token and the low-level fetch helpers, "keeping transport concerns separate from the endpoint surface", and all eight of its methods are transport; editIfc is a DOMAIN endpoint (POST /projects/{pid}/edit). Moving it there would contradict that file's stated purpose and the layering SCALE-SEAM exists to establish. The answer is the NeedsEditIfc pattern, now shared from types.ts. I wrote that forecast as a plan one slice after (91) recorded that a placement forecast is a hypothesis for the next slice to TEST — and phrasing it as a plan is exactly what makes it read as settled. 84 above the banner now, still no map. # SCALE-SEAM (91): the R20 CRE DEAL DESK — should we transact on this income property, and on what terms? Thirteen methods to a new creDeal.ts (867 -> 746), one contiguous run: verify the seller's numbers (normalizeT12 — the tie-out is a gate; rentRollScrub — a check without its inputs reports not-run; netEffectiveRent; tieredComps; competitiveSupply), decide (holdSell, decisionGate), set the terms (clausePlaybook + reviewContractClauses, loanCovenants, dealAuthority). THREE INDEPENDENT SIGNALS, DERIVED, AND THEY AGREED — more than any slice in this sequence has had. (i) The source marks them: ten CRE- codes, and grepping apps/web/src showed those eleven occurrences are the ONLY ones in the whole web tree, all in client.ts. (ii) A 1:1 router match: every route these thirteen call (eleven distinct paths — the playbook and the authority table each have a read and a write half on one path) is served by aec_api/routers/realestate.py and by no other router, and its ten (R20) docstrings name the same ten codes — paired @router. line to def beneath it, not read top-to-bottom. (iii) One question runs through all thirteen. A marker alone would NOT have been enough: four shared-word traps (entitlements, view, carbon, lifecycle) are why CRE- counts as one vote of three and the router match carries the weight. THE BOUNDARY THAT DECIDES rent-roll: proforma.ts already holds GET /rent-roll and two of these sit under it on /rent-roll/scrub and /rent-roll/net-effective, so a prefix split would take all three. The backend states the boundary — the plain rent roll carries no CRE- code, no (R20), and its docstring calls it the operating rent roll "from the lease module (the hold phase)", while these two POST a body of numbers the counterparty supplied. What are we earning and is their number true are different questions sharing a prefix. AND (88)'S FORECAST WAS TESTED, NOT EXECUTED, AND IT LOST: (88) left camReconciliation with a note saying it "goes with rentRollScrub, netEffectiveRent and normalizeT12 when a rent-roll slice takes them". This is that slice and it did not take it — no CRE- code, no (R20), and /cam/reconciliation is served by operations.py, not realestate.py; a CAM true-up bills a completed operating year to sitting tenants. A placement forecast is a hypothesis for the next slice to test, not an instruction to carry out — phrased as a plan it reads as settled and invites the next reader to execute it without re-deriving anything. The note at the method now says what was measured. REVIEW OF #408 FOUND TWO THINGS AND THE SECOND WAS BIGGER THAN THE FIRST. (a) The fourth-corroboration caveat named one of test_cre_tier3's out-of-set routes and truncated its path; there are three (POST /proforma/scenarios, POST /proforma/scenarios/{sid}/review, GET /projects/{pid}/reports/ic_memo.pdf), all fixtures rather than assertions, so that claim holds for ASSERTED routes only. (b) The no-behaviour-change claim rested on apps/web/src/api/surface.test.ts, which spot-checks a name list none of the thirteen was on plus a floor of 751 on the total client surface — and the surface MEASURES 788. Losing one of the thirteen leaves 787 and passes; losing the whole mixin leaves 775 and passes. The count guarded nothing, which is precisely the slack that file's own comment at the 696 floor predicted would accumulate as endpoints are added; the review asserted the opposite ("losing any one reduces the count below 751"), off by 37. All thirteen are named there now and mutation-checked both ways (renaming holdSell fails naming it; restoring passes). A FLOOR THAT NEW WORK KEEPS CLEARING STOPS BEING A RATCHET WITHOUT EVER GOING RED — the same decay this item already records for client.ts > 1200 and for the banner-string proxy, now found in a third gate. The fix is to name the group, not to raise the floor. 88 above the banner now, still no map. # SCALE-SEAM (90): the CLIENT PORTAL — how does someone with no account see this project and answer back? Eight methods to a new clientPortal.ts (906 -> 867): the owner half mints/lists/revokes share tokens and reads what came back, the recipient half is the token-authenticated public surface (page + digest URLs, comment, approve/acknowledge/decline). The TOKEN is the seam — minted on one side, IS the credential on the other, revoking closes both — so splitting owner from recipient would put a capability and its only means of exercise in different files. aec_api/routers/client_portal.py groups the same set: 8 of its 9 routes, checked. The ninth (/shared/{token}/model.frag) has no client method BY DESIGN — the server-rendered share page fetches it, not this SPA. FOUND ON THE WAY, NOT FIXED HERE: that route serves geometry only to a token minted with show_model, an opt-in the backend treats as independent of show_payments — and createShareToken never sends it, nor does the returned row type carry it. Every token this product mints has it false, so the public 3D viewer is dark from the UI. Left alone because an extraction slice's claim is that no behaviour changed; recorded in the mixin header at the method. AND THE EXTRACTION SCRIPT HAD A REAL BUG, caught by docComments.test.ts rather than by me: its brace counter only terminated when j>i, so a ONE-LINE method consumed one line too many and swallowed the NEXT method's doc comment. sharedPageUrl and sharedDigestUrl are one-liners; the first draft dragged spaceUtilBenchmarks's SPACE-UTIL doc out of this file and duplicated sharedPageUrl's. Reverted and re-extracted. Seven of these eight carry /** */ docs, which the walk-back did not handle either until this slice — (88) and (89) survived only because their methods used // banners. THE GATE FOUND WHAT MY OWN ORPHAN CHECK MISSED: mine treated a following comment as an acceptable neighbour, which is exactly the case that defines the defect. 101 above the banner now, still no map. # SCALE-SEAM (89): TWO clusters, two new mixins, eight methods (953 -> 906). The four /resilience/* to resilience.ts — what could the ENVIRONMENT do to this project, asked of the site (flood/SFHA/design flood elevation), the civil design (runoff, detention) and the programme (weather-sensitive activities, delay days), plus the composite that folds all three into one rating. TENSION NAMED RATHER THAN SMOOTHED: resilienceWeather reads ACTIVITIES with trade/start/finish/percent, which is schedule.ts vocabulary and a subject-matter split would send it there; it stays because climateRisk consumes its weather_delay_days in the same composite as the flood and runoff numbers, and separating an input from the rollup that eats it is the split (87) had to undo. AND THE BANNER OVER-CLAIMED FOR THE SIXTH TIME: "climate & water resilience (flood + stormwater)" named TWO of the four beneath it, weather and the composite filed at whatever header was nearest — same defect as ⓽'s AI-drafting banner, (81)'s ifcClassify under a G704 header and (82)'s RACI banner describing 4 of 13. The four /responsibility/* to responsibility.ts — who is ACCOUNTABLE for what. aec_api/routers/responsibility.py holds those four routes AND NOTHING ELSE, a 1:1 router correspondence, checked rather than assumed. NOT filed on modules.ts even though that router's own docstring says the rows ARE ordinary module records whose CRUD lives there: storage is a HOW, and "they are all module records" is (85)'s "they are all multipart uploads" one layer down. Four methods is a small file and that is fine — risk/dealMemory/assetRights hold two each. ResponsibilityMatrix's type import left client.ts with them; it had no other reader. 109 above the banner now, still no map. # SCALE-SEAM (88): the OPERATE-PHASE cluster, and the FIRST slice from the 126 methods that were never in any map (1015 -> 953). Nine to a new operations.ts — cmms x2, energyActual, energyBenchmarkStatus, twinReadiness, fca x2, reserveStudy, esgSummary — answering "how is this asset performing in service, and what will it need?". Checked against the backend rather than assumed: all nine are served by aec_api/routers/operations.py, whose docstring names the same cluster. THREE DID NOT COME, and the words say otherwise on every one. lifecycle/lifecycleSeed are DESIGN lifecycle (RIBA/AIA stage gates, design_fee_pct, soft_costs) — the fourth shared-word trap after entitlements, view and carbon. projectCarbon is EMBODIED carbon, a design estimate, while esgSummary GHG comes from meters. camReconciliation shared reserveStudy's banner AND its backend router and still stayed: it allocates recoverable opex across TENANTS and returns balance_due per suite, a lease answer, so it waits for the rent-roll slice with rentRollScrub, netEffectiveRent and normalizeT12. Its banner is rewritten in client.ts rather than left naming a method that has gone. AND THE STAYING BANNER CITED esgSummary as an example of work still above the line — a banner offering, as evidence of what is unfinished, a method this slice moved. Corrected, and it now says to re-derive rather than read. # SCALE-SEAM (87): the CX-1 residue is FINISHED — 13 methods to elements/entitlements/proforma/estimate/modules/procurement/schedule/evm.ts (1131 -> 1015), and five more types out of client.ts into types.ts. TWO CORRECTIONS OF MY OWN EARLIER WORK. (i) ⓽ moved bidLevelingDetail to procurement.ts reasoning about procurementLevel and never noticed bidLeveling, the SUMMARY its detail belongs to, sitting in the same file — a rollup separated from its detail, the exact split (83) and (84) refused. Reunited. (ii) modules.ts carried a "compliance expiry" banner since SCALE-SEAM ④ (v0.3.803) whose method never came: complianceExpiring stayed in client.ts while its LABEL sat above addEnumOption. docComments.test.ts could not see it — its stranded check looks for /** */ above /** */, and this is a // banner above an undocumented method. AND THE BIG ONE: deriving the whole population showed client.ts has 130 methods, only 4 of them under the banner — 126 above it were never in any map. So (85)'s roadmap_status predicate ("--- UNFILED —" in client.ts) was ALSO a proxy: it would have declared SCALE-SEAM done with 126 methods outstanding. Replaced with a count of methods beyond the file's declared keep-list. # SCALE-SEAM (86): executivePortfolio + constructionPortfolio -> evm.ts, portfolioPrioritization -> proforma.ts, smart views -> elements.ts (1173 -> 1131). THE PORTFOLIO TRIO SPLIT 2/1: two REPORT status and are the cross-project form of evm.ts's projectHealth ("is the job on track across domains?"); the third RANKS deals and belongs with the pipeline in proforma.ts. And a shared WORD is not a shared domain for the third slice running: modules.ts already had a saved-views family (SavedViewDef = {q, state, sort}, a data-grid filter) and the name matched exactly, but a SmartView is {selector, mode: isolate|color|hide} that resolves to GUIDs — a saved SELECTION, so it went to elements.ts beside colorBy. # SCALE-SEAM (85): source-model ingest + the RVT bridge -> model.ts, raisePlan -> authoring.ts, takeoffDxf -> estimate.ts (1212 -> 1173). MECHANISM IS NOT A QUESTION: (84) had grouped these five as "how do I get a file into this project?", which is really "they are all multipart uploads" — a HOW. Read for what they answer and they split FOUR ways. rvtBridgeStatus did NOT go to entitlements.ts: that mixin is LAND-USE entitlements (planning approvals), a homonym. Also widened unfiledMap.test.ts, which matched only 2-space method indentation and so could not see authoring.ts (37 methods), assetRights, docqa or library — the async bug again, in the gate written to prevent it; it now asserts every mixin contributes at least one name. # SCALE-SEAM (84): dev budget + draws -> proforma.ts, pricing/traceability -> cost.ts, subcontractorBilling -> accounting.ts (1292 -> 1212), and the DevBudget* interfaces out of client.ts into types.ts, where a mixin can import them. THE TYPES DECIDED THE SPLIT: gmpReconciliation and syncGmpToHard look like cost.ts work (㉚ put the GMP stack there) but both return DevBudgetLine/DevBudgetSummary, and filing them there would have forced one type family into two mixins. cost.ts gmpBudget is the GC's own GMP; these two are the developer comparing against it. ALSO: (83) recorded the CX-1 banner as 44 methods using a regex that did not match `async` — it was 49. The right number was in the roadmap and (83) "corrected" it to a wrong one. Restored, and the UNFILED map is now derived by apps/web/src/api/unfiledMap.test.ts rather than proofread. # SCALE-SEAM (83): commissioning -> documents.ts, model checks + rebarCheckCage -> model.ts, qtoByFloor -> estimate.ts (1339 -> 1292). The "CX-1 commissioning loop" banner ran to the END of the file over 44 methods, three of them commissioning; with those three gone it named NOTHING, so it is replaced by an UNFILED map of the 35 that remain rather than narrowed. Two placements came from return shapes, not routes: qtoByFloor looks like a quantity and reads /qto/, but every line carries rate and amount and the payload has a grand_total, so it is priced takeoff; and rebarCheckCage came to model.ts while its two /rebar/ siblings did NOT, because a bar bending schedule is a quantity and an ACI cage check is the same question envelopeAudit asks. # SCALE-SEAM (82): model-quality audits -> model.ts, namingAudit -> documents.ts (1374 -> 1339). The RACI banner covered THIRTEEN methods and described four. namingAudit was on its way to model.ts until the return shapes were compared with documents.ts's namingConventions — same two subjects, containers and sheets, one stating the pattern and one reporting compliance. The banner is now split, and the three methods still under it that are not RACI are named as unfiled rather than left implicit. # SCALE-SEAM (81, unnumbered — the ⓵–⓾ glyph range is exhausted and the item carries no mark): turnover/G704 -> documents.ts, ifcClassify -> model.ts, market escalation -> cost.ts (1436 -> 1374). THIRD banner in three slices that over-claimed: ifcClassify sat under "turnover: substantial completion (G704)" separated by a blank line, a model question filed at whatever banner was nearest. # SCALE-SEAM ⓾: concept-render + aiReadiness -> ai.ts (1462 -> 1436). aiReadiness had been declined TWICE — by ai.ts at ㉑ on route grounds ("it is /ai-readiness"), and by documents.ts at ㉛ on semantic grounds ("it is an AI scorecard, not a CDE question"). The second characterisation is what moved it; the first is the superseded rule. MARKS widened to ⓾ (U+24FE), the last of the double-circled range. # SCALE-SEAM ⓽: drafting -> ai.ts, extractSheets -> drawingSheets.ts, bidLevelingDetail -> procurement.ts (1512 -> 1462). One banner, THREE questions: the "AI drafting" section labelled where the /draft/ run started and then carried on into sheet extraction and bid levelling — the exact failure procurement.ts's own header recorded at ⑥. No version bump (tag lag at bound). # v0.3.1143 follow-on SCALE-SEAM ⓺+⓻+⓼: preflight -> documents.ts, types+groups -> authoring.ts (1584 -> 1512). # v0.3.1143 follow-on SCALE-SEAM ⓷+⓸+⓹: license -> auth.ts, land -> entitlements.ts, pins -> topics.ts (1663 -> 1584). # v0.3.1143 follow-on SCALE-SEAM ⓴+⓵+⓶: clash import -> clash.ts, jobs -> routines.ts, projects -> auth.ts (1740 -> 1663). # v0.3.1143 follow-on SCALE-SEAM ⓱+⓲+⓳: inbox+escalations -> routines.ts, versions -> model.ts (1814 -> 1740). # v0.3.1143 follow-on SCALE-SEAM ⓮+⓯+⓰: roster+audit -> auth.ts, presence -> sync.ts (1867 -> 1814). # v0.3.1143 follow-on SCALE-SEAM ⓫+⓬+⓭: health -> evm.ts, safety+field-log -> schedule.ts, E57 -> model.ts (1916 -> 1867). # v0.3.1143 follow-on SCALE-SEAM ❽+❾+❿: bidding -> procurement.ts, quality -> topics.ts, closeout -> documents.ts (1966 -> 1916). # v0.3.1143 follow-on SCALE-SEAM ❺+❻+❼: actions -> routines.ts, RFI register -> topics.ts, feasibility -> entitlements.ts (2_000 -> 1966). # v0.3.1143 follow-on SCALE-SEAM ❷+❸+❹: ask -> ai.ts, submittals -> procurement.ts, CO log -> contracts.ts (2_035 -> 2_000). # v0.3.1143 follow-on SCALE-SEAM ㊾+㊿+❶: reports -> documents.ts, T&M + WH-347 -> cost.ts (2_065 -> 2_035). CJK enclosed numbers end at ㊿; ❶ is 51. # v0.3.1143 follow-on SCALE-SEAM ㊻+㊼+㊽: verification -> model.ts, rent-roll -> proforma.ts, opendata permits -> entitlements.ts (2_127 -> 2_065). # v0.3.1143 follow-on SCALE-SEAM ㊸+㊹+㊺: view templates -> model.ts, selections -> contracts.ts, progress -> schedule.ts (2_194 -> 2_127). # v0.3.1143 follow-on SCALE-SEAM ㊵+㊶+㊷: layout+loads -> model.ts, optioneer -> authoring.ts (2_258 -> 2_194). leftover // banners deleted. # v0.3.1143 follow-on SCALE-SEAM ㊲+㊳+㊴: graph+layers -> model.ts, macros -> authoring.ts (2_323 -> 2_258). # v0.3.1143 follow-on SCALE-SEAM ㉞+㉟+㊱: structure -> model.ts, RFI -> topics.ts, logistics -> schedule.ts (2_440 -> 2_323). No version bump (tag lag at bound). # v0.3.1143 SCALE-SEAM ㉝: Last-Planner pull board -> schedule.ts (2_486 -> 2_440). # v0.3.1142 SCALE-SEAM ㉜: appraisal/listing -> proforma.ts (2_531 -> 2_486). # v0.3.1141 SCALE-SEAM ㉛: ISO 19650 CDE/BEP/info-requirements -> documents.ts (2_580 -> 2_531). # v0.3.1140 SCALE-SEAM ㉚: GMP / pay-app stack -> cost.ts (2_641 -> 2_580). # v0.3.1139 SCALE-SEAM ㉙: investor capital stack -> finance.ts (2_696 -> 2_641), nine methods, existing withFinance wrapper. # v0.3.1134: clash group -> clash.ts (2_731 -> 2_696). SOFT-CLASH-RULES added clashClearanceRules + clashMatrix; the pin refused them in this file so the whole /clash cluster moved. # v0.3.1124: the four refusal-aware response shapes moved to types.ts as named interfaces (2_769 -> 2_731, under the 2_752 pin they had broken). THE RATCHET DID ITS JOB: 18 lines of new response fields went into this file and it went red, which is the whole point — types.ts exists precisely so the type surface lives apart from the client, and its header says so. The pin comes DOWN 21 rather than up, and the file is now 20 lines below where main had it. # ㉘ SCALE-SEAM ㉘ construction accounting -> accounting.ts (2_816 -> 2_752), ten methods in TWO non-contiguous clusters ~700 lines apart, which is the strongest case for grouping by what methods ANSWER: no prefix split would have found them together, and `journalBatchExportUrl` builds the URL for the batch `createJournalBatch` creates. The 2_837 this replaces was ㉗'s; v0.3.1119 had already taken it to 2_816 under duress from this very check, without lowering the pin — so the ratchet had slack it had not been told about. #: R39-DECOMP-VIEWER. Pinned at its CURRENT size before any extraction, deliberately. #: #: `app.ts` had no per-file entry, so it lived under the 5,200 global — which it also *set*, being