From 44a6ed4464b95861c3655af7d36202b4e8237c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 04:06:12 +0000 Subject: [PATCH 1/6] =?UTF-8?q?SCALE-SEAM=20(95)=20=E2=80=94=20element=20s?= =?UTF-8?q?tate:=20two=20read/write=20pairs,=20and=20why=20the=20matrix=20?= =?UTF-8?q?loses?= 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 2/6] =?UTF-8?q?SCALE-SEAM=20(96)=20=E2=80=94=20the=20as-bu?= =?UTF-8?q?ilt=20question's=20aggregate=20reader,=20and=20a=20witness=20th?= =?UTF-8?q?at=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 3/6] =?UTF-8?q?SCALE-SEAM=20(97)=20=E2=80=94=20the=20undo?= =?UTF-8?q?=20stack,=20and=20a=20destination=20that=20looked=20right=20and?= =?UTF-8?q?=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 4/6] =?UTF-8?q?Correct=20the=20undo/redo=20republish=20doc?= =?UTF-8?q?s=20=E2=80=94=20all=20three=20sites,=20not=20just=20the=20flagg?= =?UTF-8?q?ed=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 5/6] =?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 6/6] =?UTF-8?q?SCALE-SEAM=20(99)=20=E2=80=94=20the=20conte?= =?UTF-8?q?nt=20shelf,=20and=20a=20destination=20header=20that=20was=20wro?= =?UTF-8?q?ng=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