Skip to content

chore: promote dev to main for 1.2.0 - #910

Merged
laynepenney merged 44 commits into
mainfrom
promote/v1.2.0
Aug 22, 2026
Merged

chore: promote dev to main for 1.2.0#910
laynepenney merged 44 commits into
mainfrom
promote/v1.2.0

Conversation

@laynepenney

Copy link
Copy Markdown
Member

Ceremony promotion of dev to main for the 1.2.0 release.

Promoted via a throwaway promote/ branch rather than making dev the PR head, because delete_branch_on_merge is enabled and merging a dev-headed PR would delete the integration branch. Ancestry is preserved exactly: this branch is dev's tip commit.

Content is already gated. The release commit passed a four-version public-push gate with two reviewers bound to head 2eca904 and body 72deb4d4. This PR adds no new bytes.

Premium boundary: grip is OSS — multi-repo workspace orchestration, no identity or org semantics.

laynepenney and others added 30 commits August 16, 2026 01:41
feat(gr2): add native add, commit, and push verbs
Manual link application now fetches every referenced branch-backed gripspace and refuses before writing when behind upstream.

Materialization records the requested revision so a detached HEAD is accepted only when it matches an explicit tag or commit pin. Rev-less or branch-configured detachment refuses rather than claiming freshness from an unproven state.

Tests cover source enumeration, stale refusal and recovery, detached refusal, and explicit-SHA acceptance.
…eporting

fix(link): refuse stale gripspace sources
Prove the propagation state contract before any daemon touches a real
clone: observed -> fetched -> planned -> applied -> verified ->
acknowledged, with refused, partial, and unverifiable reachable from any
state. Every transition names the observation that established it and
every receipt names the exact source and destination revisions. All
identifiers are opaque; the allowed directions are a required input with
no default.

The tests build a bare source remote and three destinations (clean
replica, dirty authoring clone, diverged authoring clone) and prove, each
as its own witness: kill-between-states then replay applies exactly once
(reflog and journal both measured); the cursor advances only on
acknowledged; a moved expected base refuses with the observed base
recorded and nothing merged or forced; dirty and diverged authoring clones
are refused and left byte-for-byte untouched; an unreadable destination
after the apply verb is unverifiable, never collapsed into a neighbour,
and resolves on replay; acknowledged replays as a no-op returning the
original outcome while refused starts a new attempt.

Also carries the born-red outbox witness: read_events() advances the
consumer cursor before the caller performs its effect, so a consumer that
fails after reading loses the event. Marked xfail(strict=True) so the
marker fails the suite the moment acknowledgment moves after the effect.

Co-Authored-By: Claude <noreply@anthropic.com>
The first cut of the propagation prototype keyed journal rows and cursor files
by joining the six coordinate fields with "|". The fields are opaque, so no
delimiter can be assumed absent from them, and review proved the collision:
(source="source|dest", destination="target") and (source="source",
destination="dest|target") produced one key, so two distinct coordinates would
have shared cursor and replay state. That contradicts the prototype's own
opaque-identifier contract.

The key is now the canonical JSON of the coordinate (sorted keys, compact
separators), the same encoding operation_id_for already uses, and
Coordinate.from_key decodes it. Injectivity is therefore STRUCTURAL, and the
witness asserts it that way: every key round-trips to the coordinate that
produced it, over the proven pair, over shifts across every field boundary
including the enum-guarded one (an opaque field may itself contain the enum
words), and over the bytes JSON uses for structure. The consequence is witnessed
too: advancing one coordinate's cursor and writing its note leaves the other's
cursor absent and its notes at zero.

Each collision pair asserts its own control first, that the old scheme DID
collide on it, so a green says the fix discriminates rather than that the pair
was harmless.

Mutation: restoring the pipe-join reds exactly the new witnesses (three pairs,
eleven round-trips, the cursor-and-journal isolation) and nothing else, 22 of
the original tests staying green; module restored and hash-verified against the
pre-mutation bytes. 37 passed, 1 expected xfail, ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
…ain the outbox xfail to its cause

Two defects found in second review, both in how the prototype REPORTS rather
than in how it propagates.

run_all dropped declared targets whose cursor was already at the source
revision. run() returns None for "no new source revision" (not an operation,
and that contract stands), and run_all took None to mean "not part of this
invocation", so a replayed partial outcome reclassified itself: first run
partial with reached=(clean,), second run -- nothing changed -- refused with
reached=(). The aggregate must distinguish "already reached" from "never
declared", and the only way to do that is to keep reporting the reached target.
run_all now drives each target through a private path that, when the cursor is
at the source revision, returns the terminal receipt from the journal
(replayed=True) instead of None; a cursor at a revision the journal never
acknowledged is a corrupted sink state and raises rather than guessing. The
public run() is unchanged. Witnessed across three invocations: partial, then
partial again with the reached target enumerated as a replayed terminal
receipt and not applied twice (reflog count held at 1) while the refused
target starts attempt 2, then acknowledged with both reached once the author
cleans the refusal; plus an empty-targets control.

The born-red outbox witness accepted any failure. xfail(strict=True) marks a
test expected to fail for ANY reason, so a broken premise -- nothing emitted,
nothing offered on the first read -- would have satisfied the marker forever
while its reason text kept claiming cursor loss; replacing the first read's
result with [] still reported the same expected xfail. The marker is now
xfail(strict=True, raises=EventLost), where EventLost is raised only by the
final survival check; every premise assertion is a plain assert, which is not
the expected type and is reported as a real failure. The premise itself is
also an ordinary test of its own, so an upstream outbox regression reds the
suite instead of hiding inside the envelope.

Evidence: 40 passed, 1 xfailed, ruff clean. Mutations, each restored from a
saved copy and hash-verified: run_all back to the first cut's run() reds
exactly the new replay witness; the witness's own first read returning []
reds the premise test AND reports the witness as FAILED rather than xfailed;
an outbox that offers the event again reports XPASS(strict) as a failure,
which is the marker-off signal the witness exists to give.

Co-Authored-By: Claude <noreply@anthropic.com>
Found in third review: the branch that refuses a cursor sitting at the source
revision with no acknowledged journal attempt behind it was promised by the
previous fix and by the PR body, and nothing witnessed it. Replacing the
RuntimeError with a silent return None kept the whole file green -- and a
silent None there is the exact outcome class the previous fix closed, a
declared target dropped from the aggregate, now with a corrupted sink state
underneath it instead of a healthy one.

The check now runs on both driver paths, not only the aggregate one: run()
and run_all() both consult the journal when the cursor is already at the
source revision, so a cursor the journal never acknowledged raises from
either entry point instead of run() reporting "nothing new" over it. The
witness forges the one state the journal can never produce (cursor advanced
with no rows), asserts both entry points raise with the missing-
acknowledgement detail, asserts the destination is byte-for-byte untouched,
and carries a healthy control: an acknowledged cursor is the ordinary current
state, None from run() and an already-reached replayed receipt from run_all().

Evidence: 41 passed, 1 xfailed, ruff clean. Mutation, restored from a saved
copy and hash-verified against the pre-mutation bytes: the raise replaced by
return None reds exactly the new witness and nothing else.

Co-Authored-By: Claude <noreply@anthropic.com>
feat(gr2): propagation state machine prototype on synthetic repos
…lica

Run the Prototype 0 state machine on a loop against a single destination that a
declaration names as a managed replica of one branch of one source. One tick
observes the source with ls-remote; when the cursor already names the source
revision there is no operation and nothing is written; otherwise the machine runs
once and its receipt, whatever its state, is written as its own JSON file with
per-state latency derived from the receipt's own transition timestamps, and
announced as one propagation.receipt event on the gr2 outbox. A refusal is a
receipt too, and a refused replica is left untouched.

The declaration can only name a replica: an authoring kind, an unknown git
environment, a non-positive interval, or a missing field is refused before any
git call. ensure_replica clones the declared branch single-branch when the path
is absent and refuses a checkout whose origin or branch differ, or a non-git
directory, before the machine ever reads it.

Adds EventType.PROPAGATION_RECEIPT (documented in HOOK-EVENT-CONTRACT.md 3.2 and
7.2, with receipt_path declared as an explicit exception to the relative-path
rule) and bumps the exhaustive EventType count test to 36.

Tests: 35 new witnesses on synthetic repositories (declaration refusals, replica
ensure and refuse, first tick, current tick, pushed change, refused then applied,
receipt round trip with recomputable latency, loop and CLI). run_loop resolves
stdout at call time so a redirecting caller gets the lines.

Co-Authored-By: Claude <noreply@anthropic.com>
…keeps inherit as inherit

The first dogfood run against a real private remote hung on a username prompt:
the machine collapsed an explicit empty git environment (the daemon's "inherit"
mode) into its isolated default because {} is falsy, so the clone made with the
host's credential helper was followed by an ls-remote without it. Three changes:

- the machine distinguishes None (the isolated default) from {} (inherit), and
  names a failed or empty ls-remote SourceUnobservable (a RuntimeError subclass,
  raised before any state is touched) instead of a bare RuntimeError
- the daemon sets GIT_TERMINAL_PROMPT=0 in both git environment modes, so a
  missing credential fails the tick instead of hanging the loop on a tty
- run_loop prints and counts a tick whose git call fails (SourceUnobservable,
  CalledProcessError, OSError) and goes on; the next tick replays whatever the
  machine left pending, which is the machine's own kill-and-replay contract

Witnesses: inherit reaches the machine as inherit (asserted at both ends), both
modes carry the prompt override, a source moved away fails two ticks with
tick-failed lines and no receipt, and the tick after it returns acknowledges.
Four mutations (restore the `or`, drop the prompt override, stop catching
SourceUnobservable, raise the bare RuntimeError again) each kill exactly their
witnesses.

Co-Authored-By: Claude <noreply@anthropic.com>
… the journal escape

The second reviewer of this branch enumerated every raise site reachable from a
tick and ran the one the loop still let escape: the machine wraps a failed
destination read in DestinationUnreadable, which is raised at observe, plan, and
verify (each a point the machine replays from), and the loop caught the two
exceptions it wraps but not the wrapper, so a checkout removed or a volume
unmounted mid-loop crashed the daemon on the next operation.

- run_loop now catches DestinationUnreadable with the other environmental
  failures, prints it as a tick-failed line, counts it, and goes on; the catch
  list is derived from the machine's raise sites and says so
- the machine's deliberate escape for a cursor the journal cannot account for is
  named JournalInconsistent (a RuntimeError subclass) so an escape that is meant
  is distinguishable from one that is not
- the docstring lists what propagates by design

Witness: the first tick acknowledges, the checkout is removed and a new source
revision pushed, two ticks fail with "destination unreadable" lines and no new
receipt, and a fresh loop re-ensures the replica and acknowledges. Mutations:
dropping DestinationUnreadable from the catch list kills exactly that witness;
raising the bare RuntimeError again kills exactly the corrupted-cursor witness.

Co-Authored-By: Claude <noreply@anthropic.com>
…ursor; witness what propagates

Reviewing commit 3, the second reviewer widened the loop's catch list to swallow
RuntimeError and LookupError and the suite stayed green: the docstring's claim
about what propagates by design was unwitnessed. Writing the witness found a
real gap behind it. The daemon's tick observed the source itself and, when the
cursor already named the source revision, returned "current; not an operation"
without asking the machine, so the machine's corrupted-sink check (a cursor at a
revision the journal never acknowledged raises JournalInconsistent) never ran on
the daemon path and "not an operation" would have hidden corrupted sink state on
every tick forever. Found by the witness, not by the dogfood.

- Propagator.run accepts an optional observation so a caller that already
  observed this tick can hand it over instead of asking the source twice; the
  cursor check runs on it all the same
- the daemon hands its observation to the machine on both paths, so a corrupted
  cursor leaves the loop by name with no tick line and no receipt
- witnesses: a forged cursor makes run_loop raise JournalInconsistent with
  nothing written; LookupError, a bare RuntimeError, and ValueError from a tick
  are not swallowed (the loop is bounded in the test so a wrong loop fails
  rather than hangs)

Mutations, eight rows in one run, each killing exactly its witnesses: the six
from before, plus restoring the not-new shortcut and widening the catch list to
RuntimeError and LookupError.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(link): verify gripspace pins before applying
feat(gr2): Prototype 1 propagation daemon on one declared managed replica
…lease-guarded fast-forward push

The contribution protocol on the Prototype 0 machine: one new destination kind and
no new state. DestinationKind.CANONICAL is a bare remote that owns the branch; an
operation with direction=up observes the child's branch, fetches it into the sink's
mirror, plans against the owner's branch as read now, and lands by `git push
--force-with-lease=<branch>:<expected_base>` from the mirror. The receiving
repository enforces the compare-and-swap; the plan's fast-forward gate guarantees
the lease never forces; a rejected lease is a REFUSAL carrying the revision the
owner holds. Replanning is the author's act: the machine never rebases, merges, or
forces on anyone's behalf.

Machine changes: read_head reads the branch ref (never HEAD) for a canonical; the
cleanliness gate is recorded NOT RUN for a bare destination rather than omitted;
ahead/behind fetches the branch for a canonical; apply has a lease-push verb with a
`before_apply_verb` test seam; verify names the canonical postcondition
(branch-is-intended-after-and-tree-matches-digest, no worktree term).

Six witnesses on a scratch parent + two subspace clones: the happy path with exact
revisions and the not-run gate; the manufactured collision refused at plan with the
observed base and nothing touched; the compare-and-swap race (plan, sink dies, the
other child lands, resume) refused at apply; the lease refusing a move inside the
check→push window; replan-by-the-author landing as a fresh attempt with both
attempts in the journal; policy refusing `up` before any verb. Five mutations each
kill their own witness (bare --force instead of the lease → the window witness;
dropped apply-step head check → the race witness; fast-forward gate forced to pass
→ the collision witness, because a held lease would then force; porcelain on the
bare remote → every landing; clean gate as pass instead of not-run → the happy
path). Existing suites: 83 passed, 1 xfailed unchanged; with this file 89 + 1.

Co-Authored-By: Claude <noreply@anthropic.com>
…ibution sets, retire refusal, append surfaces

The machine lands ONE contribution. This adds the protocol around it, each piece
the smallest shape its witness needs:

- the state machine's run() takes stop_after=PLANNED: drive an operation through
  its gates and stop BEFORE any verb, journaled at planned; a later run resumes it.
  This is how a set prepares every member against its owner's current base before
  landing any of them
- ResolvedManifest / ResolvedEntry: every resolved entry carries declared_by and
  overridden_by; owner is the override or the declaration; classify() answers by
  longest declared prefix and refuses a path under no entry; two layers declaring
  the same entry without override is a NAMED ResolutionCollision, never a silent
  precedence. Today's resolver flattens this away, so the dataclass is the field
  the resolver must grow and the witnesses run against the stub
- ContributionSet: prepare() every member (no verb), land() in declared order,
  STOP at the first member that does not acknowledge; the set receipt names the
  landed, the refusing, and the not-attempted members; nothing is rolled back
  (history is forward-only; a rollback would be a new forward operation)
- Subspace.retire() refuses while any contribution is open (not acknowledged and
  not explicitly abandoned) and lists their operation ids; abandon() is a note in
  the contribution's own journal, so a change can never simply evaporate
- AppendSurface: one guarded append point (exclusive lock across write, flush,
  fsync), arrival-ordered sequence numbers, no expected base because appends
  commute BY DECLARATION; file-level only, never a git-tracked path

Eleven witnesses in gr2/tests/test_contribution_protocol.py (W1 x3, W4 x4, W5 x2,
W6 x2). Eight mutations, each killed by its own witness: refused-is-terminal,
retire-ignores-open, land-never-stops, prepare-lands, collision-by-precedence,
owner-ignores-override, append-caches-seq, classify-first-match. Two of those
needed the harness tightened first: one mutation had not actually applied (the
harness now asserts the mutated file differs from the saved copy before running),
and the classify fixture's declaration order made first-match and longest-prefix
agree, so it was reordered until they disagree. All four propagation suites:
100 passed, 1 xfailed.

Co-Authored-By: Claude <noreply@anthropic.com>
…s, printed for one landing and a two-member set

state_latencies(receipt) returns (state, seconds since the previous transition) rows
from the receipt's timestamps — the number comes from the artifact, not from a
stopwatch around it. One test prints the table (visible with -s, written to a file)
for a single contribution and both members of a set. On this host a single
contribution lands in ~0.23 s end to end (fetch ~0.09, plan ~0.05, lease push
~0.07, verify ~0.02); for set members the applied row spans prepare -> land by
construction, so it reads as the time the prepared base sat, not the push alone.

Co-Authored-By: Claude <noreply@anthropic.com>
… neighbour

The journal writer appends and fsyncs, so a kill between the two leaves a
partial line. Three consequences, each with its own witness.

A tear used to cost TWO rows, not one. Appending onto an unterminated line
glues the new row to the remnant, so a row that was written correctly and
fsynced becomes unreadable. _write now terminates a remnant before
appending, confining the damage to the line actually interrupted.

_rows raised a bare JSONDecodeError on any unparseable line. The daemon
lets anything outside its named failure list propagate, so one bad moment
exited the loop on its first tick and on every restart after it. Lines that
cannot be parsed are now dropped and counted, and MalformedLine keeps the
drop from being silent.

Position does not discriminate cause, which is why the drop is unconditional
rather than trailing-only: a tear is trailing only until the daemon restarts
and appends again, after which the same orphan sits mid-file with intact
rows on both sides. The question that does discriminate is whether the
cursor can still be accounted for, and replay already asks it and already
raises JournalInconsistent by name. Dropping here lets that check decide
instead of being pre-empted by a parse error.

Prototype 1 asks the machine to account for an observation on every tick,
including idle ones, which moved this parse from once-per-change to
once-per-tick. Rows are now cached against (size, mtime_ns); the Propagator
is built once per loop and holds one Journal, so the cache spans ticks. The
one state it cannot see - an in-place mutation changing neither size nor
mtime_ns - is named in the code rather than assumed away.

86 passed, 1 xfailed, up from 83 by exactly the three witnesses added.
Each guard is mutation-proven: removing the newline repair reddens the
neighbour witness, restoring the bare parse reddens that one and the skip
witness both, and disabling the cache reddens the idle-tick witness.

Ref #893 - closes at promotion

Co-Authored-By: Claude <noreply@anthropic.com>
fix(gr2): a torn journal line stops being fatal, and stops eating its neighbour
feat(gr2): Prototype 2 contribution protocol — canonical destinations, lease-guarded up, sets, retire refusal, append surfaces
A writer killed between its record and the terminator leaves a remnant line.
Three consequences, all measured before this change:

  1. append() raised on the remnant and kept raising: the guarded append point
     was BRICKED permanently, because every later writer re-reads the whole file
     to compute the next sequence number and dies on the same line.
  2. The next record GLUED onto the remnant, producing one unparseable line and
     losing a record that had itself completed and fsynced.
  3. records() raised, so every reader of the surface died too.

Both scans now skip a line they cannot read AND count it (malformed_lines,
reflecting the most recent full scan). Skipping alone is silent, and silence is
the defect: the remnant sits in the file while a caller sees a healthy-looking
surface with a contiguous sequence. append() also repairs a missing terminator
before writing, so a completed record is never swallowed by an incomplete one.

MalformedLine is reused from the state machine rather than re-declared, so the
two append-only surfaces in this tree report a torn line in one vocabulary.

Four witnesses, five mutations, each mutation killing witnesses whose failure
TYPE matches it: removing the terminator repair kills by AssertionError (glue is
wrong content, not an exception); removing either scan's guard kills by
JSONDecodeError and KeyError, exactly the types the guard catches; silencing
either count kills only that path's count assertions. Tear-fixture count for
this surface: 4, previously 0.

Ref #897 - closes at promotion

Co-Authored-By: Claude <noreply@anthropic.com>
Two review blocks on this PR, both correct, and both about the SHAPE of the
guard rather than a case it was missing.

FIRST: an except tuple over untrusted file content is a DENYLIST, and a denylist
leaks by construction. Valid JSON seq 1e999 parses to inf, int(inf) raises
OverflowError, and no list written from the parse side would have predicted it,
because the COERCION AFTER the parse is what invents the new failure. Asking what
else escaped the same guard found a sibling: deeply nested JSON raises
RecursionError from json.loads itself and bricks identically. So the coercion is
gone. Everything after the parse validates types rather than converting them, and
inf is refused because a float is not a sequence number, with no exception
existing to catch.

SECOND: the decode is a raise site BELOW the parse. The exception type was never
the problem, since UnicodeDecodeError subclasses ValueError which was already
caught; the OPERATION that raises it sat outside the guarded region. In text mode
the decode happens while the iterator MANUFACTURES the line, so bytes are
converted before any try block can see them. The fix guarded the transformation
and left the ACQUISITION unguarded.

Operationally that was worse than a brick: a whole-file decode means ONE bad byte
anywhere destroys every record in the surface, including the thousands written
correctly around it. The file is now read as bytes and decoded one line at a
time, which is what a JSONL file actually is, so a bad byte costs its own line.

A line passes through four layers - read, decode, parse, shape-check - and the
last three are each guarded where they happen. Naming the enumeration rather than
claiming completeness, because two earlier claims that the raise-site set was
closed were each broken by the next reviewer: unbounded line length is a resource
limit on the READ layer and is deliberately not defended, stated in the class
docstring and the PR body rather than left to be discovered.

Also pinned, found when a new witness exposed a wrong expectation in a fixture of
mine rather than a defect in the code: a line can be unreadable as a RECORD while
its sequence number is perfectly readable, and numbering honours it anyway. No
writer is ever issued a number a reader can already see on disk.

Seventeen hostile-content and undecodable-byte rows plus a confinement witness
proving ten good records survive a bad byte written between them. Ten mutation
rows, measured with an extractor that reads only E-prefixed traceback lines and
is proven against a green control - an earlier extractor read exception names out
of parametrized TEST IDS and reported types that were never raised. One row was
discarded as contaminated, its kills coming from a sloppy edit rather than the
defect, and re-run clean.

Ref #897

Co-Authored-By: Claude <noreply@anthropic.com>
fix: AppendSurface survives a torn write, and counts what it skips
laynepenney and others added 14 commits August 20, 2026 07:59
Both lane prototypes carried a BYTE-IDENTICAL append_jsonl and a reader with the
same body under two names. That is one defect with two addresses, not a class
with two instances, so it gets one fix rather than two: a shared jsonl_store
module both consume. Fixing them separately would have written the same fix and
the same witnesses twice and left the copies free to diverge again, which is how
the situation arose.

The defect, in both copies: the writer appended with no terminator repair and no
fsync, so a writer killed mid-write left a remnant and the NEXT record glued onto
it - a record that had itself completed became part of one unparseable line and
was lost. The reader parsed with a bare json.loads over read_text().splitlines(),
so the remnant raised, and the whole-file decode meant one invalid byte anywhere
destroyed every record in the file rather than its own line.

WHERE THE COUNT LIVES, which was the open design question: these are module-level
functions with no instance to hang health on, and a module-level accumulator
would be hidden state and wrong under concurrent readers. So the count is a
RETURN VALUE - read_jsonl returns rows AND the lines it could not read. The
question dissolves rather than getting answered.

And the count reaches a CONSUMER: both CLI callers report it on stderr, so a
--json caller's stdout stays machine-readable. A count nobody surfaces is the
same silence as no count at all, which is the defect this fix exists to close.

The reporter lives in the shared module too. The first version of this change put
a helper in one consumer and an inline copy of the same three lines in the other -
byte-adjacent duplication of a reporting rule across two files, which is the exact
shape being eliminated, committed inside the fix. Caught before the gate.

Structure is validated; schema is not. A generic reader has no fields to check, so
a line that is well-formed JSON and a well-formed object IS a row even when its
values are odd. That boundary is pinned by its own witness, added after a witness
failed against a wrong expectation of mine rather than against the code - the
hostile-content table had been copied from a surface that does have a schema.

21 witnesses, 7 mutation rows, each mutation killing witnesses whose failure TYPE
matches it: removing the decode guard kills seven by real UnicodeDecodeError;
removing the parse guard kills six by real JSONDecodeError, RecursionError and the
integer-literal ValueError; silencing the count kills fifteen; silencing the
reporter kills exactly one, which is what proves the count reaches a consumer.

The fsync witness is deliberately weaker than it sounds and says so: it pins that
fsync is CALLED, not that durability holds, because a real crash is unwitnessable
in-process. It exists because without it, deleting the fsync killed nothing at
all - and a guard nothing checks is not a guard.

Unbounded line length is a resource limit on the READ layer and is deliberately
not defended, named in the module docstring rather than left to be discovered.

Ref #897

Co-Authored-By: Claude <noreply@anthropic.com>
fix: one torn-line-safe JSONL home for both lane prototypes
Fix 4 of the torn-line sweep, and the two-part contract it was ruled as.

TERMINATOR REPAIR. emit() appended with "a" and wrote json + "\n". A previous
write that died between write() and fsync() leaves a last line with no
terminator, so the next append GLUES two records into one. The damage runs
FORWARD from the tear: the torn record and THE NEXT HEALTHY APPEND fuse into
one unparseable line, while the record before the tear is untouched. A torn
write therefore costs that record and the next one written after it,
permanently, because every later append builds on the glued line. emit() now probes the
last byte under the existing write lock and heals the seam first.

THE COUNT. Both readers skipped unusable lines in silence. For the channel
bridge an unreadable line is a message that never reaches a channel. It is
reported on EVERY read, not once: the cursor filter applies only to lines that
parse, so a line with no usable seq can never be advanced past, wherever it
sits -- position is irrelevant and mid-file lines repeat exactly as trailing
ones do. Deliberate, not incidental, and its cost is named in the residuals.
read_events_detailed() now returns the events AND the lines it could not read,
from the SAME read. read_events() stays list-shaped for the ELEVEN call sites
that index and len() it -- all tests, and ZERO production callers remain once
the bridge moves to read_events_detailed(), so the wrapper is a
test-compatibility surface rather than a load-bearing API. An earlier draft of
this message said seventeen; that was a substring artifact counting a def, two
prose mentions inside strings, and four hits on an unrelated _read_events helper
in another test file. The bridge reports on stderr so stdout stays parseable.

Reported from the read path ONLY. _current_seq() runs once per emit inside the
write lock, where a count would be per-APPEND and aimed at whoever happened to
be writing. Its docstring says so, because an omission and a decision look
identical in code.

Also guarded, each with witnesses: the decode moved to bytes-per-line so a
single invalid byte cannot escape from outside every guard; the parse
guard's exception tuple is now derived from what json.loads can
raise rather than from what had been seen -- the old (JSONDecodeError,
TypeError) caught syntax errors but missed RecursionError, which is not a
ValueError, so deep nesting escaped; structure is checked separately, since a
valid JSON array parses and is still not an event; seq values are type-validated, since
bool is an int subclass and a float becomes inf and serializes as Infinity;
and a corrupt cursor no longer bricks reads.

THREE THINGS THIS FIX GOT WRONG FIRST, none found by its own witnesses:

- An early version swallowed OSError in the line iterator. That is exactly the
  OSError-to-zero fallback an earlier fix removed on purpose: swallow it and
  _current_seq returns 0, then emit allocates sequence numbers that duplicate
  live ones. A pre-existing test stood guard and caught it. Content errors are
  data and get skipped-and-counted; an I/O error is not knowing what the file
  holds and must fail closed. The reader tolerates FileNotFoundError only,
  because rotation renames the file, and propagates every other OSError.
- MalformedLine and EventRead were dataclasses. This module is loaded
  out-of-tree by spawned workers via spec_from_file_location + exec_module,
  which does not register it in sys.modules, and dataclass resolves field types
  through exactly that. Every worker died at import. Now plain classes, with a
  witness that fails in 0.06s naming the cause instead of after a 10s timeout
  that reads like flakiness.
- The default report echoed raw line content to stderr, verbatim, including an
  API-key-shaped string in a measured probe. stderr is copied into CI logs and
  transcripts. Redacting was rejected: matching secret-shaped patterns in
  arbitrary bytes is a denylist over untrusted input, the same defect the parse
  guard exists to avoid. The excerpt stays on the data object; the default
  report prints ordinal and reason, both structural, with show_content opt-in.

One existing test's monkeypatch target moved read_text -> read_bytes because
the read verb changed; its contract and every assertion are unchanged, and it
would otherwise have gone green by missing its target. Both it and the new
witness now undo the patch before reading the file back, since a verification
must not travel the path the test deliberately sabotaged.

A reviewer measured both of those statements against an earlier version of
this message and of the description, where the glue direction was backwards
and the count was claimed to be reported once. The suite was green while the
prose said the opposite of the code, because nothing pinned that behavior.
Three witnesses now do, including the mid-file case, which shows the rule is
broader than the trailing-line framing that surfaced it.

A third review then caught a further claim, in the description only: that a
torn last line self-heals at the next emit. That is true of one tear and false
of the other, and the bare word "torn" hides the difference. An UNTERMINATED
record -- complete, only its newline lost -- DOES recover fully once the seam is
healed. A TRUNCATED record -- the write stopped mid-record -- never does: its
bytes were never written, so it stays unreadable and every read reports it until
the file is repaired. A FOURTH review then caught the opposite
overcorrection, which a later draft of this message had made -- claiming a torn
record never becomes readable -- and the unterminated-tear control disproves it. Both cases are now named separately and the bare
word is not used to claim anything about recovery. That prose was wrong only
because every tear fixture until then dropped the trailing newline and left the
record COMPLETE -- the lucky case. Three witnesses now cover the realistic tear,
with the UNTERMINATED tear kept as a discriminating control so the finding cannot be
mistaken for the norm. What the repair buys, stated exactly: it cannot recover a
record that was never fully written, and it saves the NEXT one.

Correcting the message and the description was not enough, and a second review
caught that: the same backwards claim was still in the production repair
comment and in the test module docstring. Fixing the two cited surfaces and
stopping there left the code asserting one direction while the description
asserted the other -- an artifact contradicting itself, which is worse than
being uniformly wrong. A sweep for the whole class rather than the cited
instances found a third live instance neither review named: the
reported-once claim, still standing in that same docstring. Those particular
corrections changed comments only and left the executable syntax tree identical,
verified by parsing both revisions and comparing with docstrings stripped.

A FIFTH review then caught the same overclaim surviving in the test module
comment -- "A TORN RECORD IS NEVER REPAIRED BY A LATER EMIT" -- which the
unterminated control disproves. The class query that was supposed to have swept
it missed it because the query was CASE-SENSITIVE and the comment is upper case:
a false negative from my own instrument, of the kind noted one revision earlier
and then committed in the next query. That round also renamed the control from
"benign" to "unterminated" so the tests and the prose use one vocabulary, so
unlike the previous round the syntax tree DID change here and the suite was
re-run rather than reasoned about.

A SIXTH review, from the other reviewer, found three defects and all three
were PROSE -- the code it gated was clean. The first is the one worth the round:
the reported-once claim was still live in the channel-bridge comment, in the
bridge hunk of ALL FIVE versions, and every class sweep I ran missed it because
it PARAPHRASES the sentence the earlier reviews cited rather than repeating it.
A query built from a cited instance finds COPIES, not paraphrases; the class is
the CLAIM, and the only instrument that finds a paraphrase is reading the hunk.
The second: the parse-guard sentence read as if the new tuple were open-ended.
It is enumerated -- (ValueError, RecursionError). What changed is where the
enumeration comes FROM: what json.loads can raise, rather than what had been
seen. The third: "seventeen call sites" was an artifact of a substring query
that counted a def, two prose mentions inside strings, and four hits on an
unrelated _read_events helper in another test file. The real count is eleven,
all tests, zero production callers. BOTH reviewers co-signed seventeen at v1
from that same defective query, which is why it needed re-deriving rather than
re-reading -- and the class sweep for it then found three more copies of the
count in docstrings that no review had cited.

A SEVENTH review found two more, and one of them was wrong in the CODE rather
than in the prose about it. The bridge comment said an unterminated record is
reported and then heals at the next emit. Measured false: an unterminated record
whose bytes are COMPLETE is never reported at all, because _iter_outbox() splits
on b"\n" and a complete final chunk parses on the spot -- before any emit, repair
or no repair. The sentence implied a window of unreadability that does not exist.
The gap that let it survive is now pinned: every tear fixture in this file emitted
AFTER tearing, so nothing had ever asked what the READER alone does with a torn
file. W11 is that pair -- the unterminated case reporting nothing across two
reads, the truncated case reporting one as its discriminating control. The
reviewer's own probe became the witness. Its two siblings, the W10 header and the
description's table, are scoped to tear -> emit -> read and are true there, so
they are deliberately unchanged.

The second finding was stale suite totals, and the mechanism differs from the
one proposed: the +3 is not the earlier merge, which added 21 tests that are
inside both baselines, but a test directory the narrow invocation does not
collect. Both figures were correct measurements of different scopes and the
defect was publishing one without naming which -- which is also how the three
mutation rows above went stale, unasked about by any review.

RESIDUALS, named rather than presented as clean: this is a third copy of these
primitives, and consolidating them is outside this fix's scope; the same
content-echo property exists in the prototype reporter merged earlier;
unbounded line length remains a read-layer resource limit and is undefended;
and a TRUNCATED record -- an UNTERMINATED one is readable throughout and never
reaches the report at all -- is permanently unreadable and reported on every read, so a consumer
polling in a loop warns every cycle until the file is repaired -- suppressing
that would need persisted already-reported state and would hide a re-occurring
fault, so it is the lesser cost but it is a cost.

Evidence: 37 witnesses, 0 before. Nine mutation rows, RE-MEASURED for this
version over the two events spec files (71 tests), each killing witnesses whose
failure TYPE matches the mutation, restores hash-verified with the unmutated
pair as a control: decode 11, exception tuple 1, terminator repair 5, seq
validation 4, count silenced 19, reporter silenced 1, OSError swallowed 3,
FileNotFoundError widened 1, cursor guard 1. Three of those rows were STALE --
published as 8, 3 and 12 where the true figures are 11, 5 and 19 -- because
they were measured early and never re-derived while five review rounds added
witnesses to the files they count.

Full suite at BOTH scopes, base pinned by SHA, since omitting the scope is what
made the last version's totals unverifiable. On origin/dev@93675677 in an
isolated worktree: pytest gr2/tests 1032 passed / 5 failed, pytest gr2 1035 / 5.
On this head: 1069 / 5 and 1072 / 5. Delta exactly +37 in both, matching the
witness count, failure sets identical in both directions. The scopes differ by
gr2/gr2_overlay/tests/test_overlay_refs_namespace.py -- 3 tests, measured
passing on both sides, which the narrow invocation does not collect. Lint
unchanged: production 6 = 6, edited test file 23 = 23, new file 0.

Premium boundary: grip is OSS; this is local file mechanics over opaque paths
and carries no identity, org, or policy content.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(events): repair torn outbox lines and report the ones that cannot be read
gr checkout reported every per-repo failure to the terminal and
incremented no counter, so a run in which every repo failed printed
"Switched 0/N repos to <branch>" and exited 0. The printed ratio is
something a caller has to read and interpret; the exit code is the only
failure signal a script sees, and it reported the batch as done.

Adopt cli/repo_iter::for_each_repo -- which is what gives this command an
error count at all -- and make a nonzero exit disjoin the per-repo
failures.

Two changes fall out of the adoption:

- for_each_repo's cloned-check called path_exists on the repo directory
  while its own docstring promised to skip repos that "aren't cloned".
  RepoInfo::exists tests for .git, which is what cloned means; the two
  diverge on a directory that exists and is not a clone. Aligning the
  code with the docstring preserves checkout's existing behavior exactly
  rather than reclassifying a skip as an error.
- for_each_repo_path had no callers and gains none here, so it is
  removed rather than left relying on pub visibility to keep its own
  dead-code warning quiet.

The plan step prescribing that suppression is struck in the same change.
An unused private item warns and an unused pub item does not, so
following the step as written left the module with zero callers and
nothing anywhere going red for as long as it existed. A warning is a
detector; silencing one to reach a clean build is not a fix.

Tests pin both sides of one property. The discriminator is an ABSENT
.git versus a PRESENT BUT UNOPENABLE one: absent is a skip and still
exits 0, present but unopenable is an error and exits nonzero. Without
both sides, a witness asserting only the failure case could pass while
the skip path had silently become an error too.

These are two separate tests with their own fixtures, and those fixtures
differ in more than the discriminating property -- one corrupts a single
repo and the other both, one creates a branch first, and they target
different branches. The claim is about which property discriminates, not
about the fixtures being otherwise identical.
…o-failures

fix(checkout): count repo failures and disjoin them into the exit code
…topology

feat(workspace): initialize spec from declared topology
gr pr merge reported success when every merge in a run had failed. The
per-repo diagnostics were printed and truthful; the exit code was not, so
a script driving the command was told a batch had merged when none of it
had.

Three exits returned Ok while carrying failures:

- an empty candidate list that was empty because the PR lookups FAILED.
  "We looked and found nothing" and "we could not look" are different
  answers, and only the first is an absence of PRs. They are now tracked
  separately rather than arriving at the summary as one state.
- the --auto path, where every attempt to enable auto-merge could fail.
- the final exit, after a mixed or wholly failed run.

Each now fails with a count of what failed against what was attempted.

The test fixtures needed correcting first. mock_get_pr mounts a single
invariant response, so a read issued after a successful merge still
reported merged: false, which the real API cannot produce.

Three groups of tests were affected, by three different causes, and they
should not be counted together.

FIVE tests began failing for the fixture reason once the exit code became
truthful. The new mock_pr_lifecycle couples the GET and the merge PUT
through shared state so the sequence behaves as the live API does; all
five then passed with NO ASSERTION CHANGED, which is what distinguishes a
fixture defect from a contract change.

A SIXTH test, repo_filter_excludes_non_target, carried the same fixture
defect and never went red at all: its PR was mocked as state "open" and
merged: true simultaneously, making the command's own post-merge
verification vacuous, so it passed whether or not the merge did anything.
It now starts unmerged and GAINS an assertion that the merge actually
fired.

ONE test, branch_behind_suggests_update, was red for an unrelated reason
and is the only assertion this change edits. It required is_ok() while
its own failure message said "handled without crashing". Those are
different claims. The merge did not happen, so graceful handling means a
useful error rather than a success. Its fixture is untouched.

Every added guard is mutation-proved: neutering each of the three exits
independently turns a named test red. The --auto path had no test of any
kind before this change, so its guard arrived with the first one.

Ref #884 -- closes at promotion
Ref #886 -- closes at promotion
…ilures

fix(pr): count failed merges and disjoin them into the exit code
Bumps the crate to 1.2.0, adds the 1.2.0 changelog entry, corrects a false
[Unreleased] heading, and fixes formatter drift in one test file.

SCOPE. The work promoted is v1.1.0..6192e8c -- the 1.1.0 tag to the dev tip at
freeze -- which is 42 commits / 14 first-parent units. This release-prep commit
sits one beyond that; counting it makes the range 43 / 15.

Units are classified by diffing each against its FIRST PARENT. A merge commit has
no canonical diff and git show --name-only returns nothing for these, so the
method is part of the claim.

FIVE of those 14 units are in the published crate. Nine are gr2, a separate
Python surface at 0.1.0 that is not distributed. An entry claiming fourteen units
of work would describe a release nobody can install.

Two claims are deliberately narrow. The exit-code work fixes two instances of a
class with 9+ known members, and the most consequential member is untouched. The
freshness work ships with two measured holes open and no user-facing
documentation. Both are stated as limits in the entry.

A commit-level statistic was removed twice. First as unsourced -- "roughly a
third of this repository's active commit volume," no range, no metric. Then as
mis-sourced: "22 of 42 commits, measured by first-parent diff" cited a method
that did not produce it, since 22 comes from a path-limited rev-list whose
history simplification drops eight merges that touch gr2 against their first
parent. Under the named method the figure is 30 of 42. A number citing the wrong
method is worse than an unsourced one, because the citation invites trust it has
not earned. The unit measure, 9 of 14, needs no footnote and is what remains.

The [Unreleased] heading was false: that work shipped in v1.1.0 on 2026-08-13 and
is on crates.io. Verified by checking the feature's own symbols into the v1.1.0
tree with a negative control at v1.0.2, not by commit archaeology.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@laynepenney
laynepenney merged commit 174354a into main Aug 22, 2026
7 of 13 checks passed
@laynepenney
laynepenney deleted the promote/v1.2.0 branch August 22, 2026 00:50
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