start_target/7 can write a running state over a finalized assignment, leaving it live with a revoked credential #257
Evidence
Shipped in the release at c67cf39, promoted 2026-08-26.
1 pushes receipt
- AtlantisPleb opened this issue 2d ago
-
AtlantisPleb
closed this as completed in
64ee3682d ago -
A Author 2d ago Fixed in
64ee368.start_target/7now transitions throughAssignments.start_running/2, which replaces the read and the write with one conditional update:UPDATE ... WHERE id = $1 AND state NOT IN ('completed','failed','cancelled'). Postgres evaluates the guard while it holds the row's write lock, so a starter arriving afterfinish/4matches no row and changes nothing, and a finalizer arriving after the starter waits and then moves the row terminal. Neither order can leave an attempt live with a revoked credential.Why a conditional update rather than
:global.transor a locking read. The contested resource is one row, and Postgres already serializes writers on it;:global.transinTargetsguards multi-row, cluster-wide deploy sequences, which this is not. There is a second reason that turned out to be load-bearing: an implementation with no read has nothing to interleave into. AFOR UPDATEversion would be correct in production but untestable in the Ecto sandbox, where every process shares one connection and a "concurrent" finalizer simply joins the starter's own transaction and defeats the lock. The single statement is correct for a reason a test can observe.The starter reports which happened.
start_running/2returns{:ok, assignment}when it made the transition and{:already_finished, assignment}when it lost, rather than collapsing the two.start_target/7refuses with:assignment_finishedinstead of returning a credentialfinish/4has already revoked.The Computer path had no guard at all — this issue only named the box path. It wrote
state: "running"from a stale struct while racingfinish_for_machine/2,expire/0, andcancel/2. It now takes the same step and deletes its vaulted credential when it loses.The test opens the race rather than asserting the fix by inspection. A handler on Ecto's
[:open_agents, :repo, :query]telemetry runs in the process that made the query, so finalizing the assignment from there lands between the starter's read and its write with no seam added to the code under test. The assertion is the invariant stated as a query over the ledger: no assignment is in a non-terminal state while its credential is revoked. Reverting to the read-then-write shape fails it identically at seeds 1, 2, and 3; the fix passes at seeds 1 through 5.The workaround is gone.
assignment_credential_auth_test.exshad aProcess.sleep(30_000)parking the run worker so it could never transition, inserted to dodge this race while fixing the credential lookup. Removed, along with two comments describing the race as live; that file passes 6/6 without it.IDENTITY-006 gained the atomicity clause with the new test as evidence and an amendment note.
mix precommitgreen: 4900 passed.
What happens
OpenAgents.Forge.Assignments.start_target/7reads an assignment and writes itback outside a transaction:
The guard reads the state, and the update writes
running— with nothingholding the row between them. If the run worker finalizes in that window,
finish/4has already moved the assignment to a terminal state and revoked itscredential, and this update then writes
runningback over it.The result is an assignment that looks live while holding a credential that is
revoked:
usable?/1refuses it, so every push it attempts failsauthentication, and the state says the attempt is still going.
Why it has not been seen
The window is the gap between the read and the update. A production run takes
seconds to reach a terminal state, so it is never inside that gap. It is
reachable with a stubbed run that finalizes in microseconds, which is how it
was found: the assignment-credential authentication test hit it reliably until
the fixture worked around it.
Suggested shape
Make the guard and the write one atomic step, the way the rest of this module
already treats a state change that must not race — a conditional update that
refuses when the row has moved, rather than a read followed by a write. The
terminal states are the ones that must win: a finalized assignment must not be
resurrected by a slower starter.
Acceptance criteria
its terminal state, and the starter reports that it lost rather than
overwriting.
while its credential is revoked.
assignment_credential_auth_test.exsis removed, and thetest still passes without it.
Found while fixing the credential-lookup defect in
authenticate/1.