Skip to content

P1–P7: extract the seams, get work off the request path, make verified mean something - #26

Merged
archdex-art merged 104 commits into
mainfrom
p1/extract-seams
Aug 2, 2026
Merged

P1–P7: extract the seams, get work off the request path, make verified mean something#26
archdex-art merged 104 commits into
mainfrom
p1/extract-seams

Conversation

@archdex-art

@archdex-art archdex-art commented Jul 30, 2026

Copy link
Copy Markdown
Owner

52 commits. Each is independently green and carries its own reasoning; this is the map.

The single most important change: verified used to mean after.score >= before.score — a fix graded by the very metric it was built to move. Four real gates replace it, and codegraph fix --verify runs them where a developer's test suite actually lives.

What ships

P1 — enforceable seams. app/ becomes an npm-workspaces monorepo. 14 packages with a layering rule enforced in CI, not a diagram. Findings become rows with fingerprints.

P2 — work off the request path. apps/worker as a separate process with a child per job — the ADR-001 fix for web-tree-sitter's ever-growing WASM heap. Measured, not asserted: two concurrent index jobs at --memory=512m --cpus=0.5, both done, peak 341.8 MiB / 512 MB, no OOM, 2 children spawned and reaped.

P3 — make verified mean something.

Gate Check Skips when
1 · syntax real TypeScript parse of each edited file never
2 · types tsc --noEmit no type config
3 · tests the repo's own suite, timeboxed, SIGKILLed host cannot isolate
4 · re-analysis the target finding's fingerprint is gone never

Gate 4 checks a fingerprint, not the aggregate score. "The score went up" is not evidence that this finding was fixed.

apps/cli — gate 3 needs an isolated container, which Render cannot give, so a hosted instance can only ever report partial. On your machine it reports full. Source never modified; you get a diff proven to apply with git apply with the suite still green afterwards. Exit code is the verdict.

P4 — score credibility. Pillars split so defect risk is surfaced alone (maintainability was 22% of a number read as risk). Coverage published beside the score (ADR-008). Eight organisational signals from one git log pass.

P7 — surface honesty. Every README claim mapped to a test; ARCHITECTURE.md reconciled; feature status labels.

Review items closed

Was Now
C3 verified = after.score >= before.score four gates + VerificationRecord
C1 one finding ran every fixer over every file — 27-file diff for a one-line fix POST /api/findings/:id/fix; Fixer.handles: [ruleId]
C4 UI said "your source is never modified" while the executor pushed a branch every remote mutation behind confirmed: true
C5 projectedScore a linear guess re-runs the real scorer; refuses to project from a truncated list
C6 ARCHITECTURE.md described two architectures three paragraphs apart reconciled, guarded by tests
B1 line-based edits behind a regex guard AST decides — reproduced the regex silently rewriting a help string inside a template literal

Bugs found by running it, not by reading it

  • 54% of CodeGraph's self-index was other repositories' code — cloned targets under data/workspaces/, contributing 77 of 200 reported issues.
  • Only the root package.json was ever read — 3 dependencies found where 36 were declared.
  • The worker exited on an idle queuesetTimeout(...).unref() meant nothing held the loop; every unit test bounds it with maxJobs, so none ever waited.
  • getJob cast the raw column — queue says succeeded, dashboard polls for done. Finished jobs left clients polling forever.
  • rev-list --before returned a commit from a different repository merged into socket.io's history — a 7-file tree. --first-parent is load-bearing.

Five gates that reported success while inspecting nothing

The recurring shape of this branch, and the reason for the meta-checks:

  • npm run lint had never run. no-console: ["error", { allow: [] }] is invalid under ESLint 9 → exit 2 before linting a file. CI never invoked it. Both were needed to hide it.
  • Three packages had no layer rule — absent from ALLOWED means unconstrained, while the gate printed "no violations".
  • Three workspaces were never typechecked--if-present skips silently. A deliberate error in apps/cli produced 0 errors.
  • scripts/ was typechecked by nothing — not a workspace.
  • /build/ had a leading space in .gitignore, so the pattern matched nothing — 478k lines of build output and runtime data were being committed.

Each now fails loudly, each verified to exit non-zero.

A negative result, kept

P4 §5.3's calibration failed its own gate and the weights are not adoptedk = 0.06 still stands, still labelled hand-picked. Model 0.673 mean-per-repo vs churn 0.718 and file size 0.717.

More importantly, ADR-009 retires the target. Cross-project AUC measures whether weights learned from other people's repositories transfer to yours — the question a product shipping one universal model must answer. CodeGraph indexes one repo deeply (HLD §2.2) and holds its whole history. The measured failure was base-rate non-transfer (socket.io: 78% defective, max prediction 0.269), which cannot occur within one repository.

Adopting a competitor's headline figure as an exit criterion is reactive positioning (IDENTITY.md §4.4) in methodology rather than in copy. The corpus, harness and leakage guard are retained and retargeted to within-repo validation.

Verification

typecheck    PASS (incl. scripts/)   lint      PASS (0 errors, pinned ceiling)
tests        902/902 · 66 files      desktop   35/35
depcruise    0 violations            boundaries 288 files, 0 violations
workspaces   18/18 gated             build     PASS
docker       512 MB / 0.5 vCPU, 2 concurrent jobs, peak 341.8 MiB, no OOM

npm run bench reproduces every published benchmark figure from a fresh clone.

Reviewing this

Mutation testing caught eight tests that passed for the wrong reason — an invented gate-4 target, a recordRun fingerprint test, two individually-redundant scoped-fix filters, an unexercised diff offset, a parent check, and a doc assertion matching prose instead of a table. Writing a test and watching it pass proved less than it appeared to, every time.

Worth reading in order: ADR-009 (why a phase's exit criterion was wrong), docs/LINT_DEBT.md (what turning a dead gate on revealed), benchmarks/calibration/scorecard.json (a published negative result).

Not done, stated plainly

  • Fixers still rewrite line arrays. B1's complaint is closed; range-based edits end-to-end need P5's typed extraction.
  • codegraph fix is the only CLI command — no index, no score. A test asserts they don't exist.
  • Call resolution is still heuristic. CommonJS extraction took express from 11 to 37 resolved edges, but 155 of 174 symbols still have no inbound edge. That is P5, and P6's cache depends on it — invalidation walks resolved edges backward, so building it now would appear to work while serving stale findings.
  • 7 react-hooks/refs violations are genuinely wrong, inventoried in docs/LINT_DEBT.md rather than suppressed.

Amendment: cross-repo calibration deleted (3462c6f)

ADR-009 retired the cross-project ROC AUC target but said the corpus and harness were "kept and
retargeted". That was half a decision. Nothing in the product imported @codegraph/calibrate,
and 784 KB of twelve other projects' git history sat in the tree as an invitation to re-run the
wrong experiment.

Deleted: packages/calibrate, benchmarks/calibration (13 datasets + scorecard),
scripts/calibrate.mts, scripts/fit.mts, both npm scripts.
Kept: ADR-009, amended — every measurement is quoted there. The reasoning is the durable
artefact, not the pipeline that produced it.

CodeGraph indexes one repository deeply (HLD §2.2). A universal learned model answers a question
this product does not ask.

Also recorded honestly: seven of the eight organisational signals are computed and unread
they were built for the deleted calibration. They ride a git pass churn already requires, so
they cost ~0, but PLAN.md §5.2 now states the condition on which they stay (shown to a user), and
"already written" is not it.


P4 closed out: confidence enters the kernel (de667a9, 18d94a2)

PLAN.md §5.2 asked for "actually using the confidence the scorer stores and ignores". It stored
one on all 87 express findings and weighed a 0.7 guess exactly as heavily as a 0.95 eval().

penalty = severity × blast × volume × confidence

Multiplying is an expectation, not a tuned weight — so unlike §5.3 it needs no corpus and
ADR-009 does not apply. That the two axes are independent is established by the rule table, not
assumed: eval() and Possible hardcoded secret are both severity 5, so severity means
impact if real and multiplying doesn't double-count.

Express 74 → 77, security 45 → 52. k = 0.06 deliberately not rescaled to hold the old
headline. Also collapses a formula that scoreIssues and the issue ordering duplicated, kept in
step only by a comment saying they must agree.

The golden table was verified, not pasted. Uniform-0.9 fixture ⇒ every score moves to
100·(s/100)^0.9; hand-computed before editing (maintainability 100·0.11^0.9 = 13.7 → 14). The
law is now asserted so the semantics are locked, not just the outputs. Mutation-tested 4/4 — and
the first run reported "additive SURVIVED" because my harness was wrong (sed pattern spanning a
newline), not the test.

Not closed by this: HLD §8.3's ladder says lexical-tier findings are "marked low-confidence",
but nothing sets confidence from tier. Recorded in PLAN.md §5.2 rather than left implied.


P5 scoped by measurement, not assumption (7fe7322)

Before building P5 I measured its premise — the mistake ADR-009 records is aiming a phase at a
number that isn't the question.

Found a real bug on the way. Type-aware resolution was silently losing to the name-based
fallback. Two defects, neither of which matters without the other:

type-aware hits call edges
before 1,094 1,912
after 2,167 1,914

createProgram got relative rootNames while getCurrentDirectory() reported the real cwd; and
getSymbolAtLocation on an imported identifier returns an alias whose declaration is the
import line in the calling file. The ids named symbols that exist in no file, missed, and fell
through in silence.

It never produced wrong edges — it produced unusable ids. And the heuristic is good enough
that nothing looked broken.

How good, measured: across 2,159 calls where both resolvers answered, they disagreed on 35
(1.6%)
, checker right every time — all same-name shadowing. So: ~35 corrected edges, correct
provenance for ~1,000 more. Not a step change, and not described as one.

P5's exit criterion is already met and shouldn't be the target. For TypeScript the compiler
is ground truth for what a call refers to — no labelled corpus needed. Scored that way the
heuristic is at 98.4%, so "precision ≥ 0.85" would pass without doing any work. The real gap
is recall (47% of symbols here, 89% on express have no inbound edge), and the checker added
zero project targets the heuristic missed. PLAN.md now says so.

Two things I got wrong and corrected in-branch: a directoryExists override I was sure was
required measured exactly 0 and was deleted. And my three new tests all survive disabling
the typed path — the doc comment now says that outright instead of implying they prove it.


Recall: module scope is a caller (395183c)

The gap wasn't resolution — it was attribution. Call-site source was a partial function:
only a call lexically inside a named function produced an edge, and if (!caller) continue
discarded the rest. 2,313 of 5,025 resolved calls (46%) were thrown away after being
correctly resolved.

Visible symptom: rep in pillars.test.ts is called four times in its own file and the graph
showed zero callers — so it was reported as dead code.

Source is now total. Every call site has an enclosing execution context; at worst that's the
module body, which executes on import. Standard modelling — <module> in Python's profiler,
<clinit> on the JVM, LLVM module constructors.

before after
call edges (this repo) 1,914 2,529
symbols with no inbound edge 47% 32%
functions reported dead 276 146
call edges (express) 38 261
unreferenced (express) 89% 61%

Health Score unchanged at 77 — blast radius reads file-level import fan-in, not symbol
fan-in. Checked, not assumed.

Kept only what measured. constant in the caller kinds scored exactly 0 edges and was
removed — an arrow in a const is already extracted as function, and in const x = foo()
the initialiser runs at module scope, so naming x would be a confident wrong answer. class
stayed on evidence: 5 edges, 9 fewer synthetic nodes, and a field initialiser genuinely runs.

6 tests, mutation-verified 5/5 — and every mutation asserted to have applied before the
run, after a sed pattern silently matched nothing earlier in this branch and reported a false
SURVIVED.

Honest limit: ~38% of the remaining 411 unreferenced symbols still have real call sites.
Recall is improved, not finished. What's settled is the diagnosis: the bottleneck was
attribution, and typed extraction would not have moved either number.


Rendering is invoking (e1a2de0)

The extractor recorded only CallExpression, so a function used as a value produced no
reference. Measured with the checker: 372 function identifiers in value position vs 6,836 in
call position. Two classes are unambiguous — JSX tags (91) and callback arguments (41).

before after
components with no inbound edge 41 / 41 6 / 41
call edges (this repo) 2,529 2,599
functions reported dead 146 100
call edges (express) 261 299
unreferenced (express) 107 / 174 72 / 174

<AgentSwarm /> is a JsxSelfClosingElement, never a CallExpression. Every component in a
React codebase was an isolated node
— the worst graph defect on this branch, in a product whose
thesis is that the graph is the product. The 6 remaining are Next.js page/layout entry points,
which genuinely have no in-repo caller.

Corrects a claim published two commits earlier. That commit said "~38% of the remaining
unreferenced set has real call sites" — from git grep counting matches inside strings and
comments. On the AST: 3 of 137 (2%). Recall was already essentially closed and I reported it
as a third open. The error inflated the remaining work, which is the flattering direction, so
README and PLAN.md are corrected in place.

Mutation-tested 5/5 — and the fifth found a vacuous test, not a code defect. The
"no speculative edges" case used export const limit = 5 as its decoy, which the extractor never
records as a symbol, so no edge could form whatever the code did and the assertion could never
fail. Decoy is now a class. Second test this branch has shipped that asserted nothing; second
time mutation testing was the only thing that noticed.


P5 item 1: rules fire only where they can be true (1ec6978)

Each rule now declares the syntactic context in which it can hold, checked against comment and
string ranges from the TypeScript scanner. Measured before writing anything: 35% of rule
matches on express, 64% on this repo
, landed somewhere the rule cannot be true.

before after
issues (express) 87 66
security dimension 52 71
Health Score (express) 77 82

All 21 suppressed findings were verified by reading themeval( and innerHTML inside an
XSS test fixture string in test/res.redirect.js, and 16 http://localhost:3000 URLs inside
// example: comments. No true positive lost.

Per-rule, not blanket comment/string stripping: a TODO belongs in a comment, @ts-ignore
can only be a comment, a hardcoded localhost URL is necessarily a string. Blanket stripping
would have deleted three rules' true positives.

Python deliberately unchanged — syntacticSpans returns [] outside the TS family. A
hand-rolled lexer would be wrong at the edges, and a wrong span suppresses a real finding.

Mutation-tested 6/6 — two exposed weak tests, not weak code. The Python case had no quotes,
so the scanner emitted no spans either way; the offset case sat on line 1, where the +1 for the
newline cannot matter (and 12 lines was still too few — the drift landed exactly on the string's
opening quote). Both would have passed review and CI forever.

Recorded limit: this is the position class, not yet a graph-shape query. eval( in code is
accepted without confirming it's a CallExpression resolving to global eval.


P5 item 2: taint analysis — and it found a real vulnerability (d9ecd8a)

eslint-plugin-security flags any non-literal sink argument and never asks where the value came
from. Measured here: 170 sink findings, all at one confidence.

verdict count
tainted 2 reaches a sink from user-controlled input
sanitized 26 a source, then a transform or dominating guard
untraced 142 no source found in this function

The 2 were a genuine hole in our own code. FileSystemService.readFile/writeFile passed
request.path from an Electron IPC message straight into fs. The fs:read/fs:write
permissions answer "may the renderer touch the disk", never "which file" — so any renderer
holding one could read or write everything the user could.

Fixed with FsGrants: a directory is reachable because the user picked it in the OS dialog,
the capability model @codegraph/fsx already uses server-side. Symlink escapes defeated via
realpath of the nearest existing ancestor. The analysis now reports both sinks sanitized
detector finds it, fix lands, same detector confirms it.

Taint modulates confidence, never deletes. An incomplete source list would otherwise become
silent false negatives. Since confidence already multiplies into expectedHarm, the two
changes compose with no new machinery. Express 82 → 85, security 71 → 78.

Guard recognition is where the CFG value was. JS validates far more than it transforms —
check, return early, use the original value — so no assignment happens and def-use sees
nothing. Guards took tainted 6 → 2; the four dropped were api/browse/route.ts, guarded one
line above each sink.

Two things measured and thrown away: a per-path seen set (30ms vs 29ms — it prunes within
a path, never across branches, so the depth cap already did the work) and a live differential
test against fsx.resolveSafe (making tsc accept it drags fsx's whole tree into the desktop
program). The differential earned its place first: it found the two contracts genuinely
diverge on absolute paths, now pinned as its own test.

Mutation-tested 10/10. All three first-round survivors were missing tests, not weak code.


P5 item 7: HLD §8.3's degradation ladder was documentation only (66f75ab)

The architecture document described a per-file analysis tier, an "% of LOC at tier ≥ ast"
coverage figure, and lexical findings "marked low-confidence". None of it existed — the only
tier in the codebase was an unrelated module-layout depth. CLAUDE.md §5 says don't claim what
the code doesn't do, and this was a claim in the architecture doc itself.

Now real: tierForExt assigns the tier, ScanCoverage.tierLoc reports LOC per tier, and a
lexical finding's confidence is scaled by 0.45 — which reaches the score, since confidence already
multiplies into expectedHarm. 56.7% of this repo's LOC is tier full; the rest is Python,
regex-scanned, and now says so. Express is unchanged at 85 — correctly, it's all JavaScript.

The tier boundary is deliberately the same one syntacticSpans uses: files that get no context
gating are exactly the files whose findings can't be trusted as far. One source of truth rather
than two extension lists drifting.

ast is defined and never produced, and the type says so. Every AST-path language here is
TypeScript-family and reaches full; Python's extractor is regex-based. The rung is real in the
design and empty in the code — recorded, not quietly dropped.

Removed a duplicated type found on the way. ScanCoverage was declared in both
analysis-model and indexer.ts; adding a field to one failed to typecheck against the other.
The drift wasn't hypothetical — it happened inside this commit.

Mutation-tested 5/5, and one survivor was another vacuous assertion: "does not zero a finding
out of existence" checked confidence > 0, but scaleConfidence has a 0.05 floor — so it passed
with the factor set to zero. Replaced with a ratio between two rules of different base
confidence, which pins proportionality without hard-coding the constant.


Correction: context gating was suppressing real findings (7e90ef3)

The gate I shipped two commits earlier drove ts.createScanner in a bare while (scan()) loop.
That loop cannot rescanTemplateToken after a TemplateHead, nor reScanSlashToken to settle
regex-versus-division — so it desyncs at the first ${…} or / and mis-tokenises the rest.

Validated against the parser over 4,783 sampled positions:

count
scanner says NOT code, parser says code 1,125 (23.5%) findings suppressed
scanner says code, parser says not 140 noise leaked

process.exitCode = 1; was being classified as a string. Suppression is the failure direction
that module's own comment claimed to avoid — and the one nobody notices.

The measurement that justified this work was parser-based all along; only the implementation
wasn't.
Rewritten on createSourceFile: false negatives 1,125 → 7, false positives 0,
index cost +13%. Template substitutions are now code, so rules can still see ${userInput}.

Also fixes trailing comments — TS classifies a comment before the first newline as the
previous token's trailing comment, which getLeadingCommentRanges deliberately skips, so
const a = 1; // note was invisible.

Mutation-tested 5/5 after two rounds of fixing the tests, not the code. An explicit EOF pass
turned out to be dead (forEachChild already visits endOfFileToken) and was deleted. The
out-of-order case needed the comment on the same line as the closing brace — on its own line
the spans come out already sorted and a missing sort before a binary search is invisible.

Item 4 measured and deferred: only 30 of 148 untraced sinks derive from a parameter — the
ceiling for caller-side propagation — and the examples are build scripts. Recorded in PLAN.md.


Synthetic module nodes leaked into enumeration (c5d0da8)

Adding <module> nodes was right; letting them into every query that lists symbols was not.
Measured right after they landed:

search("module") 21 of 30 results synthetic
hubs() ranked a module node in the top three
symbolAt(file, 1) returned <module> instead of a function starting on line 1

The symbolAt one is a real regression: it picks the smallest enclosing span, and a module
node is zero-width — so nothing is ever smaller. A finding on line 1 attributed to <module>
rather than the function containing it, and that method feeds file:line → symbol mapping for
taint reachability and UI navigation.

The rule, now stated in code: a node the user didn't write is a legitimate answer about the
graph and never an item in a list of their code. search/symbolAt/hubs filter;
callers/callees/impact/members deliberately don't — "called from module scope" is the
whole reason the node exists.

Verified the fix actually reaches the UI rather than assuming. The API route imports
QueryEngine from @/lib/codeintel/query, which looked like a second implementation that would
have made this invisible — it's a two-line re-export shim. Every intel op then checked:
cycles can't contain a module node, buildContext seeds from the filtered search.

Mutation-tested 5/5, including one that strips the filter from callers — the tests fail if
the exclusion goes too far as well as not far enough.


P6 first increment: content-addressed memo (06ea1d0)

Exit criterion corrected before building, not after. The stated exit is "warm re-index ≤ 5%
of cold"
. Profiled first (303 TS files, ~2.1s):

phase ms memoisable per file?
eslint security 624 yes
ts.createProgram 567 no
symbol extraction 420 no — see below
getTypeChecker 199 no
syntacticSpans 104 yes

5% is unreachable while a TS program is built. oldProgram reuse was measured, not assumed:
751ms cold → 734ms with one file changed → 682ms with nothing changed. Parsing is reused;
binding and checker construction are not. ~766ms is a hard floor.

Delivered: 2,205ms → 1,220ms (55% of cold), 614 hits and no new misses on the second pass.

Symbol extraction excluded deliberately despite being the third-largest cost: a reference's
resolvedTargetId names a declaration in another file, so a content-keyed hit can serve a
stale edge once that file moves. It needs a dependency key, not a content key — the unsound
version trades visible slowness for invisible wrong answers.

Where re-indexing actually happens isn't users re-opening a repo: executor.ts indexes
twice per remediation to measure the score delta, and the Timeline indexes one snapshot per
commit
. Both index near-identical trees in one process.

PLAN.md now proposes a checkable exit — warm ≤ 60% of cold with a documented reason for the
remainder. Below that needs an incremental graph, a much larger piece of work.

Mutation-tested 5/5, including dropping version from the key — the failure that silently
serves findings from a previous implementation for the life of the process.


P5 item 5: SARIF 2.1.0 export (d6d162f)

HLD §3 named a "SARIF 2.1.0 export adapter at the boundary" as the mechanism satisfying the
interoperability requirement, and LLD §13 specified the route. Neither existed — the same
shape of claim as HLD §8.3's tier ladder.

toSarif in @codegraph/analysis-model, served by GET /api/repos/:id/sarif behind the same
repoAccessDenied + viewerId pair as every other repo route. A download endpoint is exactly
where tenant isolation gets forgotten.

One direction, enforced by layering rather than discipline: this module imports from the
model, no SARIF type appears in any signature the rest of the codebase uses. Written from the
spec's shape rather than pulling a SARIF library — zero new dependencies, and the mapping
decisions are visible instead of inherited.

Two fields deliberately not emitted, both pinned by tests:

  • rank — SARIF's 0-100 priority field. Filling it exports a second ranking that can
    disagree with the Health Score. The inputs are all in properties instead.
  • partialFingerprintscore-domain has a real fingerprint keyed on a normalised
    snippet; Issue doesn't carry one. A weaker hash under the standard name would silently
    disagree with the product's own identity for a finding.

Severity 1-5 → three levels is lossy, so the original number always rides in properties. An
export that loses information the API already gives you is worse than none.

Mutation-tested 5/5 real mutations. A sixth was provably inert (rules is a Map keyed by
ruleId, so re-setting the same key changes nothing) — reported as inert, not counted. A seventh
reported SURVIVED and had never applied: shell escaping mangled the backslashes. Third time
this branch a harness bug masqueraded as a passing mutation.


Tenant isolation enforced structurally (a5c760c)

tenant-isolation.test.ts covered named routes by hand — so a route added tomorrow is
covered only if someone remembers. Same shape as the gates this branch already fixed: a check
that cannot tell you what it skipped is not a check.

Concretely: the SARIF download route from the previous commit is guarded because I thought to
guard it. Nothing would have failed if I hadn't — and a download endpoint serving another
tenant's findings is exactly where this gets missed.

Now enumerated from disk: every route.ts under repos/[id]/ must call repoAccessDenied or
requireWorkspace. 11 routes today, and any new one is checked the moment it exists.

Two meta-assertions, because a structural test is precisely the kind that passes while
inspecting nothing: the enumeration must find >8 routes (a wrong path can't make it vacuous),
and a file that imports a guard without calling it fails.

The audit found no vulnerability, and that's worth stating. Three routes — fs, search,
timeline — looked unguarded because they use requireWorkspace rather than repoAccessDenied
directly. Reading requireWorkspace showed it calls repoAccessDenied on line 65. The grep
was incomplete, not the routes.

Mutation-tested 3/3 against real code, not fixtures: removing the guard from the SARIF
route, stubbing out requireWorkspace while leaving its import, and pointing the scan at a
nonexistent directory.


Dogfood pass: CodeGraph run on CodeGraph (d45e33a)

Self score was 68 with the security dimension at 12, and the top of our own findings was one
rule firing wrongly over and over. Two false-positive classes, both real code here:

site value why it isn't a secret
settings.ts:71 anthropicApiKey: "assistant.anthropicApiKey" a settings path
redact.test.ts anthropicApiKey: "sk-ant-BAD-KEY" a test fixture

Entropy was tried and rejected on evidence. The fixture sk-ant-SCOPED-BUT-VALID-KEY scores
H=4.18 — above AKIAIOSFODNN7EXAMPLE (3.68) and a 40-char hex digest (3.83). Across ten
samples the separating signal was a digit. Applied as a confidence multiplier, never a
reject, because correcthorsebatterystaple is a real secret with no digits.

detect-unsafe-regex downgraded 0.85 → 0.5. Both instances it reports here were measured
and neither backtracks — sub-millisecond at n=16,000 — while the control /^(a+)+$/ takes 258ms
at n=26. It's a static over-approximation that flags a shape without proving the alternatives
overlap. One of the two regexes it flagged is one I wrote in this commit.

Self 68 → 71, security 12 → 22. Express 85 → 89, security 79 → 91 — and its six
downgraded "secrets" were read before publishing a better number: 'keyboard cat',
'manny is cool', 'some secret here', all placeholders in examples/.

Mutation-tested 5/5 after two rounds. Two survivors were harness bugs (shell escaping ate
\d — fourth time). Two were real gaps: CONFIG_PATH_RE looked dead but isn't (a dotted path
with a digit needs it), and "does not delete a downgraded finding" asserted > 0 against a
0.05 floor — so it passed with the factor set to zero.

Recorded, not fixed: Large file (1259 LOC) on indexer.ts is a true positive about work in
this branch. Extracting rules removes ~180 lines against a 600 threshold — a reduction that
doesn't clear it. Clearing it is the detect-engine/score-engine split LLD §13 specifies.


score-engine extracted (d6d816b, c805353)

First slice of LLD §13's five-way split of indexer.ts, prompted by CodeGraph's own output:
Large file (1259 LOC) was the top self-finding, naming the file this branch kept growing.

Chosen because it removes a real coupling, not just lines. agents/orchestrator.ts computes
the swarm's projected score by re-running the real scorer (review C5) — which meant importing
scoreIssues from the indexer, so scoring a hypothetical list of findings dragged in the file
walker, the ESLint layer, the TypeScript program builder and the taint analysis. The score model
depends on none of it.

The swarm now imports @codegraph/score-engine, whose only dependency is analysis-model, and
dependency-cruiser enforces it rather than a comment asking nicely.

HITS_PER_RULE_PER_FILE moved with the scorer — it's what volumeMultiplier measures excess
against, so two copies would let the cap and the scale disagree silently.

indexer.ts 1,259 → 1,131. The finding does not clear — predicted and recorded before
starting, since the threshold is 600 and one ~130-line slice was never going to reach it. The
remaining slices (pipeline/enumerate, lang-*, detect-engine, viz) are what get there.
Reporting a reduction as a reduction.

Every published number identical: express 89, projected 89 → 90, remediation 89 → 95, and the
24-case golden score table moved with the code it locks.

ARCHITECTURE.md's package table was caught by its own guard the moment the package existed —
updated from the failure, not from memory. And a follow-up commit records the outcome in
PLAN.md: the first attempt's doc edit silently failed on an anchor mismatch while the code
landed, leaving the plan with the problem statement and no result.


viz extracted + the shared contract (768ab9e)

Second slice of LLD §13. The prerequisite mattered more than the slice: ScannedFile lived
inside indexer.ts, and every stage the split extracts takes one — so each new package would
import the file it was extracted from, which is the coupling the split exists to remove. It's
now the shared input contract in analysis-model, with LANG_BY_EXT (two copies would let the
graph disagree with the language table beside it).

viz separates because it answers a different question: the symbol graph is what the product
reasons over; this is what a person looks at, and its node cap has nothing to do with
detection being correct.

indexer.ts 1,259 → 1,001 across both slices. Still above 600 — reported as a reduction,
not a fix. detect-engine (~415 lines) is next and more entangled: it needs PipelineContext,
so the abort contract moves first.

Numbers unchanged again: express 89, projected 89 → 90, remediation 89 → 95, 920/920. The
benchmark proves behaviour preservation rather than the diff looking safe.


detect-engine extracted — indexer.ts 1,259 → 703 (36b6c7b)

Third and largest slice (407 lines). The rule table, context gate, taint verdict, tier ladder and
secret value-shape signal now sit together — because they compose. A finding in a Python file
from a rule whose value doesn't look generated is discounted twice, and both multiply into
expectedHarm. Reading that product in one place is the only way to see it.

Scoring is deliberately elsewhere: keeping them apart is what stops a rule being tuned to move a
number.

The layering gate caught a wrong slice boundary. analyzeDependencies moved with the other
rules and dependency-cruiser failed instantly — raw-fs-only-in-io-packages. It reads
package.json from disk, so it's I/O and belongs with the pipeline. The gate found a mistake I
had already made and typechecked.

resetIssueIds() replaces indexRepo assigning a module-level counter across a package
boundary — ids are a per-run sequence and executor.ts indexes twice per remediation.

Detection's tests stay in analysis on purpose: they drive indexRepo, so moving them would
make detect-engine depend on the pipeline it was extracted from.

104 lines from clearing the 600 threshold. Numbers unchanged for the third refactor running:
express 89, projected 89 → 90, remediation 89 → 95, 920/920.


LLD §13 split complete — indexer.ts 1,259 → 478 (29b719d)

Two final moves: buildModuleGraphviz (display structure, same category as buildVizGraph
and buildTree), and import extraction/resolution → @codegraph/imports.

The gate corrected the package name. It was created as lang-imports and
lang-packages-are-leaves rejected it instantly — a lang-* package "knows its own syntax and
nothing about detection, scoring, or storage", which is what makes adding a language additive.
This stage needs ScannedFile and PipelineContext, so it's a pipeline stage containing
per-language syntax, not a language plugin. The rule was right; the name was wrong.

The Large file finding clears — as a consequence of splitting on cohesion, not as the
target. What remains is walk, scan, dependency hygiene (reads package.json, so it stays with
the I/O) and indexRepo orchestrating. Splitting further would be ceremony.

Five packages out of one file over four commitsscore-engine, viz, detect-engine,
imports — plus shared contracts in analysis-model. Dependency-cruiser polices every edge.

The layering gates caught two structural mistakes that typechecking and 920 tests did not:
analyzeDependencies placed in a package forbidden raw fs, and this package's name. Both
invisible to the compiler.

Express 89, projected 89 → 90, remediation 89 → 95, 920/920 — identical through all four
refactoring commits.


Detection precision measured: 72% [58–83%] — P5.3 not met (a226b36, 18032de, ac6033e)

Protocol pre-registered and committed before any finding was sampled — the commit order is
the evidence. Fixed the stride, three labels, six decision rules, and that unclear counts
against.

corpus precision 95% CI
express 22/25 = 88% 70–96%
self 14/25 = 56% 37–73%
total 36/50 = 72% 58–83%

The aggregate misleads; the breakdown is the point. 35 of 50 findings come from rules right
every time (debug 16/16, fs paths 7/7, large files 5/5, localhost 3/3, any 3/3). Four rules
carry all the failure — and two of them, secrets 0/7 and TODO 0/4, account for 11 of 14
false positives.

TODO and Suppressed checker fail identically: the rule matches text that describes the
thing rather than is it.
Every false TODO was prose about TODO handling. This repo discusses
its own rules constantly, which is why self (56%) trails express (88%) — unrepresentative, as
§5 said before the numbers existed.

The one true ReDoS led somewhere unexpected. IMPORT_RE was genuinely superlinear
(5,583ms at n=3200) — and unreachable: astTsExtractor took the regex extractor as a
fallback and never called it. That dead branch had already cost this branch real time (a
CommonJS fix written into code that never ran). 95 lines deleted; the live path
(@codegraph/imports) was measured instead and guarded there.

Two process failures worth recording. The first guard was vacuous — it called
extractorFor(".ts"), which returns the AST extractor, so restoring the vulnerable pattern left
it green (sixth such test caught by mutation). And verifying the replacement hung the run for 15
minutes, leaving the source mutated because the restore never executed — caught by checking the
file rather than trusting the harness.


Precision 72% → 88% by acting on the breakdown (7df585f)

Same protocol, same corpora, same stride. Sample redrawn each pass because fixing a rule changes
the finding list — which is why the per-rule table matters more than the total.

pass change express self total 95% CI
1 baseline 88% 56% 72% 58–83%
2 markers must follow a comment opener, unquoted 88% 76% 82% 69–90%
3 placeholder tokens + synthetic runs 100% 76% 88% 76–94%

Pass 2 — mention vs occurrence. TODO 0/4 and Suppressed 0/1 were 11 of 14 false positives.
Comment-context was necessary and not sufficient: a comment discussing markers is still a
comment. A real marker directly follows //, /*, * or #; a mention sits mid-sentence. Plus:
a marker in backticks is a quoted example, and every match is scanned, since one comment can
quote an example and leave a real marker.

Pass 3 — placeholders. Secrets 0/8, all fixtures. Placeholder words as whole tokens plus runs
no generator emits. The token boundary is load-bearing: AKIAIOSFODNN7EXAMPLE contains "EXAMPLE"
preceded by 7, so it isn't a token and stays reported.

Is 0.85 met? The point estimate is, at 88%. The interval is not settled — lower bound 76%,
and n=50 can't resolve it. Reported as both, not whichever reads better.

One fix deliberately not made. Suppressing secrets in *.test.*/examples/ would clear the
last three outright. A real credential committed to a test file is exactly what's worth catching,
and path suppression silences it. That's a recall decision, not an oversight.

Two more weak tests caught by mutation, plus an existing fixture the new rule correctly
suppressed — abcdefghij… isn't what a generator emits, so the rule was right and the fixture
was unrealistic.


Held-out validation: 87% — the fixes generalise (012a41a, 81d4f65)

Passes 2 and 3 were fitted to false positives I had already read — the placeholder token list
contains words taken from the failures it fixes. That's legitimate rule design and also how a
number stops generalising, so criteria and a falsifiable prediction were committed before
cloning anything.

Three repos never analysed for findings: axios@c3f553c, flask@6a2f545, got@e3924aa.

corpus precision 95% CI
got 15/15 = 100% 80–100%
axios 13/15 = 87% 62–96%
flask 11/15 = 73% 48–89%
held-out 39/45 = 87% 74–94%

Prediction held: below express's post-fix 100%, above 0.85 — 87% vs 88% on the tuned pair.

Strongest evidence: Suppressed checker 16/16. Pass 2 was written against JavaScript //
comments; the held-out corpus exercised it almost entirely on Python # type: ignore[...] forms
it had never seen. A rule fitted to its own examples doesn't transfer like that.

A new failure, exactly where the criteria aimed. debugger statement scored 0/4, all in
flask — Python has no debugger keyword, so every match was docstring prose or the CLI string
"--debugger/--no-debugger", and Python is lexical tier with no context gate. Criterion 4
demanded a Python repo because that tier had never been precision-tested. Fixed via statement
form + JS/TS restriction; flask 92 → 97.

Mutation testing then showed my first four tests were each satisfied by a different mechanism
— word boundary, extension gate, context gate — so none pinned the statement form.


HLD §14 metrics: two implemented, the third corrected (322a3d0)

HLD §14 names six metrics. Three existed — checked rather than assumed, the same way the
§8.3 tier ladder and the SARIF adapter turned out to be documentation only.

metric before
cg_run_total, cg_findings_total, cg_verification_total present
cg_cache_hit_ratio missing — data source built two commits ago
cg_queue_depth missing — one COUNT away
cg_stage_duration_seconds missing — nothing captures the timings

Added gauge support and the two with real sources. Gauges are sampled at scrape, not stored
a stored gauge serves whatever was true when some process last wrote it, which for "how full is
the queue" is worse than no answer. They're read in the route, not inside renderPrometheus,
because their sources sit above persistence in the layering.

queueDepth() counts queued only: a running job isn't backlog, and including in-flight work
makes a healthy queue with one busy worker look identical to a stalled one.

Found while reading the renderer: formatValue was
Number.isInteger(v) ? String(v) : String(v) — both arms identical. Worse, it emitted
Infinity, which the format rejects. Unreachable with only counters; reachable the instant a
ratio gauge divides by zero.

Then mutation testing showed my own NaN branch was inertString(NaN) is already "NaN".
Only the infinities need translating. Removed; 5/5 after that.

HLD §14 now states what exists and what doesn't, and records cg_stage_duration_seconds plus
the Run record's stage timings as one piece of missing work — nothing instruments stage
boundaries, so neither can exist without that.


Stage instrumentation — the last HLD §14 gap (a393246)

The previous commit recorded cg_stage_duration_seconds and the Run record's stage timings as
one piece of missing work. This closes it.

timeStage lives in analysis-model beside PipelineContext — same seam. It records in a
finally, so a stage that threw still reports the time it burned; losing that is how a slow
stage that eventually errors becomes invisible.

Stages are named for the packages LLD §13 split out — scan, imports, dependencies,
detect, score, symbol-graph — so a slow run points at a package, not at "indexing".
That the split makes the metric legible is an argument for the split I hadn't anticipated.

stage ms
symbol-graph 1,233
detect 822
scan 35
imports / dependencies / score 1 each

Sum 2,093ms against a 2.1s total — and these independently reproduce the manual profiling
from earlier in this branch (buildSymbolGraph ~1.35s, eslint ~624ms). Two methods, one answer.

Timings are returned, not pushed: analysis sits below persistence, so the worker that
owns the run row records them. Same reasoning as gauges being sampled in the route.

Real work in the renderer: _sum/_count are stored as counters — they're monotonic — but
they are two series of one family, and Prometheus rejects a duplicate # TYPE line per
family. Two counters is a malformed scrape, not a cosmetic slip.

No buckets, so no percentiles. Boundaries would have to come from evidence nobody has, and
arbitrary ones produce confident wrong quantiles. Said in the HELP text, where an operator
reading the scrape will actually see it.

4/4 mutants caught. HLD §14 is now fully implemented.


Two optimisations killed by measuring them (0f161cf)

Stage timings said symbol-graph is 59% of a run, so I went to optimise it. Both candidates
are dead, and the measurements are worth more than the code would have been.

phase cost
symbol-graph stage 1,233ms (59% of 2,093ms)
├ TS program ~802ms
└ parsing all 329 TS files 58ms

1. Cache extracted symbols per content hash. Symbols are a pure function of file text —
content-cache.ts even says so. But parsing is 58ms; it was never the expense. Ceiling: ~58ms
of 2,093ms.

2. Make the typed program optional. ~923ms for 6 edges out of 2,549 (0.24%). Every number
says delete it. Deleting it is wrong: those 6 are method calls through a receiver, where the
fallback doesn't lose the edge — it emits a confidently wrong one. For Timeline, the
caller that would benefit most, that's the worst possible failure: an edge flipping between two
same-named functions across snapshots is phantom churn, a diff showing a change that never
happened.

What's actually new. typed-resolution.test.ts already recorded that no discriminating test
could be found — four candidates built, all four passed with the typed path disabled, left as
"an honest gap rather than a test that looks like proof and is not."

That gap is now closed. The missed shape is a method call through a receiver — the name never
appears in an import, so the fallback's import table has nothing to offer. Found by diffing
real-path against synthetic-base builds and reading the six edges that differed
, not by
inventing candidates. That's why it worked where inventing four didn't.

Two process failures, both caught by tooling rather than by me:

  • My first tests used an imported free function and survived deleting the whole program
    the exact vacuity that file's comment warns about, reproduced while fixing it.
  • I wrote them with cat > onto a path I hadn't read, destroying the existing file and its
    three tests.
    Caught only because the total dropped 955 → 954 while the file count held.

Program reuse: it works, it doesn't fit in 512MB (75f40c6)

The surviving incremental-graph idea — hold the ts.Program, pass it as oldProgram. Measured
both halves before building.

It works. With a persistent host returning identical SourceFile objects,
structureIsReused reaches Completely. Checker fully exercised, 12,643 resolutions, identical
answers every run:

run program checker total
cold 558ms 567ms 1,125ms
reuse, 0 changed 6ms 312ms 318ms
reuse, 27/327 changed 15ms 356ms 371ms

~67% off the warm path — independently reproducing the ~26%-of-run ceiling estimated earlier by
a different method.

It doesn't fit.

RSS heap
baseline 213 MB 46 MB
program held + exercised 842 MB 528 MB

Target is a 512MB host (ADR-001, the --memory 512m gate, the constraint web-tree-sitter
already broke once). A real run peaks at 341.8 MiB. This doesn't overshoot the budget, it
multiplies it.

The distinction that decides it: a cold run allocates comparable memory transiently and
gives it back. Reuse never gives it back.

Status changes from "large" to "measured; blocked by the memory budget." Not deferred for
size — it's about a day's work. It's the one optimisation whose benefit is proven and whose cost
the product can't pay.

Two wrong measurements first. structureIsReused=0 on every run — a fresh host each time,
so reuse never engaged. Concluding "reuse doesn't help" from that is the same vacuity as the
four non-discriminating tests in the previous commit. Checking the flag caught it.

And because a documented verdict stops nobody: CI now reports peak memory as a number and
gates at 85%. The existing check enforced 512MB as a cliff — OOM death — which catches a
catastrophe and is blind to erosion. Verified locally both directions: 341.8 MiB → 66.8% passes
with 170 MiB headroom; 470 MiB → 91.8% fails.


CI had been red for 12 runs while I reported green (84a5e1a, d7f5bac, 949fa90)

I checked the actual CI status for the first time. main is green; this branch had failed
every run since it began.
The Desktop (Electron) job — which this branch introduced along with
the desktop app — had never once passed, while every summary said "desktop 43/43". That figure
is the vitest unit suite. The Playwright E2E job is separate and no local gate ran it.

Three real defects behind it:

1. fs.pathExists test asserted the security boundary was broken. It expected true for the
runner's cwd. The service returns ok(false) for any ungranted path on purpose — "existence is
information too: probing outside the grant leaks the filesystem layout"
— and at boot nothing is
granted. The app was right; the test required it to be wrong. Rewritten to assert the
boundary holds, which tests strictly more.

2. The Electron bundle and container image shipped the web test suite. Found only because a
doc guard failed for an unrelated reason: readme-claims counts test files by walking the tree,
and after building locally it counted 42 instead of 9. The extra 33 were
build/standalone/apps/web/tests — 292 KB riding into the .dmg, and into the image by the same
route. Next's standalone writer copies the app directory wholesale; the exclusion lists only ever
named data/. Fixed in both packaging paths, and the bundle verifier now fails the build if
a test suite reaches it — verified by removing the exclusion: FATAL: test suite leaked into the bundle.

3. The boot-screen test could not be deterministic in e2e — and my first fix was wrong about
why.
I assumed polling was the problem and switched to recording navigations. CI answered:
no boot screen in navigations: ["http://127.0.0.1:45717/"]. The splash was gone before
Playwright's launch handshake handed over the window; the assertion was racing Playwright's
attach latency
, not the app. Moved to window-manager.test.ts where the ordering is exact,
mutation-tested 4/4 — including a case that exists only because a mutant survived my first
version (a fresh window is the one state where a broken showLoading still looks correct).

E2E keeps what e2e can prove: the window is never left blank, true on either side of the swap.

All three jobs now green. The lesson isn't the defects — it's that a local gate list
assembled by the person being gated is not the gates the project runs, and I trusted mine for
twelve commits.

- P0 remediation/safety hardening: executor, fixers, urlSafety, authz,
  rateLimit, store, db, gitops, github API + regression tests
- v2 design docs: IDENTITY (binding), HLD, LLD, SPIKES, DETECTION_ENGINE,
  PROMPT_P1, REVIEW_2026-07-29, CLAUDE.md working agreement
- desktop/ Electron shell (not yet in CI)
Structural only; no behaviour change. LLD §1.

Layout
- app/ -> apps/web/ (git-tracked renames; contents byte-identical)
- root package.json with workspaces ["apps/*","packages/*"]
- tsconfig.base.json carrying the LLD §1.2 baseline
- vitest.workspace.ts; tests run from the root, attributed per project
- docker-compose.yml moved to the root (build context is the workspace now)

Standalone / Docker (SPIKES.md §1)
- outputFileTracingRoot -> monorepo root so the tracer follows packages/*
- entrypoint therefore moves to .next/standalone/apps/web/server.js
- Dockerfile: context is the repo root, installs from the root lockfile,
  copies the standalone tree VERBATIM and runs `node apps/web/server.js`.
  Flattening it to preserve the old CMD produces a container that answers
  /api/health with 200 while every TypeScript-compiler route 500s
  (REVIEW P1-5) — which is why the smoke test indexes a real repo.
- render.yaml: dockerContext -> repo root, dockerfilePath -> apps/web/
- root .dockerignore replaces the now-dead apps/web/.dockerignore

Layering gate
- .dependency-cruiser.cjs encodes HLD §6.1 as a data-driven ALLOWED table
- resolution goes through tsconfig.depcruise.json; without the `@/*` alias
  the cruiser saw a fraction of the graph and every rule passed blind
- 4 pre-existing apps/web cycles baselined, not suppressed; verified the
  gate still fails on a newly introduced cycle. Wired into CI.

Lockfile
- root package-lock.json committed; CI uses npm ci
- regenerated from a clean tree: resolving against a populated node_modules
  dropped all 11 lightningcss platform variants, which builds locally and
  fails on every other platform (REVIEW P1-3)

Verified: typecheck, depcruise, 326/326 tests, build, standalone boots and
GET /api/health -> 200, Docker smoke at --memory=512m --cpus=0.5 --tmpfs
/app/data (health 200, Hello-World 2s, express 6s, peak 195MiB/512MiB).

Found and deliberately unfixed: REVIEW_2026-07-29 "Found during P1" P1-1..P1-5.
LLD §2. Pure types and pure functions; zero deps, zero I/O. Inherits
tsconfig.base.json UNRELAXED (strict + noUncheckedIndexedAccess +
exactOptionalPropertyTypes), unlike apps/web.

Contents
- branded ids (RepoId/RunId/JobId/SymbolId/FindingId) so swapped arguments
  cannot compile, plus ViewerId where `null` is an explicit public-bucket
  value rather than an omitted argument (LLD §8)
- Finding, Evidence, ConfidenceBasis, Severity, Dimension, SourceRange
- AnalysisTier + tierRank, keeping the ordinal map private so a persisted
  run never depends on it
- AnalysisRun/AnalysisCoverage/StageTiming — the run is the unit of
  immutability (HLD §7)
- fingerprint() + normalizeSnippet() (LLD §2.1)

fingerprint() is treated as a persisted contract: a suppression row IS a
fingerprint, so changing the algorithm un-suppresses what users dismissed.
26 tests, grouped by the property defended, including pinned output vectors
that are SUPPOSED to fail on an algorithm change (the fix is a migration,
not a test edit).

Two design points worth the reader's time:
- normalizeSnippet drops whitespace beside non-identifier characters, not
  just whitespace runs. Collapsing runs alone left `"a" ;` distinct from
  `"a";`, so running a formatter over a repo would have invalidated every
  stored suppression. `return x` still does not collide with `returnx`.
- comments are NOT stripped. The TODO/FIXME and suppressed-checker rules
  flag comments, so the comment is the entire evidence; stripping collapses
  every TODO in a file to one fingerprint.
- fields are length-prefixed, so ("a","bc") cannot collide with ("ab","c").

Deviation: root vitest config uses `test.projects` instead of LLD §1's
`vitest.workspace.ts` — Vitest 3.2 deprecates that file and warns on every
run. Same semantics.

Verified: typecheck both workspaces, depcruise clean, 352/352 tests
(326 pre-existing + 26 new).
LLD §10.3. Replaces 38 scattered `process.env` reads across 12 files with a
single declared surface. Boot-time validation reports EVERY invalid variable
at once; v1 read env in nine modules with inline fallbacks, so a typo in
CG_MAX_FILES silently became the default.

`Config` is a named interface and `buildSchema` must satisfy it, so a reader
whose type drifts from its field is a compile error.

Three corrections found by doing this, each a behaviour bug I would have
shipped by following the LLD literally:

- CG_FORCE_SECURE_COOKIES must stay THREE-valued. Unset does not mean false
  and does not mean NODE_ENV==="production": lib/session.ts falls back to
  inspecting x-forwarded-proto (F013). Collapsing it would mark cookies
  Secure on a production deployment served over plain HTTP, so the browser
  would stop sending the session cookie and sign-in would break.
- CG_ALLOW_LOCAL_ACCESS defaults to NODE_ENV!=="production", per
  lib/localAccess.ts. LLD §10.3 sketches `.default(false)`, which would
  silently disable local-folder indexing in development.
- CG_TRUSTED_PROXY_HOPS floor is 0, not 1 — v1's guard was
  `isFinite(hops) && hops >= 1`, so 0 is a supported direct-exposed setting.

`config` reads process.env at each access rather than snapshotting. An
eagerly-frozen singleton broke 48 tests in 10 files: seven mutate env at
runtime (including the Secure-cookie and rate-limit-keying security
regressions), and because ES imports are hoisted, a test's top-level
`process.env.CG_DATA_DIR = tmp` runs after its imports were evaluated, so a
frozen config pointed every test at the real database. The exported object is
frozen and holds no state, so this is not the module-level mutable state
behind review item B4.

childEnv() covers the three sites that legitimately need the WHOLE inherited
environment (git needs PATH/HOME/SSH_AUTH_SOCK; the Claude SDK spawns its own
CLI). That is env propagation, not a config read, and keeping it here makes
the ban one invariant with one greppable exception.

Enforcement: scripts/check_boundaries.py, wired into CI, plus ESLint
no-restricted-properties for editor feedback. Written in Python because the
exclusions need justifying in prose and because this machine's /usr/bin/grep
("pi-uu-grep") mishandles BRE alternation badly enough to report matches on
lines that do not contain the pattern — an untrustworthy basis for a gate.

Relative imports are extensionless: Turbopack does not map `./x.js` to x.ts
for workspace sources, which failed the production build while tsc was happy.

Verified: typecheck, depcruise (145 modules), 379/379 tests (was 326; +26
fingerprint +27 config), build, standalone boots with CG_TRUSTED_PROXY_HOPS
present in the server chunks and GET /api/health -> 200.
HLD §14. Replaces all 18 `console.*` call sites (12 server, 6 client) with a
`Logger` that emits one JSON object per line carrying level/time/msg plus
caller fields, and `child()` bindings so runId/jobId/stage land on every line
once P2 has them (HLD G7: a failure must be attributable to a stage).

Deviation from HLD §14, which names pino: this ships a small isomorphic
JSON-line implementation behind the Logger interface instead. Six call sites
are React client components, a Node logger cannot go into a browser bundle,
and LLD §1.1 allows a package exactly ONE public entry — so a ./client subpath
is not available. What callers depend on is the interface; pino can be dropped
in behind it in P2 when the worker gives server logging its own boundary,
without touching a call site.

Details that earned their place:
- Errors are serialised explicitly. `JSON.stringify(new Error("x"))` is "{}"
  because message and stack are non-enumerable, so logging an error without
  this records nothing — asserted in a test.
- No level filtering. v1's console.warn/error always printed; silencing any of
  them by default would hide diagnostics an operator sees today.
- Everything goes to stderr, where console.warn/error already wrote, so
  `docker logs` and existing drains are unaffected.
- An unserialisable field cannot throw out of a log call; the message still
  gets written. A log line must not become the outage.
- No credential scrubbing here, deliberately: call sites that can produce a
  token-bearing string already pass it through redactCredentials (LLD §10.2
  says carry that forward verbatim), and a second weaker implementation would
  invite callers to stop doing it properly.

`console.*` is now confined to packages/observability/src, enforced by
scripts/check_boundaries.py in CI (143 source files, 0 violations) alongside
the process.env ban. Comment-only lines are exempt so fixers.ts can keep
documenting the brace-less-block hazard (REVIEW B1) with literal console.log
examples.

Verified by running, not asserted: 393/393 tests (14 new), typecheck,
depcruise (148 modules), build, and a real logged error path in the production
standalone server — an unreadable directory through /api/browse produced
{"level":"warn","time":"...","msg":"browse: failed to read directory",
"path":"...","err":{"name","message","stack"}} on stderr as a single valid
JSON line, while the client still received only {"error":"Cannot read
directory"} (CLAUDE.md §5).

DEPLOY.md documents the format change and the root-level CI commands.
LLD §10.1. workspace.ts moved with `git mv` (history preserved); now the only
module in the workspace permitted to import node:fs, enforced by
.dependency-cruiser.cjs. No shim was needed — all seven importers were
repointed in the same commit, so there is no deprecated path to delete later.

The real gap this closes is not `..` traversal (v1 handled that) but raw paths
escaping the module. `resolveSafe` returned an absolute path that callers then
used with their own fs calls, which makes containment something each call site
must remember and leaves the path valid-looking long after it was checked.

- openWorkspace(root) returns a WorkspaceHandle taking only RELATIVE paths and
  re-validating containment on every access. A caller cannot hold a path.
- readWorkspaceBytes/writeWorkspaceBytes added so the fs route no longer
  imports node:fs at all; the download and upload ops were the two remaining
  raw-fs escapes there. Returning the basename removes its node:path use too.
- resolve() is kept as ONE documented escape hatch, used by lib/trash.ts to
  move an entry out of the workspace into the per-repo trash dir — a
  cross-boundary move a workspace-scoped API cannot express.

Honest about what per-access re-checking does and does not buy: it shrinks the
TOCTOU window from "however long the caller keeps the string" to "within one
method call". It does not close it. Doing that needs openat-style
descriptor-relative traversal, which Node does not expose. O_NOFOLLOW is not a
substitute and would break legitimate in-repo symlinks that v1 deliberately
allows when they resolve inside the root — there is a test asserting those
still work, as the counterweight to the eight tests asserting escapes fail.

noUncheckedIndexedAccess is ON here (packages inherit the baseline unrelaxed)
and immediately surfaced the `lines[i]` pattern the P1 traps warn about, in
searchWorkspace. Fixed properly by iterating `lines.entries()` so the value is
non-optional, not by suppressing with `!` — suppression is what hides this
exact bug class elsewhere (REVIEW B1).

FsEntry moved to core-domain so fsx (producer) and the editor UI (consumer)
share one declaration without the UI importing a node:fs module; re-exported
from lib/types.ts as a type-only export, so no importer changed and no runtime
dependency reaches the client bundle.

Remaining raw node:fs in apps/web/src/app: browse/route.ts only. It lists the
HOST filesystem behind CG_ALLOW_LOCAL_ACCESS, not a workspace, so fsx's
workspace-scoped charter does not cover it.

Verified: typecheck (strict + noUncheckedIndexedAccess for fsx), depcruise
(152 modules), 410/410 tests (17 new, real symlinks on a real filesystem),
build, boundaries clean.
LLD §10.2. gitops.ts, githubApi.ts and urlSafety.ts moved with `git mv`
(history preserved) as git.ts / github.ts / urlSafety.ts. Now the only module
permitted to import node:child_process, enforced by dependency-cruiser and
verified: zero occurrences under packages/ outside vcs. All 13 importers
repointed in the same commit, so again no shim to delete later.

`lib/gitops/` (the timeline engine) deliberately stays in apps/web — LLD §13
gives it its own package later, and the prompt scopes this step to gitops.ts.

TWO REAL SECURITY GAPS CLOSED, both only visible once the module boundary
forced the question:

1. v1 had two redactors of different strength. `redactCredentials`
   (lib/indexer.ts) stripped only URL userinfo; `redactToken`
   (lib/githubApi.ts) also matched bare `gh[pousr]_` tokens. A token appearing
   in a message WITHOUT being part of a URL — precisely what the GitHub REST
   API returns and what `git` prints on an auth failure — was redacted on the
   GitHub path and not on the git path. Now one function, the union of both,
   used everywhere.

2. `err.cmd` was never redacted anywhere. `push()` puts a token-bearing remote
   URL in argv, execFile copies the whole command line onto the error, and only
   `.message` was scrubbed — at one route boundary. Redaction now happens in
   the single `git()` helper every one of the 15 git operations goes through,
   plus `.stderr`/`.stdout`, which makes LLD §10.2's "every error path passes
   through redactCredentials" structurally true instead of a rule to remember.

noUncheckedIndexedAccess (on for all packages) surfaced 17 real errors in the
moved code, every one a `split()[i]` or regex-capture access. All fixed
properly, none with `!`:
- normalizeIPv4 now returns an octet tuple instead of a dotted string, which
  deletes the build-then-reparse round trip that produced four
  possibly-undefined values in the SSRF predicate.
- getCommitDiffFiles collapsed from a confused double-map (whose own comment
  read "To be safe:") into one pass that drops malformed lines rather than
  emitting `status: undefined`.
- git log / porcelain parsing use destructuring defaults, so a short field is
  an empty string rather than a key that silently vanishes from JSON.
- regex capture groups are guarded, not asserted.

Git types (GitStatus/GitBranch/GitLogEntry/...) moved to core-domain so vcs and
the editor's Git panel share one declaration without the UI importing a module
that shells out to git; re-exported type-only from lib/types.ts, so no importer
changed.

Verified: typecheck, depcruise (156 modules), 420/420 tests (10 new pinning the
redaction union, including that a bare token is now stripped), build,
boundaries clean.
…migrations

LLD §8. All SQL out of lib/store.ts, lib/settings.ts, lib/trash.ts and
lib/db.ts (deleted) and into the only module allowed to write it. Verified:
zero node:sqlite imports outside packages/persistence.

TENANT ISOLATION IS NOW A TYPE-LEVEL OBLIGATION
Every scoped repo read takes a ViewerId and there is no overload without one,
so a route cannot forget it. v1 enforced the rule in repoAccessDenied at the
top of each handler — correct today, one new route away from a leak. The rule
itself is unchanged: owner_id IS NULL is a shared public bucket, anything else
is private, and a non-owner gets "not found" so existence is not disclosed.

The unscoped reads the background job runner genuinely needs are named
findRepoUnscoped / getRepoForSystem so every one is greppable, rather than
reachable by omitting an argument. authz.viewerId now returns the branded
ViewerId, so a repo id cannot be passed where a viewer belongs.

16 tests assert the property at the layer that owns it, including that a
refused delete leaves both the row AND its jobs intact, that public-bucket
deletion stays open to anyone (v1 behaviour, deliberately unchanged), and that
a signed-out viewer is not accidentally matched against NULL owner_id — the
bug a naive `owner_id = ?` bind would introduce, hiding the viewer's own repos.

NUMBERED MIGRATIONS REPLACE THE ad-hoc ALTER CHAIN
001_initial_schema is a faithful, IDEMPOTENT port of v1's init(): every
existing database has the full schema but no schema_migrations row, so 001 is
applied to live production data and must be a no-op there. One transaction per
migration, not one for all, so a later failure cannot roll back an earlier
success. 6 tests build real pre-migration databases by hand and assert the
upgrade preserves existing rows verbatim.

Two bugs found and fixed while doing it (REVIEW P1-6, P1-7):
- v1 created idx_repos_owner in the same exec() as the tables, BEFORE the
  ALTER that adds owner_id. On a database predating that column, boot fails
  with "no such column: owner_id". Caught by a test that builds exactly such a
  database; index creation moved after the column additions.
- the Migration interface living in migrate.ts made
  migration -> migrate -> index -> migration, a real cycle the layering gate
  rejected. Moved to its own module.

Also: the node:sqlite type shim moved from an ambient .d.ts to ordinary
exported interfaces. The ambient form only applies inside a program that
includes the .d.ts, so the moment this became a package the app's compilation
stopped seeing it and every node:sqlite import failed to resolve. The lookup
now also verifies the builtin exists, so Node < 22 gets a clear message
instead of "DatabaseSync is not a constructor" from inside a repository.

Corrected my own depcruise rule: LLD §1.1 permits node:fs in fsx, vcs AND
persistence (it creates the data dir before opening SQLite); the rule named
only fsx and was failing a legitimate import.

apps/web/tests/db.test.ts relocated to packages/persistence/tests/schema.test.ts
— it scrapes the write path for `UPDATE repos SET` and compares against PRAGMA,
so it had to follow the SQL. Assertions unchanged and two added (migration
version recorded, every table present); pointing it at a file with no SQL left
would have left it green while checking nothing.

Verified: typecheck, depcruise (165 modules, 0 violations), 444/444 tests
(24 new), build.
HLD §11.2, LLD §8.1/§8.2. Two migrations: 002 creates structure, 003 moves the
data. Separate on purpose — a failure while rewriting live rows must not roll
back the schema and leave the two steps ambiguous.

002 adds `runs`, `findings` and `suppressions` per LLD §8.1. `runs` comes with
it because a finding belongs to a run, not a repo (HLD §7: a repo does not have
a score, a run has a score); without it there is nowhere to hang `run_id` and
the table would need migrating again as soon as Timeline compares two runs.
`suppressions` keys on fingerprint, not id, which is the entire point of having
a fingerprint — a dismissal has to survive the next run.

003 reads each `repos.issues` blob, synthesises one historical run per repo,
and inserts rows. `repos.issues` is LEFT IN PLACE: the app still reads it in P1
and rewiring that is P3, so this migration is purely additive and changes no
behaviour. That also keeps a rollback possible for two releases.

THE HONEST PART, recorded as REVIEW P1-8. v1 stored no snippet, no rule id and
no symbol, so a backfilled fingerprint is either location-dependent (unique but
silently breaks on reformat — the exact failure fingerprints prevent) or coarse
(stable but rule+file granular). 003 chooses coarse: a suppression against a
backfilled fingerprint suppresses that rule in that file, which can be
explained in one sentence. There is a test asserting that coarseness so nobody
"improves" it into the lying version. Backfilled rows carry a `legacy/` rule-id
prefix and truthful confidence_basis='syntactic' / analysis_tier='lexical', so
they are never presented as confidently as a dataflow-verified finding.

Findings repository adds what a blob could not answer: countByDimension,
newFindingsSince (compared BY FINGERPRINT, so a finding that merely moved down
the file is not "new"), and fingerprint-keyed suppression.

VERIFIED AGAINST A REAL v1 DATABASE, not just a fixture. No production file was
available, so one was produced by checking out the pre-P1 commit and driving its
actual lib/db.ts init() to create the schema, then seeding realistic indexed
repos. A COPY of that file was migrated:

  BEFORE tables: jobs,repos,settings,trash   (no schema_migrations)
  MIGRATION OK in 1ms — versions applied: 1,2,3
  AFTER tables:  findings,jobs,repos,runs,schema_migrations,settings,
                 suppressions,trash
  2 runs synthesised (the 0-issue repo correctly got none)
  15 findings from 15 blob entries; by dimension: correctness 4,
     maintainability 9, security 2
  legacy blobs preserved (6/9/0); settings + scores byte-identical
  re-run: versions unchanged, still 15 findings

11 new tests cover the same ground plus corrupt blobs, missing/garbage fields,
determinism across two copies, and the run diff.

Limitation stated plainly: the fixture is synthesized from v1's real code path,
not taken from a long-lived deployment, so it cannot account for schema drift a
production install may have accumulated.

Verified: typecheck, depcruise (168 modules), 455/455 tests, build, boundaries.
… catch

Gate work
- added `no-unresolvable`, which closed a hole in my own gate: a deep import
  like `@codegraph/core-domain/src/fingerprint` is UNRESOLVABLE (each package
  publishes only "." and "./package.json" per LLD §1.1), so the no-deep-import
  rules never saw it and depcruise passed. Verified by introducing exactly that
  import: the rule now fires, and `npm run build` independently fails with
  "Module not found", so the exports field is real enforcement too.
- corrected the fs rule to name fsx, vcs AND persistence (LLD §1.1), which was
  failing a legitimate import.
- proved the gate rather than asserting it: an upward import from core-domain to
  persistence fails (exit 11), node:sqlite outside persistence fails, a new
  cycle fails, and a deep import now fails.

REVIEW P1-9 — the serious one
The move relocated the data directory off the persistent disk. `dataDir`
defaults to `cwd/data`, and Next's standalone server chdirs to the directory
holding server.js, which went from /app to /app/apps/web. Observed in the
running container: /proc/1/cwd -> /app/apps/web, live SQLite and every editor
workspace under /app/apps/web/data, and /app/data — where render.yaml mounts the
disk — empty.

It would have shipped. The container boots, health returns 200, indexing
completes, scores render. The loss only appears on the next deploy, when every
indexed repo, workspace and setting is gone because no write ever reached the
volume. The smoke test could not see it: it asks "does it work now", and within
one container lifetime it does.

Fixed with ENV CG_DATA_DIR=/app/data — explicit, rather than derived from a cwd
Next controls. The smoke job now asserts the database is on the mount, is
non-empty, has migrations recorded, and holds the repos just indexed; on failure
it prints the server cwd and every codegraph.sqlite on disk.

Verified with a REAL bind mount, not tmpfs:
  writes land on the volume (codegraph.sqlite + wal + shm + workspaces)
  indexed repo and score survive `docker restart`
  schema_migrations has one applied_at per version — applied once, not per boot

Method note worth keeping: the postmortem's `--tmpfs /app/data` simulates the
disk's OWNERSHIP behaviour, not persistence. tmpfs is memory-backed and Docker
recreates it on restart, so a restart under tmpfs shows zero repos whether or not
this bug exists. It briefly looked like the fix had failed.

Full gate, all green:
  typecheck   7 workspaces
  depcruise   168 modules, 0 violations (4 known cycles ignored)
  boundaries  169 files, 0 violations
  tests       455/455 across 32 files
  build       ✓ + nested standalone entrypoint present
  docker      512m/0.5cpu: health 200, Hello-World 2s, express 6s,
              post-index health 200, peak 222MiB/512MiB, healthy
Closes B2, B3, B7, C5, C6 from docs/REVIEW_2026-07-29.md.

B2 - the score inverted its own ranking. penalty = severity x blastRadius
made a TODO (sev 1) in a file imported 60x score 61 while an eval() (sev 5)
in a leaf scored 5. The same raw product also sorted the issue list the user
reads, so it was visible in the UI. Both sites now use one damped, capped
multiplier: min(8, 1 + log2(1 + blastRadius)). The cap is what guarantees
severity 5 always outranks severity 1; damping alone does not. Also makes
the scorer agree with judgeScore, which was already damping.

B3 - volume did not register. The 5-hits-per-rule emit cap was doing metric
duty, so 500 console.logs and 5 scored identically. Counting continues past
the cap; volumeMultiplier damps the excess. Returns exactly 1 at or below
the cap, so repos under it score exactly as before.

C5 - projectScore was P0*2.2 + P1*1.1, a linear guess presented as a
forecast of an exponential model. Now re-runs the real scorer over the
issues that would remain. On express: 77->100 became 77->88, against a
measured 82.

B7 - /api/fleet called getRepo per repo (SELECT *, dragging the symbol
graph) to read one deps array; an OOM at LIMIT 100 on the 512MB target.
One 7-column query now. Guard spies on prepare and asserts one statement
with no blob columns.

C6 - desktop/ had no CI. First thing it caught: package.json and its
committed lockfile were never in sync (esbuild 0.28.1 vs ^0.20.1), so
npm ci failed from the commit that added them. Typecheck + 28 tests +
build now run per push. The Playwright e2e suite is documented as NOT
wired in: its serverPath and build manifest still point at app/, which
P1 deleted, and retries:2 would report green while proving nothing.

Docs: 5 README links broken by the P1 move; benchmark numbers re-measured
and pinned to expressjs/express@a371447 - the claimed score of 90 matched
neither the new model (77) nor the old (83). Test claim 265/22 -> 479/34.

Recorded, not fixed: call resolution is weak on CommonJS (11 edges from
123 symbols on express, 0 cycles, vs a claimed 94 and 14), which inflates
deadCode() to 110 of 123; and judge calibration puts everything in P0/P1.

Verified: typecheck, depcruise (168 modules, 0 violations), boundaries,
479/479 tests, build, README links; desktop npm ci + typecheck + 28 tests
+ build.
…nal review

Five substantive additions, all measured or cited against existing docs —
none hand-waved.

LLD.md §2.1 - fingerprint replaced single-hash with two-factor design
(primary: rule+scope+AST-structural shape; secondary: rule+scope+snippet)
plus an explicit move-matching/merge-split resolution algorithm. The old
single-hash version is already broken in shipped data: P1-8 in
docs/REVIEW_2026-07-29.md found one fingerprint per (rule, file) instead of
per occurrence.

LLD.md §3.1.1 - SSA form and def-use chains added as an explicit stage
between Cfg and the taint solver. §4.5's pseudocode already said "propagate
along def-use"; core-graph never defined what that meant. SSA/def-use are
pre-approved technique per IDENTITY.md §3, not adopted from any vendor.

LLD.md §5.3.1 - cache invalidation matrix: what's keyed by what, and a
reverse-dependency walk over resolved call edges for interprocedural
summaries. States plainly that this is unsound at today's resolution
quality - measured 11 edges across 123 symbols on expressjs/express - and
documents the file-neighbourhood fallback if P5 ships before that improves.

DETECTION_ENGINE.md §4.9 - engine execution strategy. Corrects the memory
model precisely: full-tier extraction runs the TS compiler API on the V8
heap (GC pressure applies as described); tree-sitter's WASM arena is the
one that already caused the postmortem OOM and is disabled by default, so
pushing more work into it is the wrong direction, not the fix. States what
the worker-process boundary actually buys (bounded per-job failure, both
heaps reset on exit) versus what it doesn't (raise the per-job ceiling).
Adds a measured-threshold pruning ladder (cyclomatic > 50, RSS gate,
function LOC) and documents where taint solving cannot follow: React
context, getServerSideProps, unresolved edges.

HLD.md §17 - states explicitly that P5 depends on P3's call-resolution
quality, not just calendar sequencing, with the same measured evidence.
Also flags P3's own exit criterion (precision >= 0.85) as needing a
ground-truth benchmark corpus that doesn't exist yet (SPIKES.md Spike 2).

Two citation errors caught and fixed before commit: a claim attributed to
IDENTITY.md §7 (Review checklist) that doesn't appear there, corrected to
its actual source (DETECTION_ENGINE.md §4.5); IDENTITY.md §5 vs §3
misattribution for the SSA-is-free-technique quote.

Verified: every ts code block across all three docs re-extracted and
typechecked - zero TS1xxx (syntax) errors; every quoted string traced to
its literal source line; every self-referencing and cross-doc §-reference
resolved against actual headers (9 false positives from the pre-existing
"[Doc.md](link) §N" convention checked by hand, confirmed none are in this
diff); markdown tables and code fences balanced in all three files.
On expressjs/express@a371447 all 59 findings landed in P0/P1 (P0:21 P1:38
P2:0 P3:0), so a priority label told the user nothing. judgeScore
multiplied five factors each >= 1 in the common case, giving it no real
low end: a bare-minimum severity-2 finding scored ~40, exactly the P1
floor.

First attempt failed and is documented: severity*20 (20/40/60/80/100)
matches the 70/40/20 thresholds exactly, which is precisely the problem --
those land ON the thresholds, and severity 2 is 44 of 59 real findings, so
any modifier >= 1.0 tipped it into P1. Measured P0:8 P1:44 P2:1 P3:0.

Fix: bands sit at the MIDPOINT of their priority range, not its edge --
10 + (severity-1)*20 -> 10/30/50/70/90. A typical severity-2 finding now
scores 30 (mid-P2) and must earn P1 with a strong modifier (>=1.33) or
fall to P3 with a weak one (<0.67).

Measured: P0:8 P1:16 P2:35 P3:0, every P0 severity >= 4, P2 all severity 2.

P3 still empty on express, recorded not tuned away: it is reachable (an
exported unreferenced symbol scores 14 at confidence 0.3, observed in the
churn.test.ts fixture); express just has nothing that weak. Forcing it
non-empty would fit the metric to one data point.

Caught a regression I introduced: clamping the modifier to [0.4, 1.6] plus
a hard 100 score cap made churn.test.ts fail with "expected 80 to be
greater than 80" -- the critic boosts corroborated findings to confidence
1.0, pushing both a hotspot (churn 50) and an untouched file (churn 1) past
the ceiling onto an identical score, erasing the churn signal Task 6.11
exists to provide. Ceiling raised to 2.5, 100 cap removed: this is a
ranking number rendered as a bare `score {n}`, not a percentage.

Also replaced a weak new test: "at least 3 of 4 buckets non-empty" passed
under both the old and new model, so it defended nothing. Now asserts
P2 > P1 on a severity-2-dominated mix, which discriminates.

Verified: 482/482 tests, typecheck, depcruise (168 modules, 0 violations),
boundaries, build. New tests checked against the pre-fix model: 5 fail.
- Desktop was moved from desktop/ to apps/desktop/
- Desktop is now integrated into root workspace and lockfile
- Build manifest paths and config serverPath repointed to apps/web standalone layout
- Claude SDK now explicitly fetched from root node_modules
- E2E tests are unblocked and running in CI
…he bundle

Closes the C6 follow-up. The Playwright/Electron suite was disabled behind a
comment listing preconditions; all are now met and it runs in CI at retries: 0.

Packaging (LLD §13 move + nested standalone layout):
- desktop/ -> apps/desktop, now a root workspace member. This is what actually
  closes C6: the standalone desktop lockfile had never matched its package.json
  (esbuild@0.28.1 vs ^0.20.1), so npm ci failed from the commit that added it.
  One lockfile now, which cannot drift.
- manifest sourceRoot ../../../app (deleted by P1) -> ../../../web; wasm source
  and asarUnpack glob repointed to the nested apps/web/ path; serverPath ->
  build/standalone/apps/web/server.js.
- claude-native-sdk sourced from the root node_modules: Next's tracer copies
  only the base claude-agent-sdk, not the platform subpackage the SDK resolves
  at runtime, so the packaged AI Assistant could never have worked.

Data leak found while verifying the assembled bundle:
build/standalone/apps/web/data was 16MB of codegraph.sqlite plus full git clones
of locally analysed repos, which electron-builder would have packed into a .dmg.
Not a tracing issue -- under Turbopack the standalone writer copies data/
wholesale (no .nft.json or required-server-files entry references it; a sentinel
file was copied through), so outputFileTracingExcludes has zero effect. That
negative result is documented in next.config.ts instead of leaving dead config
that resembles protection. Enforcement now lives where distributables are built:
.dockerignore already covered the image; asset-copy.ts filters the subtree and
verify.ts independently halts the build if it returns (checked by injecting a
fake sqlite into a bundle). Both paths are gitignored, so CI cannot reproduce
this -- only someone who had used the app would have shipped it.

Two gates the move silently broke:
- depcruise excluded ^desktop/, which then matched nothing, so the Electron app
  cruised with an unresolvable tsconfig -> 40+ phantom no-unresolvable errors.
- check_boundaries scanned apps/web/data/workspaces/ and reported 54 violations
  against other projects' vendored code, including a clone of CodeGraph itself.

Six real boundary violations fixed, not exempted. apps/desktop is production
source and belongs in SCAN_ROOTS, so the same rule is enforced a level down:
process.env confined to the Electron ConfigManager, console.* to its
electron-log Logger. server-manager's raw ...process.env spread became
ConfigManager.childEnv(port) (the pattern the ban's own message prescribes);
index.ts's bootstrap console.error routes through the Logger, with a console
fallback only when the Logger itself failed to construct. The shell cannot use
@codegraph/config: that schema validates the web server's CG_* vars and fails
fast, so it would refuse to start over variables it does not use.

childEnv holds security-relevant invariants (loopback bind, ELECTRON_RUN_AS_NODE)
and had no coverage; config.test.ts pins them against a hostile inherited env
(HOSTNAME=0.0.0.0, PORT=9999). Mutation-checked: reversing the spread order kills
2 tests, reverting serverPath to the old layout kills 2 more.

Verified: typecheck; 482/482 root; 35/35 desktop (was 28); depcruise 0/168;
boundaries 198 files 0 violations (was 171 - desktop now genuinely in scope);
web build; e2e 4/4 at retries 0; full CI sequence replayed locally from clean.
xvfb-run on a real Ubuntu runner is verified by construction only.
First slice of P2 (HLD §17): the schema and SQL a separate worker process needs.
No behaviour change yet — the fire-and-forget path in store.ts still runs, which
is why this migration is strictly additive.

Migration 004 brings `jobs` to the LLD §8.1 shape. v1's table
(id, repo_id, status, progress, message, error) could report on work the web
process was already doing inline and nothing more. It cannot express:
  - what the job is        -> kind + payload_json
  - who holds it           -> worker_id + lease_until
  - how often it has tried -> attempts + max_attempts
  - whether it is a repeat -> idempotency_key
Plus idx_jobs_claim, the partial idx_jobs_idem, and idx_jobs_repo_status (the
last is not in §8.1's index list; added because the per-repo mutex makes "live
jobs for this repo" a hot query that would otherwise scan on every enqueue).

ADDITIVE ONLY, verified against a hand-built v1 database holding a live
in-flight job: status/progress/message survive untouched, new columns take
defaults, all three indexes appear, re-running is a no-op. A migration that
renamed `message` to `stage` would have broken every in-flight job on deploy.

Queue SQL in persistence per LLD §8 (only module that writes SQL). Policy —
lease duration, retry timing, heartbeat interval — deliberately does NOT live
here; that is @codegraph/jobs, next.

Two decisions worth stating:
- attempts increments on CLAIM, not on completion. A worker killed by the OOM
  reaper never reports anything, so counting at completion would let exactly the
  crash this architecture exists to survive retry forever.
- heartbeat/progress/succeed/fail are all scoped to worker_id. The split-brain
  case (worker A stalls, lease expires, worker B takes over) must not let A
  write a stale result over B's run; those calls return false so A abandons.
- failJob decides retry in SQL from the row's own attempts, so a worker that has
  lost its lease cannot talk the queue into a fresh budget.
- cancelJob leaves terminal jobs alone, so cancelling a succeeded job cannot
  rewrite history. A running job's worker notices at its next checkpoint; there
  is no way to interrupt a synchronous parse mid-file and pretending otherwise
  would be the dishonest version of the feature.

21 new tests against a real SQLite file, not a mock — every property here is a
property of the SQL (claim atomicity comes from the UPDATE ... WHERE id =
(SELECT ... LIMIT 1) write-lock order). Covers single-winner claim, priority
then FIFO ordering, expired-lease reclaim (the crash-recovery path), stale-worker
rejection, retry budget, cancellation of queued vs running vs terminal, and the
per-repo mutex read.

Also fixed backfill.test.ts asserting schemaVersions === [1,2,3] as a literal.
Its stated claim is "applies every version", so it now derives the expectation
from MIGRATIONS and asserts ordering separately — otherwise every future
migration breaks a test it did not break.

Verified: typecheck; 504/504 tests (was 482); depcruise 0 violations
(169 modules); boundaries 200 files 0 violations; web build.
…ueue

Second P2 slice. Still no behaviour change: nothing calls this yet, the
fire-and-forget path in store.ts is untouched. apps/worker is next.

DOC GAP, stated rather than papered over: LLD §1 lists packages/jobs and §10's
heading names it, but no subsection specifies its interface — §10 details fsx,
vcs and config only. This surface is derived from what IS specified: the kind
values and column set in §8.1, the claim/lease semantics in §8.3, and HLD §5.1's
"lease · heartbeat · retry". Choices the docs do not cover are marked as choices.

Writes no SQL (LLD §8). The split is not ceremonial: the SQL owns atomicity, this
package owns the decisions — lease length, renewal cadence, and what a handler's
exception means. Those are the parts worth testing without a database, which is
why JobQueue is an interface: provoking a real split-brain would mean rewriting
lease_until behind a live worker's back, testing my ability to manipulate
timestamps rather than the runner. The SQL is covered against a real file in
packages/persistence/tests/jobs-queue.test.ts.

Decisions worth stating:
- JobContext is deliberately narrow: progress() and cancelled(), nothing else. A
  handler that could mark itself succeeded could do so and then throw, leaving
  the queue holding a lie. Success/failure is the runner's call, made from
  whether the handler returned or threw.
- Heartbeat renews at a THIRD of the lease. A single long synchronous parse can
  block the event loop past one interval (the tree-sitter path does exactly
  this), so one missed beat must not lose the lease.
- lease-lost is checked BEFORE cancelled and before success. A worker that no
  longer owns a job must write no terminal state at all, even a correct one —
  the new owner is authoritative and will write its own.
- A throw that follows lease loss is not recorded as a failure: it is very likely
  a consequence of the takeover (new owner moved the workspace), and recording it
  burns an attempt the new owner is already spending.
- runJob never throws. A bad repository killing the worker would relocate the
  crash the separate process exists to contain, not remove it.
- Cancellation is cooperative and says so. There is no way to interrupt a
  synchronous parse mid-file, so a handler polls at its own checkpoints.

Config gains workerConcurrency / workerPollIntervalMs / workerLeaseMs per
LLD §10.3, plus analysisBudgetMs which §10.3 lists and was missing. Verified
fail-fast: CG_WORKER_CONCURRENCY=99 is rejected at import with
'expected an integer >= 1 <= 8'. Default concurrency is 1 and that is not
timidity — HLD §3 pins peak RSS under 400MB on a 512MB host, and two concurrent
analyses in one process share the same monotonically-growing WASM arena, so
raising it multiplies exposure to the exact OOM the worker exists to contain.

A bug the compiler caught that my tests had missed: serializeError returns
`SerializedError | string`, so reading `.message` off it wrote `undefined` into
the job's error column whenever a handler threw a non-Error (a rejected promise
carrying a string, a library throwing an object) — precisely the failures that
are hardest to diagnose. Fixed and now covered by two tests.

16 runner tests via an injected scheduler, so no test waits on wall time.
Mutation-checked: deleting the lease-loss guard kills 2, treating cancellation
as success kills 1, leaking the heartbeat interval kills 3, reverting the
serializeError fix kills 2.

Verified: typecheck; 520/520 tests (was 504); depcruise 0 violations
(173 modules); boundaries 205 files 0 violations; web build.
…LLD §13.2)

Found while starting apps/worker, not from reading: the worker cannot import
apps/web (no-cross-app-imports), but the analyse handler needs indexRepo, which
LLD §13 routes to six packages P3 creates. The phase table presented P2 as
independent and it is not. HLD §17 already carries a P5->P3 note; this adds the
missing P2 one in the same form, plus a pointer above the table so a reader does
not have to rediscover either.

Neither obvious escape works, and both are recorded with the reason:
- doing P3's five-way split now means cutting a 901-line file inside a phase
  whose constraint is no behaviour change, and drawing detect-engine's boundary
  before the detection work that reveals where it belongs;
- leaving the worker in apps/web delivers the process boundary WITHOUT the
  enforcement -- store.ts could still call indexRepo in-process, and the next
  route to copy that quietly reintroduces the OOM ADR-001 exists to retire.

LLD §13.2 therefore stages it, moving only what already has a home plus one
transitional package:
  types.ts                    -> core-domain (exists; 318 LOC of pure models)
  clone/resolve/cleanup/churn -> vcs (exists)
  codeintel/{graph,query}.ts  -> core-graph (new; §13 already routes it there)
  the rest                    -> analysis (new, transitional; P3 splits it)

The vcs move is not new work. indexer.ts:1,4,99,452 runs `git clone` and
`git log --since` through child_process, which contradicts §10.2's "only vcs
shells out" -- so moving it CLOSES A P1 GAP rather than opening a seam.

Deliberately NOT naming indexer.ts's destination `score-engine`: scoreIssues is
one of five exports, and the same file walks the tree, extracts imports, runs the
rule array, and builds the viz graph. That name would lie, P3 has to dismantle it
anyway, so the misnomer gets paid for twice and corrupts the taxonomy in between.
`analysis` says what it is and its README will state the split it awaits.

Also records a gate hole found while sizing this: child-process-only-in-vcs,
sqlite-only-in-persistence and raw-fs-only-in-io-packages are all scoped
`from: { path: "^packages/" }`, so they never look at apps/ -- precisely where
un-migrated v1 code lives. That is why indexer.ts shelling out to git has always
passed. Measured: 4 files under apps/web import child_process, 6 import node:fs,
0 import node:sqlite (the persistence extraction did land). Scope widens per
extraction as the violations disappear; widening first would only add ignores.

LLD §13 also gains a types.ts row, which was missing from the map entirely.

No code in this commit. Verified: phase rows unique, markdown tables
column-consistent, §13.2 resolves from both documents.
…ayering gap

First implementation step of LLD §13.2. cloneRepo, resolveLocalDir, cleanup and
the churn scan move from apps/web/src/lib/indexer.ts to @codegraph/vcs.

This is not new work. indexer.ts:1,4,99,452 ran `git clone` and
`git log --since` through child_process directly, contradicting LLD §10.2's
"only vcs shells out". The rule that should have caught it
(child-process-only-in-vcs) is scoped `from: { path: "^packages/" }`, so it never
looked at apps/ and the violation always passed. indexer.ts now imports no
child_process at all; the single remaining match is a detection RULE's regex --
a security pattern that finds child_process in ANALYSED code, which is the
opposite of a violation.

It is also the prerequisite for apps/worker: the worker cannot import apps/web,
and it must acquire a tree before it can index one.

lib/indexer.ts keeps re-exporting all three names (§13.1 step 1), so none of its
importers changed. Proof the shim is faithful rather than merely compiling:
security-hardening.test.ts imports cloneRepo through `@/lib/indexer` and asserts
its failure path never leaks an embedded token -- that test passes untouched, so
the credential-redaction property survived the move.

computeChurn becomes vcs.churnByFile. The six-month window is carried forward
verbatim; changing it would move every score that weights churn, which this
phase may not do.

8 new tests for the two functions that had none. resolveLocalDir was only ever
exercised through a full index, yet it does path resolution and tilde expansion
on operator input; churnByFile's documented graceful path (empty map for a
non-git directory, because an unversioned local folder is a supported input)
was unasserted. The churn test builds a real two-commit repository and checks a
changed file scores above an untouched one -- the signal Task 6.11 depends on.

Still blocking a wider gate scope, recorded in §13.2 and measured today:
child_process remains in agents/executor.ts (-> P4) and gitops/{timeline,
snapshotLoader}.ts (-> vcs + packages/timeline, unscheduled); node:fs in 6 files.
The rule widens as those extractions land -- widening now would only add ignores.

Verified: typecheck; 528/528 tests (was 520); depcruise 0 violations
(174 modules); boundaries 207 files 0 violations; web build.
…traction bug

Second implementation step of LLD §13.2. codeintel/{graph,query,extractors,
ast-extractor}.ts and the symbol-graph half of lib/types.ts move to
@codegraph/core-graph. All four lib/* paths become re-export shims (§13.1
step 1), so no importer changed and 533 tests pass with no test edits.

TWO CORRECTIONS TO MY OWN §13.2, both from evidence rather than review:

1. types.ts does NOT go to core-domain. core-domain already declares the v2
   Dimension (6 members, incl. "performance"); lib/types.ts has the live v1 one
   (5 members). indexer.ts:533 enumerates dimensions from
   Object.keys(DIMENSION_META) and :549 computes the score as Σ score × weight
   with weights summing to exactly 1.0, so adopting the 6-member type forces a
   sixth weight taken from the other five -- moving every repo's Health Score.
   P2 is structural and may not. "performance" is not an oversight either: it is
   an AGENT, and the swarm's Finding has no dimension field at all
   (agents/types.ts:14). Reconciling the taxonomies is P3 work with a real design
   question behind it. So the symbol-graph types go to core-graph (§3's charter
   names exactly them) and the v1 view models travel to `analysis`.

2. The extractors go to core-graph, not `analysis`. graph.ts imports
   extractorFor from extractors.ts while indexer.ts imports buildSymbolGraph from
   graph.ts, so splitting them yields core-graph -> analysis -> core-graph. P3
   still lifts them to lang-typescript per §13.

BUG FOUND BY TYPING `any` PROPERLY. ExtractContext.program was `program?: any`.
Typing it ts.Program surfaced three unchecked-undefined sites, because
getSourceFile returns undefined when the program lacks that path -- a real case,
since a program is built from a tsconfig file list and a path that does not
normalise identically is absent.

I first wrote that this "would throw". It does not, and I checked instead of
shipping the claim: ts.forEachChild(undefined, …) visits nothing and throws
nothing, so the extractor returned an EMPTY result and reported success. Silence
is the worse failure -- the file contributes no symbols and no edges and nothing
says so. Plausibly a contributor to the express sparsity already on record
(11 resolved edges across 123 symbols), since a file whose symbols never enter
the graph cannot be a call target. Now falls back to the standalone parse, which
is the same path taken when no program is supplied; the checker is dropped with
it, so no reference resolves against an unrelated file.

An earlier draft of the test asserted only .not.toThrow(), which passed against
the broken version too and defended nothing. It now asserts result CONTENT:
2 of 5 fail when the guard is reverted.

Cycle fixed rather than re-baselined. ast-extractor <-> extractors has been in
the known-violations baseline since v1, and survived the move only because the
baseline keyed on the old apps/web paths. The dependency was type-only in one
direction, so the six shared types moved to contracts.ts and the cycle is gone
outright -- zero runtime change (verbatimModuleSyntax erases type imports).
Baseline drops 4 -> 3 entries.

core-graph carries the same two tsconfig relaxations apps/web has, for the same
documented reason: 67 noUncheckedIndexedAccess/exactOptionalPropertyTypes errors
came with the code, and clearing them decides what the extractor DOES on
out-of-range input, which is a behaviour change. lib: ES2024 added because
graph.ts:76 uses Promise.withResolvers (declaration-only widening; target
unchanged).

Corrected a false claim in apps/web/tsconfig.json while I was there. It said
those relaxations "get deleted when the code they cover moves out of this
workspace". Measured after this move: still exactly 275 errors, 58 now reported
against packages/core-graph. Because packages publish raw TS (exports:
"./src/index.ts", §1.1), a consumer's tsc follows the symlink and re-checks
package SOURCE under the CONSUMER's flags -- per-package strictness governs that
package's own typecheck only. The comment now records that and the post-move
ownership of all 275.

Verified: typecheck; 533/533 (was 528); depcruise 0 violations / 181 modules,
baseline 4->3; boundaries 215 files 0 violations; web build; desktop 35/35.
… client-safe

Third implementation step of LLD §13.2, and the last one before apps/worker can
exist. indexer.ts and eslintSecurity.ts move to @codegraph/analysis (transitional
-- README states the P3 split); the v1 output models move to
@codegraph/analysis-model. lib/{indexer,eslintSecurity}.ts and lib/types.ts become
re-export shims (§13.1 step 1): no importer changed, 533 tests pass untouched.

WHY TWO PACKAGES, not one. I put the models inside `analysis` first and the build
failed: "the chunking context does not support external modules (request:
node:child_process)". lib/types.ts re-exports DIMENSION_META, which is a VALUE, so
Turbopack must resolve the module it comes from -- and with the model inside the
pipeline package that dragged node:child_process (via vcs) and fs (via eslint's
fdir) into a "use client" component's graph.

Splitting DIMENSION_META itself was the obvious alternative and it is wrong: the
report UI renders `weight {Math.round(meta.weight * 100)}%` per dimension, so the
weights are user-visible -- that is IDENTITY.md's explainable Health Score, not an
internal detail. The table is legitimately shared by the scorer and the UI, so it
needs a client-safe home rather than a division. analysis-model has zero runtime
dependencies, which is its whole reason to exist.

A REAL VIOLATION THE MOVE REVEALED, not introduced. depcruise now reports
raw-fs-only-in-io-packages against indexer.ts. It has always walked the tree with
readFileSync/readdirSync/statSync; it sat in apps/web, and that rule is scoped
`from: ^packages/`, so nothing ever looked. Exempted by exact FILE path, not by
package -- verified narrow: injecting `node:fs` into models.ts still fails the
gate. Not fixed because fsx's WorkspaceHandle is async by design (§10.1) and this
walk is synchronous throughout, so routing it through fsx changes the pipeline's
execution shape; §13 already routes this code to `pipeline/enumerate`, where the
conversion belongs. The containment concern the rule exists for is handled at the
boundary before this runs (vcs.resolveLocalDir validates the root), and this code
only reads.

DEPENDENCIES THAT WERE NEVER DECLARED. eslintSecurity.ts imports eslint,
@typescript-eslint/parser and eslint-plugin-security at runtime, and is called from
indexer.ts:380 on the analysis path. In apps/web `eslint` was a devDependency and
@typescript-eslint/parser was not declared at all, resolving only through npm
hoisting. Survivable there -- verified Turbopack inlines all three into the server
chunk, the deployed bundle carries eslint-plugin-security@4.0.1's own module -- but
apps/worker is plain Node with no bundler, so all three are now real dependencies
of `analysis`, pinned to the versions the lockfile already resolves.

eslint-plugin-security ships no types. Declared as ESLint.Plugin (its real
contract, and the position eslintSecurity.ts actually uses it in) rather than
suppressed with `any`, so a future caller reaching into its internals stops
compiling instead of silently type-checking.

BASELINE FIX instead of a fourth copy of the same workaround. Promise.withResolvers
(core-graph/graph.ts:76) is ES2024, and because packages publish raw TS (§1.1)
every consumer re-checks that source under its own `lib` and hits the identical
error -- three packages needed the override independently. Raised `lib` to ES2024
in tsconfig.base.json once (target stays ES2022, so emitted syntax is unchanged;
Promise.withResolvers ships in Node 22, CI pins 24) and removed the per-package
overrides. Also updated observability, whose ["ES2022","DOM"] override existed to
ADD DOM and would otherwise have silently held that package on an older lib.

analysis-model's tsconfig relaxations are labelled NOT THIS PACKAGE'S DEBT, with
the measurement behind it: of the 70 errors those flags produce there, 0 originate
in src/ -- all 70 are core-graph's source, re-checked across a type-only import of
one interface. That is the raw-TS-export cost, and it is the argument for having
packages publish declarations eventually.

Verified: typecheck; 533/533; depcruise 0 violations / 187 modules; boundaries 221
files 0 violations; web build; desktop 35/35; client bundle contains no
child_process or node:fs.
PLAN.md supersedes HLD §17, so HLD §17 now says so rather than leaving two
competing phase tables. Retained (not deleted) because §18's traceability rows and
several ADRs cite its phase numbers, with an explicit warning that numbering
diverges from P3 onward.

Corrected PLAN.md §2: types.ts cannot go to core-domain. The observation behind
that row is right — 318 lines of pure dependency-free models — but the blocker is
semantic, not structural. core-domain already declares the v2 taxonomy and the two
have diverged: Dimension is 6 members there (incl. "performance") and 5 in the
live one. scoreIssues enumerates Object.keys(DIMENSION_META) and computes
Σ score × weight over five weights summing to exactly 1.0, so adopting the
six-member type forces a sixth weight taken from the others and moves every
repository's Health Score. P1/P2 are structural.

"performance" is an AGENT, not a scored dimension — the swarm's Finding carries
agent with no dimension field — so whether it earns weight is a §5.1 pillar-split
question, which is where this plan already puts it.

Also recorded why analysis-model is separate from analysis: found by a build
failure, not by argument. lib/types.ts re-exports DIMENSION_META, a value, so
Turbopack must resolve its module, which dragged child_process and fs into a
"use client" component. Splitting DIMENSION_META was the wrong fix because the
report UI renders its weights — that is §1's explainable score, not an internal.

Status table updated to landed reality: P1 done (9 packages, 533 tests), P2 in
progress (queue + jobs package done), with the eight commits listed.
PLAN.md §3. The supervisor claims a job and spawns a short-lived executor to run it;
the executor dies with the job. That is the mechanism, not tidiness:
web-tree-sitter's WASM arena only grows for a process lifetime (postmortem
2026-07-10, ~26MB per parsed file), so nothing in-process can reclaim it and process
exit reclaims it unconditionally.

Design landed by applying the ponytail ladder before writing, which changed it:
runJob in @codegraph/jobs ALREADY owns the heartbeat timer, cancellation plumbing
and outcome mapping, all tested. So supervision is expressed as a JobHandler that
spawns a child and awaits it, and none of that policy is rewritten. child_process +
Promise.withResolvers covers the rest; no state machine, no event bus.

Split of duties, so both failure modes are covered without a reaper:
  - supervisor holds the lease and is the ONLY writer of job state
  - executor writes no job state; it owns exit code and stdout only
  - executor dies      -> non-zero exit seen at once; retry budget applies
  - supervisor dies    -> lease expires, next poll reclaims (LLD §8.3)

Payload crosses on stdin, not argv: a clone URL can carry a token and argv is
visible in `ps` to every user on the host. Progress crosses as one JSON object per
line on stdout, with non-JSON treated as log output — dependencies print banners and
one must not fail a job. Line buffering is real: a message can split across two data
events, and parsing per-chunk drops those.

A gate for an invariant no test would catch: supervisor-loads-no-parser forbids
main/start/supervise from importing analysis or core-graph. An import there would
not break a build; it would quietly restore the OOM this architecture removes, since
the process holding the lease would grow with the work. Verified it bites.

POISON-PILL QUARANTINE, found by running ponytail-review over my own diff. The
review flagged that `code === 0` collapsed exit codes 1/2/3, so EXIT_CANCELLED and
EXIT_BAD_PAYLOAD encoded intent nothing read. Acting on it rather than deleting the
constants delivered the quarantine PLAN.md §3 asks for: a malformed payload exits 3
and goes terminal immediately, because the same bytes deserialise identically every
time and the budget would otherwise burn three attempts and three spawns to learn
that. Deliberately NOT applied to signal deaths, which look similar and are the
opposite case — an OOM kill often succeeds on retry once it lands beside less
resident memory. Mutation-checked in both directions: dropping the quarantine fails
1 test, widening it to signals fails 2.

`permanent` is read off the thrown error rather than added to JobHandler's
signature, so only handlers that have such a case know the concept exists.

Boundary gate caught two real process.env reads. supervise now uses config's
childEnv() (the pattern the ban's own message prescribes — the executor genuinely
needs PATH for git, HOME, proxy vars), and CG_JOB_ID is gone entirely: jobId already
travelled in the stdin envelope, so it was two copies that could disagree.

Exit codes now live in one place and are imported by the executor rather than
duplicated. Two copies of a protocol drift, and the drift is silent.

23 tests. Supervision runs against REAL child processes with stub executor bodies —
spawn semantics, exit codes, signal deaths and stdout framing are the behaviour, so
a mocked child_process would assert my beliefs about spawn rather than spawn.
Integration tests use a real SQLite queue and the real supervisor loop. Timings
confirm the escalation: SIGTERM cancellation 1.0s, SIGKILL path 6.0s (1s poll + 5s
grace).

One test caught its own flaw while being written: the lease/poll ratio guard was
first exercised with CG_WORKER_LEASE_MS=100, which config's own `min: 5000` rejects
first — so it passed without ever running the guard. It now uses a pair the
validator ACCEPTS (lease 5000, poll 2000), where only the ratio is wrong, which is
precisely what no per-variable bound can see.

Verified: typecheck; 548/548 (was 533); depcruise 0 violations / 194 modules;
boundaries 228 files 0 violations; web build; desktop 35/35.
Two gaps closed, both found by re-reading HLD rather than by a test failing.

1. AbortSignal reached only the STAGE BOUNDARIES in analyze.ts, not inside the
pipeline. HLD §11 is specific: "every stage checks between files". Without that, a
cancel during indexing waits for every remaining file — on a large repo, the whole
run — and only the worker's 5s SIGKILL escalation bounded it.

PipelineContext (HLD §8) now exists in @codegraph/analysis and indexRepo takes it
optionally. The checks ride the three EXISTING per-15-file yield points, so this adds
a branch rather than a pass. Optional because P5 splits this package and existing
callers must keep working without threading a context they do not have.

HLD specifies PipelineContext also carrying logger, clock, cache and budget. Only
`signal` is here, and the omissions are phase boundaries, documented in the type:
cache needs P6's store to exist; budget needs the §8.3 degradation ladder first,
because adding the field earlier invites a stage to THROW on budget, which HLD §8
explicitly forbids ("a stage that exceeds budget degrades — it does not throw").

2. HLD §419 specifies a stricter quarantine than I shipped: "a job that OOM-kills its
worker twice is quarantined rather than retried forever." My version retried signal
deaths to the full attempt budget. Now a second signal death is permanent — one OOM
is worth retrying, since the next attempt may not land beside whatever else was
resident, but a second is evidence the repository does not fit this host's memory and
a third spawn only buys another OOM. Uses `attempts`, which increments on claim, so
no schema change.

AbortError is a named error rather than a message string, so a caller can distinguish
"the user cancelled" from a real failure without matching text.

7 new tests. Cancellation is tested against a real 40-file temp repository, because
the checks sit at a per-15-file boundary and a 2-file fixture would pass with the
feature removed. Mutation-checked: deleting the in-loop checks fails the mid-walk
test; the OOM rule is covered from both sides (first signal death still retries,
second goes terminal despite a budget of 5).

Verified: typecheck; 555/555 (was 548); depcruise 0 violations / 196 modules;
boundaries 230 files 0 violations; web build.
…lt off)

createIndexJob can now write a queued job instead of running the analysis inline, and
the per-repo mutex is wired into it. The route returns 202 for a fresh enqueue.

WHY THE FLAG, AND WHY IT DEFAULTS OFF. I cut over unconditionally first, then checked
what would actually happen in production and reverted to a gate. Measured:

  - apps/web/Dockerfile ends `CMD ["node", "apps/web/server.js"]` and starts nothing
    else, so no process would claim a queued job;
  - `tsx` is absent from .next/standalone/node_modules, so the worker could not run
    raw TypeScript there even if something started it.

Shipping the cutover on would have replaced "indexing is slow and can OOM" with
"indexing silently never happens", which is strictly worse than the bug it fixes, and
it would have broken `npm run dev` the same way. PLAN.md's own rule is that each step
keeps main green, so this uses the strangler-fig gate LLD §13.1 step 4 already
prescribes for CG_ENGINE. The flag flips in the commit that (a) compiles the worker to
JS and (b) runs it beside the web process in the container — proven by the 512 MB
two-concurrent-job smoke test, which is P2's real exit criterion and cannot pass
without a deployable worker.

A refused enqueue is 200 with `alreadyRunning: true`, not 409. Submitting the same
repository twice is an ordinary thing to do (double-click, two tabs); 409 would make
the UI render a failure for something that is working. The caller gets the IN-FLIGHT
job's id so it can attach to that progress stream.

IDEMPOTENCY IS NARROWER THAN HLD §418, stated in the code rather than left to look
finished. §418 wants `hash(repoId, commitSha, engineVersion)` so re-submitting a
commit returns the existing run. commitSha is unknown before the clone, which is the
worker's first step, and keying on the freshly-minted repoId would make every key
unique and the column decorative. It belongs with P6, which needs commitSha before
enqueue anyway. What IS enforced today is the per-repo mutex, which covers the failure
§418 actually guards: two runs on one workspace directory.

4 tests on the enabled branch, since the default path is the one the existing 555
already cover.

Verified: typecheck; 559/559 (was 555); depcruise 0 violations / 196 modules;
boundaries 231 files 0 violations; web build; desktop 35/35; useWorker default
confirmed false.
… at 512MB

Closes P2's real exit criterion. Two concurrent index jobs in the built image at
--memory=512m --cpus=0.5 both reached `done`, peak RSS 341.8 MiB of 512 (HLD §3 targets
under 400), no OOM kill, no restart, 2 children spawned and 2 reaped. That is ADR-001
measured rather than asserted.

Compiled worker (apps/worker/build.mjs, esbuild):
  dist/start.mjs     ~130 KB  supervisor
  dist/execute.mjs   ~15 MB   per-job executor, pipeline inlined
The size gap is itself a check on `supervisor-loads-no-parser`: if start.mjs were tens
of MB, something had imported the analysis pipeline into the long-lived process.

Self-contained rather than externalised, because the Next standalone tree is a TRACED
subset that does not contain typescript or eslint-plugin-security (measured earlier), so
externals would need a hand-assembled node_modules beside it. Two exceptions:
web-tree-sitter stays external (it loads .wasm from disk; inlining the loader without
grammars fails on first parse), and a CJS banner supplies require/__filename/__dirname
because typescript and eslint are CommonJS. That banner was found by RUNNING the bundle,
not reading it: it builds clean, then dies with "Dynamic require of fs is not supported",
then "__filename is not defined", each only on the path that touches it.

THREE REAL BUGS THE CONTAINER FOUND THAT NO UNIT TEST COULD:

1. The worker exited immediately on an idle queue. `sleep()` used
   `setTimeout(...).unref()`, so with nothing to claim that timer was the only pending
   handle and Node drained the event loop and exited — it logged "worker started" and
   vanished, leaving jobs queued forever. Every unit test bounds the loop with `maxJobs`
   and so never waits on an idle poll. Only a long-lived process shows this.

2. The entrypoint's dead-worker watchdog never fired, for two reasons at once: the
   runner image's /bin/sh is dash (`wait -n` → "Illegal option -n") and, after `exec`
   replaced the shell, the background subshell's `wait` referred to a sibling rather
   than its own child. Replaced with a dull `kill -0` liveness loop that works in dash.
   It also now refuses to start when CG_USE_WORKER=true but the bundle is missing,
   instead of serving a queue nothing consumes behind a healthy /api/health.

3. getJob cast the raw column: `r.status as JobStatus`. The queue's vocabulary is
   queued|leased|running|succeeded|failed|cancelled; the dashboard polls
   queued|cloning|indexing|scoring|done|error. It compiled fine and returned
   "succeeded", which page.tsx:79 never matches, so a finished job left the client
   polling forever. Now mapped explicitly, preferring the executor's reported `stage`
   for in-flight work since that is already the UI's vocabulary. Verified in-container:
   the UI sees queued -> cloning -> indexing -> done.

Flag flipped where it is proven: ENV CG_USE_WORKER=true in the Dockerfile, since the
image always ships the worker. Left false in packages/config so `npm run dev` keeps
analysing inline — `next dev` starts no worker and a developer should not need two
processes to index a repo. `npm run dev:worker` runs the real path alongside.

ARCHITECTURE.md's CG_TREE_SITTER_MAX_RSS_BYTES entry corrected: that gate no longer
exists in the code. It degraded analysis to the regex extractor to dodge an OOM, and
per-job process isolation removes the need to make that trade at all.

Exit codes moved to their own module so the executor stops importing the spawner —
which also broke the compiled build, since supervise.ts resolves paths via
import.meta.url.

Verified: typecheck; 560/560; depcruise 0 violations / 202 modules; boundaries 233 files
0 violations; web build; docker build; container index green on DEFAULT config with no
env passed.
GET  /api/jobs/:id/events   server-sent progress
POST /api/jobs/:id/cancel   cooperative cancellation

Both verified against the built container, not asserted. SSE emitted the real sequence
queued -> cloning -> indexing -> done followed by `end`; cancel returned 202
"cancelling", the worker logged "cancelling executor" -> "executor ignored SIGTERM;
killing" -> "job cancelled mid-run", the job settled with message "Cancelled", and
cancelling an already-finished job returned 409.

SSE polls the database rather than subscribing, deliberately. The progress writer is a
different PROCESS and the two share only SQLite, so there is no in-process emitter to
listen to; a pub/sub channel would be a second coordination mechanism beside the queue,
with its own failure modes, to save one indexed primary-key lookup. HLD §11 frames
cancellation the same way ("a status write the worker observes on heartbeat"). What the
stream buys is fewer round trips and a finish that arrives on the tick it happens rather
than up to a poll late — polling /api/jobs/:id still works, which matters because SSE
through a proxy is not guaranteed.

Details that are easy to get wrong and were: authorisation happens ONCE before the
stream opens, since a job's repo cannot change owner mid-run and re-checking would put an
authz query on a twice-a-second loop; frames are emitted only on change, or an idle index
sends an identical frame twice a second for minutes; `req.signal` abort clears the
interval, because every completed index ends in a navigation and the common case is the
client leaving; `no-transform` and `X-Accel-Buffering: no` are set because a buffering
proxy makes progress arrive all at once at the end, which is the exact failure this
endpoint exists to avoid.

Cancel returns 202, not 200: it writes `cancelled` and the worker acts at its next
heartbeat, so the work is still winding down. Returning 200 would imply it had stopped.
Already-terminal jobs get 409 rather than a pretend cancellation, matching cancelJob's
refusal to rewrite a succeeded job. Rate-limited like any other state change an anonymous
visitor can reach.

FIXED AN INCOHERENCE THE STREAM MADE VISIBLE. The first SSE run showed a frame reading
`status: "indexing"` beside `message: "Queued"` — my status mapping sent `leased` to
"indexing", but leased means claimed with nothing reported yet and the row's message
still says Queued. It now maps to "queued"; only `running` falls back to "indexing".
Understating a ~100ms window beats contradicting the message next to it. This was
invisible in the polling path and obvious the moment the frames were listed.

Verified: typecheck; 560/560; depcruise 0 violations / 204 modules; boundaries 235 files
0 violations; web build; desktop 35/35; both routes exercised in-container.
@codegraph/verify implements LLD §7.2's gates and record. Nothing is wired to a route
yet; this is the harness plus the semantics, with the executor cutover next.

WHAT IT REPLACES. agents/executor.ts:179, live in shipped code:

    const verified = after.score >= before.score && after.issues.length <= before.issues.length;

That grades a fix by the metric the fix was built to move, and answers a question nobody
asked. "The aggregate score did not go down" is not evidence that THIS finding was fixed:
an unrelated improvement in the same re-index masks a fix that changed nothing, and a fix
that trades its target for a worse finding still passes. The PR body it generates says
"verified by re-indexing", which a reader hears as "the tests were run". They were not.

Gate 4 is the correction: the TARGET FINDING'S FINGERPRINT must be absent AND no new
finding may appear. The second half is not optional — removing your target while
introducing something new has not earned the word.

Two record rules that are the actual C3 fix, both mutation-checked:

  · `verified` requires no failures AND that syntax + reanalysis actually RAN. "No gate
    failed" is trivially true of a run where nothing ran; dropping the floor fails 4 tests.
  · `level` is `full` only when the suite ran and PASSED. A skipped test gate means
    `partial`, never `full` — treating absence as success fails 3 tests. This is the
    distinction SPIKES §2 forces: Render grants no privileged containers, so the hosted
    demo can only ever report `partial`, and collapsing the two is how "verified" came to
    mean less than a reader assumes.

`describeRecord` exists so callers stop composing that claim themselves, and it will not
say "tests passed" for a partial record.

Gates never throw. One unavailable toolchain must not abandon a whole record — that is how
a harness reports less than it knows. Skips carry reasons; a `tsc` or suite TIMEOUT is a
failure, not a skip, because the check did not complete and skipping would let a
pathological project quietly downgrade its own verification level.

`parse` and `reanalyse` are injected, so this package depends on no language plugin and no
detection engine. That independence is exactly why PLAN.md could promote this ahead of the
detection work: it takes a patch and a sandbox.

PLAN.md §4's exit criterion has a test: a deliberately broken fix (suite exits 1) fails
gate 3, and the assembled record reports verified=false, level=none.

DEVIATION FROM LLD §7.1, recorded not silent: `TextEdit` drops the top-level `file`.
`SourceRange` already carries one, so the spec's shape holds the path twice with no rule
for which wins when they disagree. The path lives on the range.

28 tests. Verified: typecheck; 588/588 (was 560); depcruise 0 violations / 209 modules;
boundaries 241 files 0 violations; web build; desktop 35/35.
`after.score >= before.score && after.issues.length <= before.issues.length` is gone from
executor.ts. Verification now runs the four gates and the result carries a
VerificationRecord, so the answer is auditable instead of asserted.

The PR body is generated from that record rather than declaring an outcome. It used to say
"verified by re-indexing" — which a reader hears as "the tests were run", and for a
`partial` record they were not. It now prints a gate-by-gate table plus an explicit
callout when no suite ran.

SandboxHandle implementation lives in apps/web, not in @codegraph/verify, for two reasons
pointing the same way: `child-process-only-in-vcs` forbids a package outside vcs from
spawning, and the isolation actually available is a property of the HOST (SPIKES §2: Render
grants no privileged container). The verifier takes the handle so that decision stays with
whoever knows the answer.

What the sandbox does and does not guarantee is stated in the file, because gate 3 runs
ARBITRARY CODE from the analysed repository. Enforced: hard timeout with SIGKILL, no shell
(argv array, so a crafted script name cannot inject through quoting), cwd pinned, and an
environment with every CodeGraph secret explicitly blanked so a repo's own suite cannot
read the operator's token. NOT enforced: kernel isolation — `network: false` sets the vars
a cooperating toolchain honours and a determined script can ignore all of them. Hence
CG_ALLOW_TEST_VERIFICATION defaults false; claiming a sandbox this cannot deliver would be
the same class of overstatement as C3 itself.

A DESIGN ERROR THE EXISTING SUITE CAUGHT. My first cutover picked the highest-severity
issue as gate 4's target. executor.test.ts then failed with "the target finding is still
present" — correctly: the fixers handle debug output, TODO markers and empty catches, so a
security finding at the top of the list was never going to disappear, and a fix that did
exactly what it claimed was reported unverified.

The gate was right and the wiring was wrong. `targetFingerprint` is now `string | null`,
and the batch path passes null because it genuinely cannot attribute an edit to the finding
it served. With null the gate verifies the half it can — nothing new was introduced — and
its reason says in words that this does not prove a specific finding was fixed, so the
record cannot be read as claiming more. That makes review C1's per-finding /fix the thing
that unlocks the stronger claim, rather than a later nicety.

Also recorded, because it bounds what gate 4 can ever prove today: v1 fingerprints are
rule+FILE granular (no snippet stored — measured in the P1-8 backfill), so "is this
fingerprint gone" means "are ALL occurrences of this rule in this file gone". Stricter than
per-occurrence, never weaker, so it cannot manufacture a false pass — a fix removing one of
two occurrences reports NOT verified, which is the safe direction. Per-occurrence identity
needs LLD §2.1's multi-factor fingerprint, which is P5.

Gate 1's parse check is bracket/quote/comment balance, not a parser, and says so: the
plugin that would answer properly is P5's, and what is checkable without one is exactly the
damage a line-deleting fixer does (review B1 shipped `if (x)` with no body). It reports ok
for anything it cannot disprove, so it catches the destructive case without blocking valid
fixes it does not understand.

Verified: typecheck; 590/590; depcruise 0 violations / 210 modules; boundaries 242 files
0 violations; web build. 26 existing executor tests pass unchanged, which is the evidence
the cutover preserved behaviour.
… relation

`Fixer.handles: readonly string[]` — "THE binding v1 lacks entirely" (LLD §7.1).

Without it there is no relation between a finding and a fixer, and the consequence was
concrete: POST /api/repos/:id/fix ran ALL THREE fixers over EVERY file, so clicking a P0
"untrusted input reaches eval()" finding produced a diff deleting console.log in 27
unrelated files. The ranked plan and the executor were two disconnected systems the UI
implied were one.

`fixersForRule(ruleId)` returns the fixers claiming a rule, and returns a LIST rather than
one so two providers can claim a rule at different safety levels (§7.1's `safety`) without
pretending to choose here.

Ids use the `legacy/<slugged-title>` form because that is what findings in the database
actually carry (migration 003). `legacyRuleIdFor` is duplicated from persistence's
`legacyRuleId` deliberately, not exported from it: that function is a fixed property of a
released migration and must never change, while this one tracks whatever current findings
carry. Coupling them would make a migration's frozen behaviour a live dependency of the
fixer registry.

Verified the binding is real rather than decorative — all four declared handles resolve to
rules `analysis`'s RULES table actually emits, zero dead handles, zero emitted-but-unhandled
fixable rules.

The test pins handles against the WORDS in that table, which sounds brittle and is the
point: a rule id is derived from its title, so rewording a title silently orphans its
fixer and the finding becomes quietly unfixable. Checked by renaming "TODO/FIXME marker"
to "TODO or FIXME marker" — 2 tests fail. That is a rename I would otherwise have shipped.

Also asserts a security rule resolves to NO fixer, which is the specific mis-pairing that
produced the 27-file diff.

Verified: typecheck; 597/597 (was 590); depcruise 0 violations / 210 modules; boundaries
243 files 0 violations; web build.
…two passes

Same protocol, same corpora, same stride as the baseline. The sample is redrawn each pass
because fixing a rule changes the finding list, which is why the per-rule table matters more
than the total.

  pass 1  baseline                                    72%  [58-83%]
  pass 2  markers must FOLLOW a comment opener        82%  [69-90%]
  pass 3  placeholder tokens + synthetic runs         88%  [76-94%]
          express 100% (25/25), self 76%

PASS 2 - MENTION VERSUS OCCURRENCE. TODO scored 0/4 and Suppressed checker 0/1: together 11 of
the 14 false positives. Restricting them to comments was necessary and not sufficient, because
a comment DISCUSSING markers is still a comment. A real marker directly follows `//`, `/*`, a
JSDoc `*` or `#`; a mention sits mid-sentence. A second signal handles the narrower class that
remained - a marker inside backticks is a quoted example - and every match is scanned rather
than the first, because one comment can quote an example AND leave a real marker. Both rules
are now 1/1.

PASS 3 - PLACEHOLDERS. Secrets scored 0/8, every match a fixture or a documentation
placeholder. Placeholder words as whole tokens, plus character runs no generator emits, suppress
five of eight. The token boundary is load-bearing: `AKIAIOSFODNN7EXAMPLE` contains "EXAMPLE"
preceded by `7`, so it is not a token and stays reported.

IS 0.85 MET? The point estimate is, at 88%. The interval is not settled - its lower bound is
76%, and n=50 cannot resolve that. Reported as an estimate that passes and an interval that is
open, rather than picking whichever reading is convenient.

ONE FIX DELIBERATELY NOT MADE. Suppressing secrets in `*.test.*` and `examples/` would clear
the remaining three failures outright. It is not done: a real credential committed to a test
file is exactly the case worth catching, and path-based suppression silences it. The remaining
false positives are the price of that, and it is a recall decision rather than an oversight.

Two things caught while doing this. An existing fixture asserted that a long digit-free value
stays reported using `abcdefghijklmnopqrstuvwxyz...` - which the new synthetic-run check
correctly suppresses, because the alphabet is not what a generator emits. The rule was right
and the fixture was unrealistic. And mutation testing found two more weak tests: one whose
quoted marker came second (so checking only the first match still passed) and one where the
backtick guard alone sufficed, hiding whether the opener anchor did anything.

Mutation-tested 5/5 on the marker rules after strengthening those two fixtures.

Verified: typecheck; lint; 940/940 across 75 files; depcruise 0 violations; boundaries 307
files; workspace gates 21/21; web build; desktop 43/43; bench express 89, remediation 89 -> 96.
…iction

Passes 2 and 3 were fitted to false positives already read - the placeholder token list contains
words taken from the very failures it fixes. Legitimate rule design, and also how a number stops
generalising.

Criteria fixed before any repository is cloned: never analysed for findings here, real
production software, 50-3000 source files, pinned commits, and at least one Python repository
because the lexical tier has never been precision-tested and gets no context gate.

Prediction recorded up front: held-out precision should land below express's post-fix 100%, and
if it falls under 0.85 the fixes did not generalise - which would make passes 2 and 3 tuning
rather than improvement.
…ne new failure found

Three repositories never analysed for findings here, pinned, sampled by the same stride and
labelled by the same rules: axios@c3f553c, pallets/flask@6a2f545, sindresorhus/got@e3924aa.

  got    15/15 = 100%
  axios  13/15 =  87%
  flask  11/15 =  73%
  TOTAL  39/45 =  87%  [74-94%]

THE PREDICTION HELD. It was recorded before selection: held-out should land below express's
post-fix 100%, and below 0.85 would mean the fixes were tuning rather than improvement. 87%
against 88% on the tuned pair.

The strongest evidence is `Suppressed checker` at 16/16. Pass 2 was written against JavaScript
`//` comments; the held-out corpus exercised it almost entirely on Python `# type: ignore[...]`
forms it had never seen. A rule fitted to its own examples does not transfer like that.

A NEW FAILURE, EXACTLY WHERE THE CRITERIA AIMED. `debugger statement` scored 0/4, all in flask:
Python has no `debugger` keyword, so every match was docstring prose or a CLI option string
`"--debugger/--no-debugger"` - and Python is lexical tier, so no context gate stood in the way.
Criterion 4 demanded a Python repository precisely because that tier had never been
precision-tested, and it was the only place the run found something new.

Fixed by requiring the STATEMENT form (line start, or after `;`/`{`/`}`/a comment close) and
restricting to the JS/TS family where the construct exists. `const debuggerPort = 9229` stops
matching too. flask 92 -> 97 with its four false positives gone.

Mutation testing then exposed that my first four tests were each satisfied by a DIFFERENT
mechanism - the word boundary, the extension gate, the context gate - so none of them pinned
the statement form, and reverting the regex to `\bdebugger\b` left all four green. A property
key `{ debugger: false }` pins it. A bare `debugger` line, which is a valid Python expression
statement, is what makes the extension gate load-bearing rather than decorative.

Verified: typecheck; lint; 945/945 across 75 files; depcruise 0 violations; boundaries 307
files.
…correct the third

HLD 14 names six metrics at `/api/metrics`. Three existed. Checked rather than assumed, the
same way the 8.3 tier ladder and the SARIF adapter turned out to be documentation only:

  cg_run_total            present
  cg_findings_total       present
  cg_verification_total   present
  cg_cache_hit_ratio      MISSING - and its data source was built two commits ago
  cg_queue_depth          MISSING - one COUNT away
  cg_stage_duration_seconds  MISSING - and nothing captures the timings it needs

Added gauge support and the two with real sources. Gauges are sampled at scrape rather than
stored, which is the whole difference: a stored gauge serves whatever was true when some
process last wrote it, and for "how full is the queue" that is worse than no answer.

They are read in the ROUTE, not inside `renderPrometheus`, because their sources sit above
persistence in the layering - the content cache is `core-graph`, the depth is a query.
Reaching up for them would invert the dependency the layer rules exist to protect.

`queueDepth()` counts `queued` only. A running job is not backlog; including in-flight work
would make a healthy queue with one busy worker look identical to a stalled one.

FOUND WHILE READING THE RENDERER: `formatValue` was
`Number.isInteger(v) ? String(v) : String(v)` - both arms identical, so the branch decided
nothing. Worse, it emitted `Infinity`, which the exposition format rejects; it wants `+Inf`.
Unreachable while only counters existed, and reachable the moment a ratio gauge exists -
`cg_cache_hit_ratio` divides by zero on a process that has looked nothing up.

Mutation testing then showed my NaN branch was itself inert: `String(NaN)` is already `"NaN"`,
the spelling the format wants. Only the infinities need translating. Removed rather than left
looking load-bearing - 5/5 after that.

HLD 14 now states which metrics exist and which do not, instead of listing six as though all
six were there. `cg_stage_duration_seconds` and the Run record's "persists its own stage
timings" are recorded as ONE piece of missing work, because that is what they are: nothing
instruments stage boundaries, so neither can exist without that.

Verified: typecheck; lint; 951/951 across 75 files; depcruise 0 violations; boundaries 307
files; workspace gates 21/21; web build; desktop 43/43.
Closes the one gap the previous commit recorded: `cg_stage_duration_seconds` and the Run
record's "persists its own stage timings" were ONE piece of missing work, because nothing
instrumented stage boundaries. Now something does.

`timeStage` lives in `analysis-model` beside `PipelineContext` — it is the same seam, a thing
every stage cooperates through. It records in a `finally`, so a stage that THREW still reports
the time it burned; losing that is how a slow stage that eventually errors becomes invisible,
which is exactly the run worth measuring.

Stages are named for the packages LLD 13 split out — scan, imports, dependencies, detect,
score, symbol-graph — so a slow run points at a package rather than at "indexing". That the
split makes the metric legible is an argument for the split I did not anticipate.

Measured on this repository: symbol-graph 1,233ms · detect 822ms · scan 35ms · imports 1ms ·
dependencies 1ms · score 1ms. Sum 2,093ms, against a 2.1s total. Those figures INDEPENDENTLY
REPRODUCE the manual profiling from earlier this session (buildSymbolGraph ~1.35s, eslint
~624ms) — two methods, one answer, which is the only reason to trust either.

Timings are RETURNED, not pushed to a metrics store: `analysis` sits below `persistence`, and
the worker that already owns the run row is what records them. Same reasoning as the gauges
being sampled in the route.

Emitted as a Prometheus SUMMARY, which required real work in the renderer: `_sum` and `_count`
are stored as ordinary counters — they are monotonic, the storage need not know better — but
they are two series of ONE family, and Prometheus rejects a duplicate `# TYPE` line for a
family. Rendering them as two counters is a malformed scrape, not a cosmetic slip.

No buckets, so no percentiles. Bucket boundaries would have to be chosen from evidence nobody
has, and arbitrary ones produce confident wrong quantiles. Stated in the HELP text, where an
operator reading the scrape will see it, rather than only in a design doc.

4/4 mutants caught: summary typed as counter, TYPE emitted per series, family detection
disabled, `_count` unrecognised.

Verified: typecheck; lint; 955/955 across 75 files; depcruise 0; boundaries 307; workspace
gates 21/21; web build; desktop 43/43; bench unchanged.
…imisations by measuring

The stage timings from the last commit said symbol-graph is 59% of a run, so I went to
optimise it. Both candidates are dead, and the measurements are worth more than the code would
have been.

MEASURED, on this repository:

  symbol-graph stage                     1,233ms  (59% of a 2,093ms run)
    TS program (create + getTypeChecker)  ~802ms
    parsing all 329 TS files                58ms

1. CACHE EXTRACTED SYMBOLS PER CONTENT HASH. Symbols are a pure function of file text, so this
   looked like the safe slice of incrementality — `content-cache.ts` even says so in a table.
   But parsing is 58ms. It was never the expense. Ceiling on the idea: ~58ms of 2,093ms.

2. MAKE THE TYPED PROGRAM OPTIONAL. It costs ~923ms and changes 6 edges out of 2,549 (0.24%).
   Every number says delete it. Deleting it is wrong: those 6 are method calls through a
   receiver, where the fallback does not LOSE the edge, it emits a confidently WRONG one. For
   Timeline — the caller that would benefit most, indexing dozens of commits — that is the
   worst possible failure, because an edge flipping between two same-named functions across
   snapshots is phantom churn: a diff showing a change that never happened.

WHAT IS NEW HERE, not just re-derived: `typed-resolution.test.ts` already recorded that no
discriminating test could be found — four candidates built, all four passed with the typed
path disabled, left as "an honest gap rather than a test that looks like proof and is not".

That gap is now closed. The shape those candidates missed is a method call through a receiver
(`this.config.childEnv(...)`, `contentCache.clear()`): the name never appears in an import, so
the fallback's import table has nothing to offer. Found by diffing real-path against
synthetic-base builds of this repo and READING the six edges that differed — `childEnv` on
desktop's config attributed to `packages/config`, `register` on `ipc/router.ts` to `core/di.ts`,
`clear` on `content-cache.ts` to `di.ts` — not by inventing candidates. That is why it worked
where inventing four did not.

Two process failures on the way, both caught by tooling rather than by me:

- My first version of the tests used an imported free function and SURVIVED deleting the whole
  program — the exact vacuity the file's comment warns about, reproduced while fixing it.
- I wrote the tests with `cat >` onto a path I had not read, DESTROYING the existing file and
  its three tests. Caught by the total dropping 955 -> 954 while the file count held. Restored
  from git and the new cases appended. `cat >` onto an unread path is how you silently delete
  someone's work; the count is the only reason I noticed.

Both mutants now caught: synthetic base, and skipping the program.

Verified: typecheck; lint; 957/957 across 75 files; depcruise 0; boundaries 307; workspace
gates 21/21; web build; desktop 43/43.
…n 512MB

The surviving incremental-graph idea was to hold the `ts.Program` and pass it as `oldProgram`
so unchanged files keep their parsed and bound state. Measured both halves before building.

IT WORKS. With a persistent host returning identical `SourceFile` objects,
`structureIsReused` reaches `Completely`. With the checker fully exercised — 12,643 call
resolutions, identical answers every run:

                         program   checker    total
  cold                     558ms     567ms   1,125ms
  reuse, 0 changed           6ms     312ms     318ms
  reuse, 27 of 327 changed  15ms     356ms     371ms

~67% off the warm path, independently reproducing the ~26%-of-run ceiling I estimated earlier
by a different method. The executor indexes twice per fix; Timeline indexes dozens of commits.

IT DOES NOT FIT. Retaining the program retains every `SourceFile` and the checker:

  baseline                   213 MB RSS /  46 MB heap
  program held + exercised   842 MB RSS / 528 MB heap

The target is a 512MB host — ADR-001's reason for a separate worker, the `--memory 512m` smoke
gate, the constraint web-tree-sitter already broke once. A real run peaks at 341.8 MiB inside
it. This does not overshoot the budget, it multiplies it.

The distinction that decides it: a cold run allocates comparable memory TRANSIENTLY and gives
it back. Reuse means never giving it back — the worker holds that RSS for its whole life,
which is exactly what a 512MB box cannot do.

Two earlier attempts at this measurement were wrong and I nearly published the first:
`structureIsReused=0` on every run, because a fresh host was created each time. Concluding
"reuse doesn't help" from a run where reuse never engaged is the same vacuity that produced
four non-discriminating tests in the last commit. Checking the flag is what caught it.

PLAN.md's status for this item changes from "large" to "measured; blocked by the memory
budget". Not deferred for size — the work is about a day. It is the one optimisation whose
benefit is proven and whose cost the product cannot pay, and it becomes available if the
deployment target grows. That is the trigger to revisit, not new profiling.

ALSO, because a documented verdict does not stop anyone: CI now reports peak memory as a
NUMBER and gates at 85% of the budget. The existing check enforces 512MB as a CLIFF — the
container OOM-dies and the poll notices — which catches a catastrophe and is blind to erosion.
A change taking the peak from 342 MiB to 500 MiB passed silently, and the next small change
would have looked guilty. Read from cgroup v2 `memory.peak`, v1 path kept for self-hosted
runners.

Gate verified locally against a real 512MB container, both directions: 341.8 MiB observed
peak -> 66.8%, passes with 170 MiB headroom; a 470 MiB regression -> 91.8%, fails. Workflow
YAML parsed and the heredoc checked after block-scalar dedent, since a broken workflow is
invisible until CI runs it.

Verified: typecheck; lint; 957/957 across 75 files; depcruise 0; boundaries 307; workspace
gates 21/21; web build; desktop 43/43.
…I reported green

I have been reporting "desktop 43/43" after every commit on this branch. That is the vitest
UNIT suite. The Playwright/Electron E2E job is separate, has never once passed, and I never
looked. `main` is green; this branch introduced both the desktop app and its CI job, so the
red is mine. Twelve consecutive failing runs while the summaries said all gates green.

Two failures, two different kinds.

1. BOOT SCREEN (CI-only, and the test was wrong). It polled `window.content()` for
   "CodeGraph is booting" some moments after launch. That is a state the app is DESIGNED to
   leave as fast as it can, so the assertion asks "is it STILL booting?" at an arbitrary later
   time — and on a fast runner the answer is legitimately no. CI saw the loaded app and called
   it a missing loading screen.

   Now recorded rather than polled: `framenavigated` collects every main-frame URL from the
   moment the window exists, and the test asserts the boot screen appears BEFORE the server
   URL. A recording cannot race, and order is what the requirement actually was.

2. IPC PATHEXISTS (both platforms, deterministic — the APP was right). It asserted
   `data === true` for the runner's cwd. `FileSystemService.pathExists` returns `ok(false)` for
   any path outside a granted root, on purpose: "Existence is information too: probing outside
   the grant leaks the filesystem layout." At boot nothing has been opened, so no path is
   granted, so `false` is correct for every path. The test required the capability boundary to
   be BROKEN in order to pass.

   Rewritten to assert the security property, which tests strictly more than before: the
   `success` envelope still proves the contextBridge round trip, and `data === false` for a
   directory that certainly exists proves confinement is on.

Both verified by mutation, with the app rebuilt each time: removing the boot-screen load, and
making `pathExists` answer for ungranted paths. 2/2 caught.

The lesson is not the two bugs, it is that a local gate list I assembled myself is not the same
as the gates the project runs, and I trusted mine for twelve commits.

Verified: reproduced locally (test 3 fails identically on macOS), fixed, 4/4 Playwright
passing, mutants caught. CI watched to green in the next step, not assumed.
…e-run reporting gap

The memory verdict cited 341.8 MiB from an earlier note. The headroom gate added in 75f40c6 has
now run for real and reports 313.9 MiB of 512 MiB — 61.3%, 198.1 MiB headroom — measured
indexing express under --memory=512m rather than estimated. It confirms the rejection: a
retained ts.Program adds ~500 MB on top of that.

Also records the e2e failure mode, which is worth more than the two bugs: a local gate list
assembled by the person being gated is not the gates the project runs.
Found by a doc guard failing for an unrelated reason, which is the only reason it was found at
all. `readme-claims` counts test files by walking the tree; after I built the desktop app
locally it counted 42 instead of 9 and failed.

The extra 33 were real: `apps/desktop/build/standalone/apps/web/tests`. Next's standalone
writer copies the app directory wholesale — already documented in `apps/web/next.config.ts` for
`data/`, and the exclusion lists only ever named `data/`. So 292 KB of vitest specs and
fixtures was riding into the .dmg, and into the container image by the same route. Not operator
state, so not a leak in the `data/` sense; just code with no runtime caller shipped to users,
including fixtures full of synthetic credentials that have no business in a distributable.

Four fixes, one per thing that was actually wrong:

1. `readme-claims` walked BUILD OUTPUT. It passed on CI only because the desktop and web jobs
   build in separate checkouts, and failed for any developer who built both. Counting sources
   now skips build/, dist/, .next/, test-results/. This test's own comment says it went stale
   twice; this is the third way it could be wrong, and the first that was environment-dependent.

2. `asset-copy.ts` EXCLUDED_PATHS now excludes `apps/web/tests` — the Electron path.

3. `.dockerignore` now excludes test suites — the container path. The Dockerfile builds and
   never invokes vitest, so nothing in the build needs them.

4. `verify.ts` FAILS the build if a test suite reaches the bundle. Fixing the filter is not
   enough: the exclusion list and the standalone writer's behaviour are maintained by different
   people at different times, which is exactly how `data/` got there in the first place.

Verified by removing the new exclusion and rebuilding: "FATAL: test suite leaked into the
bundle: standalone/apps/web/tests", build fails. 1/1 caught. Bundle now contains 0 test files.

Full local gates, now INCLUDING the Playwright e2e that CI runs and my gate list did not:
typecheck; lint; 957/957 across 75 files; depcruise 0; boundaries 307; workspace gates 21/21;
desktop unit 43/43; desktop e2e 4/4; bundle verification.
…nistic

The e2e fix in 84a5e1a was wrong about WHY it was failing, and CI said so:

  no boot screen in navigations: ["http://127.0.0.1:45717/"]

I had assumed polling was the problem and switched to recording navigations. The recording
captured exactly one entry — the app URL. The splash had come and gone before Playwright's
launch handshake completed and handed over the window, so no listener could have been attached
in time. The assertion was racing PLAYWRIGHT'S attach latency, not the app. No amount of care
inside the e2e makes that deterministic, which is the part I got wrong the first time.

The ordering is a property of `WindowManager`, so it is now tested there, exactly:

  · splash on create, before any server exists, and nothing pointing at a port
  · swap to the app only after a port arrives, and in that order
  · retry after a server that HAD been running returns to the splash
  · FAILED shows the error screen

Mutation-tested 4/4, and the third case is only there because of a mutant that survived: my
first retry test called `create()` then emitted the retry, so `appOrigin` was still null. A
mutation making the splash conditional on "no app origin yet" passed it. A fresh window is the
one state where a broken `showLoading` still looks right, so the test now drives the whole
cycle — server up, server dies, user retries.

The e2e keeps what e2e can actually guarantee: whatever the window shows by the time anyone can
look, it is real content — never `about:blank`, never an empty document. True on either side of
the swap, which is why it does not race.

Also: `readme-claims` enforces the desktop file count, so 9 -> 10, and cases 43 -> 47.

Verified: typecheck; lint; 957/957 workspace; desktop 47/47; desktop e2e 4/4; depcruise 0;
boundaries 308; workspace gates 21/21; web build. CI watched next, not assumed.
Full UI rehaul. The old surface was near-black + purple + Geist, which is the exact
generic-AI-tool look; nothing about it said what this product is.

DIRECTION. CodeGraph measures a codebase and shows you the reading, so the surface is an
instrument: an ink field, ONE phosphor signal colour (chartreuse #c6f24e), a calibration grid,
and every number in tabular mono. Instrument Serif carries the headlines with its italic as the
emphasis device — an editorial serif against mono data is the whole typographic idea, and it is
the opposite of the sans-only dev-tool default.

Three colours carry meaning and nothing else does: signal = a reading, violet = graph structure,
coral = risk. A fourth accent would make all four decorative.

WHAT IS ON THE PAGE IS TRUE. Every figure on the landing was measured in this repository and the
command that produces it is in the repo — 957 tests, 87% held-out precision, 2.1s over 327
files, 313.9 MiB peak under a 512 MiB cap. The pipeline section shows an actual run's per-stage
timings from the instrumentation added earlier this branch. IDENTITY.md forbids claiming in the
README what the code does not do, and a landing page is a README with better typography.

The hero figure is the product doing its job — symbols resolve, edges draw, a pass sweeps, one
node comes back hot, a score lands. Not an abstract "tech" illustration, because the graph IS
the product.

FIVE REAL BUGS, none of which a screenshot would have caught:

1. Hero invisible on mobile. `whileInView` for above-the-fold content is a race it can lose:
   the element never crosses an intersection boundary because it was never outside one. Failed
   at 390px, passed at 1440px, no error anywhere. Added `Entrance` (animates on mount) and drew
   the line: scroll-triggered below the fold, mount-triggered above it.

2. Console clipped, not overflowing. Grid/flex children default to `min-width:auto` and refuse
   to shrink below their content's min-content width, so the hero column rendered at full
   viewport width INSIDE a padded container. `scrollWidth` stayed clean, so an overflow check
   said fine. Only measuring element rects found it.

3. `.panel` was unlayered CSS, so its `border`/`background` shorthands outranked Tailwind
   utilities in `@layer utilities` — `hover:border-*` on a panel silently did nothing. Moved
   into `@layer components`. Found by a dead hover state, reported by a subagent.

4. `border-[var(--x)]` is ambiguous to Tailwind (length or colour?) and the hover variant
   generated NO rule at all. Hover states now use the theme utilities.

5. `--text-muted` was 3.67:1 on ink — AA for large text only, and its entire job is 11–12px
   helper lines. Raised to #707e8b (4.82:1). Flagged by a subagent, who proposed #6b7986; I
   recomputed and that lands at 4.49, still under. `--text-faint` (2.05:1) is now documented as
   decorative-only.

Also: the middleware's Basic Auth challenge page still used the old palette — for a gated
visitor that is the first and possibly only screen they see. Settings' unauthorized state was a
bare pill above an empty page; it now names the situation and offers the way out.

Five React 19 lint warnings in my own code, fixed properly rather than by raising the
`--max-warnings=22` budget: scroll is now `useSyncExternalStore` (which also fixes a real
one-frame flash when reloading already scrolled), the mobile sheet resets during render, the
OAuth error derives from `useSearchParams`, and two setState-in-effect calls became derived
values. `useSearchParams` needs a Suspense boundary or the page stops prerendering — added, and
the route still reports Static.

Verified in a real browser, not asserted: every route at 1440px and 390px with zero element
exceeding the viewport; reduced-motion renders the finished frame with no animation; focus
rings visible on every interactive element; the panel hover measured going 0.10 -> 0.18.
typecheck; lint; 957/957; depcruise 0; boundaries 312; workspace gates 21/21; desktop 47/47.
…ver swap

CI:

  Error: page.content: Unable to retrieve content because the page is navigating
  and changing the content.

My own test, from the rehaul commit. It reads `window.content()` once, immediately after the
URL assertion — which is exactly when the app swaps the splash for the server URL. Reading a
document mid-navigation throws rather than returning stale content.

The invariant is "whatever the window shows is real content, never a blank shell", and that
holds on BOTH sides of the swap. It is only unobservable *during* it. So the read is wrapped in
`toPass` and retried, instead of being sampled once at the least convenient moment.

Passed 3/3 locally before and after; the failure only reproduces on a runner slow enough to
still be navigating when the assertion lands, which is why the fix is a retry rather than a
longer wait.
`cd CodeGraph/app` — `app/` has not existed since LLD 13 moved it to `apps/web`, so the
documented first command of the project fails on a fresh clone. Nothing caught it: the
readme-claims guard checks test-file counts and the package table, not whether a path in a
code fence resolves.

Corrected to the repository root and stated why it is the root (one lockfile, npm workspaces).

Also recorded what the run actually needs, which was never written down: analysis runs IN the
web process by default (`CG_USE_WORKER` defaults to false), so `npm run dev` alone is the whole
app and the worker is opt-in. Someone reading ARCHITECTURE's separate-worker description would
reasonably assume a second terminal is required; it is not.

And 'hit Start Indexing' -> 'hit Index', which is what the button says after the UI rebuild.
Cards were the wrong shape for this page. Six repositories in six boxes, in insertion
order, is a list you have to READ to compare — and comparison is the only reason to
open a dashboard. Rebuilt as a dense ranked table.

Ordered by RISK by default, not recency. The question this page exists to answer is
"where do I look first", and insertion order answers a different one. Failed and
in-flight rows sort above everything because an index that never finished is the most
urgent thing on the page and has no score to rank it by. A Risk/Recent toggle is there
for when you do want the other question answered.

A prose readout replaces the three bare stat tiles: "CodeGraph has measured 6 of 6
repositories. The mean Health Score reads healthy, and the lowest is X at 61." A
sentence says what the figures MEAN; a grid of numerals leaves the reader to infer it.
The lowest-scoring repo is a link, so the summary is also the fastest route to the
thing it is warning you about — and it stays tagged `LOWEST` wherever sorting puts it,
because the tag marks the repo, not the first row.

Two columns added that cost nothing because both timestamps were already on the row:
`Indexed` as relative time (an absolute date makes you do arithmetic to answer "is this
stale?") and `Took`, the index duration. No column was invented — `RepoSummary` carries
id, url, name, status, sourceType, score, createdAt, finishedAt, and that is exactly
what is rendered. LOC and issue counts live on `RepoDetail`, so they are not here.

Layout inspiration from repowise.dev's repo surface — the prose-summary-over-figures
idea and the ranked "where the risk concentrates" table. Their palette, typography,
copy and data model are theirs; the structure of "state the reading in a sentence, then
rank what needs attention" is a good idea and now serves our own numbers.

Accessibility: the band is a word (`healthy` / `watch` / `at risk`) beside the colour at
every width, and below `sm` — where the word column collapses — the score itself is
promoted into the row, so severity is never carried by hue alone. The row delete button
is revealed on hover on desktop but also on `group-focus-within`, so it is reachable by
keyboard rather than mouse-only.

Verified in a real browser: sort toggle genuinely reorders (asserted on the rendered
order, not the handler), `LOWEST` follows the repo across sorts, 0px overflow at 1440px
and 390px. typecheck; lint; 957/957; web build.
The report was one 463-line page holding seven tabs as hidden `<div>`s, which meant
Monaco, three graph renderers, the swarm and the timeline all MOUNTED on every visit
whether or not you opened them. Now eight routes:

  /repos/<id>                overview
           /architecture     /circle-pack     /network
           /code-intel       /agents          /editor      /timeline

Measured, not assumed: the overview renders 752 DOM nodes and no Monaco; the editor
route renders 433 and is the only place Monaco exists. A Next nested layout is not
remounted when its child changes, so one `fetchRepo` covers every section — five
requests across a cold load plus six client-side section switches, i.e. the switches
are free.

Layout follows the reference the user supplied: a persistent left sidebar with the
sections grouped (Structure / Intelligence / History) and the repo pinned at the top,
a header carrying source icon, owner/name, language, LOC and indexed-at, then a code
health block with the score and a prose reading beside three pillar bars, a divided
stat strip, and a ranked findings table.

Ours, not theirs, where it counts: the score is out of 100 because that is our model,
the three bars are our real pillars (`pillarsFrom`), and every column in the findings
table is a field that exists on `Issue` — severity, blastRadius, churn. No column was
invented to match a screenshot.

WHAT THE DOC GUARD CAUGHT, and it was right: rebuilding moved the ADR-008 coverage
line into the header meta row, where it read as index trivia — "indexed 24m ago · 186
of 195 files scanned" — rather than as a qualifier on the number. `readme-claims`
asserts the coverage rendering lives in the report page, and the point of ADR-008 is
that THE SCORE reports its own coverage. Restored into the sentence that makes the
claim: "…Scored over 95% of files (186 of 195, 3 over the size cap, 6 unsupported)."
That is a better place than where it was before the rebuild, too.

Also worth recording: I read "186 of 185" off a downscaled screenshot and nearly
"fixed" an accounting bug that did not exist. The API says 186 of 195. Digits are not
reliable at 1/3 scale — the DOM text is.

Mobile: the sidebar collapses to a scrollable rail rather than a hamburger, because
section switching is the primary action here and hiding it costs a tap on every move.
Verified the PAGE does not scroll horizontally (390 = 390) while the rail does
(878 > 342) — a distinction an overflow check alone would miss.

Verified: all 8 routes render with correct nav state and 0px overflow; typecheck;
lint; 957/957; boundaries 322; web build; desktop 47/47.
Measured before touching it, because "too big" is an opinion and a type scale is not:

  health numeral   56px   <- the reading the page exists to deliver
  repo title       38px   <- identification
  section heading  24px
  nav / sidebar / meta   15 / 14 / 13px

The title was within touching distance of the numeral and more than half again the
size of the headings that organise the page, so the repository's NAME was competing
with its score. On a report the score is the hero; the name says which report you are
in.

Title 38 -> 28px, one step above the section headings and clearly below the numeral.
The icon tile came down 48 -> 40px with it: a 48px tile beside 28px type reads as a
logo rather than a source marker.

The two call-to-action buttons were stacked in a column, 44px each, standing 98px tall
against a header block of ~70px — the actions were physically larger than the thing
they act on. Now side by side at 40px, which also puts the primary action on the same
optical line as the title.

Everything else about the header is unchanged; this is scale, not content.

The header lives in the section layout, so all eight routes get it. Worth it there:
on /architecture the graph now starts about 90px higher, which on a 1000px viewport is
the difference between seeing two rows of modules and three.

Verified: title measures 28px at both 1440px and 390px, CTAs 40px, hierarchy now
56 > 28 > 24 > 15/14/13; no horizontal page scroll at 390px; typecheck; lint; 957/957;
web build.
Two ideas taken from the supplied references. Most of what is in those shots is
decoration on invented data — a time-tracker dial, an avatar card, a hatched segment
labelled "60%" that measures nothing. Two things in them are real patterns worth
having, and both attach to numbers this product already computes.

1. SECTION COUNTS IN THE SIDEBAR (from the Core 2.0 nav shot's badges)

   Architecture 10 · Circle pack 186 · Network 244 · Code intel 870 · Agents 151.
   Every figure is read off the payload the layout ALREADY fetched, so there is no
   extra request and nothing that can disagree with the page it links to.

   Sections with no honest count get no badge rather than a zero: Overview has
   nothing to count, and Timeline would need a separate history fetch. A zero there
   would read as "empty" when it means "not counted" — the two are different claims
   and the UI should not conflate them.

   Neutral, never coloured. The shot uses orange and green chips because they are
   notifications; ours are magnitudes, and the accents on this surface are reserved
   for readings, structure and risk.

   Also from that shot: a hairline down each group with the items indented off it.
   Three lists at the same indent under three eyebrows left the headings doing all
   the work of grouping.

2. THE TIER LADDER, RENDERED AT LAST (from the HR shot's segmented labelled bar)

   HLD §8.3 defines an analysis ladder — `full` (typed), `ast`, `lexical`, `skipped` —
   because a file is not scanned-or-not, it is read at the deepest level its language
   and size allowed, and the tier decides which detections are even POSSIBLE. The data
   has been in `ScanCoverage.tierLoc` all along and nothing ever drew it.

   On this repository: 51% typed, 49% lexical. That is the single most useful thing on
   the page and it was invisible. "Scored over 95% of files" was true and left you
   believing the whole thing was read the same way; half of it was pattern-matched
   without a parse, and findings there are marked low-confidence.

   Segments are computed over the tiers PRESENT, so they always total the bar. Tiers
   the run never produced are absent rather than drawn at zero width — `ast` is defined
   in the model but not currently emitted, and a zero-width segment with a legend entry
   claims a capability the index did not exercise.

   Amber for lexical is semantics, not palette: caution is exactly what half a codebase
   read without a parser warrants.

Verified: badges render 10/186/244/870/151 from the DOM, legend reads 51% · 18,121 LOC
and 49% · 17,151 LOC, two segments (not four); badges hidden below `lg` where the rail
is already tight; no horizontal page scroll at 390px; typecheck; lint; 957/957;
boundaries 322; web build.
Redesign of the two dashboard surfaces, taking the structural ideas from the supplied
references and leaving the parts that only work because a mockup has no backend.

RADIAL DIAL for the score, on both the repo report and the fleet mean. A 0–100 reading
has a natural full scale, and an arc shows the REMAINDER — how far from good this is —
which a bare numeral cannot. The number stays full size in the middle because the
number is still the answer; the arc is context around it.

270°, not a full ring: a full ring at 100% and one at 99% look identical, and the gap
gives the eye somewhere to start and stop. Pure SVG, no charting dependency for one
shape, `pathLength`-normalised so the fill is a plain fraction.

The same dial serves both pages. A gauge on the report and a bare numeral on the
dashboard would make the fleet mean look like a different KIND of number than the
scores it averages.

BENTO GRID for the overview, at three weights, because uniform cards claim everything
matters equally:

  [ reading — dial + prose, 2 cols ] [ pillars ]
  [ analysis depth, 2 cols ]         [ act on this first ]

The size of a tile is the claim it makes. The reading is the largest thing on the page,
the pillars and the depth it was read at qualify it, and the single highest-impact
finding is the one thing actionable without scrolling. That last tile takes the first
row of `repo.issues` — already ranked by the scorer — rather than re-sorting, so it
cannot disagree with the table below it.

WHAT I DID NOT TAKE. The HR shot features a dark card among light ones for emphasis;
inverting a tile on an INK surface makes it recede, which is the opposite of featuring
it, so the featured tile is raised with a brighter face and an edge instead. The
time-tracker dial, avatar card and the hatched "60%" segment measure nothing — they are
compositional filler in a mockup, and there is no data behind them here.

Deferred rather than faked: the metric cards in both shots carry a delta chip
("↑36.8% vs last year"). "Is this getting better or worse" is the most valuable thing
on that pattern, and CodeGraph has the history for it — but only behind the timeline
endpoint, and a second fetch on the overview to render one chip is a trade worth making
deliberately, not on the way past. `latestRunCoverage` exists; a previous-score query
does not.

BUG FOUND AND FIXED IN THE DIAL: the arc's natural gap sits at 135°, so my -135°
rotation carried it to the TOP and it read as a broken ring rather than a gauge. +45°
puts it straight down, symmetric about the vertical. Caught by looking at it.

Verified: dial renders on both pages, one per page, gap at the bottom; bento stacks to
one column at 390px with 0px overflow; typecheck; lint; 957/957; boundaries 323;
depcruise 0; web build.
… servers

The bundle was broken in three independent ways and nothing caught it, because
`verify.ts` only checked that directories existed:

- electron-builder could not resolve the hoisted electron, so `pack:mac` failed
  outright. Pinned `electronVersion`.
- `files: ["build/**/*"]` packed the Next standalone tree into app.asar, but
  `config.ts` resolves it at `process.resourcesPath/standalone` — and
  electron-builder silently drops nested `node_modules`, so ZERO of them shipped,
  including the 257MB @Anthropic-AI native binary. Now `extraResources`.
- `static/` was never packaged at all, so the splash screen 404'd on boot.

Lifecycle, all reachable and none covered by the existing tests:

- a second `spawnServer` dropped the handle to the first child, which then held
  its port for the lifetime of the machine
- an unhandled 'error' event from a failed spawn took down the main process
  instead of showing the error screen
- `http.get` with no deadline never settled against a server that accepts the
  connection and then stalls, so `waitForHealth`'s own timeout was never re-checked
- `stop()` returned before the child had exited
- one crash could fan out into several live Next processes, because both the
  child's exit AND the health check giving up started a new attempt
- a restart pending at quit spawned a server with nothing left to shut it down
- the navigation guard was a prefix test, not an origin check:
  `http://127.0.0.1:41000@evil.com/` passed it and inherited the preload's
  filesystem access

Verified end to end: packaged .app boots, serves, shuts down with no orphan.
fsx — `resolveSafe` let a DANGLING symlink escape the workspace. realpath fails
identically for "absent" and "dangling", so the ancestor walk fell back to the
parent (the root) and passed — but open(2) FOLLOWS a dangling symlink and creates
the file at its target. Also adds a hop budget so a symlink cycle is refused
rather than looped.

persistence — three races, all reachable on the shipped image, which runs the web
tier and apps/worker against one SQLite file:
- busy_timeout was set AFTER journal_mode, so no busy handler existed for the
  pragma that needs one; and a journal_mode change never invokes it anyway. 5 of 6
  concurrent boots died with "database is locked".
- runMigrations computed its pending list outside the write lock, so both
  processes applied the same migration and the loser hit a UNIQUE violation.
- enqueueJob did SELECT-then-INSERT, so the loser of that race got a 500 for
  exactly the double-submitted POST the idempotency key exists to absorb.

persistence — a poison job was re-leased forever: only failJob enforced
max_attempts, and a worker that dies never reports. It sorted oldest-first, ahead
of every healthy job, so the queue stopped draining behind it.

vcs — `log()` interpolated its count into an argv token, and the callers are HTTP
routes passing `Number(searchParams.get("limit"))`. `limit=-5` produced `git log
--5`, a 500 for a merely malformed query. Now clamped, which also caps an
unbounded buffer of history into memory.

Tests: the concurrency ones spawn real processes behind a start barrier; every
new test was confirmed to fail against the pre-fix source.
/api/index accepted a localPath and indexed it with no containment check, while
/api/browse refused the same path outside CG_LOCAL_ACCESS_ROOT. Indexing is the
STRICTLY more powerful capability — browse discloses directory names, indexing
walks the tree and makes file CONTENTS readable through the repo's fs/search/
editor endpoints — so the boundary was enforced on the weaker entry point only.
The check now lives in lib/localAccess.ts and both callers share it. It also
stopped refusing legitimate not-yet-existing paths wherever the root crosses a
symlink (every macOS /var).

- SSE progress stream threw ReferenceError for any client attaching to an
  ALREADY-FINISHED job: finish() cleared a timer declared below it, in its TDZ.
  That is the normal outcome of a fast index and of every reconnect.
- The trash route echoed raw ENOENT messages carrying absolute server paths,
  while the fs route beside it sanitises. Same policy now.
- The settings save could hang forever: fetch has no default timeout and the
  route passed no signal, though the helper already accepted one.
- OAuth returnTo was validated only where the cookie was WRITTEN. new URL()
  honours an absolute URL, so a cookie holding https://evil.com would redirect a
  freshly signed-in user off-site. Re-validated at redirect time.
historicalAnalysis -> evolutionEngine -> graphDiff -> historicalAnalysis, and
evolutionEngine <-> narrativeAgent. The call chain is one-way and legitimate; what
made it a cycle is that each module also had to NAME the types the others produce.
Types carry no runtime edge, so hoisting the shared vocabulary to a leaf module
(gitops/types.ts) breaks all three without moving a line of logic.

The dependency-cruiser known-violations file is now empty, so a new cycle fails
the build instead of being recorded.
`npm run boundaries` failed for anyone who had packaged the desktop app: dist-bin/
was not in SKIP_DIR_NAMES, so the checker linted Next's generated server.js and
reported its process.env reads as violations.
Measured before writing any of it: the landing page alone rendered 22 distinct
font sizes (12.5, 13.5, 14.5, 15.5, 16.5, 20.8, 23.2, 43.68, 49.6...), the source
carried 25 across 40 components — 17 arbitrary px, seven at half-pixels — and
spacing came off 20 ad-hoc rungs of Tailwind's linear ramp. Twenty-five sizes is
not a hierarchy; it is noise that happens to be legible, and it accumulated one
locally-defensible text-[13.5px] at a time.

The ladder steps by sqrt(phi) so every SECOND step is exactly phi: pure phi
(16 -> 26 -> 42) offers nothing between body copy and a section heading, which a
dense instrument UI needs. Eight sizes replace twenty-five. Values are rounded to
whole pixels — the ratio survives rounding, half-pixel type does not survive a
hinting engine. Space uses the same progression and the same numbers. Measures are
terms of one series: 272 -> 440 -> 712 -> 1152 -> 1864.

Structure:
- ONE page frame on every route. There were two (1152 marketing, 1440 repo), so
  content jumped sideways on navigation. Now .shell everywhere: content's left
  edge is identical on all twelve routes, it fills the viewport to the 1864 cap,
  and the gutter climbs the ladder with it (26 -> 42 -> 68).
- The hero is a golden section (measured 634/392 = 1.618); code intelligence is
  inverted (1 : 1.618) because the detail pane is the primary term and an even
  split starved it.
- A measure belongs to a SIZE, not a block: 712 is 89ch at 16px but 142ch at 10px,
  so prose takes the series term matched to its rung.

Also fixed en route: --spacing-md shadows Tailwind's max-w-md, which resolved to
16px and pushed every page 8px wide at 768; a <select> sizing to its widest option
(527px inside a 390px viewport); the mobile rail missing min-w-0 (232px of
overflow); and the index page's segmented control stretching the full card instead
of hugging its three labels.

The report rail collapses to icons (272 -> 68, main 1134 -> 1338) with the active
section still chartreuse, labels removed from the DOM rather than clipped, and the
state read from localStorage with useSyncExternalStore.

design-system.test.ts makes the ladder a property of the repository: 164
assertions that no arbitrary size, off-ladder step or shadowed measure comes back,
and that the series is still golden. That last group caught a real 5% error in the
first draft of the ladder.
Two halves of one workflow. A finding names a file and a line and then makes you
go and find them yourself, which is the one step of this the product can just do.

- Every row in the report's findings table gets an open-in-editor control linking
  to `?file=…&line=…`. Disabled with an explanation when there is no live
  workspace, rather than linking to an empty state.
- The editor grows an Issues panel: all findings, grouped by FILE and ordered by
  their worst severity. The report already ranks by severity — that answers "what
  should I look at first"; this answers "what is wrong with the file I am in", and
  the file is also the unit the editor opens.

Three real bugs surfaced building it, each found by driving the thing:

- `openFile` read `tabs` from its closure and appended after an await, so two
  calls for the same path both passed the "already open?" guard. StrictMode's
  double-invoked effect made that reproducible: the deep link opened the file in
  two tabs. Now guarded by an in-flight set and deduped inside the updater.
- The restore path replayed `openTabs` verbatim, so ONE duplicate ever written
  reopened forever, and its `setTabs(restored)` clobbered a deep-link arrival that
  landed mid-restore. Deduped and merged.
- Revealing the line failed in both directions. The editor is a dynamic import, so
  a reveal requested on arrival happened before Monaco existed — deep links opened
  the right file at line 1. And `key={activeTab.path}` remounts Monaco on every tab
  switch, so a cross-file reveal landed on the dying instance — clicking an issue in
  another file switched tab and stayed at line 1. The request now carries its path
  and is applied by whichever instance owns it.

Verified in the browser: arriving at tasks.py?line=215 centres line 215; clicking a
correctness.js:86 issue from that same panel switches tab and centres line 86; no
duplicate tabs; no overflow at 390/768/1440.
`syntacticSpans` returns [] for anything the TypeScript parser cannot read, and
`detect.ts` reads an empty span list as "everything is code" — so `context:
["comment"]` and `context: ["code"]` were unenforced on every non-TS language.

Reproduced before fixing: a Python file whose only content is a docstring reading
"an interactive eval() is available" and a string containing "# TODO" produced TWO
findings, one of them `Use of eval()` at SEVERITY 5. The byte-identical TypeScript
produced none.

The module's own comment refuses to lex non-TS languages, on the grounds that a
wrong span suppresses a real finding. That reasoning is correct about JavaScript
and only about JavaScript — regex-versus-division, template substitution and
`rescanTemplateToken` are the hazards it names, and none exist in Python, Go,
Java, Ruby, C#, Rust, C, C++, Kotlin, Scala, Swift, Shell or PHP. Declining to lex
was not neutral; it took 100% of the risk in the other direction.

`lexicalSpans` covers those grammars; `spansFor` picks parser or lexer. A language
with no rules still returns [] and still fails open, so nothing is silently
swallowed.

Measured on real corpora, not fixtures:
- psf/requests and pallets/flask: UNCHANGED (88 -> 88). No false negatives.
- psf/black: 279 -> 275, and all four are unambiguous false positives:
  - literals.py:4 — `Use of eval()`, severity 5, on the module docstring "Safely
    evaluate Python string literals WITHOUT USING EVAL()". The one file in the
    repo whose stated purpose is not using eval.
  - test_black.py:1898,2185,2188 — `# type: ignore` inside string literals.

11 new tests assert the failure direction the module warned about: an apostrophe
in a comment must not swallow the file, an unterminated single-quoted string must
not run past its line, an escape must not close a string early, a triple-quoted
docstring is one span.

Found by the research in docs/design/DETECTION_RELIABILITY.md, which is added here
along with docs/design/COMPETITIVE_LANDSCAPE.md.
…l-system

Hardening pass + one golden ratio for the whole surface
@archdex-art
archdex-art merged commit b05a03a into main Aug 2, 2026
3 checks passed
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.

1 participant