Skip to content

Register sweeps and reads refuse what they could not read: retire-rows, archive-rows, migrate-state-cells, the DORMANT class, next-free and add-row (#138 lanes-edit.sh items, #157 item 4b) - #230

Merged
brettheap merged 26 commits into
mainfrom
fix/register-scan-hardening-138
Oct 19, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Refs #138 (items 1, 2, 4, 5, 7, 8, 9, 10, 11, 12, 17, 18 — the lanes-edit.sh items)
Refs #157 (item 4b)

The register sweeps (retire-rows, archive-rows, migrate-state-cells) and the reads behind the DORMANT class, next-free and add-row now refuse, or decline to classify, where a read failed or a path is not what it looks like. Before, each of those cases was read as "nothing there". Every fixed item was re-proved on main first, against a throwaway register in a local bare repository. Each one has a proving test in the new tests/test_register_scan_hardening_138.py that fails on the code before it and passes here. The branch carries main up to babd9fe as normal merge commits.

Each commit carries one item. Line ranges are on this branch's head.

Item Status Before (re-proved) Function, lines Test
#157 4b fixed a refusal in the scan left a LANES(pre-existing@…) commit: HEAD moved by 1 for retire-rows ("named twice"), archive-rows ("no RETIRED row") and migrate-state-cells ("nothing to migrate") migrate_state_cells 10845–10851 and 11100–11112; retire_rows 11639–11641 and 11868–11870; archive_rows 11943 and 12080–12083: handle_preexisting moves from before the scan to the head of each write. The lock is still taken before the scan. test_a_refusal_in_the_scan_commits_no_peer_edit[×3], test_a_sweep_that_writes_still_captures_the_peer_edit_as_its_own_commit
1 already fixed for a failed show; see 18 for an unreadable blob with the published register's show failing, the sweep exits 1 ("is UNKNOWN") before refusal 1 runs, because managed_seam_refuse (from #97) reads the published register and this checkout's row with their status. With the blob unreadable, the probe read this checkout's copy instead and the sweep was published (exit 0); item 18's register_published closes that. none (see 18) test_item_1_… (pins it); test_item_18_the_seam_refuses_…
2 fixed: refuse, the issue's second option archive outside the checkout (skip-worktree): a new one-row archive was created and the register deletion staged, then git add failed with exit 6 archive_rows 11972–11985 test_item_2_…
4 fixed a lane whose only log is local was classed dormant once lanes/log could not be listed; and lane-end --retire-dormant then said "nothing to do" (exit 8) lanes_rows 7483–7492 and 7703–7713; lane-end 723–728 (review of #230) test_item_4_…, test_item_4_the_sweep_refuses_… (both skipped as root)
5 fixed next-free offered a position over a zero-byte register and over a broken symlink; a directory was already refused, because -s is true for one lane_position_rows 8011–8018 test_item_5_…[zero bytes, broken symlink]
7 fixed next-free offered position 4 over a local archive that is a directory, a broken symlink or a FIFO archive_text and the new archive_local_text 3315–3329; retired_identity_hits 3340 test_item_7_…[×3]
8 fixed: read the local cell, the issue's first option the RETIRED line carried the published (older) id where the writer's newest id was committed here and not pushed retire_rows 11612–11621 test_item_8_…
9 fixed an unlistable log directory passed the scan, and the run failed inside the write loop with exit 5 log_files_named_ci 2621–2632; retire_rows 11801–11804 test_item_9_… (skipped as root)
10 fixed the row left the repository: removed from the register, pushed, and appended through a tracked symlink to a file outside it archive_rows 11963–11971 test_item_10_…
11 fixed an IDLE lane whose published log git show could not read was shown NO LOG / dormant and named in lane-end --retire-dormant's preview remote_log_events 3087–3097; lanes_rows 7384–7402 and 7703–7713; lane-end 723–728 for the no-warnings-file case test_item_11_…
12 fixed the RETIRED line was written through a tracked symlink to a file outside the repository, then committed and pushed (exit 0) retire_rows 11756–11763 test_item_12_…
17 fixed with the published log's blob unreadable, the scan passed and wrote a RETIRED line, then git add failed with exit 6 retire_rows 11720–11737: ls-tree of the path replaces cat-file -e test_item_17_…
18 fixed (completed over two review rounds) a row a peer had published, and this checkout had not pulled, was added again: two repoX-5 rows landed on origin. Where git refused cat-file -e and show, or the blob was deleted, register_text read this checkout's copy: add-row exited 0 (two rows) or 3 (the duplicate committed here), next-free offered a position, and the seam published a sweep. Re-review: the listing then dropped the rows and lane-end --retire-dormant said "nothing to do" (exit 8). archive_text had the same cat-file -e, so a published archive git could not read reissued retired repoX-4 (next-free offered it, add-row committed it). the new register_published [<path>] 3260–3276 asks ls-tree, never cat-file -e; register_text 3238–3251 (returns 1, caches nothing); archive_text 3296–3310 (asks it of the archive); lane_position_rows 8018–8030; lanes_rows 7403–7412 (checks register_text once and withholds the class); rows_named_ci 4670–4682 (keeps both statuses); add-row 13866–13872 test_item_18_… (×8: add-row under a refused show; add-row in both review modes; next-free; the seam; the withheld class; the archive in both modes)

Two choices between the issue's options

  • Item 2. I took the refusal, not seeding the archive from origin. git 2.43 will not stage a skip-worktree path (measured), so a seeded archive would still fail at the commit, after the rows had left the register here.
  • Item 8. I took reading the local cell ($LANES_SESSION, else the last id of this checkout's row, else the published row's), not requiring LANES_SESSION. Requiring it would break the documented LANES_LANE=<lane> … retire-rows call. A checkout behind origin is refused anyway, so the local row is never the older of the two.

Merged with #221 (babd9fe, register_text_checked)

#221 landed first. Merge 2765929 resolved the one conflict, register_text.

#221's session_ids_checked is untouched.

How this combines with #210 (fix/wrapper-hardening-138)

git merge-tree --write-tree of the head before the #221 merge with #210's ae1b37b was clean. The combined lane-end block has #210's capture, which is always made and refuses when it cannot be made or read back, followed by the two refusal arms: "session records could not be read", and this PR's "no row is classed DORMANT here".

Scope and neighbours

Tests

🤖 Generated with Claude Code

Brett Heap and others added 14 commits October 13, 2026 18:44
…scans, so a refusal in the scan commits nothing (#157 item 4b)

migrate-state-cells, retire-rows and archive-rows called handle_preexisting
right after taking the lock, before the scan, so a refusal in the scan (the
seam's, "named twice", "nothing to migrate", "no RETIRED row") left a
LANES(pre-existing@...) commit behind. The capture now runs at the head of
each write, after every refusal and skip; the lock is still taken before the
scan, and the capture changes no byte, so the line numbers read are the ones
rewritten.

New tests/test_register_scan_hardening_138.py: a throwaway register in a
local bare repository, and the 4b cases for all three sweeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… git cannot read refuses the sweep (#138 item 17)

The scan's published-log probe was `git cat-file -e`, which fails for a
missing or corrupt blob exactly as for a path that is not there. A log the
tree names and git could not read was therefore "no published log",
lane_log_events gave an empty stream, and the row was retired. `ls-tree` of
that one path now says whether it is published; a listed log that `show`
cannot read, or a tree that cannot be listed, refuses with 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted (#138 item 9)

log_files_named_ci was `ls ... 2>/dev/null | awk`, whose status is the
awk's, so an unreadable log directory read as "no log of any case". The
sweep's scan passed on it and the run then failed inside the write loop
(exit 5, "append changed line count by 0"). The helper now returns 1 where
the directory exists and cannot be listed, and retire_rows' scan refuses on
that with 1 before anything is written. Its other callers ignore the status,
as they did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ar file (#138 item 12)

The sweep appends each RETIRED line with `>>`, which follows a link. A
tracked symlink at a lane's canonical log path passed the scan, and the line
was written into whatever file the link named, outside the repository, while
the commit recorded only the link. The scan now refuses, with 2, a log path
that is a symlink or exists and is not a regular file, before it is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pt in this checkout's row (#138 item 8)

The writer's uuid came from session_for, which reads $LANES_SESSION or the
PUBLISHED row, and the documented call sets only LANES_LANE. A sweeping lane
whose newest transcript was stamped here and not yet pushed therefore wrote
an older id into every append-only RETIRED line. The uuid is now
$LANES_SESSION, else the last id of this checkout's row for the writer, else
the published row's; a checkout behind origin is refused, so the local row is
never the older one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le (#138 item 10)

The rows are appended to lanes/archive/LANES-retired.md with `>>`, which
follows a link. A tracked symlink there passed the -f and cleanliness checks,
the rows went into whatever file it named outside the repository, and the
commit published the link and the rows' removal from the register, so the
rows left the repository altogether. The act's preflight now refuses, with
2, an archive (or archive directory) that is a symlink and an archive path
that exists and is not a regular file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…138 item 2)

Where origin holds lanes/archive/LANES-retired.md and this checkout does not
(outside a sparse checkout, or marked skip-worktree), the file is absent and
in no dirty list, so the move created a NEW archive of only its own rows and
removed them from the register; a commit of that would drop every earlier
reservation, and git 2.43 refuses to stage the path, so the act failed (6)
with the checkout half-written. The preflight now refuses with 2, naming the
cause. Taken over the issue's other option, seeding the file from origin,
because a seeded skip-worktree path still cannot be staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ar file (#138 item 7)

archive_text's last rung, and retired_identity_hits' read of this checkout's
archive, asked `[ -f ]` alone, so a directory, a FIFO or a broken symlink at
lanes/archive/LANES-retired.md read as no archive: next-free offered the
positions it should reserve, and add-row would take one. Both now read
through archive_local_text, which returns 1 for such a path as for a file
that will not open; next-free and add-row already refuse on that 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ro-byte or dangling register refuses (#138 item 5)

lane_position_rows counted the working-tree register only under `[ -s ]`.
Where origin has no register, a zero-byte register (what a failed rewrite
leaves) or a broken symlink was skipped, and next-free answered from its
stdin rows and the archive alone. Any path that exists or is a link is now a
source, so the existing "rendered empty" check refuses it. A directory was
already refused, `-s` being true for one; no register at all is still
silence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y cannot be listed (#138 item 4)

lanes_rows fences the logs this checkout has and origin has not with one
`ls -- "$LANES_LOG_DIR" 2>/dev/null`, so a directory that exists and cannot
be listed gave an empty fence, and a lane whose only object log is local was
classed DORMANT: hidden by default and offered to the sweep. The listing now
tells a missing directory from a failed listing, says so on stderr, and
leaves the class unset on failure, as it already does when the liveness read
fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d not read (#138 item 11)

DORMANT is read from a lane's absence from lane_row_facts. Where a published
log exists and its `git show` fails, remote_log_events gave no lines for it
(the parser's status is the pipeline's), so with a readable liveness read and
state cell the row was classed DORMANT: hidden, and named by
`lane-end --retire-dormant`'s preview. remote_log_events now names each log
it could not read on stderr, which state_events keeps in its warnings file;
lanes_rows reads those paths back, leaves those lanes unclassified and
listed, and says so. Without a warnings file no row is classed dormant. The
--index read is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of reading it as having no such row (#138 item 18)

register_text answered 0 with an empty text (and cached it) where the
published register exists and `git show` of it failed, and rows_named_ci
ended in an awk over it whose status is the awk's; add-row then took the
answer through `|| :`. A row a peer had published and this checkout had not
pulled was therefore appended a second time and, wherever the rebase did
not clash, pushed: two rows for one lane on origin. register_text now
returns 1 there and caches nothing, rows_named_ci returns 1 when either half
of its pipeline failed, and add-row refuses with 1. Every other caller of
the two still reads only the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efused before refusal 1 reads it

Already fixed on main: refusal 1 still reads both rows' session ids through
`|| :`, but managed_seam_refuse, asked for the same lane just before it under
the same lock, reads the published register (lane_is_managed_owned's empty
check) and this checkout's row with their status and refuses UNKNOWN with 1.
The case pins that, with a live id only in the published row; with 4b it
also shows the refusal commits nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the archive back; a docstring reads plainly (#138 item 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 2 hours and 53 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens register sweeps, lane classification, next-free, and add-row to fail closed whenever required register, archive, log, or Git reads are unreadable or structurally unsafe, while preserving successful peer-edit capture semantics; a comprehensive isolated regression suite proves each issue item.

Sequence diagram for fail-closed register sweep

sequenceDiagram
    participant Operator
    participant Sweep
    participant Git
    participant Register
    participant Logs
    participant Archive

    Operator->>Sweep: retire_rows()
    Sweep->>Git: acquire_lock()
    Sweep->>Git: ls-tree published log path
    Git-->>Sweep: path exists or read failure
    alt required read fails or path is unsafe
        Sweep-->>Operator: refuse, nothing written
    else reads succeed
        Sweep->>Register: register_text()
        Sweep->>Logs: log_files_named_ci()
        Sweep->>Archive: archive_local_text()
        Sweep->>Git: handle_preexisting()
        Sweep-->>Operator: commit and publish sweep
    end
Loading

Flow diagram for fail-closed lane classification

flowchart TD
    A[lanes_rows] --> B[Read published logs with remote_log_events]
    B --> C{Log or log directory readable?}
    C -- No --> D[Record unreadable log warning]
    D --> E[Do not classify lane as DORMANT]
    C -- Yes --> F[Read local log listing and state facts]
    F --> G{All required reads succeeded?}
    G -- No --> H[Leave lane unclassified]
    G -- Yes --> I[Classify eligible lane as DORMANT]
Loading

Flow diagram for safe next-free and add-row reads

flowchart TD
    A[next-free or add-row] --> B[Inspect local register or archive path]
    B --> C{Path exists and has expected type?}
    C -- No --> D[Refuse or report unreadable source]
    C -- Yes --> E[Read published register/archive]
    E --> F{Git read succeeded?}
    F -- No --> G[Refuse; do not treat as empty]
    F -- Yes --> H[Use rows and retired identities]
    H --> I[Offer position or add one row]
Loading

File-Level Changes

Change Details Files
Make register, archive, log, and published-object reads fail closed instead of treating unreadable or malformed paths as absent.
  • Propagate failures from register, row, log-directory, archive, and remote log reads.
  • Reject zero-byte registers, broken symlinks, non-regular archive/log targets, unreadable published blobs, and missing local copies of published archives.
  • Prevent DORMANT classification and next-free position reuse when required state cannot be read.
lanes-edit.sh
Harden sweep and add-row write paths against unsafe filesystem state and stale or incomplete reads.
  • Validate published paths with tree metadata before reading blobs.
  • Refuse writes through symlinks or non-regular files and refuse archive moves when the published archive is unavailable locally.
  • Read the writer's newest local session identifier and detect duplicate published rows before add-row.
lanes-edit.sh
Move pre-existing register-edit capture until after sweep validation and immediately before writes.
  • Keep the lock before scans while avoiding commits when a scan refuses.
  • Preserve capture as a separate commit for successful writes across migrate-state-cells, retire-rows, and archive-rows.
lanes-edit.sh
Add regression coverage for all listed register-scan hardening cases and commit-boundary behavior.
  • Build isolated bare-repository fixtures and simulate unreadable Git paths, permissions, symlinks, FIFOs, sparse/skip-worktree files, and peer edits.
  • Verify refusal status, unchanged HEAD/register contents, no unsafe external writes, correct DORMANT/next-free behavior, and local session IDs.
tests/test_register_scan_hardening_138.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 5 commits October 18, 2026 08:37
… blob git cannot read is a refusal and never this checkout's copy (#138 item 18, review of #230)

register_text and lane_position_rows asked `cat-file -e` whether the
register is published. That fails for a missing or corrupt blob exactly as
for a path origin does not have, so register_text answered with this
checkout's copy: add-row's duplicate check passed (a second repoX-5 row
landed, or was committed and stopped only by the rebase, exit 3), next-free
offered a position, and the seam called every lane legacy, so a sweep was
published. The new register_published asks `ls-tree` of the path: 0
published, 8 absent, 1 the tree unreadable. register_text and
lane_position_rows use it, and a listed register that `show` cannot read is
a 1, which add-row, next-free and the seam already refuse on.

Tests for both modes the review named (git refusing cat-file and show, and
the blob deleted from the object store), for add-row, next-free and the seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… class, instead of finding nothing to do (#138 items 4 and 11, review of #230)

With lanes/log unlistable (item 4), or with no warnings file to tell an
unreadable published log from a readable one (item 11), lanes_rows classes
no row DORMANT and says so on stderr. lane-end turned only the "session
records could not be read" sentence into a refusal, so it counted zero rows
and exited 8, "there is nothing to do". Both notes now carry "no row is
classed DORMANT here", and lane-end refuses on it with 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s: UNPRIVILEGED_ONLY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, the reads that fail closed, and the after-the-scan capture (#138, #157 item 4b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 3 commits October 18, 2026 09:47
…be read, so the sweep refuses instead of finding nothing to do (re-review of #230)

lanes_rows built its rows from register_text with the status thrown away.
Since c78eb92 register_text answers 1 with no text for a published register
git cannot read, so those rows vanished from `lanes --closed`, and
`lane-end --retire-dormant` counted none of them and exited 8, "nothing to
do". The listing now checks register_text once, says the register could not
be read and that no row is classed DORMANT here, and withholds the class;
lane-end refuses on that sentence with 1. lane-end's refusal now names a
failed read generally, not only the object logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive blob git cannot read reissues no retired position (re-review of #230)

archive_text asked `cat-file -e`, the probe c78eb92 took out of
register_text. A published archive git could not read (refused, or its blob
gone) was therefore "not published", this checkout's copy answered (absent,
on a checkout that had not pulled it), next-free offered the retired
repoX-4 and add-row committed it locally. register_published now takes a
path, and archive_text asks it of the archive: 0 shows it and a failed show
is 1, 8 reads the local copy, 1 is 1. That makes the manual's "a published
archive that cannot be rendered is a REFUSAL" true for this case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 18, 2026 16:01
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an-hardening-138

The one conflict is register_text, now #221's register_text_checked. It
keeps #221's shape: a text and a status, a failed read printed and never
cached, and register_text as the same read with the status dropped. The
published-or-not question inside it is #230's register_published, the
tree's answer (0 show, 8 this checkout's copy, 1 a refusal), not
`cat-file -e`.

The three #230 readers that relied on register_text's status now read
register_text_checked, as #230's body planned: rows_named_ci (add-row's
refusal), lanes_rows' one check (the withheld DORMANT class) and
lane_position_rows (next-free).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 5e88a2c into main Oct 19, 2026
5 checks passed
@brettheap
brettheap deleted the fix/register-scan-hardening-138 branch October 19, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant