Skip to content

Let a session read claim state instead of probing for it by claiming (#707) - #744

Merged
mkreyman merged 8 commits into
masterfrom
feat/channel-claims-read-2
Aug 22, 2026
Merged

Let a session read claim state instead of probing for it by claiming (#707)#744
mkreyman merged 8 commits into
masterfrom
feat/channel-claims-read-2

Conversation

@mkreyman

@mkreyman mkreyman commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Closes #707 (the half of it that survived triage — see below).

The defect

Until now the only way to learn whether a handoff ref was claimed was to attempt the
claim and read the result: 201 meant free, 409 already_claimed meant taken. That
probe is destructive on this deployment.

channel_claim is idempotent for the owning agent — re-claiming your own active ref
returns the existing claim rather than a 409 — and every session on the fleet
authenticates as one agent_id. So a probe issued while a peer session holds the ref
hands back that peer's claim as though it were the prober's own, and the release that
tidies the probe up deletes it. The peer keeps working a handoff the bus has already
reopened, and a second machine picks it up.

That is not hypothetical. #707 recorded the sequence: a claim vanished well inside its
stated lease while another session was most of the way through the work, and two machines
ended up on the same task.

The fix

GET /api/v1/channel/claims — a read that writes nothing and answers what the caller
needs before claiming, with an optional ref for the point lookup.

It lists every UNSWEPT claim row, not the "active" ones. The
(tenant_id, project_id, ref) unique index does not care about lifecycle: a DONE claim,
an OPEN one, and one whose lease expired before the sweeper reaped it all refuse a fresh
claim identically. Each row carries done and expired flags so the caller can also
tell what state it is in.

The first cut of this PR listed the handoffs exclusion predicate instead (DONE or
unexpired), reasoning that the read should answer "why is that handoff missing from my
handoffs list". Review caught that this makes the read disagree with the write:
resolve_claim_collision/4 returns 409 already_claimed for an expired-but-unswept
row, so the read said "free", the claim said "taken, move on", and a session abandoned
a claimable ref for up to one sweeper interval. Fixed in 2a9d0836.

Page ordering is three-tier — open, then expired-unswept, then done — because DONE
claims are retained for days and a plain newest-first page would drop a live claim to
make room for a week-old finished one, reporting a held ref as free.

A malformed ref (blank, ?ref[]=, or a NUL byte — valid UTF-8 that Postgres
refuses to compare against text, previously a 500) yields an empty page and never falls
through to the unfiltered query, which would hand a caller asking about one ref some
other ref's claim. Coordination.valid_ref?/1 is now the single ref-shape rule, mirroring
ChannelClaim.create_changeset/2, and the write twins (fetch_owned_claim/4, claim/5)
use it too.

The scope that made the hazard possible is documented, not silently changed. Claims
are owned by (tenant, project, AGENT, ref) with no session dimension, so two sessions on
one key can done or release each other's claims and the server cannot tell them apart.
That now appears in the controller moduledoc and the channel_claim / channel_release
tool descriptions, with the note that the abandoned-lease sweep — not the release path —
protects a claim whose session died. Tightening ownership to a session dimension would
strand any session that restarts mid-work behind its own lease: a worse failure than the
one being fixed.

What the listing does NOT promise. An earlier draft claimed a ref is listed exactly
when a fresh claim would be refused. Review showed that is false in both directions — the
owning agent's own open row is returned idempotently rather than refused, and a superseded
ref, an exhausted per-agent claim budget, and a non-member caller are all refused with no
row listed. The wording is corrected in all four places it had been copied to.

The read is tenant-scoped and not membership-gated, uniform with GET /channel/locks;
the US-40.D3 membership gate is on the write path. A foreign or nonexistent project still
gets the oracle-safe empty page, now recorded as an ownership_rejected security event
like the write path. A missing or non-UUID project_id is a 422 rather than an empty page
that the contract reads as "claimable".

Triage: two of #707's three findings did not survive

Both recorded on the issue with the evidence.

  • lease_seconds ignored — does not reproduce. Probed live against production: 60
    requested, 60 honoured to the microsecond. The plumbing has been intact since channel_claims table — exactly-once handoff claim (INSERT-to-claim, 409 already_claimed) #447, a
    month before the report, and @default_lease_seconds has never been 86_400.
  • The suggested mechanism for the invisible read is disproven by the query.
    directed_handoffs_page/3 correlates claims with NOT EXISTS, so an unclaimed
    handoff is precisely the row that passes.

Verification

  • Enhanced review wf_4a844eca-d81: 2 rounds, 8 reviewer lenses, 15 confirmed
    findings, 16 fixed, 0 invalid deferrals
    . publish.inSync: true.
  • Quality gate green on every fix commit (latest: 7813 tests, 0 failures), credo
    --strict clean, dialyzer clean.

Out-of-scope finding — owed, not deferred

One confirmed finding falls outside this diff and gets its own follow-up branch:
FallbackController's 409 already_claimed body says "do not retry, move on", which is
wrong for the expired-but-unswept case this PR makes visible. The fix is distinct error
codes in resolve_claim_collision/4 for its four causes (expired-unswept, superseded ref,
exhausted claim budget, caller's own DONE claim) plus matching FallbackController
clauses. The in-scope half — the channel_claim tool description no longer telling an
agent a 409 unconditionally means "move on" — is in this PR.

MCP server 2.77.0 adds channel_claims.

test/mix/tasks/loopctl_enrich_search_events_test.exs asserts that the enrichment
task will not answer another machine's row from this machine's transcripts. It
built that row with a literal client_host of Marks-Mac-mini.local, which is a real
host in the fleet.

So the test passes everywhere except on the machine it names. Run it on mac-mini
and "another machine" IS this machine: the row is legitimately this host's, the
task correctly enriches it, and the assertion inverts. The very next test in the
file is the positive case built on local_host/0, so the two were asserting opposite
outcomes for the same host - which is only invisible while nobody runs the suite
there.

It is a full stop, not a flake: every commit on that machine fails the gate, on a
test that has nothing to do with the change being committed.

Derive the foreign host from local_host/0 instead - "not-" <> local_host() <>
".invalid" - so it is guaranteed to differ on every machine, and the .invalid suffix
(RFC 2606) means it can never name a real host in any fleet.

Verified by running the file on mac-mini, where it previously failed: 11 tests,
0 failures.
…707)

Until now the only way to learn whether a handoff ref was claimed was to attempt
the claim and read the result: 201 meant free, 409 already_claimed meant taken.
That probe is destructive on this deployment.

channel_claim is idempotent for the owning AGENT - re-claiming your own active ref
returns the existing claim rather than a 409 - and every session on the fleet
authenticates as one agent_id. So a probe issued while a PEER SESSION holds the ref
hands back that peer's claim as though it were the prober's own, and the release
that tidies the probe up DELETES it. The peer keeps working a handoff the bus has
already reopened, and a second machine picks it up. Issue #707 recorded that
sequence: a claim vanished well inside its stated lease while another session was
most of the way through the work.

GET /api/v1/channel/claims is the read that makes the probe unnecessary. It writes
nothing and answers the same question, with an optional ref for the point lookup a
session about to claim actually wants - an empty claims array means that ref is free
right now.

The listed set is the handoffs EXCLUSION, mirrored deliberately. A row is active when
it is DONE (terminal) or still inside its lease, byte-for-byte the condition
active_claim_subquery/1 uses to hide a handoff from directed_handoffs_page/3. A
released claim and a lease that expired without completion are both absent, exactly as
both reopen the handoff. That coupling is the point: this read has to answer "why is
that handoff missing from my handoffs list", and a predicate that merely resembled the
exclusion would answer a subtly different question and send the reader back to probing.
A test asserts the two sets against each other on the same rows, so they cannot drift.

Also documents the scope that made the hazard possible, rather than silently changing
it. Claims are owned by (tenant, project, AGENT, ref) with no session dimension, so two
sessions on one key can done or release each other's claims and the server cannot tell
them apart. That is now stated in the controller moduledoc and in the channel_claim /
channel_release tool descriptions, alongside the note that the abandoned-lease sweep,
not the release path, is what protects a claim whose session died. Same posture
ChannelLockController already documents for soft-locks. Tightening it to a session
dimension would strand any session that restarts mid-work behind its own lease, which
is a worse failure than the one being fixed.

The read gets its own rate-limit bucket rather than a share of the claim write cap: an
endpoint that exists to replace a probe must never be able to rate-limit its caller out
of claiming, which is what a shared counter would do and would push the caller straight
back to probing.

Oracle posture matches the sibling reads - a foreign, nonexistent or malformed
project_id is an empty page, never a 404.

While verifying this, two of the three findings in #707 did not survive and are
recorded on the issue: lease_seconds does not reproduce (probed live: 60 requested, 60
honoured, and the plumbing predates the report by a month), and the suggested mechanism
for the invisible read is disproven by the query, which uses NOT EXISTS and so cannot
filter out a never-claimed handoff.

MCP server 2.77.0 adds channel_claims.
…ould do

The read listed the handoffs EXCLUSION (done or unexpired), but the unique slot
that refuses a claim is held by any unswept row. An expired-but-unswept claim was
therefore absent from the read, whose empty page is documented as "this ref is
free", while the very next claim answered 409 already_claimed, whose documented
meaning is "taken, move on" - so a session abandoned a handoff for up to one
sweeper interval. The read now lists every row that still holds the slot and
flags the two lifecycle states on each row, so "absent" and "claimable" are one
set again. Renamed to claims_page/3 (and clamp_claims_limit/default_claims_limit)
because "active" already means the handoffs predicate in this module.

Also in this round:

- A NUL byte in the ref query param reached Postgres and 500'd (22021). A blank,
  non-binary or NUL-carrying ref now yields an empty page instead of silently
  falling through to the UNFILTERED query, which handed a caller asking about one
  ref some other ref's claim.
- OPEN claims are ordered ahead of terminal ones. DONE claims are retained seven
  days, so a newest-first page could drop a live claim to make room for a
  week-old finished one - reporting a held ref as free.
- A missing or non-UUID project_id is a 422 rather than an empty page. The MCP
  client only sends project_id when it is set, and an empty page here means
  "claimable"; a foreign well-formed id keeps the oracle-safe empty page, now
  recorded as an ownership_rejected security event like the write path.
- The read cap is clamped to a quarter of the per-key pipeline budget, so polling
  can no longer 429 the caller out of claiming - the guarantee the plug comment
  claimed and a dedicated bucket alone does not provide.
- Corrected the docstring that asserted a US-40.D3 membership gate on the read.
  The read is tenant-scoped and NOT membership-gated, uniform with GET
  /channel/locks, and now says so where ChannelLockController does.
…he write twins

The round-1 commit made the read agree with the write on the expired-unswept row,
then overstated the result as an equivalence with the claim outcome. It is not one,
and the gaps are exactly where #707 lives:

- Listed does NOT imply refused. claim/5 returns the OWNING agent's still-open row
  idempotently, which on a fleet sharing one agent_id is the common case, and
  re-claiming a listed ref to check IS the destructive probe. Absent does NOT imply
  claimable either: a superseded ref, an exhausted per-agent claim budget and a
  non-member caller are all refused with no row listed. Restated as "a row holds
  this ref's slot" in coordination.ex, the controller docstring, the MCP tool
  description and mcp-server README, with the exceptions named where the claim is.
- Only OPEN rows were protected from page truncation, so an expired-unswept row --
  which still holds the slot -- was evicted by a newer retained DONE row and the ref
  read back as free. DONE now sorts LAST, behind open and expired-unswept alike.
- The NUL-byte guard landed on the read only. claim/5 compares the ref against
  channel_posts.key, and done/5 and release/5 against channel_claims.ref, both
  before any changeset runs, so the same input escaped as a Postgrex 22021 500.
  One definition of a well-formed ref (Coordination.valid_ref?/1, mirroring the
  claim changeset's trim/NUL/byte-cap rules) now serves the read filter, the
  owner-claim fetch, and the controller; claim/5 applies its changeset before it
  queries.
- A malformed ref got the empty page the same commit ruled unacceptable for a
  malformed project_id, for the same reason -- now a 422, and the MCP client sends
  a blank ref through instead of silently widening the point lookup into a
  whole-channel list.
- The read rate cap was clamped to a quarter of the pipeline budget with no floor,
  so below a 240/min budget it bound TIGHTER than the claim write cap and 429'd the
  caller out of the very read that replaces the probe. Floored at claim_limit/1,
  and the two comments that claimed the opposite corrected.
- channel_claim's tool text no longer says a 409 means move on unconditionally: an
  expired lease awaiting the sweeper should be retried shortly.
- Stale post-rename comments: the controller moduledoc bullet still said ACTIVE
  claims, and resolve_claim_collision still said discovery treats expired leases as
  inactive.
An expired lease is a fact about the CLAIM; whether the handoff is back in
GET /channel/handoffs is a fact about the POST. The round-1 wording conflated
them and told the agent to retry a ref whose post was superseded, quarantined or
past its 30-day TTL -- none of which reappear, so the advised retry spins and
then lands on a refusal. Says what expired actually means in coordination.ex,
the controller docstring, the claim_json comment, the MCP tool description and
the mcp-server README, and points the reader at channel_handoffs for the other
half.
…ue (#707)

The review of the claim read routed this to a follow-up because FallbackController
was outside the reviewed diff. It is not a follow-up: this PR is what created the
contradiction, and an existing Node test pinned the old wording, so CI on this branch
was red until it was resolved.

POST /channel/claims answered 409 already_claimed for four unrelated situations, and
that code renders "Do not retry the same ref; move on to other work." Two of the four
are not that:

  - a lease that EXPIRED without completion still holds the unique slot, so the insert
    fails - but nobody is working it and ChannelClaimSweeper is about to reap it. The
    ref is seconds from being free and the caller was told to abandon it.
  - a SUPERSEDED ref is held by nobody at all. The caller should claim the successor
    handoff, and instead recorded the ref as taken.

The third, an exhausted per-agent claim budget, is a limit on the CALLER that says
nothing about the ref - a peer can take that very ref, which the new controller test
asserts directly.

So the four now carry four codes, all still HTTP 409: already_claimed (a peer's live
claim, or your own completed one - move on), claim_lease_expired (retry THIS ref
shortly, with an advisory retry-after of 60s), ref_superseded (claim the successor),
and claim_budget_exhausted (finish or release one of yours).

claim_lease_expired is the one that was actually costing work, and it is the one this
branch made visible: claims_page/3 LISTS that same row and flags it expired, so the
read and the write were telling a caller opposite stories about one ref. A test now
asserts both halves against the same row so they cannot drift apart again.

The controller lists the three new reasons explicitly rather than adding a catch-all,
so a future fifth cause is a compile-time CaseClauseError instead of a silent 500.

The channel_claim tool description stops apologising that the body "cannot see which of
those you hit" and names the four codes; its Node test now pins that split rather than
the single move-on message it used to require. CHANGELOG records the code change as
operator-facing, since a client branching on already_claimed for the superseded or
budget cases needs updating.
…ctl into feat/channel-claims-read-2

# Conflicts:
#	CHANGELOG.md
@mkreyman
mkreyman merged commit b1b9655 into master Aug 22, 2026
16 checks passed
@mkreyman
mkreyman deleted the feat/channel-claims-read-2 branch August 22, 2026 03:01
mkreyman added a commit that referenced this pull request Aug 22, 2026
…dly (#746)

GET /api/v1/routes advertises itself as what an agent calls to discover the main
endpoints before guessing. Every one of the 15 Epic 39/40 coordination-bus routes
was absent from it - channel posts, handoffs, claims and advisory file locks, the
whole surface.

That omission is legal by the module's own contract (a curated index, not the API
surface, which is the OpenAPI spec) and it is still the wrong call here, for the same
reason the admin routes earn coverage. This is the mechanism a session is TOLD to
reach for when it hands work to another machine. An agent that cannot find it does
not degrade politely to the OpenAPI spec - it does the work twice, or drops it. That
is precisely the failure the bus exists to prevent, so hiding the bus from discovery
undoes the feature rather than inconveniencing it.

Found while verifying #744 against production: the deployed router serves GET
/api/v1/channel/claims and the curated index listed no channel route at all, so the
read I had just shipped to make claim state discoverable was itself undiscoverable.

A coverage test now pins the set the way the /admin one does, matching on (method,
path) rather than path alone so a route that gains a verb is caught too. It earned
its keep on the first run: it found two more I had missed by hand, the operator
quarantine pair (GET /channel/posts/quarantined, POST /channel/posts/:id/release).
Like the /admin test it asserts its own filter matched something, so a future change
to route shapes cannot quietly turn it into a test of nothing.

Descriptions carry the hazards an agent needs at the point of choosing, not just the
route: that channel post bodies are untrusted data authored by peers, that a soft
lock is advisory and blocks nobody, that a release is scoped to your AGENT and not
your session, and that the claim 409 must be branched on by error.code.
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.

Open unclaimed handoff invisible to channel_handoffs until a claim-and-release cycle touched it

1 participant