Skip to content

Sixteen unreachable or unchecked things, the gates that derive them, and the times those gates caught me - #581

Draft
ibuilder wants to merge 21 commits into
mainfrom
claude/codebase-audit-roadmap-45656d
Draft

ibuilder wants to merge 21 commits into
mainfrom
claude/codebase-audit-roadmap-45656d

Conversation

@ibuilder

@ibuilder ibuilder commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

What & why

Sixteen items on one theme: things that shipped correct and were never checked or never reached,
and the gates that turn the class from hand-found into derived. Those gates then caught me three
times
— on the card written to demonstrate one of them, on a shim I justified for callers that do
not exist, and on an edit I had convinced myself was fully verified. Two items carry a live wrong
answer; one exists only because I went to check whether a sentence I had written in the item before
it was true.

item what it was
7e39f34 SCHEMA-UNGENERATED the committed client types described half the API, and had since the day they were generated
6be23d3 RESIDUAL-DARK the developer's actual question — what can I pay for the dirt? — built, routed, tested, unreachable
43cd696 RENTROLL-DARK two engines that say whether you can believe the rent roll; no screen called either
5c6b8e6 T12-SELFTIE a tie-out gate that could not fail, because nobody was handing it evidence
f8bae56 COVENANT-DARK two verdicts that read clean when nothing was evaluated
f661a33 VERDICT-COVERAGE the class found five times by hand, now derived — and it found three more
6fc46a2 AUTHORITY-DARK a gate you could read and could not satisfy
f779f0c DECISION-GATE-DARK the keystone over the whole tab, and nothing called it
482084b the gate caught its own demonstration test_verdict_coverage failed on the card written to argue for it — and the first reading of that failure was "false positive"
b2bb594 SUPPLY-DARK a market where every project was filtered out reported the most favourable verdict there is
b2cc295 SCAN-TRUNC a model cut short turned a correct building into 500 as-built findings — and it is the opposite sign from CLASH-TRUNC
e22c282 the dead-code gate caught a shim I kept a wrapper "for callers that only want the array". There are none
0601e21 TRUNC-COUNTED three screens printed the size of a page as the total, and one of them acted on it
d1085e8 the size ratchet caught an edit I had verified I enumerated behavioural coverage and called it coverage
e3905a4 SCAN-DARK SCAN-TRUNC's refusal pointed at a route formally frozen as unreachable
850aa32 PROGRESS-UNMATCHED an engine whose own note promised "never silently dropped" and silently dropped
5abcc1c SCOPE-EMPTY an empty register and one with nothing done were the same three numbers
7e1c0d0 DEGENERATE-SWEEP the ledger: 17 engines measured, 9 defects, 8 clean
9db199b CI-LATEST-DARK the model-CI badge is stored so it need not be recomputed, and recomputing it was the only way to see it
ba3bddd PORTAL-TXN-DARK a whole client-decision transaction with neither end built — recorded and parked, not half-built
999bdd8 SSO-DOOR-DERIVED the predicted fourth sign-in door arrived guarded, and the gate watching for it could not see it

SCHEMA-UNGENERATED

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 checked 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. schema.d.ts and the apps/web/.gitignore line hiding its input were written in the same
commit
(8432a88), and the app has fewer route decorators today — 1016 against 1022 — so drift
cannot account for a gap running the other way. And re-running the generator reproduced it:
package.json ran openapi-typescript src/api/openapi.json …, which .gitignore ignores, so it
regenerated from whatever untracked dump was on the machine and printed a green tick.

Fixed in three parts: gen-api-types.mjs dumps the spec from aec_api.main:app into a temp file it
deletes and sorts paths first (so the next regeneration is a reviewable diff); schema.d.ts
regenerated by that script, byte-identical across two runs; and
services/api/test_schema_types_agree.py asserts every live (path, method) is declared — a
generator can only be run, and nothing makes anyone run it.

Four callerless CRE engines, and what each one's refusal cost

Each of these shipped complete, correct and tested, with a client method written for it and no
screen calling it
— so nothing had yet had the chance to get its careful parts wrong.

  • RESIDUAL-DARK — residual land value on the Feasibility tab. The substance is the three caveats:
    unreachable-at-$0-land ("the deal, not the dirt"), converged: false (a bracket, not a price), and
    bounds — declared nowhere in the client, so no unread-field audit could see it. The applied
    value goes back as the basis the engine solved for (the first category: "land" line, others
    zeroed), not the one the driver form is bound to.
  • RENTROLL-DARK — net effective rent + the rent-roll scrub, under the Operations rent roll.
    clean is bool(ran) and not failed, so one check passing with six unable to run is true; the
    card leads with coverage. NER's totals cover only the computable leases, so its Face GPR is a
    different population from the base rent three lines above it — said out loud. And skipped is
    capped at 50 while skipped_count is not, so the card says when it is showing a page.
  • T12-SELFTIE — measured through the real engine, one unmapped $90,000 line: no stated totals →
    reconciles: true, all deltas 0.0, adjusted NOI published ($3,180,000); with stated totals →
    stopped, null.
    Without totals the reference is the sum of the source lines, so both sides come
    from the same mapping. The two responses are indistinguishable, which is why "were totals stated"
    is a parameter of the renderer rather than something inferred.
  • COVENANT-DARK — again measured: a register with 2 uncomputable obligations and 2 untested
    covenants reports at_risk: false, because every count that flag reads is zero for want of
    inputs; and clean is true with 1 of 3 tested. Also rendered, all previously undeclared: the four
    fields that make a due date re-derivable ("A due date a reviewer cannot re-derive by hand is not a
    due date"
    — and its client could not see them), both readings when the lender's notice and our
    receipt dates disagree, and the note separating a breach inside an open cure window from one outside.

AUTHORITY-DARK and DECISION-GATE-DARK — the two gates at the top of the tab

AUTHORITY-DARK is sharper than "nobody called it", and my first reading of it was wrong.
dealAuthority() — the READ — has had a caller all along: portal/panels/design.ts renders the gate
on its go/no-go card. What was callerless is saveDealAuthority() — the WRITE. So the table could be
seen and never declared: the design panel could say "Source facts NOT current" and nothing in
the product could make them current. A gate you can read and cannot satisfy is worse to ship than one
nobody can see
— it is visibly unfinished and reads as a bug in the analysis rather than a missing
surface. The load-bearing test is a tripwire reading deal_authority.py off disk, because
FACT_TYPES has to be mirrored (the GET returns declared rows plus the required missing ones, so an
optional type nobody has declared appears in neither) and the required flag is the column that
matters: a drift there shows a fact type as optional while the engine blocks on it.

DECISION-GATE-DARK is the keystone, and its contract is the principle this whole PR is about,
implemented in the engine rather than in a consumer: "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."
Measured with no evidence: 6 of 7 unknown, blocked, six actions.
The card leads with the actions, renders unknown as blocking in word and colour, and keeps the
reason on a vacuous pass (exhibits passes "no exhibits were required for this package" — a gate
that tested nothing). It gathers the two pieces of evidence this app can produce and leaves the rest
absent, which blocks; a source that fails is omitted rather than filled in, because an invented
payload would make a gate pass or fail on something the card made up.

VERDICT-COVERAGE — and the defect it found on its first run

Five engines, one shape: a boolean verdict beside a count of what could not be evaluated. Every one
of those engines is careful
; the defect is always in the consumer, and four of five had none.

A subset verdict is derived structurally — bool(A) and (all(…) | not B), the guard being the
author recording that the subset can be empty. Coverage-word matching returned 24 candidates of
mostly unrelated senses and would have needed five exemptions; the structural form returns 5 with
none
, and three were instances nobody had found by hand.

One was live: 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 never rendered skipped_count. Fixed,
with skipped declared on the client for the first time and a warn kind added to resultNote, which
had no way to say the check ran and part of the input was not examined.

The gate caught the card written to demonstrate it — 482084b

test_verdict_coverage.py landed in f661a33. One commit later it failed on
apps/web/src/proforma/decisionGateCard.ts
— the card written to demonstrate the principle it
enforces — and that commit (f779f0c) was pushed red.

The first reading was "false positive", and measuring is what overturned it. The card does not
render the scrub's clean; it forwards the whole payload to decision_gate.evaluate and renders
that engine's answer. A forwarder, not a renderer. But _gate() splats **extra onto the row, so
rent_roll_scrubbed arrives at the client carrying ran, failed and not_applicable, and the
card declared five fields and rendered detail. Through the real engine, a scrub of
{ran: 1, not_applicable: 6, failed: 0}:

status: "pass" · detail: "the scrub ran but found nothing"

"Nothing" there means no problems found. It reads as "nothing was wrong", over six checks that
never ran — the same defect one layer up from the four already fixed here, in the card that argues
for fixing them. The engine is sound and untouched: decision_gate already requires ran > 0 and
already attaches the coverage. It measured the thing and handed it over; the client dropped it.

DecisionGateRow/DecisionGateResult are now named interfaces rather than an inline re-spelling —
which is why three fields could sit on the wire unread by any derivation over declarations — and
coverageOf() is written as a rule over whatever a row carries, not a special case for the one gate
that carries it today. The engine's status word is left alone: this card declines to drop what the
engine measured, it does not overrule it.

SUPPLY-DARK — the sharpest instance of the whole theme

supply_pipeline.py weighs a competitive pipeline by what is recorded about each project rather
than by the label on the deck. competitiveSupply() was callerless and typed
Record<string, unknown>, so every field was invisible to deadFieldTyped.test.ts and to any other
audit starting from the client's declarations — the excluded_comparables hole again.

Measured before writing the card. Four projects, none matching the subject's product type:

counts.competing 0
excluded.counts.wrong_product 4
weighted_index.band "undersupplied"
discount_pct 0.0

The most favourable verdict the engine can produce, byte-identical to a market with genuinely no
competition.
"Undersupplied" is an argument to build — worse than the four clean: true cases,
which at least read as neutral. The engine claims nothing: the coverage is in the response and its
note says the exclusions are listed "so a thin competitive set is visible, not implied". So the
card withholds rather than annotates — no band, no totals, no discount on a vacuous set — and
separates "the filters ate the pipeline" from "there was no pipeline", which are different findings.

test_verdict_coverage does not flag this, and the limit is recorded rather than papered over.
It derives boolean subset verdicts; a band string and an lsi integer match no part of that
shape. A derived population is bounded by the form it derives, and that form came from five
hand-found instances that all happened to be booleans.

SCAN-TRUNC — the one live wrong answer, and it points the other way

scan_deviation.model_surface_points caps the model reference at 200,000 surface vertices and
reaches that cap by breaking out of the element iterator, so it drops whole elements rather than
thinning evenly — in whatever order ifcopenshell yielded them. Scan points over a dropped element
were measured against whatever surface remained. Nothing in the response said the cap was hit.

Measured on a two-wing model built exactly to design:

reference within_pct out of tolerance max deviation
full 100.0 0 0.004 m
truncated to the first wing 50.0 500 50.0 m

That is a fabricated defect, not a loss of precision — on a QA/QC as-built check it sends a crew
to re-survey a wing that is fine. The cap exists for large models, so this is the normal case on a
real project. And it is the opposite sign from CLASH-TRUNC, where truncation made a partial
matrix read clean: the direction a silent bound pushes the answer is a property of the bound, not
of truncation
, so neither case predicts the other and both had to be measured.

The two caps are now handled differently, and the asymmetry is the decision. 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: there is no population the figure is true
of, so within_pct and the histogram are withheld. A caveat is for a number that means
something.

And the gate's first draft verified the refusal without verifying anything could reach it. Two
mutations — the producer returning False unconditionally, and the route dropping the keyword —
both left it reporting a clean tree, because it exercised analyze() in isolation and never the
chain. The refusal could have been correct and dead, and the gate would have agreed with itself.
Closed by exercising the producer against a fake geometry iterator and reading the route's call by
AST, with both keywords required to be names rather than constants — a hardcoded False was exactly
what the second mutation did. Ten mutations now, all red, in both directions: over-correcting
reds as well as under-correcting.

TRUNC-COUNTED — three screens printed the size of a page, and one ACTED on it

roundtrip_diff carries three bounds — rows[1:5001], changes[:1000], unknown_guids[:100] — and
disclosed one, via truncated. model.ts never declared even that one, so qaSection.ts could
not read it, while the same file reads the identical flag correctly forty lines earlier on a response
whose type does declare it. The CLASH-TRUNC hole in a second place.

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.

Why there is no sweep gate, which is the finding. 43 list truncations, 26 already carrying a
sibling count
— the good pattern is the house convention, which is what makes the rest anomalous.
Of the 17 without, 11 carry the count under another name or are top-N by design. Joining the rest to
.length reads by NAME reports 35 sites and it is not a finding: guids alone accounts for 12,
and the two I read in full were correct code. Even a checker-resolved join must separate ".length
shown as a count" from ".length used for an ellipsis" — a judgement call, and a rule needing those
needs an exemption list.

SCAN-DARK — and why it exists

SCAN-TRUNC's refusal says "use /scan/verify-lod500, which queries per element and never truncates
the model."
I went to check whether that was true. That route had no client method at all and was
listed in test_route_reachability.py as deliberately clientless
— so I had written a refusal that
redirects to something unreachable, which is the AUTHORITY-DARK shape, authored by me one commit
earlier. scanDeviation's declared type also omitted every field SCAN-TRUNC added.

A clean negative, measured before any UI. verify_from_scan on a scan covering nothing:
verified: 0, stamped: 0, uncovered: 50, nothing stamped, within_tolerance null rather than
false. It fails closed and needed no repair — so the card's job was only not to subtract that care.

The method behind nine of these, and the eight times it found nothing

Every item from RENTROLL-DARK onward began the same way: call the engine with nothing to evaluate,
before writing a line of UI.
Not reading the code — calling it. Seventeen engines were measured
that way. Nine carried a defect; eight were clean, and 7e1c0d0 records the whole table because
a sweep that only ever finds things is one nobody should trust.

The clean results are the interesting half. ci/latest returns overall: "none", badge: "NONE",
"No CI run yet." — it already refuses to let a no-run read as a pass, which is this sweep's entire
thesis, implemented before I got here. doc-graph 409s with its reason. verify_from_scan stamps
nothing and says absence is not a pass. The axis ended rather than being abandoned: the last four
probes came back clean against an actually-empty project.

Two of the nine came in this batch:

  • PROGRESS-UNMATCHED — capture_diff filtered both its sets through if g in known while its
    note promised disappearances were "never silently dropped". The promise held only among elements
    the current model still contains, which excludes the case a rework flag is for. Measured: 500
    capture GUIDs against a 300-element model → 100% complete, 200 dropped, no key naming them. The
    filter stays (removing it crashes the engine, which the gate proves by doing it); only the silence
    is fixed.
  • SCOPE-EMPTY — an empty scope register and one holding a single un-quantified item returned the
    identical 0.0 / 0.0 / 0.0. Opposite findings — go write the register, versus go do the work.
    The repair needed no judgement: spine.traceability, named beside it in coverageMaps.ts as the
    other completeness mapper, already returned None. An existing test asserted the defect, and
    changing it is only legitimate because the old line pinned a value standing in for a property it
    never stated; it now states the property.

The sweep is deliberately NOT a gate, which this repo's rules make a claim I have to justify. The
derivable part already is one — test_verdict_coverage, no exemptions. The rest is not: band: "undersupplied" is a string, 0.0 is a float, and "would a reader mistake this for a
measurement"
is a judgement. A rule needing judgement calls needs an exemption list, which is where
the next instance hides.

Following a prediction a previous session left in the roadmap

The concurrency sweep record ends with "a fourth sign-in path is the risk." It arrived, and the
code was already right
— SCIM auto-provisions and does route through the shared helper. What was
wrong is that test_sso_provision_race tested a hardcoded list of three and printed "all 3
auto-provisioning doors"
: silent about the fourth, and about any fifth. A registry reports on what
it contains, and its silence is indistinguishable from a clean bill.

The population is derived twice now, neither derivation needing an exemption list — every caller
of the helper must be a known door, and every site constructing a User must reach the helper or
be a by-request creation. Both proved by writing a real fifth door into the tree: wired to the helper
it reds one, provisioning inline it reds the other.

My own walk double-counted on its first draft and did not fail, it answered — attributing each
User(...) to every enclosing function gave 11 sites where an independent probe found 7, with the
precondition floor calibrated on the inflated number. The only reason it was caught is that the
population had been derived separately first and the two disagreed.

Two things deliberately not built, and why

  • PORTAL-TXN-DARK. POST /shared/{token}/decision is complete and careful — public by design,
    whitelisted, length-capped, a hard per-token cap. Nothing can reach it and nothing can read what it
    stores: the page a client sees is read-only HTML (no <form, no <button, no fetch(), and both
    client methods are callerless. Two callerless methods on the same seam are one missing feature,
    not two missing buttons.
    The write half means putting interactive elements on a public,
    unauthenticated
    surface — a security posture, not a layout choice — and building only the owner's
    feed would be a panel empty by construction, whose emptiness reads as "no client has decided"
    rather than "no client can." Parked with the six other items needing a decision.
  • A 500-where-409 race in register/create_user. Real, and low severity: unlike a sign-in,
    where both racers are the same legitimate user and the loser must succeed, here the loser is
    claiming a taken username and its request correctly fails either way. Inflating it to match the SSO
    finding would make the ledger less useful, not more.

Gate defects found in my own work, each by measuring

  • A relative path is a question about where somebody stood. An env-independence precondition
    globbed Path("src") — fine under run_tests.py (cwd=services/api), names nothing under ci.yml
    (repo root), where rglob yields nothing and reports a clean tree.
  • A parity check between two derivations that share a failure mode is not a check. Path keys were
    counted independently of group bodies… with a regex failing the same way. One whitespace mutation
    broke both and the counts stayed equal.
  • The XSS source pin accused a safe line, matching the escaper's name rather than its binding —
    not a miss, a confident accusation telling the author they had an XSS.
  • A mutation that did not compile printed no results line, and the harness read that as silence
    rather than a failed experiment.
  • Asserting one inequality is not asserting that three states are three states. A mutation giving a
    curable covenant breach the passing colour satisfied curable !== uncured while being worse than
    folding the two together.
  • day() formatted through toLocaleDateString, which renders en-GB September as "Sept" on this
    Node's ICU and "Sep" on another — a calendar spelled differently on two machines.
  • clearCacheKeys.test.ts caught a localStorage key with no readable stem — "an unread key is an
    unclassified key"
    , because Clear cached data then decides about it unchecked.
  • .btn matches no rule in style.css — copied from portal/panels/, where six files carry the
    same unstyled class. A class name is not a style, and nothing typechecks the gap.
  • An assertion satisfied by a different code path is not an assertion about that path. The
    authority blocking-table test checked a label against the whole card's text, and the same label
    appears in the "Not declared" line built straight from FACT_TYPES — so rendering the raw key in
    the blocking table passed.
  • The one mutation that survived was the one that mattered. 11 of 12 supply mutations redded;
    the twelfth — folding rumored units into the certain total, the single thing that engine is most
    careful to keep apart — passed, because the test asserted the engine's wording on the stated
    grounds that "750 is the raw total and is legitimately shown, so the number is not checkable". It
    is checkable; it just has to be read out of the row it belongs to. A number asserted against the
    whole card is not asserted at all
    — the line above, arriving from the opposite direction. 14 of
    14 after the repair.
  • I sized two items wrong, in opposite directions. Both progressCaptureDiff and
    scopeRegister were opened as small wiring jobs. The first has no producer at all — nothing in
    the product makes an "installed GUIDs at time T" set. The second has producers but needs three
    datasets assembled. An item's size is a claim about its dependencies, and neither claim survived
    looking. Both stay in the callerless freeze list with the reason recorded.
  • I enumerated one kind of coverage and called it coverage. Before pushing 0601e21 I checked
    that every suite covering the changed files' behaviour had passed, and concluded the rest could
    not touch the diff. test_file_sizes could: it covers changed files structurally. Two files
    under down-only ratchets had grown. The repair was extraction, not a raised cap — and it made
    three rendering decisions unit-testable that the Python gate had been string-matching.
  • A shim justified for callers that do not exist. model_surface_points kept an array-only
    wrapper "for callers that only want the array"; test_dead_code_population found none within the
    hour. Its sibling parse_point_cloud kept its wrapper and is not dead, because a second route
    still calls it — the asymmetry is the evidence that checking callers, rather than applying a
    symmetric habit, would have caught it.
  • Asserting the one case you thought of is not asserting the property. refused() keyed on
    reference_truncated passed all 14 scan tests, because every fixture set that flag and the null
    figure together — and the engine has a third refusal branch where the flag is false.
  • Bare python here is 3.11 and the venv is 3.12, so launching the backend suite with it
    produced ~500 failures in 0.0s each — ModuleNotFoundError: No module named 'fastapi'. An
    environment failure that looks exactly like a catastrophic regression, and the only thing that
    told them apart was reading one traceback before reacting to the count.
  • A suite run against a tree you are editing is a claim about no particular commit. The
    confirming backend run for f779f0c came back 719/720 — but it had read the working tree while I
    was editing it, so its one red was my own mid-run roadmap edit and its test_verdict_coverage
    pass came after I had already applied the fix. Both results were worthless. f779f0c's true
    state was established from the commit instead: git show f779f0c:…/decisionGateCard.ts reads zero
    coverage names.
  • Three gates in this repo caught me while I worked, which is the best evidence in this PR that
    they earn their keep: test_gap_records on a roadmap marker I had invented (⭐⭐),
    clearCacheKeys.test.ts on a localStorage key with no readable stem, and roadmapLanes.test.ts on
    an item I opened and assigned to neither a lane nor Parked. Each repair was the narrow one the gate
    implied, never a widened allowance.

Checklist (mirrors CONTRIBUTING.md)

  • Backend: cd services/api && python -m ruff check ../.. clean
  • Backend: full suite re-run on every commit. The first five were 719/719, 0 failures. The
    sixth came back 719/720, and the one red is worth stating rather than smoothing over:
    test_gap_records' loose-vs-strict parity check caught a marker I had invented in the roadmap
    heading (⭐⭐), which "shrinks its population without changing a word of its verdict". The
    repair is the established single ⭐, not a wider marker list — that gate's own lesson is
    that a hand-widened list is a list somebody stopped widening. Fixed, and re-verified across
    every gate that reads docs/roadmap.md, derived rather than guessed: 9 backend + 5 web, all
    green. test_schema_types_agree and test_verdict_coverage are registered in run_tests.py
    TESTS, the former also in test_roadmap_status.py's DONE_WHEN. Later runs are 720/720.
  • Web: npm run typecheck && npm run lint && npm run build clean (Node 24); npm run test
    271 files / 2,958 tests pass
  • No import cycles introduced
  • CHANGELOG.md entries added (newest at top); roadmap items updated, Lane I cell corrected
  • Version bumped — deliberately not. test_release_current reports v0.3.1143 is already
    10 releases ahead of the newest tag (v0.3.1133), exactly at its bound, so the next bump reds
    that gate. Ten merged versions have not reached a user; tagging is the repair, not raising the
    bound.
  • No secrets, no competitor names in shipped docs

Each item is its own commit and independently revertible. The diff statistics are deliberately not
restated here
— GitHub shows them at the top of this page, and an earlier revision of this body
carried a hardcoded +43,917 / −7,640 across 35 files that was stale within two commits. A number
that has to be re-edited on every push is one that should point at its source instead.
What is worth
saying and does not decay: the great majority of the insertions are the regenerated
apps/web/src/api/schema.d.ts, which is machine-produced and was byte-identical across two runs; the
hand-written part is the pro-forma and scan cards, their tests, the generator script, and six gates.

One red was this PR's — f779f0c's — and it is fixed in 482084b. test_verdict_coverage,
the gate added two commits earlier, failed on that commit's own card. Stated precisely, because CI
never ruled on it:
the API test gate on both f779f0c and b2bb594b came back cancelled, each
superseded by my next push, so the run on b2cc295 is the first CI backend run on any of this work.
The claim rests on local evidence — git show f779f0c:…/decisionGateCard.ts reads zero coverage
names, and the gate failed on a tree whose only other diffs were elsewhere — not on a CI result.

Three checks are red and none of them is this PR's. Two of the three are one cause:

check why
python (security.yml — pip-audit) anyio 4.14.1, CVE-2026-63374 (CRITICAL, fixed in 4.14.2) — pre-existing on main; the scheduled Dependency scan on main failed 2026-09-21
Container build (api) (containers.yml — Trivy) the same anyio CVE. The image builds; Trivy scans it and exits 1. Named here because the earlier triage said "pip-audit and Trivy" without naming the check, which reads as two findings rather than one
Android debug build (mobile.yml) android-actions/setup-android asks Google's repository for the deprecated tools package

Triaged with patches in this comment.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA

…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 "what is the most I can pay for the dirt and still
clear my hurdle" — a different solve.

FIN-CALC shipped that solve. proforma/residual.py bisects the land line over the
same forward solve() every other number comes from, the route serves it,
test_fin_calc.py covers it, and ApiClient.residualLand() was written for it. No
screen ever called it: the method sat in clientCallers.test.ts's UNCALLED list,
reachable only from a script. Same shape as DISC-poché and the MEP browser.

THE SUBSTANCE IS THE THREE CAVEATS, NOT THE NUMBER. residual_land_value is
careful in exactly the way a panel throws away:

  * land_value: null — unreachable even at $0 land. Printing a figure would be
    inventing one; printing nothing reads as a failure. It renders as the
    finding, quantified with at_zero_land, in 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.
  * bounds — the honest answer in that second case, and DECLARED NOWHERE in this
    client. The route returns it and residual.py's docstring lists it; the
    client's return type omitted it, so no unread-field audit here could see it,
    every one of them starting from the declared interfaces.

The applied value goes back as the basis the engine SOLVED for. _with_land
scales the FIRST category:"land" line and zeroes any others, so the residual is
the TOTAL basis; the driver form's "Land $" field is bound to
cost_lines.0.amount, true of the default set and not of one adopted from the
massing tab. A second land line left standing would make the forward re-solve
carry more land than the answer allowed for, and every visible number would
still balance. landBasis() / applyLandBasis() address it the engine's way.

Thirteen tests, one caveat per `it`, because one test over three caveats passes
when two of them work. Mutation-checked: printing a figure when land_value is
null, always claiming convergence, ignoring bounds, sending the percentage
unscaled, reading cost line 0 instead of the first land line, and dropping the
zeroing each red exactly the test written for them.

ALSO 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 and
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, telling its author they
had an XSS. 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. The escaper resolves through the file's own ui/feedback import now.

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 moving every
sink out of proforma/ would have left it green and meaningless. It now proves it
found files and is still looking at sinks before it may report none unescaped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…nd "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 not served      2

IT WAS NOT STALENESS, and the name it was filed under ("SCHEMA-STALE") 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. (The name also collided with services/api/test_schema_stale.py, an
unrelated gate about module-record schema versioning.)

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 IN THREE PARTS, because any one alone leaves the defect reachable:

  * apps/web/scripts/gen-api-types.mjs dumps the spec from aec_api.main:app into a
    temp file it deletes, so there is no persistent input left to be stale. It
    finds the venv interpreter on either OS rather than resolving a bare `python`
    off PATH, and refuses to overwrite a committed file from an implausible spec.
    It sorts `paths` first: FastAPI emits them in route-registration order, so
    including a router earlier shuffles thousands of lines — much of why
    "regenerating churned 38,231 lines" had become a reason not to. Two
    consecutive runs are byte-identical.
  * schema.d.ts regenerated by that script rather than by hand: 947 paths, 1,021
    operations, all declared.
  * services/api/test_schema_types_agree.py asserts every live (path, method) is
    declared and every declared one still served. A generator can only be run; a
    gate fails.

The gate 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 — the shape a
half-regenerated file actually has. It fails closed (an unclassifiable path group
raises rather than being skipped) and runs five preconditions before any verdict,
including that no route is registered under an `if`: the comparison only makes
sense if the app serves the same routes here, in CI and on the machine that last
ran the generator. Verified against the file as shipped: 482 served-but-undeclared
and 2 declared-but-gone.

That last precondition's first draft globbed a RELATIVE "src" path.
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.
Anchored on __file__ now, with the population count floored beside the verdict and
the gate run from both working directories.

WHY IT WENT UNSEEN, which 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. test_route_reachability's is the sharpest: it once counted
schema.d.ts as CLIENT code, so 29 routes were "called" by a generated file
restating the server's own route table. That is the opposite error; it is only
ever a claim about the SERVER.

And the item had 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.

Registered in test_roadmap_status.py's DONE_WHEN by the change that closed it,
which is the thing test_gap_records exists because nothing forces. That file's own
verdict line said "the two that HAVE a gate" and was wrong the moment a third
registered itself; the counts are derived now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
@coderabbitai

coderabbitai Bot commented Sep 25, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copy link
Copy Markdown
Owner Author

Three red checks, none of them this PR's — standing down on all three, with patches

This branch touches no dependency file, no workflow and no mobile file. git diff origin/main...HEAD --name-only is 17 files, all under apps/web/src/, services/api/test_*, docs/ and CHANGELOG.md/CLAUDE.md; the only package.json change is the gen:api-types script string. So none of the three can be caused by the diff, and each is verifiable as pre-existing rather than asserted as such.

1 + 2 — python (Dependency scan) and Container build (api) — one dependency between them

Both are anyio==4.14.1:

check finding
python → pip-audit gate CVE-2026-64847, CVE-2026-63349 → fixed in 4.14.2 (+1 more advisory with a published fix)
Container build (api) → Trivy CVE-2026-63374 — CRITICAL, fixed in 4.14.2

Pre-existing on main, not merely "unrelated". services/api/requirements.lock is byte-identical between origin/main and this head, and the scheduled Dependency scan on main itself failed on 2026-09-21 (run 35598718812, head e736c4d9, which predates this branch). The Trivy half is red by construction for the same reason: it is a function of the lock on main × today's Trivy DB.

No fix exists to port, so here is the patch rather than a widening of this PR. anyio is transitive (# via anthropic, httpx, httpx2), so the floor goes in the input file:

  # --- web / API framework ---
+ # CVE-2026-63374 (CRITICAL), CVE-2026-64847, CVE-2026-63349 — all fixed in 4.14.2. Transitive
+ # via anthropic / httpx / httpx2; pinned here because pip-compile reads only this file.
+ anyio>=4.14.2

It cannot be finished from this PR in one push, and that is the reason I have not started it. requirements.in's own header says the lock is "generated … inside python:3.12-slim … never on a dev box", and lockfile.yml's auto-commit is scoped to dependabot/pip/**. So editing requirements.in on this branch makes Verify committed lock is up to date fail — a fourth red check — until the recompiled bytes are transcribed back from that step's head -60 diff on a second push. That is the #546/#547 dance, and it belongs in a dependency PR of its own rather than inside a PR about type generation and a pro-forma card. anyio carries exactly two hashes, so the diff will fit inside head -60 when someone does run it.

3 — Android debug build — a third-party action, upstream

[command] …/cmdline-tools/20.0/bin/sdkmanager tools
Warning: Failed to find package 'tools'
Error: The process '…/sdkmanager' failed with exit code 1

android-actions/setup-android@40fd30f (v4) is invoked with no inputs, so this is its default package set; Google has removed the deprecated tools SDK package from the remote repository. The job died in setup, before any build step ran, and names a toolchain the diff does not touch. No re-run spent: the remote repository either has the package or does not, so a second attempt answers nothing.

The likely fix is a with: packages: list on that step omitting tools — written as "likely" on purpose. I cannot read android-actions/setup-android's action.yml from this session to confirm the input's name and default, and guessing at another action's internals is exactly the unwritten precondition this PR's own gate work is about. Someone who can read it should pin the input rather than take my word for the spelling.

Where that leaves the PR

API test gate is still running; everything else that can be green is green — web typecheck/test/build, both CodeQL analyses, the Postgres alembic parity job, the web and converter container builds, sbom. Locally on this exact tree: 719/719 backend suites, 0 failures, and 266 files / 2,875 web tests. I'll keep watching and re-check on a timer.


Generated by Claude Code

…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 clientCallers.test.ts's UNCALLED list,
reachable only from a script. Same shape as RESIDUAL-DARK and the MEP browser.

BOTH ENGINES ARE BUILT AROUND A REFUSAL, AND THE REFUSAL IS WHAT A PANEL
DESTROYS. That, not the numbers, is what this change is about and what its
fourteen tests pin.

  * `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. Its own docstring names what a green tick would be:
    "a scrub that reports 'no findings' because half its inputs were missing is
    worse than no scrub — it launders absent data into apparent confidence."
    Coverage is the headline; the flag qualifies it.
  * The NER totals cover a smaller population than the card above them.
    net_effective.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, on one screen, inviting a subtraction. Said out loud, with the skipped
    leases and their reasons.
  * The skipped list can be a page. `skipped` is capped at 50 server-side while
    skipped_count is not — the CLASH-TRUNC shape, a screen reporting over a
    partial view and calling it complete. The card says "Showing 50 of 120".
  * No leasing commission means the optimistic case. lc_included is false unless
    a rate is supplied and the engine never invents one, so landlord costs are
    understated and both NERs are high. An absent input must not read as a
    complete answer.
  * Zero computable leases renders as a refusal, not as $0. A total of zero and
    an absence of a total are different claims.

The scrub card's not-run table names what each blocked check NEEDS, which is the
actionable half: it says which document to go and get, rather than leaving the
gap as an absence of green ticks.

Six mutations, each redding exactly one test: rendering clean as clean, dropping
the population warning, presenting the skipped page as the whole set, dropping
the commission caveat, rendering zeros as a valuation, and dropping what a
blocked check needs.

Verified: tsc --noEmit clean, eslint clean, npm run build clean, 266 files /
2,875 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…t evidence

services/api/src/aec_api/t12.py exists for one refusal, in its own module
docstring: "income, expense and NOI must reconcile before and after mapping, or
the engine stops ... 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:

    no stated totals    reconciles: true    deltas all 0.0   adjusted_noi: 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 field that cannot tell.

So stated totals are the primary input here, not an optional extra, and absent
them the card prints "Tie-out tied to itself -- not a check" and puts
unmapped_count where the verdict would be, that being the only signal left.

Past the gate: adjusted NOI, one-time items 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 rather
than hidden, which is true (they are computed only past the gate) and stops
somebody hunting for a toggle.

THE PASTE PARSER SPLITS ON THE LAST COMMA. 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 enters the mapping as a real line worth nothing,
shifts no total, and is invisible in both the tie-out and the unmapped list. Rows
this client cannot read are reported, because they never reach the engine and are
therefore absent from both totals however the tie-out is run.

IT ALSO CLOSES THE LOOP ON RENTROLL-DARK. Five of the scrub's seven checks report
applicable: false for want of an income statement, and a normalised T-12 is one.
"Use in the rent-roll scrub" re-runs it in place with gross_potential_rent and
bad_debt, and says which statement it ran against -- a coverage figure that moved
is otherwise indistinguishable from a rent roll that changed. 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.

Sixteen tests, seven mutations each redding exactly one: trusting `reconciles`
without knowing whether totals were stated, splitting on the first comma,
returning 0 for an unreadable amount, presenting the unmapped page as the whole
set, inventing the prior-period fields, dropping "never applied for you", and
calling the derived views hidden rather than never calculated.

TWO MORE UNDECLARED FIELDS, both on this response. run_rate_vs_trailing.label was
found by THE COMPILER rather than 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".

AND residualLandCard.ts's buttons carried className = "btn", which 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 had 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 for proforma/; the six
in portal/panels/ are pre-existing and left for Lane B.

Verified: tsc --noEmit clean, eslint clean, npm run build clean, 267 files /
2,891 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
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   clean
    2 uncomputable obligations, 2 untested covenants    false    false
    1 of 3 covenants tested and passing                 false    TRUE

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.

THE FOURTH ENGINE THIS SESSION WITH THAT SHAPE, after soft_clash (CLASH-TRUNC),
rent_scrub and t12. Each one returns a boolean verdict beside a count of what it
could not evaluate, each hands over the antidote -- covenants.py's own note says
"an untested covenant is not a passing one" -- and each was defeated only by the
absence of a consumer. VERDICT-COVERAGE is filed to make that a derived gate
rather than a fifth hand-found instance.

So coverage is the headline here and the verdicts are qualifiers on it: "N of M
obligations computable, K of L covenants tested", and a register that evaluated
nothing says so in those words.

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, so the one property the engine was built for
was invisible to its client by construction. Rendered now, with
alternate_reading beside them: when the lender's notice date and our receipt date
differ the obligation carries BOTH 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, so the sentence could
not reach a screen.

TWO MISTAKES IN ITS OWN TESTS, BOTH THE SAME SHAPE.

A mutation that did not compile printed no results line, and the harness read
that as silence rather than as a failed experiment -- so it reported nothing
about a property it had not tested. It now says so explicitly.

And the three-state test first asserted only TEXT, every word of which comes from
the server, so painting a curable breach and an uncured one the same colour
PASSED. `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. Asserting one inequality is not asserting that three states are three
states; all three are compared pairwise now.

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 calendar read across machines would spell its dates
differently on two of them. Formatted from a fixed month table now.

FIXED: the localStorage key had no readable stem. clearCacheKeys.test.ts walks
every localStorage 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.

The register lives in localStorage per project, the way testfitTab.ts keeps a
unit mix -- a working register, not a shared system of record, and the card says
so where somebody might otherwise rely on it. A register module would make it
shared and is a product decision; recorded as chosen-against rather than
overlooked.

Verified: tsc --noEmit clean, eslint clean, npm run build clean, 268 files /
2,905 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
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:

    soft_clash      coordinated          over a page of the matrix (CLASH-TRUNC)
    rent_scrub      clean                bool(ran) and not failed -- 1 of 7 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

EVERY ONE OF THOSE ENGINES IS CAREFUL. Each hands the caller the coverage beside
the verdict and three attach a sentence saying why -- rent_scrub.py: "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 defect is always in
the CONSUMER, and four of the five had none, so nothing was wrong until somebody
wrote one. Finding it a sixth time by hand is what this gate exists to prevent.

THE VERDICT IS DERIVED STRUCTURALLY, NOT BY NAME. A subset verdict is a dict key
whose value is `bool(A) and (all(...)|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, nested ones included, 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: 5 subset verdicts, 616 client methods, 0 bare consumers. THE
DERIVATION FOUND THREE INSTANCES NOBODY HAD FOUND BY HAND -- sequence_clash,
fived and perf_budget; the last two have no client method at all, so the gate
reports them unexposed rather than passing them silently.

THE DEFECT IT 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. And 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, on 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 FIRST DRAFTS GOT WRONG, EACH MEASURED RATHER THAN REASONED ABOUT.

1. Matching coverage-ish WORDS against client response types returned 24
   methods, mostly unrelated senses of the word -- 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 } = ...`
   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 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 rentRollQuality.test.ts and
covenantCard.test.ts assert the coverage is RENDERED, with mutations. Written
down rather than discovered later.

It replays the pre-fix consumer it was written from, frozen in the file rather
than fetched with `git show`, because CI's checkout is shallow: it must re-find
that instance, and must find its repair clean.

Verified: ruff over the whole tree clean, manifest 1:1 with disk, tsc --noEmit
clean, eslint clean, npm run build clean, 268 files / 2,905 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
@ibuilder ibuilder changed the title SCHEMA-UNGENERATED + RESIDUAL-DARK: types that were never complete, and a solve nothing could reach Six callerless or unchecked things, and the gate that derives the last class Sep 25, 2026
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 MY FIRST READING WAS 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 (missing, stale with
days_over, and the superseded-still-active case "nobody looks for, because the
document exists and reads fine"). What was callerless is saveDealAuthority() --
the WRITE. So the table could be SEEN and never DECLARED: the design panel could
say "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.

The card is on Underwriting -- it governs every figure on that tab rather than
one of them. 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". 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. And the table shows age against each
fact type's OWN limit rather than 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, NOT THE RENDERING. FACT_TYPES has to be
mirrored 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
reportMoments.test.ts technique against reports.py, for the same reason. The
`required` flag is the one that matters -- a drift there shows a fact type as
optional while assess() 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 -- the shape
noRespelledShapes.test.ts exists to catch. AuthorityAssessment is declared once
and used by both routes. And `stale` carried the full row while being 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 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.

AUTHORITY-DOWNSTREAM is filed open rather than decided: the residual-land, NER,
rent-roll-scrub, T-12 and covenant cards all compute regardless of this gate,
which exists to stop exactly that. How hard to block -- refuse, warn, or
annotate -- is a product decision, and the wrong choice in either direction is
expensive.

Fifteen tests, eight mutations. Verified: tsc --noEmit clean, eslint clean, npm
run build clean, 269 files / 2,920 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
… 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. ApiClient.decisionGate() was
callerless, so none of it was reachable -- and the card's job is therefore not to
add judgement but to avoid subtracting it.

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. `unknown` renders as
blocking in WORD and COLOUR -- "no evidence — blocks", on the warn token,
distinct from both pass and fail -- because a card that renders it as a shrug
states something the engine did not. And 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.

THE EVIDENCE IT GATHERS IS NAMED, AND WHAT IT CANNOT GATHER IT LEAVES ABSENT. 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: a committee package without a citation contract, tiered comps, a
tied-out T-12 or a named signer is not ready. Naming what WAS supplied makes an
unknown legible as "not gathered here" rather than "gathered and found wanting",
which are two different things to do about it. A source that fails is omitted
rather than filled in, because 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.

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. 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.

Verified: tsc --noEmit clean, eslint clean, npm run build clean, 270 files /
2,930 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
@ibuilder ibuilder changed the title Six callerless or unchecked things, and the gate that derives the last class Eight callerless or unchecked things, and the gate that derives the last class Sep 25, 2026
services/api/test_verdict_coverage.py was added in f661a33 to derive, rather
than re-find by hand, the class this branch keeps hitting: an engine returns a
boolean verdict beside a count of what it could not evaluate, and a consumer
renders the verdict without the coverage. One commit later it failed on
apps/web/src/proforma/decisionGateCard.ts -- the card written to demonstrate
that very principle, shipped in f779f0c and pushed red.

THE FINDING IS REAL AND I NEARLY TALKED MYSELF OUT OF IT. The card does not
render the rent-roll scrub's `clean`; it forwards the whole payload to
decision_gate.evaluate and renders that engine's answer. So the first reading was
"a forwarder, not a renderer -- false positive". Measuring instead of reasoning
showed otherwise. _gate() splats **extra onto the row, so `rent_roll_scrubbed`
arrives at the client carrying `ran`, `failed` and `not_applicable`, and the card
declared five fields and rendered `detail`, which expresses none of them.

Measured through the real engine, a scrub of {ran: 1, not_applicable: 6,
failed: 0}:

  status:  "pass"
  detail:  "the scrub ran but found nothing"
  dropped: {ran: 1, failed: 0, not_applicable: 6}

"Nothing" there means NO PROBLEMS FOUND. It reads as "nothing was wrong", over
six checks that never ran -- the same defect one layer up from the four this
branch already fixed, in the card that argues for fixing them.

THE ENGINE IS SOUND AND STAYS UNTOUCHED. decision_gate already requires ran > 0
before it will pass this gate, and it already attaches the coverage to the row.
It measured the thing and handed it over; the client dropped it.

DECLARED, NOT RE-SPELLED. The response was an inline object literal at the call
site, so DecisionGateRow and DecisionGateResult are now named interfaces in
apps/web/src/api/creDeal.ts -- the shape noRespelledShapes.test.ts exists to
stop, and the reason the three coverage fields could sit on the wire unread by
any derivation over declarations.

WRITTEN AS A RULE, NOT A SPECIAL CASE. coverageOf() reports whatever coverage a
row carries; only rent_roll_scrubbed carries any today, and the next
_gate(..., **extra) will arrive the same way. A special case is a thing somebody
has to remember to widen. The engine's own status word is left alone: this card
declines to drop what the engine measured, it does not overrule the engine -- a
card that demoted this pass to a warning would be inventing a verdict the engine
did not reach.

Four tests, four mutations, each redding its own: never rendering the coverage,
coverageOf always null, coverageOf reporting at full coverage, and a total
counting only the checks that ran. The full-coverage case is the anti-vacuity
twin -- without it, a coverageOf that always returned null would pass the
positive assertion only by accident of the fixture.

Verified: test_verdict_coverage green (5 subset verdicts, 616 client methods, 0
consumers reading a verdict without its coverage); tsc --noEmit clean; eslint
clean; 20 tests across decisionGateCard and clientCallers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…rdict 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, and states
the distinction it exists for: "'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."

Two filters (delivery window, product type) and an eight-tier evidence weight; a
raw and a weighted months-of-supply index side by side, "because the gap between
them IS the argument". ApiClient.competitiveSupply() was written for it and no
screen called it.

A SECOND FINDING IN THE SAME METHOD: the response was typed
Record<string, unknown>. Every field was therefore invisible to
apps/web/src/api/deadFieldTyped.test.ts and to every other audit that starts from
the client's declarations -- the same hole excluded_comparables fell through in
SCREEN-VS-REPORT. Five interfaces are now declared in apps/web/src/api/creDeal.ts.

MEASURED THROUGH THE ENGINE BEFORE A LINE OF THE CARD WAS WRITTEN. A pipeline of
four projects, none matching the subject's product type:

  counts.competing              0
  excluded.counts.wrong_product 4
  weighted_index.band           "undersupplied"
  lsi                           0
  discount_pct                  0.0

A market in which every supplied project was filtered out reports THE MOST
FAVOURABLE VERDICT THE ENGINE CAN PRODUCE, byte-identical to a market with
genuinely no competition. "Undersupplied" is an argument to build -- which is
worse than the four `clean: true` cases this branch already fixed, because those
at least read as neutral.

THE ENGINE IS NOT WRONG AND NOTHING IN IT CLAIMS COVERAGE. counts.competing and
excluded.counts are in the response, and its own note says the excluded projects
are listed "so a thin competitive set is visible, not implied". The defect would
have been entirely the consumer's -- so the card 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 left on it. It also distinguishes "the
filters ate the pipeline" from "there was no pipeline", which are two different
findings; collapsing them sends somebody to widen a window that is already right.

Three narrower withholdings, each measured rather than assumed:
  * discount_pct is `round(...) if raw else 0.0`, so a zero raw yields the same
    0.0 as a pipeline that needed no discount. Withheld when raw is 0.
  * certain and rumored units are never added. The engine keeps them apart on
    purpose and says so; one combined figure is the number it refuses to produce.
  * 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
    difference between a fact and a broker's assertion, which is the whole reason
    the table exists.

test_verdict_coverage DOES NOT FLAG THIS, and the limit is recorded rather than
papered over. That gate derives boolean subset verdicts -- bool(A) and (all/any |
not B). A `band` string and an `lsi` integer match no part of that shape. A
derived population is bounded by the form it derives, and the form there was
chosen from five hand-found instances that all happened to be booleans.

AND THE MUTATION THAT SURVIVED WAS THE ONE THAT MATTERED. Eleven of twelve redded
immediately; the twelfth -- folding rumored units into the certain total, the
single thing the engine is most careful to keep apart -- passed, because the test
asserted the engine's WORDING on the stated grounds that "300 + 450 = 750 is the
raw total and is legitimately shown, so the number is not checkable". It is
checkable; it just has to be read out of the row it belongs to. A number asserted
against the whole card is not asserted at all -- the AUTHORITY-DARK lesson one
item later, arriving from the opposite direction. Re-mutated after the repair:
14 of 14 red, including two more blending mutations added while there.

The mirrored EVIDENCE table is pinned against supply_pipeline.py off disk, weight
included: a drift there makes the editor offer a discount the engine will not
apply. RUMORED is read from the Python too, so a tier the engine treats as
rumored that this card cannot offer reds the build.

The pipeline persists per project, because a competitive set is typed in once and
consulted for months, and re-entering six projects to re-run a reading is the
reason a screen goes unused.

Verified: 24 tests, 14 mutations; tsc --noEmit clean; eslint clean; production
build clean; test_claude_md_gates, test_verdict_coverage, test_roadmap_status and
test_gap_records all green; full web suite 271 files / 2,958 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
@ibuilder ibuilder changed the title Eight callerless or unchecked things, and the gate that derives the last class Ten callerless or unchecked things, the gate that derives the class, and the card it caught Sep 25, 2026
…uilt findings

scan_deviation.model_surface_points caps the reference at 200,000 surface
vertices, and reaches that cap by BREAKING out of the element iterator. So
hitting it does not thin the reference evenly -- it drops whole elements, in
whatever order ifcopenshell happened to yield them. Every scan point over a
dropped element is then measured against the nearest surface that remains, which
can be metres away. Nothing in the response said the cap had been hit.

MEASURED ON A TWO-WING MODEL BUILT EXACTLY TO DESIGN:

  full reference                  within_pct 100.0 -   0 out of tolerance - max  0.004 m
  reference truncated to wing A   within_pct  50.0 - 500 out of tolerance - max 50.0   m

That is not a loss of precision, it is a FABRICATED DEFECT -- and on a QA/QC
as-built check it sends a crew to re-survey a wing that is fine. The cap exists
FOR large models, so this is the normal case on a real project, not an edge.

IT IS THE OPPOSITE SIGN FROM CLASH-TRUNC, where truncation made a partial matrix
read CLEAN. The direction a silent bound pushes the answer is a property of the
bound, not of truncation, so neither case predicts the other and both had to be
measured. Having found one is not having found the class.

THE TWO CAPS ARE TREATED DIFFERENTLY, AND THE ASYMMETRY IS THE DECISION.

  * A truncated SCAN (parse_point_cloud, 500,000 points) is a COVERAGE claim.
    Every point read was measured correctly, so the verdict is true of the part
    examined; it stands, with its coverage beside it, in the shape this
    repository uses everywhere else. The note says a prefix of a scan file is a
    REGION of the cloud rather than a sample of it, because scan files are
    written in sweep order.
  * A truncated REFERENCE is a CORRECTNESS claim. There is no population the
    figure is true of, so within_pct, out_of_tolerance and the histogram are
    WITHHELD rather than qualified. A caveat is for a number that means
    something. The refusal names the cap and points at /scan/verify-lod500,
    which queries per element and never truncates the model.

model_surface_points_capped and parse_point_cloud_counted are new; the original
two remain as array-only wrappers, so nothing that only wanted the array breaks.
The counter keeps counting past the cap while it stops appending, so memory stays
bounded and the response can say what share of the cloud it examined -- the old
form returned an array whose length WAS the cap, making a 500,000-point file
indistinguishable from a truncated one.

AND THE GATE'S FIRST DRAFT VERIFIED THE REFUSAL WITHOUT VERIFYING ANYTHING COULD
REACH IT. Two mutations -- the producer returning False unconditionally, and the
route dropping the keyword -- both left test_scan_trunc reporting a clean tree,
because it exercised analyze() in isolation and never the chain. The refusal
could have been correct and dead code, and the gate would have agreed with
itself. Verifying a refusal is not verifying that anything can reach it.

Closed both: the producer is exercised against a fake geometry iterator (the cap
arithmetic is what is under test, not ifcopenshell), and the route's analyze call
is read by AST with both keywords required to be NAMES rather than constants --
a hardcoded False was the form the second mutation took.

Ten mutations, all red, in BOTH directions: over-correcting reds as well as
under-correcting (the scan cap also refusing; the producer claiming truncation
always). One earlier mutation printed no verdict line, which the harness reports
as a failed experiment rather than a pass, and it was rewritten until it ran.

Verified: test_scan_trunc, test_scan_deviation, test_scan_to_lod500,
test_scan_cache and test_lod500_readiness all green; ruff clean; registered in
run_tests.py beside test_clash_trunc; the doc gates and the six roadmap-reading
web tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
test_dead_code_population caught b2cc295 within the hour: `model_surface_points`
is unreachable. It is right, and the entry it flagged was mine from one commit
earlier.

Splitting `model_surface_points_capped` out, I kept the original as an array-only
wrapper and wrote "kept for callers that only want the array" beside it. There
are no such callers. The route moved to the capped form in the same commit, and
that was its only production caller -- so the shim was compatibility for a
caller that does not exist, justified by a sentence that sounded like a reason
and named nobody.

`parse_point_cloud` keeps its wrapper and is NOT dead, because the LOD 500 route
at routers/analysis.py:851 still calls it. That asymmetry is the evidence: the
same refactor produced one wrapper with a caller and one without, and only the
second is dead. Had I checked callers instead of applying a symmetric habit to
both, the gate would have had nothing to find.

The repo's rule for this state is wire it, delete it, or FROZEN with a reason.
Deleting is the honest one: there is now a single function, and it tells its
caller whether it truncated, which is the whole point of the change.

Verified: test_dead_code_population, test_scan_trunc, test_scan_deviation,
test_scan_to_lod500, test_lod500_readiness and test_reachable all green; ruff
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…hem acted on it

THE ONE THAT ACTS IS roundtrip_diff. It carries three bounds -- rows[1:5001] on
the sheet, changes[:1000], unknown_guids[:100] -- and disclosed exactly one, via
`truncated`. apps/web/src/api/model.ts never DECLARED even that one, so
qaSection.ts could not read it, while the same file reads the identical flag
correctly forty lines earlier on a response whose type does declare it. A field
absent from the declaration is invisible to every audit over declarations: the
CLASH-TRUNC hole in a second place, and the reason this was carelessness by
nobody.

The Apply button posts d.changes -- the PAGE. So a sheet with more changes than
the cap was APPLIED IN PART AND REPORTED AS WHOLE, and the model ends up
differing from the spreadsheet the operator believes they applied, with no error
anywhere. Reachable by construction rather than in principle: _diff_row emits one
change per changed CELL against a 5,000-row bound, so a single property column
overflows a 1,000 cap fivefold.

The other two only print. operations.ts summed three 100-row pages into "Broken
links (N)" beside percentages computed over the full population, so the card
contradicted the numbers next to it; portal.ts showed 20 of a 100-row page with
no total at all. The "every sheet, spec and package is linked" verdict was never
wrong -- truncation cannot make a non-empty list empty -- only the counts were,
and saying which is which is part of the report.

WHY THERE IS NO SWEEP GATE, which is the part worth reading. The class was
derived: 43 list truncations inside returned dicts, and 26 ALREADY CARRY a
sibling len(). The good pattern is this codebase's own convention, which is what
makes the rest anomalous rather than normal. Of the 17 without, 11 carry the
count under a different NAME (total - compliant; unapproved; count) or are top-N
by design, leaving 6 fields in 3 files.

Joining those names to .length reads in the web tree reports 35 sites AND IT IS
NOT A FINDING: `guids` alone accounts for 12, matched against engines their
callers never call. The two read in full were correct code -- qaSection.ts:205
reads an assembly-thermal result's guids and slices to 200 itself, and
repairPanel.ts:184 reads sample.length only to decide whether to print an
ellipsis, with the authoritative `removable` count rendered beside it. A leaf
name is not a response, and a number with a list attached reads as evidence. Even
a checker-resolved join would still have to separate ".length shown to a user as
a count" from ".length used for an ellipsis" -- a judgement call, and a rule that
needs judgement calls needs an exemption list, which is where the next instance
hides. So the three fixed sites are gated behaviourally and the sweep stops.

AND THE GATE'S OWN CHECK WAS WRONG TWICE, both times by matching a name too
widely. `"truncated:" in model_ts` passed with the field deleted, because
`rows_truncated:` contains it -- a suffix match, the mirror of the prefix match
test_gap_records paid for. Word-boundary matching over the WHOLE FILE also
passed, because `truncated: boolean` is declared on four other unrelated
endpoints in that 66 KB file. The sentence "a leaf name is not a response" is in
this test's own docstring, about the 35-site join, and was committed as a defect
one screen later. The subject is now roundtripDiff's own declaration, extracted
by locator; renaming the method reds eight checks rather than narrowing the
search to nothing.

Two more, both mine. The web-tree anchor was parents[1], which is services/ --
it failed closed with a traceback, the right DIRECTION but the wrong shape,
because a check that tracebacks blames Python instead of naming the path it
wanted; a bad anchor is now a named failure. And innerHtmlGuard.test.ts caught
gapList interpolating the bare parameter `total` into innerHTML: a server-side
number today, but that guard keys on the BINDING NAME, which does not move when a
caller starts passing something else. Escaped, not grandfathered.

spineExcluded.test.ts's fixture predated gaps.counts and now blanks the card
rather than showing a wrong number. That is the intended direction and no
fallback was added: server and client ship from one build, so a response missing
the field is a bug, not a skew to paper over -- and a fallback summing the page
lengths would reinstate the defect silently.

Verified: 10 mutations red in both directions, plus 4 more on the scoped
declaration check; the gate runs identically from services/api and from the repo
root; web 271 files / 2,958 tests green; typecheck, eslint and production build
clean; ruff clean; test_claude_md_gates, test_roadmap_status, test_gap_records,
test_reachable, test_dead_code_population and test_ruff_scope green; registered
in run_tests.py beside test_scan_trunc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…nel may not grow

test_file_sizes caught 0601e21 AFTER I pushed it, and the reason I missed it is
worth more than the fix. Before pushing I checked that every suite covering the
CHANGED FILES' BEHAVIOUR had already passed in that run -- test_xlsx_roundtrip,
test_dashboard, test_traceability, test_golden_thread, test_roundtrip_qa,
test_trunc_counted -- and concluded the remaining suites could not touch the
diff. They could: test_file_sizes covers changed files too, STRUCTURALLY rather
than behaviourally, and enumerating one kind of coverage felt like enumerating
coverage. A gate that reads the size of a file I edited is a gate about my edit.

Two files under down-only extraction ratchets had grown: portal.ts 1233 > 1228
and qaSection.ts 1293 > 1274. Raising a cap is not the repair -- those files
exist to shrink -- so the additions went where the repo's own guidance puts them:
a self-contained module plus one small mount point.

apps/web/src/viewer/tools/roundtripDiffView.ts now owns the headline, the Apply
label, the truncation note and the change table. qaSection.ts is 1261 (13 under
its cap, having been 19 over), and portal.ts is exactly at 1228 with the worklist
total folded into the heading it already renders rather than added beside it.

AND THE EXTRACTION FIXED A REAL WEAKNESS IN MY OWN GATE. test_trunc_counted.py
asserted the three rendering decisions by string-matching qaSection.ts, which is
the weakest check available: it cannot tell a rendered string from a comment, and
the declaration check next to it had already passed TWICE while its subject was
deleted. Those three decisions are now unit tests --
roundtripDiffView.test.ts, 14 of them, calling the functions -- including the
load-bearing one: on an overflowing sheet the button must not name the 4,000 it
is not going to write, and Apply must receive the 1,000 the server actually sent.
It also pins that spreadsheet cells are escaped into innerHTML, which the inline
version had and nothing asserted.

test_trunc_counted.py keeps what a unit test cannot see: the server shape, the
client DECLARATIONS, and that the panel delegates rather than keeping a second
copy. Plus a check that the vitest file exists at all -- without it the rendering
assertions could be deleted and nothing here would notice.

Verified: test_file_sizes green (portal.ts 1228/1228, qaSection.ts 1261/1274,
client.ts 545/554, app.ts 2442/2442, register.ts 2447/2452); web 272 files /
2,972 tests green; typecheck, eslint and production build clean; ruff clean;
test_trunc_counted, test_claude_md_gates, test_dead_code_population,
test_reachable and test_roadmap_status green.

The full backend suite on the previous commit finished 721/722, the one failure
being this size gate, now fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…unreachable

THIS ITEM EXISTS BECAUSE OF THE PREVIOUS ONE. SCAN-TRUNC made /scan/deviation
refuse when the model reference was cut short, and wrote a message telling the
reader to "use /scan/verify-lod500, which queries per element and never truncates
the model". That route had NO client method at all and was listed in
services/api/test_route_reachability.py as deliberately clientless. A refusal
that redirects to an unreachable feature is a gate you can read and cannot
satisfy -- the AUTHORITY-DARK shape, created here by my own wording one commit
earlier, and found only by going to check whether the sentence was true.

And ApiClient.scanDeviation's declared return type OMITTED EVERY FIELD SCAN-TRUNC
ADDED -- reference_truncated, points_truncated, points_total, error -- so the
refusal was invisible to the client. Third instance of that hole this session
after excluded_comparables and roundtripDiff.truncated. The engine also emitted
the two flags from only two of its three branches; all three emit them now, so
the client type requires them instead of guessing.

A CLEAN NEGATIVE, MEASURED BEFORE A LINE OF UI. Five engines this session carried
a verdict that went vacuous once nothing had been evaluated, so verify_from_scan
was measured on a scan covering nothing first: verified 0, stamped 0, uncovered
50, nothing stamped, and within_tolerance is null rather than false on an
uncovered element. IT FAILS CLOSED AND NEEDED NO REPAIR. Recording that is part
of the report -- the card's only job is not to subtract the care already there.

So: the aggregate's refusal renders as a refusal and prints no percentage, no
band table and no statistic, because a zero there reinstates exactly what
SCAN-TRUNC closed; `uncovered` carries the same weight as `verified`, since a
high verified count over a thin scan is the number somebody quotes; and a finding
renders as "verified as WRONG -- not stamped", a punch item rather than a
handover. The per-element pass runs after the aggregate whatever the aggregate
said, which is what makes the refusal's own advice actionable.

THE SURVIVING MUTATION NAMED A BRANCH MY FIXTURES DID NOT HAVE. Keying refused()
on `reference_truncated` instead of on the missing figure passed all 14 tests,
because every fixture set the two together -- and the engine has a THIRD refusal
branch, an empty cloud or reference, where within_pct is null and that flag is
false. Asserting the one case you thought of is not asserting the property. The
predicate reads the absent figure, both branches are fixtures now, and
refused()-always-true and always-false both red.

The route leaves test_route_reachability's clientless list and scanDeviation
leaves the callerless ratchet in clientCallers.test.ts. Mounted through a
delegating three-line call in qaSection.ts, which is under a down-only size
ratchet: 1266 of 1274.

Verified: 9 mutations red plus 3 on the refusal predicate; web 273 files / 2,987
tests green; typecheck, eslint and production build clean; ruff clean;
test_route_reachability, test_file_sizes, test_scan_trunc, test_scan_deviation,
test_scan_to_lod500, test_reachable, test_claude_md_gates, test_roadmap_status,
test_gap_records and test_dead_code_population all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
@ibuilder ibuilder changed the title Ten callerless or unchecked things, the gate that derives the class, and the card it caught Eleven unreachable or unchecked things, the gates that derive them, and the three times those gates caught me Sep 25, 2026
…and dropped

progress_rollup.capture_diff builds both of its sets by filtering through the
model's element list:

    added   = sorted(g for g in (s2 - s1) if g in known)
    removed = sorted(g for g in (s1 - s2) if g in known)

while its note said: "Elements present at t1 but absent at t2 are surfaced as
'disappeared' -- a re-scan or rework flag, NEVER SILENTLY DROPPED."

The promise held only among elements the CURRENT model still contains, which
excludes exactly the case a rework flag exists for: something taken out of the
model AND off the site between two captures. Measured, 500 capture GUIDs at t2
against a 300-element model:

    installed_t2 300 · newly_installed 300 · pct_complete_t2 1.0
    200 dropped · no key naming them

A note that claims more than the code does is worse than no note, because it is
read as a guarantee and stops the next person looking.

THE FILTER IS KEPT AND ONLY THE SILENCE IS FIXED. A diff is scoped to the model
it is about, and an unmatched GUID carries no class or storey, so added_by_class
and added_by_level have nowhere to put it -- removing the filter crashes the
engine, which the gate proves by doing it. What changed is that unmatched_t1 and
unmatched_t2 are reported, so a capture aimed at a different model version shows
as an unmatched count instead of arriving as a quietly smaller diff that reads
like slower progress. The note states the scope it has, and says nothing at all
when everything matched -- a sweep that always speaks is one nobody reads.

THE WIRING HALF IS NOT SMALL, AND ASSUMING IT WAS IS THE CORRECTION WORTH
RECORDING. This was opened as another callerless-method item. It is not one:
progressCaptureDiff AND progressRollup are both callerless, and nothing in the
product produces an "installed GUIDs at time T" set -- so giving either a screen
means building capture capture, not adding a card. Same shape as CITE-RECORD,
where "give the record builder a producer" turned out to mean building
record-aware QA. An item's size is a claim about its dependencies, and this one's
was wrong until somebody went looking for the producer. Both methods stay in the
callerless freeze list, deliberately.

Eight mutations, all red. Two of them redded by CRASHING rather than by failing a
named check, and the harness reported those as "NO VERDICT LINE" -- inconclusive,
not passes -- so they were re-run with exit codes to confirm. One of the two was
my own test indexing a deleted key: a bare KeyError IS a red, but it names Python
instead of the property that broke, so that access is a .get() now and the same
mutation reports two named failures. A check whose failure message can misdiagnose
is worse than one that stays silent.

Two clean negatives from the same sweep, recorded so nobody re-runs them:
adjacency.summary returns total, satisfiable and the complete unmet list with no
cap and no vacuous verdict; verify_from_scan fails closed on a scan that covered
nothing. Half the engines measured in this sweep needed no repair, and saying so
is what makes the other half credible.

Verified: test_progress_unmatched, test_progress_rollup, test_verified_progress,
test_schedule_progress, test_claude_md_gates, test_roadmap_status and
test_gap_records green; 466 web shell/tooling tests green; ruff clean; registered
in run_tests.py beside test_progress_rollup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…he same numbers

scope_register.register returned pct_quantified / pct_allocated / pct_scheduled
as 0.0 when there were no items. Measured, and the two responses are
indistinguishable on every number a card would show:

    an empty register        item_count 0   0.0 / 0.0 / 0.0
    one item, nothing done   item_count 1   0.0 / 0.0 / 0.0

Those are opposite findings -- go write the register, versus go do the work --
and only item_count separated them, which a percentage-led card need not render.
Same shape as T12-SELFTIE, where the two responses were likewise byte-identical
and the discriminator had to become a parameter of the renderer.

THE REPAIR IS NOT A JUDGEMENT CALL, BECAUSE THE ANSWER WAS ALREADY IN THE TREE.
spine.traceability returns None for an empty population, and
apps/web/src/api/coverageMaps.ts names the two engines side by side as THE two
completeness mappers, each answering "what proportion of these records carry the
link they need". Two engines documented as answering the same question disagreed
about how to say "no population", and one of the two spellings cannot be told
from a real measurement.

AN EXISTING TEST ASSERTED THE DEFECT, and that is the part worth recording.
test_scope_register had `assert e["pct_quantified"] == 0.0` on the empty case: a
VALUE standing in for a property it never stated. Changing a test to match new
behaviour is only legitimate when the old assertion encoded the bug, and saying
which it was is the whole of the justification. It now asserts the property --
the three are None, a real 0% is still 0.0, and the two cannot be equal.

Five mutations, all red, in BOTH directions: each percentage reverting to 0.0 on
empty, and the over-correction that turns a genuine 0% into None. The
over-correction matters because the narrow fix and the sloppy one are
indistinguishable on the empty case alone.

WIRING scopeRegister IS A FEATURE, NOT A CARD. The route takes scope_items,
qto_lines and activities entirely from the request body and reads nothing from
the database, so a caller has to assemble three datasets first. Unlike
PROGRESS-UNMATCHED the producers do exist, so this is buildable rather than
blocked -- it is simply not extra-small, and the method stays in the callerless
freeze list until somebody sizes it honestly rather than assuming.

Verified: test_scope_register, test_scope_gap, test_cbs, test_takeoff_scope,
test_claude_md_gates, test_roadmap_status, test_gap_records, test_file_sizes,
test_ruff_scope and test_dead_code_population all green; ruff clean; 466 web
shell/tooling tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…erived

Every item from RENTROLL-DARK onward began the same way: call the engine with
nothing to evaluate, BEFORE writing a line of UI. Not reading the code -- calling
it. Seventeen engines were measured that way across this session. Nine carried a
defect. Eight were clean.

The clean ones are recorded because an unrecorded negative gets re-derived by the
next person, and because a sweep that only ever finds things is one nobody should
trust. decision_gate attaches coverage to the row it belongs to; deal_authority
blocks on all three required fact types; verify_from_scan stamps nothing and says
absence is not a pass; adjacency.summary returns total, satisfiable and the full
unmet list uncapped; and the four self-supplying endpoints probed last -- ci/latest,
doc-graph, drawings/sync-status, client-decisions -- were all clean against an
actually-empty project. ci/latest is the nicest of them: overall "none", badge
"NONE", "No CI run yet." It refuses to let no-run read as passed, which is the
whole thesis of this sweep, already implemented.

That is how the axis ENDED rather than being abandoned: the last four probes came
back clean.

THIS IS A SNAPSHOT AND DELIBERATELY NOT A GATE, which this roadmap's own rule
makes a claim needing justification -- "a sweep held as prose is a check that can
only report good news". The mechanically derivable part of the population ALREADY
is a gate: test_verdict_coverage derives boolean subset verdicts structurally,
with no exemption list. What is left over is not derivable. `band:
"undersupplied"` is a string, `0.0` is a float, and "would a reader mistake this
for a measurement" is a judgement -- and a rule that needs judgement calls needs
an exemption list, which is where the next instance hides. TRUNC-COUNTED paid for
that lesson twice in one afternoon.

So the ledger says what was measured and when, and the next sweep starts by
re-measuring rather than by trusting the table.

Verified: test_claude_md_gates, test_roadmap_status and test_gap_records green;
466 web shell/tooling tests green. Probe databases removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
@ibuilder ibuilder changed the title Eleven unreachable or unchecked things, the gates that derive them, and the three times those gates caught me Fourteen unreachable or unchecked things, the gates that derive them, and the times those gates caught me Sep 25, 2026
… recomputing it was the only way to see it

ciRun had a caller; ciLatest did not. The MODEL-CI pack -- rule library, data
completeness, clash, pinned IDS, quantity drift -- PERSISTS its report, and the
tool's own footnote says why: "the badge is stored so every model version carries
a quality gate." Opening the tool ran the whole pack again. A result persisted
specifically so it need not be recomputed, reachable only by recomputing it.

The tool now opens on the stored report with a Run-again action beside it; an
unrun project gets "Run the check pack" instead.

WIRING IT HANDED THE RENDERER A STATE ciRun CAN NEVER PRODUCE, and it could not
describe it. model_ci.latest returns {overall: "none", badge: "NONE", checks: [],
note: "No CI run yet."}, and the inline line

    ${r.passed ?? 0}/${r.total_checks ?? r.checks.length} passed

renders that as "0/0 passed" -- a score, for something never scored. A renderer
is only as sound as the states it has been handed, and while ciRun was its only
source it had never been handed this one. isUnrun is a branch rather than a
caveat: no score, the engine's own sentence, a warn colour rather than a pass.

The ENGINE was clean before any of this and is in the DEGENERATE-SWEEP ledger as
such -- it already refuses to let a no-run read as a pass. The defect was
entirely in what could reach it.

THE SURVIVING MUTATION NAMED A THIRD STATE. Reducing isUnrun to !r.checks.length
passed all eleven tests, because no fixture had an empty `checks` on a report
that had actually RUN -- a run with nothing applicable to check. "Run it" and "it
ran and found nothing to check" are different findings, and conflating them is
this session's own theme one level down. Both are fixtures now, and isUnrun keyed
on either field alone reds.

Extracted to modelCiView.ts rather than grown in place: qaSection.ts is under a
down-only size ratchet and had eight lines of headroom. 1270 of 1274.

ALSO FIXES A COMMIT I PUSHED RED. 7e1c0d0 added DEGENERATE-SWEEP to the roadmap
marked open, which roadmapLanes.test.ts refuses unless an open item is in a lane
or explicitly Parked. I ran that suite for the ledger commit and tailed three
lines of output, which showed the DURATION and not the verdict -- I read a
timing and called it a pass. The sweep is closed rather than parked, because the
axis ended (the last four probes came back clean) and a future sweep is new work,
not the remainder of this one.

Verified: web 274 files / 2,999 tests green -- the whole suite this time, with
the verdict line read, not the tail; typecheck, eslint and production build
clean; test_claude_md_gates, test_roadmap_status, test_gap_records,
test_file_sizes and test_model_ci green; 12 tests and 11 mutations on the new
module, the survivor among them re-killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…than build half of it

POST /shared/{token}/decision is complete and careful: public by design, inputs
whitelisted and length-capped, a hard per-token decision cap, unknown or revoked
token -> 404. It records a timestamped approve / acknowledge / decline and is
explicit that it is "NOT a payment and NOT an e-signature of record."

NOTHING CAN REACH IT, AND NOTHING CAN READ WHAT IT STORES.

  the page a client sees   read-only HTML -- grepped: no <form, no <button,
  (GET /shared/{token})    no fetch(, no mention of /decision
  sharedDecision           callerless (the SPA's recipient method)
  clientDecisions          callerless (the owner's feed)

So this is not a callerless method, it is a whole transaction with neither end
built -- which is the reason both methods sit in the freeze list, and reading
either one alone would not have explained it. Two callerless methods on the same
seam are one missing feature, not two missing buttons. I opened this expecting a
third small wiring item and found something a size larger; that is the third time
this session an item's size turned out to be a claim about its dependencies.

DELIBERATELY NOT BUILT, AND THE REASON IS THE WRITE HALF. Giving the client a way
to decide means putting interactive elements on a PUBLIC, UNAUTHENTICATED HTML
surface whose docstring currently promises every value is escaped and the page is
read-only. That is a security posture, not a layout choice, and changing it is
the user's call.

And building only the owner's feed would be decoration: a panel empty by
construction on every project, because nothing can write a decision. Half a loop
is worse than none -- the empty screen reads as "no client has decided anything"
rather than as "no client can".

Parked with the other six items needing a decision, so nobody picks it up
believing it is a sprint item, and recorded with what it would need if it is
built: the form, the feed, and a statement of what the record IS for -- the
endpoint already says what it is not.

Verified: 559 web shell/tooling/register tests green (verdict line read, not the
tail -- that is how the previous commit went out red); test_claude_md_gates,
test_roadmap_status and test_gap_records green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
…te could not see it

The concurrency sweep record in docs/roadmap.md ends with a prediction: "a
fourth sign-in path is the risk." IT ARRIVED, AND THE PREDICTION HALF CAME TRUE.
routers/scim.py auto-provisions users and DOES route through
auth.get_or_create_sso_user, so the code was right.

What was wrong is that test_sso_provision_race tested three doors from a
hardcoded DOORS list and printed "all 3 auto-provisioning doors (OAuth, SAML,
massing.cloud)" -- silent about the fourth, and about any fifth. A registry
reports on what it contains, and its silence is indistinguishable from a clean
bill: test_gap_records' own sentence, arriving from the other direction. Not a
stale OPEN this time, but a population that quietly stopped being whole.

THE POPULATION IS DERIVED TWICE, FROM TWO ANGLES, AND NEITHER NEEDS AN EXEMPTION
LIST:

  * every CALLER of the helper must be a known door -- refuses a fifth door
    wired correctly but never race-tested;
  * every site CONSTRUCTING a User must reach the helper or be a by-request
    creation -- refuses a fifth door that provisions inline, which is the
    dangerous shape.

Both are proved by writing a real fifth door into the tree and running the gate:
wired to the helper it reds the first, provisioning inline it reds the second.
The by-request set (register, create_user, the admin bootstrap) is a real
category rather than a fudge -- each guards with an explicit 409 and none is a
sign-in door.

THE WALK DOUBLE-COUNTED ON ITS FIRST DRAFT AND DID NOT FAIL, IT ANSWERED.
Attributing each User(...) to every enclosing function put a construction inside
a nested _make_user under both the factory and its route: 11 sites where an
independent probe found 7, and "8 via the helper" when there are four. The
precondition floor was calibrated on the inflated number, so it would have been
satisfied by a walk finding half the tree. Attribution is innermost-wins by line
range now, and the floor is 7. Caught by comparing the gate's count against a
derivation written separately -- which is the only reason it was caught at all.

RECORDED AND NOT FIXED, DELIBERATELY: register and create_user are
check-then-insert, so a true race yields a 500 where 409 was intended. That is
low severity and saying so matters. Unlike a sign-in, where both racers are the
same legitimate user and the loser must succeed, the loser here is claiming a
taken username and its request correctly fails either way. A wrong status code on
an already-failing request is not the defect the SSO doors had, and inflating it
to match would make the ledger less useful, not more.

Verified: test_sso_provision_race, test_seeding_sweep, test_saml, test_sso,
test_scim, test_auth, test_claude_md_gates, test_roadmap_status and
test_gap_records green; ruff clean; 559 web shell/tooling/register tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tt2XKB83wwNt2nrMbK6eEA
@ibuilder ibuilder changed the title Fourteen unreachable or unchecked things, the gates that derive them, and the times those gates caught me Sixteen unreachable or unchecked things, the gates that derive them, and the times those gates caught me Sep 25, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants