Skip to content

Board: implement the work-first Now, Timeline, Releases, and Health views - #1000

Merged
jeffhuber merged 20 commits into
mainfrom
claude/948-board-work-first
Sep 16, 2026
Merged

jeffhuber merged 20 commits into
mainfrom
claude/948-board-work-first

Conversation

@jeffhuber

@jeffhuber jeffhuber commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

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.v1 contract 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 session Code Mower Claude #948)

Exact head

3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66

Base origin/main at 2a07dc542c8e01ce5c8cfe772276914e6adcf962 (merged #957). Twenty linear commits, no merge commit: the implementation at ac9ca53c, 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), and 3dec5ba5 (round 18, one P2).

Changed files

File Change
src/code_mower/board.py observations_payload() reading the B0 contract, --observations-path, observations in /api/status, and the four-view work-first page inside render_board_html
tests/test_board.py BoardObservationReaderTests and BoardWorkFirstViewTests, executing the shipped renderer JS under node against a stubbed DOM
tests/test_board_lease.py the lease renderer helper moved off node -e onto stdin
docs/board-data-contract.md Board Views and Board Observations sections
docs/launch-command-surface.md --observations-path row

Five files, +11854 / -305 across the twenty commits. src/code_mower/board_observation.py is 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.

  • Now — work rows, selected-work evidence, participant strip, and the existing owner queue / lane work / supervised pilot / open PR sections.
  • Timeline — meaningful recent changes, local Board history, verdict timeline, recent workflow runs.
  • Releases — release campaigns, productivity, spend. Completed campaign history lives here, not in front of current work.
  • Health — observation sources and freshness, Board version and restart state, cache state, GitHub availability, gate alerts, orchestrator lease, agent cards, local Board and lane processes.

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/gate verdict 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 carry hidden so 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_at and heartbeat_at are 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_work is idle with complete coverage and names the fresh complete source kinds, and only while the observation read itself covered every candidate file; unlinked claims no stage and no route; an unavailable measurement renders not recorded and 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

observations is 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_payload drops the block, so code-mower board record and --record-events persist 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 6d6e73fe8e23697f5afa9cdb3ddfb5cad2a484c7

The independent Code Mower Codex exact-head merge-authority audit BLOCKED ac9ca53c with exactly three P2 findings. All three are fixed narrowly in 6d6e73fe; ownership stays inside the same four #948 files.

Finding Fix Regression test
codex:88916db59e3e0cef6252 — a same-numbered PR in a foreign repository got this repository's link recordedPrUrl() requires row.repository === REPO before offering a link; a foreign record is still rendered truthfully, named as belonging to that other repository, with no link invented test_a_same_numbered_pull_request_in_another_repository_is_never_linked
codex:ec5060bbc67e0e90d36d — duplicate linked observations produced competing rows workRows() deduplicates by the opaque identity (session, worktree, work id); the newest observation wins on created_at, then last meaningful update, then row signature — all deterministic. Change tracking consumes the same set. Unlinked consolidation is unchanged test_duplicate_observations_of_one_identity_render_the_newest_once
codex:80220e57a31d75a48022 — observation files were read unbounded before decode observations_payload() reads at most MAX_BYTES + 1 bytes per file and rejects an oversize file on that length with the contract's own invalid_contract diagnostic; the remainder is never loaded or decoded test_an_oversize_file_is_read_bounded_and_rejected_before_it_is_decoded

docs/board-data-contract.md gains 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 c44b9137ea62109e5846f3bb2c451b5532b1f3d8

The second independent Code Mower Codex exact-head audit cleared all three round-1 findings and BLOCKED 6d6e73fe with two adjacent P2 consistency findings. Both are fixed in c44b9137; ownership stays inside the same four #948 files and src/code_mower/board_observation.py remains 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.

Finding Fix Regression test
codex:35af9aa458ff662bf884participantSummary() 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 left Participants are summarized from the deduplicated rows the work list renders, from the runs each row was actually built from, resolved against that row's own sources. One run seen dispatched and then running is one run, in the phase the newest observation records, under either file order test_a_run_that_changes_phase_across_observations_counts_once_and_newest
codex: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 row 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 recorded; the age of the oldest retained observation, or no claimed age when one 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

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, and all three round-1 fixes are preserved.

docs/board-data-contract.md states 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 6d6e73fe and pass at c44b9137.

Audit round 3 — two P2 findings resolved at 6c8ef9162c3bf8873cb20ca81836f499a204f6b6

The third independent exact-head audit cleared both round-2 findings and BLOCKED c44b9137 with two P2 findings, both fixed in 6c8ef916.

Finding Fix Regression test
codex:77b5ccf0a4ab02656f91 — row order reused STATE_RULES' headline precedence, where merged is rank 0, so finished work sorted first and was what an operator who had chosen nothing was shown Row urgency is its own explicit ranking in four bands — blocked, waiting on a named person, untrustworthy evidence, in flight — with terminal headlines placed last by explicit rank rather than by falling off the end. Headline precedence is untouched test_row_order_is_urgency_and_never_headline_precedence, test_finished_work_never_outranks_actionable_or_blocked_work
codex: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 body Each action carries an id derived from the work it acts on and the action's own name, plus data-focus-fallback naming its row button, read off the element before the update destroys it test_detail_actions_keep_keyboard_focus_across_a_refresh, test_a_detail_action_that_disappears_never_hands_focus_to_another_control

Found 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 a374728c3dca61398d782add48e68a36e7d4708a

The fourth independent exact-head audit cleared all prior findings and BLOCKED 6c8ef916 with two P2 edge cases. Both are fixed in a374728c; ownership stays inside the same #948 files, the one-detail-region interaction model is unchanged, and src/code_mower/board_observation.py remains untouched.

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 it contributed no layout height and was drawn over the Work Now and Participants sections The detail is still the single region rendered inside the selected row; at desktop the row itself is a two-column grid and the detail is one of its items, so it stays 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 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 differing only in punctuation (owner/re.po against 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: a letter, digit or hyphen stands for itself and every other code unit — including the _ escape and code units outside ASCII — becomes _<hex>_, so an id decodes back to exactly one key. Rows, the detail's aria-labelledby, the action ids and every focus lookup 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 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 6c8ef916 and pass at a374728c.

Audit round 5 — one P2 finding resolved at e237195536533a8a024964ef4975b1b9df2589c2

The fifth independent exact-head audit cleared both round-4 findings and BLOCKED a374728c with one P2.

Finding Fix Regression test
codex:ad5cf581a3c0258a5c5b — row urgency was read from the row headline alone, and the headline is chosen by display precedence, so a record stating both ready_to_merge and approval_required read as "ready to merge" and was ordered as if the approval nobody has given yet were not recorded Urgency is 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. Headline precedence is untouched test_row_urgency_is_computed_from_every_recorded_state, test_headline_only_urgency_would_bury_work_that_still_owes_something

Audit round 6 — two P2 findings resolved at d79b9bd79053a3412d16c2756c35784c5201df43

The sixth independent exact-head audit cleared the round-5 finding and BLOCKED e2371955 with two P2 completeness findings.

Finding Fix Regression test
codex:776f336c36db45a3bc06provider_suspended and update_required are reasons the frozen contract accepts and the Board had no state rule for either, so neither produced a display state and neither could be ranked Both have a display state — "provider run suspended" and "branch update required" — and both are ranked with the blockers. The coverage is now read from board_observation.REASON_ROUTES, ACTORS and ACTIONS, 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_order
codex: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 new The offset is preserved across the refresh and restored against the same opaque work identity the selection is kept against; a different identity opens at the top of its own evidence and a selection that stops being rendered has nothing to restore onto test_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_position

Found 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 d30596492cc86ab4cac4323bb9932357c7726482

The seventh independent exact-head audit cleared every prior finding and BLOCKED d79b9bd7 with one P2 lifecycle-display consistency finding, fixed in d3059649. Ownership stays inside the same #948 files and src/code_mower/board_observation.py remains untouched.

Finding Fix Regression test
codex:044c7d368aacd887ee1a — the frozen contract requires a suspended provider run to carry phase === "failed", and lifecycle.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 else One shared helper — runState() / 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 is suspended alone, 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 state BoardRunLifecycleDisplayTests — six tests, of which two are table-driven over all 21 accepted lifecycle/phase pairs

The 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, uncertain and 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 through board_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_run fixture 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_CLASSES are 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's lifecycle_category was 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 c759793f46e70eb112bb303dc25d965e364fe875

The eighth independent exact-head audit cleared every lifecycle finding from round 7 and BLOCKED d3059649 with one P2 hidden-tab scroll-state finding, fixed in c759793f. Ownership stays inside the same #948 files and src/code_mower/board_observation.py remains untouched.

Finding Fix Regression test
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 with display: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 top The offsets 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 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 any number of hidden polls leave what is remembered untouched. Each identity keeps its own place and none is ever inherited across identities; an identity the Board stops showing is dropped by the render that stops showing it, and the map is bounded besides, evicting least recently touched first. A replacement rendered while Now was hidden is never read from either — only the element the page has actually synchronised is, and a scroll the operator performs is always believed test_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_bounded

The 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 in renderWork(), 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 68666b039d0b197ac6f929ad6d692240c0a8fae0

The eleventh independent exact-head audit cleared the truncation handling added at 9c9ca3e0 and BLOCKED it with one P2 stale-idle-truth finding, fixed in 68666b03. The change is page-side presentation only: observations_payload(), the contract reader and the frozen code_mower.boardObservation.v1 record contract are untouched.

Finding Fix Regression test
codex:fa58d877f7e1c8039cab — a valid no_work record that had gone stale still rendered green as idle with complete coverage with nothing to do in this session beside it, indefinitely, while recordFreshness().current was false and the row's own age pill said last 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 now One shared classifier, idlePresentation(), decides what a no_work record 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 moving BoardIdleFreshnessTests: 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_idle

Matrix semantics. The new tests are driven by a table over freshness {current, stale, unavailable/unknown} × coverage {complete, partial, truncated} for one no_work record. Exactly one of the nine cells — current evidence under whole coverage — yields idle with complete coverage, nothing to do in this session and an ok cue. 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", and warn, bad or muted styling — never ok, and never a present-tense claim.

The freshness axis is built from what the frozen contract can actually record: a no_work record 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 shipped render() under node, 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. Against 9c9ca3e0, 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 #948idle, 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 as merged or ready to merge are 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 c978b4a1f2cfe96702e7d5581805d92280d5af22

The twelfth independent exact-head audit cleared the stale-idle handling added at 68666b03 and BLOCKED it with one P2 reader-outcome-completeness finding, fixed in c978b4a1. The frozen code_mower.boardObservation.v1 record contract and src/code_mower/board_observation.py are untouched.

Finding Fix Regression test
codex:95516310f2225639d2e8 (P2) — a selected observation file raising OSError incremented rejected but left the read reporting coverage: complete, so a readable no_work record beside an unreadable work record could assert a current idle session and reconciliation could retire the work row the unread file might have recorded one candidate-accounting model, _ObservationAccounting, recording exactly one outcome per candidate; coverage is whole only when nothing was lost BoardObservationOutcomeAccountingTests (9 tests, 26 subtest cells)

One accounting, with counters that partition the candidate set. _ObservationAccounting replaces the ad-hoc rejected counter and is the only place completeness is decided. Its invariants are asserted directly by the tests and documented in docs/board-data-contract.md:

  • candidate_files = selected_files + omitted_files
  • attempted_files = selected_files
  • attempted_files = read_files + unreadable_files
  • read_files = accepted_records + invalid_records
  • accepted_records = len(records)
  • unaccounted_files = unreadable_files + invalid_records
  • rejected = 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 refused
  • coverage_complete is true only when omitted_files and unaccounted_files are both zero

Malformed, 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_work record beside it, so every kind of loss makes coverage partial and suppresses affirmative absence claims. coverage_gaps[] states which kinds occurred, from the fixed vocabulary directory_unreadable, files_omitted, files_unreadable, records_invalid. An unlistable directory still reports unavailable with null totals.

Diagnostics stay privacy-safe and now distinguish the two failures. unreadable_file names a candidate that raised on open or on read; invalid_contract and 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 new observationDiagnostics, so the page no longer prints the record file name the payload carries.

Every consumer routes through that one result. observationCoverage gains incomplete/whole and reports each gap in its note, while truncated keeps meaning the cap alone. idleCoverageState returns a new unread state 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. reconcileSessionScopes retires 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 on incomplete rather than on the cap.

Matrix semantics. The new tests are driven by a table over candidate outcomes {accepted, invalid JSON, oversize, OSError on open, OSError on 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 is partial and 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 + 1 request), 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 under node, and reconciliation refusing to retire an active work row under incomplete evidence.

Mutation check, both sides. Dropping files_unreadable from the reader's gaps makes it call the read whole — and the view still refuses, because it counts unaccounted candidates itself; removing the view's unread gate restores idle with complete coverage and nothing to do in this session on the very same read. The shipped code, unmutated, says neither. Seven of the nine new tests fail at method level against 68666b03, 33 failures counting subtest cells.

Self-review covered every reader exception branch — os.scandir, a non-directory path, entry.stat(), Path.open, handle.read, and board_observation.decode (which wraps invalid UTF-8, empty and non-object payloads into invalid_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 ddae4632712ce3de2a9d10fe5669b70f7d9cbc50

The thirteenth independent exact-head audit cleared the candidate accounting added at c978b4a1 and BLOCKED it with one P2 availability finding, fixed in ddae4632. The frozen code_mower.boardObservation.v1 record contract and src/code_mower/board_observation.py are untouched.

Finding Fix Regression test
codex:0b8f439aeac1e55fe63c (P2) — the observation selector accepted every directory entry ending in .json and 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 it classify every candidate with one lstat that never opens anything, open only regular files, and account for every refused entry as unreadable in the same candidate-accounting model BoardObservationNonRegularEntryTests (10 tests, 14 subtest cells)

The bounds only start applying once the file is open. MAX_OBSERVATION_FILES bounds how many files are opened and MAX_BYTES + 1 bounds how many bytes each is asked for, but neither is a bound on open() itself. Opening a FIFO for reading with no writer does not fail — it waits. One such entry wedges observations_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_files now calls entry.stat(follow_symlinks=False) — one lstat, which does not block on a pipe — and returns each selected candidate with whether stat.S_ISREG held. _read_observation_records opens 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 is selected, and it lands in unreadable_files with the same closed unreadable_file diagnostic a file that raised gets. So coverage is partial, coverage_gaps[] carries files_unreadable, the message says the snapshot is incomplete, and both absence claims stay withheld: idleCoverageState returns unread, the row reads idle in the records read, and reconcileSessionScopes does 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, and board_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-source no_work record, with a platform-safe SkipTest where 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, partial coverage with files_unreadable and no truncated, 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, where files_omitted and files_unreadable are 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: complete with candidate_files: 1 and renders idle with complete coverage / nothing to do in this session on the very same read. The shipped code, unmutated, says neither. Two source-level mutations were also run and caught: removing follow_symlinks=False opens 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 against c978b4a1, 13 failures counting subtest cells; the two that pass there do so honestly, because a directory and an OSError were already routed to unreadable by round 12.

Pre-audit hardening at b6717cdc

Two 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 lstat and 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 lstat filter 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.fstat then 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.fdopen takes it, and every failing path in between — a refusal, an fstat that raised, an fdopen that failed — closes it exactly once. All of them, plus every genuine open or read error, land in the one centralized unreadable_file accounting 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_GUARDS and 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 wherever os.name == "posix", so nothing can silently restore a blocking path on macOS or Linux. No write flag is ever set.

New tests, in BoardObservationClassificationRaceTests and the existing non-regular class:

Test Proves
a candidate replaced by a named pipe is refused, not awaited the real race: the shipped classifier sees a genuine regular file, the entry becomes a FIFO before the open, and the read returns inside the deadline as one refused candidate with partial coverage
a candidate replaced by a symlink is refused, not followed O_NOFOLLOW holds under the race; the link's valid target appears nowhere on the page
a refused candidate leaks no descriptor eight repeated reads over a refused entry leave /dev/fd unchanged
removing the nonblocking open guard would wedge the refresh the mutant does not return in 2s against a real pipe; the shipped code does
removing the descriptor-type check would read the replacement the mutant reads the pipe and blames the contract (records_invalid) instead of reporting a file it could not read (files_unreadable)
the open succeeds and the descriptor check is what refuses os.open on the pipe succeeds with the shipped flags, so only fstat can be the refusal
classifying every candidate as regular no longer wedges the refresh the lstat filter is defence in depth, not the thing keeping the read prompt: removing it costs one refused descriptor and nothing else
the open guards are in force on every supported platform explicit platform handling, asserted rather than assumed

Both 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_files 0 != 1).

2 — every Python 3.12 / 3.13 / 3.14 package-matrix job failed on argv

Run 35131068414 failed 62 Board tests with OSError: [Errno 7] Argument list too long: '/usr/local/bin/node' at tests/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 at MAX_ARG_STRLEN — a fixed 32 pages, 131,072 bytes, unrelated to ARG_MAX and not raisable — so node -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:

Helper Program size Was
_render_board_sequence (the reported failure) 144 KB node -e
_render_board_focus 144 KB node -e
_eval_board_page 144 KB node -e
_render_board_dom 144 KB node -e
_eval_board_view 88 KB node -e
_eval_board_truth 20 KB node -e
_run_board_poll_script small temp file (already safe; folded in for uniformity)
test_board_lease.py lease renderer 89 KB — 68% of the Linux ceiling node -e

The harness process.argv indices shift by one, because - now occupies argv[1] and the first JSON argument is argv[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:

Test Proves
no helper puts its generated program in argv six helpers are driven and their real command lines inspected: the program arrived on stdin, - is at argv[1], no -e, and every argument is under LINUX_MAX_ARG_STRLEN — Linux's exact rule applied to the actual command lines, on macOS
every Node command in this module is built in one place the next helper someone adds cannot reintroduce the ceiling
a program past the Linux argv limit runs and keeps its arguments a program larger than any argument Linux accepts runs, and argv[2] still carries the first argument
argv has a ceiling on this platform that stdin does not measures this platform's real ceiling by growing an argument until E2BIG, then runs a program that size through the runner

The 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 102ed860272e86f5abc042a046fa0fbda23f55a9

The fourteenth independent exact-head audit BLOCKED b6717cdc with one P2 UI change-detection finding, fixed in 102ed860. Only page-side presentation JavaScript changed: observations_payload(), the /api/status serving path, the observations block's shape and counters, and the frozen code_mower.boardObservation.v1 record contract are all byte-identical.

Finding Fix Regression test
codex:a12904a45ebb3b2878bf (P2) — a refresh that loses file coverage while the same accepted no_work record stays on disk moves the row from idle with complete coverage to idle in the records read, but the record-only signature is unchanged, so meaningfulChanges returns nothing and both the Timeline and the aria-live region stay silent about the withdrawn claim one canonical signature carrying both halves of a row's state — what the record said and what the page derived from the file coverage and the clock — built from the same objects the row is rendered from BoardEffectiveStateChangeTests (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. workSignature reads 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.

  • idlePresentation now returns signature: 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.
  • effectiveState is 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 is workSignature plus that, and is what every row carries and what change detection compares.
  • workRow computes 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 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 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, every checked_at, observed_at and 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, 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 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; the banner is a role="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_work beside 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 readidle 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 against b6717cdc under CI's own runner: 17 cells, 10 failures and 7 errors.

Audit round 15 — one P2 finding resolved at 1c41085cc05a3f6552f212acdf2dff8bbb3dfb4a

The fifteenth independent exact-head audit BLOCKED 102ed860 with one P2 observation-directory boundary finding, fixed in 1c41085c. Only the observation path preflight changed: the candidate selection, the descriptor-open hardening, the accounting model, the page-side presentation JavaScript and the frozen code_mower.boardObservation.v1 record contract are all untouched.

Finding Fix Regression test
codex:3f3b1f6a2b3c309bebbd (P2) — Path.exists() and Path.is_dir() ran outside the OSError handler, so on 3.12/3.13 an observation directory under an inaccessible ancestor raised PermissionError out of observations_payload() and aborted the whole status refresh instead of returning the unavailable-observations payload one _classify_observation_path() answering every preflight question from a single os.stat that cannot raise, returning one token from the closed OBSERVATION_PATH_STATES vocabulary BoardObservationPathBoundaryTests (9 tests, 23 subtest cells) plus one rendered-page test

The cost was never observations. observations_payload() is one step of status_payload(), so an exception at this boundary does not produce an unavailable observations block — it produces no snapshot at all. StatusCache._refresh catches 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() and Path.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 returns directory, missing, not_directory or unreadable.
  • missing is the only state the Board may report as nothing recorded yet. not_directory and unreadable are losses of evidence and degrade the block to available: false with unavailable coverage and the existing directory_unreadable gap — never to an absence.
  • Each of the three has its own fixed diagnostic: observation path is not a directory, could not check the local Board observation path, and could not list local Board observations for a directory lost between the check and the enumeration. None carries a path, an errno or an OS message.
  • path_state is published beside path_exists, which 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. The page already branches on path_exists === true, so null cannot be read as an absence, and the available === false branch takes precedence over both.

The missing/unreadable split is deliberately conservative. It reuses the errnos pathlib itself 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 answers missing, and only the errnos that previously escaped as an exception become unreadable. Nothing that used to work changes its answer.

One stat also 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 OSError handler 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.scandir and the per-entry lstat (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 in agent_adapters_payload() is pre-existing on main, 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 stat can fail, as eleven cases covering PermissionError, EIO, ENAMETOOLONG, ESTALE, ETIMEDOUT and an errno-less OSError against 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 zero scandir and zero open calls follow; a directory removed, swapped for a file, or revoked between the check and the enumeration; per-entry lstat failures 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 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.

Audit round 16 — one P2 finding resolved at c179387d830d89776c1419dd63a9874ac1bdb355

The sixteenth independent exact-head audit BLOCKED 1c41085c with one P2 snapshot-confirmation finding, fixed in c179387d. Page-side presentation JavaScript only: observations_payload(), the /api/status serving path, the observations block and the frozen code_mower.boardObservation.v1 record contract are byte-identical.

Finding Fix Regression test
codex:fd2cddc6d640cb3abf85 (P2) — /api/status answers a cold cache with metadata only and a stale one with the snapshot the last completed refresh produced, so a cached no_work record 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 authority snapshotAuthority() 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 stated BoardSnapshotAuthorityTests (13 tests)

Audit round 17 — one P2 finding resolved at 8ffd8215d137313e77e38abfe294fc6b9ae08c0d

The seventeenth independent exact-head audit BLOCKED c179387d with one P2 polling-authority finding, fixed in 8ffd8215. Page-side presentation JavaScript only, on the same terms as round 16.

Finding Fix Regression test
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 covered no_work record and then lost /api/status held 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 successful render(), so the ten-minute threshold never arrived, and the catch handler wrote a warning into the summary rather than withdrawing the claims 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 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 signature BoardPollingAuthorityTests (10 tests, 9 subtest cells)

Audit round 18 — one P2 finding resolved at 3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66

The eighteenth independent exact-head audit BLOCKED 8ffd8215 with one P2 polling-resilience finding introduced by round 17's transport refactor, fixed in 3dec5ba5. Page-side presentation JavaScript only: observations_payload(), the /api/status serving path, the observations block and the frozen record contract are byte-identical to 1c41085c.

Finding Fix Regression test
codex:b12d88b211c38c5a2c3d (P2) — a valid-JSON /api/events response is not necessarily a renderable one, and renderEvents() was called outside the status handler and inside no boundary of its own, so a shape it could not walk threw out of load(), skipped scheduleNextLoad() 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 succeeded one poll is two independently guarded halves and one guarantee: renderEventsIsolated() contains an unrenderable history where it happens, renderStatusOutcome() settles the status half and returns the delay the next poll should wait, and scheduleNextLoad(delayMs) moves into a finally that is load()'s only exit BoardPollingContinuityTests (9 tests, 42 subtest cells)

Three shapes, all valid JSON. 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 — on the copy, on the reverse, and inside the row builder respectively. fetchJson reports 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/events feeds 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 shape render() 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 poll with (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 of load().

Tests. BoardPollingContinuityTests drives the shipped load() 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 inside renderEvents — 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 shape render() 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 escaping load(). 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 the finally kept escapes load() and the next poll is still armed. The arming moved back outside the finally with the boundary kept ends the loop on a throwing rerender. Against 8ffd8215's board.py under CI's own runner the new class is 27 failures across its 9 tests. The existing structural assertion was tightened from scheduleNextLoad(delayMs) is the last statement of load() to it is the body of the finally that closes load().

Harness. _run_board_lifetime gains a third request outcome — a response that arrives with a body that is not JSON, which reaches fetchJson differently from one that never arrived — records what escaped load() rather than letting it abort the run, and mutate now takes several fragments so one test can reconstruct a whole earlier shape.

Focused validation at head 3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66

Check Result
tests/test_board.py 273 passed, 0 skipped (264 at 8ffd8215; +9 new), 290 subtest cells
  BoardTests 63 passed
  BoardWorkFirstViewTests 50 passed, 29 subtest cells
  BoardPresentationTruthTests 23 passed
  StatusCacheTests 20 passed
  BoardObservationNonRegularEntryTests 14 passed, 14 subtest cells
  BoardSnapshotAuthorityTests 13 passed, 35 subtest cells
  BoardObservationReaderTests 12 passed
  BoardSessionScopeReconciliationTests 11 passed
  BoardPollingAuthorityTests 10 passed, 9 subtest cells
  BoardPollingContinuityTests 9 passed (new), 42 subtest cells
  BoardObservationPathBoundaryTests 9 passed, 23 subtest cells
  BoardObservationOutcomeAccountingTests 9 passed, 26 subtest cells
  BoardIdleFreshnessTests 7 passed
  BoardRunLifecycleDisplayTests 6 passed
  BoardObservationCoverageViewTests 5 passed
  BoardEffectiveStateChangeTests 5 passed, 19 subtest cells
  BoardNodeHelperArgvTests 4 passed
  BoardObservationClassificationRaceTests 3 passed
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) 293 tests, OK
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 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.py and tests/test_cloud_client.py were re-run at this head: 684 passed, 976 subtests, zero failures, zero skips — unchanged from 1c41085c. With the three Board modules above (293 passed, 297 subtests) that is 977 passed across fifteen modules.

BoardPollingContinuityTests was confirmed to fail against 8ffd8215's board.py under CI's own runner: 9 tests, 27 failures. The failing cells are exactly the finding's shape: every unrenderable /api/events payload 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 in BoardTests that changed was tightened rather than relaxed: scheduleNextLoad(delayMs) must now be the body of the finally that closes load(), not merely the last statement in it. The earlier rounds' proofs stand unchanged at this head: BoardObservationPathBoundaryTests still fails against 102ed860, all five BoardEffectiveStateChangeTests still fail against b6717cdc, both classification-race tests still fail against the pre-b6717cdc open, and the argv tests still fail by construction against node -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 serve on port 5390 against this tree served /api/status with an observations block containing seven validated code_mower.boardObservation.v1 records. The listener was then stopped with the supported code-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/status returns 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 6d6e73fe and has not been repeated since. Heads c44b9137 through 312f5920 change only page-side presentation JavaScript and CSS inside render_board_html, leaving the /api/status serving path, observations_payload() and the contract reader byte-identical. 9c9ca3e0 (round 10) is the first head since that smoke to change observations_payload(): it adds file-level coverage metadata to the observations block and keeps the read bounded to 32 files of at most MAX_BYTES + 1 bytes each, with the frozen code_mower.boardObservation.v1 record contract and src/code_mower/board_observation.py untouched. 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, leaving observations_payload(), the /api/status serving path and the contract reader byte-identical again. c978b4a1 (round 12) changes observations_payload() again: it accounts for every candidate outcome and adds the counters above to the observations block, with the read still bounded to 32 files of at most MAX_BYTES + 1 bytes 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) changes observations_payload() once more: it classifies each candidate with one non-blocking lstat and opens only regular files, leaving the /api/status serving path, the observations block's shape and the frozen record contract unchanged, with the read still bounded to 32 files of at most MAX_BYTES + 1 bytes 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) changes observations_payload() once more: the candidate open becomes non-blocking and no-follow with the kind re-decided on the descriptor, leaving the /api/status serving path, the observations block's shape, its counters and the frozen record contract unchanged, with the read still bounded to 32 files of at most MAX_BYTES + 1 bytes 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/status serving path, the observations block's shape and counters, and the frozen record contract are byte-identical to b6717cdc. 1c41085c (round 15) changes observations_payload() once more: the directory's own preflight metadata is decided by one guarded os.stat that cannot raise, and the block gains a path_state token with path_exists becoming null for a path that could not be examined. The /api/status serving 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 most MAX_BYTES + 1 bytes 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) and 3dec5ba5 (round 18) change page-side presentation JavaScript only: every edit to src/code_mower/board.py across those three heads is inside the _BOARD_HTML page template, so observations_payload(), the /api/status serving path, the observations block's shape and counters, the descriptor-open hardening and the frozen record contract are byte-identical to 1c41085c.

Status

This PR is a draft, needs-codex-audit is retained, and it is neither marked ready nor merged.

Merge remains blocked pending an independent exact-head audit of 3dec5ba5 with every P0/P1/P2 resolved, normal CI, and the authoritative code-mower/gate verdict. 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.

…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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: ac9ca53cb4a8d40989b228198b79d5384b043eee
Findings: P0=0, P1=0, P2=3, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Match repository identity before attaching a PR link -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-aggcu5nh/wt/src/code_mower/board.py:2543
    Finding ID: codex:88916db59e3e0cef6252
    When a custom observations directory contains records from another repository, matching only the PR number attaches the current Board repository's URL to unrelated work. The reader does not filter records by scope.repository, although each row preserves that identity. Require a matching repository before offering the link, or filter foreign records during ingestion.
  • [P2] Deduplicate work observations by identity -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-aggcu5nh/wt/src/code_mower/board.py:2310
    Finding ID: codex:ec5060bbc67e0e90d36d
    If two accepted files describe the same session, worktree and work ID, both rows survive because only unlinked records are consolidated. Selecting that identity then expands both rows, producing duplicate row IDs and multiple workdetail regions; the change tracker also silently retains only one record. Resolve duplicate work identities deterministically, preferably using the latest observation, before rendering and tracking changes.
  • [P2] Bound observation reads before decoding -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-aggcu5nh/wt/src/code_mower/board.py:1218
    Finding ID: codex:80220e57a31d75a48022
    An oversized observation file is read completely into memory before decode enforces MAX_BYTES. Consequently, an accidentally large JSON file can consume substantial memory and delay every snapshot refresh despite eventually being rejected. Read at most MAX_BYTES + 1 bytes and reject oversized input without loading the remainder.

…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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Audit findings addressed — new exact head 6d6e73fe8e23697f5afa9cdb3ddfb5cad2a484c7

All three P2 findings from the exact-head merge-authority audit of ac9ca53c are fixed narrowly. Ownership stays confined to the four #948 files (code-mower.yml unchanged this round — the three touched files are listed below).

Finding → fix → test

P2 codex:88916db59e3e0cef6252 — repository identity before a PR link

  • Fix: src/code_mower/board.py, recordedPrUrl() / workActionsHtml(). A link is offered only when row.repository === REPO. A record from another repository is still accepted and rendered truthfully — PR #946 in other-org/other-repo, not this repository; no local link recorded — and no local link is invented for it. Foreign records are not filtered at ingestion, so nothing observed is hidden.
  • Test: BoardWorkFirstViewTests.test_a_same_numbered_pull_request_in_another_repository_is_never_linked. Builds a contract-valid ready record whose scope.repository and every runs[].binding.repository name other-org/other-repo, with a payload that does record PR Board: freeze the provider-neutral observation contract and truth fixtures #946 for this repository. Asserts the link is absent and the truthful foreign line is present, then asserts the same payload with this repository's record does get the recorded link — so the suppression is the repository check and nothing else.

P2 codex:ec5060bbc67e0e90d36d — deduplicate linked work observations by opaque identity

  • Fix: src/code_mower/board.py, workRows() plus new observationOrder() / isNewerObservation(). Rows are keyed by the existing opaque identity (session, worktree, work id). For a linked identity the newest observation wins, ordered on recorded created_at, then last meaningful update, then row signature — all deterministic, so directory listing order cannot change the winner. noteChanges() already consumes workRows() output, so change tracking reads the same deduplicated set. Unlinked consolidation semantics are unchanged (still merged assignments/items/signature in one scope).
  • Test: BoardWorkFirstViewTests.test_duplicate_observations_of_one_identity_render_the_newest_once. Two contract-valid records (ready and merged) rewritten to share one work id and binding. Asserts exactly one row key, one class="rowbtn" id=, one id="workdetail", that the newest (stage: merged) is rendered and the older (stage: ready to merge) is not; that reversing the record order produces byte-identical worklist and announces nothing; and that a genuinely newer observation of the same identity is still announced as a change.

P2 codex:80220e57a31d75a48022 — bound observation reads before JSON decoding

  • Fix: src/code_mower/board.py, observations_payload(). record_file.read_bytes() replaced with record_file.open("rb") + handle.read(board_observation.MAX_BYTES + 1). A result longer than MAX_BYTES is rejected before decode; the remainder is never loaded. The diagnostic reuses the contract's own closed invalid_contract token — no path, no value.
  • Test: BoardObservationReaderTests.test_an_oversize_file_is_read_bounded_and_rejected_before_it_is_decoded. Writes a small valid record plus a file of MAX_BYTES * 4, wraps Path.open for that directory to record every requested read size and wraps board_observation.decode to record every decoded length. Asserts reads are exactly [MAX_BYTES + 1, MAX_BYTES + 1], that decode was called once with <= MAX_BYTES bytes, that the payload holds one record with rejected == 1 and the single warning {"file": "b-huge.json", "message": "invalid_contract"}, and that neither the file content nor the directory path appears anywhere in the payload.

Changed files (this round)

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.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 6d6e73fe8e23697f5afa9cdb3ddfb5cad2a484c7
Findings: P0=0, P1=0, P2=2, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

Two P2 findings affect participant counts and consolidated source freshness when multiple observation files coexist.

Findings:

  • [P2] Deduplicate observations before summarizing participants -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-wbowfv48/wt/src/code_mower/board.py:2369
    Finding ID: codex:35af9aa458ff662bf884
    When multiple files describe successive observations of the same work item, this loop counts the same run once per file, although workRows() correctly retains only the newest observation. A run transitioning from observed_running to implementation_complete therefore appears as two recorded runs in different phases. Build the participant summary from the deduplicated observations so superseded phases and counts do not contradict the selected work item.
  • [P2] Recompute freshness when consolidating unlinked observations -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-wbowfv48/wt/src/code_mower/board.py:2344
    Finding ID: codex:28cf2b6e857e7491c7e3
    When two unlinked records share a repository, their evidence is combined but the resulting row retains only the first record's freshness and update metadata. With one fresh record and one unavailable record, reversing their order changes the consolidated row from green/current to unavailable. Aggregate freshness and timestamps across the retained evidence so a fresh first file cannot hide an unavailable source or a later meaningful update.

…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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Round-2 findings resolved at c44b9137ea62109e5846f3bb2c451b5532b1f3d8

Both P2 consistency findings from the round-2 audit of 6d6e73fe are fixed in one commit. Ownership stays inside the same four #948 files; src/code_mower/board_observation.py is untouched and the B0 contract is unchanged.

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.

Finding mapping

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 ago with the bad cue and names Source unavailable: run_registry. — the fresh first record cannot hide it;
  • the last meaningful update is 25s ago (the later event), not 50s ago (the first record's);
  • one run, attested by the worst source: assignments: claude unknown once, run_registry, unavailable, unavailable coverage, and 1 recorded run in 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 passedtest_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.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: c44b9137ea62109e5846f3bb2c451b5532b1f3d8
Findings: P0=0, P1=0, P2=2, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

Completed items are prioritized over blocked work, and periodic rendering loses keyboard focus on detail actions.

Findings:

  • [P2] Separate row urgency from headline precedence -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-ijt714tl/wt/src/code_mower/board.py:2449
    Finding ID: codex:77b5ccf0a4ab02656f91
    When merged work and work awaiting approval coexist, this sort places the merged row first because ROW_RANK inherits the headline rules' ranks (merged is 0, approval is 8). resolveSelection() then automatically opens the completed item rather than the blocked work. Use a separate urgency ordering for rows so terminal items do not displace actionable work.
  • [P2] Preserve focus on the selected item's action controls -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-ijt714tl/wt/src/code_mower/board.py:2640
    Finding ID: codex:2c6a9a0968e8146189d2
    If keyboard focus is on Open PR, Inspect connection, or View recent changes when a poll refreshes the work list, update() destroys that control and this function returns without restoring focus because none of those controls has an ID. Even an unchanged poll drops keyboard focus to the document body. Give the detail actions stable identities and restore them, or avoid replacing unchanged work-list DOM.

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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Third exact-head audit: both P2s fixed, plus one more found in the same pass

Head: 6c8ef9162c3bf8873cb20ca81836f499a204f6b6 (was c44b9137ea62109e5846f3bb2c451b5532b1f3d8)

codex:77b5ccf0a4ab02656f91 — row urgency separated from headline precedence

ROW_RANK was STATE_RULES' own rank, where merged is 0 because it is the truth that describes a merged item best. Row order asks a different question, so a board holding merged work and work awaiting approval sorted the merged item first — and since resolveSelection falls back to rows[0], that is what an operator who has chosen nothing was shown.

Row order is now ROW_URGENCY_ORDER, an explicit ranking in four bands — blocked, waiting on a named person, untrustworthy evidence, in flight — with TERMINAL_ROW_HEADLINES (merged, idle with complete coverage) placed last by explicit rank rather than by falling off the end. An unranked headline sorts after everything ranked and before the terminal band, so adding a state without ranking it can neither promote it to the top nor bury it under finished work. STATE_RULES is untouched: merged is still rank 0 for the headline. Reference then opaque identity still break ties.

Tests: test_row_order_is_urgency_and_never_headline_precedence (headline precedence unchanged; every headline the views can produce is ranked; ranks are strictly ordered non-terminal < unranked < terminal with no duplicates), test_finished_work_never_outranks_actionable_or_blocked_work (blocked → awaiting approval → merged → idle over four fixtures; default selection is the blocked row; reversing the directory order is byte-identical; a merged-only board still selects its merged row; a shared headline still tie-breaks deterministically).

codex:2c6a9a0968e8146189d2 — keyboard focus preserved on detail actions

The three detail actions were the only focusable controls on the page without ids, so withFocusPreserved could not restore them and every poll dropped the keyboard to the body. Each now carries id="workaction-<name>-<work identity>" — action name first, every name a single hyphen-free token, distinct workaction-/workrow- prefixes, so no action id can collide with another action's or a row's however a work identity is spelled — plus data-focus-fallback naming its row button. withFocusPreserved reads the fallback off the element before the update destroys it; if the exact control is gone it focuses only that named owner, and if the owner is gone too it leaves focus where the browser put it rather than guessing.

Tests: test_detail_actions_keep_keyboard_focus_across_a_refresh (each of the three actions, plus a row button and a tab, focused and then put through an unchanged poll — focus returns to the same control, never to the body; a poll that does change the row keeps it too), test_a_detail_action_that_disappears_never_hands_focus_to_another_control (recorded PR link withdrawn → focus lands on the row; whole work item replaced by a different one → nothing is focused; empty board → nothing is focused).

Additional blocker-level issue found in the broader self-review — P2, fixed here

Activating a detail action that opens another view left focus inside the panel the switch had just hidden. Inspect connection and View recent changes live in panel-now; selectView sets panel-now.hidden = true while the activated control still holds focus. Browsers resolve focus inside hidden content by dropping it to the document body, so a keyboard operator pressing Enter on Inspect connection was silently sent back to the top of the page. list.onclick now moves focus to the tab for the view that was opened. Test: test_opening_a_view_from_a_detail_action_moves_focus_out_of_the_hidden_panel (both actions; panel hidden state asserted; choosing a row is not a view switch and leaves the view and the keyboard alone).

The rest of the review — automatic selection, periodic refresh, action identities, keyboard navigation, hidden/accessibility state, unchanged-poll behaviour — turned up nothing else at blocker level. Keyboard navigation now has executed handler-level coverage it did not have before (test_keyboard_navigation_moves_selection_and_focus_through_rows_and_tabs): Up/Down/Home/End move selection and focus, Down on the last row clamps, an unhandled key is a no-op, an arrow key pressed on a detail action is not row movement, and tabs wrap with the roving tabindex following.

Test harness

BOARD_FOCUS_HARNESS runs the shipped page against a DOM shim in which focus is a real question: elements exist because rendered markup declared an id, replacing a container's innerHTML destroys everything inside it, and only the ids in the page's own static shell exist up front — so a lookup for a control a render removed returns nothing instead of conjuring a phantom to focus. Steps drive real clicks and keydowns through the page's own handlers. Each of the four fixes was mutation-checked: reverting it individually fails the test that claims it.

Checks

  • pytest tests/test_board.py — 139 passed, 11 subtests
  • pytest tests/test_board.py tests/test_board_observation.py tests/test_board_lease.py — 159 passed, 18 subtests
  • pytest tests/test_release_hygiene.py — 360 passed, 881 subtests
  • ruff check . — clean; privacy_scan.py — passed; compileall src scripts — clean; guard_package_workflows.py — passed

Repository-wide suite not run locally, as instructed. Edits stayed in the four existing #948 files; the frozen B0 contract and every earlier fix are untouched. Still a draft, needs-codex-audit retained.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 6c8ef9162c3bf8873cb20ca81836f499a204f6b6
Findings: P0=0, P1=0, P2=2, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Reserve layout height for the desktop detail panel -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-3qu2l_xp/wt/src/code_mower/board.py:1458
    Finding ID: codex:1a94d9fd98d5f667aa33
    At desktop widths with only one or two work rows, the absolutely positioned detail panel is taller than the list's 180px minimum height but contributes nothing to its layout height. Its six evidence groups therefore extend over the following Work Now and Participants sections, obscuring their content and controls. Keep the detail panel in a height-contributing layout or reserve its actual rendered height.
  • [P2] Encode work keys without collapsing distinct repository names -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-3qu2l_xp/wt/src/code_mower/board.py:2691
    Finding ID: codex:85f9785c6a1d42702fa0
    When a custom directory contains unlinked observations for repositories such as owner/re.po and owner/re-po, their distinct work keys both become unlinked-owner-re-po. This creates duplicate row IDs, so getElementById restores keyboard focus to the wrong row and the selected detail's accessible label can reference another row. Use an injective encoding for DOM identities rather than replacing every punctuation character with a hyphen.

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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: a374728c3dca61398d782add48e68a36e7d4708a
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

Work ordering depends on headline precedence, which can demote actionable or blocked work despite its recorded urgency.

Findings:

  • [P2] Rank urgency using all recorded states, not only the headline -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-a_9d_vur/wt/src/code_mower/board.py:2517
    Finding ID: codex:ad5cf581a3c0258a5c5b
    When an observation records multiple states, headline precedence can hide the state that should determine urgency. A contract-valid record containing both ready_to_merge and approval_required headlines as “ready to merge,” placing it alongside ordinary merge-ready work instead of in the higher-priority approval band. Source-unavailable or failed work is similarly demoted when a higher-precedence headline wins. Compute urgency from all applicable states while retaining the explicit terminal-work exception.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Round-4 findings resolved at a374728c3dca61398d782add48e68a36e7d4708a

Both P2 findings from the fourth independent exact-head audit of 6c8ef916 are fixed. The one-detail-region interaction model and the frozen code_mower.boardObservation.v1 contract are unchanged, and all earlier rounds' fixes are preserved.

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 pins width:1px; height:1px and clips, so it cannot overlap anything. .chrome is position: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 VIEWS list, row buttons from the work identity, and detail actions from that identity plus a hyphen-free action name. keySlug was the only sanitizer and is the one fixed here; aria-labelledby, aria-controls, data-focus-fallback and every getElementById read 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 passedtest_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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Fifth audit P2 fixed at e237195536533a8a024964ef4975b1b9df2589c2

Audit comment: #1000 (comment)

Finding-ID mapping

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_BANDS declares the ranking as four bands — blocked, actionable, untrusted, in-flight — and ROW_URGENCY_ORDER is 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 at board.py:2887 with workRows output at board.py:3108) — the automatic default selection is rows[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) covering ready_to_merge+approval_required, ready_to_merge+provider_failed, ready_to_merge+source_unavailable, ready_to_merge+stale_observation, plain ready_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 one STATE_RULES picks, 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-expanded selection 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 on ready-approval instead of merged-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)

  1. Sort reverted to stateUrgency(a.headline) - stateUrgency(b.headline) → 3 failures (test_row_urgency_is_computed_from_every_recorded_state in 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).
  2. Terminal exception weakened to terminal !== null && owed === null (plain minimum, no demanding gate) → test_row_urgency_is_computed_from_every_recorded_state fails: the merged-alone row's urgency drops from the terminal band to implementation 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.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: e237195536533a8a024964ef4975b1b9df2589c2
Findings: P0=0, P1=0, P2=2, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Include all blocking reasons in work-state urgency -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-l0asy1k3/wt/src/code_mower/board.py:2015
    Finding ID: codex:776f336c36db45a3bc06
    The supported blocking reasons provider_suspended and update_required lack state rules, so rowUrgency() cannot account for them. An observation requiring inspect_provider can appear below progressing work waiting for CI because it retains only the provider run observed state. Add state rules and demanding urgency entries for these reasons.
  • [P2] Preserve the selected detail panel's scroll position -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-l0asy1k3/wt/src/code_mower/board.py:2888
    Finding ID: codex:4a8e6fdb6d1087739231
    At desktop widths, .workdetail scrolls independently with max-height:70vh. Every poll replaces it through put("worklist", ...), resetting its scroll position even when the selected work and evidence are unchanged. Restoring focus with preventScroll does not restore the destroyed element's scroll offset. Preserve the offset when the selected identity survives, or update the existing detail element instead.

…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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Sixth exact-head audit: both P2 completeness findings fixed

New exact head: d79b9bd79053a3412d16c2756c35784c5201df43 (was e2371955). Still a draft, needs-codex-audit retained.

codex:776f336c36db45a3bc06 -- supported blocker reasons with no state rules

provider_suspended and update_required are accepted by the frozen code_mower.boardObservation.v1 contract and had no Board state rule, so neither produced a display state and neither was ranked -- work whose recorded route is inspect_provider or update_branch sorted below ordinary progressing/CI work, and the default selection opened on the wrong row.

  • New display states: "provider run suspended" (warn) and "branch update required" (warn), placed in STATE_RULES at the positions the contract's own route table gives them (provider_failed 60 < provider_suspended 70 < cancelled 80; changes_requested 90 < update_required 100 < ci_failed 110).
  • Both ranked in the blocked urgency band (demanding: true), next to the states they are closest to.
  • Display and urgency stay distinct responsibilities: a record that is ready to merge and suspended still headlines as "ready to merge" and is ordered by the suspension.

Found in the same pass and fixed: the contract records a suspended session as the suspended lifecycle state and permits it only alongside the failed phase, so the failure rule -- which read the phase alone -- reported a paused session as a failed one. The failure state is now read from runs that are not suspended, so neither claim is made on the other's evidence.

codex:4a8e6fdb6d1087739231 -- detail scroll reset on every poll

The desktop .workdetail scrolls independently and every poll replaced it, returning an operator who had read down the evidence to the top of the panel -- including on polls that observed nothing new.

  • The offset is captured before the update and restored after, keyed by the same opaque work identity the selection is kept against (the detail region now carries data-key).
  • Restoring is clamped to scrollHeight - clientHeight, floored at 0.
  • Composed with the existing focus preservation in one wrapper; the offset is restored after focus, so a browser that ignores preventScroll cannot undo it.

Exhaustive vocabulary coverage

The reason vocabulary is derived from board_observation.REASON_ROUTES rather than a hand-picked subset. For all 19 reasons the contract accepts, the test asserts exactly one display state, a declared urgency band, an explicit rank (never UNRANKED_ROW_URGENCY), and a demanding flag matching its band. The neutral classification ("state not recorded") is asserted to exist and to be unreachable from any supported reason. ACTION_LABELS / ACTOR_LABELS are asserted equal to the contract's ACTIONS / ACTORS.

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 subtests
  • ruff check . passed; scripts/privacy_scan.py passed; compileall src scripts passed; scripts/guard_package_workflows.py passed

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.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: d79b9bd79053a3412d16c2756c35784c5201df43
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

Suspended provider sessions are correctly identified in headlines but incorrectly displayed as failures in run-level displays.

Findings:

  • [P2] Preserve suspended lifecycle state in run-level displays -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-bm6r7t_7/wt/src/code_mower/board.py:2193
    Finding ID: codex:044c7d368aacd887ee1a
    For an accepted run with lifecycle.state === "suspended", the contract requires phase === "failed". Although workStates() handles this distinction, the evidence panel still renders a red failed label, and assignments and Participants also report failure. This is reproducible with the existing _record_with_suspended_run fixture and contradicts the row's suspended headline. Apply lifecycle-aware labeling consistently across these displays so paused sessions are not presented as failures.

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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: d30596492cc86ab4cac4323bb9932357c7726482
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Preserve detail scroll position while its tab is hidden -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-i2kakkcf/wt/src/code_mower/board.py:2896
    Finding ID: codex:d600f928d3e3acf7490c
    At desktop widths, scrolling the evidence panel, switching to another tab, and receiving a poll resets the evidence position. renderWork() replaces the detail even while Now is hidden; hidden elements have zero scrollHeight and clientHeight, so this clamp discards the saved offset. Returning to Now therefore starts at the top. Keep the offset by work identity outside the DOM and defer restoration until the panel is visible.

…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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Round 8 P2 resolved at c759793f46e70eb112bb303dc25d965e364fe875

codex:d600f928d3e3acf7490c — hidden-tab detail scroll state.

The offset was read off the detail region just before a render replaced it and restored by clamping against the replacement's layout. A hidden view is hidden with display:none, so its content is out of layout and reports a zero-sized box and a zero offset: a poll that landed while the operator was on Timeline, Releases or Health read their place as zero, clamped against zero travel, and returned them to the top of Now.

Fix. Detail scroll state now lives outside everything a render replaces, in a map keyed by the same injective opaque work identity the selection is kept against. It is captured when the operator scrolls and again before the panel is replaced or before Now is hidden; it is 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 any number of hidden polls leave it untouched. Each identity keeps its own place, none is inherited across identities, an identity the Board stops showing is dropped by the render that stops showing it, and the map is bounded (least recently touched evicted first). A replacement rendered while Now was hidden is never read from: only the element the page has synchronised is, and a scroll the operator performs is always believed.

Lifecycle. Every path that opens a view — tab click, tab keyboard navigation, and the two detail actions that switch view — ends in applyView(), where restoration happens. Every path that re-renders the work list — poll, row click, row keyboard navigation — ends in renderWork(), which captures before the replacement and restores after focus restoration, in that order.

Tests. Six new executed-JS tests: 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 with no hidden panel; the clamp and the growth case on return as well as while visible; per-identity offsets across switching away and back; an identity that disappears and comes back as new work; and the bound on what is remembered. The DOM shim now reports a zero-sized box for content inside a hidden panel, with the panel each container belongs to read off the shipped markup, and a scroll step tells the page the element moved. The stylesheet's [hidden] { display:none !important } is asserted rather than assumed.

Mutations executed, each caught: 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; trusting a replacement the page never synchronised.

Focused checks at this headtests/test_board.py 167, tests/test_board_observation.py (B0 contract) 16, tests/test_board_lease.py 4, board-dependent modules 583, tests/test_cloud_client.py 68 — 838 passed, zero failures, zero skips; ruff check . clean; scripts/privacy_scan.py passed; compileall -q src scripts clean; scripts/guard_package_workflows.py passed; scripts/smoke_easy_mode.py --json status: pass; release-readiness --json status: pass (20 checks, 0 warnings); git diff --check clean. The repository-wide suite was not re-run locally; GitHub CI is the canonical full matrix.

Still a draft, needs-codex-audit retained, not marked ready and not merged. src/code_mower/board_observation.py is untouched — the frozen B0 contract is preserved.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: c759793f46e70eb112bb303dc25d965e364fe875
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Supersede idle observations when the session starts work -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-jipskqoq/wt/src/code_mower/board.py:2558
    Finding ID: codex:ee79c8f096bd68aa93e5
    When the directory retains a no_work observation followed by a newer work observation for the same session and worktree, these records receive different keys and both survive deduplication. The Now view consequently shows “idle with complete coverage” and “nothing to do in this session” alongside that session's active work. This occurs with valid contract records, including the existing idle and running fixtures. Reconcile session-level idle observations against newer work observations before rendering, rather than treating the idle state as an independent enduring item.

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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Ninth exact-head audit: the one P2 is fixed

Head: 312f5920c5a2814c045099e1ce515ef3501aaf46 (was c759793f46e70eb112bb303dc25d965e364fe875)

Finding mapping

Finding Status Where
P2 codex:ee79c8f096bd68aa93e5 — a retained no_work observation and a newer work observation for the same session and worktree get different work keys, so both survive identity deduplication and Now claims complete idle and active work at once Fixed src/code_mower/board.pysessionScope, workSupersedesIdle, reconcileSessionScopes, reconciledObservationGroups; workRows now reads the reconciled set

Identity deduplication only ever compares like with like: idle:session:worktree and work:session:worktree:id are different identities, so the two readings never met. An explicit reconciliation step now runs once, immediately after deduplication and before any view reads a row.

Transition semantics

Ordering is the trusted recorded one — created_at, then the last meaningful update — and never file, directory or input order.

Transition (one session + worktree) Result
idle → work idle snapshot is stale and is dropped; every work item observed after it survives
work → idle idle snapshot is the current truth; superseded work rows are dropped rather than restated as active work
terminal work → idle same — an item observed as merged before its session reported itself idle is not current work either
work → idle → new work only work observed after the snapshot survives (each work item is decided against the snapshot on its own)
equal timestamps real tie, broken by specificity: the work-specific observation wins, because "nothing to do in this session" over a work item observed at the same instant is the contradiction being removed
same session, different worktree never compared — one session holds several worktrees
same worktree, different session never compared — one worktree is reused by session after session
no usable session/worktree identity (every unlinked record) never correlated; keeps the existing unlinked consolidation semantics, no guessing

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-review

Every work-first consumer descends from the single workRows(data, nowMs) call and from nothing else: work list / Now, participant summary, Timeline change tracking, default selection, announcement region, and the two chrome counts. observationGroups is now reachable only through reconciledObservationGroups. 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. Documented in docs/board-data-contract.md.

Checks (focused; the repository-wide suite was not run locally)

Check Result
pytest tests/test_board.py tests/test_board_observation.py tests/test_board_lease.py tests/test_adoption_diagnostics.py 231 passed, 101 subtests passed
pytest tests/test_board.py -k SessionScopeReconciliation 11 passed, 12 subtests passed
ruff check . All checks passed
scripts/privacy_scan.py privacy scan passed
compileall src scripts clean
scripts/guard_package_workflows.py package workflow guard passed

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 observationOrder before the tie-break is asserted; the reconciled set is checked through the participant summary, the default selection and the change log; the rendered page is asserted to carry no idle with complete coverage / nothing to do in this session / This session is idle because text beside active work in one scope. The idle→active case is mutation-checked by executing deduplication without the reconciliation after it, which reproduces the contradiction exactly and is then contrasted with the shipped model.

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 (src/code_mower/board_observation.py) is untouched and every prior fix is preserved. needs-codex-audit retained; still a draft, not merged.

@jeffhuber

jeffhuber commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Round 14 — the one P2 is fixed at 102ed860272e86f5abc042a046fa0fbda23f55a9

Fixing the fourteenth independent exact-head audit of b6717cdc (comment): P0=0, P1=0, P2=1, P3=0.

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.

  • idlePresentation returns signature — 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.
  • effectiveState is 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.
  • workRow computes 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

  1. rowSignature(record, freshness, update, idle) = workSignature(record) + "|effective:" + effectiveState(...), and the recorded half is byte-identical to workSignature(row.record) for every row.
  2. workSignature remains a pure function of the record — no clock, no payload-level coverage — so isNewerObservation and the group tiebreak stay deterministic.
  3. No signature quotes an age or a polling instant. One payload read at four instants inside the current window yields one signature.
  4. 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 idle and freshness objects.
  5. 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.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 102ed860272e86f5abc042a046fa0fbda23f55a9
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

An inaccessible observation path can prevent the whole Board snapshot from refreshing instead of degrading only observation availability.

Findings:

  • [P2] Handle observation-directory stat failures -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-yyowx2vs/wt/src/code_mower/board.py:1614
    Finding ID: codex:3f3b1f6a2b3c309bebbd
    On supported Python 3.12/3.13 installations, Path.exists() and Path.is_dir() can raise PermissionError or other filesystem errors. If the observation directory has an inaccessible ancestor, these calls execute outside the OSError handler and abort the entire status refresh instead of returning the intended unavailable-observations payload. Guard the metadata checks as well as directory enumeration, distinguishing a missing directory from an unreadable one.

`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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 1c41085cc05a3f6552f212acdf2dff8bbb3dfb4a
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Gate current idle claims on snapshot cache freshness -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-2n7h2nfa/wt/src/code_mower/board.py:2633
    Finding ID: codex:fd2cddc6d640cb3abf85
    When /api/status serves a stale cached snapshot after a delayed or failed refresh, this calculation still treats its observations as current for ten minutes. A cached no_work record consequently displays green “idle with complete coverage” and “nothing to do in this session,” even while the summary correctly reports an unconfirmed snapshot. It can also continue suppressing older work rows through reconcileSessionScopes. Pass the cache confirmation state into this calculation and withhold affirmative idle claims when the containing snapshot is stale.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Round 15 — P2 codex:3f3b1f6a2b3c309bebbd resolved at 1c41085cc05a3f6552f212acdf2dff8bbb3dfb4a

Previous head 102ed860272e86f5abc042a046fa0fbda23f55a9. Findings addressed: P0=0, P1=0, P2=1, P3=0 — the one blocker from the fifteenth audit.

Finding mapping

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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Writer lane: audit 16 P2 resolved

Head: c179387d830d89776c1419dd63a9874ac1bdb355 (was 1c41085c)
Findings: P0=0, P1=0, P2=1, P3=0 -> 1 fixed, 0 deferred

Finding mapping

codex:fd2cddc6d640cb3abf85 (P2, board.py:2633, Gate current idle claims on snapshot cache freshness) -> fixed at the flagged line. recordFreshness now takes the containing snapshot's confirmation reading and requires it for current, so a cached no_work record no longer renders green "idle with complete coverage" / "nothing to do in this session", and reconcileSessionScopes no longer retires work on its authority.

Canonical input

snapshotAuthority(data) is the single derivation of the confirmation fact, from board.cache alone: only fresh confirms; cold, stale and any unknown state do not; a payload with no cache metadata states nothing either way. It is derived once per payload in workRows and in render, and threaded as authority through reconciledObservationGroups -> reconcileSessionScopes -> workRow -> recordFreshness. observation() stopped computing its own cacheState !== "fresh" check and now reads the same object, so the summary and the rows cannot disagree.

One gate, not two. idlePresentation.affirmative still reads freshnessState === "current" only — the confirmation state is already inside it. idleAuthorityState chooses which withheld reading explains the row, never whether the claim holds. The first draft gated in both places; the mutation tests caught it as a duplicated check and it was removed.

Authority invariants

  • A stale/unconfirmed snapshot may display records as historical evidence; it may not assert current absence, retire work, or render ok.
  • Retiring an idle row because newer work was observed inside the same payload is unaffected: that is recorded order, not a claim about now.
  • Refresh-in-progress vs a refresh that is not coming are distinct readings with distinct next actions; the server's last_error and retry window are quoted in the note and in Health.
  • 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. An unreachable source keeps its more severe bad reading.
  • Signature carries the classification (snapshot:confirmed|refreshing|unconfirmed); the cache age and error text are excluded, so a timestamp-only poll is not news.
  • Every other absence claim is gated by the same two facts: empty work list, empty participant summary, empty source list.

Surfaces traced

API status -> snapshotAuthority -> observation (summary/Now/productivity), recordFreshness (row label/class/detail), idlePresentation (headline, cues, action, coverage evidence), effectiveState/rowSignature (change detection), reconcileSessionScopes, workRows, participantSummary, Now banner, work-list banner, chrome pill, Health Snapshot cache row (now coloured by the verdict, so cold is no longer neutral), Timeline + aria-live, selection (unchanged — by opaque identity). Fast-poll behaviour untouched: awaitingRefresh/freshDelayMs are unmodified.

Tests

New BoardSnapshotAuthorityTests: 13 tests / 35 subtests. Table-driven single-page-lifetime transitions — confirmed -> unconfirmed with the same current complete record; unconfirmed -> confirmed; refresh-in-progress both ways; timestamp-only refresh; last-refresh-failed; cold cache; unknown cache state; unconfirmed + a candidate left unread; partly covered + unconfirmed. Each asserts row label/colour/cue/action/coverage evidence, reconciliation in both directions and both input orders, Now/work-list/chrome/Health warnings, Timeline and aria-live text, and no false churn. Mutation checks: ignoring confirmation in recordFreshness restores the false green; ignoring it in idleAuthorityState misattributes the cause; ignoring it in reconcileSessionScopes restores work suppression.

Checks run (focused, not repo-wide)

  • tests/test_board.py + tests/test_board_observation.py + tests/test_board_lease.py: 274 passed, 246 subtests
  • tests/test_checks.py: 10 passed
  • ruff check src tests: clean (ruff format drift is pre-existing and unenforced)
  • Rendered page script: node --check clean

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 needs-codex-audit. Not merged, not marked ready.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: c179387d830d89776c1419dd63a9874ac1bdb355
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Withdraw current observation claims when polling fails -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-nvhn2hwl/wt/src/code_mower/board.py:4632
    Finding ID: codex:c8253cb2f2bf447734ed
    After a fresh no_work record is rendered, repeated fetch failures update only the summary, leaving the Work section asserting “idle with complete coverage” and “nothing to do in this session” indefinitely. Age and snapshot authority are recalculated only by a successful render(), so the ten-minute freshness limit never takes effect. Mark retained observations unconfirmed and rerender their freshness on polling failure; the summary warning alone does not withdraw these affirmative claims.

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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Writer lane: seventeenth exact-head Codex audit, P2 resolved

Head: 8ffd8215d137313e77e38abfe294fc6b9ae08c0d (was c179387d830d89776c1419dd63a9874ac1bdb355)
Audit: #1000 (comment)
Findings addressed: P0=0, P1=0, P2=1, P3=0

Finding mapping

Finding Location Resolution
codex:c8253cb2f2bf447734ed — Withdraw current observation claims when polling fails board.py:4632 (the load() catch handler) The single canonical authority model now composes server cache confirmation with client transport confirmation. A status poll that does not complete marks the retained payload unconfirmed in a bounded local state and rerenders every Work/Now/Health/Timeline/aria-live surface; recovery clears the override and renders the new server authority.

What changed

transportAuthority(transport) classifies this page's own last status poll; snapshotAuthority(data, transport) composes it with the server's cache reading. Everything that descends from that one reading withdraws together — the observation summary, recordFreshness, idlePresentation, the reconciliation that retires work beside an idle snapshot, every absence message, the Now banner, and the Health rows.

An unanswered poll is its own reading (unanswered) rather than a flavour of unconfirmed, because the operator's next move differs: restore the poll, not wait for a refresh. It renders under the existing idle in an unconfirmed snapshot label with its own next action and note.

On failure the last successful payload is rerendered under the withdrawn authority rather than left as-is. render(data, transport) takes the reading as a parameter; the retained payload is never mutated.

The two requests are settled independently via a single fetchJson helper. Only /api/status carries observations whose currency the page asserts, so only its failure applies transport authority. A failed /api/events feeds a view that never claims to be current and no longer blocks a good status payload from rendering or degrades the pacing its cache decided — the one intentional behaviour change beyond the finding, and it is covered by a test.

Client/server authority invariants

  • Composition: confirmed = server_confirmed && transport_confirmed. Both halves are exposed (server_confirmed, transport_confirmed, server_note) so each surface reads the canonical answer rather than re-deriving it.
  • Transport dominates the reading: when a poll fails, reading = "unanswered" whatever the retained cache state says; the server's own reason is carried along behind it in the note.
  • No payload mutation: the client override lives only in transportState; lastStatusData is adopted only after a render succeeds, so a payload the page cannot render never becomes the payload it retains and never clears a failure count that is still true.
  • Recovery is exact: a completed poll replaces the transport state wholesale — every non-history surface renders byte-identically to the pre-failure frame, and a recovery onto a stale snapshot reports the server's reason, never the client's.
  • Nothing derived reaches a signature but its classification: the failure count, the error text and the cache age are reported on screen and are in no signature, so repeated failures, a different error message, and an observation ageing out under a failure are all silent.
  • No retirement from retained data: idlePresentation(...).affirmative is false under an unanswered poll, so an idle snapshot cannot retire work beside it; both readings stay and neither contradicts the other.
  • Bounded local state: failure count capped at 99, error string truncated to 160 chars; changeLog still bounded at 20.
  • One timer, always: every path through load() — rendered, retained, or never loaded — reaches scheduleNextLoad exactly once. Pacing and backoff otherwise unchanged.

Tests

New BoardPollingAuthorityTests plus a _run_board_lifetime harness that drives the shipped load() loop across one page lifetime (stubbed fetch/setTimeout/DOM, per-step clock, live-region writes recorded rather than final value).

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)

Check Result
tests/test_board.py + test_board_observation.py + test_board_lease.py 284 passed, 255 subtests
+ test_release_hygiene.py, test_package_install_failure_taxonomy.py, test_minimal_lineage_packaging.py 678 passed, 1166 subtests
ruff check . All checks passed
B0 freeze board_observation.py and tests/fixtures/board_observations.json untouched — diff is board.py + test_board.py only

Two existing assertions were updated to track the change rather than be weakened: the read-only-endpoints test now matches the fetchJson("…") literals and additionally pins the single raw fetch( call site; the idleAuthorityState mutation check targets the rewritten body.

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 needs-codex-audit. Not merged, not marked ready.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 8ffd8215d137313e77e38abfe294fc6b9ae08c0d
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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:

  • [P2] Keep scheduling polls when event rendering fails -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-4gyb1hhn/wt/src/code_mower/board.py:4853
    Finding ID: codex:b12d88b211c38c5a2c3d
    If /api/events returns valid JSON with an unexpected shape, fetchJson reports success but renderEvents can throw. This call now sits outside the error handler, so the exception skips scheduleNextLoad and permanently stops status refreshes, leaving the last snapshot displayed without a transport warning. Previously, rendering errors were caught and polling continued. Isolate event-rendering failures and ensure the next poll is scheduled in a finally block.

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>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66
Findings: P0=0, P1=0, P2=0, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

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.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Writer lane: eighteenth exact-head Codex audit, P2 resolved

Head: 3dec5ba5e8d1a1354cb09feb8de35b8878cd1c66 (was 8ffd8215d137313e77e38abfe294fc6b9ae08c0d)
Audit: #1000 (comment)
Findings addressed: P0=0, P1=0, P2=1, P3=0

Finding mapping

Finding Location Resolution
codex:b12d88b211c38c5a2c3d — Keep scheduling polls when event rendering fails board.py:4853 (the renderEvents call in load(), introduced by round 17's transport refactor) One poll is now two independently guarded halves and one guarantee: renderEventsIsolated() contains an unrenderable history where it happens, renderStatusOutcome() settles the status half and returns the delay, and scheduleNextLoad(delayMs) moves into a finally that is load()'s only exit.

What changed

Three /api/events shapes parse as valid JSON and then throw inside renderEvents: events as a string (throws on the reverse), as an object that merely has a length (throws on the copy), and as a list holding a null (throws in the row builder). fetchJson reports all three as successes, because they are successful responses — the failure is in the shape, which only the renderer can discover. The 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 permanently.

  • renderEventsIsolated(history) — writes nothing 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, so an events failure recorded there would withdraw a snapshot that rendered perfectly well.
  • renderStatusOutcome(status) — the status half, settled: the request's outcome in, the delay the next poll should wait out. nextDelayMs(...) for a response this page rendered, REFRESH_MS 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 row.
  • scheduleNextLoad(delayMs) in a finally — still exactly one call site, still clearing the pending timer before arming the next, now reached on every path out of load().

Nothing is swallowed to keep the loop alive. A status render that throws is reported through the existing bounded surface — 1 failed status poll with (prs.map is not a function) beside it — and anything that gets past both handlers still reaches the caller. What it can no longer do is end the loop.

Scheduling invariants

  • Exactly once per invocation. scheduleNextLoad(delayMs) runs on every path out of load() — status rendered, status withdrawn, retained rerender threw, events unrenderable, handler failed unexpectedly — and never twice.
  • No stacking. The helper clears the pending timer before arming; across sixteen consecutive mixed polls the timer count is the poll index, always, and the pending timer is always the one that poll armed.
  • Delay provenance. The delay a successful status response chose is what the poll waits — including the 750 ms fast poll for a still-refreshing snapshot — and every other outcome falls back to the configured interval. The events half never influences pacing.
  • Fast-poll budget unchanged. Only a response no longer awaiting a refresh resets fastPollAttempts; the cap still holds while the same refresh is pending.
  • Isolation is one-directional by design. An events failure cannot touch the status half. A throw from the status half skips the events render of that poll and still arms the timer — the alternative was a blanket catch that would have hidden a renderer bug from the Health row.

Authority invariants (round 16 and 17, preserved)

  • Status authority is confirmed when the status half rendered, regardless of any events failure: transportState stays {confirmed: true, failures: 0, error: ""}, the present-tense claim stands, and the Health row still reads status polls answered.
  • Retained authority withdraws when the status half failed, by any of its three routes — request never arrived, body not JSON, payload does not render — and the retained payload is rerendered under the withdrawn reading. Nothing is retired.
  • Event history remains last-known on an events failure; it is never emptied, never replaced with an error, and never used as evidence of now.
  • A later healthy poll recovers both surfaces: every status surface renders byte-identically to the pre-failure frame, and the history is the new one.
  • The payload is still never mutated, the local state is still a flag, a capped count and a truncated string, and none of it reaches a signature.

Tests

New BoardPollingContinuityTests9 tests, 42 subtest cells — driving the shipped load() loop through the round 17 lifetime harness.

  • Every events outcome (fetch reject, body not JSON, and the three throwing shapes) against a healthy status poll: authority, claim, Health row, pacing, announcements and history all asserted.
  • The full sixteen-cell matrix of every status outcome against every events outcome, including a status payload whose shape render() cannot walk.
  • Sixteen consecutive mixed polls in one lifetime: one timer per load, clearing the one it replaces, never stacked, nothing escaping load().
  • The fast-poll delay surviving every events failure; a retained rerender that throws still arming the next poll; recovery of both surfaces afterwards.

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: armed == 0, timer count frozen, with a green idle with complete coverage and status polls answered still on screen — the finding exactly. The events render without its boundary but with the finally kept escapes load() and the next poll is still armed. The arming moved back outside the finally with the boundary kept ends the loop on a throwing rerender. Against 8ffd8215's board.py under CI's own runner the class is 27 failures across its 9 tests.

Harness. _run_board_lifetime gains a third request outcome (a response that arrives with a body that is not JSON, which reaches fetchJson differently from one that never arrived), records what escaped load() rather than letting it abort the run, and mutate now accepts several fragments.

One existing assertion was tightened, not weakened: BoardTests now requires scheduleNextLoad(delayMs) to be the body of the finally that closes load(), rather than merely the last statement in it.

Checks run (focused; repository-wide suite deliberately not run locally)

Check Result
tests/test_board.py 273 passed (264 at 8ffd8215; +9 new), 290 subtests
tests/test_board_observation.py (B0 contract) 16 passed
tests/test_board_lease.py 4 passed, 7 subtests
python -m unittest discover -s tests -p "test_board*.py" (CI's own runner) 293 tests, OK
Board-dependent set + test_cloud_manifest + test_package_install_failure_taxonomy 684 passed, 976 subtests
ruff check . All checks passed
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
B0 freeze board_observation.py and tests/fixtures/board_observations.json untouched — diff is board.py + test_board.py only

Every board.py edit at this head is inside the _BOARD_HTML page template: observations_payload(), the /api/status serving path, the observations block's shape and counters, the descriptor-open hardening and the frozen record contract are byte-identical to 1c41085c.

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 needs-codex-audit. Not merged, not marked ready.

@jeffhuber
jeffhuber marked this pull request as ready for review September 16, 2026 20:46
@jeffhuber
jeffhuber merged commit 5b32c96 into main Sep 16, 2026
25 checks passed
@jeffhuber
jeffhuber deleted the claude/948-board-work-first branch September 16, 2026 20:46
jeffhuber added a commit that referenced this pull request Sep 17, 2026
…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>
jeffhuber added a commit that referenced this pull request Sep 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

builder:claude Code Mower generated label codex-audit-done

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Board: implement the work-first Now, Timeline, Releases, and Health views

1 participant