Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6be23d3
RESIDUAL-DARK: the developer's actual question was built, routed and …
claude Sep 25, 2026
7e39f34
SCHEMA-UNGENERATED: the generated client types were never complete, a…
claude Sep 25, 2026
43cd696
RENTROLL-DARK: two engines that say whether you can believe the rent …
claude Sep 25, 2026
5c6b8e6
T12-SELFTIE: a gate that could not fail, because nobody was handing i…
claude Sep 25, 2026
f8bae56
COVENANT-DARK: two verdicts that read clean when nothing was evaluated
claude Sep 25, 2026
f661a33
VERDICT-COVERAGE: the class found five times by hand, now derived
claude Sep 25, 2026
6fc46a2
AUTHORITY-DARK: a gate you could read and could not satisfy
claude Sep 25, 2026
f779f0c
DECISION-GATE-DARK: the keystone over everything else on the tab, and…
claude Sep 25, 2026
482084b
VERDICT-COVERAGE: the gate caught the card written to demonstrate it
claude Sep 25, 2026
b2bb594
SUPPLY-DARK: an empty competitive set reported the most favourable ve…
claude Sep 25, 2026
b2cc295
SCAN-TRUNC: a model cut short turned a correct building into 500 as-b…
claude Sep 25, 2026
e22c282
SCAN-TRUNC: delete the compatibility wrapper nothing was compatible with
claude Sep 25, 2026
0601e21
TRUNC-COUNTED: three screens printed the size of a page, and one of t…
claude Sep 25, 2026
d1085e8
TRUNC-COUNTED: extract the diff view, because the ratchet said the pa…
claude Sep 25, 2026
e3905a4
SCAN-DARK: the refusal SCAN-TRUNC added pointed at a route frozen as …
claude Sep 25, 2026
850aa32
PROGRESS-UNMATCHED: an engine that promised "never silently dropped" …
claude Sep 25, 2026
5abcc1c
SCOPE-EMPTY: an empty scope register and one with nothing done were t…
claude Sep 25, 2026
7e1c0d0
DEGENERATE-SWEEP: the ledger, so the eight clean results are not re-d…
claude Sep 25, 2026
9db199b
CI-LATEST-DARK: the badge is stored so it need not be recomputed, and…
claude Sep 25, 2026
ba3bddd
PORTAL-TXN-DARK: record a transaction with neither end built, rather …
claude Sep 25, 2026
999bdd8
SSO-DOOR-DERIVED: the fourth sign-in door arrived guarded, and the ga…
claude Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
718 changes: 718 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,83 @@ whose regex carries the prefix by construction, and probe validity is **self-che
must match its own route), so an unhandled converter reds the build. *A list of known cases is a list
somebody stopped widening; a self-check is not.*

**A seventeenth joined them on 2026-09-25: `services/api/test_schema_types_agree.py`** — does the
committed `apps/web/src/api/schema.d.ts` describe the API this server serves? It is generated from the
FastAPI spec and checked in, so it is a claim about the server sitting beside the client, and nothing
was checking the claim: **500 of 947 paths, 541 of 1,021 operations**, 482 undeclared across 165 of 203
path groups. **Filed as "SCHEMA-STALE", and the name was the first thing wrong with it** — stale
implies it was once right, and that file and the `.gitignore` line hiding its input were written in the
SAME commit, with the app carrying *fewer* route decorators today (1016 vs 1022) than when it was
generated. *A stale-sounding name sends the next reader at the one action that cannot fix it*, which
here was re-running the generator: `gen:api-types` read `src/api/openapi.json`, **a gitignored file**,
so it regenerated from whatever dump sat on that machine, printed a green tick and wrote the same file.
*"Regenerate the types" did not mean "read the server", and nothing said so* — the generator now dumps
from `aec_api.main:app` into a temp file it deletes, so there is no persistent input left to be stale.
**The rule is method-level and that is load-bearing:** `openapi-typescript` emits all eight verbs per
path and marks the unserved ones `?: never`, so a path-level check passes a file declaring all 947 URLs
and none of their verbs — which is the shape a half-regenerated file actually has. Both arms are
mutation-proved, and the parser REFUSES a group it cannot classify rather than narrowing, because a
silent narrowing reports that path as missing everything, reading as "regeneration due" instead of "the
parser no longer understands this file".
**The reason it went unseen is worth more than the fix: SEVEN audits here exempt this file by name**
(`deadFieldScope`, `docComments`, `unfiledMap`, `deadFieldTyped`, `noRespelledShapes`,
`test_route_reachability`, `test_file_sizes`), each for a good reason of its own. *Seven exemptions and
no owner is how an artifact stops being checked by anybody.* And `test_route_reachability`'s is the
opposite error, still instructive: it once counted this file as CLIENT code, so **29 routes were
"called" by a generated file restating the server's own route table.** It is only ever a claim about
the SERVER. **The item had also been parked on a decision that was not load-bearing** — commit
`openapi.json`, or produce it in CI? — and needed neither answer, because an input that does not
persist needs no home, and the agreement check needs no node and no artifact crossing jobs. *An item
parked on a decision stays parked until somebody re-derives whether the decision was load-bearing.*
**And the gate's own first draft asked its newest precondition from the wrong place.** The
env-independence scan globbed `Path("src")`, relative to the working directory — which `run_tests.py`
sets to `services/api`, while `ci.yml` invokes `python services/api/run_tests.py` from the repo ROOT.
`rglob` over a missing directory yields nothing and raises nothing, so it would have reported a clean
tree under CI and nowhere else. *A wrong question returns a confident number* — the same failure
`test_scratch_ignored` paid for twice, in a file written days after reading its lesson. **A relative
path is a question about where somebody stood**; it is anchored on `__file__` now, the population
count is returned rather than discarded and floored beside the verdict, and the gate is run from both
directories.
**And its parity check was not a check.** A path group whose shape the group regex cannot match is
absent from the parse *silently*, and is then reported as "served but undeclared" — a parser failure
wearing the costume of a stale file — so the path keys are counted independently. The first draft
counted them with a regex that **fails in exactly the same way the group regex does**, so a
trailing-whitespace mutation broke both derivations and the counts stayed equal. *A parity check
between two derivations that share a failure mode is not a check* — the crude count catches what the
strict one cannot, and both mutations are now folded in and must be refused before the gate reports.

**An eighteenth joined them on 2026-09-25: `services/api/test_verdict_coverage.py`** — does a screen
that reads a verdict about a SUBSET also read what the engine could not evaluate? Five engines in one
session returned a boolean beside a count of the unevaluated, and the boolean was meaningless without
the count: `soft_clash.coordinated` over a page of the matrix (CLASH-TRUNC), `rent_scrub.clean` =
`bool(ran) and not failed` with 1 of 7 checks run, `t12.tie_out.reconciles` against a reference derived
from the answer, `covenants.clean`/`at_risk` on counts that are zero for want of inputs, and
`sequence_clash.clean` over the activities that happened to have a date and a location. **Every one of
those engines is careful** — each hands the caller the coverage and three attach a sentence saying why.
*The defect is always in the consumer*, and four of the five had none, so nothing was wrong until
somebody wrote one. **A subset verdict is derived structurally, not by name:** a dict key whose value
is `bool(A) and (all(…) | not B)`, the guard being the author writing down that the subset can be
empty. Matching coverage-ish WORDS instead returned 24 candidates of mostly unrelated senses — a
sprinkler's coverage area, a `dry_run` flag — and *a rule that needs an exemption list is a rule whose
population is wrong.* The structural form returns 5 with no exemptions, and **three of the five were
instances nobody had found by hand.**
**Four more drafts were wrong in ways only measurement showed.** Linking consumers by shared
VOCABULARY cross-talked — a net-effective-rent card was reported as a `sequence_clash` consumer on
`skipped`, `findings` — so the link is the call site of the owning client method, the only thing that
claims *this file reads THIS response*. Treating `not_` as a coverage prefix matched `not_covered`, a
prose note about a **different dimension**, which *would have blessed the one consumer the rule exists
to catch*. Detecting a read as `.name` missed `const { not_applicable: notRun } = …` and called a
careful card bare; widening to a bare word boundary then matched the English word "skipped" inside an
unrelated prose string in the same file. *A detector keyed on one spelling cannot see the others, and
one keyed on none sees everything.*
**And mutating it found the limit it does not cover:** wrapping the coverage render in `if (false)`
leaves the text in place and PASSES. It is a source-level check — the same bargain
`test_route_reachability` makes for URL literals — and the complement is the consumer's own test,
where `apps/web/src/proforma/rentRollQuality.test.ts` and
`apps/web/src/proforma/covenantCard.test.ts` assert the coverage is *rendered*. Written down rather
than discovered later, because a gate whose reach is assumed is the thing these eighteen entries are
about.

"Cite a gate only after `git ls-files` confirms it" is itself a rule held as prose, so it is now
`services/api/test_claude_md_gates.py`: every backticked code file named here, in
`docs/roadmap-directions.md` **and in `docs/roadmap.md`** must resolve to a tracked path — including
Expand Down
7 changes: 7 additions & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
*.local
# Vestigial, deliberately kept. `gen:api-types` no longer writes here — it dumps the spec into the OS
# temp directory and deletes it, because an input that persists is an input that can be stale, and this
# very line is what hid that: the generator's one documented invocation read a file git was told to
# ignore, so "regenerate the types" silently meant "re-read whatever dump is on this machine". See
# apps/web/scripts/gen-api-types.mjs and services/api/test_schema_types_agree.py. The pattern stays so
# that a leftover dump from before 2026-09-25 does not surface as untracked work in every lane's
# `git status` — the residue problem services/api/test_scratch_ignored.py exists for.
src/api/openapi.json
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"cap:sync": "cap sync",
"mobile:android": "npm run build:mobile && cap sync android && cap open android",
"mobile:ios": "npm run build:mobile && cap sync ios && cap open ios",
"gen:api-types": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
"gen:api-types": "node scripts/gen-api-types.mjs"
},
"dependencies": {
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
Expand Down
130 changes: 130 additions & 0 deletions apps/web/scripts/gen-api-types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Regenerate `src/api/schema.d.ts` from the FastAPI app — reading the SERVER, never a file.
*
* WHY THIS SCRIPT EXISTS — the generator's input was a file nobody tracked
* `package.json` used to run, literally:
*
* openapi-typescript src/api/openapi.json -o src/api/schema.d.ts
*
* and `apps/web/.gitignore` ignores `src/api/openapi.json`. So the one documented way to
* "regenerate the types" read an **untracked** intermediate: on a fresh clone the command fails
* outright (no input), and on a machine that has one it regenerates from whatever dump happens to
* be sitting there — which may predate the routes you just added. **It succeeds, prints a green
* tick, and produces the same stale file.** *"Regenerate the types" did not mean "read the
* server", and nothing said so.*
*
* Measured 2026-09-25: the committed `schema.d.ts` declared **500** of the **947** paths the app
* serves, missing 448 across 165 of 203 path groups. That file and the `.gitignore` line hiding
* its input were last written in the SAME commit (`8432a88`, 2026-09-11), and the API has *fewer*
* route decorators today (1016) than it had then (1022) — so drift cannot explain the gap. **The
* types were already half the API on the day they were generated**, which is why the roadmap
* entry naming this "SCHEMA-STALE" was itself wrong: stale implies it was once right, and a
* stale-sounding name sends the next reader to re-run the generator, the one action that does not
* fix it.
*
* WHAT CHANGED
* The spec is dumped from `aec_api.main:app` into a file under the OS temp directory, generated
* from, and deleted. There is **no persistent input to be stale**, so the failure mode above is
* not merely discouraged, it is unreachable: if the app cannot be imported this exits non-zero
* with Python's own traceback, rather than quietly falling back to a dump on disk.
*
* THE AUTHORITY IS A TEST, NOT THIS SCRIPT
* `services/api/test_schema_types_agree.py` asserts every live `(path, method)` is declared in
* the committed `schema.d.ts`. A generator can only be run; a gate fails. This script exists so
* that running it means something — the gate is what makes not running it visible.
*/
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

const WEB = resolve(import.meta.dirname, "..");
const REPO = resolve(WEB, "..", "..");
const API = join(REPO, "services", "api");

/** The venv interpreter for `services/api`, on either OS — never a bare `python`. */
function venvPython() {
const candidates = [
join(API, ".venv", "bin", "python"),
join(API, ".venv", "Scripts", "python.exe"),
];
for (const p of candidates) if (existsSync(p)) return p;
console.error(
`gen:api-types — no interpreter at any of:\n ${candidates.join("\n ")}\n` +
`Create it first: the backend venv is what holds fastapi/ifcopenshell, and a bare \`python\`\n` +
`off PATH is the resolution mistake \`check-vite-version.mjs\` documents for vite.`,
);
process.exit(1);
}

const DUMP = [
"import json,sys",
"from aec_api.main import app",
"sys.stdout.write(json.dumps(app.openapi()))",
].join("\n");

const py = venvPython();
const spec = spawnSync(py, ["-c", DUMP], {
cwd: API,
encoding: "utf-8",
maxBuffer: 64 * 1024 * 1024,
env: { ...process.env, PYTHONPATH: ["src", join("..", "data", "src")].join(process.platform === "win32" ? ";" : ":") },
});
if (spec.status !== 0) {
console.error(spec.stderr || "gen:api-types — the app could not be imported");
process.exit(spec.status ?? 1);
}

// Sanity-check the dump BEFORE overwriting a committed file: a truncated or empty spec would
// otherwise generate a valid-looking `schema.d.ts` that declares nothing.
let parsed;
try {
parsed = JSON.parse(spec.stdout);
} catch (e) {
console.error(`gen:api-types — the spec dump is not JSON (${String(e)})`);
process.exit(1);
}
const paths = Object.keys(parsed?.paths ?? {});
if (paths.length < 100) {
console.error(
`gen:api-types — the app reports only ${paths.length} paths, which is not a plausible spec for ` +
`this API. Refusing to overwrite src/api/schema.d.ts from it.`,
);
process.exit(1);
}

/** `openapi-typescript`'s CLI, wherever npm hoisted it — root or nested under apps/web. */
function generatorCli() {
const candidates = [
join(REPO, "node_modules", "openapi-typescript", "bin", "cli.js"),
join(WEB, "node_modules", "openapi-typescript", "bin", "cli.js"),
];
for (const p of candidates) if (existsSync(p)) return p;
console.error(
`gen:api-types — openapi-typescript is not installed at either:\n ${candidates.join("\n ")}`,
);
process.exit(1);
}

// Sort `paths` before generating. FastAPI emits them in ROUTE REGISTRATION order, so including a
// router earlier shuffles thousands of lines and the diff of a one-route change is unreviewable —
// which is a large part of why "regenerating churned 38,231 lines" became a reason not to regenerate.
// Key order carries no meaning in an OpenAPI document, so sorting costs nothing and makes the next
// regeneration a diff somebody will actually read.
parsed.paths = Object.fromEntries([...Object.entries(parsed.paths)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));

const tmp = mkdtempSync(join(tmpdir(), "massing-openapi-"));
const specFile = join(tmp, "openapi.json");
const out = join(WEB, "src", "api", "schema.d.ts");
try {
writeFileSync(specFile, JSON.stringify(parsed));
const gen = spawnSync(process.execPath, [generatorCli(), specFile, "-o", out], {
cwd: WEB,
stdio: "inherit",
});
if (gen.status !== 0) process.exit(gen.status ?? 1);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
console.log(`gen:api-types — ${paths.length} paths from the live app → src/api/schema.d.ts`);
11 changes: 7 additions & 4 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,13 @@ export class ApiClient extends _ApiStageB {
const res = await fetch(this.url(`/projects/${pid}/scan/deviation?tolerance=${tolerance}`),
{ method: "POST", body: fd, headers: this.authHeaders() });
if (!res.ok) throw new HttpError((await res.json().catch(() => ({ detail: res.status }))).detail || `scan -> ${res.status}`, res.status);
return res.json() as Promise<{ point_count: number; reference_count: number; tolerance: number;
within_tolerance: number; within_pct: number | null; out_of_tolerance: number;
mean_deviation: number; max_deviation: number; p95_deviation: number;
histogram: { band: string; count: number }[]; note: string }>;
// A truncated REFERENCE produces no deviation figure at all — `within_pct` is null and every
// per-band field is absent — so those are optional and a reader has to handle the refusal.
return res.json() as Promise<{ point_count: number; reference_count: number; tolerance?: number;
points_total: number; points_truncated: boolean; reference_truncated: boolean;
within_pct: number | null; error?: string; within_tolerance?: number; out_of_tolerance?: number;
mean_deviation?: number; max_deviation?: number; p95_deviation?: number;
histogram?: { band: string; count: number }[]; note?: string }>;
}

// W9-1 property mapping / normalization — the transform verb between IDS-validate and COBie-export
Expand Down
23 changes: 15 additions & 8 deletions apps/web/src/api/clientCallers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,25 +295,32 @@ const UNCALLED: readonly string[] = [
"addBasePlate", "addCurtainWall", "addMepFitting", "addRebarCage",
"addShearTab", "addTopicComment", "applyDetailingRules", "arrayElement",
"assignMaterialSet", "assumptionsRegister", "attachDocument",
"ciLatest", "citedQuery", "clashFederated", "clausePlaybook",
"citedQuery", "clashFederated", "clausePlaybook",
"clientDecisions", "codeAdoptions", "codeCheck", "colorFacets",
"competitiveSupply", "connectElements", "costSummary", "createAssembly", "createGroup",
"createType", "decisionGate",
"connectElements", "costSummary", "createAssembly", "createGroup",
"createType",
"docGraph", "draftPost", "drawingSchedulesCalc", "drawingSetPlan",
"drawingsSyncStatus", "ebcPathways", "editType", "elements5dMap",
"energyModel", "equipmentSpecCheck",
"expandMacro", "feasibilityLotSupply", "feasibilitySellout", "holdSell",
"importFamilyPack", "layoutVerify", "listMacros", "listingReso",
"liveStream", "loanCovenants", "massingOptionRecipes", "mcpTools",
// `loanCovenants` left this list 2026-09-25 — `proforma/covenantCard.ts` on Budget & Capital.
"liveStream", "massingOptionRecipes", "mcpTools",
"modelAdjacency", "moduleCalc", "myWork",
"netEffectiveRent", "normalizeT12", "parcelsDataStatus",
// `netEffectiveRent` and `rentRollScrub` left this list 2026-09-25 — `proforma/rentRollQuality.ts`
// renders both under the Operations rent roll. Shipped with R20, called by nothing.
// `normalizeT12` left this list 2026-09-25 — `proforma/t12Card.ts`. Its tie-out is a gate that
// could not fail without a caller supplying stated totals; see T12-SELFTIE in that file's header.
"parcelsDataStatus",
"pdfInfo", "permitsTimeline", "preconSnapshot",
"proformaRenovation", "proformaRollover", "progressActuals",
"progressCaptureDiff", "progressRollup", "raisePlan", "recordDistribution",
"rentRollScrub", "residualLand", "reviewPost",
// `residualLand` left this list 2026-09-25 — `proforma/residualLandCard.ts` is the Feasibility
// tab's inverse solve. The engine and the client method shipped with FIN-CALC; nothing called it.
"reviewPost",
"reviewScenario", "reviseDrawing", "runClash", "runMacro", "saveClausePlaybook",
"saveDealAuthority", "saveMacros", "saveViewTemplates",
"scanDeviation", "scopeRegister", "securitiesPackage", "sendDigest",
"saveMacros", "saveViewTemplates",
"scopeRegister", "securitiesPackage", "sendDigest",
"setLod", "setPhase", "sharedComment", "sharedDecision",
"sharedDigestUrl", "spaceUtilBenchmarks", "speckleStatus", "tieredComps",
"topicComments", "updateConnection", "veLog", "verificationDeviations",
Expand Down
Loading
Loading