Repository navigation
Conversation
…losed Gate A row N10a (steward #2641, gateway spec WP-E). Before this, anyone could set anyone's licensing terms and royalty splits, claim any vault (recorded as 0x0), and settle royalties for any job with a body-supplied revenue and payer. Identity: only a SIWE-proven wallet. An API key's operatorId is asserted, not proven: /api/auth/provision is public, so an owner check on it passes for anyone. This is why job.cancel is unregistered. Ownership (E1): there is no owner column, so ownership comes from durable records only. - A capability IP is owned by its capability's kernel operator (story_ip_registrations.capability_id). - A job-evidence IP is owned by its job's kernel operator (story_derivative_links.job_id). - No record means no owner, and every mutation is refused (403 ip_owner_unknown). Three paths write those records: - register-capability: the caller must operate the capability's kernel, with designer == caller and the capability recorded. - register-job-evidence: the caller must operate the job's kernel, with operator == caller and the job and parent recorded. - The settlement service's automatic derivative now names the kernel operator, not 0x0. For both register routes the record is the ownership proof, so a failed write is an error, not best-effort. POST /api/csd mints its IP to the caller's proven wallet only (never the body's designer, never 0x0), or registers no IP and says why. E2: set-licensing-terms, distribute-royalties and claim require the owner. Claim records the caller. E3: settle-royalties runs only for the job's buyer (the escrow payer) or its kernel operator, and only for a RELEASED milestone. - Revenue is the milestone amount in USDC base units, and the payer is the escrow payer. A disagreeing body is 409. - The child IP must be the job's own evidence. - A failed payment fails the request (502), and each row reports whether it was paid or failed. E4: - pay: the payer is the caller, against a recorded IP. - dispute: any proven wallet may dispute a recorded IP, and is named as the disputant. Tests: - ip-authz.test.ts is new, 17/17. All 17 fail against master's ip.ts. - ip.test.ts is updated to sign in as the seeded owner. - Full gateway run: 2999 passed, 1 failed. The failure, completion-real-tier, is pre-existing: it fails the same way on master's files in a full run (2980 passed, 1 failed) and passes alone. - tsc --noEmit is clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGRcaWqVay3ueHYRBcXJ7K
…efuses pcc-economics N10b (#360) makes Story real mode refuse operations it cannot execute, with StoryNotExecutedError (code STORY_NOT_EXECUTED). Whichever PR merges first, the IP routes now answer that refusal as 501 not_executed, never as a generic 500 that a client could mistake for a transient fault. settle-royalties answers 501 when Story executed none of the payments. A mix of paid and failed rows stays 502 settlement_incomplete, with the per-row outcomes. The check is duck-typed on err.code, so it holds on master before #360 lands. ip-authz: 20/20. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGRcaWqVay3ueHYRBcXJ7K
…Ps, the gateway never pays Answers coord-watch's cross-family verdict (#2974, astra, DO-NOT-SHIP) and gateway's review (#2971, SHIP_WITH_FIXES) on ff18daa. - settle-royalties no longer falls back across workflows. A job settles only from its own workflow's escrow milestone; another workflow's released milestone and payer never do (astra 2). - settle-royalties is once-only (astra 3, gateway 2). A durable claim is written to the append-only audit log, with no await between the check and the claim. A second settlement is 409 already_settled. A settlement that paid nothing releases its claim so it can be retried; one that paid anything never repeats. - In Story real mode, pay and settle answer 501 not_executed. The gateway would otherwise pay from its own key on the caller's behalf; the payer's own on-chain path is operator queue G (gateway 3). - POST /api/csd no longer mints a Story IP. A CSD IP could never have a durable owner record (astra 1); IPs are registered through /api/ip/register-capability. - Job evidence must derive from the IP of the job's own capability, not from any known IP. pay and dispute require an IP with a recorded owner (astra "also"). - Settlement-service auto-derivatives: - skip a zero-address or malformed kernel operator; - log an unrecorded derivative to the audit log for operator repair, instead of swallowing it. - Release-time Story royalties computed from STORY_MILESTONE_AMOUNT x STORY_ROYALTY_PERCENT, with the escrow named as payer, are removed (astra 4, pre-existing). - A registration minted but not recorded is audit-logged for repair (gateway 5). - verifiedWallet accepts req.provenWallet when gateway #326's apiGate sets it (gateway 1). Until then, only the SIWE session counts. Tests: - ip-authz 26/26, ip 33/33, story-pipeline 9/9, csd 21/21. - Full gateway suite: 3001 passed, 0 failed. - tsc is clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGRcaWqVay3ueHYRBcXJ7K
… final Gateway #3160 fixed the #326 interface: apiGate sets req.provenWallet on every request, to a proven wallet or to null. The null is deliberate in some cases, for example a SIWE cookie riding on another identity's API key. verifiedWallet used to fall back to the SIWE session whenever provenWallet was not an address. After #326 it would therefore have accepted that cookie's wallet behind the gate's back. The field is now final once present: an address is used, and null means no proven wallet. Only when the field is absent, as on master before #326, is the session the proof. Nothing on master sets the field today, so current behavior is unchanged. When #326 lands, no edit to #385 is needed. Test: the owner's session passes with no gate verdict, and is refused (401 verified_wallet_required) once the gate has answered null. It fails on the old fallback. ip-authz, ip and story-pipeline 69/69; tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGRcaWqVay3ueHYRBcXJ7K
LamaSu
added a commit
that referenced
this pull request
Sep 29, 2026
…IWE session or a SIWE-minted key Economics asked for this (N8 follow-up, #2888), and it closes the seam between #326 and #385. #326's money floor lets only an explicitly scoped KEY move money, and a key's operatorId is only asserted on the email path. So an owner check that needs a proven wallet (N10a, /api/ip) refused keys outright, and after both merge the /api/ip money routes would have needed a key AND a SIWE cookie on the same request. - /api/auth/provision writes { siweVerified: true, provenAddress } into the metadata of a key it mints for a SIWE-proven wallet. Only that branch writes it; the email and delegated paths never do, and no route writes caller-supplied metadata onto a key. - provenWalletOfKey accepts only that exact shape, naming the key's own (case-folded) operatorId. A custodial-quickstart walletAddress, a legacy row, malformed JSON or a proof for another identity gives null. - apiGate sets req.provenWallet on every request: null first, then the session address or the key's proof. The key is the principal, so a SIWE cookie riding along on another identity's key proves nothing. There is no schema change. proven-wallet.test.ts has 17 tests. Against the pre-change gate and provision wiring (helper kept so the file imports), 7 fail. 2 are true negatives: a SIWE-minted key and a SIWE session must yield the address. 5 are null guards that fail there only because the field was absent. The neighbouring auth suites (provision-*, settlement-scope, identity-binding, apigate-public-methods, api-key-mint-scopes, siwe-hardening) pass 193/193. tsc exits 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A8smAyyypDGhf46eT71TXo
… owner, honest outcomes Answers astra's round-2 verdict on #385 at 9d5a57b (DO-NOT-SHIP). A factual inventory of the root-of-trust writers is in returns/pcc-economics-work/n10a-r3/inventory.md. Settlement (ip.ts): - The claim is keyed by the released escrow MILESTONE, which is the royalty obligation, not by (jobId, childIpId). Nothing stops one job having two derivative links, and the old key paid a milestone once per child IP. - A per-row journal: an "intent" before each payment call, then "paid" with the txHash or "failed". An intent with no outcome is an uncertain payment, kept for reconciliation. - The claim is released only when Story reports that it executed none of the rows (STORY_NOT_EXECUTED). Any other failure may have executed after submission, so the claim stands and the 502 says the settlement is held for reconciliation. - escrows.cwm_id is not unique, and findByCwm returned the first row. Several escrows for one workflow are now ambiguous and settle nothing. - Story real mode is recognized if EITHER the environment or the service instance says real. The instance fixes its mode when it is built, so the route can no longer say mock while a real signer runs. - Still open, and stated in the code: this is not atomic across processes. The transactional table (royalty_settlement_events, BEGIN IMMEDIATE) is in the economics DDL, operator item 27. Parent registration (ip.ts): a job/capability association is not permission to derive. The job's capability must be on the job's kernel, and the caller must be the parent IP's recorded owner. Job submission does not check that pair, so a job on kernel A naming kernel B's capability used to derive from B's IP. Automatic derivatives (settlement-service.ts): the same parent rule applies. A link Story minted but the store failed to record now emits settlement_claim "failed" with outcome "unrecorded" and repairRequired, never "completed", and no settlement.story_registered. Tests: 7 new. Six fail on 9d5a57b: four settlement, one parent and one pipeline. The seventh is the promised root-of-trust negative test: a capability cannot be repointed, an owned kernel cannot be re-registered by another actor, and the IP stays its owner's. It passes on both commits and pins the property. Full gateway suite 3008 passed, 6 skipped, 0 failed (188 files); tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…view loop) The review loop requires a reproduction for every change. This test shows astra's round-2 point about the route's real-mode predicate. A StoryIPService instance built in real mode, while STORY_MOCK now says mock, reached payJobRoyalty with 9d5a57b's route (the request returned 200). At 6f0c763 the request is refused with 501 not_executed, and payJobRoyalty is never called. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu
added a commit
that referenced
this pull request
Sep 30, 2026
…eath it (N79 round 2) astra's pack 116b on #462 @38cf4116: DO-NOT-SHIP, with three HIGH findings and one MEDIUM. Each was reproduced first with a failing test at 38cf411 (n79-refund-vs-settlement.test.ts): - F1 HIGH: a repeated failure write while /complete is in flight refunded ("expected 'refunded' to be 'skipped'"); - F2 HIGH: cancelling one job refunded an escrow another job shares; - F3 HIGH: a refund landed while SettlementService.releaseMilestone awaited the chain ("expected 'refund_pending' to be 'skipped'"); - F4 MEDIUM: a completion that failed after a failure write left the failed job's escrow funded. Two more were reproduced the same way: - the keeper, which sweeps a findAll() snapshot across awaits, drove an escrow given back mid-sweep (astra's Q2 note); - the raw chain release route let a refund land while it awaited the chain (the F3 class, found by extending the audit). The fix is one mechanism: DURABLE SETTLEMENT OWNERSHIP on the escrow row. - A settlement takes its escrow with a compare-and-set from funded/active to `completing` (already in @pcc/spec's vocabulary), synchronously at its claim: /complete (same stretch as the job claim), resume-settlement, SettlementService.releaseMilestone (before the chain await) and the raw release route (by contract address). - A refund never lands on a `completing` escrow, however often the job's mutable status is rewritten (F1, F3, raw route). - A settlement that gives up BEFORE recording evidence hands the escrow back (prior status). If the job ended meanwhile, the escrow goes to the payer then, in one transaction (F4). After evidence exists, the settlement keeps it, since resume-settlement continues it. - A release that went through records its milestone `released`, and the escrow `completed` once every milestone is. - The refund gives back a whole escrow only if no other job references it; a shared escrow is skipped as `escrow_shared` (F2). - The keeper re-reads the row right before each drive. Tests: the six reproductions, plus the gaps astra named: - a failure on the LAST write rolls back every milestone and the status; - a failure after evidence keeps ownership; - resume takes a legacy row (the write repeated, as in F1); - a partial release hands the escrow back; - failed releases (service and raw route) hand it back. All 40 mutants (both rounds) are killed. The gateway suite passes: 3053 passed, 6 skipped (six threads; the Spark is at load 37, where two /complete tests time out at 5 s and pass alone). tsc is clean. It merges cleanly with #326, #385, #403, #424, #441, #445, #450, #451 and #459.
…durable journal; no legacy kernel claim; release on receipt Cross-family review A07b (astra, DO-NOT-SHIP at 4df69fa). Each finding was reproduced at 4df69fa with a test that fails there (or, for F5, a scratch trace), then fixed. - F1 (HIGH): the caller chose which schedule consumed the milestone. At 4df69fa all of these paid (200): a job with two evidence IPs, settled through the one paying a third party; two jobs that name one milestone; and a retry after nonexecution with a changed schedule. Now settle-royalties refuses as settlement_ambiguous a job with more than one evidence IP, or a milestone another job also settles through. The first claim binds the milestone to its job, evidence IP and schedule digest, so a retry that differs is refused as settlement_binding_changed. register-job-evidence refuses a second evidence IP for a job (job_evidence_already_registered), including one racing an in-flight registration: without the in-process guard, two concurrent requests both registered. - F2 (MEDIUM): journal writes were swallowed. With the "paid" write failing, 4df69fa answered 200; with the "intent" write failing, it paid anyway. Now nothing is paid without its intent on record: 503 journal_unavailable, and the claim is released because nothing ran. An outcome that cannot be recorded stops the settlement with 502 settlement_unrecorded, with reconciliationRequired set; the claim stands. - F3 (HIGH): re-registering a legacy kernel (operator "" or the zero address) with any API key claimed it. At 4df69fa OTHER became the operator, and so owned its capabilities' IP. kernel.facade no longer claims such rows; they stay unowned until an admin migration. In /api/ip, "" and the zero address are no owner. - F4 (HIGH): SettlementService.releaseMilestone reported "released" and settled the job on broadcast. Now it waits for the receipt (waitForReceipt, bounded at 90 s): - success: released, and the job is settled; - reverted: failed (release_reverted); - timeout, or an unreadable receipt: submitted (release_unconfirmed), with the hash. Three test files' escrow-client mocks gain a success receipt. - F5 (HIGH, SIWE audience from Host): reproduced by trace (a message signed for evil.example opened a session with Host: evil.example). There is no change here: gateway #326 @4de265ce binds domain and URI to PCC_SIWE_ORIGINS, and #385 ships after #326. The r2 test "settled once, whichever of the job's child IPs names it" encoded F1's hole. It is replaced by the A07b F1 tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ses only on a receipt Cross-family review A07c (astra, DO-NOT-SHIP at a31ae78). F1, F2 and F3 are CLOSED. Each open finding was reproduced at a31ae78 with a test that fails there, then fixed: - N1 (CRITICAL): any authenticated caller could bind a signing key it controls to a kernel with no recorded owner and no signer, taking the trust root its machine logs and evidence are verified against. At a31ae78 the attacker's re-registration answered 200 and its signer was stored. Now such a row refuses any signing proof (403) before anything is written. A row that already has a signer keeps the usual rule: that signer's proof is idempotent, and another is a conflict (409). A guard test pins this. - F4 (HIGH): SettlementFacade.releaseMilestone, behind the escrow chain release route and its workflow activity, announced settlement_complete/completed and logged escrow.released right after the broadcast. Now it waits for the receipt (waitForReceipt, bounded): - success: announced and recorded once, with the hash; - reverted: a permanent failure (release_reverted), announced as failed; - timeout, or an unreadable receipt: status "submitted", recorded as escrow.release_submitted with the hash, and never announced as released. - A07c Q6: SettlementResultDTO now declares "released" and "submitted", the statuses releaseMilestoneForJob returns, and the cast that hid the mismatch is gone. - F5 (HIGH, external): no change. Gateway #326 carries the trusted SIWE origin check; its head 85c9d5b descends from 4de265c. #385 merges after #326. kernel-signing-ed25519's concurrent SET-ONCE test created its kernel with no owner, so it relied on N1. Its kernel now has a recorded owner, and it still races two binds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cross-family review A07d (astra, DO-NOT-SHIP at d5d6467): N1 and F4 CLOSED. The new N2 (HIGH) named three pre-existing paths that announce settlement without a release receipt. Each was reproduced at d5d6467 first. - N2(c), fixed here. PUT /api/jobs/:jobId/complete with real settlement, and POST /api/jobs/:jobId/resume-settlement when the crank has not released, recorded evidence_submitted but emitted settlement_complete/completed. At d5d6467 the new tests (settlement-crank-wiring) saw ["completed"]. Now the status is "completed" only when the job is settled, and "started" (under way) otherwise. A released resume still reports "completed", and a guard test pins it. The chain-preconfirmed resume emit is already gated on a confirmed release and is unchanged. - N2(a), owned elsewhere. GET /api/settlement/:jobId answered settled: true for a completed job whose escrow is still funded (a scratch trace at d5d6467). Readmodels' #382 (board N79; astra SHIP at 42ca1ea) rewrites exactly those lines, so #385 changes nothing there and merges after #382. - N2(b), already fixed on master. A completed job's timeline had a "settled" event (a scratch trace at d5d6467). Master fixed it in 45e1b22, in a file #385 does not touch, so it reaches #385's head with the merge-up after #382 and #326 land. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
Gate A row N10a (
/api/ip/*authorization). Moved to pcc-economics by steward #2641; spec: gateway's WP-E. Reviewers: gateway (the authorization rule) and coord-watch (cross-family verdict on the exact merge SHA, per board rule 1).Why
On master, any authenticated caller could do any of these to any IP:
POST /api/ip/set-licensing-terms);POST /api/ip/distribute-royalties);0x000…0(POST /api/ip/:ipId/claim);jobRevenueandpayerAddress, silently swallowing failed payments (POST /api/ip/settle-royalties).POST /api/csdalso minted a Story IP to a body-supplieddesignerAddress, which defaulted to the zero address (kits #2615).Rules
Who the caller is. Only a wallet proven by a SIWE (EIP-4361) session counts. An API key's
operatorIdis asserted, not proven:POST /api/auth/provisionis public, so an owner check on it passes for anyone. That is whyjob.cancelis unregistered inmcp/operation-policy.ts. With no proven wallet, a mutation gets401 verified_wallet_requiredplus the SIWE steps.Who owns an IP (E1). There is no owner column, so ownership comes from durable records only:
story_ip_registrations.capability_idstory_derivative_links.job_id403 ip_owner_unknownPaths that write ownership records (3). Nothing about ownership comes from a request body.
register-capability: the caller must operate the capability's kernel,designerAddressmust be the caller, and the capability must be recorded. The registration row is the ownership proof, so a failed write is a 500, not best-effort.register-job-evidence: the caller must operate the job's kernel,operatorAddressmust be the caller, and the job and parent IP must be recorded. The link row is required.0x0, never a malformed address). An unrecorded link is audit-logged for repair. Job evidence must derive from the IP of the job's own capability.STORY_MILESTONE_AMOUNT x STORY_ROYALTY_PERCENT, with the escrow as payer, is removed.POST /api/csdno longer mints a Story IP (storyIpSkipped: "register_via_capability"). A CSD IP could never have a durable owner record, so IPs are registered only throughregister-capability.E2.
set-licensing-terms,distribute-royaltiesandclaimrequire the owner.set-licensing-termsrequiresdesignerAddressto be the caller, andclaimrecords the caller's address.E3:
settle-royalties.released(otherwise 409).parseUnits(amount, 6)); any other currency is 409. The payer is the escrow's payer. A bodyjobRevenueorpayerAddressthat disagrees is 409.already_settled. A settlement that paid nothing releases its claim and can be retried; one that paid anything is never repeated.settlement_incomplete), and every row sayspaidorfailed.not_executedbefore any Story call, so the gateway never pays from its own key on a caller's behalf (operator queue G).Story real mode. Once #360 (N10b) lands, real-mode Story calls refuse with
STORY_NOT_EXECUTED. Every/api/iproute answers that as 501not_executed, never as a generic 500, and this holds whichever PR merges first. settle-royalties answers 501 when Story executed none of its payments.E4.
pay: anyone may pay into the vault of an IP with a recorded owner, but only as themselves (payer_must_be_caller). The amount is a positive integer in base units. In real mode it is 501, as above.dispute: any proven wallet may dispute an IP with a recorded owner, and is returned asraisedBy. A dispute's claimant is often not a party (for example the real author of a copied design), and Story's bond bounds abuse in real mode.Review status
ff18daae: DO-NOT-SHIP. Every confirmed finding is fixed @67d12bec(#3015). The re-review is requested on the current head,9d5a57ba.ff18daae: SHIP_WITH_FIXES. All 5 are addressed (#3016).9d5a57ba, fix(gateway)!: WP-A scope hardening — wildcard is not money/admin authority, fail-closed scopes, identity binding (P0 MUST-CLOSE 6-10) #326's proven wallet (gateway #3160).verifiedWallet()takesreq.provenWalletas final once apiGate sets it, null included. apiGate answers null on purpose for a SIWE cookie riding on another identity's API key, so the session is consulted only while the field is absent (master before fix(gateway)!: WP-A scope hardening — wildcard is not money/admin authority, fail-closed scopes, identity binding (P0 MUST-CLOSE 6-10) #326). No edit to this PR is needed when fix(gateway)!: WP-A scope hardening — wildcard is not money/admin authority, fail-closed scopes, identity binding (P0 MUST-CLOSE 6-10) #326 lands.9d5a57ba(diff-only pack A07): DO-NOT-SHIP. Answered at6f0c7639. A round-3 pack with full files isreview-router/n10a-385-ipauthz-r3-astra/brief.md(bus #3638).STORY_NOT_EXECUTED; any other failure is held for reconciliation. Several escrows for one workflow are refused. Real mode is recognized if the environment or the service instance says so.failed/unrecorded, nevercompleted.kernels.operatorAddressis the root of trust, and its integrity is gateway's.Tests
ip-authz.test.ts(new, 27 tests): each negative is paired with its allowed case.STORY_NOT_EXECUTEDis answered as 501, and any other Story failure stays a 500.ip.ts.ip.test.tsandstory-pipeline.test.tsare updated to sign in as the seeded kernel operator. New CSD cases cover the no-session and someone-else's-designer paths, and the auto-registered derivative names the kernel operator.67d12bec: 3001 passed, 0 failed. (completion-real-tieris flaky in full runs, and fails the same way on master's files.)9d5a57ba: ip-authz, ip and story-pipeline 69/69. The new gate-null test fails on the old session fallback. The change only acts whenreq.provenWalletis set, which nothing on master does yet.tsc --noEmiton the gateway is clean.Known gaps
fix/key-proven-wallet@560a433e(#3160), a one-commit delta to fix(gateway)!: WP-A scope hardening — wildcard is not money/admin authority, fail-closed scopes, identity binding (P0 MUST-CLOSE 6-10) #326 after its review. This PR already reads it.shop_kernels.operator_address) is the root of trust here. Its own integrity at kernel registration and upsert is gateway's row.paid-job-flow.tsfillsjob.cwmIdandescrow.cwmIdfrom two independent random fallbacks when the session has nocwmId, so they differ. N10a finds the job's milestone through the job's uniquestepIdwhen the workflow ids disagree, and refuses when that is ambiguous.Merging is the operator's decision.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KGRcaWqVay3ueHYRBcXJ7K