pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
166 lines (138 loc) • 11.2 kB
Markdown
# ADR 0008: one `TurnEndLane` interface, and what stays in the composer
## Status
Accepted — 2026-09-23
## Context
`clients/runtime-turn.ts` was 4,654 lines with fourteen delivery lanes written
inline in `handleTurnEnd`. Each lane read its own store, applied its own
freshness/disposition policy, and formatted its own section into
`blockerParts`/`advisoryParts`, so the composer stated every lane's rules and no
lane could be read, tested or changed on its own. #1892's third slice deepens
the composer into a thin orchestrator over lane modules that share ONE
interface, one lane per round, with no agent-facing change.
The constraint that shapes the interface is #3264's fold: the turn-end scanner
stores share ONE `gateFindingsByPathFreshness` call, so a path two stores cite
is `stat`'d once, spends one stat budget, and writes one bounded decision
record per delivery instead of up to six. A lane that gated itself would
re-split that pass the moment a second lane existed.
The second constraint is the delivery-surface registry
(`clients/finding-delivery-gate.ts`): its `@delivery-surface:` seam scan reads
exactly `clients/runtime-turn.ts` and `tools/lens-diagnostics.ts`. Code that
renders an agent-facing section from a file that scan does not read would be an
unregistered surface, invisible to the mechanism built to enumerate them.
## Decision
**The interface** (`clients/turn-end/lane.ts`) is three stages, and nothing else — no lane id, because nothing would read one:
| Stage | Signature | Owns |
|---|---|---|
| collect | `collect(ctx) → Promise<S>` where `S` maps STORE name → `FindingFreshnessSource` | which caches to read, classification, the per-store `citedPath`/`scannedAt`/`onMissing` policy. Structured rows with source identity, never a rendered string. Calls no gate. |
| gate | `gate(gates: TurnEndLaneGates<S>, ctx) → Kept` | the policy the shared pass cannot apply: dispositions through `filterFindingsByDisposition` (the seam in `clients/dispatch/finding-policy.ts`, ADR 0004), plus any per-store existence/lifecycle contract. `Kept` is lane-private. |
| render | `render(kept, ctx) → TurnEndLaneParts` | the sections, the display cap, the tier each section belongs to, the per-store suppressed counts, and the location keys it delivered. Returns sections; never pushes. `TurnEndLaneParts` carries a field per tier the composer actually pushes and no others — an unread field is a side channel that drops a lane's output, so the advisory tier joins the type with the lane that renders one. |
`TurnEndLaneContext` is the whole window a lane gets onto the turn: `cwd`, the
hook's `signal`, `readScannerCache` (memoized per turn by the composer) and
`peekActionableWarnings`.
**Writers by axis** — what the composer keeps, and why it cannot be a lane's:
| Axis | Owner | Why |
|---|---|---|
| Path-freshness pass (stat memo, stat budget, `finding_dead_path_drop` / `finding_stale_line_demote` rows) | composer | A cross-lane resource. Per-lane gating is #3264 undone: N stat passes, N budgets, N records for one filesystem fact. A lane only DECLARES sources. |
| One read per scanner store per turn | composer (`readScannerCache` memo) | The trivy store is read by the secrets lane and by the CVE/license tiers. Two envelopes of one store in one delivery is the parallel-store shape, and the TTL boundary can fall between the reads. |
| Disposition application | lane | Per-store anchor identity; only the lane knows which `ProjectDiagnostic` adapter anchors its rows. |
| Tier order, `blockerParts`/`staleSecretParts`/`advisoryParts` accumulation, the `@delivery-surface:` tags | composer | The tags are what enumerates the surfaces; the scan reads the composer. |
| Suppressed-by-disposition notice | composer, fed per lane | One bounded notice per turn (#1616), with per-lane attribution. |
| Turn signature dedupe, `turn-end-findings` cache write, git-guard record, message cap, turn telemetry | composer | Delivery-event properties of the whole turn, not of one lane. |
| Delivered location keys | lane produces, composer consumes | One secret is reported once across tiers (#131 Mode 3). |
**Registry evidence.** A surface whose render moved into a lane keeps its
registry `file` as `clients/runtime-turn.ts`, because that is where its tagged
push seam and its gate call live. Its evidence literal is repinned from the
partition it used to read (`gitleaksGate.live,`) to the gated store object the
composer hands the lane (`gitleaksGate,`). That is a weaker pin — it no longer
states which partition feeds which tier — and the loss is recorded rather than
hidden: the live/stale split is a lane rule now, pinned behaviourally by
`tests/clients/runtime-turn-finding-freshness.test.ts` and the witness goldens.
What the pin still proves is what the registry exists for: the rows came out of
a real gate call, not a hand-built object.
**Lanes that do not fit, and are not being made to.** Test-runner findings
(generation/retirement state, a verdict rather than diagnostics, its own
provenance validation at read), the git-guard latch (a durable second
writer/consumer with a commit-gate audience) and the agent nudge (session
read/edit relevance, no findings and no freshness axis at all) stay outside
this interface. So do the package-pinned trivy CVE/license tiers: they have no
cited path, so their `collect` would declare no freshness source and their
honesty mechanism is the age label, not the gate.
**One lane is one delivery contract, not one store.** The secrets lane
(`clients/turn-end/lanes/secrets.ts`) owns BOTH secret stores, because #131
Mode 3 collapses gitleaks, trivy-secrets and ast-grep by LOCATION into one
blocker section with combined provenance, and the demoted tier merges the same
two stores. A "gitleaks-only" lane would leave every rendering rule in the
composer and move only a cache read.
## Amendment — 2026-09-23: the advisory tier joins `TurnEndLaneParts`
`TurnEndLaneParts` gains ONE field, `advisoryParts?: readonly string[]`, with
the govulncheck lane (`clients/turn-end/lanes/govulncheck.ts`). This is the
amendment the Decision above anticipated ("the advisory tier joins the type
with the lane that renders one"), not a widening of the rule.
**Why it was withheld until now.** The rule is "a field per tier the composer
PUSHES, and no others", because a field the composer does not read is a side
channel that silently drops a lane's whole output (AGENTS.md shape 5). The
secrets lane renders no advisory, so an `advisoryParts` shipped with the
interface would have been exactly that: a slot a later lane could fill and lose.
The field, the composer's push and the surface's registry id therefore move
together, in one round, as one claim.
**What the composer pushes it into.** `handleTurnEnd`'s `advisoryParts` array
(`clients/runtime-turn.ts`), under the existing tag
`// @delivery-surface: runtime-turn:govulncheck-advisory`, at the same position
in the tier order the inline block occupied. The tag is what makes the tier a
registered delivery surface rather than a string, and
`tests/config/turn-end-lane-boundaries.test.ts` already listed `advisoryParts`
in the tier-push rule it reds on — so a lane that pushes its own advisory was
forbidden before the field existed, and still is.
**What did NOT change.** Still three stages; still no lane id; still no fourth
method. The `runtime-turn:govulncheck-advisory` registry entry keeps its `file`,
its `gated` mode and its evidence literal `scannerGates.govulncheck` unchanged
— the composer still binds that gate arm itself and hands it to the lane, so
unlike the secrets repin nothing was weakened here.
**The interface question this lane was asked and did not need to answer.** The
brief asked how a lane declares "no freshness source" honestly, since a
package-pinned row (trivy CVE/license) has no path to stat. The state table is:
| Lane's store | has a cited path | honesty mechanism | fits the interface today |
|---|---|---|---|
| govulncheck | yes (first trace frame with a filename) | the shared gate, `onMissing: "demote"` | yes — this round |
| gitleaks / trivy-secrets | yes (`finding.file`) | the shared gate, `onMissing: "drop"` | yes — #3269 |
| trivy CVE / license | no — pinned by package, not by file | `formatCacheAgeLabel`, the registry's `labeled` mode | NO |
| a finding whose `citedPath` returns `undefined` for SOME rows | mixed | the gate leaves the uncited row live and gates the rest | yes — already handled per row |
The last row is the one that matters for "no freshness source": the gate already
takes `citedPath` returning `undefined` as "leave live", per FINDING, so a lane
with a partially-uncited store needs nothing new (the govulncheck witness pins
it: `GO-2026-0103` has an empty trace and renders by module). A store where
EVERY row is uncited is a different animal — it is not a gated lane at all, it
is a `labeled` one, and giving it a `sources: {}` `collect` would let it pass
the composer's gate with an empty declaration and look gated in the registry
while nothing checked anything. That is the second exception the brief said not
to bend the lane around, so it is not added: the labeled lanes need a `label`
stage (an age string the composer renders into the header) before they can be
extracted, and that is the design decision of the round that extracts one.
## Consequences
- The composer no longer states a govulncheck rule (2026-09-23): 4,438 →
4,403 lines, and the six rules the block held (the `demote`/first-frame
freshness declaration, the both-arm disposition anchor, the stale-line
withholding and marker, the module/package fallback, the fix hint and the
display cap) are stated once, in the lane.
- The composer no longer states a secrets rule: 4,654 → 4,438 lines, and the
ten rules the block used to hold (cache key, classification budget and
fail-open, blocking filter, freshness declaration, two disposition anchors
over both arms, location dedupe, ast-grep enrichment, display cap, both
preambles, demoted-row identity) are stated once, in the lane.
- Extracting a lane moves code out from under two mechanisms that read only
`clients/runtime-turn.ts`, so `tests/config/turn-end-lane-boundaries.test.ts`
walks `clients/turn-end/` and reds if a lane pushes into a tier or calls the
freshness gate itself. Both rules name the recurrence they prevent.
- Registrations move with the code: the `bounded()` call-site registry entry,
the one-hop unbounded-await pin, and the delivery-surface marker exemption
all name the lane file now. A lane is a new file every directory-scanning
governance suite sees.
- The next lanes are extracted one per round against this interface. A lane
that needs a stage this interface does not have is a finding to report, not a
fourth method to add quietly.
## Links
- Umbrella: #1892. Previous slices: #3264 (the shared freshness pass), #3269
(this interface and the secrets lane).
- Witness convention: docs/adr/0007-end-to-end-witness-per-seam-slice.md.
- Disposition seam: docs/adr/0004-disposition-policy-seam.md.
- Registry and evidence rules: `clients/finding-delivery-gate.ts`.