Repository navigation
Conversation
…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>
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
operatorIdor the SIWEuserId. Nothing is taken from a body field or from the self-assertableX-Posted-Byheader. 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'ssameIdentity(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
operatorIdthat 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.kernelIdwas taken from the body, with no ownership or existence checkkernel_not_owned.deliveredorcancelledon any offer, withbytaken from the bodyin_progress,pickupanddeliveredcome from the claimant only.cancelled,noteandprogress_updatecome from the claimant or the poster.byrecords the role, never an identity.driverAgentdriverAgentis only a label. Events are authorized the same way.maintainerDid, which also redirects demand routingpublished_immutable: fork it to make a new version.authorIdwas null. Ratings fell back to"anonymous"and could be repeated. Machine PUT had no author check.already_rated). Machine PUT is author-only. Every template write needs an actor.Privacy. The claimant's
operatorIdis 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 anoperatorIdcan 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.jobOffersRoutestakes an optionalkernelOwnerOfresolver. It defaults toshop_kernels.operator_address; tests inject a stub.Tests
All runs were on the Spark, in worktree
/mnt/sparkbulk/pcc-lanes/wt-kits-k0b.job-offers.test.tsbyis 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.tstool-catalog.test.tstemplates-ownership.test.tspackages/gatewayvitestcompletion-real-tierhit its 5000 ms timeout under full-suite load, and passes 5/5 alone. It touches no file in this PR.print-and-mail.test.tstsc --noEmit(gateway)The existing claim and event tests now authenticate, and the race tests give each claimant its own kernel. The poster tests on
X-Posted-Byare 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
server_only_eventfor every caller).sameIdentity.142-k0b-395-ownership-cc66f511(first cross-family review): DO-NOT-SHIP with five required closures. Each was reproduced first.claimedBylabel. A legacy courier claim falls back to the owner of the kernel itsdriverAgentnames, and fails closed otherwise.Not in this PR
X-Posted-Byfallback and treats a nullposterDidas "anyone". That belongs to gateway's WP-C job-offer ownership.humanDidbinding (D10) is K3, wave D.Merge order and review
sameIdentityon 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).
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