You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
v2 is forty tickets of new capability landing on a codebase whose seams were placed for v1's shape. Four consequences are already visible, and each one costs an Operator or a Listener something real:
The suite is green about an Instance that is not the one that boots. The harness re-implements twelve of the binary's thirteen assembly steps by hand and omits two entirely — no Retention sweeper, no operator log sink. Nothing in-process covers how configuration becomes a running Instance.
Tickets already written will break features already shipped, silently.URL-addressable everything #61 moves search state into the URL, which rebuilds the filter object and reorders its keys — permanently disabling the page-ahead Prefetch across archive page boundaries #32 shipped, with no error and no failing test. The Delay policy #73's Delay stores a Call early and emits it late, so the Backfill cursor skips it and a Listener reconnecting never receives the Call a safety policy delayed.
Adding anything costs a ritual. One configuration setting takes nine to twelve edits across four files, only two of which any test holds together. Instance identity and guardrails #77 alone adds roughly eight settings.
The panel cannot meet the acceptance criteria written for it.Talkgroups panel at county scale #57 requires 400+ rows scrolling smoothly while audio plays; today every row re-renders several times a second precisely because audio is playing.
None of this is v1 being wrong. It is v1's seams meeting v2's demands, discovered by grilling an architecture review against the v2 ticket list one finding at a time.
Solution
Thirteen deepening changes — two shipped against v1, eleven as v2 tickets that gate the feature work depending on them. Every one replaces a shallow interface with a deep one: fewer things a caller must know, more behaviour behind them, and a seam at the interface rather than below it.
The programme also closes two terminology collisions and one domain gap found along the way, and adds four terms to the ubiquitous language (Admission, Feed down, Backfill, Run) — already written to CONTEXT.md.
User Stories
As an Operator, I want the test suite to exercise the same Instance my binary boots, so that a green run means my Retention sweeper and my log sink actually start.
As an Operator, I want my S3 secret to be unloggable by construction, so that no future debug line can leak it.
As an Operator, I want a delayed Call to reach a Listener who reconnects, so that a safety policy does not silently drop traffic.
As an Operator, I want the status page to show every worker's queue depth, so that "is it healthy" has one answer.
As an Operator, I want a rejected ingest to be countable by reason, so that a recorder misbehaving is visible as a number, not only as a log line.
As an Operator, I want a 5xx to hand me an identifier I can search my logs for, and one word for it, so that "ref" does not also mean a talkgroup number.
As an Operator, I want boot to fail loudly on a setting I mistyped, including in .env, so that a typo is not silently a default.
As an Operator on a Raspberry Pi, I want a Listener reconnecting to cost six database round-trips rather than six hundred, so that a phone unlocking does not stall my scanner.
As an Operator, I want to add a setting in one place, so that shipping a new option is not a nine-site ritual.
As a Listener, I want the screen to tell me why the live feed is not playing, so that I can tell a deliberate off from a dropped connection.
As a Listener, I want the controls to be disabled for one reason I can see, so that a dead button is explained rather than mysterious.
As a Listener, I want my archive playback to keep rolling across a page boundary after I change the URL or clear a filter, so that page-ahead does not quietly stop working.
As a Listener with 400+ talkgroups, I want the panel to scroll smoothly while audio plays, so that the app is usable at county scale.
As a Listener, I want my Avoid countdown to survive a reload, so that a timer is not lost with the page.
As a Listener with a Priority talkgroup, I want a full queue to drop routine traffic before mine, so that the one instruction I gave is honoured.
As a Listener, I want to be told when my Backfill was truncated, so that a gap in my history is knowable rather than invisible.
As a Contributor, I want to make an I/O call fail by naming it, so that testing an error arm does not mean dropping a table or writing plpgsql.
As a Contributor, I want to assert an ingest decision without a socket and a multipart body, so that the dedup window can be property-tested.
As a Contributor, I want to await a worker becoming idle, so that proving something did not happen is deterministic rather than a sleep.
As a Contributor, I want a new handler to get its logging, status mapping and error redaction by construction, so that I cannot ship the drift push.rs already shipped.
As a Contributor, I want to open a scoped live connection in a test, so that ADR-0008's access-scope promise is proven rather than asserted about dead code.
As a Contributor, I want a new subsystem to have one obvious home for its configuration, so that I do not invent a fourth pattern.
As a Contributor, I want the live-feed protocol testable as a table of state and event, so that reaping is not asserted with a wall-clock sleep.
As a Contributor building Dirwatch, I want to reuse the ingest pipeline without an HTTP request to answer, so that file ingest is not a second implementation.
As a Contributor building the DVR, I want the archive Run to already exist, so that gapless oldest-first playback is a configuration rather than a rewrite.
As a Contributor, I want "Catch-up" and "Backfill" to mean exactly one thing each, so that a ticket about one is not implemented as the other.
Implementation Decisions
Instance assembly. One module takes a resolved configuration and returns a running Instance: base directory, database, log sink, all three credential provisionings, store, workers, router, listener. Credential sources become explicit inputs rather than process-environment reads inside the function, so the harness gets a genuinely provisioned Instance — a real generated Web Push identity, a real .env written into its own temporary directory. AdminAuth::locked and a disabled Push stop being harness knobs and become what they are: provisioning outcomes. A small second parameter carries only what genuinely varies — the object store (filesystem, S3, fault decorator) and the clock. Everything else is a configuration value, retiring six of the harness's eight builder knobs. The returned handle carries the bound address, the database, the store and a shutdown.
Configuration. The section type is the subsystem's type. The four pure field-for-field copies disappear; the six that convert seconds to durations share one deserialization helper. Uniformity is deliberate — a rule with four exceptions is not applied. The environment-coverage test becomes derived and exhaustive rather than a hand-written case list where an omission is silently untested, and .env.example comes under the same table.
Ingest. Resolve, then decide purely, then perform. One pass resolves the System, the Talkgroup, key authorization and the Calls inside the dedup window; a pure function turns those facts and the ingest configuration into an Admission; a perform phase writes the audio object, the row, the live-feed publish and the enhancement offer. Enhancement scope stays resolved after the insert, because auto-populate may have just created the System or Talkgroup and a fresh row's NULL is the value that inherits. The Admission's reason is one closed vocabulary with the machine-readable slug and the recorder-facing wire detail derived from the same value — the rdio response strings stay byte-identical, and a new rejection path cannot skip the funnel because there is nowhere else to obtain a reason. The recorder's facts separate from the stored-object facts, so "write the object before the row" becomes a property of the types rather than a comment.
Request handling. Handlers return a result rather than a response, and stop naming status codes, JSON serialization or tracing. Two closed vocabularies, deliberately not one: a Stage says where the code was when a 5xx happened (Operator diagnostics), a Reason says what the caller did wrong. Conflating them would make "rejections by reason" ambiguous between "an Operator must act" and "a Recorder sent something we refused" — the split ADR-0011's levels exist for. Redaction stays in the request middleware, never in the response conversion, because it must cover any 5xx including one a route produces some other way. The 5xx correlation identifier is renamed to the request id it already is, resolving a collision with the glossary's Ref.
Workers. Every worker returns the same handle — join, shutdown, an idle signal, and a depth or health reading. The reading is an Operator-facing surface the status page consumes, not test scaffolding. Loop bodies stay hand-written; only the envelope is shared. Separately, the enhancement worker and the database gain seams at their interfaces rather than below them: a narrow port for the worker, and a connection decorator that refuses a named statement. Both retire the fault-injection machinery built because the seam was in the wrong place.
Live feed. The connection becomes a state machine over an event enum returning actions, with the socket demoted to an adapter. The Backfill cursor becomes an emission sequence distinct from the Call's identifier — a protocol change, because a delayed Call is stored early and emitted late, and a cursor over storage order skips it. Access scope becomes an input to the connection rather than a hardcoded value, so it is reachable and provable in tests while still resolving to unrestricted in production until access codes exist.
Archive reads. One module answers "a filtered window of the Archive" with the page and its total, owning ordering, batching and count consistency. The Backfill path stops issuing a query per Call.
Client. The feed's state becomes two derived layers: why the live feed is not delivering (live, Feed off, playback mode, Feed down) — read by the banner, the LED and the reducers' guards — and control enablement derived from that plus what is playing and what is in history. The archive Run becomes one module whose ordering is a parameter, with an identity it compares structurally rather than a serialized key, so a rebuilt filter object cannot silently end it. The Talkgroups panel derives once, memoized, behind a referentially stable selector, with each row carrying its key, selected state, avoid deadline and tap action. Avoid expiry becomes lazy — a timestamp compared when audibility is asked — so it survives a reload for free and the interval leaves the socket component. The listening queue gains an ordering policy, and truncates in the same order it plays.
Domain language. Four terms added and two amended in the glossary: Admission, Feed down, Backfill, Run; Catch-up now excludes Backfill explicitly, and Listening queue now states what the cap drops.
Testing Decisions
A good test here asserts external behaviour through a module's interface. The programme's premise is that several behaviours are currently only reachable by driving the whole application, and several failure arms only by damaging the schema — both are signs the seam is in the wrong place, and both are what these changes fix.
Existing seams are preferred and mostly retained. The in-process harness stays the workhorse; what changes is that it becomes an adapter over the real assembly rather than a parallel implementation of it, so "the harness wires the app the way the binary does" is true by construction.
The ingest decision becomes property-testable. The dedup window, near-miss adjacency, blacklist and patch membership become pure functions over resolved facts — the shape Keep-best dedup: one transmission, best copy, once #46's "property-tested" criterion needs, and the shape that would have killed the surviving +→* mutant the integration suite missed.
Handler outcomes become assertable without a socket, so the stage vocabulary can be matched exhaustively rather than pinned as five strings.
Failure arms become reachable by naming a call rather than by dropping a table, writing plpgsql, or parking a read between two statements. The dialect-specific helpers and the error-wording matcher retire with them.
Absence becomes provable. Awaiting a worker's idle signal replaces four bespoke pollers and the fixed sleeps that currently stand in for "nothing happened".
The live protocol becomes a table of state and event to actions — reaping, acknowledgement ordering, Backfill truncation and re-subscribe claim ordering included, with the socket adapter keeping a small number of integration tests.
Client behaviour moves out of whole-route rendering. Run transitions, feed status, panel derivation and queue ordering become unit-testable; route tests keep what they should — that a gesture dispatches and the markup renders.
Prior art already in the repo:Selection's pure algebra, Params' parametrized tables, the live feed's already-extracted broadcast and heartbeat decisions, and the retention sweep's injected clock. Each was extracted for exactly this reason and each says so in its own doc comment; this programme applies the same move where it was not made.
Both dialects and real object storage continue to gate, unchanged.
Out of Scope
Any new Listener- or Operator-facing capability. Every ticket here is behaviour-preserving except where a bug is named: the Backfill cursor's handling of delayed Calls, the queue cap's interaction with Priority, and the S3 secret's redaction.
Making the live-feed heartbeat an Operator-facing setting. It stays a clock, injected for tests.
Further Notes
Every finding was grilled against the v2 ticket list before being accepted, which is where most of the value came from — three of the four findings originally rated "worth exploring" were upgraded once it was clear what v2 would ask of them, and one apparent finding (the site entity) was withdrawn as wrong.
Documentation amendments this programme owes: ADR-0012 (where a resolved configuration is consumed), ADR-0001 (dedup and the orphan invariant keep their promise while changing shape), ADR-0011 (rules 3, 4 and 6 become structural), ADR-0009 (the fault-injection seam moves to the module's interface), ADR-0004 (the Backfill cursor). CLAUDE.md's fault-injection section and docs/agents/dual-dialect.md describe machinery that retires, and must be rewritten in the same change that retires it — stale instructions are worse than none, since every agent session loads CLAUDE.md first.
Parent
Sub-issue of #41 (Spec: Radio-Scout v2).
Problem Statement
v2 is forty tickets of new capability landing on a codebase whose seams were placed for v1's shape. Four consequences are already visible, and each one costs an Operator or a Listener something real:
None of this is v1 being wrong. It is v1's seams meeting v2's demands, discovered by grilling an architecture review against the v2 ticket list one finding at a time.
Solution
Thirteen deepening changes — two shipped against v1, eleven as v2 tickets that gate the feature work depending on them. Every one replaces a shallow interface with a deep one: fewer things a caller must know, more behaviour behind them, and a seam at the interface rather than below it.
The programme also closes two terminology collisions and one domain gap found along the way, and adds four terms to the ubiquitous language (Admission, Feed down, Backfill, Run) — already written to
CONTEXT.md.User Stories
.env, so that a typo is not silently a default.push.rsalready shipped.Implementation Decisions
Instance assembly. One module takes a resolved configuration and returns a running Instance: base directory, database, log sink, all three credential provisionings, store, workers, router, listener. Credential sources become explicit inputs rather than process-environment reads inside the function, so the harness gets a genuinely provisioned Instance — a real generated Web Push identity, a real
.envwritten into its own temporary directory.AdminAuth::lockedand a disabled Push stop being harness knobs and become what they are: provisioning outcomes. A small second parameter carries only what genuinely varies — the object store (filesystem, S3, fault decorator) and the clock. Everything else is a configuration value, retiring six of the harness's eight builder knobs. The returned handle carries the bound address, the database, the store and a shutdown.Configuration. The section type is the subsystem's type. The four pure field-for-field copies disappear; the six that convert seconds to durations share one deserialization helper. Uniformity is deliberate — a rule with four exceptions is not applied. The environment-coverage test becomes derived and exhaustive rather than a hand-written case list where an omission is silently untested, and
.env.examplecomes under the same table.Ingest. Resolve, then decide purely, then perform. One pass resolves the System, the Talkgroup, key authorization and the Calls inside the dedup window; a pure function turns those facts and the ingest configuration into an Admission; a perform phase writes the audio object, the row, the live-feed publish and the enhancement offer. Enhancement scope stays resolved after the insert, because auto-populate may have just created the System or Talkgroup and a fresh row's
NULLis the value that inherits. The Admission's reason is one closed vocabulary with the machine-readable slug and the recorder-facing wire detail derived from the same value — the rdio response strings stay byte-identical, and a new rejection path cannot skip the funnel because there is nowhere else to obtain a reason. The recorder's facts separate from the stored-object facts, so "write the object before the row" becomes a property of the types rather than a comment.Request handling. Handlers return a result rather than a response, and stop naming status codes, JSON serialization or tracing. Two closed vocabularies, deliberately not one: a Stage says where the code was when a 5xx happened (Operator diagnostics), a Reason says what the caller did wrong. Conflating them would make "rejections by reason" ambiguous between "an Operator must act" and "a Recorder sent something we refused" — the split ADR-0011's levels exist for. Redaction stays in the request middleware, never in the response conversion, because it must cover any 5xx including one a route produces some other way. The 5xx correlation identifier is renamed to the request id it already is, resolving a collision with the glossary's Ref.
Workers. Every worker returns the same handle — join, shutdown, an idle signal, and a depth or health reading. The reading is an Operator-facing surface the status page consumes, not test scaffolding. Loop bodies stay hand-written; only the envelope is shared. Separately, the enhancement worker and the database gain seams at their interfaces rather than below them: a narrow port for the worker, and a connection decorator that refuses a named statement. Both retire the fault-injection machinery built because the seam was in the wrong place.
Live feed. The connection becomes a state machine over an event enum returning actions, with the socket demoted to an adapter. The Backfill cursor becomes an emission sequence distinct from the Call's identifier — a protocol change, because a delayed Call is stored early and emitted late, and a cursor over storage order skips it. Access scope becomes an input to the connection rather than a hardcoded value, so it is reachable and provable in tests while still resolving to unrestricted in production until access codes exist.
Archive reads. One module answers "a filtered window of the Archive" with the page and its total, owning ordering, batching and count consistency. The Backfill path stops issuing a query per Call.
Client. The feed's state becomes two derived layers: why the live feed is not delivering (live, Feed off, playback mode, Feed down) — read by the banner, the LED and the reducers' guards — and control enablement derived from that plus what is playing and what is in history. The archive Run becomes one module whose ordering is a parameter, with an identity it compares structurally rather than a serialized key, so a rebuilt filter object cannot silently end it. The Talkgroups panel derives once, memoized, behind a referentially stable selector, with each row carrying its key, selected state, avoid deadline and tap action. Avoid expiry becomes lazy — a timestamp compared when audibility is asked — so it survives a reload for free and the interval leaves the socket component. The listening queue gains an ordering policy, and truncates in the same order it plays.
Domain language. Four terms added and two amended in the glossary: Admission, Feed down, Backfill, Run; Catch-up now excludes Backfill explicitly, and Listening queue now states what the cap drops.
Testing Decisions
A good test here asserts external behaviour through a module's interface. The programme's premise is that several behaviours are currently only reachable by driving the whole application, and several failure arms only by damaging the schema — both are signs the seam is in the wrong place, and both are what these changes fix.
+→*mutant the integration suite missed.Selection's pure algebra,Params' parametrized tables, the live feed's already-extracted broadcast and heartbeat decisions, and the retention sweep's injected clock. Each was extracted for exactly this reason and each says so in its own doc comment; this programme applies the same move where it was not made.Out of Scope
/rdio-scannersurface.siteentity: it is scaffolding for Ingest enrichment: recorder truth + duration on every Call #42, not dead surface.Further Notes
Every finding was grilled against the v2 ticket list before being accepted, which is where most of the value came from — three of the four findings originally rated "worth exploring" were upgraded once it was clear what v2 would ask of them, and one apparent finding (the
siteentity) was withdrawn as wrong.Documentation amendments this programme owes: ADR-0012 (where a resolved configuration is consumed), ADR-0001 (dedup and the orphan invariant keep their promise while changing shape), ADR-0011 (rules 3, 4 and 6 become structural), ADR-0009 (the fault-injection seam moves to the module's interface), ADR-0004 (the Backfill cursor). CLAUDE.md's fault-injection section and
docs/agents/dual-dialect.mddescribe machinery that retires, and must be rewritten in the same change that retires it — stale instructions are worse than none, since every agent session loads CLAUDE.md first.