Board: implement the work-first Now, Timeline, Releases, and Health views - #1000
Conversation
…iews Render the accepted B0 observation fixtures behind a compact work-first interaction model. The Board becomes a consumer of the frozen `code_mower.boardObservation.v1` contract; it never produces, resolves, or repairs one, and it adds no cloud field and no mutating control. - Read `.code-mower/board/observations/*.json` (or `--observations-path PATH`) through `board_observation.decode` and embed the survivors as `code_mower.boardObservations.v1` under `observations` in `/api/status`. Reading is bounded to 32 files per refresh; a record that fails the contract is dropped with the contract's own fixed diagnostic rather than widened. A missing directory reports "nothing recorded yet", which is a different statement from "no work". - Keep observations out of persisted local history: `_recordable_payload` drops the block, so `board record` and `--record-events` write the shape they already wrote. The cloud board-snapshot allowlist is untouched. - Organize the page as four semantic tabs over the one payload — Now, Timeline, Releases, Health — with persistent chrome carrying repository, version, snapshot time, the one next action, and observation freshness at every width. Completed campaigns move under Releases; connection, version, and process diagnostics move under Health. - Show work rows with the recorded safe reference, stage, assignments, last meaningful update, next action, and responsible role. Selecting a row exposes builder/review/CI/gate/merge/human-policy evidence as six independent readings, each naming its source and that source's freshness, so none can stand in for another. The gate publisher sits beside the `code-mower/gate` verdict and is labelled as publisher execution only. - Keep review requested, review observed running, stale review, changes requested, implementation complete, ready for human review, ready to merge, and merged distinct. - Make tabs and rows keyboard operable with visible focus and expanded state: rows are buttons carrying `aria-expanded`/`aria-controls`, arrow keys wrap across tabs and clamp across rows, and unselected panels carry `hidden` so they leave the accessibility tree. - Hold selection by opaque work identity (session, worktree, work id) rather than row position, so a refresh that reorders, adds, or drops rows leaves the operator's choice where it was. There is one detail region, rendered inside the selected row so phone widths follow the row, with CSS placing that same region adjacent to the list at desktop widths. - Announce a change only when a recorded fact differs — stage, reasons, route, PR identity, evidence state, measurements, run phase or basis, or a source's freshness, coverage or event time. `created_at`, `checked_at`, `observed_at` and `heartbeat_at` are excluded because they advance on every successful poll, so an unchanged poll announces nothing and collapses from Timeline. - Invent no totals: an unavailable measurement renders "not recorded", a partial one renders the value with the evidence it was counted from, and no ratio becomes a percentage or an ETA. Unknown states stay neutral and colour always carries a text label and a text cue. - Read liveness rather than infer it: a record whose sources are not all fresh, or that is older than ten minutes, is reported as a last observation and may not claim anything is running now. `no_work` reads as idle with complete coverage and names the fresh, complete source kinds; `unlinked` claims no stage and no route. - Keep primary actions read-only — open a recorded PR link, inspect a connection in Health, view recent changes. A locally observed PR number is never turned into a remote address the payload has not recorded. No merge, requeue, force-lease, cancel, retry, restart, cloud-schema or Slack-specific control, no form, and no non-GET request. `render_board_html` becomes a plain-text template with two substituted placeholders instead of an f-string, so the shipped CSS and JavaScript read exactly as the browser receives them and the tests execute the same text. Producer integration stays with #949: no session resolver, lifecycle producer, or local evidence correlation is added here. Closes #948 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: The observation integration permits incorrect cross-repository links and duplicate selections, and its file-size limit does not bound actual reads. Findings:
|
…und reads Three narrow fixes from the exact-head merge-authority audit of ac9ca53. Match repository identity before attaching a PR link. A custom observations directory can hold a record another repository produced, where the same pull request number means a different pull request; attaching this repository's URL to it on the number alone linked unrelated work. A link is now offered only when the record names this Board's own repository, and a foreign record is still shown for exactly what it is -- named as belonging to that other repository -- without a link being invented for it. Deduplicate linked work observations by their opaque identity. An older file and the file that replaced it are one work item, not two rows competing for one row id and one detail region. The newest observation wins, chosen on recorded `created_at` with the last meaningful update and then the row signature as deterministic tiebreaks, so which file the directory listed first cannot change what is rendered. Change tracking consumes the same deduplicated set, so reordering duplicates is no longer announced as news. The existing unlinked consolidation semantics are unchanged. Bound observation reads before JSON decoding. Each file is now read with a single request of at most MAX_BYTES + 1 bytes and an oversize file is rejected on that length, so the remainder is never loaded and never reaches the decoder. The rejection reuses the contract's own closed `invalid_contract` diagnostic, which names no path and repeats no value. Regression tests cover foreign-repository same-number link suppression, one row and one detail region per duplicated linked identity with the newest observation selected, and a bounded read that rejects an oversize file before decode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit findings addressed — new exact head
|
| File | Change |
|---|---|
src/code_mower/board.py |
+58 / −8 — all three fixes |
tests/test_board.py |
+168 — three regression tests plus a _RecordingHandle helper |
docs/board-data-contract.md |
+19 / −3 — one precise sentence per behavior change |
Focused validation (local)
| Check | Command | Result |
|---|---|---|
| Board views/reader | pytest tests/test_board.py |
131 passed, 11 subtests passed |
| B0 observation contract | pytest tests/test_board_observation.py |
16 passed |
| Release/doc hygiene | pytest tests/test_release_hygiene.py |
360 passed, 881 subtests passed |
| Lint | ruff check . |
All checks passed |
| Privacy scan | scripts/privacy_scan.py |
passed |
| Compile | compileall src scripts |
clean |
| Package workflow guard | scripts/guard_package_workflows.py |
passed |
| Release readiness | code_mower.migration release-readiness --json |
status: pass, 20 checks, 0 warnings |
The repository-wide suite was deliberately not run locally; GitHub CI owns the canonical full matrix.
State
Still a draft, not merged, not marked ready. needs-codex-audit retained — a new independent exact-head audit should run against 6d6e73fe8e23697f5afa9cdb3ddfb5cad2a484c7.
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Two P2 findings affect participant counts and consolidated source freshness when multiple observation files coexist. Findings:
|
…deduplicated set Two adjacent P2 consistency findings from the independent exact-head audit of 6d6e73f. Both are fixed by giving the work-first views a single shared normalization and deduplication step, so the work list, the participant summary and change tracking read the same observation set and cannot drift apart again. Deduplicate observations before summarizing participants. The participant strip walked the raw records on disk, so successive observation files of one work identity counted the same run once per file and went on reporting the phase that run had already left: one run seen dispatched and then running read as two runs, one of them still dispatched. Participants are now built from the runs the rendered rows were built from, resolved against those rows' own sources, so a superseded observation contributes nothing. Recompute freshness and update metadata when consolidating unlinked observations. Consolidating several unlinked observations of one repository merged their assignments and evidence items but kept the first record's freshness, age, last meaningful update and signature, so a fresh first file hid an unavailable source behind the very evidence being shown, a later recorded event was lost, and reversing the directory listing changed the row. The consolidated row is now read off one record recomputed from all retained evidence: per source id the worst freshness and coverage any retained file reported, carrying the newest time each of them recorded; the age of the oldest retained observation, or no claimed age at all when one of them records none; and one entry per run however many files observed it, kept as the worst-attested of those observations. Records within a group are ordered by what they record, never by the order the directory listed them, so the consolidated row is byte-identical under any input order. The frozen code_mower.boardObservation.v1 contract is untouched: consolidation is a presentation step over records the contract has already accepted, and the Board still never produces, resolves or repairs one. All three fixes from the previous audit round are preserved. Regression tests cover one run transitioning phase across two observations counting once at the newest phase under either file order, and consolidated unlinked observations that are byte-identical under reversal, that cannot hide an unavailable source behind a fresh first record, and that retain the later meaningful update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-2 findings resolved at
|
| Finding | Status | Fix | Regression test |
|---|---|---|---|
codex:35af9aa458ff662bf884 (P2, old board.py:2369) — participants summarized from raw records, so successive observation files of one work identity counted the same run once per file and kept reporting superseded phases |
Fixed | participantSummary() now takes the deduplicated rows, not the payload. Each row carries the runs it was actually built from, with each run's source freshness resolved against that row's own record, so a superseded observation contributes nothing |
test_a_run_that_changes_phase_across_observations_counts_once_and_newest |
codex:28cf2b6e857e7491c7e3 (P2, old board.py:2344) — consolidating unlinked observations merged assignments and evidence but kept the first record's freshness, age, last meaningful update and signature |
Fixed | The consolidated row is read off one record recomputed from all retained evidence: per source id the worst freshness and coverage any retained file reported, carrying the newest time each of them recorded; the age of the oldest retained observation, or no claimed age when one of them records none; and one entry per run however many files observed it, kept as the worst-attested of those observations. Group members are ordered by what they record, never by directory order | test_consolidated_unlinked_observations_recompute_freshness_and_update |
What the tests prove
test_a_run_that_changes_phase_across_observations_counts_once_and_newest — one run observed dispatched and then observed_running across two files of one identity renders one participant, one recorded run, in phase observed running, with dispatched absent; participants and worklist are identical under either file order.
test_consolidated_unlinked_observations_recompute_freshness_and_update — two unlinked observations of one repository, the first with a fresh source and the second whose run_registry source has gone unavailable while preserving a later recorded event:
- the rendered worklist is byte-identical with the records reversed;
- the row reads
last observed 30s agowith thebadcue and namesSource unavailable: run_registry.— the fresh first record cannot hide it; - the last meaningful update is
25s ago(the later event), not50s ago(the first record's); - one run, attested by the worst source:
assignments: claude unknownonce,run_registry, unavailable, unavailable coverage, and1 recorded runin the participant strip.
Both tests were confirmed to fail against 6d6e73fe and pass at c44b9137. Against 6d6e73fe the observed pre-fix output was 2 recorded runs with both dispatched 1 and observed running 1, and a consolidated row reading observed 30s ago (ok cue), last update: 50s ago, assignments: claude unknown; claude unknown, differing between the two input orders.
Contract and prior fixes
Consolidation is a presentation step over records the frozen code_mower.boardObservation.v1 contract has already accepted. No contract field, rule or diagnostic changes; the Board still never produces, resolves or repairs an observation. All three round-1 fixes (repository-identity PR link, linked-identity deduplication, bounded read before decode) are preserved and their tests still pass.
Every hunk in src/code_mower/board.py is inside the _BOARD_HTML page script. observations_payload(), the contract reader and the /api/status serving path are byte-identical to 6d6e73fe.
Focused validation at c44b9137ea62109e5846f3bb2c451b5532b1f3d8
| Check | Result |
|---|---|
tests/test_board.py |
133 passed, 0 skipped |
BoardWorkFirstViewTests |
21 passed (was 19) |
BoardPresentationTruthTests |
23 passed |
BoardObservationReaderTests |
6 passed |
BoardTests |
63 passed |
StatusCacheTests |
20 passed |
tests/test_board_observation.py (B0 contract) |
16 passed |
tests/test_board_lease.py |
4 passed |
| Board-dependent modules | 583 passed — test_release_hygiene 360, test_release_qualify 70, test_doctor_campaign_readiness 49, test_adoption_diagnostics 33, test_tracker_queue 27, test_file_locks 13, test_context_readiness 12, test_productivity_report 12, test_campaign_discovery 7 |
tests/test_cloud_client.py (cloud allowlist) |
68 passed (pytest; bare test functions unittest discover does not collect) |
ruff check . |
clean |
scripts/privacy_scan.py |
passed |
python -m compileall -q src scripts |
clean |
scripts/guard_package_workflows.py |
passed |
scripts/smoke_easy_mode.py --json |
status: pass |
git diff --check |
clean |
804 focused tests passed, zero failures, zero skips. The repository-wide suite was not re-run locally for this head; GitHub CI remains the canonical full matrix and is pending. The live Board smoke was run at 6d6e73fe and has not been repeated — the serving path is unchanged between the two heads.
This PR stays a draft and is not marked ready. needs-codex-audit is retained. Requesting a new independent exact-head audit against c44b9137ea62109e5846f3bb2c451b5532b1f3d8.
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Completed items are prioritized over blocked work, and periodic rendering loses keyboard focus on detail actions. Findings:
|
Row order reused the headline precedence in STATE_RULES, where "merged" is rank 0 because it is the truth that describes a merged item best. That made finished work the first row, and the first row is what an operator who has chosen nothing is shown -- so a board holding merged work and work awaiting approval opened on the merged item. Row urgency is now its own explicit ranking: blocked work, then work waiting on a named person, then work whose evidence cannot be trusted, then work in flight, with terminal work placed last explicitly rather than by falling off the end. Headline precedence is untouched, an unranked headline sorts between the ranked and the terminal bands, and the reference and opaque identity still break ties so an unchanged snapshot never reshuffles. The selected row's detail actions -- Open PR, Inspect connection, View recent changes -- were the only focusable controls on the page without ids, so every poll that replaced the row list dropped the keyboard to the document body. They now carry identities derived from the work they act on and the action's own name, which no row id or other action id can collide with, and each names its row as where focus should land if the action stops being offered. When that row is gone too, focus is left where the browser put it rather than handed to an unrelated control. Found in the same pass: activating a detail action that opens another view left focus on a control inside the panel the switch had just hidden, which browsers resolve by dropping focus to the body. Focus now moves to the tab for the view that was opened. Tests execute the shipped page against a DOM shim in which focus is a real question -- replacing a container's markup destroys what was in it -- and drive real clicks and keystrokes through the page's own handlers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third exact-head audit: both P2s fixed, plus one more found in the same passHead:
|
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Two P2 findings affect desktop layout and keyboard accessibility. Work-view tests passed; filesystem-dependent reader tests could not run in the read-only sandbox. Findings:
|
The desktop detail region was absolutely positioned against the work list and the list reserved a guessed 180px minimum, so a list of one or two rows was shorter than the panel beside it. The panel contributed no layout height at all, and everything after the list -- Work Now, Participants, and the sections below them -- was drawn underneath it. The interaction model is unchanged: there is still exactly one detail region, still rendered inside the selected row, so source order, selection, focus order and announcements are identical at both widths. What changed is the structure that places it. The row itself is now a two-column grid and the detail is one of its items, so it is in normal flow: the row, and so the list, and so the section, is always at least as tall as the detail, and no reserved height is guessed anywhere. Every row reserves the second column so the row buttons keep one width whichever row is selected, and the row's frame moves onto the button so an unselected row is not drawn around an empty column. Element ids were derived from the opaque work identity by replacing every character outside `[A-Za-z0-9_-]` with a hyphen. That is not injective, and the frozen contract admits the keys that prove it: unlinked work in `owner/re.po` and in `owner/re-po` are two distinct work items that collapsed onto one id. The document then carried a duplicate id, two rows claimed one `aria-labelledby` target, and a focus lookup after a refresh could land on the neighbour. The encoding is now injective for any key whatsoever -- a letter, digit or hyphen stands for itself and every other code unit, including the `_` that introduces an escape and code units outside ASCII, becomes `_<hex>_` -- so an id decodes back to exactly one key. Row ids, the detail region's `aria-labelledby`, the detail actions' ids and the focus-fallback lookups all read the same encoding. Tests execute both rules rather than asserting on the stylesheet text. A small box model cascades the shipped CSS onto a one- and two-row list and checks that the height the list reserves covers the detail's bottom edge; fed the rule this replaced, the same model reports the overlap, so the check has teeth. A second test holds the general invariant the defect broke: nothing the payload can grow is taken out of flow, the one exception being the clipped one-pixel live region. The id tests run the shipped encoder over colliding punctuation, non-ASCII, astral and long opaque keys, prove every id decodes back to its key, and drive two rows whose keys differ only in punctuation through selection, labelling and focus restoration across a refresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Work ordering depends on headline precedence, which can demote actionable or blocked work despite its recorded urgency. Findings:
|
Round-4 findings resolved at
|
| Finding | Fix | Regression test |
|---|---|---|
codex:1a94d9fd98d5f667aa33 — the desktop detail panel was absolutely positioned against the work list, which reserved a guessed 180px minimum, so with one or two rows the panel contributed no layout height and was drawn over the Work Now and Participants sections below it |
The detail region is still the single region rendered inside the selected row. What changed is the structure that places it: at desktop the row itself is a two-column grid and the detail is one of its items, so it is in normal flow. The row — and so the list, and so the section — is always at least as tall as the detail, and no height is guessed anywhere. Every row reserves the second column so the row buttons keep one width whichever row is selected, and the row's frame moves onto the button so an unselected row is not drawn around an empty column | test_desktop_detail_reserves_its_height_so_a_short_list_cannot_overlap, test_no_rule_takes_dynamic_content_out_of_flow_without_pinning_its_box, test_mobile_detail_follows_the_row_and_desktop_places_it_adjacent |
codex:85f9785c6a1d42702fa0 — element ids replaced every character outside [A-Za-z0-9_-] with a hyphen, so contract-valid identities that differ only in punctuation (unlinked work in owner/re.po and in owner/re-po) collapsed onto one id, giving a duplicate id, a shared aria-labelledby target and a focus lookup that could land on the neighbour |
The encoding is injective for any key whatsoever: a letter, digit or hyphen stands for itself, and every other code unit — including the _ that introduces an escape, and code units outside ASCII — becomes _<hex>_, so an id decodes back to exactly one key. Row ids, the detail region's aria-labelledby, the detail actions' ids and the focus-fallback lookups all read that one encoding |
test_element_ids_encode_every_work_key_injectively, test_keys_that_differ_only_in_punctuation_keep_separate_rows_and_focus |
The layout test executes a layout rule, it does not match stylesheet text
A small box model cascades the shipped stylesheet onto a one-row and a two-row list (selector matching, @media (min-width: 900px), max-height in vh, grid tracks, out-of-flow detection) and asserts that the height the list reserves covers the detail's bottom edge. Fed the rule this replaced, the same model reports the overlap — reserved 180px against a detail running on to 420px — so the check has teeth rather than merely passing. A second test holds the general invariant the defect broke: no rule takes content the payload can grow out of flow, the single exception being the visually hidden live region, which pins its own box to a clipped pixel.
Both fixes were confirmed to fail against 6c8ef916 and pass at a374728c: reverting the id encoding alone fails both id tests, and reverting the desktop rule alone fails all three layout tests, including every subtest of the executed model.
Targeted self-review requested by the audit
- Every absolutely positioned rule in the Board: implement the work-first Now, Timeline, Releases, and Health views #948 CSS. Only
.sr, the visually hidden live region, remains out of flow; it pinswidth:1px; height:1pxand clips, so it cannot overlap anything..chromeisposition:sticky, which stays in flow and reserves its own height. This is now an executed invariant, not an inspection. - Every DOM id construction and lookup. The page builds exactly three families of ids: view tabs and panels from the fixed
VIEWSlist, row buttons from the work identity, and detail actions from that identity plus a hyphen-free action name.keySlugwas the only sanitizer and is the one fixed here;aria-labelledby,aria-controls,data-focus-fallbackand everygetElementByIdread the same encoding. The only other lossy transform on the page,normalized(), compares check names against two fixed allowlists and is never used for identity. - No further P0/P1/P2 issue was found in the reviewed files by this self-review.
docs/board-data-contract.md states both rules in the section that already documents the single detail region and per-control identity.
Focused validation at head a374728c3dca61398d782add48e68a36e7d4708a
Run with the repository's canonical invocation, python -m unittest discover -s tests -p <module>.py.
| Check | Result |
|---|---|
tests/test_board.py |
143 passed, 0 skipped (139 at 6c8ef916; +4 new) |
BoardWorkFirstViewTests |
31 passed (27 at 6c8ef916) |
BoardPresentationTruthTests |
23 passed |
BoardObservationReaderTests |
6 passed |
BoardTests |
63 passed |
StatusCacheTests |
20 passed |
tests/test_board_observation.py (B0 contract) |
16 passed |
tests/test_board_lease.py |
4 passed |
| Board-dependent modules | 583 passed — test_release_hygiene 360, test_release_qualify 70, test_doctor_campaign_readiness 49, test_adoption_diagnostics 33, test_tracker_queue 27, test_file_locks 13, test_context_readiness 12, test_productivity_report 12, test_campaign_discovery 7 |
tests/test_cloud_client.py (cloud allowlist) |
68 passed (pytest; bare test functions unittest discover does not collect) |
ruff check . |
clean |
scripts/privacy_scan.py |
passed |
python -m compileall -q src scripts |
clean |
scripts/guard_package_workflows.py |
passed |
scripts/smoke_easy_mode.py --json |
status: pass |
python -m code_mower.migration release-readiness --json |
status: pass, 20 checks, 0 warnings |
git diff --check |
clean |
814 focused tests passed, zero failures, zero skips. The layout, id, focus and accessibility claims are executed assertions: BoardWorkFirstViewTests runs the shipped page JavaScript under node against a stubbed DOM, and the layout model cascades the shipped stylesheet rather than matching its text.
The repository-wide suite was not re-run locally for this head; GitHub CI is the canonical full matrix.
Status
Draft, needs-codex-audit retained, not marked ready and not merged.
The fifth independent exact-head audit has already run against a374728c and reports P0=0, P1=0, P2=1: both round-4 findings are cleared, and one new, unrelated P2 is open — codex:ad5cf581a3c0258a5c5b, on computing row urgency from all recorded states rather than the headline alone. That finding is not addressed at this head.
Row urgency was read from the row headline alone. The headline is chosen by display precedence, so a record that states both ready_to_merge and approval_required reads as "ready to merge" and was then ordered as if the approval nobody has given yet were not recorded at all. The same loss demoted merged work that is simultaneously blocked, failing or unreadable, and the first row is the default selection, so the Board could open on work nobody can act on. Urgency is now a deterministic function of the whole recorded state set: the most urgent state a row records wins, with one explicit exception for terminal work, which stays last only when it owes nothing. The display headline rules are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifth audit P2 fixed at
|
| Finding | Severity | Status | Where |
|---|---|---|---|
codex:ad5cf581a3c0258a5c5b |
P2 | Fixed | src/code_mower/board.py (ROW_URGENCY_BANDS, stateUrgency, isDemandingState, rowUrgency, the workRows sort) |
What changed
Row urgency was rowUrgency(row.headline) — a function of the single label the display precedence in STATE_RULES picks. Everything else the record states was discarded before ordering, so a record carrying both ready_to_merge and approval_required read as "ready to merge" (correct) and then sorted in the ready-to-merge band instead of the higher-priority approval band, and merged work that is simultaneously blocked, failing or source-unavailable sorted into the terminal band. Because the first row is the automatic default selection, the Board could open on work nobody can act on.
Urgency is now a deterministic function over the full row state set:
ROW_URGENCY_BANDSdeclares the ranking as four bands — blocked, actionable, untrusted, in-flight — andROW_URGENCY_ORDERis derived from it, so the previously reviewed ordering is unchanged label-for-label.stateUrgency(label)ranks one state;rowUrgency(row)takes the minimum over every state the row records. Order therefore cannot depend on which state the headline rules chose, nor on the order states or records arrived in.- The terminal exception stays explicit: a row that records a terminal state (
merged,idle with complete coverage) keeps the terminal band only when no state in it is demanding — i.e. nothing in the blocked or actionable bands, and no unranked state. Merged work that is also waiting for approval, failing or unreadable is ordered by what it still owes. Merged work that additionally records only "implementation complete" / "review passed" is genuinely terminal and stays last.
Display headline rules (STATE_RULES, states[0], headline, headline_class) are untouched, and the frozen B0 contract (board_observation.py, its schema and fixtures) is unmodified — the diff is src/code_mower/board.py and tests/test_board.py only.
Caller self-review
rowUrgency has exactly two consumers, both of which now receive the row rather than a label:
workRows(board.py:2572) — the one sort of work rows; no other view re-sorts them.resolveSelection(board.py:2694, called atboard.py:2887withworkRowsoutput atboard.py:3108) — the automatic default selection isrows[0], so it follows the same ranking by construction.
Remaining row.headline uses are display and change-summary text only (appeared as / moved from … to …), which are meant to report the headline.
Regression coverage
New, table-driven, executed against the shipped view model in Node (_eval_board_view / _render_board_sequence), with every record built through board_observation.validate so no case is proved against a shape a producer could not emit:
test_row_urgency_is_computed_from_every_recorded_state— an 11-row matrix (URGENCY_MATRIX+ the idle row) coveringready_to_merge+approval_required,ready_to_merge+provider_failed,ready_to_merge+source_unavailable,ready_to_merge+stale_observation, plainready_to_merge, merged alone, merged+approval_required, merged+provider_failed, merged+source_unavailable, and observed-running work. It asserts the exact row order, each row's urgency value, that the display headline is still the oneSTATE_RULESpicks, and that the automatic default selection (resolveSelection(rows, null)) is the most urgent row — across three input record orders (declared, reversed, rotated) plus all 20 permutations of four recorded state sets.test_the_default_selection_opens_on_work_that_still_owes_something— the same ranking proved through the rendered page: first row, last row,aria-expandedselection and the per-row headlines in the real DOM.test_headline_only_urgency_would_bury_work_that_still_owes_something— executes the reverted, headline-only ordering against the shipped rows and asserts it differs: it buries all four merged records beneath in-flight work and opens the Board onready-approvalinstead ofmerged-unavailable.
test_row_order_is_urgency_and_never_headline_precedence now exercises stateUrgency for the per-state ranking properties it was always asserting; its assertions are unchanged.
Mutation checks (both executed, then reverted)
- Sort reverted to
stateUrgency(a.headline) - stateUrgency(b.headline)→ 3 failures (test_row_urgency_is_computed_from_every_recorded_statein all three input orders,test_the_default_selection_opens_on_work_that_still_owes_something,test_headline_only_urgency_would_bury_work_that_still_owes_something). - Terminal exception weakened to
terminal !== null && owed === null(plain minimum, no demanding gate) →test_row_urgency_is_computed_from_every_recorded_statefails: the merged-alone row's urgency drops from the terminal band toimplementation complete.
Checks run (focused; repository-wide suite deliberately not run locally)
| Check | Result |
|---|---|
pytest tests/test_board.py tests/test_board_observation.py tests/test_board_lease.py |
166 passed, 25 subtests passed |
pytest tests/test_board.py -k BoardWorkFirstViewTests |
34 passed, 7 subtests passed |
ruff check . |
All checks passed |
scripts/privacy_scan.py |
privacy scan passed |
compileall src scripts |
OK |
scripts/guard_package_workflows.py |
package workflow guard passed |
pip check |
No broken requirements found |
code_mower.migration release-readiness --json |
"status": "pass", 20 checks, 0 warnings |
Every previous fix on this branch is preserved. needs-codex-audit is retained; the PR stays a draft and has not been marked ready or merged.
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Two P2 findings: supported blocking reasons are omitted from urgency ordering, and refreshes reset the selected detail panel's scroll position. Findings:
|
…place Two adjacent P2 completeness findings from the sixth independent exact-head audit of e237195. codex:776f336c36db45a3bc06 -- `provider_suspended` and `update_required` are reasons the frozen code_mower.boardObservation.v1 contract accepts, and the Board had no state rule for either. Neither produced a display state, so neither was ranked: work whose recorded route is `inspect_provider` or `update_branch` sorted below ordinary progressing and CI work, and an operator who had chosen nothing was opened on the wrong row. Both now have a display state -- "provider run suspended" and "branch update required" -- and both are ranked with the blockers, because nothing about that work moves until someone acts on it. Found in the same pass: the contract records a suspended session as the `suspended` lifecycle state and allows it only alongside the `failed` phase, so the failure rule -- which read the phase alone -- reported a session the provider paused as a session that failed. The failure state is now read from runs that are not suspended, so neither claim is made on the other's evidence. Headline precedence and urgency stay separate responsibilities: a record that is ready to merge and also suspended still reads as ready to merge and is ordered by the suspension. The coverage is now exhaustive rather than a hand-picked subset. The vocabulary is read from `board_observation.REASON_ROUTES`, and every reason it names is asserted to produce exactly one display state, in an explicitly declared urgency band, ranked rather than unranked -- so a reason added to the contract without a Board classification fails instead of falling silently through to "state not recorded". The closed actor and action vocabularies are covered the same way against `ACTORS` and `ACTIONS`. codex:4a8e6fdb6d1087739231 -- the desktop detail region scrolls independently of the row list, and every poll replaced it, returning an operator who had read down through the evidence to the top of the panel -- including on the polls that observed nothing new. The offset is now preserved across the refresh and restored against the same opaque work identity the selection is kept against: changed evidence for the same work item keeps the position, a different identity opens at the top of its own evidence rather than inheriting someone else's, and a selection that stops being rendered has nothing to restore onto. Restoring is clamped to what the replacement can actually scroll, so a refresh that shortens the evidence lands at the end of what is now there. Focus and the offset are carried across one refresh together, the offset restored last, so a browser that ignores `preventScroll` cannot undo it. Tests execute the shipped page. The DOM shim now models scroll as a browser does -- a replacement node starts at the top, and assigning `scrollTop` clamps nothing -- so a restored offset is only ever in range because the page put it there. Covered: unchanged refresh, changed evidence under one identity, content that shrinks and content that stops overflowing, a selection that disappears, a board with nothing to show, switching identity and coming back, and focus and scroll surviving one refresh together. A separate test enumerates the ephemeral state that lives inside the subtree `put("worklist", ...)` replaces -- proving from the shipped stylesheet that `.workdetail` is the only independently scrolling element in it and that the subtree holds no field, disclosure or editable region carrying state of its own -- so selection, focus and scroll are the whole set. The frozen B0 contract is untouched, and every earlier fix in this branch is preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixth exact-head audit: both P2 completeness findings fixedNew exact head:
|
| band | reasons |
|---|---|
| blocked (demanding) | source_unavailable, changes_requested, update_required, ci_failed, gate_failed, provider_failed, provider_suspended, cancelled |
| actionable (demanding) | approval_required, user_input_required, ready_to_merge, human_review_required, review_requested |
| untrusted | stale_observation, review_stale, identity_unlinked |
| in flight | review_in_progress, ci_pending, gate_pending |
Ephemeral selected-detail state across put("worklist", ...)
Enumerated and proved, not assumed. A test reads the shipped stylesheet and asserts .workdetail is the only element in the replaced subtree with overflow: auto|scroll, and that the subtree contains no <input>, <textarea>, <select>, <details>, <summary> or contenteditable -- so the complete set is:
| state | where it is kept | lifecycle |
|---|---|---|
| selection | selectedWorkKey, outside the subtree |
preserved across reorder/add/drop; re-resolved when the identity is gone |
| keyboard focus | restored by element id, row named as fallback | preserved; falls back to the row; left alone when the row is gone too |
| detail scroll offset | restored against data-key, clamped |
preserved on unchanged and changed-evidence refresh; reset on identity change, disappearance, empty board |
Tests and checks
9 new tests in tests/test_board.py (146 -> 155 passed, 40 subtests). Executed JS throughout -- the DOM shim now models scroll the way a browser does (a replacement node starts at 0; assigning scrollTop clamps nothing), so a restored offset is only in range because the page put it there.
tests/test_board.py-- 155 passed, 40 subtests- focused Board/B0 set (
test_board,test_board_observation,test_board_lease,test_remote_session,test_lane_status) -- 210 passed, 70 subtests tests/test_release_hygiene.py-- 360 passed, 881 subtestsruff check .passed;scripts/privacy_scan.pypassed;compileall src scriptspassed;scripts/guard_package_workflows.pypassed
Repository-wide suite not run locally, per lane policy.
Mutation checks
| mutation | result |
|---|---|
drop the provider run suspended state rule |
8 failed |
drop the branch update required state rule |
7 failed |
failure rule reads phases.includes("failed") again |
1 failed |
| move both new labels out of the blocked band | 8 failed |
drop withDetailScrollPreserved |
4 failed |
drop the clamp (scrollTop = before.top) |
1 failed |
| drop the identity check on restore | 2 failed |
Frozen B0 contract untouched; every earlier fix on this branch preserved. docs/board-data-contract.md updated for both.
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Suspended provider sessions are correctly identified in headlines but incorrectly displayed as failures in run-level displays. Findings:
|
The frozen B0 contract requires a suspended provider session to carry the `failed` phase, so any display built from the phase alone reported a paused session as a failed one. workStates() already distinguished them, but the selected-work evidence panel, the assignments line and the participant summary each read the raw phase, so one run was suspended in the headline and failed everywhere else. One helper now decides what a run is called, how it is coloured and what it counts as. A lifecycle state overrides the recorded phase only where it means something the phase cannot say -- `suspended` alone -- so cancelled stays distinct from failed, an actual lifecycle failure stays failed, and complete, implementation complete and running keep reading as themselves. The row headline reads the same helper, so the headline and the run-level displays can only agree. Raw phase survives only in the change signature, which is contract evidence rather than operator status and compares the recorded lifecycle state alongside it. Tests are table-driven over every lifecycle state the contract accepts and every phase it allows that state to carry, asserting one label, class, cue and count across the evidence panel, the assignments, the participant summary and the row states; that the existing suspended fixture renders no failure anywhere on the page; that one suspended and one failed run are one of each rather than two failures; that the Timeline reports the move from suspended to failed; and, by executing the raw-phase labelling this replaced, that those assertions catch its return. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Scroll preservation loses the operator’s reading position when polling updates a hidden tab. The 49 focused view and lifecycle tests pass but do not cover hidden-panel layout measurements. Findings:
|
…n tab
The one P2 finding of the eighth independent exact-head audit,
codex:d600f928d3e3acf7490c, which cleared every prior lifecycle finding.
The detail scroll offset was read off the detail region itself, immediately
before a render replaced it, and restored by clamping against the
replacement's layout. Both halves are only true while the panel is on
screen. A hidden view is hidden with `display:none`, so everything inside
it is out of layout: its metrics read zero and so does its scroll position.
An operator who scrolled down through one work item's evidence, switched to
Timeline, Releases or Health and let a poll land there therefore had their
place read as zero and clamped against zero travel -- and came back to Now
at the top of the panel, with nothing on screen to say why.
The offsets now live outside everything a render replaces, in a map keyed
by the same injective opaque work identity the selection is kept against.
They are captured when the operator scrolls and again before the panel is
replaced or before Now is hidden, and restored -- clamped to what is
actually there to scroll -- only when the same identity's detail is
rendered and measurable, including at the moment a hidden view is opened
again. A panel with no box is never read from and never clamped against, so
a hidden poll leaves what is remembered untouched however many times it
repeats.
Two consequences are deliberate. Each identity keeps its own place, so
moving to another work item opens that item's evidence at the top and
coming back returns to where its own evidence was being read; no offset is
ever inherited across identities. And a work item the Board stops showing
takes its position with it -- the render that drops a row drops what was
remembered for it -- with the map bounded besides, evicting least recently
touched first, so a Board left open for days cannot accumulate an entry per
identity it has ever rendered.
One further trap the fix has to avoid: a replacement rendered while Now was
hidden sits at the top because nothing could measure it, and reading that
top the moment the panel became measurable again would overwrite the real
position with zero. Only the element the page has actually synchronised is
ever read from before a replacement; a scroll the operator performs is
always believed, and is what marks the element as synchronised.
The composition with focus preservation is unchanged and still ordered:
focus restoration asks not to scroll, and the offset is restored after it.
Every path that opens a view ends in `applyView()` and every path that
re-renders the work list ends in `renderWork()`, so restoration happens at
the one visible lifecycle point in each.
The DOM shim the tests drive now reproduces the browser behaviour this is
all about: content inside a hidden panel reports a zero-sized box, with the
panel each container belongs to read off the shipped markup, and a scroll
step tells the page the element moved the way a browser does. Six new tests
cover a poll that lands while Now is hidden and the return to it, eight
hidden polls including ones that change the evidence being read, the same
route driven from the keyboard through the tab strip, zero metrics without
a hidden panel, the clamp and the growth case applied on return, per
identity offsets across switching away and back, an identity that
disappears, and the bound on what is remembered. The shipped stylesheet's
`[hidden] { display:none !important }` -- the premise the whole thing rests
on -- is asserted rather than assumed.
Five mutations were executed against the fixed page and each is caught:
treating zero metrics as measurable, clamping against the hidden panel,
dropping the restore when a view is opened again, keeping identities the
Board no longer shows, and trusting a replacement the page never
synchronised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 8 P2 resolved at
|
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Observation deduplication can display a superseded idle state alongside active work for the same session. All 49 work-view tests passed; sandbox restrictions prevented the broader suite from completing successfully. Findings:
|
The one P2 finding of the ninth independent exact-head audit, codex:ee79c8f096bd68aa93e5, which cleared the hidden-tab scroll handling. Identity deduplication only ever compares like with like. A session-level `no_work` snapshot is keyed `idle:session:worktree` and a work-specific observation of that same session and worktree is keyed `work:session:worktree:id`, so the two never met and both survived. Now then stated, of one session at once, that it was observed complete with nothing to do -- "idle with complete coverage", "nothing to do in this session" -- and that it is running work, with the participant summary counting the runs of both and the default selection free to land on either. Neither row was wrong about its own file; the Board was wrong to show them together. An explicit reconciliation step now runs once, immediately after deduplication and before any view reads a row, and keeps the newer of the two readings by the same trusted recorded order the rest of the model uses: `created_at`, then the last meaningful update. File order, directory order and input order decide nothing. Both directions are defined. An idle snapshot followed by work observations is stale and goes; every work item observed after it stays, however many there are. Work observations followed by an idle snapshot are the session having since gone quiet, so the idle snapshot is what is current and those work rows go rather than being restated as active work -- deliberately including terminal work, because an item observed as merged before its session reported itself idle is not current work either. Nothing is invented to stand in for a dropped row: what the page already records is that the row is no longer recorded, which change tracking reports in the Timeline on the poll that drops it. A session that went idle and then picked work up again keeps only the work observed after the snapshot, because each work item is decided against the snapshot on its own. Two observations recording exactly the same instants are a real tie, and it is broken by specificity rather than by comparing their text: the work-specific observation names one item inside the session, the session-level snapshot only summarizes the session, and reading "nothing to do" over a work item observed at the same instant is the contradiction being removed. Nothing is correlated that the contract did not link. Different sessions and different worktrees are never compared -- one session holds several worktrees and one worktree is reused by session after session -- and a record carrying neither half of a session identity, which is every `unlinked` observation, keeps the unlinked consolidation semantics it already had rather than being guessed into a session. Every work-first consumer descends from the one reconciled call: the work list, the participant summary, change tracking, the default selection and the announcement region. The Health view still reads every record on disk on purpose -- a source behind a superseded observation was really contacted, and its connection is inspected there on its own terms rather than as a claim about work. Eleven new tests execute the shipped page JavaScript over the transition matrix: idle to active, idle to two distinct work ids, active to idle, terminal to idle, active to idle to new active, equal timestamps, the same session in another worktree, the same worktree in another session, a record with no session identity, and every scope shape missing either half of one. Each transition is asserted again with the records reversed, the timestamp tie is proved to be a tie before the tie-break is asserted, the reconciled set is checked through the participant summary, the default selection and the change log, and the page is rendered to prove no idle claim appears beside active work in one scope. The idle-to-active case is mutation-checked by executing deduplication without the reconciliation after it, which reproduces the contradiction exactly. Three existing ordering tests placed the idle fixture beside work fixtures in its own session scope -- the contradictory board itself. They now record that idle snapshot against a different session, which is the only session a "nothing to do" snapshot is ever truthful about, so the terminal band is still exercised by a board a producer could really emit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ninth exact-head audit: the one P2 is fixedHead: Finding mapping
Identity deduplication only ever compares like with like: Transition semanticsOrdering is the trusted recorded one —
Inverse transition, deliberately: a dropped work row has nothing manufactured to stand in for it. What the page already records is that the row is no longer recorded, and change tracking reports exactly that in the Timeline on the poll that drops it. No history is invented. Consumer self-reviewEvery work-first consumer descends from the single Checks (focused; the repository-wide suite was not run locally)
New tests (11, table-driven)Transition matrix executed against the shipped page JavaScript, each case asserted again with the records reversed; the timestamp tie is proved to be a tie via Three existing ordering tests had placed the idle fixture beside work fixtures in its own session scope — the contradictory board itself. They now record that idle snapshot against a different session, the only session a "nothing to do" snapshot is ever truthful about, so the terminal band is still exercised by a board a producer could really emit. The B0 contract ( |
Round 14 — the one P2 is fixed at
|
| Finding | Severity | Status | Fix | Regression test |
|---|---|---|---|---|
codex:a12904a45ebb3b2878bf — track changes to the effective idle state (board.py:3524) |
P2 | fixed | one canonical row signature carrying both the recorded and the derived half of a row's state, built from the same objects the row is rendered from | BoardEffectiveStateChangeTests (5 tests, 19 subtest cells) |
The finding
A refresh loses file coverage — an unreadable candidate, one the contract rejected, one left past the cap — while the accepted no_work record beside it stays byte-identical on disk. The row moves from idle with complete coverage to idle in the records read: a different label, a different cue, a different next action, different coverage evidence, a claim about the present withdrawn. workSignature reads the record alone, so the signature was identical across that refresh, meaningfulChanges returned nothing, and both the Timeline and the aria-live region said nothing at all. An operator reading — or listening to — the change log was left with the last thing it said: that the session was idle.
The fix
Half of a row's state is recorded and half is derived, and both now live in one signature rather than in one more condition beside it.
idlePresentationreturnssignature— the semantic identity of the reading it just chose: which reading, the coverage and freshness states that chose it, its label, class, next action, coverage verdict, the note with the counts and source kinds it quotes back, and the covered sources it names. The caveat is deliberately outside it, because the caveat is the one place the record's age is quoted.effectiveStateis the derived half of any row: that idle reading, the age classification (current/aged/unrecorded), and which kind of time the last meaningful update is reported from.rowSignature=workSignature+effectiveState, and is what every row carries and what change detection compares.workRowcomputes the idle reading once, before the row is built, so the headline, the state cues, the next action, the coverage evidence and the signature are all the same object. Nothing can reach the operator that change detection has not seen.
workSignature itself is untouched and stays a pure function of the record: isNewerObservation breaks observation-order ties with it, and an ordering that consulted the clock or the file coverage would not be stable.
No new timestamp churn. Ages are classified, never quoted. "3m" becoming "4m" is the poll advancing, not the state moving, so created_at, every checked_at / observed_at / heartbeat_at, update_text, freshness.label, freshness.detail and the idle caveat all stay out. What is in is the discrete transition: an observation crossing OBSERVATION_STALE_SECONDS genuinely stops claiming the present tense, and is reported once.
Signature invariants
rowSignature(record, freshness, update, idle)=workSignature(record)+"|effective:"+effectiveState(...), and the recorded half is byte-identical toworkSignature(row.record)for every row.workSignatureremains a pure function of the record — no clock, no payload-level coverage — soisNewerObservationand the group tiebreak stay deterministic.- No signature quotes an age or a polling instant. One payload read at four instants inside the current window yields one signature.
- Every user-visible derived reading a row renders is in the signature, and every reading in the signature is rendered: the row and the signature descend from the same
idleandfreshnessobjects. - The signature changes if and only if what the row says changes. Proved in both directions by the transition table, including partial → partial refreshes on both sides of that line.
Self-review of the rest of the derived state
Every other rendered reading derived from payload-level coverage or freshness rather than record fields was walked. The freshness pill's class is decided by the age classification plus unavailable sources, and the sources are recorded state workSignature already carries. workStates, evidenceGroups, measurements, the unlinked row and workActionsHtml read the record only. Row membership under reconcileSessionScopes is coverage-driven and already surfaces as appeared/gone, which the round-12 tests pin. The empty-state message and the "Incomplete snapshot" banner are coverage-driven page chrome rather than row state, and the rows now carry the same fact themselves. Nothing else derived was found missing.
Tests
Eleven single-page-lifetime transitions, each two consecutive refreshes with the exact Timeline entry and aria-live sentence asserted:
| Transition | Expected |
|---|---|
| complete idle → partial idle: a candidate went unread | … moved from idle with complete coverage to idle in the records read. |
| partial idle → complete idle: the lost candidate came back | … moved from idle in the records read to idle with complete coverage. |
accepted no_work → a candidate the contract rejected beside it |
… to idle in the records read. |
accepted no_work → a candidate left past the cap beside it |
… to idle in the files read. |
| coverage gap vocabulary changes, the reading does not | silence — live region untouched, Timeline empty |
| routine refresh: every polling instant advances, nothing moves | silence |
| partial → partial that moves the row: unread files → lost records | … moved from idle in the files read to idle in the records read. |
| partial → partial that moves the row: one more file left unread | … changed while staying idle in the files read. |
| partial → partial the row already states: a gap under the worst one | silence |
| complete idle → partial idle: a source covers part of what it covers | … to last observed idle, coverage incomplete. |
| partial idle → complete idle: the source covers all of it again | … to idle with complete coverage. |
Plus: a signature-invariant test proving the recorded half is byte-identical across every coverage-driven transition while the row signature moves if and only if the row does; selection preservation and per-surface agreement across a withdrawal and its restoration, with a second working session present so the operator's choice is a real one and the unaffected row is provably not announced; and a no-churn test reading one payload at four instants inside the current window for one signature, then one past the threshold for a reported change.
Mutation check. The signature is rebuilt from the record alone — the Board exactly as it was — and the mutant produces equal signatures across the withdrawal, an empty meaningfulChanges, and precisely the silence the assertions forbid, while still rendering the two different headlines. The shipped signature, unmutated, announces the same refresh.
Measured against b6717cdc with CI's own runner (python -m unittest): all five test methods fail — 17 cells, 10 failures and 7 errors. The seven errors are KeyError: 'announce', because at that head the live region is never written at all on these refreshes, which is the finding itself. Under pytest with pytest-subtests the same run reports 17 failing items, of which three are method-level and fourteen are subtest cells, so two of the five methods report a method-level PASSED beside their failing cells.
Focused validation at 102ed860
| Check | Result |
|---|---|
tests/test_board.py |
231 passed (226 at b6717cdc; +5 new), 181 subtest cells, 0 skipped |
BoardEffectiveStateChangeTests |
5 passed (new), 19 subtest cells |
BoardIdleFreshnessTests |
7 passed |
BoardSessionScopeReconciliationTests |
11 passed |
BoardObservationOutcomeAccountingTests |
9 passed, 26 subtest cells |
BoardObservationNonRegularEntryTests |
14 passed, 14 subtest cells |
BoardObservationClassificationRaceTests |
3 passed |
BoardNodeHelperArgvTests |
4 passed |
tests/test_board_observation.py (frozen B0 contract) |
16 passed |
tests/test_board_lease.py |
4 passed, 7 subtest cells |
python -m unittest discover -s tests -p "test_board*.py" (CI's own runner) |
251 tests, OK |
Board-dependent modules + test_cloud_manifest, test_package_install_failure_taxonomy, test_cloud_client |
684 passed, 976 subtests, 0 failures, 0 skips |
ruff check on both changed files |
clean |
scripts/privacy_scan.py |
passed |
python -m compileall -q src scripts |
clean |
scripts/guard_package_workflows.py |
passed |
python -m code_mower.migration release-readiness --json |
status: pass, 20 checks, 0 warnings |
git diff --check |
clean |
node --check on the rendered page script |
clean |
The repository-wide suite was not re-run locally; GitHub CI is the canonical full matrix.
What is preserved
Every prior round's fix is intact. Only page-side presentation JavaScript changed at this head: observations_payload(), the /api/status serving path, the observations block's shape and counters, and the frozen code_mower.boardObservation.v1 record contract are byte-identical to b6717cdc. src/code_mower/board_observation.py is untouched, as it has been for all sixteen commits. The Node-stdin helper change and the descriptor-open hardening from b6717cdc are unchanged and still covered by their own tests.
Diff at this head: 2 files, +511 / -8 (src/code_mower/board.py, tests/test_board.py).
Status
Draft retained, needs-codex-audit retained, not marked ready, not merged. Requesting the fifteenth independent exact-head audit at 102ed860272e86f5abc042a046fa0fbda23f55a9.
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: An inaccessible observation path can prevent the whole Board snapshot from refreshing instead of degrading only observation availability. Findings:
|
`observations_payload` asked `Path.exists()` and `Path.is_dir()` outside the handler that guards the enumeration. Both swallow only the errnos that mean the name did not resolve and re-raise everything else, so an observation directory under an ancestor the process cannot search raised `PermissionError` out of what reads like a boolean. That read is one step of assembling the whole `/api/status` snapshot, so the exception did not degrade observations -- it aborted the refresh, taking the repository, PR and lane data with it and leaving the page on whatever it served before, over one local directory the Board only ever reads. Every preflight question is now answered by `_classify_observation_path`, which returns one token from the closed `OBSERVATION_PATH_STATES` vocabulary and cannot raise. Three facts stay distinct, because they are different statements about the evidence: `missing` is a name that does not resolve and is the only one the Board may report as "nothing recorded yet"; `not_directory` and `unreadable` are losses, and degrade observations to unavailable coverage with their own fixed diagnostic. The split between missing and unreadable reuses the errnos `pathlib` itself treats as non-existence, so a path that answered "no" before still answers `missing` and only the errnos that previously escaped become `unreadable`. `path_exists` keeps its meaning and gains `null` for a path the Board could not examine, because "it is not there" is a claim an unreadable path cannot support. One `stat` answers both questions, so the syscall budget is one metadata call per refresh and there is no window between "exists" and "is a directory" for the entry to change kind inside. Classification is still not a promise: a directory removed or replaced before the enumeration reaches it is reported as a coverage gap rather than as an absence, which is the same rule every lost candidate is held to. The candidate accounting for a readable directory is unchanged. Tests cover the boundary as a table: every real path state with its syscall count, every way the preflight `stat` can fail (PermissionError and representative OSErrors, the missing errnos, an unencodable path), an inaccessible ancestor on a real filesystem, per-entry `lstat` failures, a directory lost between the check and the enumeration, and privacy-safe diagnostics. Two of them assert the whole snapshot still refreshes with its repository, PR and lane data, and one makes `Path.exists()` and `Path.is_dir()` fatal for the duration so moving either check back outside the guard fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: The new observation views can assert current session idleness from a stale server snapshot. All 29 targeted tests passed, but a separate renderer check confirmed this inconsistency. Findings:
|
Round 15 — P2
|
| Finding | Where | Fix | Regression test |
|---|---|---|---|
codex:3f3b1f6a2b3c309bebbd (P2) — Path.exists() / Path.is_dir() outside the OSError handler abort the whole status refresh on an inaccessible ancestor |
board.py:1614 and the two branches below it |
_classify_observation_path(): one os.stat that cannot raise, returning one token from the closed OBSERVATION_PATH_STATES vocabulary |
BoardObservationPathBoundaryTests (9 tests, 23 subtest cells) + 1 rendered-page test |
What the finding actually cost
observations_payload() is one step of status_payload(). The exception did not degrade observations — it aborted the refresh. StatusCache._refresh caught it, recorded a failure summary and armed the retry backoff, and the page went on serving the snapshot before it: repository, PRs, workflow runs, lanes, spend and productivity all withheld over one local directory the Board only ever reads.
Path-state invariants after the fix
| State | When | path_exists |
available |
coverage |
Diagnostic |
|---|---|---|---|---|---|
directory |
stat succeeds, S_ISDIR |
true |
true |
from the candidate accounting | per-candidate only |
missing |
ENOENT, ENOTDIR, EBADF, ELOOP, the three Windows equivalents, or an unencodable path |
false |
true |
complete |
none — nothing recorded yet |
not_directory |
stat succeeds, not S_ISDIR |
true |
false |
unavailable / directory_unreadable |
observation path is not a directory |
unreadable |
any other OSError, including PermissionError |
null |
false |
unavailable / directory_unreadable |
could not check the local Board observation path |
directory lost before enumeration |
scandir raises after a directory classification |
true |
false |
unavailable / directory_unreadable |
could not list local Board observations |
Holding across all of them: no filesystem call in this read may raise; exactly one directory metadata call per refresh, and zero scandir/open calls once the path is unreadable; missing is the only state the Board may report as an absence; no diagnostic carries a path, an errno, an OS message or an exception class name; and the non-observation half of the snapshot is built exactly as it would have been. path_exists is null rather than false when the Board could not look, because it is not there is a claim an unreadable path cannot support — and the page's path_exists === true branch is behind available === false, so null can never be read as an absence.
The missing/unreadable split reuses the errnos pathlib itself treats as non-existence, so every path that answered "no" before still answers missing and only the errnos that previously escaped become unreadable. For a readable directory the candidate accounting model is byte-for-byte what rounds 12 and 13 left.
Scope check
The PR added four filesystem metadata call sites in the observation read: os.scandir, the per-entry lstat (both already guarded, both now covered by table-driven failure tests), and the two preflight calls fixed here. No unguarded observation-directory metadata call remains in this PR's scope. The identical pattern in agent_adapters_payload() is pre-existing on main, untouched by this PR, and about a different directory and contract — left alone rather than widening this diff.
Focused validation
| Check | Result |
|---|---|
tests/test_board.py |
241 passed (231 at 102ed860; +10), 204 subtest cells |
BoardObservationPathBoundaryTests |
9 passed (new), 23 subtest cells |
BoardWorkFirstViewTests |
50 passed (+1) |
tests/test_board_observation.py (B0 contract) |
16 passed |
tests/test_board_lease.py |
4 passed, 7 subtest cells |
tests/test_cloud_manifest.py, tests/test_package_install_failure_taxonomy.py |
33 passed, 30 subtest cells |
python -m unittest discover -s tests -p "test_board*.py" (CI's own runner) |
261 tests, OK |
| board-dependent module set (9 modules) + cloud manifest/client/taxonomy | 684 passed, 976 subtests — unchanged |
ruff check . |
clean |
scripts/privacy_scan.py |
passed |
python -m compileall -q src scripts |
clean |
scripts/guard_package_workflows.py |
passed |
python -m code_mower.migration release-readiness --json |
status: pass, 20 checks, 0 warnings |
git diff --check |
clean |
node --check on the rendered page script |
clean |
The repository-wide suite was not run locally; GitHub CI is the canonical full matrix.
Mutation check
Both metadata checks were moved back outside the guard, one at a time. Restoring path_exists: path.exists() and restoring the if not path.exists() / if not path.is_dir() branches each produce 21 failures against 4 passes in the boundary class. One test makes Path.exists() and Path.is_dir() fatal for its duration, so neither can return to this read undetected. Against 102ed860's board.py under CI's own runner the new class is 6 failures and 22 errors across 9 tests. The new rendered-page test is not a regression proof of the fix — it locks the health wording for the path_exists: null state this head introduces, which no earlier head could produce.
Preserved
Every prior fix, the frozen code_mower.boardObservation.v1 contract and src/code_mower/board_observation.py (untouched), the descriptor-open hardening (O_NONBLOCK / O_NOFOLLOW with the kind re-decided on the descriptor), the Node stdin transport, the effective row signatures, and the candidate accounting model for a readable directory.
Status
Draft retained, needs-codex-audit retained. Not marked ready, not merged. Requesting an independent exact-head Codex audit of 1c41085cc05a3f6552f212acdf2dff8bbb3dfb4a.
`/api/status` answers a cold cache with metadata only and a stale one with the snapshot the last completed refresh produced, so a delayed or failed refresh leaves the page holding records written when that refresh ran. Their own timestamps and source freshness are all still inside the record-level thresholds -- a two-minute-old `no_work` record read as current by every measure it carried. That was enough: a cached `no_work` record rendered green, as "idle with complete coverage" with "nothing to do in this session" beside it, for the whole ten minutes before its own age caught up, while the observation summary above it correctly reported an unconfirmed snapshot and reconciliation went on retiring work rows on its authority. `snapshotAuthority` is now the one canonical reading of that fact, derived once per payload from the cache metadata alone and consumed everywhere: the observation summary stops computing its own, `recordFreshness` takes it as the third condition for calling a record current, and the idle classification and reconciliation inherit it through that one gate rather than re-testing it. `idleAuthorityState` only chooses which reading explains the withholding, so an operator is told to wait for a running refresh rather than to re-observe a session whose own evidence is fine. Refresh-in-progress and stale-with-error stay distinct and are both stated, and the unconfirmed note is appended to every reading it touches so a row withheld for a lost file does not imply the rest was confirmed. The confirmation state is classified into the derived half of the row signature; the cache age and the error text are kept out, so a poll that only advances the cache age is not news while a real confirmation change reaches the Timeline and the live region. Every other absence claim on the page -- the empty work list, the empty participant summary, the empty source list -- is qualified by the same two facts, and the Now, work-list, chrome and Health surfaces state the unconfirmed snapshot beside the incomplete-read warning they already carried. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writer lane: audit 16 P2 resolvedHead: Finding mapping
Canonical input
One gate, not two. Authority invariants
Surfaces tracedAPI status -> TestsNew Checks run (focused, not repo-wide)
Prior fixes preserved: frozen B0 contract, path-state classification, descriptor-open hardening, Node stdin transport, effective semantic signatures, session-scope reconciliation, observation coverage accounting. Still draft, still |
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: Observation views can retain affirmative current-state claims indefinitely after polling fails. The 81 selected tests passed but do not cover this failure path. Findings:
|
A rendered observation went on speaking for *now* after polling stopped. The snapshot cache answers whether the server confirmed what it served; it cannot answer whether this page has heard from the server since. A page that rendered a fresh, completely covered no_work record and then lost /api/status kept a green "idle with complete coverage" with "nothing to do in this session" beside it for as long as the failures continued: record freshness, the idle reading and the reconciliation that retires work beside an idle snapshot were all recalculated only by a successful render(), so the ten-minute observation threshold never arrived either. The catch handler wrote a warning into the summary, which does not withdraw those claims -- only rendering them again does. The one canonical authority model now composes both confirmations. `transportAuthority` classifies this page's own last status poll, and `snapshotAuthority(data, transport)` composes it with the server's cache reading, so every surface that descends from that one reading -- the observation summary, one record's freshness, the idle classification, the reconciliation that retires work, every absence message, the Now banner and the Health rows -- withdraws together. An unanswered poll is its own reading rather than a flavour of `unconfirmed`, because the operator's next move is different: get this page talking to the server again, not wait for a refresh. On failure the last successful payload is rerendered under that state rather than left as it was. The payload itself is never touched, so the records, their recorded times, the evidence and the selection all stay on screen as the historical observations they always were, nothing is retired or suppressed, and the poll that recovers restores exactly the server's own authority with no client state left behind. The local state is bounded and explicit: a flag, a capped failure count and one truncated error string, none of which reaches a signature -- so a repeated failure, a different error message and an observation ageing out under a failure are all silent in the Timeline and the live region. The two requests are now settled independently. Only /api/status carries the observations whose currency this page asserts, so only its failure withdraws that authority; a failed /api/events feeds a view that never claims to be current and no longer stops a good status payload from rendering or degrades the pacing its cache decided. Pacing and backoff are otherwise unchanged: a failure is a normal-interval tick and the fast-poll budget is still reset only by a response no longer awaiting a refresh, and every path through load() arms exactly one timer. Tests drive the shipped load() loop across one page lifetime: fresh complete idle into a first failure, repeated identical failures, failure after active work, failure onto an already-stale server snapshot, recovery with a fresh and with a stale snapshot, the freshness threshold elapsing under a failure, and error-message and timestamp changes under an unchanged reading. They assert idle withdrawal, that retained data retires and suppresses nothing, that the historical evidence stays visible, the summary, Now and Health wording, one Timeline entry and one announcement per semantic transition, selection preservation and one poll timer -- and mutation-check both the summary-only catch handler and a failure rerender without the local authority override. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writer lane: seventeenth exact-head Codex audit, P2 resolvedHead: Finding mapping
What changed
An unanswered poll is its own reading ( On failure the last successful payload is rerendered under the withdrawn authority rather than left as-is. The two requests are settled independently via a single Client/server authority invariants
TestsNew Table-driven cases: fresh complete idle → first failure; repeated identical failures; failure after active work; failure onto an already-stale server snapshot; recovery with a fresh snapshot; recovery with a stale snapshot; the freshness threshold elapsing under a failure; a different error message under the same reading; a cache timestamp that moved under the same reading. Assertions: idle withdrawal on every surface; no retirement or suppression from retained data; historical evidence still rendered; summary/Now/Health wording; exactly one Timeline entry and one announcement per semantic transition; selection preserved across a failure rerender; one poll timer per poll. Plus a bootstrap case (first poll fails, nothing ever rendered), an events-only failure case, and two mutation checks — the summary-only catch handler, and a failure rerender without the local authority override — both of which reproduce the retained green claim. Checks run (focused; repository-wide suite deliberately not run locally)
Two existing assertions were updated to track the change rather than be weakened: the read-only-endpoints test now matches the All prior fixes preserved: frozen B0, path-state classification, descriptor-open hardening, Node stdin transport, and the semantic signature discipline (classifications only, never ages, counts or error text). Remains draft with |
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: The polling refactor can permanently stop refreshes after an event-rendering error. Full test verification was limited by read-only filesystem and socket restrictions. Findings:
|
A valid-JSON `/api/events` response is not necessarily a renderable one: `events` arriving as a string, as an object that merely has a `length`, or as a list holding a null all parse cleanly and then throw inside `renderEvents`. That call sat outside the status handler and inside no boundary of its own, so the throw escaped `load()`, skipped `scheduleNextLoad()` and ended this page's status refreshes for good -- leaving the last snapshot on screen indefinitely, still asserting the present tense, with no transport warning anywhere, because the status poll it came from had succeeded and nothing had failed to record. One poll is now two halves and one guarantee. - `renderEventsIsolated()` contains an unrenderable history where it happens. Nothing is written on failure: the history card keeps the last events it could render rather than being emptied over a payload that is unreadable now, and the transport row is not touched, because it reports whether *status* polls are arriving and an events failure recorded there would withdraw a snapshot that rendered perfectly well. - `renderStatusOutcome()` settles the status half and returns the delay the next poll should wait: the one a rendered status response chose, and the configured interval for every other outcome. The transport failure is recorded before the retained rerender is attempted, so a rerender that fails too still leaves the count and the message for the Health transport row -- this handler never swallows the reason a poll stopped speaking for now. - `scheduleNextLoad(delayMs)` moves into a `finally` and is the only exit from `load()`. Anything that throws past the handlers above still reaches the caller, so no failure is hidden; what it can no longer do is end the loop. Pacing, backoff and the fast-poll budget are unchanged, and so is every authority invariant: a status poll that did not complete still withdraws every current-state claim and retires nothing, and a poll that completes still restores exactly the server's own authority. Tests: `BoardPollingContinuityTests`, nine tests over 42 subtest cells, driving the shipped `load()` loop. Every events outcome -- fetch reject, body that is not JSON, and three shapes that throw inside `renderEvents` -- crossed with every status outcome, including a payload whose shape `render()` cannot walk; the full sixteen-cell matrix; sixteen consecutive mixed polls asserting one timer armed per load, clearing the one it replaces, never stacked; the delay a successful status response chose surviving an events failure; a retained rerender that throws still arming the next poll; and recovery of both surfaces afterwards. Three mutations execute the code this replaced: the pre-fix tail stops the page refreshing with `armed == 0` and a green present-tense claim still on screen, the events render without its boundary escapes a poll the `finally` still arms, and the arming moved back outside the `finally` ends the loop on a throwing rerender. The lifetime harness gains an unparsable-body outcome and records what escaped `load()`; `mutate` now takes several fragments. Against `8ffd8215`'s `board.py` the new class is 27 failures across its 9 tests under CI's own runner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex audit (merge-authority lane)Head SHA: Codex Audit: PASS Summary: No actionable regressions were identified. Full test verification was limited by the read-only sandbox and socket-binding restrictions. Findings: none. |
Writer lane: eighteenth exact-head Codex audit, P2 resolvedHead: Finding mapping
What changedThree
Nothing is swallowed to keep the loop alive. A status render that throws is reported through the existing bounded surface — Scheduling invariants
Authority invariants (round 16 and 17, preserved)
TestsNew
Mutation checks — three, each isolating one part of the fix. The pre-fix tail (events render in no boundary, arming after the try) stops the page refreshing on all three unrenderable shapes: Harness. One existing assertion was tightened, not weakened: Checks run (focused; repository-wide suite deliberately not run locally)
Every All prior fixes preserved: frozen B0, path-state classification, descriptor-open hardening, Node stdin transport, the composed server/client authority model, and the semantic signature discipline (classifications only, never ages, counts or error text). Remains draft with |
…y wording - Point every "current v1.4.2 candidate is pending" link at #952 instead of #915 (install/quickstart/try-in-10-minutes/public-release-checklist/ first-user-install-rehearsal/early-adopter docs, friendly-user-rollout, oss-v1-checklist, builders-grok-cursor, README, current-state-and-roadmap). - Restore the Graphify roadmap facts: Graphify shipped as v1.4.1 via #915, not v1.4.2 (near-term roadmap list, delivery order list, section heading, and the now-satisfied deferral note); keep the init.py packaged guidance URL pinned to v1.4.1, where docs/graphify-setup.md was actually published. Revert the lineage.py capability-boundary docstring to #915/1.4.1: that capability shipped and was qualified in v1.4.1, not this release. - Mark v1.4.1 published in release-history.md, current docs, and CHANGELOG.md (its scorecard/campaign/Board/cloud evidence completed the #915 closeout; no longer "publication pending"). Restructure CHANGELOG.md so the v1.4.2 section lists what is actually shipping in this release (#961's `board service`/`board stop --repo`, and #999-#1003/#951's Board clarity work) instead of leaving it double-booked under `## Unreleased`. docs/pypi-release.md's required-inclusion list now names v1.4.2's actual accepted issues (#999/#1000/#1001/#1002/#1003) instead of the stale Graphify #876 mention. - try-in-10-minutes.md: role admission and exact startup lease commands shipped in published v1.4.1, not this candidate; the doc now says v1.4.2 inherits them unchanged rather than "includes" them as new. - docs/first-run-transcript.md and docs/first-user-demo-transcript.md are pinned to the v1.4.0 shape; both now carry an explicit "Historical" label pointing to current guidance, and README.md/quickstart.md's links to them are annotated the same way instead of presenting them as current. - docs/first-user-install-rehearsal.md's `v141` temp-directory names moved to `v142` (current-runbook identifiers, not historical record). - Reviewed public-release-checklist.md's "current entrypoint is v1.4.2" vs. "not yet published" framing: this is the project's established two-bullet convention (target entrypoint vs. last-published feature list), also used for prior releases and enforced verbatim by test_release_hygiene.py; left unchanged rather than risk breaking that tested contract. - Never assert a fixed Board-service count as current fact. A read-only `code-mower board list --json` verified exactly two live local Board services pre-release (port 5332, `codemower-ai/code-mower`, plus one additional private-repository port); every v1.4.2 doc and the CHANGELOG entry now name that observed two-service inventory instead of the previously invented three-port template (5342/5344 removed everywhere: docs/pypi-release.md, release_readiness.py's required markers, and test_release_hygiene.py's fixtures/assertions). - Reworked docs/pypi-release.md section 15 end-to-end so it never blindly `board stop`s a service #961 manages (which refuses with `managed_service` and a nonzero exit): each port's posture is classified from `code-mower board service status --json` via a fail-closed `board_service_mode.py` helper (managed only for an exact single matching row in `ok`/`delayed_health_failed`; transient only for an exact `not_installed` with zero rows; anything else -- unsupported platform, more than one row, a row for the wrong port, malformed JSON -- aborts instead of guessing). A managed port is restarted in place with `board service restart --replace`; a transient port is stopped by both `--repo` and `--port` (never port alone, so a port reused after reconciliation can't stop the wrong repository's listener), waited gone, and re-served. Posture is asserted unchanged before and after restart. tests/test_release_hygiene.py gained `test_runbook_board_service_mode_fails_closed_on_unclassifiable_status` and an updated `test_runbook_board_restart_waits_and_polls_every_port` covering the new managed/transient branches, stop selector, and posture assertions. - Added docs/pypi-release.md step 17: a real 1.4.1-to-1.4.2 upgrade rehearsal that installs the published v1.4.1 artifact, creates representative preserved state (a staged config), upgrades in place to the verified v1.4.2 artifact, and asserts both the version transition and that the preserved config's digest is unchanged -- the "cold-install and 1.4.1-to-1.4.2 upgrade rehearsal coverage" the candidate docs already claimed, which previously pointed at no such step. - Added the InstalledPromptPackTests class docs/v142-qualification.md already told operators to run, and fixed its installed-module provenance assertion to resolve both sides of the path comparison (this sandbox's `/tmp` -> `/private/tmp` symlink otherwise makes `code_mower.__file__.resolve()` compare unequal to an unresolved argv path). Verification (fresh venv each time, unittest's own exit code, no pipeline masking): - `python -m unittest tests.test_release_v142` -> 16/16 passed, exit 0. - `python -m unittest discover -s tests -p "test_release_v14*.py"` -> exit 1 overall, with exactly one failure: `test_release_v141.InstalledPromptPackTests.test_literal_starter_and_explicit_config_walkthrough`. tests/test_release_v141.py, docs/v141-release-notes.md, and docs/v141-qualification.md are confirmed byte-identical to origin/main (`git diff HEAD:<path> <path>` produces no output); that protected historical file fails its own copy of the same installed-module-path assertion in this sandbox for the identical `/tmp` symlink reason, which is excluded from current-version (v1.4.2) acceptance rather than a regression from this change. - `python -m unittest discover -s tests -p "test_release_hygiene.py"` -> 361/361 passed, exit 0. - `ruff check .`, `scripts/privacy_scan.py`, `scripts/guard_package_workflows.py`, and `python -m code_mower.migration release-readiness --json` (status: pass) all clean on this commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…de artifacts; refresh roadmap facts
- board_service_mode.py (docs/pypi-release.md section 15) now requires
payload["schema"] == "code_mower.boardServiceStatus.v1" and requires the
sole services row to be a dict before calling .get on it. Unrelated or
changed JSON, a wrong/missing schema, or a non-dict row now fails closed
instead of being silently accepted or raising AttributeError.
tests/test_release_hygiene.py's
test_runbook_board_service_mode_fails_closed_on_unclassifiable_status
gained wrong_schema, missing_schema, non_dict_row, and non_dict_payload
regression cases (and the passing cases now carry the schema field the
real CLI always emits).
- Step 17's upgrade rehearsal (docs/pypi-release.md) now targets headless
Linux: file hashing uses a small `$RELEASE_PYTHON`/hashlib script instead
of the macOS-only `shasum -a 256`. The v1.4.2 upgrade installs the exact
wheel step 9 already downloaded and digest-verified
(`$PYPI_DOWNLOAD_DIR/code_mower-1.4.2-py3-none-any.whl`), never a fresh
`code-mower==1.4.2` index re-resolution that could silently install a
different build than the one this runbook verified. The v1.4.1 side is
now explicitly downloaded and digest-bound the same way before install,
instead of installing whatever the index resolves at rehearsal time.
tests/test_release_v142.py's UpgradeRehearsalTests updated to match.
- Fixed the awkward inline-code line break in docs/v142-qualification.md
("serving == installed ==" split across a line from "1.4.2").
- Refreshed docs/current-state-and-roadmap.md's Board section: #956/#957
are merged, not drafts behind main; #961, #999, #1000, #1002, and #951's
#1003 are accepted on `main`; #951 itself stays open only for its bounded
hosted Devin canary, tracked separately from its merged code evidence.
The near-term roadmap list's Board bullet was updated the same way.
Verification (fresh venv, unittest's own exit code, no pipeline masking):
- `python -m unittest tests.test_release_v142` -> 16/16 passed, exit 0.
- `python -m unittest discover -s tests -p "test_release_v14*.py"` -> exit 1
overall, exactly one failure:
`test_release_v141.InstalledPromptPackTests.test_literal_starter_and_explicit_config_walkthrough`
(the byte-identical, untouched historical file's own copy of the sandbox
`/tmp` symlink issue, confirmed via `git diff HEAD:<path> <path>` -> no
output for that file, docs/v141-release-notes.md,
docs/v141-qualification.md, and docs/v140-release-runbook.md).
- `python -m unittest discover -s tests -p "test_release_hygiene.py"` ->
361/361 passed, exit 0.
- `ruff check .`, `scripts/privacy_scan.py`, `scripts/guard_package_workflows.py`,
and `python -m code_mower.migration release-readiness --json`
(status: pass) all clean on this commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Board renders the accepted B0 observation fixtures behind a compact work-first interaction model. The page becomes a consumer of the frozen
code_mower.boardObservation.v1contract from #956: it decodes records, renders what survives, and never produces, resolves, or repairs one. No cloud field, no mutating control, and no producer path is added.Closes #948
Builder: Code Mower Claude (
claude-opus-5, named sessionCode Mower Claude #948)Exact head
3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66Base
origin/mainat2a07dc542c8e01ce5c8cfe772276914e6adcf962(merged #957). Twenty linear commits, no merge commit: the implementation atac9ca53c, then one commit per independent exact-head audit round —6d6e73fe(round 1, three P2),c44b9137(round 2, two P2),6c8ef916(round 3, two P2 plus one found in the same self-review),a374728c(round 4, two P2),e2371955(round 5, one P2),d79b9bd7(round 6, two P2 plus one found in the same self-review),d3059649(round 7, one P2),c759793f(round 8, one P2),312f5920(round 9, one P2),9c9ca3e0(round 10, one P2),68666b03(round 11, one P2),c978b4a1(round 12, one P2),ddae4632(round 13, one P2),b6717cdc(pre-audit hardening, two independently verified problems),102ed860(round 14, one P2),1c41085c(round 15, one P2),c179387d(round 16, one P2),8ffd8215(round 17, one P2), and3dec5ba5(round 18, one P2).Changed files
src/code_mower/board.pyobservations_payload()reading the B0 contract,--observations-path,observationsin/api/status, and the four-view work-first page insiderender_board_htmltests/test_board.pyBoardObservationReaderTestsandBoardWorkFirstViewTests, executing the shipped renderer JS undernodeagainst a stubbed DOMtests/test_board_lease.pynode -eonto stdindocs/board-data-contract.mddocs/launch-command-surface.md--observations-pathrowFive files,
+11854 / -305across the twenty commits.src/code_mower/board_observation.pyis untouched — the contract stays frozen.What the views do
Four semantic tabs over the one payload. Persistent chrome carries repository, serving and installed version, snapshot time, the one next action, and observation freshness at every width.
Work rows show the recorded safe reference, stage, assignments, last meaningful update, next action, and responsible role. Selecting one exposes builder, review, CI, gate, merge, and human-policy evidence as six independent readings, each naming its source and that source's freshness, so none can stand in for another. The gate publisher sits beside the
code-mower/gateverdict and is labelled as publisher execution only.Selection is held by opaque work identity (session, worktree, work id), not row position, so a refresh that reorders, adds, or drops rows leaves the operator's choice where it was. There is exactly one detail region, rendered inside the selected row, so phone widths follow the row while CSS places that same region adjacent to the list at desktop widths. Rows are buttons carrying
aria-expanded/aria-controls; arrow keys wrap across tabs and clamp across rows; unselected panels carryhiddenso they leave the accessibility tree; every interactive control has a visible focus ring.A change is announced once through a polite live region, and listed in Timeline, only when what a row says differs. One signature carries both halves of that. The recorded half is stage, reasons, route, PR identity, evidence state, measurements, run phase or basis, and each source's freshness, coverage and event time. The derived half is what the page works out rather than reads: the effective idle classification, which depends on whether this refresh accounted for every candidate observation file; whether the observation is still current; and which kind of time the last meaningful update is reported from.
created_at,checked_at,observed_atandheartbeat_atare excluded, and so is every rendered age, because they advance on every successful poll — so an unchanged poll announces nothing and adds no Timeline entry, while a refresh that withdraws or restores an idle claim announces exactly that.Truth rules: liveness is read, never inferred (a record whose sources are not all fresh, or older than ten minutes, is a last observation and may not claim anything is running now);
no_workis idle with complete coverage and names the fresh complete source kinds, and only while the observation read itself covered every candidate file;unlinkedclaims no stage and no route; an unavailable measurement rendersnot recordedand a partial one renders the value with the evidence it was counted from, with no percentage and no ETA; unknown is neutral and colour always carries a text label and a text cue; an empty directory reads as "nothing recorded yet", which is not "no work".Actions stay read-only — open a recorded PR link, inspect a connection in Health, view recent changes. A locally observed PR number is never turned into a remote address the payload has not recorded. No merge, requeue, force-lease, cancel, retry, restart, cloud-schema or Slack-specific control, no form, no non-GET request.
Payload and privacy
observationsis bounded to 32 files per refresh; each record is bounded by the contract itself. Records that fail the contract are dropped and counted, carrying the contract's own fixed diagnostic vocabulary, which omits observed values and local paths. The observation path is redacted._recordable_payloaddrops the block, socode-mower board recordand--record-eventspersist the shape local history already had. The cloud board-snapshot export is a fixed allowlist of summarized counts and is unchanged — no observation field is uploaded.Ownership boundary
Producer work stays with #949 (PR #999, Codex). Nothing here resolves a session, correlates local evidence, emits a lifecycle fact, or touches a #949 file.
Audit round 1 — three P2 findings resolved at
6d6e73fe8e23697f5afa9cdb3ddfb5cad2a484c7The independent Code Mower Codex exact-head merge-authority audit BLOCKED
ac9ca53cwith exactly three P2 findings. All three are fixed narrowly in6d6e73fe; ownership stays inside the same four #948 files.codex:88916db59e3e0cef6252— a same-numbered PR in a foreign repository got this repository's linkrecordedPrUrl()requiresrow.repository === REPObefore offering a link; a foreign record is still rendered truthfully, named as belonging to that other repository, with no link inventedtest_a_same_numbered_pull_request_in_another_repository_is_never_linkedcodex:ec5060bbc67e0e90d36d— duplicate linked observations produced competing rowsworkRows()deduplicates by the opaque identity (session, worktree, work id); the newest observation wins oncreated_at, then last meaningful update, then row signature — all deterministic. Change tracking consumes the same set. Unlinked consolidation is unchangedtest_duplicate_observations_of_one_identity_render_the_newest_oncecodex:80220e57a31d75a48022— observation files were read unbounded before decodeobservations_payload()reads at mostMAX_BYTES + 1bytes per file and rejects an oversize file on that length with the contract's owninvalid_contractdiagnostic; the remainder is never loaded or decodedtest_an_oversize_file_is_read_bounded_and_rejected_before_it_is_decodeddocs/board-data-contract.mdgains one precise sentence per behaviour change: the repository-identity condition on a PR link, one-row-per-identity with the newest observation and the deterministic tiebreaks, and the bounded-read/reject-before-decode rule.Audit round 2 — two P2 findings resolved at
c44b9137ea62109e5846f3bb2c451b5532b1f3d8The second independent Code Mower Codex exact-head audit cleared all three round-1 findings and BLOCKED
6d6e73fewith two adjacent P2 consistency findings. Both are fixed inc44b9137; ownership stays inside the same four #948 files andsrc/code_mower/board_observation.pyremains untouched.Both findings had the same root cause — three consumers each deriving their own view of what is on disk — so both are fixed by one shared normalization and deduplication step,
observationGroups()/consolidatedRecord(), which the work list, the participant summary and change tracking now all read and which nothing else bypasses.codex:35af9aa458ff662bf884—participantSummary()walked the raw records, so successive observation files of one work identity counted the same run once per file and kept reporting phases that run had already lefttest_a_run_that_changes_phase_across_observations_counts_once_and_newestcodex:28cf2b6e857e7491c7e3— consolidating unlinked observations for a repository merged assignments and evidence but kept the first record's freshness, age, last meaningful update and signature, so a fresh first file hid an unavailable source, a later recorded event was lost, and reversing the directory listing changed the rowtest_consolidated_unlinked_observations_recompute_freshness_and_updateConsolidation is a presentation step over records the frozen
code_mower.boardObservation.v1contract has already accepted. No contract field, rule or diagnostic changes, the Board still never produces, resolves or repairs an observation, and all three round-1 fixes are preserved.docs/board-data-contract.mdstates the consolidated-row rule and the participant-summary rule in the same section that already documents one-row-per-identity.Both regression tests were confirmed to fail against
6d6e73feand pass atc44b9137.Audit round 3 — two P2 findings resolved at
6c8ef9162c3bf8873cb20ca81836f499a204f6b6The third independent exact-head audit cleared both round-2 findings and BLOCKED
c44b9137with two P2 findings, both fixed in6c8ef916.codex:77b5ccf0a4ab02656f91— row order reusedSTATE_RULES' headline precedence, wheremergedis rank 0, so finished work sorted first and was what an operator who had chosen nothing was showntest_row_order_is_urgency_and_never_headline_precedence,test_finished_work_never_outranks_actionable_or_blocked_workcodex:2c6a9a0968e8146189d2— the three detail actions were the only focusable controls without ids, so every poll that replaced the row list dropped the keyboard to the document bodydata-focus-fallbacknaming its row button, read off the element before the update destroys ittest_detail_actions_keep_keyboard_focus_across_a_refresh,test_a_detail_action_that_disappears_never_hands_focus_to_another_controlFound in the same self-review and fixed in that commit: activating a detail action that opens another view left focus inside the panel the switch had just hidden, so focus now moves to the tab for the view that was opened.
Audit round 4 — two P2 findings resolved at
a374728c3dca61398d782add48e68a36e7d4708aThe fourth independent exact-head audit cleared all prior findings and BLOCKED
6c8ef916with two P2 edge cases. Both are fixed ina374728c; ownership stays inside the same #948 files, the one-detail-region interaction model is unchanged, andsrc/code_mower/board_observation.pyremains untouched.codex:1a94d9fd98d5f667aa33— the desktop detail panel was absolutely positioned against the work list, which reserved a guessed 180px minimum, so with one or two rows it contributed no layout height and was drawn over the Work Now and Participants sectionstest_desktop_detail_reserves_its_height_so_a_short_list_cannot_overlap,test_no_rule_takes_dynamic_content_out_of_flow_without_pinning_its_box,test_mobile_detail_follows_the_row_and_desktop_places_it_adjacentcodex:85f9785c6a1d42702fa0— element ids replaced every character outside[A-Za-z0-9_-]with a hyphen, so contract-valid identities differing only in punctuation (owner/re.poagainstowner/re-po) collapsed onto one id, giving a duplicate id, a sharedaria-labelledbytarget and a focus lookup that could land on the neighbour_escape and code units outside ASCII — becomes_<hex>_, so an id decodes back to exactly one key. Rows, the detail'saria-labelledby, the action ids and every focus lookup read that one encodingtest_element_ids_encode_every_work_key_injectively,test_keys_that_differ_only_in_punctuation_keep_separate_rows_and_focusThe layout test executes a layout rule rather than matching stylesheet text: a small box model cascades the shipped stylesheet onto a one- and two-row list and checks that the reserved height covers the detail's bottom edge; fed the rule this replaced, the same model reports the overlap, so the check has teeth. Both fixes were confirmed to fail against
6c8ef916and pass ata374728c.Audit round 5 — one P2 finding resolved at
e237195536533a8a024964ef4975b1b9df2589c2The fifth independent exact-head audit cleared both round-4 findings and BLOCKED
a374728cwith one P2.codex:ad5cf581a3c0258a5c5b— row urgency was read from the row headline alone, and the headline is chosen by display precedence, so a record stating bothready_to_mergeandapproval_requiredread as "ready to merge" and was ordered as if the approval nobody has given yet were not recordedtest_row_urgency_is_computed_from_every_recorded_state,test_headline_only_urgency_would_bury_work_that_still_owes_somethingAudit round 6 — two P2 findings resolved at
d79b9bd79053a3412d16c2756c35784c5201df43The sixth independent exact-head audit cleared the round-5 finding and BLOCKED
e2371955with two P2 completeness findings.codex:776f336c36db45a3bc06—provider_suspendedandupdate_requiredare reasons the frozen contract accepts and the Board had no state rule for either, so neither produced a display state and neither could be rankedboard_observation.REASON_ROUTES,ACTORSandACTIONS, so a vocabulary the contract gains without a Board classification fails instead of falling through to "state not recorded"test_every_reason_the_contract_accepts_is_displayed_and_ranked,test_the_two_unranked_blockers_outrank_ordinary_work_in_every_state_ordercodex:4a8e6fdb6d1087739231— the desktop detail scrolls independently and every poll replaced it, returning an operator who had read down through the evidence to the top of the panel even on polls that observed nothing newtest_the_selected_detail_keeps_its_reading_position_across_a_refresh,test_a_scroll_position_is_never_inherited_by_a_different_work_item,test_a_refresh_that_shortens_the_detail_clamps_the_restored_positionFound in the same self-review and fixed in that commit: the failure state rule read the run phase alone, so a session the provider paused was reported as a session that failed. The failure state was narrowed to runs that are not suspended.
Audit round 7 — one P2 finding resolved at
d30596492cc86ab4cac4323bb9932357c7726482The seventh independent exact-head audit cleared every prior finding and BLOCKED
d79b9bd7with one P2 lifecycle-display consistency finding, fixed ind3059649. Ownership stays inside the same #948 files andsrc/code_mower/board_observation.pyremains untouched.codex:044c7d368aacd887ee1a— the frozen contract requires a suspended provider run to carryphase === "failed", andlifecycle.state === "suspended"is the truthful operator meaning.workStates()distinguished them after round 6, but the selected-work evidence panel, the assignments line and the participant summary each still read the raw phase, so one run was suspended in the headline and failed everywhere elserunState()/runDisplay()— decides what a run is called, how it is coloured and what it counts as, and every display that names, colours, groups, counts or summarizes a run reads it. A lifecycle state overrides the recorded phase only where it means something the phase cannot say, which in this contract issuspendedalone, so cancelled stays distinct from failed, an actual lifecycle failure stays failed, and complete, implementation complete and running keep reading as themselves.workStates()reads the same helper, so the headline and the run-level displays can only agree. Raw phase survives only in the change signature, which is contract evidence rather than operator status and is compared alongside the recorded lifecycle stateBoardRunLifecycleDisplayTests— six tests, of which two are table-driven over all 21 accepted lifecycle/phase pairsThe tests are table-driven over every lifecycle state the frozen B0 contract accepts —
pending,running,waiting_for_user,waiting_for_approval,complete,failed,suspended,terminated,archived,uncertainand the no-lifecycle case — against every phase the contract allows that state to carry, 21 pairs in all, each built from an accepted fixture and re-validated throughboard_observation.validate. For each pair the evidence panel, the assignments line and the participant summary are asserted to produce one identical label, class and cue and to count the run exactly once, and no other run state in the contract's vocabulary may appear in any of them. The existing_record_with_suspended_runfixture is rendered through the shipped page and no node on the page contains the word "failed". One suspended run beside one failed run and one cancelled run summarize as one of each rather than as three failures, the Timeline reports the move from suspended to failure in those words, and the raw-phase labelling this replaced is executed against the same record and shown to report the suspended run as failed — so the assertions have teeth.Call sites audited in the same self-review:
workStates()facts, the evidence panel's builder group, the row assignments line, the row participant list,participantSummary()and its rendering all now route through the shared helper;PHASE_LABELS/PHASE_CLASSESare no longer read by any display and exist only as the base the run-state tables are derived from;workSignature()keeps the raw phase, explicitly labelled as contract evidence for change detection, alongside the recorded lifecycle state; and the tracker row'slifecycle_categorywas reviewed and left alone — it is a tracker work item's category from a different payload, not a provider run.Audit round 8 — one P2 finding resolved at
c759793f46e70eb112bb303dc25d965e364fe875The eighth independent exact-head audit cleared every lifecycle finding from round 7 and BLOCKED
d3059649with one P2 hidden-tab scroll-state finding, fixed inc759793f. Ownership stays inside the same #948 files andsrc/code_mower/board_observation.pyremains untouched.codex:d600f928d3e3acf7490c— the desktop detail scroll offset was read off the detail region immediately before a render replaced it and restored by clamping against the replacement's layout. Both halves are only true while the panel is on screen: a hidden view is hidden withdisplay:none, so its content is out of layout and reports a zero-sized box and a zero offset. An operator who scrolled through one work item's evidence, switched to Timeline, Releases or Health and let a poll land there had their place read as zero and clamped against zero travel, and returned to Now at the toptest_a_poll_that_lands_while_now_is_hidden_keeps_the_reading_position,test_many_hidden_polls_and_changed_evidence_lose_nothing,test_a_hidden_panel_is_never_read_as_a_reading_position_of_zero,test_returning_to_now_clamps_against_what_is_there_on_return,test_remembered_reading_positions_are_dropped_with_the_work_they_belong_to,test_the_remembered_reading_positions_are_boundedThe DOM shim the tests drive now reproduces the browser behaviour the finding is about: content inside a hidden panel reports a zero-sized box, with the panel each static container belongs to read off the shipped markup rather than assumed, and a scroll step tells the page the element moved the way a browser does. The shipped stylesheet's
[hidden] { display:none !important }— the premise the whole guard rests on — is asserted from the stylesheet rather than assumed.The six new tests cover a poll that lands while Now is hidden and the return to it; eight hidden polls, two of them changing the evidence being read; the same route driven from the keyboard through the tab strip, where the keyboard lands on the tab moved to and the reading position is still there; zero metrics with no hidden panel, which is read the same way; the clamp and the growth case applied on return as well as while visible; per-identity offsets across switching away and back; an identity that disappears from a poll and comes back as new work; and the bound on what is remembered, read off the page rather than assumed.
Lifecycle paths audited in the same self-review: every path that opens a view — tab click, tab keyboard navigation, and the "Inspect connection" / "View recent changes" actions inside the detail — ends in
applyView(), which is where restoration happens; every path that re-renders the work list — a poll, a row click, row keyboard navigation — ends inrenderWork(), which captures before the replacement and restores after it. Composition with focus preservation is unchanged and still ordered: focus restoration asks not to scroll, and the offset is restored after it.Five mutations were executed against the fixed page and each is caught by these tests: treating a zero-sized box as measurable, clamping against the hidden panel, dropping the restore when a view is opened again, keeping identities the Board no longer shows, and trusting a replacement the page never synchronised.
Audit round 11 — one P2 finding resolved at
68666b039d0b197ac6f929ad6d692240c0a8fae0The eleventh independent exact-head audit cleared the truncation handling added at
9c9ca3e0and BLOCKED it with one P2 stale-idle-truth finding, fixed in68666b03. The change is page-side presentation only:observations_payload(), the contract reader and the frozencode_mower.boardObservation.v1record contract are untouched.codex:fa58d877f7e1c8039cab— a validno_workrecord that had gone stale still rendered green asidle with complete coveragewithnothing to do in this sessionbeside it, indefinitely, whilerecordFreshness().currentwasfalseand the row's own age pill saidlast observed 24.0h ago. Only file-level coverage could withhold the claim; age could not. A producer that stopped writing therefore left the page asserting that a session needs nothing right nowidlePresentation(), decides what ano_workrecord may claim, and the headline, state cues, next action, coverage evidence, row order, change tracking, the announcement region and Timeline sentences all read it. An affirmative, present-tense, ok-cued claim now requires both current evidence — every source fresh and the observation inside the staleness threshold — and whole coverage — every source complete and every candidate observation file read this refresh. Five prior-observation readings are distinguished rather than collapsed: files left unread, a source that cannot be reached, partial source coverage, an observation with no readable time, and an observation that has aged out. Each carries its own label, its own next action, neutral or warning styling, and a caveat naming the age or the source. Reconciliation reads the same classification: retiring a work row asserts that the session has since gone quiet, so only a snapshot that may make a present-tense claim can retire work; a snapshot that cannot retires nothing, and the two rows no longer contradict each other because that snapshot's row claims only a past observation. Prior-observation idle is ranked explicitly — below every blocked, waiting and in-flight state, above the terminal band — so it can neither outrank nor suppress work that is observed to be movingBoardIdleFreshnessTests:test_only_current_evidence_under_whole_coverage_claims_idle_now,test_every_withheld_reading_carries_its_age_or_source_caveat,test_the_freshness_threshold_decides_at_its_own_boundary,test_a_day_later_the_same_valid_record_states_no_present_tense_claim,test_a_prior_observation_never_outranks_work_that_is_moving,test_a_snapshot_that_cannot_claim_the_present_retires_no_work,test_without_the_freshness_gate_a_day_old_record_still_claims_idleMatrix semantics. The new tests are driven by a table over freshness {
current,stale,unavailable/unknown} × coverage {complete,partial,truncated} for oneno_workrecord. Exactly one of the nine cells — current evidence under whole coverage — yieldsidle with complete coverage,nothing to do in this sessionand anokcue. The other eight yield prior-observation wording (idle in the files read,last observed idle, source unavailable,last observed idle, coverage incomplete,last observed idle at an unrecorded time,last observed idle), a next action that begins "before treating this session as idle", a coverage note ending "so this session is not shown as idle", andwarn,badormutedstyling — neverok, and never a present-tense claim.The freshness axis is built from what the frozen contract can actually record: a
no_workrecord requires its session, work queue and run registry sources to have been fresh and complete when written, so staleness is reached by age (the one-day-later fixture) and unreachability by a further source the producer names beside the required three. The contract ties an unreachable source to unavailable coverage, so the third row's complete-coverage cell is reached by the undated variant — a shape no producer can emit, handed to the view anyway, because a record whose age cannot be computed is exactly where a view must not guess.Boundary ages are asserted at 599, 600, 601 and 86400 seconds against the shipped
OBSERVATION_STALE_SECONDS, read off the page rather than restated: current up to and including the threshold, a past observation the first second after it. Input-order independence is asserted by reversing the record list and requiring an identical result. The one-day-later fixture is also rendered through the shippedrender()undernode, where no node of the page contains either present-tense sentence, and the same record read while current still contains both.A mutation removing the freshness half of the gate —
coverageState === "complete"alone — restores the old wording, class and action on the very same day-old record, and the shipped classifier says none of it. Against9c9ca3e0, three of the seven new tests fail outright and the other four report twenty failing subtest cells, 23 failures in all.The truncation fix from round 10 is preserved exactly:
idle in the files read, its next action and its coverage note are unchanged strings, and the round 10 tests pass untouched.Self-review of every other affirmative liveness or absence claim in #948 —
idle,nothing to do,no work,complete coverage,running now— found no further inconsistency to correct. The freshness notes already say a preserved last observation "is not evidence of work running now"; the participant strip already says its run states "are what the records state, not a claim that anything is running now"; the file-coverage note already refuses to be read "as complete coverage, as an idle session, or as evidence that there is no work";stage_label: no work recorded, the empty-directory message and the participant and source empty messages are statements about what was recorded, not about the present. Recorded work states such asmergedorready to mergeare past-tense readings of recorded evidence and continue to carry the row's freshness pill and caveat.Per-round sections for rounds 9 and 10 are not in this body; their findings, fixes and tests are recorded in their commit messages (
312f5920,9c9ca3e0) and in the audit comments on this PR.Audit round 12 — one P2 finding resolved at
c978b4a1f2cfe96702e7d5581805d92280d5af22The twelfth independent exact-head audit cleared the stale-idle handling added at
68666b03and BLOCKED it with one P2 reader-outcome-completeness finding, fixed inc978b4a1. The frozencode_mower.boardObservation.v1record contract andsrc/code_mower/board_observation.pyare untouched.codex:95516310f2225639d2e8(P2) — a selected observation file raisingOSErrorincrementedrejectedbut left the read reportingcoverage: complete, so a readableno_workrecord beside an unreadable work record could assert a current idle session and reconciliation could retire the work row the unread file might have recorded_ObservationAccounting, recording exactly one outcome per candidate; coverage is whole only when nothing was lostBoardObservationOutcomeAccountingTests(9 tests, 26 subtest cells)One accounting, with counters that partition the candidate set.
_ObservationAccountingreplaces the ad-hocrejectedcounter and is the only place completeness is decided. Its invariants are asserted directly by the tests and documented indocs/board-data-contract.md:candidate_files=selected_files+omitted_filesattempted_files=selected_filesattempted_files=read_files+unreadable_filesread_files=accepted_records+invalid_recordsaccepted_records=len(records)unaccounted_files=unreadable_files+invalid_recordsrejected=unaccounted_files— the name is kept, and it now deliberately includes unreadable files: a file that could not be read is no more accounted for than one the contract refusedcoverage_completeis true only whenomitted_filesandunaccounted_filesare both zeroMalformed, invalid and oversize candidates are reconciled deliberately, not left ambiguous. Dropping any candidate means the Board cannot know whether it held the work record that contradicts a
no_workrecord beside it, so every kind of loss makescoveragepartialand suppresses affirmative absence claims.coverage_gaps[]states which kinds occurred, from the fixed vocabularydirectory_unreadable,files_omitted,files_unreadable,records_invalid. An unlistable directory still reportsunavailablewith null totals.Diagnostics stay privacy-safe and now distinguish the two failures.
unreadable_filenames a candidate that raised on open or on read;invalid_contractand the contract's other terms name a record it rejected. Neither carries an errno, an OS message, a local path or a byte of file content. Health renders them as counts per diagnostic term through the newobservationDiagnostics, so the page no longer prints the record file name the payload carries.Every consumer routes through that one result.
observationCoveragegainsincomplete/wholeand reports each gap in its note, whiletruncatedkeeps meaning the cap alone.idleCoverageStatereturns a newunreadstate when a selected candidate produced no record, giving the row the prior-observation reading idle in the records read with its own next action and coverage note.reconcileSessionScopesretires work only on a claim the page may still make, so incomplete evidence never retires an active work row. The Now header and banner, the work list banner and message, the chrome pill, the participant and source empty states and the Health row are gated onincompleterather than on the cap.Matrix semantics. The new tests are driven by a table over candidate outcomes {accepted, invalid JSON, oversize,
OSErroron open,OSErroron read} and their mixtures: 0 files, all-invalid, all-oversize, unreadable-only either way, invalid+unreadable, accepted+unreadable, accepted+oversize, one of each at once, overflow alone, overflow+unreadable, and overflow with both. Each cell asserts the exact counters, every partition invariant, the coverage state,coverage_complete, the gap vocabulary and the message. Exactly the all-accepted non-truncated cells report whole coverage; every other cell ispartialand says "incomplete" in its own message.Further tests cover the read staying bounded in files and in bytes when candidates are lost (32 opens, each one
MAX_BYTES + 1request), determinism under reversed directory iteration, privacy of both the payload and the rendered page, the audit's own readable-no_work-beside-unreadable-work case end to end through the shipped renderer undernode, and reconciliation refusing to retire an active work row under incomplete evidence.Mutation check, both sides. Dropping
files_unreadablefrom the reader's gaps makes it call the read whole — and the view still refuses, because it counts unaccounted candidates itself; removing the view'sunreadgate restoresidle with complete coverageandnothing to do in this sessionon the very same read. The shipped code, unmutated, says neither. Seven of the nine new tests fail at method level against68666b03, 33 failures counting subtest cells.Self-review covered every reader exception branch —
os.scandir, a non-directory path,entry.stat(),Path.open,handle.read, andboard_observation.decode(which wraps invalid UTF-8, empty and non-object payloads intoinvalid_contract) — and every consumer of coverage completeness. All of them now read the one accounting result. Rounds 10 and 11 are preserved exactly:idle in the files read, its next action and its coverage note are unchanged strings, and their tests pass untouched.Audit round 13 — one P2 finding resolved at
ddae4632712ce3de2a9d10fe5669b70f7d9cbc50The thirteenth independent exact-head audit cleared the candidate accounting added at
c978b4a1and BLOCKED it with one P2 availability finding, fixed inddae4632. The frozencode_mower.boardObservation.v1record contract andsrc/code_mower/board_observation.pyare untouched.codex:0b8f439aeac1e55fe63c(P2) — the observation selector accepted every directory entry ending in.jsonand opened all of them, so a named pipe in the observations directory blocked the read indefinitely, long before the byte bound could apply; the refresh never finished and the page kept serving the snapshot before itlstatthat never opens anything, open only regular files, and account for every refused entry asunreadablein the same candidate-accounting modelBoardObservationNonRegularEntryTests(10 tests, 14 subtest cells)The bounds only start applying once the file is open.
MAX_OBSERVATION_FILESbounds how many files are opened andMAX_BYTES + 1bounds how many bytes each is asked for, but neither is a bound onopen()itself. Opening a FIFO for reading with no writer does not fail — it waits. One such entry wedgesobservations_payload(), and because the status cache only replaces a snapshot when a refresh returns, the page goes on serving the previous one and is never marked stale.Classification happens before the open, and it never opens anything.
_select_observation_filesnow callsentry.stat(follow_symlinks=False)— onelstat, which does not block on a pipe — and returns each selected candidate with whetherstat.S_ISREGheld._read_observation_recordsopens only those. Symlinks are deliberately not followed, even to a regular file: resolving one reintroduces the same hazard through a target the observations directory does not control, and through a target that can be swapped between the classification and the open. An entry whose own metadata will not read is not known to be a regular file either, so it is refused rather than risked.Refusing an entry is not ignoring it. This is the wrong fix the accounting model from round 12 exists to prevent: skipping what cannot be read and reporting what is left as the whole local record set. A refused candidate is counted like any other — it stays in
candidate_files, it isselected, and it lands inunreadable_fileswith the same closedunreadable_filediagnostic a file that raised gets. Socoverageispartial,coverage_gaps[]carriesfiles_unreadable, the message says the snapshot is incomplete, and both absence claims stay withheld:idleCoverageStatereturnsunread, the row reads idle in the records read, andreconcileSessionScopesdoes not retire the work row the refused entry might have recorded.The diagnostic says a candidate produced no record, and nothing else. It never says the entry was a pipe, a socket, a directory or a link, carries no errno, no OS message, no local path and no byte of content, and the rendered page names neither the entry nor the directory it came from. A page that described the shape of a local directory to whoever is looking at the Board would be a privacy regression, so a refused entry is indistinguishable on the page from a permission error.
Race branches re-reviewed. Classification is a decision about a moment, not a guarantee: a candidate classified regular can be gone, replaced or unreadable by the time the read opens it. Every discovery, stat and open branch was walked again —
os.scandir, a non-directory observations path,entry.stat,Path.open,handle.read, andboard_observation.decode— and each still routes into the one accounting, so a lost race is a counted gap rather than a silently shorter record set. A test drives both halves of it, failing the open and failing the read on a candidate that classified regular.Tests. Ten tests cover every entry kind that can exist on the platform — FIFO, symlink to a regular JSON record outside the directory, directory named
*.json, and bound Unix socket — each beside a current, complete-sourceno_workrecord, with a platform-safeSkipTestwhere the filesystem primitive is unavailable. Each proves the read returns inside a deadline on a worker thread (a refusal that did not happen does not raise, it blocks, so the deadline is the assertion), that the entry was never opened, the exact counters and partition invariants,partialcoverage withfiles_unreadableand notruncated, the withheld idle claim and the retained work row, and that neither the entry name nor its kind reaches the payload or the rendered DOM. Determinism under reversed directory iteration and the file and byte bounds are re-proved with refused entries mixed into an overflowing directory, wherefiles_omittedandfiles_unreadableare reported together without either absorbing the other.Mutation check, both halves. Classifying every selected candidate as regular — the code before this guard — does not return against a real named pipe inside a short deadline, and the test releases the wedged reader rather than leaking it. Refusing the entry but dropping it from the count reports
coverage: completewithcandidate_files: 1and rendersidle with complete coverage/nothing to do in this sessionon the very same read. The shipped code, unmutated, says neither. Two source-level mutations were also run and caught: removingfollow_symlinks=Falseopens the link, and removing the reader's regular-file guard fails all four entry kinds. Eight of the ten new tests fail at method level againstc978b4a1, 13 failures counting subtest cells; the two that pass there do so honestly, because a directory and anOSErrorwere already routed tounreadableby round 12.Pre-audit hardening at
b6717cdcTwo problems found and verified independently of the audit rounds above, fixed in one pass.
1 — the classification-to-open race was not closed by round 13
Round 13 classified each candidate with one
lstatand opened only the entries it called regular. Its stated reason for tolerating a change between the two steps was that "the open or the read raises". That is false, and it is the whole hazard: opening a named pipe for blocking read with no writer does not raise, it waits — for a writer that never arrives. Anything that can write to the observation directory could replace a regular file with a FIFO after it was classified and wedge the refresh permanently, which is exactly the outcome round 13 set out to prevent.The
lstatfilter is kept and is now documented as a filter rather than a guarantee: it keeps the ordinary non-regular entry from being opened at all. The open now carries the guards itself —O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC— so a pipe returns instead of waiting and a name that became a symlink is refused instead of resolved.os.fstatthen re-decides the kind on the descriptor that is actually open, which is the one question nothing can race, and any non-regular descriptor is refused before a single byte is read.Descriptor ownership is explicit. The open owns the descriptor until
os.fdopentakes it, and every failing path in between — a refusal, anfstatthat raised, anfdopenthat failed — closes it exactly once. All of them, plus every genuine open or read error, land in the one centralizedunreadable_fileaccounting with the same closed diagnostic: no errno, no path, and no statement of what kind of entry it turned out to be.Platform handling is explicit rather than implicit. The two guards are named in
OBSERVATION_OPEN_GUARDSand looked up rather than hardcoded, so an interpreter without one still imports and the descriptor check still makes the open safe; a test asserts every guard is in force whereveros.name == "posix", so nothing can silently restore a blocking path on macOS or Linux. No write flag is ever set.New tests, in
BoardObservationClassificationRaceTestsand the existing non-regular class:O_NOFOLLOWholds under the race; the link's valid target appears nowhere on the page/dev/fdunchangedrecords_invalid) instead of reporting a file it could not read (files_unreadable)os.openon the pipe succeeds with the shipped flags, so onlyfstatcan be the refusallstatfilter is defence in depth, not the thing keeping the read prompt: removing it costs one refused descriptor and nothing elseBoth race tests were run against the previous open (
record_file.open("rb")) at this head and fail: the FIFO test by never returning (the observation read never returned; a candidate blocked it), the symlink test by reading through the link (unreadable_files0 != 1).2 — every Python 3.12 / 3.13 / 3.14 package-matrix job failed on argv
Run
35131068414failed 62 Board tests withOSError: [Errno 7] Argument list too long: '/usr/local/bin/node'attests/test_board.py::_render_board_sequence. The cause is not the test: the shipped page script is 144,443 bytes, and Linux caps a single argument atMAX_ARG_STRLEN— a fixed 32 pages, 131,072 bytes, unrelated toARG_MAXand not raisable — sonode -e <script>is refused by the kernel before Node is reached. macOS applies a much larger whole-command-line limit instead (measured here: the ceiling is at 1 MiB), which is why every local run passed and proved nothing.Every Board Node helper now sends the program through
node -on stdin, which is bounded by a pipe rather than by argv, leaving only the small JSON arguments on the command line. One runner,_run_node, is the single place a Node command line is built:_render_board_sequence(the reported failure)node -e_render_board_focusnode -e_eval_board_pagenode -e_render_board_domnode -e_eval_board_viewnode -e_eval_board_truthnode -e_run_board_poll_scripttest_board_lease.pylease renderernode -eThe harness
process.argvindices shift by one, because-now occupiesargv[1]and the first JSON argument isargv[2]. The normalized payloads stay bounded and every helper's behaviour and return value are unchanged — all 226 Board tests pass at this head.New tests, in
BoardNodeHelperArgvTests:-is atargv[1], no-e, and every argument is underLINUX_MAX_ARG_STRLEN— Linux's exact rule applied to the actual command lines, on macOSargv[2]still carries the first argumentE2BIG, then runs a program that size through the runnerThe last two are the Linux-equivalent proof that was practical here: no Linux runtime was available locally (no Docker daemon), so the Linux per-argument rule is applied as an assertion over the real command lines, and the platform's own ceiling is measured rather than assumed.
Audit round 14 — one P2 finding resolved at
102ed860272e86f5abc042a046fa0fbda23f55a9The fourteenth independent exact-head audit BLOCKED
b6717cdcwith one P2 UI change-detection finding, fixed in102ed860. Only page-side presentation JavaScript changed:observations_payload(), the/api/statusserving path, theobservationsblock's shape and counters, and the frozencode_mower.boardObservation.v1record contract are all byte-identical.codex:a12904a45ebb3b2878bf(P2) — a refresh that loses file coverage while the same acceptedno_workrecord stays on disk moves the row from idle with complete coverage to idle in the records read, but the record-only signature is unchanged, someaningfulChangesreturns nothing and both the Timeline and the aria-live region stay silent about the withdrawn claimBoardEffectiveStateChangeTests(5 tests, 19 subtest cells)A row's state is what it says, not only what its record said. Half of an idle row is recorded and half is derived: whether this refresh accounted for every candidate observation file, and whether the observation behind it is still current. Lose a candidate and the label, the state cue, the next action and the coverage evidence all change while the record on disk does not move a byte.
workSignaturereads the record alone, so exactly that refresh was invisible — a claim about the present withdrawn in silence, leaving an operator reading (or listening to) the change log with the last thing it said.One model, not one more condition.
idlePresentationnow returnssignature: the semantic identity of the reading it just chose — which reading, the coverage and freshness states that chose it, its label, class, next action, coverage verdict, the note with the counts and source kinds it quotes back, and the covered sources it names. The caveat is deliberately outside it, because the caveat is the one place the record's age is quoted.effectiveStateis the derived half of any row: that idle reading, the age classification (current/aged/unrecorded), and which kind of time the last meaningful update is reported from.rowSignatureisworkSignatureplus that, and is what every row carries and what change detection compares.workRowcomputes the idle reading once, before the row is built, so the headline, the state cues, the next action, the coverage evidence and the signature are all the same object. Nothing can reach the operator that change detection has not seen.workSignatureis untouched and stays a pure function of the record.isNewerObservationbreaks observation-order ties with it, and an ordering that consulted the clock or the file coverage would not be stable across the machines that read the same directory.No new timestamp churn. Ages are classified, never quoted.
"3m"becoming"4m"is the poll advancing, not the state moving:created_at, everychecked_at,observed_atandheartbeat_at,update_text,freshness.label,freshness.detailand the idle caveat all stay out. What is in is the discrete transition — an observation crossingOBSERVATION_STALE_SECONDSgenuinely stops claiming the present tense and is reported once, not on every poll.Self-review of every other rendered reading derived from payload-level coverage or freshness rather than record fields. The freshness pill's class is decided by the age classification plus unavailable sources, and the sources are recorded state
workSignaturealready carries.workStates,evidenceGroups,measurements, the unlinked row andworkActionsHtmlread the record only. Row membership underreconcileSessionScopesis coverage-driven and already surfaces as appeared/gone, which the round-12 tests pin. The empty-state message and the "Incomplete snapshot" banner are coverage-driven page chrome rather than row state; the banner is arole="status"region beside the rows, and the rows themselves now carry the same fact. Nothing else derived was found missing from the signature.Tests. A table of eleven single-page-lifetime transitions, each two consecutive refreshes with the exact Timeline entry and aria-live sentence asserted — complete → partial and partial → complete idle in both the file-coverage and record-source directions; an accepted
no_workbeside an unreadable candidate, a rejected one and one left past the cap; a coverage-gap vocabulary change and a polling-timestamp-only refresh, both of which must leave the live region untouched and the Timeline empty; and partial → partial refreshes that do and do not move what the row says (idle in the files read→idle in the records read, one more file left unread → changed while staying, and a gap opening underneath the worst one the row already states → silence). A signature-invariant test proving that across every coverage-driven transition the recorded half is byte-identical and the row signature moves if and only if the row does, with the signature decomposing exactly into the recorded half plus the idle reading's own identity. Selection preservation and per-surface agreement across a withdrawal and its restoration, with a second working session present so the operator's choice is a real one and the unaffected row is provably not announced. A no-churn test reading one payload at four instants inside the current window for one signature, then one instant past the threshold for a reported change.Mutation check. The signature is rebuilt from the record alone — the Board exactly as it was — and the mutant produces equal signatures across the withdrawal, an empty
meaningfulChanges, and precisely the silence the assertions forbid, while still rendering the two different headlines. The shipped signature, unmutated, announces the same refresh. All five new tests fail againstb6717cdcunder CI's own runner: 17 cells, 10 failures and 7 errors.Audit round 15 — one P2 finding resolved at
1c41085cc05a3f6552f212acdf2dff8bbb3dfb4aThe fifteenth independent exact-head audit BLOCKED
102ed860with one P2 observation-directory boundary finding, fixed in1c41085c. Only the observation path preflight changed: the candidate selection, the descriptor-open hardening, the accounting model, the page-side presentation JavaScript and the frozencode_mower.boardObservation.v1record contract are all untouched.codex:3f3b1f6a2b3c309bebbd(P2) —Path.exists()andPath.is_dir()ran outside theOSErrorhandler, so on 3.12/3.13 an observation directory under an inaccessible ancestor raisedPermissionErrorout ofobservations_payload()and aborted the whole status refresh instead of returning the unavailable-observations payload_classify_observation_path()answering every preflight question from a singleos.statthat cannot raise, returning one token from the closedOBSERVATION_PATH_STATESvocabularyBoardObservationPathBoundaryTests(9 tests, 23 subtest cells) plus one rendered-page testThe cost was never observations.
observations_payload()is one step ofstatus_payload(), so an exception at this boundary does not produce an unavailable observations block — it produces no snapshot at all.StatusCache._refreshcatches it, records a safe failure summary and arms the retry backoff, and the page keeps serving the snapshot before it: repository, PRs, workflow runs, lanes, spend and productivity all withheld over one local directory the Board only ever reads.Path.exists()andPath.is_dir()swallow only the errnos that mean a name did not resolve and re-raise everything else, which is exactly what makes them read like booleans and behave like neither.One call, one closed vocabulary.
_classify_observation_path()is the only place the directory's own metadata is asked about, and it cannot raise. It returnsdirectory,missing,not_directoryorunreadable.missingis the only state the Board may report as nothing recorded yet.not_directoryandunreadableare losses of evidence and degrade the block toavailable: falsewithunavailablecoverage and the existingdirectory_unreadablegap — never to an absence.observation path is not a directory,could not check the local Board observation path, andcould not list local Board observationsfor a directory lost between the check and the enumeration. None carries a path, an errno or an OS message.path_stateis published besidepath_exists, which keeps its meaning and gainsnullfor a path the Board could not examine — because it is not there is a claim an unreadable path cannot support. The page already branches onpath_exists === true, sonullcannot be read as an absence, and theavailable === falsebranch takes precedence over both.The missing/unreadable split is deliberately conservative. It reuses the errnos
pathlibitself treats as non-existence —ENOENT,ENOTDIR,EBADF,ELOOP, plus the three Windows equivalents and an unencodable path — so every path that answered "no" before still answersmissing, and only the errnos that previously escaped as an exception becomeunreadable. Nothing that used to work changes its answer.One
statalso bounds the syscalls. Existence and kind were two lookups with a window between them; they are now one metadata call per refresh, and an unreadable path is never enumerated or opened at all.Classification is still not a promise. The directory can be removed, swapped for a file, or have its permissions revoked after it was classified; the enumeration's own
OSErrorhandler catches that and reports it as an unavailable coverage gap rather than as an absence, on the same rule every lost candidate is held to. For a readable directory the candidate accounting model is byte-for-byte what round 12 and round 13 left.Scope check. The PR added exactly four filesystem metadata call sites in the observation read:
os.scandirand the per-entrylstat(both already guarded, and both now covered by table-driven failure tests), and the two preflight calls this fixes. No unguarded observation-directory metadata call remains. The identical pattern inagent_adapters_payload()is pre-existing onmain, untouched by this PR and about a different directory and contract; it is left alone rather than widening this diff.Tests. A table-driven boundary class: every real path state — directory, symlink to a directory, missing name, regular file, ancestor that is a file — with its payload and a syscall count of exactly one; every way the preflight
statcan fail, as eleven cases coveringPermissionError,EIO,ENAMETOOLONG,ESTALE,ETIMEDOUTand an errno-lessOSErroragainst the four missing errnos and an unencodable path, each asserting the exact payload state and that nothing escapes; an inaccessible ancestor on a real filesystem, skipped under root or where the mode is not enforced; an unreadable path proving zeroscandirand zeroopencalls follow; a directory removed, swapped for a file, or revoked between the check and the enumeration; per-entrylstatfailures keeping the candidate counted and never opened; and a privacy test asserting the serialized payload carries no path segment, no errno number, no OS message and no exception class name. Two tests assert the whole snapshot still refreshes — repo, remote PRs, local processes, adapters, campaigns, owner queue and productivity all present while observations alone report unavailable. One rendered-page test locks the health wording for the new state: could not read local Board observations, never No local Board observation is recorded yet, with the incomplete-snapshot banner shown and the unrelated PR row still rendered.Mutation check. Both metadata checks were moved back outside the guard, one at a time: restoring
path_exists: path.exists()and restoring theif not path.exists()/if not path.is_dir()branches each produce 21 failures against 4 passes in the boundary class. One test makesPath.exists()andPath.is_dir()fatal for its duration, so neither can return to this read undetected. Against102ed860'sboard.pyunder CI's own runner the new class is 6 failures and 22 errors across 9 tests.Audit round 16 — one P2 finding resolved at
c179387d830d89776c1419dd63a9874ac1bdb355The sixteenth independent exact-head audit BLOCKED
1c41085cwith one P2 snapshot-confirmation finding, fixed inc179387d. Page-side presentation JavaScript only:observations_payload(), the/api/statusserving path, theobservationsblock and the frozencode_mower.boardObservation.v1record contract are byte-identical.codex:fd2cddc6d640cb3abf85(P2) —/api/statusanswers a cold cache with metadata only and a stale one with the snapshot the last completed refresh produced, so a cachedno_workrecord read as current by every measure it carried and rendered green as idle with complete coverage for the whole ten minutes before its own age caught up, while the summary above it correctly reported an unconfirmed snapshot and reconciliation went on retiring work rows on its authoritysnapshotAuthority()is the one canonical reading of whether a payload may speak for now, derived once per payload and consumed by the observation summary,recordFreshness(), the idle classification, reconciliation and every absence message;idleAuthorityState()only chooses which reading explains the withholding, so refresh-in-progress and stale-with-error stay distinct and are both statedBoardSnapshotAuthorityTests(13 tests)Audit round 17 — one P2 finding resolved at
8ffd8215d137313e77e38abfe294fc6b9ae08c0dThe seventeenth independent exact-head audit BLOCKED
c179387dwith one P2 polling-authority finding, fixed in8ffd8215. Page-side presentation JavaScript only, on the same terms as round 16.codex:c8253cb2f2bf447734ed(P2) — the snapshot cache answers whether the server confirmed what it served and cannot answer whether this page has heard from the server since, so a page that rendered a fresh, completely coveredno_workrecord and then lost/api/statusheld a green idle with complete coverage for as long as the failures continued; record freshness, the idle reading and the reconciliation that retires work were recalculated only by a successfulrender(), so the ten-minute threshold never arrived, and the catch handler wrote a warning into the summary rather than withdrawing the claimstransportAuthority()classifies this page's own last status poll andsnapshotAuthority(data, transport)composes it with the server's cache reading, so every surface that descends from that one reading withdraws together; on failure the last successful payload is rerendered under that state rather than left as it was, the payload itself is never touched, and the local state is a flag, a capped failure count and one truncated error string that reaches no signatureBoardPollingAuthorityTests(10 tests, 9 subtest cells)Audit round 18 — one P2 finding resolved at
3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66The eighteenth independent exact-head audit BLOCKED
8ffd8215with one P2 polling-resilience finding introduced by round 17's transport refactor, fixed in3dec5ba5. Page-side presentation JavaScript only:observations_payload(), the/api/statusserving path, theobservationsblock and the frozen record contract are byte-identical to1c41085c.codex:b12d88b211c38c5a2c3d(P2) — a valid-JSON/api/eventsresponse is not necessarily a renderable one, andrenderEvents()was called outside the status handler and inside no boundary of its own, so a shape it could not walk threw out ofload(), skippedscheduleNextLoad()and ended this page's status refreshes permanently — leaving the last snapshot on screen still asserting the present tense, with no transport warning, because the status poll it came from had succeededrenderEventsIsolated()contains an unrenderable history where it happens,renderStatusOutcome()settles the status half and returns the delay the next poll should wait, andscheduleNextLoad(delayMs)moves into afinallythat isload()'s only exitBoardPollingContinuityTests(9 tests, 42 subtest cells)Three shapes, all valid JSON.
eventsarriving as a string, as an object that merely has alength, or as a list holding a null all parse cleanly and then throw insiderenderEvents— on the copy, on the reverse, and inside the row builder respectively.fetchJsonreports each of them as a success, because they are successful responses; the failure is in the shape, which only the renderer can discover.The events view is contained, in both directions. Nothing is written on failure. The history card keeps the last events it could render, because emptying it or replacing it with an error is the loss this boundary exists to prevent. The Health transport row is not touched either: it reports whether status polls are arriving, which is what every current-state claim on this page is gated on, so an events failure recorded there would withdraw a snapshot that rendered perfectly well. Round 17's rule is kept exactly —
/api/eventsfeeds a view that never claims to be current, so its failure withdraws nothing.The status half still reports everything it did.
renderStatusOutcome()returns the delay a rendered status response chose and the configured interval for every other outcome, which is the pacing the loop has always used. A payload whose shaperender()cannot walk is a status poll that did not complete: the claim is withdrawn, the retained payload is rerendered under the withdrawn authority, and the renderer's own message reaches the Health transport row through the existing bounded surface —1 failed status pollwith(prs.map is not a function)beside it. The failure is recorded before the rerender is attempted, so a rerender that fails too still leaves the count and the reason there. Nothing is swallowed to keep the loop alive.finally, not a statement after the try. Anything that throws past both handlers — a retained rerender that fails outright, a renderer with a plain bug in it, a handler that fails unexpectedly — still reaches the caller, so no failure is hidden; what it can no longer do is end the loop.scheduleNextLoad()still has exactly one call site, still clears the pending timer before arming the next, and is now reached on every path out ofload().Tests.
BoardPollingContinuityTestsdrives the shippedload()loop across one page lifetime, the same harness round 17 added. Every events outcome — fetch reject, a body that is not JSON, and the three shapes that throw insiderenderEvents— asserted to leave the status authority, the present-tense claim, the Health row, the pacing and the announcement region untouched, with the history card still showing the last events it could render. The full sixteen-cell matrix of every status outcome against every events outcome, including a status payload whose shaperender()cannot walk, asserting that authority follows the status half alone and the history follows the events half alone. Sixteen consecutive mixed polls in one lifetime asserting one timer armed per load, clearing exactly the one it replaces, never stacked, and nothing escapingload(). The fast-poll delay a still-refreshing snapshot chose surviving every events failure. A retained rerender that throws still arming the next poll, with the transport failure already recorded and the throw still reaching the caller. And recovery of both surfaces on the next healthy poll, byte-for-byte on every status surface.Mutation check. Three, each isolating one part of the fix. The pre-fix tail — the events render in no boundary and the arming after the try — stops the page refreshing on all three unrenderable shapes:
armed == 0, the timer count frozen, with a green idle with complete coverage and status polls answered still on screen, which is the finding exactly. The events render without its boundary but with thefinallykept escapesload()and the next poll is still armed. The arming moved back outside thefinallywith the boundary kept ends the loop on a throwing rerender. Against8ffd8215'sboard.pyunder CI's own runner the new class is 27 failures across its 9 tests. The existing structural assertion was tightened fromscheduleNextLoad(delayMs)is the last statement ofload()to it is the body of thefinallythat closesload().Harness.
_run_board_lifetimegains a third request outcome — a response that arrives with a body that is not JSON, which reachesfetchJsondifferently from one that never arrived — records what escapedload()rather than letting it abort the run, andmutatenow takes several fragments so one test can reconstruct a whole earlier shape.Focused validation at head
3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66tests/test_board.py8ffd8215; +9 new), 290 subtest cellsBoardTestsBoardWorkFirstViewTestsBoardPresentationTruthTestsStatusCacheTestsBoardObservationNonRegularEntryTestsBoardSnapshotAuthorityTestsBoardObservationReaderTestsBoardSessionScopeReconciliationTestsBoardPollingAuthorityTestsBoardPollingContinuityTestsBoardObservationPathBoundaryTestsBoardObservationOutcomeAccountingTestsBoardIdleFreshnessTestsBoardRunLifecycleDisplayTestsBoardObservationCoverageViewTestsBoardEffectiveStateChangeTestsBoardNodeHelperArgvTestsBoardObservationClassificationRaceTeststests/test_board_observation.py(B0 contract)tests/test_board_lease.pytests/test_cloud_manifest.py,tests/test_package_install_failure_taxonomy.pypython -m unittest discover -s tests -p "test_board*.py"(CI's own runner)ruff check .scripts/privacy_scan.pypython -m compileall -q src scriptsscripts/guard_package_workflows.pypython -m code_mower.migration release-readiness --jsonstatus: pass, 20 checks, 0 warningsgit diff --checknode --checkon the rendered page scriptThe board-dependent module set (
test_release_hygiene,test_release_qualify,test_doctor_campaign_readiness,test_adoption_diagnostics,test_tracker_queue,test_file_locks,test_context_readiness,test_productivity_report,test_campaign_discovery),tests/test_cloud_manifest.py,tests/test_package_install_failure_taxonomy.pyandtests/test_cloud_client.pywere re-run at this head: 684 passed, 976 subtests, zero failures, zero skips — unchanged from1c41085c. With the three Board modules above (293 passed, 297 subtests) that is 977 passed across fifteen modules.BoardPollingContinuityTestswas confirmed to fail against8ffd8215'sboard.pyunder CI's own runner: 9 tests, 27 failures. The failing cells are exactly the finding's shape: every unrenderable/api/eventspayload arms no timer, on its own and inside the mixed lifetime, and the retained rerender that throws arms none either. The two mutation proofs fail there for a different and equally deliberate reason — the shipped fragments they replace do not exist at that head. The one structural assertion inBoardTeststhat changed was tightened rather than relaxed:scheduleNextLoad(delayMs)must now be the body of thefinallythat closesload(), not merely the last statement in it. The earlier rounds' proofs stand unchanged at this head:BoardObservationPathBoundaryTestsstill fails against102ed860, all fiveBoardEffectiveStateChangeTestsstill fail againstb6717cdc, both classification-race tests still fail against the pre-b6717cdcopen, and the argv tests still fail by construction againstnode -e.The repository-wide suite was not re-run locally for this head; GitHub CI is the canonical full matrix.
Live Board smoke
A live
code-mower board serveon port 5390 against this tree served/api/statuswith anobservationsblock containing seven validatedcode_mower.boardObservation.v1records. The listener was then stopped with the supportedcode-mower board stop --port 5390 --yes.An earlier automated poll of that same endpoint reported no records. The cause was in the poll, not the Board: it searched the response body for pretty-printed JSON, while
/api/statusreturns compact JSON. Parsing the response as JSON rather than matching its formatting yields the seven records. No Board code changed as a result.That smoke was run at
6d6e73feand has not been repeated since. Headsc44b9137through312f5920change only page-side presentation JavaScript and CSS insiderender_board_html, leaving the/api/statusserving path,observations_payload()and the contract reader byte-identical.9c9ca3e0(round 10) is the first head since that smoke to changeobservations_payload(): it adds file-level coverage metadata to theobservationsblock and keeps the read bounded to 32 files of at mostMAX_BYTES + 1bytes each, with the frozencode_mower.boardObservation.v1record contract andsrc/code_mower/board_observation.pyuntouched. That change is covered by the reader and rendered-page tests listed in the round 10 comment; the live-serve smoke has not been repeated for it.68666b03(round 11) changes page-side presentation JavaScript only, leavingobservations_payload(), the/api/statusserving path and the contract reader byte-identical again.c978b4a1(round 12) changesobservations_payload()again: it accounts for every candidate outcome and adds the counters above to theobservationsblock, with the read still bounded to 32 files of at mostMAX_BYTES + 1bytes each and the frozen record contract untouched. That change is covered by the reader and rendered-page tests listed above; the live-serve smoke has not been repeated for it.ddae4632(round 13) changesobservations_payload()once more: it classifies each candidate with one non-blockinglstatand opens only regular files, leaving the/api/statusserving path, theobservationsblock's shape and the frozen record contract unchanged, with the read still bounded to 32 files of at mostMAX_BYTES + 1bytes each. That change is covered by the reader and rendered-page tests listed above; the live-serve smoke has not been repeated for it.b6717cdc(pre-audit hardening) changesobservations_payload()once more: the candidate open becomes non-blocking and no-follow with the kind re-decided on the descriptor, leaving the/api/statusserving path, theobservationsblock's shape, its counters and the frozen record contract unchanged, with the read still bounded to 32 files of at mostMAX_BYTES + 1bytes each. Its other half is test-only. That change is covered by the reader, race and rendered-page tests listed above; the live-serve smoke has not been repeated for it.102ed860(round 14) changes page-side presentation JavaScript only:observations_payload(), the/api/statusserving path, theobservationsblock's shape and counters, and the frozen record contract are byte-identical tob6717cdc.1c41085c(round 15) changesobservations_payload()once more: the directory's own preflight metadata is decided by one guardedos.statthat cannot raise, and the block gains apath_statetoken withpath_existsbecomingnullfor a path that could not be examined. The/api/statusserving path, the candidate accounting and its counters, the descriptor-open hardening and the frozen record contract are unchanged, with the read still bounded to 32 files of at mostMAX_BYTES + 1bytes each. That change is covered by the boundary, reader and rendered-page tests listed above; the live-serve smoke has not been repeated for it.c179387d(round 16),8ffd8215(round 17) and3dec5ba5(round 18) change page-side presentation JavaScript only: every edit tosrc/code_mower/board.pyacross those three heads is inside the_BOARD_HTMLpage template, soobservations_payload(), the/api/statusserving path, theobservationsblock's shape and counters, the descriptor-open hardening and the frozen record contract are byte-identical to1c41085c.Status
This PR is a draft,
needs-codex-auditis retained, and it is neither marked ready nor merged.Merge remains blocked pending an independent exact-head audit of
3dec5ba5with every P0/P1/P2 resolved, normal CI, and the authoritativecode-mower/gateverdict. At this head the PR carries no CI result — the canonical full matrix (Python 3.12 / 3.13 / 3.14, actionlint, release readiness, base-install verification, fresh-clone and package-install rehearsals, graph containment) runs on GitHub and is pending — and no gate verdict.