Skip to content

Let a criteria re-review actually happen, and stop a losing ACK overwriting a verified one - #22

Merged
thisisjun786 merged 7 commits into
devfrom
codex/jun-167-ack-residual
Sep 17, 2026
Merged

thisisjun786 merged 7 commits into
devfrom
codex/jun-167-ack-residual

Conversation

@thisisjun786

@thisisjun786 thisisjun786 commented Sep 17, 2026

Copy link
Copy Markdown
Owner

The problem

Editing an assignment's canonical criteria after a verified verdict was already reported
correctly: AssignmentView drops out of verified into re_review_needed and asks the parent to
verify again. Nothing could then actually do it.

record_verdict returned the historical ruling before it ever looked at the new set, the review
could not be claimed again onto the new digest, and an event id is derived from the artifact
revision, so re-emitting unchanged bytes produced no new event to rule on either. The assignment
stayed in re_review_needed until the child happened to change a byte it had no reason to change.

The same dead end sits one step earlier, with no verdict at all. Claim a review, let somebody edit
a criterion while it is being read, and the ruling is refused with "claim the review again" -
which INSERT OR IGNORE on verification_claims and claim_context made impossible.

Separately, which of two competing acknowledgements won was decided by scheduling. acknowledge
reads the existing acknowledgement outside its write lock and returned early only for a verified
one, so an accepting and a rejecting process both passed that read. evaluate reports
duplicate_event for a settled event, but the conflict guard refused it only for accepted=True,
so the rejection carried on into the upsert and replaced a verified acceptance with a verified
rejection
. Since a verdict requires a verified acceptance (I-73), that also closed the verdict
path for a revision the parent had already accepted.

Before / after

Trigger Before After
Verified ruling, then a criterion is edited every re-review returns the old ruling with _replay; assignment blocked indefinitely re-claim rebinds to the set in force, the ruling names that set and is decided again, assignment returns to verified
Review claimed, then a criterion is edited CRITERIA_SET_CHANGED says "claim the review again"; re-claiming is a no-op re-claim succeeds and the review finishes
Accept commits, rejection loses the race stored acknowledgement becomes a verified rejection stored acceptance stands; the loser is returned the record that stands, marked _replay

How

ack._re_review_open is the single condition both entry points read, so the claim and the ruling
cannot disagree about whether a re-review is open. The set in force differs from the one this
review was decided against, the event is still the revision this generation stands on, the
relationship is readable, and any ruling recorded for it is a verified one.

needs_changes already moved the assignment to a new generation whose revision arrives as its own
event with its own claim, and aborted ends the assignment, so neither reopens - a second ruling
there would be a second allocation, not a re-review. Currency is required, so a verified ruling
that some later generation left behind cannot be rewritten.

Two fences keep reopening from becoming a way around the guard it reopens:

  • A re-review names the set it read. Without expect_criteria_digest the ruling being
    replaced is indistinguishable from a genuine re-review, so re-submitting it unchanged would
    clear re_review_needed with nobody having read the new wording. cli.py already carries the
    flag as --expect-criteria-digest. record_verdict never rebinds the digest itself; that would
    bypass the guard whose entire purpose is that findings made against one wording cannot certify
    another.
  • A re-review may rule only verified or needs_changes. Those are the two dispositions the
    assignment has a state for. Replacing a certification with unverified or aborted dropped it
    back to verifying with the claim still held, and _re_review_open then refused to reopen
    because the ruling of record was no longer verified - the same deadlock one disposition over.
    Both remain available on an event with no ruling; an assignment nobody intends to finish is
    paused or cancelled on the relationship.

verdict_context now updates on conflict; leaving the stale digest would leave the assignment
reading re_review_needed forever. The schema has one verdict row per event and this change adds
no table, so the replaced ruling is journalled as verdict_superseded with both digests.

For the race, the acknowledgement is re-read as the first statement inside the write transaction
and a verified one is returned unchanged whatever the caller asked for. Both short-circuits now
report _verified and _replay, so a caller can tell a settled duplicate from its own result
instead of reading a verified record as unestablished.

Validation

Both regression suites were written first and fail on the source before this change:

$ uv run pytest packages/codex-session-relay/tests/test_rereview_deadlock.py \
                packages/codex-session-relay/tests/test_ack_disposition_race.py -q
14 failed, 9 passed     # exit 1, ack.py at 9b01215
29 passed               # exit 0, with this change

The race is interleaved, not raced: each process gets its own connection to the same store and
the winner commits inside the window acknowledge documents as its pre-transaction host read.
Nothing sleeps and nothing depends on an ordering the test does not itself impose. PR #19's
per-recipient transport concurrency moved when acknowledgements arrive; it did not move where
that window is.

Repository checks, all exit 0: validate.py, contracts.py, secrets.sh,
CRW_PACKAGES_TMPDIR=/var/tmp packages.py - the last reports codex-session-relay 901 tests,
codex-thread-bridge 119
, against a 872/119 baseline on dev; the 29 added are exactly the two
new files. Every test runs inside its own temporary tree, so none opens a real store.

Preserved and pinned by tests rather than assumed: replay idempotency for an unchanged set
(including verified then verified), CRITERIA_SET_CHANGED, REVIEW_NOT_BOUND,
duplicate-delivery claim idempotency (I-54, including after a re-claim and after a landed
re-review), the deferred verify_pending_acks upgrade, and the currency, stale-generation and
paused refusals. docs/invariants.md gains I-143 through I-146 and an updated I-54.

Known limitations, recorded not fixed

Both are in the "Recorded limits" table in docs/invariants.md.

A claim locks an event, it does not identify who rules. record_verdict has no parameter
naming the claim it rules under, so a caller that attests the current digest is accepted whether
or not it read that set - the same trust the documented expect_criteria_digest alternative has
always carried. This predates re-review: two callers sharing one claim could always submit each
other's findings. Closing it needs a review token in verification_claims and a parameter on
record_verdict for the CLI to pass, which is a larger change than the deadlock this removes.

A same-disposition re-review is not re-synchronised. sync derives sync_id without the
criteria digest and enqueues with INSERT OR IGNORE, so a second verified ruling collides with
the first and the coordination document keeps the summary written against the earlier wording. A
re-review that changes the disposition does enqueue. The local record is complete either way.
sync.py is outside this task's edit surface.

Also noticed and left alone: after an unverified verdict on an event the view reports
VERIFYING / parent_verifies while record_verdict replays indefinitely. That is a pre-existing
view/verdict inconsistency unrelated to criteria edits and not one of the two defects this issue
scoped.

Addresses defects 3 and 4 of JUN-167.

…riting a verified one

Editing the canonical criteria after a verified verdict was already reported
correctly: the assignment drops out of verified into re_review_needed and asks
the parent to verify again. Nothing could then do it. record_verdict returned
the historical ruling before it looked at the new set, the review could not be
claimed again onto the new digest, and an event id is derived from the artifact
revision, so re-emitting unchanged bytes produced no new event either. The
assignment stayed blocked until the child changed a byte it had no reason to
change. The same dead end sat one step earlier, with no verdict at all: claim,
edit a criterion, and the ruling is refused with "claim the review again",
which INSERT OR IGNORE made impossible.

ack._re_review_open is now the one condition both entry points read, so the
claim and the ruling cannot disagree about whether a re-review is open. It is
the state AssignmentView already reports: the set in force differs from the one
this review was decided against, the event is still the revision this
generation stands on, and any ruling recorded for it is a verified one.
needs_changes already moved to a new generation and aborted ended the
assignment, so neither reopens. Currency is required, so a ruling some later
generation left behind cannot be rewritten. record_verdict does not rebind the
digest itself: that would bypass the guard whose whole purpose is that findings
made against one wording cannot certify another, so the parent re-claims and
submits fresh findings. verdict_context now updates on conflict, without which
the assignment would read re_review_needed forever, and the replaced ruling is
journalled with both digests since the schema has one verdict row per event.

The second defect is decided by scheduling. acknowledge reads the existing
acknowledgement outside its write lock and returned early only for a verified
one, so two processes disposing of one event both passed that read. evaluate
reports duplicate_event for a settled event, but the conflict guard refused it
only for accepted=True, so a rejection carried on into the upsert and replaced
a verified acceptance with a verified rejection. The acknowledgement is now
re-read as the first statement inside the transaction and a verified one is
returned unchanged, whatever the second caller asked for. Both short-circuits
now report _verified and _replay, so a caller can tell a settled duplicate from
its own result instead of reading a verified record as unestablished.

Each defect has a regression test that fails on the source before this change:
14 failed, exit 1. The race is interleaved rather than raced - each process
gets its own connection and the winner commits inside the pre-transaction host
read - so nothing depends on timing.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-17T07:10:16.844954Z 3c28b33 New commits
🔒 Security Review Completed 2026-09-17T06:05:23.729067Z 17c1d77 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

devin-ai-integration[bot]

This comment was marked as resolved.

Independent review caught a hole the first version opened. Re-claiming rebound
claim_context to the edited set, and nothing ties a ruling to the caller that
made the claim, so two sequences let findings made against the old wording
certify the new one: a second reviewer re-claims and the first submits what it
read against the previous text, or the original verified ruling is simply
re-submitted after somebody re-claims. Both recorded verified against the new
digest and cleared re_review_needed without a review. Before this branch the
first was refused and the second stayed historical, so this weakened exactly the
guard the branch promised to preserve.

An original claim can pin the set because the relay watched that review start.
After an edit it cannot tell whether the caller that rules next is the one that
re-read the artifact, so re-claiming now deletes the binding and does not
replace it. The ruling names the set it read instead, which is the alternative
criteria.coverage already provides for and which the CLI already exposes as
--expect-criteria-digest. A caller still holding the old findings has nothing
it can truthfully name: with no digest it is refused review_not_bound, and with
the digest it actually read it is refused criteria_set_changed.

Both sequences are now regression tests, and they failed on the previous commit
with "RelayError not raised".
chatgpt-codex-connector[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

…happened

Two review rounds found two ways the reopened path gave away more than it
meant to.

A re-review could rule unverified or aborted. Both replaced the verified ruling,
and the assignment has no state for either: the view fell through to the claim
and reported verifying forever, while _re_review_open then refused to reopen
because the ruling of record was no longer a verified one. That is the deadlock
this branch exists to remove, rebuilt one disposition over. Only verified and
needs_changes may replace a certification now. Neither disposition is lost:
both still apply to an event with no ruling, and an assignment nobody intends
to finish is paused or cancelled on the relationship rather than annotated onto
one of its events.

The previous commit cleared the review binding instead of moving it, to stop a
caller holding old findings from being certified by somebody else's re-claim.
That traded one hole for a worse one: with nothing bound, every later claim
looked like another re-review, so the claim became one anyone could take and
I-54 was undone. Reopening rebinds to the set in force again, and the fence
moves to where it can be enforced: a re-review names the set it read. Without
expect_criteria_digest the ruling being replaced is indistinguishable from an
actual re-review, so re-submitting it is refused. cli.py already carries the
flag as --expect-criteria-digest, so the path stays reachable.

What that leaves is recorded rather than implied: a claim locks an event, it
does not identify who rules. A caller that attests the current digest is
accepted whether or not it read that set, exactly as the documented
expect_criteria_digest alternative has always been, and two callers sharing one
claim could always submit each other's findings. Closing it needs a review token
in verification_claims and a parameter on record_verdict for the CLI to pass.

Both findings are regression tests. They failed on 99a8cf7 with "RelayError not
raised" and with a claim returning proceed where it should have been refused.
devin-ai-integration[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

The docstring claimed the condition is the state AssignmentView reports as
re_review_needed. That is true only where a verified ruling exists. Claiming
hands in the digest the review is BOUND to, so the condition also fires on a
review that was claimed and never ruled, and the view calls that one verifying:
nothing has been certified, only the binding no longer matches. One condition
still, because the remedy is one thing, but not because the view names the two
alike. Raised as a low finding in review and fixed in the invariants table
without the code catching up.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

There are two records of what a review was decided against, because there are
two ways to decide one. Claiming binds the set and the ruling inherits that
binding; stating the digest is the alternative record_verdict accepts instead,
and that path never touches claim_context. A legacy review claimed before any
criteria existed keeps a NULL binding, so once criteria are registered and the
re-review is recorded by attestation, the binding still names nothing while the
ruling names the set in force. Reading only the binding called that review stale
for as long as the assignment lived and handed the claim to whoever asked next,
which is I-54 again on a path the earlier fix never ran.

Claiming now asks whether a ruling has already been made against the set in
force before it considers reopening. Ruling still asks whether the ruling itself
is stale, which is a different question with a different answer once a review
has been claimed again: the re-claim refreshes the binding, and the standing
ruling is exactly what the re-review is there to replace.

Regression test fails on d14eb76 with 'proceed' != 'already_claimed'.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

The instructions still said a settled verdict is immutable, that re-claiming
returns already_claimed, and that a fresh execution generation is the only route
from there — relay.md called that "the contract working, not a fault". It is the
defect JUN-167 scoped, and the code no longer behaves that way. Left alone, the
repository would keep asserting a contract it does not keep, and the next agent
to observe this would dismiss a real fault because the documentation calls it
correct.

relay.md now names two routes and says which applies. Judging the same revision
again: claim returns proceed where the set moved under a review of the revision
the assignment still stands on, rebinds to the set in force, and the verdict
rules verified or needs_changes. --expect-criteria-digest is required only where
a verdict had already settled, because that is the only case a re-submission of
the replaced ruling could be mistaken for a review. A fresh generation is still
the route when the artifact has to change, when the event was ruled
needs_changes, unverified or aborted, when the event is no longer the current
revision, and when the relationship is not active. The generation machinery
below it is unchanged and still correct.

crw-check and crw-plan's integrations repeated the same claim in one sentence
each, and operations/scenarios.md S11 did too — "Preserved: the settled verdict,
which is immutable". That one is not in the reported set; the grep for the
sentence found it. It now says what is actually preserved, which is the record
of what was judged rather than the judgement.

Two tests pin the sequences the instructions now prescribe: the unruled
re-review that needs no stated digest, and the unverified ruling that still
takes a fresh generation.
@thisisjun786
thisisjun786 merged commit 9398ee4 into dev Sep 17, 2026
9 checks passed
thisisjun786 added a commit that referenced this pull request Sep 17, 2026
The merge brought PR #21 and #22 into the relay package, so the recorded tree and
OPS-1.2 digest no longer describe this checkout. verify-definition caught the drift
and these are the values it derives now. The identity test is the component's own
subtree, so this is the relay changing and not an unrelated commit.
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