Skip to content

fix(gateway): an offer event keeps the offer inside its lifecycle (N81) - #455

Draft
LamaSu wants to merge 4 commits into
masterfrom
fix/n81-job-offer-lifecycle
Draft

LamaSu wants to merge 4 commits into
masterfrom
fix/n81-job-offer-lifecycle

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner

N81: an offer event keeps the offer inside its lifecycle

The ADK rehearsal R0 found the job-offer path stranded after a claim (G5). On master, POST /api/job-offers/:id/events set any status from any status. Nothing checked where an offer was, so progress could be reported on an offer nobody had claimed, and a delivered offer could be cancelled or re-opened by a later event.

JobOffersStore.recordEvent now keeps every offer inside its lifecycle, and records nothing for a refused event:

Event Allowed from Otherwise
in_progress, pickup claimed (a repeat on in_progress is a no-op) 409
delivered claimed, in_progress (a repeat is a no-op and keeps the first delivery time) 409
cancelled open, claimed, in_progress (a repeat is a no-op) 409
anything else (note, progress_update, error, ...) any status, unchanged recorded

So nothing advances an unclaimed offer, and settled, cancelled and expired offers stay as they are. A refused event answers 409 {error: "invalid_transition", event, currentStatus}. The courier shim (/api/courier-jobs/:id/events) uses the same store and gets the same rule.

Scope

Tests

  • job-offers-lifecycle.test.ts (33): every refused transition (409, status unchanged, nothing recorded), every allowed one, notes on every status, a repeat delivery keeps the first time, and the courier shim.
  • Two existing tests posted progress on an unclaimed offer. They now claim first, which is the lifecycle the fix enforces.
  • At master's implementation: 17 of the 33 new tests fail; the 16 that pass are the transitions master also allows.
  • Mutations: 9/9 killed (the transition check, each allowed-from set, the repeat-delivery time, recording before refusing, and both 409 mappings).
  • Suites: job-offers, courier-jobs, job-offer-producer and lifecycle 111/111; full gateway: see the CI run.

🤖 Generated with Claude Code

LamaSu and others added 3 commits September 29, 2026 15:54
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>
… (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
…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
LamaSu added a commit that referenced this pull request Oct 3, 2026
…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 added a commit that referenced this pull request Oct 3, 2026


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
LamaSu added a commit that referenced this pull request Oct 3, 2026
…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