Skip to content

fix(gateway): bind job-offer claims/events, tool-catalog entries and templates to their authenticated owners (kits K0 slice 2; merge after WP-A) - #395

Draft
LamaSu wants to merge 15 commits into
masterfrom
fix/kits-k0-ownership
Draft

LamaSu wants to merge 15 commits into
masterfrom
fix/kits-k0-ownership

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

This is kits K0 slice 2, the ownership guards that steward ruling #2778(b) said must merge after WP-A (#326) (board rule 6). Slice 1 is #392.

Every change binds an owner to the authenticated principal: the API key's operatorId or the SIWE userId. Nothing is taken from a body field or from the self-assertable X-Posted-By header. A missing actor fails closed (board rule 7). Identity matching folds ASCII case only. There is no Unicode case folding and no trimming, so a U+212A or padded lookalike is a different principal (486c1d2). When this PR merges after WP-A (#326), it adopts WP-A's sameIdentity (NFKC plus full case folding, the same fold WP-A's provisioning binds identities with), so the gateway keeps one comparison (cc66f51).

After WP-A, an operatorId that already owns an api_key, kernel, registration, job offer or artifact cannot be minted by anyone else (WP-A F3). So these owner checks can't be bypassed by self-provisioning a key under someone else's id.

Kits defect Before After
D6 job-offer claim kernelId was taken from the body, with no ownership or existence check Needs an actor and a kernel registered to that actor. An unknown kernel and someone else's both get 403 kernel_not_owned.
D6 job-offer events Any key could post delivered or cancelled on any offer, with by taken from the body in_progress, pickup and delivered come from the claimant only. cancelled, note and progress_update come from the claimant or the poster. by records the role, never an identity.
D6 courier shim The same holes, through driverAgent The claim binds to the principal, and driverAgent is only a label. Events are authorized the same way.
D4 tool-catalog register Anyone could upsert over any entry and re-point maintainerDid, which also redirects demand routing Only the registrant can update the entry (403 for anyone else). The registrant is kept privately and never serialized.
D5 template PUT Any key could rewrite any template, published ones included, in place Author-only (403). A published template answers 409 published_immutable: fork it to make a new version.
D5 template publish, rate, machines Publish skipped the author check when authorId was null. Ratings fell back to "anonymous" and could be repeated. Machine PUT had no author check. Author-less templates can't be published. Ratings need an actor and are limited to one per rater (409 already_rated). Machine PUT is author-only. Every template write needs an actor.

Privacy. The claimant's operatorId is kept privately by the store and rides in the persisted row, so it survives a restart. It is stripped on hydration and never serialized, because offers and event logs are publicly readable and an operatorId can be an email address. Offers claimed before this change fall back to the owner of the claiming kernel.

New helpers:

  • auth/actor.ts: authenticatedActor, sameIdentity.
  • services/job-offer-authz.ts: authorizeOfferEvent.
  • jobOffersRoutes takes an optional kernelOwnerOf resolver. It defaults to shop_kernels.operator_address; tests inject a stub.

Tests

All runs were on the Spark, in worktree /mnt/sparkbulk/pcc-lanes/wt-kits-k0b.

Suite Result
job-offers.test.ts 47/47 at the first push; the N81 rules commit (dedb7f4) added 3 more. 9 new: unauthenticated claim refused; stolen or unknown kernel gets 403; a stranger or the poster can't deliver; events without an actor refused; an unclaimed offer accepts no advancing event; the poster can cancel and a stranger can't; by is the role; the claimant never appears in public reads; the claimant survives a store restart and is stripped from the public offer.
courier-jobs.test.ts 32/32 (4 new)
tool-catalog.test.ts 19/19 (5 new)
templates-ownership.test.ts 8/8 (new file, in-memory store)
full packages/gateway vitest At 486c1d2: 189 files, 3017 passed, 6 skipped. At the first push: 3006 passed, 6 skipped (pre-existing), 1 timeout-flake: completion-real-tier hit its 5000 ms timeout under full-suite load, and passes 5/5 alone. It touches no file in this PR.
print-and-mail.test.ts 18/18. The harness claims its courier job as an authenticated driver; the route is unchanged.
tsc --noEmit (gateway) exit 0

The existing claim and event tests now authenticate, and the race tests give each claimant its own kernel. The poster tests on X-Posted-By are unchanged.

Mutation check. With master's six source files restored under the new tests, 20 of 106 targeted tests fail. That is every new negative except the ones master passes trivially, where it stores no identity and so has nothing to leak.

Later commits

Not in this PR

  • The poster side still accepts the legacy X-Posted-By fallback and treats a null posterDid as "anyone". That belongs to gateway's WP-C job-offer ownership.
  • Skills humanDid binding (D10) is K3, wave D.
  • Bounty requester binding: the bounty routes are retiring under operator decision D5.
  • Atomic rating uniqueness (142 MEDIUM) needs a unique index, which is a schema change, so it is the operator's call (tracked).

Merge order and review

  1. Merges after WP-A fix(gateway)!: WP-A scope hardening — wildcard is not money/admin authority, fail-closed scopes, identity binding (P0 MUST-CLOSE 6-10) #326 (rule 6). Then the interim identity compare is replaced by WP-A's shared sameIdentity on the merged SHA (142 closure 1, the ASCII-case alias HIGH).
    1a. fix(gateway): bind job-offer claims/events, tool-catalog entries and templates to their authenticated owners (kits K0 slice 2; merge after WP-A) #395 carries fix(gateway): an offer event keeps the offer inside its lifecycle (N81) #455. The operator merges fix(gateway): bind job-offer claims/events, tool-catalog entries and templates to their authenticated owners (kits K0 slice 2; merge after WP-A) #395 alone; fix(gateway): an offer event keeps the offer inside its lifecycle (N81) #455 never merges by itself (astra 157).
  2. Rebase by merging master in, never by force-push.
  3. coord-watch cross-family review is required (authority).
  4. It will conflict trivially with fix(gateway,payments): bounty surfaces stop presenting unfunded or unverified state (kits K0 slice 1) #392 in tool-catalog.test.ts (different hunks).

Refs: kits reconciliation D4, D5, D6; steward ruling #2778; board rules 6 and 7.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj

LamaSu and others added 6 commits September 24, 2026 15:19
…ticated claimant

A job offer's delivery state was caller-asserted (kits reconciliation D6):
POST /api/job-offers/:id/claim took any `kernelId` from the body with no
ownership or existence check, and POST .../events let any key holder
post `delivered` or `cancelled` on any offer, with `by` taken from the
body. The legacy /api/courier-jobs shim had the same holes through
`driverAgent`.

- A claim needs an authenticated principal (API-key operatorId or SIWE
  userId, never the X-Posted-By header) and a kernel registered to that
  principal. An unknown kernel and someone else's get the same 403
  kernel_not_owned. The courier shim binds claims to the principal; its
  driverAgent is only a label.
- Events: in_progress, pickup and delivered come from the claimant only.
  cancelled, note and progress_update come from the claimant or the
  poster. A missing actor fails closed (401). `by` records the actor's
  role, never an identity.
- The claimant's operatorId is kept privately by the store. It rides in
  the persisted row so it survives a restart, is stripped on hydration
  and is never serialized, because offers and event logs are publicly
  readable and an operatorId can be an email address. Offers claimed
  before this change fall back to the claiming kernel's owner.
- New generic helpers: auth/actor.ts (authenticatedActor, sameIdentity:
  trimmed and case-insensitive, matching WP-A) and
  services/job-offer-authz.ts (authorizeOfferEvent).

Tests: job-offers 47/47 (9 new: an unauthenticated claim is refused; a
stolen or unknown kernel gets 403; a stranger or the poster cannot
deliver; events without an actor are refused; an unclaimed offer accepts
no advancing event; the poster can cancel and a stranger cannot; `by` is
the role; the claimant never appears in public reads; the claimant
survives a store restart). courier-jobs 32/32 (4 new). Existing claim
and event tests now authenticate, and the race tests give each claimant
its own kernel.

Merges after WP-A (#326) under board rule 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
POST /api/tool-catalog/register upserted by packageName or repoUrl and
spread the caller's body over the existing entry, maintainerDid
included, so anyone could take over a tool's attribution and redirect
its demand routing (kits reconciliation D4).

Registration now needs an authenticated principal (401 otherwise). The
registrant is recorded in a private map, never serialized because an
operatorId can be an email, and only that registrant may update the
entry: 403 for anyone else, matching WP-A's normalization.
maintainerDid stays caller-supplied attribution and grants nothing.

Tests: tool-catalog 19/19 (5 new: unauthenticated registration is
refused; a hijack by packageName and by repoUrl is refused and leaves
maintainerDid intact; the registrant can update its own entry,
case-insensitively; the registrant is never serialized).

Merges after WP-A (#326) under board rule 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
… immutable

capability_template_store is the durable index Capability Kits build on
(ledger R5/R6), but its routes let anyone rewrite anything (kits
reconciliation D5):

- PUT /api/templates/capabilities/:id had no author check and no status
  check, so any key rewrote any template, published ones included, in
  place with no history. It is now author-only (403), and a published
  template answers 409 published_immutable: fork it to make a new
  version.
- Publishing skipped the author check when authorId was null, so anyone
  could publish an author-less template. Nobody can now.
- Rating fell back to "anonymous" and had no per-rater dedupe. Ratings
  now need an authenticated principal, one per rater (409 already_rated).
- PUT /api/templates/machines/:id had no author check; it is author-only.
- Every template write (create, fork, rate, machine and verification
  create) needs an authenticated principal; a missing actor fails closed
  (401, board rule 7).

Tests: new templates-ownership.test.ts (8) against an in-memory store.

Merges after WP-A (#326) under board rule 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…ted driver

Courier-jobs claims are now bound to the authenticated principal, so the
print-and-mail suite's createAndClaimJob helper sends a test principal
on its claim (an x-test-operator hook standing in for an API key). The
print-and-mail route and its assertions are unchanged; 18/18 pass.

Follow-up for the carrier lane, not changed here: the handoff still
authorizes by comparing the body's driverAgent label with the public
claimedBy (print-and-mail.ts:106), and should compare the authenticated
actor with the job's claimant instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
sameIdentity rejected null and "" but compared whitespace-only ids after
trimming, so sameIdentity("  ", " ") was true. The current call sites were
not exploitable, because the actor side always comes from
authenticatedActor, which refuses blank principals. But an owner check that
lets two blank ids match is one careless caller away from an authorization
bypass, and WP-A (#326, auth/reserved-identities.ts) documents the stricter
contract: fold with String(id ?? "").trim().toLowerCase(), and never match
an empty fold.

This adopts WP-A's exact fold and contract, so swapping to WP-A's
sameIdentity when #326 merges (this PR merges after it, rule 6) is
mechanical. The new auth-actor.test.ts covers the principal precedence and
the blank-id cases. On the old helper it fails 1 of 5 (the whitespace case).

Tests: auth-actor, courier-jobs, job-offer-producer, job-offers,
print-and-mail, templates-ownership and tool-catalog pass 141/141.
Gateway tsc exits 0. A test merge onto #326 @69fd5fc4 is clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
Rehearsal R0 (G5) found the job-offer path stranded after a claim. On master,
POST /api/job-offers/:id/events moved an offer to any status from any status:
an UNCLAIMED offer could be taken delivered -> cancelled -> delivered.
recordEvent now refuses an event that would leave the lifecycle, and records
nothing for it:
- in_progress and pickup need a claimed offer (a repeat is a no-op);
- delivered needs a claimed or in-progress offer (a repeat is a no-op and
  keeps the first delivery time);
- cancelled applies only before delivery;
- settled, cancelled and expired offers stay as they are.
Events that change no status (note, progress_update, ...) are always recorded.
A refused event answers 409 invalid_transition with the current status. The
courier shim shares the store and gets the same rule. Two existing tests
posted progress on an unclaimed offer; they now claim first.

Who may post an event is kits #395's (claimant binding); what ends a delivered
offer is an open product decision (steward #3968).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…81, kits #3989)

The operator-market owner's rules (kits #3989), encoded in the lifecycle:
- delivered is the claimant's assertion: never terminal, never success;
- the poster's 'confirmed' ends it in 'completed' (new status; success; no
  money moves on this path);
- 'disputed' is allowed only within 72h of delivery (else 409
  review_window_closed) and is terminal;
- a delivery nobody confirms or disputes within the window lapses. The sweeper
  sets 'lapsed' (new status; terminal; never success; reason
  delivery_unconfirmed);
- 'settled' comes only from the server (a linked job's settlement); a posted
  'settled' is refused;
- 'release' puts a claimed or in-progress offer back to 'open' and clears the
  claim. The releasing kernel cannot re-claim that offer for 1h (409
  recently_released); other kernels can at once.
Claim refusals the routes do not know now fail closed (500), never answered as
{ ok: true }. The courier shim maps completed to delivered and lapsed to expired
(it used to map unknown statuses to open). Who may post each event stays #395's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 6 commits September 29, 2026 16:35
These are the N81 product rules (kits #3989, names confirmed by composition
#4063), so #395's authorization and composition's store state machine agree:
- claimant only: in_progress, pickup, delivered, release. "release" is a
  claimant's cancel: it hands the offer back to open before delivery.
- poster only: cancelled, confirmed, disputed. Before, the claimant could
  also post "cancelled"; now only the poster ends an offer, and only the
  poster's "confirmed" makes a delivery a success.
- server only: settled, from a linked job's evidence-path settlement. Every
  caller gets 409 server_only_event, the poster and the claimant included.
- Anything else (note, progress_update, ...) stays claimant or poster.

This module decides only WHO may post. Which transitions are valid is
composition's state machine in JobOffersStore.recordEvent (N81). Whichever
of the two PRs lands second wires "release" to clear the private claimant
binding. The legacy courier shim shares the table, so a driver there can no
longer cancel; they release on /api/job-offers. No existing test or caller
relied on a driver's cancel.

Tests: 3 new N81 cases. job-offers, courier-jobs, print-and-mail and
auth-actor pass 105/105. With the old table restored, the 3 new tests fail.
Gateway tsc is clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
… (N81, #455 review round 1)

astra's review of 10f4545 (SHIP-WITH-FIXES) found five gaps. Each was
reproduced first (36 failing tests at f3afbe7, plus the F4 and F5
evidence below), then fixed:
- F1: an event named after an inherited property (toString, constructor,
  __proto__ and others) answered 500. The event table is now a Map, and
  such an event is recorded as status-neutral.
- F2: DELETE cancelled delivered and terminal offers through both routes,
  and a repeat overwrote cancelledAt. DELETE now follows the 'cancelled'
  rule after the owner check:
  - open, claimed or in_progress are cancelled;
  - a repeat keeps the first time;
  - anything else answers 409 invalid_transition, and nothing changes;
  - an unknown refusal is 500, never a success;
  - the courier shim names the status in its own vocabulary.
- F3: a source re-verification could cancel an offer that was claimed or
  delivered while it awaited. After the await, the answer applies only if
  the offer is still the same open, unclaimed record with the same source
  address; otherwise it is discarded with no write.
- F4: the lifecycle suite now uses authenticated participants and kernel
  ownership the way #395 requires. On this branch they are harmless. A
  trial merge with #395 (dedb7f4) passed 565/565. Its conflict resolution
  is a patch for whoever merges second:
  returns/pcc-composition-work/n81-x395-conflict-resolution.patch.
- F5: the matrix is complete. It covers 10 statuses x 15 events at the
  store, the route and the shim, checked against an independent table,
  plus the 72h window boundaries. Every refusal compares the offer and the
  log before and after.

The docs say what DELETE now does. Tests: 549/549. Mutations: 59/59 killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171FftEAfqjwbJHowVTqAbz
…e or padding

sameIdentity folded both sides with String(id).trim().toLowerCase(). That
lowercasing is Unicode-aware, so U+212A (KELVIN SIGN) folds to "k", and
U+0130 folds to "i" plus a combining dot. The trim also strips NBSP, BOM,
U+3000 and tab. So a principal whose id is a lookalike or a padded copy of
an owner's id matched that owner. The same class was HIGH 2 on #461 (astra
pack 111).

The compare now lowercases A-Z only and keeps every other code point and all
padding as is. A whitespace-only id still never matches. The doc comment
says not to swap to WP-A's (#326) sameIdentity while it folds with
trim().toLowerCase().

Reproduced first at dedb7f4: the new auth-actor test failed ("expected
true to be false"), and a route test PUT from a Kelvin-sign lookalike of
the template author returned 200 instead of 403. Both pass now. Two existing
tests pinned trim semantics with padded ids; they now use ASCII case
variants of the owner's id, since a padded id is a different principal.

Tests: the full gateway suite passes (189 files, 3017 passed, 6 skipped).
Gateway tsc exits 0. Mutations: 5 of 5 killed. Each of these fails a test:
restoring the old fold, a Unicode toLowerCase without trim, the ASCII fold
plus trim, no case fold at all, and dropping the blank guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
The comment said not to swap to WP-A's sameIdentity "while it folds with
trim().toLowerCase()". #326 no longer does: at 4de265c its fold is NFKC,
trim and full case folding, and provisioning binds identities with that
same fold (identity binding refuses an email that folds to an existing
identity, with a 409).

The comment now gives the merge step. This compare folds ASCII case only
because master binds nothing at provisioning. When #395 merges after #326
(rule 6), it adopts WP-A's sameIdentity, so the gateway keeps one
comparison. The two lookalike tests then follow WP-A, where provisioning
refuses the lookalike.

Comment only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…k survives a restart, and an unknown courier status is unavailable (N81, #455 review round 2)

astra's round-2 review of 8c4b863 (DO-NOT-SHIP) closed F1-F5. Its new
findings were each reproduced first:
- The HIGH (actor roles) is a MERGE ORDER, not a change here. This branch
  must land after kits' #395, or with it. At 8c4b863, another
  authenticated operator could confirm, dispute or release (9 of 12
  reproductions fail). A trial merge with #395 @cc66f511 refuses all 12
  and passes 617 tests. The conflict patch is kept with the lane.
- MEDIUM: `confirmed` ignored the 72h review window that `disputed`
  keeps. A first judgment of a delivery, either one, is now allowed only
  inside the window.
- MEDIUM: the 1h re-claim block lived in memory, so a restart lost it.
  The releasing kernel now travels in the persisted 'release' event, and
  hydration re-arms the block from each offer's latest release (no schema
  change).
- MEDIUM: an unknown status projected to the courier view as 'open'. It
  now projects as 'expired' (unavailable).
- LOW: docs/JOB_OFFERS.md describes the current lifecycle.

Tests: the lifecycle, job-offers and courier-jobs suites, 1014/1014. The
matrix covers every (status, event, age), including the window's edges.
Mutations: 4 of 5 killed. The fifth (the rebuild ignoring a release's age)
is equivalent, because claim() re-checks the guard's expiry itself. Full
gateway suite: 3928 passed. The one unrelated timeout passes alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSBxBEX3sn9DPSqihyjuZE
…ant; legacy courier claims fall back to the kernel owner

Two findings from astra 142 (#395 @cc66f511), each reproduced there first
(scratchpad repro-142b).

HIGH: the print-and-mail handoff authorized evidence by comparing the
body's driverAgent with the job's public claimedBy label. A stranger
could copy that label and submit handoff evidence as the claimant
(reproduced: 201 instead of 403). The handoff now needs an authenticated
principal (401 missing_identity otherwise). That principal must be the
job's claimant: the principal recorded privately at claim time, or, for a
pre-binding claim, the owner of the kernel its label names. Anyone else
gets 403 not_claimant. driverAgent is still required, but it is a label:
a claimant whose driverAgent does not name the claim gets 400
driver_label_mismatch.

MEDIUM: the courier shim resolved a claimant only from the private
binding. A courier claim made before claimant binding therefore lost every
event after a restart, while the generic route fell back to the claiming
kernel's owner (reproduced: generic 200, shim 403 for the same driver).
The shim now uses the same resolution. The fallback holds only when the
legacy driverAgent label names a real kernel with a provable owner, and
fails closed otherwise.

One resolution is shared by all three surfaces: services/kernel-owner.ts
(kernelOwnerFromStore, moved from job-offers.ts, and offerClaimant).
courierJobsRoutes and printAndMailRoutes take the same optional
kernelOwnerOf override as jobOffersRoutes.

Not in this commit (see the triage): 142's HIGH on ASCII-case aliases
closes with the comparator swap onto WP-A in the merge after #326, and its
HIGH on the N81 lifecycle closes in the merge with #455. Atomic rating
uniqueness needs a unique index, a schema change for the operator.

Tests: print-and-mail 21/21 (401, stranger-with-label 403, and
label-mismatch 400 added; the existing handoffs now authenticate), and
courier-jobs 34/34 (the legacy fallback and its fail-closed case added).
Full gateway: 189 files, 3021 passed, 6 skipped, plus the known
completion-real-tier full-run timeout, which passes alone (3/3). Gateway
tsc exits 0. Mutations: 4 of 4 killed (the identity check, the actor
requirement, the shim fallback, and a fallback that trusts the label).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
LamaSu and others added 2 commits October 3, 2026 10:17


astra 142 (#395 @cc66f511) required closure 2: the route's role checks sat
on a store that did not implement N81, so a claimant could deliver after a
cancel and a release left the offer claimed. #395 now carries #455 @ced9b5c6
(frozen) by agreement with composition (#4976, #4984). The operator merges
#395 alone; #455 never merges by itself (astra 157).

Merge resolution: composition's patch n81-x395-conflict-resolution-dcfe7b1e
(#4992). Only job-offers.test.ts and courier-jobs.test.ts conflicted. They
take #395's versions plus the patch's hunks 1-3. Hunk 4 makes the second
release in job-offers-lifecycle.test.ts come from the current claimant,
because #395 makes release claimant-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
…ty (astra 142)

astra 142, required closure 2, asked for cancel-then-deliver and
release-then-reclaim negatives on the integrated head. Writing them showed
that #455's release cleared the claim fields but left the private claimant
in the store. claimantOf() still named the released operator, so on the open
offer it could post the events the claimant or the poster may post (a
progress_update answered 200, recorded as by "claimant"), and the persisted
row would restore it after a restart. A release now removes the private
claimant too, so the row persists null and a restart brings nothing back.

Tests:
- after the poster cancels, the claimant's delivered, in_progress and pickup
  each answer 409 invalid_transition, and the offer stays cancelled;
- after a release the offer is open with no claimant. The released operator
  gets 403 not_claimed for delivered and 403 not_participant for
  progress_update. Another kernel claims at once, and the former claimant
  cannot advance that claim;
- the store's claimantOf() is null after a release, and still null after a
  restart over the same database.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A

This branch has not been deployed

No deployments
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