diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c4f1813..32ad00ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,1323 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build ## Unreleased +### DASH-TERMINAL — the GC dashboard's headline KPIs ran on two hardcoded state sets across 139 module workflows + +`dashboard.build` held `terminal_states = {"closed","answered","verified","done"}` and +`open_states = {open,submitted,draft,issued,scheduled,investigating,applied,agenda,proposed,pending}` +and applied both to **every** module. Both were wrong in both directions. + +**The one-line demonstration:** three voided RFIs, all past their due date, nothing else on the +project. The portal's `overdue` **KPI** said **3**; the overdue **queue** rendered directly below it +said **0**. `rfi.RFI_WITHDRAWN` exists precisely to say *"a voided RFI was retracted: it carries no +cost or schedule exposure"* — and the KPI counted all three as late work. + +The two numbers disagreed because they were computed two different ways, and **the correct +derivation was already in the tree three times**: `modules._terminal_states` (which feeds +`due_feed`, the queue), `workflow_config.validate`'s inline `shipped_dead` — whose own comment read +*"a genuinely terminal state SHOULD have no exit"* — and `submittals.terminal_states`. +`dashboard.build` derived nothing. There is one definition now, +`workflow_config.terminal_states`, and the other three delegate to it. + +**The two directions are not the same size, and the first draft of this finding got that wrong in +the flattering direction.** Structurally 74 (module, state) pairs are terminal in their own module +and absent from the literal — but the overdue loop only scans modules carrying a due-date field, +which is **16 of 139**, so 69 of those pairs can never reach the KPI. Measured exposure: + +| | | +|---|---| +| `overdue` inflated by finished work | **5 pairs** — `decision.superseded`, `due_diligence.cleared`, `rfi.void`, `risk.accepted`, `selection.approved` | +| `overdue` hiding a late record | **1** — `rfi.answered`, which the literal called finished | +| open KPIs missing live work | **140 pairs, 76 of 139 modules** — the large half | + +Five of the six headline open KPIs undercounted by name: `pending_change_orders` missed `approved` +CORs, `open_issues` missed `resolved`/`assigned`, `open_quality` missed `dispositioned` NCRs and +`corrected` deficiencies, `open_safety` missed `assigned`/`corrected` observations, `open_punchlist` +missed `ready`. `live_states` is the **complement** of terminal rather than a second allowlist, +because the allowlist is the half that went stale: a state is live or finished, with no third set to +forget. + +Both sets are resolved per module from the workflow **in force**, so a project that overrode its +workflow gets its own answer — asserted through the route: the same RFI in the same `answered` +state, past due, is open on one project and finished on another purely because of the transitions +saved against each. `kpi_basis` publishes that per-module split, since the client cannot derive it. +A third disagreeing site, the CEP report's "Open RFIs" KPI, now reads `rfi.CLOSED_STATES` instead of +its own literal. + +**And this item shipped its own defect one layer along, which `test_trunc_counted` caught.** The +first version took `open` from the module's DECLARED live states. But `state_counts_all` reports the +states the DATA holds, and those are not the same set: a record sitting in a state the workflow no +longer declares — written before a workflow change, or imported — fell into neither half and so +vanished from every open KPI. *Exactly the shape this item exists to fix.* `terminal` stays declared +(it is the one question a workflow can answer) and `open` is the complement over the OBSERVED +states, which also fails in the safe direction: an unrecognised state shows up as work rather than +disappearing. A module with no workflow at all then has nothing terminal, so all of it is open — +the right answer, not merely the convenient one. The asymmetry is documented on `kpi_basis` rather +than left as a trap. + +**And a second gate then found the consequence from the opposite direction.** With the dashboard no +longer calling it, `workflow_config.live_states` had tests and no production caller, which +`test_tested_but_unwired` reports by name. It is **deleted**, not frozen — that gate's own header +says the frozen list "is NOT an allowlist of things to ignore", and a declared-live helper sitting +in the shared module is where the next reader would reach for an "open" set and reintroduce exactly +this defect. `terminal_states` now carries a note saying why it has no companion, and the test keeps +a local `declared_live` stand-in whose job is to demonstrate that the derivation is *wrong* for this +question. *Two gates, one defect, from opposite directions — the one that caught the behaviour and +the one that caught the leftover.* + +**12 mutations proved red, three of them only after a second pass.** `workflow_config.validate`'s +carve-out for a shipped-terminal state turned out to be **unasserted before this change** — +`shipped_dead = set()` passed `test_workflow_config.py` — so the extraction inherited a coverage +gap rather than creating one; it is pinned now with a project holding a record in `closed`. And the +override mutation passed because the test called `workflow_config.effective` directly instead of +going through the dashboard: *asserting a helper is not asserting its caller*, which is the same +slip this session made on `projecthealth.submittal_status` hours earlier. New backend suite +`services/api/test_dash_terminal.py`. + +**A clean negative worth keeping:** a tree-wide sweep for *phantom* state literals — a name no +module declares, so the filter silently never matches — found **0** across 24 distinct literals and +66 sites. And the `in (...)` direction of the terminal rule is noise, since `in (states)` can mean +"is open" as readily as "is done"; only `not in (...)` claims every listed state is finished, and +restricting to it cut 8 candidates to the 2 real ones. *A rule that needs an exemption list is a +rule whose population is wrong.* + +### SUBMITTAL-RETURNED — a rejected submittal six weeks late read as closed, and two counts hid their populations + +`submittals.CLOSED_STATES` was hand-written as `("returned", "closed")`. `returned` is **not** a +terminal state: the shipped `submittal` workflow gives it two outgoing transitions, `close` and +`revise_resubmit`. So a submittal the A/E sent back marked *"Revise & Resubmit"* counted as closed — +out of `open_count`, and **unable to be overdue at all**, because the overdue test read +`state not in CLOSED_STATES`. Measured, one such submittal six weeks past its required-on-site date: + +| | `Revise & Resubmit` | `Approved` | +|---|---|---| +| `open_count` | 0 → **1** | 0 → 1 | +| `overdue_count` | 0 → **1** | 0 → 1 | +| `avg_turnaround_days` | 9.0 | 9.0 | +| `needs_resubmittal_count` | — → **1** | — → 0 | + +**The derivation is the argument for deriving it.** A state is terminal when no transition leaves +it. Run against `rfi/module.json` that reproduces `rfi.CLOSED_STATES == ("closed", "void")` exactly +— a tuple hand-written in a sibling module, by somebody else, and right. Run against +`submittal/module.json` it returns `("closed",)` alone. *Two siblings hardcoded the same kind of +list; the rule says which one drifted.* It resolves through `workflow_config.effective`, so a +project that replaced the workflow gets its own terminal states, published as `closed_states` with +`workflow_overridden` beside it — the contract `tm_summary` states for `accepted_states`. + +The corrected counts still agree between the two rows above — both are `returned`, both are late — +so `needs_resubmittal_count` is what separates them. *A state says where the record is; the +disposition says which way it is going.* + +Two more counts were over populations the response never stated: + +- `overdue_count` can only count a submittal carrying a `required_on_site` date. **40 submittals in + A/E review with none recorded returned `overdue_count: 0`**, which reads as *nothing is late*. + `dated_count` / `undated_count` state it; EXPIRY-UNDATED is the same shape one register over. +- `avg_turnaround_days` averages only the rows carrying both review dates — **3.5 days over 2 of + 40**, the same number a fully-dated 2-row register returns. `turnaround_count` states it. + +Both reached past the one card. `projecthealth` turned `overdue_count` into a red/amber/green tile, +so the rejected submittal above made **Submittals green**; its verdict is now +`projecthealth.submittal_status`, which also goes amber on an open register where nothing is +assessable — *a count that cannot be taken is not a count of zero*. And +`report_builders.construction` printed `Open` / `Overdue` / `Avg turnaround` as KPIs on an exported +register; each now carries its population, plus the engine's `coverage_note` as `Basis` and a +`RESUBMIT` marker per row. + +Client: the inline response type became the named `SubmittalRegister` / `SubmittalRow` (the card was +reading rows through `(r: any)`), with `apps/web/src/portal/panels/submittalCoverage.ts` + test and +the `reportCenter.ts` card rendering the headline, the turnaround basis, the coverage note, the +resubmittal list and the workflow caveat. `overdueCell` renders a row with no required-on-site date +as `—` rather than as on time. New backend suite `services/api/test_submittals.py`. + +**24 mutations proved red** — 13 on the server, 11 on the client. One is worth recording: the first +version of the health-tile fix put the verdict inline and the test asserted the new keys appeared in +the file; the mutation reverting the verdict to `overdue_count` alone **stayed green**, because the +tile's *headline* reads the same keys. *Reading a value and ruling on it are two different +questions, and a substring over a file cannot tell them apart.* The verdict is a named function now, +so a mutation has somewhere to land, and the test asserts its behaviour. + +Closes the `GET /projects/{}/submittals/register` entry in +`apps/web/src/api/responseUndeclared.test.ts` (`by_status` was the smallest part of it — the live +defect sat in two keys the gate could not fault because both were declared), and lowers the +`apps/web/src/api/deadFieldTyped.test.ts` ceiling to 5,748 keys / 539 sites. + +### ESTIMATE-ZEROQTY — a mapped class with no takeoff quantity was a $0 priced line, and the estimate showed no denominator + +`assemblies.estimate_resource_based` maps each IFC class to an assembly and prices it from the +takeoff dimension that assembly bills in. `_class_quantity` reads that dimension with +`agg.get(dim, 0.0)` — so a class that **mapped** and whose takeoff carried nothing in that +dimension was priced at **zero** and appended to `lines` as a successfully-priced line. `unmapped`, +the card's only footnote, cannot name it: the class is not unmapped. Measured on the engine, 80 +`IfcSlab` carrying no volume beside 20 `IfcWall` carrying area: + +| | old | corrected | +|---|---|---| +| `total` | 14,916 | 14,916 | +| the slab line | `IfcSlab \| 80 \| 0.00 m3 \| $0.00` | not in `lines` | +| `unmapped` | `[]` | `[]` | +| `no_quantity` | — | `IfcSlab × 80, needs m3` | +| population | `element_count: 100`, rendered nowhere | **20 of 100 element(s) priced** | + +**The total was right and its population was unstated.** The same 20 walls alone return the same +`total`, the same L/M/E split, the same crew-hours and the same empty `unmapped` — so the card +rendered a $14,916 estimate over a fifth of the building identically to one over all of it. `lines` +means PRICED now; `no_quantity` carries the mapped-but-unquantifiable classes with the dimension +each one needs, because the remedy differs — an unmapped class wants a mapping, these want a +takeoff. `priced_elements` / `unpriced_elements` state the population and `coverage_note` is the +engine's sentence about it. + +`element_count` had been declared on the client since the method was written and read by nothing, +which is why no key-level audit could see this: *a declared key is not a read one.* The card now +renders the denominator in its headline, the engine's note (in warning colour when the total covers +less than the takeoff), and the two causes as separate footnotes. +`apps/web/src/portal/panels/estimateCoverage.ts` + test, and a block in +`services/api/test_assemblies.py`; nine mutations proved red across both halves. Closes the +`GET /projects/{}/estimate/resource-based` entry in `apps/web/src/api/responseUndeclared.test.ts`, +and the inline response type became the named `ResourceEstimate`, lowering the +`apps/web/src/api/deadFieldTyped.test.ts` ceiling to 5,754 keys / 540 sites. + +### ITB-DOUBLE — the bid-coverage screen counted bids twice, read an unknown denominator as 0%, and dropped bids that matched no package + +Five defects in `itb.summarize`, **every one of them flattering the coverage** on the screen that +decides whether a trade has enough bidders to award: + +| | measured before | +|---|---| +| 5 bids on a package whose name equals its ref | `responses: 10`, `bonded: 10`, rate **100%** (not 50%), `coverage` flipped "thin" → "covered" | +| 5 bids, no invite list recorded | `response_rate: 0.0` — the value that means nobody bid — on a row whose `coverage` said "covered" | +| 10 invited + 3 bids, plus an unrated package with 5 bids | `overall_response_rate: 80%`, where the one rated package is **30%** | +| the two above together | a rate **over 100%** | +| 3 bids posted, 2 naming a package that does not exist | `total_responses: 1`, the other two reported nowhere | + +The double-count was `subs = subs_by_pkg.get(name) + subs_by_pkg.get(ref)` — two lookups +concatenated — and a package referenced by its own name is the common case, not an edge one. The +headline rate is the same two-population ratio PRICE-MATCHED fixed in `pricing.reconcile`; it is +computed over the rated packages only now, with `rated_packages` / `unrated_packages` / +`rated_responses` stating the split. An unrecorded invite list gives `response_rate: null` and a +`rate_basis` saying why. And a bid matching no package is counted and named, because a bid somebody +submitted that nobody is looking at is the one worth surfacing. + +The card printed `${total_responses}/${total_invited} responses` — **the third defect in fraction +form**, a numerator over every package against a denominator over only the rated ones. + +**The first mutation of the fix did not red, and that is the useful part.** The new matcher builds a +SET of keys, and the first draft also carried an `if (i in seen) continue` dedupe inside the loop. +Mutating that guard away left the test green — because a submission carries exactly **one** `package` +string, so within a single package the same index cannot arrive twice, so the guard **could never +fire**. The set is the entire fix. *A guard that cannot fire reads as the fix and is not one, and +the mutation that proves a fix is the one that removes the mechanism, not the belt beside it.* The +dead guard is gone and the mutation now reds with "five bids counted as 10". + +**The repo's fixture missed all five, for a reason that is new in this run of items.** +`test_assistant_itb.py` creates packages with `mk`, which posts a record and lets the module engine +assign the ref — so a package's `name` is never equal to its `ref` in that fixture, and defect (1) +was unreachable from it. Both its packages also record an invite count, which hid (2) and (3). +*A test whose data-creation helper cannot produce the colliding case cannot see a defect conditioned +on that collision* — where the previous items' fixtures merely happened to omit a case, this one was +structurally incapable of producing it. + +`rows` was `Record[]` here too, so `response_rate` and `coverage` were outside +every audit built on declarations — the second opaque row list in two days to have hidden a defect +in its own fields, after SET-REVISED's `sheet_index`. + +Gated by `apps/web/src/portal/panels/itbCoverage.test.ts` (17 cases) and the new block in +`services/api/test_assistant_itb.py`. Nine mutations proved red — four on the engine and five on the +client. One of the 17 client cases caught a grammar bug in the new code itself ("1 bid **name** a +package"), which is the argument for asserting the rendered sentence and not only the fields it is +built from. The `/bidding/itb` KNOWN_GAPS entry is closed here and the `deadFieldTyped` inline +ceiling went 5,775/542 → 5,770/541 in the same edit. + +### CARBON-UNCONVERTED — a material the carbon engine recognised but could not convert left the total with no trace but a tally + +`carbon.compute_item` declines a quantity line two ways, and `compute` counted both as `unmatched`: + +| refusal | what it means | remedy | +|---|---|---| +| no EPD factor matched | the material is unrecognised | add a factor | +| unit not convertible | the material **is** recognised, wrong unit family | record it in the factor's unit, or supply a density | + +**The second is the dangerous one**, because the line is a known emitter that leaves +`total_kgco2e`, `by_material` **and** `by_cost_code` entirely. Measured on a concrete frame whose +takeoff is in kg: + +``` +329.17 tCO₂e embodied (A1-A3) · 1 line(s) unmatched +materials: structural steel: 279.0 t · glazing: 50.2 t +``` + +2,400 t of concrete — worth roughly another 300 tCO₂e, and on most frames the single largest +contributor — is absent from both rollups. *A total that silently excludes its largest term is worse +than no total*, and embodied carbon is reported outward, to lenders and to authorities. + +**The engine is right to refuse and no density was added.** Converting a mass against a volume +factor needs one, and guessing it would fabricate the number. What was missing is a consumer that +says so: the counts are split into `no_factor` and `unit_mismatch`, `assessed` gives the total its +denominator, and `unconverted_materials` **names** the excluded emitters — a reader shown "1 line +unmatched" cannot tell a stair nosing from a concrete frame. `unmatched` is preserved as the sum of +the two, because it is declared and rendered: splitting a published figure out from under its +readers is a second defect, not a fix. + +**This was found by analogy, not by the audit.** It is the same class as PRICE-MATCHED's +`unmatched`/`unit_mismatch` split, in the sibling engine — `pricing.reconcile` and `carbon.compute` +both walk `production_quantity` with the same keyword-table-plus-unit-conversion shape, and both +collapsed the same two refusals. *Fixing a class in one engine is a reason to look at its twin.* +The route's KNOWN_GAPS entry read `["lines"]` and noted that `unmatched` **was** declared, so the +screen "can say how much it could not price" — which is exactly what made this invisible to that +axis. *A declared key says nothing about whether the number in it answers one question or two.* + +**And the repo's own fixture was the flattened case for the third item running.** +`services/api/test_carbon.py` asserts both refusals at the LINE level (`compute_item("concrete", +100, "kg")["kgco2e"] is None`), and its aggregate fixture holds only the no-factor one — so +`agg["unmatched"] == 1` was asserted over a population containing one of the two cases. The new +block derives the split from the per-line notes rather than writing the counts out. + +**The class was then swept, and the result named a cheaper thing to look for.** Four engines in this +tree refuse a quantity line for two distinguishable reasons: `pricing.reconcile` and `carbon.compute` +both lost the distinction (both now fixed), while `cost.reconcile_tm_rates` (`unmatched` vs +`unpriced`, carrying `in_totals` and a reason) and the `production` reconciliation (five named +statuses plus `STATUS_MEANING` on the wire) both kept it. **What separates the halves is the shape of +the return value.** The two that lost it summarise into a single scalar — `priced_total`, +`total_kgco2e` — and report refusals as a count beside it; the two that kept it return a per-line +status, where there is nowhere for a reason to go except onto the line. An engine whose output is a +total has to *choose* to carry the refusal; one whose output is a row cannot avoid it. *The return +shape predicts the defect, which makes it cheaper to look for than the defect.* Recorded as a +comment rather than a gate: the population is four, and a derivation needing judgement at each of +four sites is what `noRespelledShapes.test.ts` bought at the price of five exemptions. + +*(An earlier draft of that note credited `cost.py`'s correctness to the three review rounds its own +comments record. Measuring `production.py` — which had no such rounds and is the cleanest of the +four — showed that was the wrong cause. **A plausible explanation for a clean negative is still a +guess until the other negatives are checked.**)* + +Gated by `apps/web/src/portal/panels/carbonCoverage.test.ts` (14 cases) and the new block in +`services/api/test_carbon.py`. Nine mutations proved red — five on the engine (the refusals +collapsed again, the excluded materials counted instead of named, an unconverted line counted as +assessed, the clean case claiming an exclusion, `unmatched` no longer the sum) and four on the +client (the material becomes a count, `carbonSuspect` firing on the unrecognised case too, the two +refusals given one sentence, the panel wiring reverted). The `deadFieldTyped` inline ceiling went +5,782/543 → 5,775/542 in the same edit. + +### SET-REVISED — the controlled drawing register issued a twice-revised sheet as "New" + +`drawingset.register` classified issuance by counting drawing **records** per sheet number — +`change = "new" if len(revs) == 1` — while `revise` keeps **one** record per sheet and appends to +`data["revisions"]`. So a sheet revised twice through this application's own endpoint, with ASI and +CCD deltas recorded against it: + +| | before | corrected | +|---|---|---| +| `sheet_index[].revisions` | **1** | 2 | +| `sheet_index[].change` | **new** | revised | +| `revised_count` | **0** | 1 | +| `superseded_count` | **0** | 1 | + +`revisions()`, reading the same `data["revisions"]` in the same file, reported both deltas with their +instruments. **Two engines over one record, disagreeing about whether the sheet had ever been +revised** — and it reached the field: `transmittal_pdf` renders `change.title()` as the issuance +column, so the transmittal told subcontractors a revised sheet was New. That flag is what decides +whether a sub re-checks their shop drawings against it. + +**Why nothing went red is the shape of the coverage, not its absence.** The only test of this +classification, in `services/api/test_payroll_drawings.py`, builds its sheet as three *separate* +drawing records — the drawing-log import model, which is the model `register` was written for — and +asserts `revisions == 3, change == "revised"` correctly. `services/api/test_drawing_revision.py` set +up the delta model, recorded two revisions, and then read only `/drawing-set/revisions`, the route +that handles it. *A test that exercises one of two data models cannot see a function that handles +only that one, and a handoff each side tests alone is a handoff nobody tests.* The new assertion +lives in that second file, where the state was already set up, and is **derived from the delta +register's own answer** rather than from a number written out beside it. + +**The ordering defect is reported, not answered.** Across records the register has only labels to +rank, and `_rev_key`'s docstring claimed *"numeric revs sort numerically; alpha revs (A[]`, the **opaque** bucket, so `revisions` and `change` were outside every +audit built on declarations — which is why a key-level entry naming two row lists was the most that +axis could see of a defect in the counts themselves. + +Gated by `apps/web/src/drawingRegisterReadings.test.ts` (17 cases) and the new block in +`services/api/test_drawing_revision.py`. Eight mutations proved red — four on the engine (records +counted again, the deltas dropped from the history, the superseded description blanked, every sheet +reported ambiguous) and four on the client (the denominator dropped, the superseding revision +dropped, the order warning silenced, the panel wiring reverted). **Two of those mutations first +failed to APPLY**, because `revisions()` carries the same two lines and the search matched twice — +the `count == 1` guard refused rather than patching the wrong function, which is the only reason it +was noticed. *A mutation that does not apply proves nothing, and "0 failures" looks identical either +way.* The `/drawing-set` KNOWN_GAPS entry is closed here, the `deadFieldTyped` inline ceiling went +5,789/544 → 5,782/543 in the same edit, and the unowned-file ratchet took a one-line Lane B row — +**eight for eight** on top-level `apps/web/src` pairs. + +### PRICE-MATCHED — the pricing variance subtracted two totals over different populations, and the sign came out inverted + +`pricing.reconcile` sums `priced_total` over every takeoff line the price book could price, and +`estimated_total` over the strictly smaller subset of those that also carried an +`estimated_unit_price`. `variance_total` subtracted the one from the other. Run against **this +repository's own `test_pricing.py` fixture** — 2 priced lines, 1 of them estimated: + +| | before | corrected | +|---|---|---| +| `priced_total` | 32,250 | 32,250 | +| `estimated_total` | 20,000 | 20,000 | +| `comparable_priced_total` | — | **18,500** | +| `variance_total` | **+12,250** | **−1,500** | + +The analytics card renders a positive variance in `--status-warn` red, so it showed an overrun on a +takeoff the book prices **$1,500 cheaper** than the estimate. The sign was wrong, not the +magnitude. And the same test file already asserted that sign one level down — +`concrete["variance"] == round(100 * 185 - 100 * 200, 2)`, commented *"negative variance (book +cheaper)"* — without ever checking the total that contradicted it. *A test that is right at the row +level and silent at the summary level reads exactly like a test of the summary.* That missing +assertion is now the gate, and it is **derived** from the per-line figures rather than written out: +with exactly one comparable line, the summary must equal that line's own `variance`. + +**The coverage half was the one the audit could see, and it was real.** `matched` was declared on +the client and used only as a truthiness gate, so 400 takeoff lines with 3 priced rendered +identically to 3 lines with 3 priced — `Priced $55,500 (book) vs estimate $27,000 — variance +$28,500` — with 397 unpriced lines and $397,000 of estimate absent from the sentence. `line_count`, +`unmatched` and `unit_mismatch` were all computed by the engine and thrown away; the last two were +recoverable only by parsing per-line `note` strings. + +The engine's module docstring already promised that "unmatched materials or unit mismatches are +reported, not guessed" — and they were, per line. **The promise was kept at the row level and the +summary discarded it.** So the fix is split the usual way: the engine states `coverage_note` and the +three population counts, `portal/panels/pricingCoverage.ts` renders them, and the card now reads +`Priced $55,500 (book) — $1,500 under estimate across 1 line priced AND estimated, not the 2 priced +above ($18,500 priced vs $20,000 estimated)` over `2 of 4 takeoff line(s) priced (1 with no +price-book match; 1 matched in the book but refused on the unit)`. + +Three further distinctions the old card could not make. **`variance_total: null` is not a variance +of zero** — the old code tested `v != null` and otherwise printed nothing at all, so "no line +carries both a price and an estimate" rendered as silence beside a priced total, which reads as +agreement. **The note must not assert a contrast that does not exist**: its first draft printed +"the variance is over 3 lines, which is not the 3 priced lines above" whenever the two coincided, +and a warning a reader checks once and finds spurious is a warning they stop reading. And the two +senses of "matched" now differ by a published number rather than by inference — a row refused on its +unit reports `matched: "concrete"` while the summary's `matched` counts priced lines only, and +`unit_mismatch` is exactly that difference. + +Scope was verified rather than assumed: `variance_total` had exactly one reader, no test pinned it, +and `pricing.reconcile` reaches no report builder and no assistant snapshot — a clean negative worth +recording, since RENTROLL-BASIS's equivalent figure did reach a lender's PDF. So the semantics +changed in place with its one consumer in the same commit. + +Gated by `apps/web/src/portal/panels/pricingCoverage.test.ts` (16 cases) and the new block in +`services/api/test_pricing.py`. Eight mutations proved red — four on the engine (the two-population +subtraction restored, the note dropping its denominator, no-comparable reported as a zero variance, +`unit_mismatch` folded into `unmatched`) and four on the client (the priced total printed beside the +estimate, null treated as zero, the false contrast always printed, the panel wiring reverted). The +`pricing/reconcile` KNOWN_GAPS entry is closed in this change, and the `deadFieldTyped` inline +ceiling went 5,805/545 → 5,789/544 in the same edit as the conversion. + +### TM-UNBILLED — $90k of unsigned drafts and $90k of GC-accepted T&M were the same sentence + +`tm.summarize` returns `unbilled_total = grand_total - billed_total`, and `billed_total` counts the +single state `billed`. The `eticket` workflow is `draft → super_signed → gc_signed → billed`, so +"unbilled" spans three different liabilities: a claim nobody has signed, a claim the sub signed and +the GC has not accepted, and one the GC HAS accepted and not yet invoiced. Measured on the engine: + +| fixture | grand_total | unbilled_total | `by_status` | +|---|---|---|---| +| all `draft` | 90,000 | **90,000** | `{draft: 90000}` | +| all `gc_signed` | 90,000 | **90,000** | `{gc_signed: 90000}` | + +The T&M rollup card printed both as `2 tickets · … · total $90,000 · unbilled $90,000` — +byte-identical, for money the GC owes versus paperwork nobody has signed. The mixed case shows the +split existing and being discarded: `{draft: 25000, super_signed: 15000, gc_signed: 40000}` became +`unbilled $80,000`. + +**Not the CO-REJECTED shape, and that is worth stating because it is the first thing to suspect.** +`eticket` declares no refusal state, so there is no money-nobody-owes inside the figure. The total is +sound; what was lost is acceptance. + +The card now reads `billed $10,000 · unbilled $80,000 ($40,000 GC-accepted · $15,000 sub-signed, +not GC-accepted · $25,000 unsigned)`, with full-precision figures because the parts are claimed to +sum to the whole — `money()`'s compact form renders `{500, 400, 100}` as `$1k · $400 · $100`, a +partition whose parts visibly do not add up. `billed_total` and `by_status` were both undeclared on +the client, so no screen could have reached the split. + +**Which states mean "the GC accepted this" is now the server's answer, derived from the workflow in +force.** The first draft of the client module wrote out `gc_signed` / `super_signed` / `draft`, which +is a contract term hardcoded on a screen — and since WORKFLOW-DARK a project may replace the +`eticket` transitions. It was hardcoded a second time too: `cost.summary` counted T&M as actual cost +for a hand-written `("gc_signed", "billed")`, where a stale copy is wrong in the expensive direction, +dropping accepted money out of `actual` so the projected over/under looks better than it is. +`tm.accepted_states` derives the set — a state counts when **every** transition reaching it is +performable by the GC alone — and `tm_summary` states it as `accepted_states` beside +`workflow_overridden`. The argument that this is the right rule is that it reproduces the pair the +two hand-written copies agreed on, and `services/api/test_eticket_tm.py` asserts that equality +instead of leaving it in a docstring. `super_signed` is the load-bearing exclusion: `super_sign` is +party `["Subcontractor", "GC"]`, i.e. the sub's signature, so a rule using `any` rather than `all` +would have swept it in and made the whole item pointless. + +`accepted_states` is `null`, never `[]`, when the module registry cannot answer — "nobody resolved +it" and "nothing is accepted" are different facts, and the second reads as a plausible answer while +understating what is owed. **Asserting that distinction on `summarize`'s default was not asserting +it**: a mutation returning `[]` from `effective_accepted`'s unresolved branch left every assertion +green until the branch was driven directly. *Reporting a value and deciding it are two different +questions.* + +Gated by `apps/web/src/tmReadings.test.ts` (23 cases, including the two 90,000 fixtures asserted to +produce **different** text) and the new block in `services/api/test_eticket_tm.py`. Six mutations +proved red: `all`→`any` in the party test, a drifted `SHIPPED_ACCEPTED`, `[]` for the unresolved +set, the bare total restored in `unbilledLine`, a hardcoded `gc_signed` in place of the stated set, +and the panel wiring reverted. + +### FIELDLOG-WINDOW — "coverage 100%" over three logged days of a 200-day job, every one a draft + +`dailylog.summary` computes `span_days = (max(dates) - min(dates)).days + 1` from the **logged** +report dates, so `coverage_pct = 100 * logged_days / span_days` measures the window the reports +themselves define. The denominator is derived from the answer. Measured on the engine: + +| fixture | span | logged | coverage | +|---|---|---|---| +| one report | 1 | 1 | **100.0%** | +| three contiguous days (on a 200-day build) | 3 | 3 | **100.0%** | +| 20 of 31 days, with gaps | 31 | 20 | 64.5% | + +A superintendent who logged three days and stopped got a clean 100%. + +**And drafts count toward everything.** Three unsigned reports report the same `coverage_pct: 100.0` +with `submitted_count: 0` — identical on every number the card printed. `submitted_count` was +declared and never rendered; `span_days`, `logged_days`, `draft_count`, `first_date` and `last_date` +were undeclared (RESPKEY-HOP2 made them visible), so the screen could not have shown the window even +if someone wanted it. On a document that becomes evidence in a delay claim, "100% coverage, nothing +submitted" and "100% coverage, all submitted" must not be the same sentence. + +**The repo's own fixture was the flattened case.** `test_construction_depth.py` creates three daily +reports with `mk`, which leaves them in the initial `draft` state, and asserted +`coverage_pct == 100.0` as correct output — a test pinning a tautology and reading like a +measurement. + +The percentage is **not** redefined: "how complete is the run I have" is a real question, and +measuring against the schedule's span is a different one plus a product decision (the schedule may +not exist or be unbaselined, and a field log legitimately starts after mobilisation). What changed is +that the window travels with the figure — `coverage_note` carries the engine's sentence, and +`fieldLogReadings.ts` renders `3 of 3 days — … is the whole window these reports span, so 100% here +means unbroken, not complete` plus the draft split. + + +### RENTROLL-BASIS — 30% occupancy read as 100% when the building's area was unset + +`rentroll.summarize` computes `total_rentable = building_rentable_sf if building_rentable_sf else +occupied_sf`, and `rent_roll` sources that from `project.dev_property["sqft"]` — a field nobody is +obliged to fill. Measured on the engine, same two leases both times: + +| building area | total_rentable_sf | occupied_sf | vacant_sf | occupancy_pct | +|---|---|---|---|---| +| 20,000 sf | 20,000 | 6,000 | 14,000 | **30.0%** | +| unset | 6,000 | 6,000 | 0 | **100.0%** | + +`occupancy_pct` was the only one of those four the client declared (RESPKEY-HOP2 is what made the +others visible), so the Operations card printed "Occupancy 100%" with no vacancy row and could not +have shown the basis. + +**It did not stop at the screen.** The figure is a KPI in the rent-roll PDF +(`report_builders/finance.py`), it is passed into the income basis (`proforma/income_basis.py`) and +into the assistant's project snapshot. A figure that leaves the building as a lender-facing KPI is a +different class of wrong from a caption — and `income_basis` already refuses to underwrite an empty +rent roll as zero income, which is the same hazard one field along. + +Nothing downstream could work it out: a genuinely full building reports the **same four numbers**, so +an equality cannot separate them. The engine now states `occupancy_basis` (`"building"` / `"leased"`) +with `building_rentable_sf` and a note naming the remedy; the numbers are unchanged, and all four +consumers qualify the figure. The card shows Rentable / Leased / Vacant as rows of their own and +`n/a` where there was never a denominator; the PDF KPI is withheld rather than restated. + + +### SQL-CAP — the data browser printed its own row cap as a total, and the cap could not be raised + +`POST /connections/{cid}/query` is the read-only SELECT console in the Data-connections modal. +`connectors.query` wrapped the SQL as `SELECT * FROM () AS _q LIMIT {limit}` and returned +`row_count` = the length of that page, and the console printed it bare. Measured on a seeded +connection: a 5,000-row table read at the console's hardcoded 200 and a 200-row table read in full +both returned `{row_count: 200, limit: 200}` — byte-identical, both captioned **"200 rows"**. An +equality cannot separate two equal things, so the server now fetches one row past the cap and +reports `truncated` as a fact. + +The table list's preview buttons run `SELECT * FROM ""`, so the unbounded select was the +DEFAULT path through the screen, not the edge case. And the cap was unraisable — the console passed +200 where the server clamps at 1000, and an inner `LIMIT 1000` is still wrapped by the outer one — +so a row-limit selector landed with the wording. `connections/ledgerBrowse.ts`, one function away, +already named this class for the QuickBooks read in the same modal. + +### RESPKEY-HOP2 — the response-key derivation reported "did not look" as "nothing to find" + +`response_keys.py` followed exactly one hop from a route into an engine. `GET +/projects/{pid}/drawing-set` is `return drawingset.drawing_set(db, pid)` and `drawing_set` is +`return register(drawings)` — two hops — so it resolved to nothing and was published as +`{"complete": true, "keys": []}`, byte-identical to a file download. `complete` means *the key set +is the whole truth*; it was being asserted over a key set nothing had looked for. + +Measured over 1,018 routes: 291 return a single unresolved call — 14 one hop away through a bare +same-module call the resolver refused by rule, 20 two hops away, 84 not pure delegations, 173 +genuinely unfollowable. Following both took comparable routes **584 → 621**, made nine +already-compared routes gain keys (`/schedule/health` eight, `/standards/check` five), and cost none +theirs. 13 newly visible disagreements are seeded in `KNOWN_GAPS` with what each key is, and 11 +routes joined `INCOMPLETE_LOWER_BOUND` — a correction, not a tightening: the old index read a +function's dict-literal returns and ignored its other return paths, so `if x: return {...}` / +`return build()` published its named keys as complete. + +The remaining blindness is counted rather than silent: a route whose return cannot be followed now +carries a `call-unresolved` or `call-no-dict` shape and a `stops` entry naming where resolution gave +up, and `UNREAD_WITH_CALLER_CEILING` pins the 108 unreadable routes that do have a typed client +caller. The resolver's own self-test gained three arms, each mutation-proved and each with its own +fixture so that one regression cannot red another's check with the wrong message. + +### CPM-SILENT — the schedule card said the loop was broken and never said a relationship was dropped + +Found by RESPKEY-HOP2 on the route it made comparable. `GET /projects/{pid}/schedule/cpm` sends 15 +keys; the client declared 6. Two of the missing nine are the only things the response says when the +schedule is wrong. + +On a three-activity loop the card rendered **"CPM: project 0d · 0/3 on the critical path · ⚠ cycle +broken"** with "Critical: —", while the response carried `cycle: ["a1","a2","a3","a1"]` and an error +issue reading *"Circular logic: a1 -> a2 -> a3 -> a1"* / *"no dates computed; break the loop and +recalculate"*. `schedule_cpm._cyclic` computes **no dates at all** — breaking the loop in dictionary +order is what it deliberately stopped doing — so the caption described the behaviour it replaced, +over a `project_duration` of 0 taken from `blank_result()`. + +The quieter half needs no loop: a typo'd predecessor is reported as +`MASSING.PREDECESSOR_UNRESOLVED` / "relationship dropped", and the card rendered **"project 2d · 2/3 +on the critical path · Critical: A → B"** — a confident schedule with one activity hanging off +nothing. `schedule_engine.build_network`'s docstring says exactly why that list exists; nothing was +reading it. + +`portal/panels/cpmRead.ts` now owns both wordings for both renderers, reports a refused computation +as a refusal rather than as zeros, and translates the engine's record ids into the refs a planner +typed. + + +### SSO-DOOR-DERIVED — the sign-in door list is derived from the helper's callers + +The roadmap's concurrency record predicted "a fourth sign-in path is the risk". SCIM is that fourth +path, and it correctly routes through `auth.get_or_create_sso_user` — but `test_sso_provision_race` +tested a hardcoded list of three and reported "all 3 auto-provisioning doors", silent about the +fourth and about any fifth. + +The population is now derived twice: every caller of the helper must be a known door, and every site +constructing a `User` must either reach the helper or be a by-request creation guarded with a 409. +Both are proved by writing a real fifth door into the tree — wired to the helper it reds the first, +provisioning inline it reds the second. + +### CI-LATEST-DARK — the Model CI tool opens on the stored badge instead of re-running the pack + +`ciRun` was wired and `ciLatest` was not, so the persisted model-CI report — stored precisely so the +badge survives without recomputation — could only be seen by running the whole pack again (rule +library, completeness, clash, IDS, quantity drift). + +The tool now opens on the stored report with **⟳ Run again** beside it. Wiring it exposed a state +`ciRun` can never return: a project that has never run CI, which the old renderer would have shown as +**"0/0 passed"** — a score, for something never scored. It now shows the engine's own "No CI run yet." +with no score and a warn colour, and a run that had nothing applicable to check is kept distinct from +a project that never ran. + +### SCOPE-EMPTY — an empty scope register no longer reads as one where nothing was done + +`scope_register.register` returned `0.0` for all three coverage percentages on an empty register — +byte-identical to a register holding one item with nothing quantified, allocated or scheduled. Those +are opposite findings, and only `item_count` separated them. + +`spine.traceability`, named beside this engine in `apps/web/src/api/coverageMaps.ts` as the other +completeness mapper, has always returned `None` for an empty population. The two now agree. The +existing test asserted the old value, so it now asserts the property that value stood in for: the +three are `None`, a real 0% is still `0.0`, and the two cannot be confused. + +### PROGRESS-UNMATCHED — a capture diff now says how much of the capture it could not use + +`progress_rollup.capture_diff` filtered both its added and disappeared sets through the model's +element list while its note promised disappearances were "never silently dropped". The promise held +only among elements the current model still contains — which excludes the case the flag is for. + +Measured: 500 capture GUIDs at t2 against a 300-element model reported 300 installed, 100% complete, +and 200 dropped with no key naming them. `unmatched_t1`/`unmatched_t2` are now reported and the note +states the scope it actually has, so a capture aimed at a different model version is visible instead +of arriving as a quietly smaller diff. + +### SCAN-DARK — scan-to-BIM deviation and LOD 500 verification reach a screen + +SCAN-TRUNC's refusal tells the reader to use `/scan/verify-lod500` instead. That route had no client +method and was frozen as deliberately clientless, so the refusal redirected to something unreachable. +`scanDeviation`'s declared type also omitted every field SCAN-TRUNC added, making the refusal +invisible to the client. + +Both are wired now, behind **📡 Scan → as-built** in the model-QA tools. The engines were measured +first and are sound: a scan covering nothing reports `verified: 0, stamped: 0, uncovered: 50` and +stamps nothing. The card renders the aggregate's refusal as a refusal rather than a zero, gives +`uncovered` the same weight as `verified`, and shows a finding as *verified as wrong, not stamped* — +a punch item rather than a handover. + +### TRUNC-COUNTED — three screens printed the size of a page as the total, and one acted on it + +`roundtrip_diff` carries three bounds and disclosed one. `apps/web/src/api/model.ts` never declared +even that one, so the QA panel could not read it — while the same file reads the identical flag +correctly on another response whose type does declare it. + +The Apply button posts the truncated `changes` page, so a sheet past the cap was **applied in part +and reported as whole**: the model ends up differing from the spreadsheet the operator believes they +applied, with no error. Reachable by construction — one change per changed *cell* against a 5,000-row +bound overflows a 1,000 cap with a single property column. + +All three bounds now report themselves, `spine.traceability` returns `gaps.counts`, the dashboard +returns `action_item_count`, and the three screens read the totals instead of the page lengths. The +Apply button names what it will actually write. + +No sweep gate, and that is deliberate: 43 list truncations, **26 already carrying a sibling count**, +and joining the rest to `.length` reads by NAME reports 35 sites of which the ones read in full were +correct code. A leaf name is not a response. + +### SCAN-TRUNC — a model cut short turned a correct building into 500 as-built findings + +`scan_deviation.model_surface_points` caps the reference at 200,000 surface vertices and reaches the +cap by **breaking out of the element iterator**, so it drops whole elements rather than thinning +evenly. Scan points over a dropped element were measured against whatever surface remained. + +Measured on a two-wing model built exactly to design: **100.0% within tolerance** against the full +reference, **50.0% with 500 out-of-tolerance points and a 50 m maximum** once the reference was cut +short — with nothing in the response saying so. That is a fabricated defect, not a loss of precision, +and on a QA/QC check it sends a crew to re-survey a wing that is fine. It is the *opposite sign* from +CLASH-TRUNC, where truncation made a partial matrix read clean. + +The two caps are now handled differently on purpose. A truncated **scan** is a coverage claim — the +points read were read correctly — so the verdict stands with its coverage beside it. A truncated +**reference** is a correctness claim, so `within_pct` and the histogram are withheld and the response +says why, pointing at `/scan/verify-lod500`, which queries per element and never truncates the model. + +### SUPPLY-DARK — an empty competitive set reported the most favourable market verdict there is + +`services/api/src/aec_api/supply_pipeline.py` weighs a competitive pipeline by what is **recorded** +about each project rather than by the label on the deck: + +> "Under construction" on a broker deck and "under construction" with a recorded construction deed of +> trust are not the same fact, and a rendering with no permit is neither certain supply nor zero. + +`ApiClient.competitiveSupply()` was written for it and no screen called it. Its response was also +typed `Record`, so every field was invisible to `deadFieldTyped.test.ts` and to any +other audit that starts from the client's declarations — five interfaces are now declared in +`apps/web/src/api/creDeal.ts`. + +**Measured through the engine first.** A pipeline of four projects, none matching the subject's +product type, returns `counts.competing: 0` and `excluded.counts.wrong_product: 4` beside an index +whose band is **`"undersupplied"`** and whose `lsi` is `0` — the most favourable verdict the engine +can produce, byte-identical to a market with genuinely no competition. *"Undersupplied" is an +argument to build.* + +The engine is sound: the coverage is in the response, and its own note says the excluded projects are +listed "so a thin competitive set is visible, not implied". So the new **Competitive supply** card on +the Feasibility tab withholds rather than annotates — on a vacuous set there is no band, no totals and +no discount, and the exclusions with their reasons are the only thing on the card. Certain and rumored +units are never added; `from_status_label` is rendered, because it marks the row whose tier was +inferred from a status string rather than read from a recorded flag. + +### The pre-committee gate card dropped the coverage its own gate demands + +`services/api/test_verdict_coverage.py` — added one commit earlier, to derive exactly this class — +failed on `apps/web/src/proforma/decisionGateCard.ts`, the card written to demonstrate the principle +it enforces. + +`_gate()` in `decision_gate.py` splats `**extra` onto the row, so `rent_roll_scrubbed` arrives +carrying `ran`, `failed` and `not_applicable`. The card declared five fields and rendered `detail`, +which expresses none of them. Measured through the real engine with a scrub of `ran: 1, +not_applicable: 6, failed: 0`: + +> `status: "pass"` · `detail: "the scrub ran but found nothing"` + +— where "nothing" means *no problems found*, over six checks that never ran. + +`DecisionGateRow` and `DecisionGateResult` are now named interfaces in `apps/web/src/api/creDeal.ts` +rather than an inline re-spelling, and `coverageOf()` renders the coverage beside the verdict as a +rule over whatever a row carries, not as a special case for the one gate that carries it today. The +engine's own status word is left alone: this card declines to drop what the engine measured, it does +not overrule the engine. + +### DECISION-GATE-DARK — the keystone over everything else on the tab, and nothing called it + +`services/api/src/aec_api/decision_gate.py` names the failure it exists to prevent: + +> a package that *looks* finished reaching a committee, because nothing between the analyst and the +> room ever asked whether the numbers were sourced. + +Seven gates — citation coverage, comp tiering, the T-12 tie-out, the rent-roll scrub, the deal-room +authority gate, exhibits, and a named human sign-off — and two rules that make it honest rather than +decorative: + +> A gate whose evidence was not supplied is `unknown`, and unknown BLOCKS — absent evidence must never +> read as a pass. The actions list says what to do, not just what failed. + +**That is the principle this session spent itself enforcing in consumers, implemented here at the +composition level** — so the card's job is not to add judgement but to avoid subtracting it. +`ApiClient.decisionGate()` was callerless, so none of it was reachable. + +### Added + +- **`apps/web/src/proforma/decisionGateCard.ts`**, last on the Underwriting tab because it is the gate + over everything above it: it reads the evidence the other cards produce rather than the conclusions + they draw. +- **The actions lead.** A list of failures is a status report; a list of actions is what somebody can + act on at the moment they are stopped, which is the engine's stated contract. +- **`unknown` is rendered as blocking, in word and in colour** — *"no evidence — blocks"*, on the warn + token, distinct from both pass and fail. A card that renders it as a shrug states something the + engine did not. +- **A vacuous pass keeps its reason.** With nothing supplied, `exhibits` passes *"no exhibits were + required for this package"* — a gate that tested nothing, which is exactly what the other six exist + to refuse, so it must not render as a bare tick. +- **The evidence it gathers is named.** The card sends the two things this app can produce on demand — + the authority assessment and the rent-roll scrub — and leaves the other five absent, which makes them + `unknown`, which blocks. **That is the correct answer rather than a gap in the card**, and naming + what was supplied makes an `unknown` legible as *"not gathered here"* rather than *"gathered and + found wanting"* — two different things to do about it. A source that fails is omitted rather than + filled in: an invented payload would make a gate pass or fail on something the card made up. +- Measured with no evidence at all: **6 of 7 `unknown`, verdict blocked, six actions emitted.** +- `decisionGateCard.test.ts` — ten tests, **seven mutations**, each redding its own: a neutral word for + `unknown`, the pass colour for `unknown`, dropping the actions table, labelling actions by key, + dropping the per-gate detail, folding `unknown` into the failed count, and inventing a payload for a + source that failed. + +### And the lane table caught the item I opened + +`apps/web/src/shell/roadmapLanes.test.ts` refused the build: **AUTHORITY-DOWNSTREAM** was opened as a +roadmap item and assigned to neither a lane nor Parked. Parked is the right home — that list exists +for items needing a decision, *"so nobody starts one thinking it is a sprint item"* — and this one is +exactly a decision: whether a stale authority table should **refuse**, **warn** or **annotate** the +analysis downstream of it. Wrong in either direction is expensive: refusing makes the tab unusable +during diligence, annotating makes the gate decorative. + +### AUTHORITY-DARK — a gate you could read and could not satisfy + +`services/api/src/aec_api/deal_authority.py` opens with the case for itself: + +> A data room accumulates three offering memoranda, two rent rolls and a tax bill from the year before +> the reassessment. Every one of them is a real file; only one of each is *authoritative*. Analysis +> that reads whichever copy it happened to open is how a superseded number reaches a committee. + +**The finding is sharper than "nobody called it", and the first draft of this entry got it wrong.** +`dealAuthority()` — the READ — has had a caller all along: `portal/panels/design.ts` renders the gate +on its go/no-go card, and renders it well. What was callerless is **`saveDealAuthority()` — the +WRITE.** So the table could be *seen* and never *declared*: the design panel could tell you *"Source +facts NOT current"* and no screen in the product could make them current. **A gate you can read and +cannot satisfy is worse to ship than one nobody can see**, because it is visibly unfinished and reads +as a bug in the analysis rather than as a missing surface. + +### Added + +- **`apps/web/src/proforma/authorityCard.ts`** on the Underwriting tab — the gate on whether any + figure on that tab should be trusted, so it sits with the underwriting rather than beside one of the + things it governs. A row per fact type: document, date, optional freshness override. Saving returns + the **reassessment**, which is what the card renders — re-fetching would race the write and could + show the previous verdict. +- **The gate leads**, because its purpose is *"to stop the work, not to annotate it after the fact"*, + with the engine's own `why` per blocked fact type. Advisory is rendered **apart** from blocking: a + stale offering package is not a stale tax bill, and one list would make the required ones look + negotiable and the optional ones alarming. `superseded_still_active` gets its own table — it is the + one row type a reader cannot derive from the others, because nothing about it looks wrong in + isolation. +- **Age against the fact type's own limit**, not a bare "stale": a tax bill and an offering package go + stale on different clocks, and `147 / 60d` is checkable where "stale" is asserted. +- An undated row is refused **here**, before the PUT, because the server's 422 names an entry by index + and the person is looking at a table of labels. + +### The tripwire is the load-bearing test + +`FACT_TYPES` is mirrored from Python because the GET returns declared rows plus the *required* missing +ones — so an optional fact type nobody has declared yet (a title commitment, a survey) appears in +neither, and a card built from the response alone could never offer to add one. +`authorityCard.test.ts` reads `deal_authority.py` off disk and asserts every key, label, default +freshness and required flag agrees, in order — the `ui/reportMoments.test.ts` technique against +`reports.py`, for the same reason. **The `required` flag is the one that matters**: if it drifts, the +card shows a fact type as optional while the gate blocks on it. + +### What I checked and did not find + +Four engines this session carried a verdict that went vacuous once nothing had been evaluated, so the +empty table was measured first. **It fails closed**: `gate.passes` is `not blocking`, and with no +entries the three required fact types are each missing, so it blocks; one fresh rent roll still blocks +on the other two. `test_verdict_coverage.py` leaves it alone correctly — `not blocking` is not a +verdict quantified over a subset. *A clean negative is worth as much as a finding, and saying so is +part of the report.* + +### Fixed + +- **`saveDealAuthority` declared `assessment: { gate: { passes: boolean } }`** — a narrowed + re-spelling of the record the GET returns in full, which forced a consumer that wanted to render the + saved verdict to cast. That is the shape `apps/web/src/api/noRespelledShapes.test.ts` exists to + catch. `AuthorityAssessment` is declared once and used by both routes. +- **`stale` carried the full row and was declared `{fact_type, days_over}`**, so a screen could say a + document was stale and not which one; `advisory` was `unknown[]` when it is `{fact_type, why}[]`; + `reviewer` and `supersedes` on a table row were undeclared. + +### And a test that was satisfied by the wrong code path + +The blocking-table test asserted the fact type's **label** against the whole card's text — and the +same label also appears in the "Not declared" line, which is built straight from `FACT_TYPES` rather +than through `label()`. So a mutation rendering the raw `fact_type` key in the blocking table +**passed**. *An assertion satisfied by a different code path than the one it is about is not an +assertion about that path.* Scoped to the blocking table's own cells, and re-mutated. + +### VERDICT-COVERAGE — the class found five times by hand, now derived + +Five engines in this session returned a boolean verdict beside a count of what they could not +evaluate, and the verdict was only meaningful in light of the count: + +| engine | verdict | vacuous because | +|---|---|---| +| `soft_clash` | `coordinated` | fed a page of the matrix (CLASH-TRUNC) | +| `rent_scrub` | `clean` | `bool(ran) and not failed` — 1 of 7 checks ran | +| `t12` | `tie_out.reconciles` | reference derived from the answer | +| `covenants` | `clean`, `at_risk` | counts zero for want of inputs | +| `sequence_clash` | `clean` | over the activities that had a date and a location | + +Every one of those engines is careful: each hands the caller the coverage beside the verdict, and +three attach a sentence saying why. **The defect is always in the consumer**, and for four of the five +there was no consumer — so nothing was wrong until somebody wrote one. Finding it a sixth time by hand +is what `services/api/test_verdict_coverage.py` exists to prevent. + +### Added + +- **`services/api/test_verdict_coverage.py`.** A **subset verdict** is a dict key whose value is + `bool(A) and (all(…)/any(…) | not B)` — the `bool(A)` guard being the author writing down that the + subset can be empty, which is precisely when the verdict means nothing. Its **coverage** is a + sibling key, including nested ones, whose name by itself says something was not evaluated. A + **consumer** is a web file calling the client method whose declared response type carries the + verdict plus ≥3 of its siblings. Every consumer must read at least one coverage field. +- Population today: **5 subset verdicts, 616 client methods, 0 bare consumers** — and the derivation + found **three instances nobody had found by hand** (`sequence_clash`, `fived`, `perf_budget`). Two of + those have no client method at all, so the gate reports them as unexposed rather than passing them + silently. +- It replays the pre-fix consumer it was written from — frozen in the file, not fetched with + `git show`, because CI's checkout is shallow — and must re-find it, and must find its repair clean. + +### Fixed — the defect the gate found on its first run + +- **`viewer/tools/analyseSection.ts` printed "clean" over whatever fraction of the schedule was + analysable.** `sequence_clash` skips any activity with no location, no dates, or finish before + start, *each with a stated reason*, and the panel rendered `analyzed`, `finding_count`, + `support_unscheduled_count` and the `not_covered` note but never `skipped_count`. So on a schedule + where 180 of 200 activities lack a location it read *"20 dated locatable activities · 0 space + contention · 0 install-before-support"* with a green note and the status **clean**. It now says + `clean of N · M not checked`, groups the skipped activities by the engine's own reason, and stops + claiming `ok` while anything went unexamined. +- `skipped` — the list carrying those reasons — was **declared nowhere** in `api/schedule.ts`, so the + reasons could not reach a screen even once somebody went looking. +- **`resultNote` had no way to say the third thing.** Its kinds were `ok` (green, which over an + unexamined remainder is the appearance of a result) and `bad` (red, which claims a finding that is + not there). `warn` added, using the `--status-warn` token — the two rules above it are literal hexes + equal to the same tokens, left alone rather than bundled in, since light mode redefines them. + +### Five things the gate's first drafts got wrong, each measured + +1. **Matching coverage-ish WORDS** against client response types returned **24** methods, mostly + unrelated senses: a sprinkler's `max_coverage_m2_per_head`, a `dry_run` flag, `ok`/`writes` beside + a `truncated` list. *A rule that needs an exemption list is a rule whose population is wrong.* +2. **Linking consumers by shared vocabulary** cross-talked: it reported a net-effective-rent card as a + consumer of `sequence_clash`, on `skipped`, `skipped_count` and `findings`. The link is the call + site of the owning client method; nothing else claims *this file reads THIS response*. +3. **Treating `not_` as a coverage prefix** matched `not_covered`, a prose note about a *different* + dimension — it would have blessed the one consumer the rule exists to catch. +4. **Detecting a read as `.name`** missed `const { not_applicable: notRun } = s.counts` and called a + carefully-written card bare. +5. **Widening that to a bare word boundary** then matched the English word *"skipped"* inside an + unrelated prose string in the same file — the opposite error, one line later. + +And what it does **not** prove, found by mutating it: this is a source-level check. Wrapping the +coverage render in `if (false)` leaves the text in place and passes — measured, not assumed. Same +bargain `test_route_reachability` makes for URL literals; the complement is the consumer's own test, +where `rentRollQuality.test.ts` and `covenantCard.test.ts` assert the coverage is *rendered*, with +mutations. This gate's job is to stop a new consumer being written without one. + +### COVENANT-DARK — two verdicts that read clean when nothing was evaluated + +`services/api/src/aec_api/covenants.py` opens with the case for itself: + +> A borrower with clean financials who files on day nine of a "ten business days" notice — counted +> from the lender's notice date, not from the day it landed — has breached exactly as surely as one +> who missed a DSCR test. + +`ApiClient.loanCovenants()` was written for it and **no screen ever called it**, so nothing had yet +had the chance to get its verdicts wrong. **Measured through the real engine, not reasoned about:** + +| register state | `at_risk` | `financial.clean` | what was evaluated | +|---|---|---|---| +| 2 uncomputable obligations, 2 untested covenants | `false` | `false` | **nothing** | +| 1 of 3 covenants tested and passing | `false` | **`true`** | one third | + +`at_risk` is `overdue > 0 or due_soon > 0 or breach > 0 or cure_period_open > 0`, so a register that +could evaluate nothing reports *not at risk* — every count it reads is zero **for want of inputs +rather than for want of problems**. And `clean` is `bool(tested) and all(passing)`, which fails closed +only at *zero* tested; one tested and two not is `true`. This is the **fourth** engine this session +with that shape, after `soft_clash`, `rent_scrub` and `t12`. + +The engine is not wrong and hands over the antidote to both — `summary.untested_covenants`, +`summary.uncomputable_obligations`, and its own note: *"an untested covenant is not a passing one."* + +### Added + +- **`apps/web/src/proforma/covenantCard.ts`**, on Budget & Capital beside the facility it is about. + Coverage is the headline — *"N of M obligations computable · K of L covenants tested"* — and the + verdicts are qualifiers on it. A register that evaluated nothing says so in those words, rather + than inheriting `at_risk: false`. +- **The due date shows its work**, which is the engine's stated purpose: *"the anchor date, the + basis, the count, and every non-working day it skipped. **A due date a reviewer cannot re-derive by + hand is not a due date.**"* All four fields were **declared nowhere in `api/creDeal.ts`** until this + change, so the one property the engine was built for was invisible to its client by construction. +- **Both readings when the clock starts disagree.** When the lender's notice date and our receipt + date differ, the obligation carries two due dates, the day difference and *"confirm the counting + basis with counsel before the calendar goes live."* A calendar showing one of two possible dates is + worse than one that admits it has two. +- **Three covenant states, not two.** The engine keeps `cure_period_open` apart from `breach` and + says why in a `note` that was also undeclared — *"A breach inside an open cure window is a different + conversation from one outside it"* — so the sentence could not reach a screen. +- An unreadable line in the actuals box is reported, because an absent actual is an **untested** + covenant: a typo would otherwise look exactly like a figure you never had. +- `apps/web/src/proforma/covenantCard.test.ts` — fourteen tests, seven mutations. + +### The two mistakes this found in its own tests, both the same shape + +- **A mutation that did not compile proved nothing, and printed nothing.** The runner emitted no + results line at all and the loop read that as silence rather than as a failed experiment. The + mutation harness now says so explicitly when no result line comes back. +- **Asserting one inequality is not asserting that three states are three states.** The three-state + test first checked only text — every word of which comes from the server — so painting a curable + breach and an uncured one the same colour **passed**. Asserting `curable !== uncured` was the next + draft, and a mutation giving a curable breach the *passing* colour satisfied it while being worse + than folding the two together. All three are now compared pairwise. + +### Fixed + +- **`day()` formatted through `toLocaleDateString`, which is environment-dependent.** This Node's ICU + renders `en-GB` short September as **"Sept"**; another build renders "Sep", so the test pinning it + would pass or fail on which runtime executed it — and a covenant calendar read across machines + would spell its dates differently on two of them. Formatted from a fixed month table now, the same + reasoning that made `test_route_reachability`'s verdict invariant under file order. +- **The localStorage key had no readable stem.** `apps/web/src/ui/clearCacheKeys.test.ts` walks every + `localStorage.*Item` call and refuses an argument it cannot resolve — *"an unread key is an + unclassified key"*, because **Clear cached data** then makes a decision about it that nothing has + checked. The first draft built the key through a helper, so the argument began with `${` and had no + stem; the prefix is a literal at the call site now. It is *kept* by that button, which falls out of + the denylist design rather than needing a declaration — a hand-entered register has no server copy, + so clearing it loses work rather than evicting a cache. + +### T12-SELFTIE — a gate that could not fail, because nobody was handing it evidence + +`services/api/src/aec_api/t12.py` exists for one refusal, stated in its own module docstring: + +> income, expense and NOI must reconcile *before* and *after* mapping, or the engine stops and lists +> the reconciling items. It does not publish an adjusted NOI on top of a mapping that lost money. + +`ApiClient.normalizeT12()` was written for it and **no screen ever called it**. Measuring what a +caller would actually get found something sharper than the missing UI. + +**`normalize()` says what it does and the consequence is easy to miss** — *"When the caller supplies +source totals they are the reference the mapping must reproduce; **without them the sum of the source +lines is**."* Both sides of the comparison are then computed from the same mapped rows, so the deltas +are zero by construction and **the gate passes vacuously.** Measured through the real engine on one +T-12 carrying a single unmapped $90,000 line: + +| input | tie-out | adjusted NOI | +|---|---|---| +| no stated totals | `reconciles: true`, all deltas `0.0` | **published: $3,180,000** | +| with stated totals | `reconciles: false`, expense −90,000 | `stopped: true`, `null` | + +The $90k reclass — precisely what the gate exists to catch — is invisible in the first case, and an +adjusted NOI is published on top of it. **That is not an engine defect: it is a caller obligation that +never had a caller.** *A guard is only as sound as the evidence it is handed* — the CLASH-TRUNC lesson +one layer over, where a panel fed the guard a page of a matrix and declared it whole. Here a caller +can feed it a reference derived from the answer. + +**And the two responses are indistinguishable.** `reconciles` is `true` either way, and so is every +other field the card could read. Only the caller knows which it handed over, which is why "were totals +stated" is a parameter of the renderer rather than something derived from the response — a check that +tried to infer it would be inferring it from the very thing that cannot tell. + +### Added + +- **`apps/web/src/proforma/t12Card.ts`** — paste the seller's statement, give its own stated totals, + and get the mapping tied out. Stated totals are the **primary** input, not an optional extra; with + them absent the card prints *"Tie-out tied to itself — not a check"* and puts `unmapped_count` where + the verdict would be, because that is the only signal left. +- Past the gate: adjusted NOI, one-time income and expense separated from run-rate, capital below the + line, the run-rate-vs-trailing movers, and the owner-operated **questions** — rendered as questions + with the number behind each, and labelled *never applied for you*, because treating them as priced + in is the 15–25% NOI miss they exist to prevent. +- A stopped tie-out says its derived views were **never calculated**, not hidden. `add_back_questions` + and `run_rate_vs_trailing` are computed only past the gate, so "hidden" would misdescribe what + happened and send someone looking for a toggle. +- **The paste parser splits on the LAST comma**, because account names carry commas + (*"Repairs, maintenance & turnover"*) and amounts do not carry tabs. Splitting on the first truncates + the description **and** reads the remainder as the amount — which parses, so the failure would be a + wrong number rather than an error. An unreadable amount returns `null`, never `0`: a zero would enter + the mapping as a real line worth nothing, shift no total, and be invisible in both the tie-out and the + unmapped list. Rows the client cannot read are reported, because they never reach the engine and are + therefore absent from both totals and invisible to the tie-out however it is run. +- **The handoff into the rent-roll scrub.** Five of the scrub's seven checks report + `applicable: false` for want of an income statement, and a normalised T-12 is exactly that input — + so **Use in the rent-roll scrub** re-runs it with `gross_potential_rent` and `bad_debt`, in place, + and says which statement it ran against. Only those two: `prior_bad_debt`, `occupancy_pct` and + `prior_occupancy_pct` describe a prior period and a unit inventory, and filling them from this period + to make a check run would be the defect the scrub is written to refuse, committed from the outside. + The check that needs them stays not-run and says so. +- `apps/web/src/proforma/t12Card.test.ts` — sixteen tests, **seven mutations each redding exactly + one**: trusting `reconciles` without knowing whether totals were stated, splitting the paste on the + first comma, returning `0` for an unreadable amount, presenting the unmapped page as the whole set, + inventing the prior-period fields for the scrub, dropping *"never applied for you"*, and calling the + derived views hidden rather than never calculated. + +### Fixed + +- **Two more fields the client declared nowhere**, both on this response. `run_rate_vs_trailing.label` + was found by **the compiler** rather than by a reader — the card rendered `x.label` and `tsc` refused + it, which is the declared type doing its job; an inline re-spelled shape would have accepted it + silently, which is why `noRespelledShapes.test.ts` exists. And `add_back_questions` omitted `amount` + and `pct_of_income`, where the engine's docstring says each finding is *"a QUESTION with the number + behind it"* — the number being the point of the finding rather than detail beside it. +- **`residualLandCard.ts`'s buttons carried `className = "btn"`, and `.btn` matches no rule in + `style.css`** — so they rendered as browser defaults beside styled siblings. Every button in + `proforma/` uses `file-btn` or `tool-btn`; this card copied a class from `portal/panels/`, where six + files use it and are equally unstyled. *A class name is not a style, and nothing typechecks the gap.* + Fixed here for `proforma/`; the six in `portal/panels/` are pre-existing and left for Lane B. + +### RENTROLL-DARK — two engines that say whether you can believe the rent roll, and no screen called either + +The pro forma's Operations tab shows **face** numbers: base rent, in-place income, occupancy, WALT. +Face rent is what a broker quotes. Two R20 engines shipped to qualify it and neither was ever called — +`ApiClient.netEffectiveRent()` and `ApiClient.rentRollScrub()` both sat in +`apps/web/src/api/clientCallers.test.ts`'s `UNCALLED` list, reachable only from a script. Both are now +rendered under the rent roll by `apps/web/src/proforma/rentRollQuality.ts`. + +**Both engines are built around a REFUSAL, and the refusal is what a panel destroys.** That, rather +than the numbers, is what this change is about and what its fourteen tests pin. + +### Added + +- **Net effective rent** — face GPR against the discounted and straight-line NER, the concession load, + and the leases with the widest face→NER gap (the ones an underwriter re-cuts first). The card names + which form agency underwriting uses, so two numbers are a choice rather than a puzzle. +- **Rent-roll scrub** — the seven diligence checks, with **coverage as the headline**, the findings by + severity, and every check that could not run beside **what it would need**. That last table is the + actionable half: it tells you which document to go and get, rather than leaving the gap as an absence + of green ticks. + +### The caveats, each of which a naive panel would delete + +- **`clean` is never rendered as a clean rent roll.** `rent_scrub.py` computes it as + `bool(ran) and not failed`, so **one** check running and passing with six unable to run is + `clean: true`. A green tick off that flag is precisely what the engine's own docstring exists to + refuse — *"a scrub that reports 'no findings' because half its inputs were missing is worse than no + scrub — it launders absent data into apparent confidence."* The card leads with `N of M checks could + run` and qualifies the flag with it. +- **The NER totals cover a smaller population than the card above them.** `roll_up` filters to the + computable leases *before* summing, and `lease_count` is that subset's size — so with any + `skipped_count > 0` the Face GPR here is a different set from the rent roll's "Base rent / yr" three + lines above, and the two sit on one screen inviting a subtraction. Said out loud, with the skipped + leases and their reasons listed. +- **The skipped list can be a page.** `skipped` is capped at 50 server-side while `skipped_count` is + not. A page presented as the whole set is the CLASH-TRUNC shape — a screen reporting over a partial + view and calling it complete — so the card says `Showing 50 of 120` when it is showing a page. +- **No leasing commission means the optimistic case.** `lc_included` is false unless a rate is + supplied, and the engine never invents one; without it the landlord's costs are understated, so both + NERs are high. The absence would otherwise read as a complete answer. +- **Zero computable leases renders as a refusal, not as `$0`.** Every total would be zero, and "$0 of + net effective rent" is a different claim from "this cannot be stated for this rent roll". + +`apps/web/src/proforma/rentRollQuality.test.ts` — fourteen tests, and **six mutations each red exactly +one of them**: rendering `clean` as clean, dropping the population warning, presenting the skipped page +as the whole set, dropping the leasing-commission caveat, rendering zeros as a valuation, and dropping +what a blocked check needs. + +### RESIDUAL-DARK — the developer's actual question was built, routed, tested and unreachable + +Every other figure on the pro forma runs **forward** from a land price somebody typed. Site +acquisition is the one number a developer negotiates, and the question at the table is not "what is +this deal's IRR" but *"what is the most I can pay for the dirt and still clear my hurdle"*. That is a +different solve. + +FIN-CALC shipped it: `services/api/src/aec_api/proforma/residual.py` bisects the land line over the +same forward `solve()` every other number here comes from, `POST /proforma/residual-land` serves it, +`services/api/test_fin_calc.py` covers it, and `ApiClient.residualLand()` was written for it. **No +screen ever called it** — the method sat in `apps/web/src/api/clientCallers.test.ts`'s `UNCALLED` +list, reachable only from a script. Same shape as DISC-poché and the MEP systems browser: complete, +correct, and behind no door. + +### Added + +- **Residual land value on the Feasibility tab** (`apps/web/src/proforma/residualLandCard.ts`). Pick a + target — equity IRR · project IRR · equity multiple · yield on cost · profit margin — give it a + number, and get the land price that hits it, shown against the land line you already typed as + headroom or overpayment. **Apply to the deal** writes it back into the land cost line and re-solves + the forward model, so the inverse answer becomes the deal. +- **The target box changes units with the target**, because 1.8x is not 180%. A multiple is sent + unscaled and a percentage is divided by 100; both directions are asserted, and sending the percent + unscaled reds the test. +- **All three of the engine's caveats are rendered, and that is the substance of the change rather + than the number.** `residual_land_value` is careful in exactly the way a panel throws away — this + repo's SCREEN-VS-REPORT axis: + - `land_value: null` — the target is unreachable **even at $0 land**. Printing a figure here would + be inventing one; printing nothing would read as a failure. It renders as the finding, quantified + with `at_zero_land`, under the engine's own words: *"the deal, not the dirt"*. + - `converged: false` **with** a figure — the bisection hit its cap, so that figure is a bracket + endpoint, not a price. A panel that prints the money and drops the flag asserts a precision it + does not have. + - `bounds` — **declared nowhere in this client.** The route returns it and `residual.py`'s own + docstring lists it, and `ApiClient.residualLand()`'s return type omitted it, so no unread-field + audit in this tree could see it: every one of them starts from the declared interfaces. It is the + honest answer in the un-converged case, which is precisely when it matters. Declared and read now. +- **The applied value goes back as the basis the engine SOLVED for, not the one the form is bound to.** + `proforma/residual.py::_with_land` scales the **first** `category: "land"` line and zeroes any + others, so "the land basis" is that line and the residual is the *total*. The driver form's "Land $" + field is bound to `cost_lines.0.amount`, which is true of the default assumption set and not + guaranteed of one adopted from the massing tab — and a second land line left standing would make the + forward re-solve carry more land than the answer allowed for, so the deal would quietly disagree with + the number that produced it. `landBasis()` / `applyLandBasis()` address it the engine's way, and both + are mutation-checked: reading line 0 instead of the first land line, and dropping the zeroing, each + red their own test. *Applying a number under a looser definition than the one it was solved under is + how an inverse answer stops reconciling.* +- `apps/web/src/proforma/residualLandCard.test.ts` — thirteen tests, one caveat per `it`, because one + test over all three passes when two of them work. **Mutation-checked**: printing a figure when + `land_value` is null, always claiming convergence, ignoring `bounds`, and sending the percentage + unscaled each red exactly the test written for them. + +### Fixed + +- **The XSS source pin in `proforma.render.test.ts` accused a safe line.** It required the literal + spelling `escapeHtml((e as Error).message)`, and this directory imports the escaper under two names + — `escapeHtml` in `massingTab.ts`/`proforma.ts`, `escapeHtml as esc` in `testfitTab.ts` and now + `residualLandCard.ts`. So a line escaping **correctly** under the conventional alias was reported as + an unescaped XSS sink. *A check keyed on the local NAME cannot see a call that is about the + BINDING* — and it failed in the direction that costs the most trust: not a miss, a confident + accusation about a safe line, with a message telling the author they had an XSS. The escaper is now + resolved from the file's own `ui/feedback` import, so either spelling counts and a name bound to + nothing does not. Renaming the import to suit the regex was the tempting repair and the wrong one: + the convention it would bend to is the regex's, not the directory's. +- **And the pin could not tell "no offenders" from "nothing to offend".** It asserted an empty list + with no floor on either side, so a refactor that moved every sink out of `proforma/` would have left + it green and meaningless. It now asserts the directory scan found files and that it is still looking + at sinks, before it may report none unescaped. Mutation-verified both ways: a real unescaped sink in + the new card reds it, and reverting the resolver to name-matching reds it and its self-test. + +### SCHEMA-UNGENERATED — the generated client types were never complete, and "regenerate" did not mean "read the server" + +`apps/web/src/api/schema.d.ts` is generated from the FastAPI spec and **committed**, so it is a claim +about the server checked in beside the client. Nothing was checking the claim. + +| | | +|---|---| +| paths the app serves | **947** (1,021 operations) | +| paths `schema.d.ts` declared | **500** (541 operations) | +| operations served but undeclared | **482**, across 165 of 203 path groups | +| operations declared but no longer served | **2** | + +**It was not staleness, and the name it was filed under pointed at the one action that could not fix +it.** Stale implies the file was once right. `schema.d.ts` and the `apps/web/.gitignore` line hiding +its input were last written in the **same commit** (`8432a88`, 2026-09-11), and the app has *fewer* +route decorators today — 1016 against 1022 at that commit — so drift cannot account for a gap running +in the other direction. The types were already half the API when they were generated. + +**Why re-running the generator reproduced it.** `package.json` ran +`openapi-typescript src/api/openapi.json -o src/api/schema.d.ts`, and `apps/web/.gitignore` ignores +`src/api/openapi.json`. On a fresh clone the command fails for want of an input; on a machine that has +one it regenerates from whatever dump is sitting there, prints a green tick and writes the same file. + +### Fixed + +- **`apps/web/scripts/gen-api-types.mjs` — the generator reads the app, not a file.** It dumps the + spec from `aec_api.main:app` into a temp file under the OS temp directory, generates, and deletes + it, so **there is no persistent input left to be stale**: if the app cannot be imported it exits + non-zero with Python's own traceback instead of falling back to a dump on disk. It finds the venv + interpreter on either OS rather than resolving a bare `python` off PATH — the mistake + `check-vite-version.mjs` documents for vite — and refuses to overwrite a committed file from a spec + reporting an implausible number of paths. +- **`paths` is sorted before generating.** FastAPI emits them in route-registration order, so + including a router earlier shuffles thousands of lines and the diff of a one-route change is + unreviewable. Key order carries no meaning in an OpenAPI document, and this is much of why + *"regenerating churned 38,231 lines"* had become a recorded reason not to regenerate. Two + consecutive runs are now byte-identical. +- **`schema.d.ts` regenerated** — by that script, not by hand: 947 paths, 1,021 operations, all + declared. +- **`apps/web/src/api/openapiTypes.ts`'s header** described the two-step recipe whose second step was + the defect. Corrected to the one command, with the measurement and the gate named. + +### Added + +- **`services/api/test_schema_types_agree.py`** — the seventeenth gate. Every live `(path, method)` + must be declared in the committed `schema.d.ts`, and every declared one must still be served. **A + generator can only be run; a gate fails.** It lives in Python, in `services/api`, because it needs + the live app and does *not* need node — the declared set is parsed out of the committed `schema.d.ts`, so + nothing is generated, no artifact passes between CI jobs, and `api-tests` (which already imports the + app) is the only job involved. +- **Method-level, not path-level, and that is load-bearing.** `openapi-typescript` emits all eight + verbs for every path and marks the unserved ones `?: never`, so "the URL is in the file" and "the + file says this URL answers POST" are different questions and only one is the right one. A + path-level check passes a file that declares all 947 URLs and not one of their verbs — which is the + shape a partially-regenerated file actually has. Mutation-proved in both arms: a deleted path group + must be found, and one verb flipped to `never` must be found. +- **It fails closed.** A path group whose body the parser cannot classify *raises* rather than being + skipped — proved by deleting a verb line and requiring the refusal, because a parser that silently + narrowed would report that path as missing everything, which reads like "regeneration due" rather + than "the parser no longer understands this file". Floors on both sides (≥900 paths, ≥950 + operations) so a parser that matched nothing cannot report a clean tree. +- **Verified against the file as shipped**: 482 served-but-undeclared and 2 declared-but-gone, with + the floor check red as well — detection in both directions, on the real defect, before the fix. +- **Its own parity check was not a check, and mutation is what said so.** `finditer` yields nothing for + a path group whose shape it cannot match — silently — and a path absent from the parse is reported + as *served but undeclared*, which reads as **regenerate** rather than **the parser broke**. So the + path keys are counted independently of the group bodies. The first draft counted them with + `^ "…": \{$`, which **fails in exactly the same way the group regex does** (both anchor the brace + at end of line), so a trailing-whitespace mutation broke both derivations and the counts stayed + equal. *A parity check between two derivations that share a failure mode is not a check.* The key + count uses a deliberately cruder shape now, and both mutations — whitespace after an opening brace, + a closing brace indented one space too far — are folded into the gate and must be refused before it + may report anything. +- **No carve-out for `head`/`options`/`trace`.** An earlier draft excluded them from both sides as + "verbs nobody writes". Measured: the live spec emits **0 of all three**, so the exclusion bought + nothing and silently shrank the population — *a list of known cases is a list somebody stopped + widening*, and an explicit `@router.head` would have been skipped on both sides with a missing + declaration reading as a clean tree. All eight are compared. +- **The comparison's own assumption is asserted, not believed.** It only makes sense if the app serves + the same routes here, in CI and on the machine that last ran the generator — so the gate scans + `src/aec_api` for any route registered under an `if` (measured: 0 sites across 466 modules) and reds + if one appears. Without it, one `if settings.FEATURE:` would make this gate red with *"N served but + undeclared — regenerate"*, a reader would regenerate, and the file would disagree in the other + direction on the next machine. *A check whose failure message can misdiagnose is worse than one that + stays silent, because somebody acts on it.* The scanner is shown finding a synthetic gated + registration before it may report zero. +- **And that scan's first draft asked the question from the wrong place.** It globbed `Path("src")`, + relative to the working directory. `run_tests.py` sets `cwd=services/api`, so it would have worked + under the runner — and `ci.yml` invokes `python services/api/run_tests.py` from the repo **root**, + where the same literal names nothing, `rglob` yields nothing, raises nothing, and the precondition + reports a clean tree. *A wrong question returns a confident number* — the failure + `test_scratch_ignored` paid for twice. The path is anchored on `__file__` now, the module count is + returned rather than discarded and floored beside the verdict, and the gate is verified from both + working directories. + +### Why it went unseen for a fortnight — the part worth more than the fix + +**Seven audits in this tree exempt `schema.d.ts` by name** — `deadFieldScope`, `docComments`, +`unfiledMap`, `deadFieldTyped`, `noRespelledShapes`, `test_route_reachability`, `test_file_sizes` — +each for a good reason of its own. *Seven exemptions and no owner is how an artifact stops being +checked by anybody.* + +`test_route_reachability`'s exemption is the sharpest and belongs beside this one. It used to count +`schema.d.ts` as client code, so **29 routes were "called" by a generated file restating the server's +own route table** (v0.3.1049). That is the opposite error — treating the artifact as evidence about +the *client*. It is only ever a claim about the *server*, which is the one thing this gate reads it as. + +**And the item was parked on a decision that turned out not to be load-bearing.** The entry said the +repair was a build decision — whether `openapi.json` is committed (a large artifact in every diff) or +dumped in CI (a step needing the API importable in the web job) — and it needed neither answer. The +input does not have to be committed *or* produced in CI if it does not persist. *An item parked on a +decision stays parked until somebody re-derives whether the decision was load-bearing.* + ### ROUTE-SHADOW — four routes were registered on a URL another route already owned Starlette matches the **first** route whose path and method fit, so a second registration on the same diff --git a/CLAUDE.md b/CLAUDE.md index baf7e4a36..ee9e2ab34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -410,6 +410,83 @@ whose regex carries the prefix by construction, and probe validity is **self-che must match its own route), so an unhandled converter reds the build. *A list of known cases is a list somebody stopped widening; a self-check is not.* +**A seventeenth joined them on 2026-09-25: `services/api/test_schema_types_agree.py`** — does the +committed `apps/web/src/api/schema.d.ts` describe the API this server serves? It is generated from the +FastAPI spec and checked in, so it is a claim about the server sitting beside the client, and nothing +was checking the claim: **500 of 947 paths, 541 of 1,021 operations**, 482 undeclared across 165 of 203 +path groups. **Filed as "SCHEMA-STALE", and the name was the first thing wrong with it** — stale +implies it was once right, and that file and the `.gitignore` line hiding its input were written in the +SAME commit, with the app carrying *fewer* route decorators today (1016 vs 1022) than when it was +generated. *A stale-sounding name sends the next reader at the one action that cannot fix it*, which +here was re-running the generator: `gen:api-types` read `src/api/openapi.json`, **a gitignored file**, +so it regenerated from whatever dump sat on that machine, printed a green tick and wrote the same file. +*"Regenerate the types" did not mean "read the server", and nothing said so* — the generator now dumps +from `aec_api.main:app` into a temp file it deletes, so there is no persistent input left to be stale. +**The rule is method-level and that is load-bearing:** `openapi-typescript` emits all eight verbs per +path and marks the unserved ones `?: never`, so a path-level check passes a file declaring all 947 URLs +and none of their verbs — which is the shape a half-regenerated file actually has. Both arms are +mutation-proved, and the parser REFUSES a group it cannot classify rather than narrowing, because a +silent narrowing reports that path as missing everything, reading as "regeneration due" instead of "the +parser no longer understands this file". +**The reason it went unseen is worth more than the fix: SEVEN audits here exempt this file by name** +(`deadFieldScope`, `docComments`, `unfiledMap`, `deadFieldTyped`, `noRespelledShapes`, +`test_route_reachability`, `test_file_sizes`), each for a good reason of its own. *Seven exemptions and +no owner is how an artifact stops being checked by anybody.* And `test_route_reachability`'s is the +opposite error, still instructive: it once counted this file as CLIENT code, so **29 routes were +"called" by a generated file restating the server's own route table.** It is only ever a claim about +the SERVER. **The item had also been parked on a decision that was not load-bearing** — commit +`openapi.json`, or produce it in CI? — and needed neither answer, because an input that does not +persist needs no home, and the agreement check needs no node and no artifact crossing jobs. *An item +parked on a decision stays parked until somebody re-derives whether the decision was load-bearing.* +**And the gate's own first draft asked its newest precondition from the wrong place.** The +env-independence scan globbed `Path("src")`, relative to the working directory — which `run_tests.py` +sets to `services/api`, while `ci.yml` invokes `python services/api/run_tests.py` from the repo ROOT. +`rglob` over a missing directory yields nothing and raises nothing, so it would have reported a clean +tree under CI and nowhere else. *A wrong question returns a confident number* — the same failure +`test_scratch_ignored` paid for twice, in a file written days after reading its lesson. **A relative +path is a question about where somebody stood**; it is anchored on `__file__` now, the population +count is returned rather than discarded and floored beside the verdict, and the gate is run from both +directories. +**And its parity check was not a check.** A path group whose shape the group regex cannot match is +absent from the parse *silently*, and is then reported as "served but undeclared" — a parser failure +wearing the costume of a stale file — so the path keys are counted independently. The first draft +counted them with a regex that **fails in exactly the same way the group regex does**, so a +trailing-whitespace mutation broke both derivations and the counts stayed equal. *A parity check +between two derivations that share a failure mode is not a check* — the crude count catches what the +strict one cannot, and both mutations are now folded in and must be refused before the gate reports. + +**An eighteenth joined them on 2026-09-25: `services/api/test_verdict_coverage.py`** — does a screen +that reads a verdict about a SUBSET also read what the engine could not evaluate? Five engines in one +session returned a boolean beside a count of the unevaluated, and the boolean was meaningless without +the count: `soft_clash.coordinated` over a page of the matrix (CLASH-TRUNC), `rent_scrub.clean` = +`bool(ran) and not failed` with 1 of 7 checks run, `t12.tie_out.reconciles` against a reference derived +from the answer, `covenants.clean`/`at_risk` on counts that are zero for want of inputs, and +`sequence_clash.clean` over the activities that happened to have a date and a location. **Every one of +those engines is careful** — each hands the caller the coverage and three attach a sentence saying why. +*The defect is always in the consumer*, and four of the five had none, so nothing was wrong until +somebody wrote one. **A subset verdict is derived structurally, not by name:** a dict key whose value +is `bool(A) and (all(…) | not B)`, the guard being the author writing down that the subset can be +empty. Matching coverage-ish WORDS instead returned 24 candidates of mostly unrelated senses — a +sprinkler's coverage area, a `dry_run` flag — and *a rule that needs an exemption list is a rule whose +population is wrong.* The structural form returns 5 with no exemptions, and **three of the five were +instances nobody had found by hand.** +**Four more drafts were wrong in ways only measurement showed.** Linking consumers by shared +VOCABULARY cross-talked — a net-effective-rent card was reported as a `sequence_clash` consumer on +`skipped`, `findings` — so the link is the call site of the owning client method, the only thing that +claims *this file reads THIS response*. Treating `not_` as a coverage prefix matched `not_covered`, a +prose note about a **different dimension**, which *would have blessed the one consumer the rule exists +to catch*. Detecting a read as `.name` missed `const { not_applicable: notRun } = …` and called a +careful card bare; widening to a bare word boundary then matched the English word "skipped" inside an +unrelated prose string in the same file. *A detector keyed on one spelling cannot see the others, and +one keyed on none sees everything.* +**And mutating it found the limit it does not cover:** wrapping the coverage render in `if (false)` +leaves the text in place and PASSES. It is a source-level check — the same bargain +`test_route_reachability` makes for URL literals — and the complement is the consumer's own test, +where `apps/web/src/proforma/rentRollQuality.test.ts` and +`apps/web/src/proforma/covenantCard.test.ts` assert the coverage is *rendered*. Written down rather +than discovered later, because a gate whose reach is assumed is the thing these eighteen entries are +about. + "Cite a gate only after `git ls-files` confirms it" is itself a rule held as prose, so it is now `services/api/test_claude_md_gates.py`: every backticked code file named here, in `docs/roadmap-directions.md` **and in `docs/roadmap.md`** must resolve to a tracked path — including @@ -490,7 +567,7 @@ never contingent on the npm question. **What an agent working in `apps/web/src/viewer` should know.** **Seventy-nine** commits have touched that directory since extraction began on 2026-08-06, and `apps/web/src/viewer/app.ts` has gone from 5,064 lines to -**2,442** — largely R39-DECOMP-VIEWER, which is the same decomposition the extraction plan asks for and is being +**2,410** — largely R39-DECOMP-VIEWER, which is the same decomposition the extraction plan asks for and is being done here first. That is good and it is also divergence: every one of those commits is a change the swap will have to reconcile. So: @@ -513,6 +590,19 @@ have to reconcile. So: The `wc -l` in the re-measure command above was also bare and would have waited on stdin — an instruction to verify that hangs is an instruction nobody runs twice.)* + *(**2,442 → 2,410 on 2026-10-12**, BATCH-DARK: the "Script this" window left for + `apps/web/src/viewer/tools/scriptThis.ts`, and this line moved in the same edit because + `services/api/test_claude_md_gates.py` refuses the build otherwise — which is the lesson above being + enforced rather than remembered. **And the commit count above was deliberately NOT re-measured, which + is the more useful half.** `git log --since=2026-08-06 -- apps/web/src/viewer | wc -l` returns **24** + in this cloud container, against the seventy-nine stated — because the checkout is **shallow** (two + grafts, 123 commits, oldest dated 2026-09-11), so `--since` cannot see past the graft and the small + number is an artefact of clone depth rather than a correction. *A re-measure that depends on the depth + of somebody's clone is not a measurement*, which is the same thing `services/api/test_gap_records.py` + had to learn when its replay used `git show` and failed only in CI. Check with + `git rev-list --count HEAD` and `test -f .git/shallow` before believing a history count here; the LINE + count is safe because `wc -l` reads the working tree.)* + - **Keep shipping.** Blocking this roadmap for the extraction would make the extraction expensive and it would die. Landing viewer work here is the correct default. - **Prefer changes that survive the swap** — new behaviour behind the existing seams, rather than new coupling diff --git a/apps/web/.gitignore b/apps/web/.gitignore index ac808c270..7f657e7b5 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,2 +1,9 @@ *.local +# Vestigial, deliberately kept. `gen:api-types` no longer writes here — it dumps the spec into the OS +# temp directory and deletes it, because an input that persists is an input that can be stale, and this +# very line is what hid that: the generator's one documented invocation read a file git was told to +# ignore, so "regenerate the types" silently meant "re-read whatever dump is on this machine". See +# apps/web/scripts/gen-api-types.mjs and services/api/test_schema_types_agree.py. The pattern stays so +# that a leftover dump from before 2026-09-25 does not surface as untracked work in every lane's +# `git status` — the residue problem services/api/test_scratch_ignored.py exists for. src/api/openapi.json diff --git a/apps/web/index.html b/apps/web/index.html index a83a98640..56cb29662 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -25,6 +25,9 @@
+ +