@allorc/a423-sdk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ # Cancellation subflow — 0.6.0
2
+
3
+ A job has one parent lifecycle and an optional cancellation subflow. Cancellation can negotiate compensation for completed work and agreed expenses, then close the job. It does not permit continuing work after a partial release.
4
+
5
+ The flow is:
6
+
7
+ 1. Publish `propose_settlement`, or alternate `counter_settlement`, with the worker's proposed amount, reason, claims, original deadline and expected revision. The payer receives the remainder. Funds remain in the escrow contract, never the worker's wallet. Work pauses.
8
+ 2. Both parties review the exact current proposal and sign the `ClosingSettlement` typed data returned by `settlementConsent`. Signature authorization is bound to chain, contract, agreement, job, revision, amounts, proposal hash and expiry.
9
+ 3. Accept using `workflow.acceptCancellation`. The adapter verifies both EOA/ERC-1271 signatures and current on-chain terms/proposal. The workflow atomically saves an immutable JSON instruction and marks the child flow `accepted` before any closing transaction is signed or broadcast. The instruction says `funds_status: "not_executed"`.
10
+ 4. Forward the saved instruction. The adapter validates the JSON against the job, encodes `settleByConsent`, applies the signing/broadcast policy and journals the transaction. The contract verifies signatures and distributes the exact split atomically. It does not parse or trust JSON.
11
+ 5. After the configured confirmations and matching settlement evidence, save the final cancellation receipt. It records actual worker/payer allocations, zero remaining locked amount, transaction/block evidence and the accepted instruction ID.
12
+
13
+ An acceptance record is an authorization receipt, not proof of payment. Pending, rejected, expired or unavailable execution must not display a completed payment receipt.
14
+
15
+ ## Server API
16
+
17
+ ```js
18
+ const proposed = await workflow.act(id, 'propose_settlement', {
19
+ worker_amount: '30', // raw token units; payer gets total minus 30
20
+ expected_revision: 0,
21
+ expires_at: proposalExpiry, // no later than original resolution deadline
22
+ reason: 'Research completed; remaining work cancelled',
23
+ claims: [{ kind: 'work', description: 'Checked source list', evidence: ['artifact:sources'] }]
24
+ }, { actor: workerId, idempotencyKey: 'cancel-proposal-1' });
25
+
26
+ const job = await workflow.acceptCancellation(id, {
27
+ expected_revision: proposed.negotiation.current.escrow_version,
28
+ proposal_hash: proposed.negotiation.current.proposal_hash,
29
+ payer_signature: payerSignature,
30
+ worker_signature: workerSignature
31
+ }, { actor: payerId, idempotencyKey: 'cancel-accept-1' });
32
+ ```
33
+
34
+ The default forwards immediately AFTER saving acceptance. To separate acceptance and execution, pass `forward: false`, then:
35
+
36
+ ```js
37
+ const instruction = await workflow.cancellationInstruction(id, payerId);
38
+ await workflow.act(id, 'execute_cancellation', {
39
+ instruction_id: instruction.id
40
+ }, { actor: payerId, idempotencyKey: 'cancel-execute-1' });
41
+ ```
42
+
43
+ `createEscrowApplication({forwardCancellations:false,...})` stages HTTP acceptance. An explicitly enabled keeper (`allowExecute:true` / factory `keeperEnabled:true`) forwards accepted instructions. Observe-only reconciliation never creates a new payment transaction.
44
+
45
+ | Route, relative to `/api/a423/jobs/:id` | Meaning |
46
+ |---|---|
47
+ | `GET /subflows` | Child records and active child ID |
48
+ | `GET /cancellation` | Cancellation child or null |
49
+ | `POST /cancellation/accept` | Body: revision/hash and both signatures; save then optionally forward |
50
+ | `POST /cancellation/execute` | Body: `instruction_id`, optionally external `transaction_hash` |
51
+ | `GET /cancellation/instruction` | Accepted instruction; unavailable until acceptance |
52
+ | `GET /cancellation/receipt` | Final receipt; HTTP 409 before confirmation |
53
+
54
+ POST requests require the usual credential and `idempotency-key`. The typed workflow client exposes the corresponding methods. The application handler freshly revalidates final receipt evidence; unavailable/changed evidence returns 503. The lower-level workflow getter validates record integrity only; use `freshCancellationReceipt` with an evidence revalidator for current chain proof.
55
+
56
+ ## State and recovery
57
+
58
+ Child statuses are `proposal_pending`, `negotiating`, `acceptance_pending`, `accepted`, `execution_pending`, `execution_failed`, `disputed`, `proposal_rejected`, and `completed`. Its ID and `parent_job_id` link it to the parent job; events retain proposal revisions and receipt/instruction IDs. The job remains `settlement_negotiation` while acceptance/execution is pending. The separate escrow ledger changes only with confirmed chain evidence.
59
+
60
+ Keep the same actor, request and idempotency key on retries. Accepted JSON survives storage restarts. Recovery observes the durable transaction journal; it must not send a duplicate payout. If approval is saved but execution was never started, call acceptance again or use an authorized keeper. A rejected execution preserves the accepted instruction and locked ledger; retry deliberately using a new execution key after resolving its cause. Pending execution must be reconciled/resumed first.
61
+
62
+ The SDK blocks counters after instruction acceptance. External transactions can still change the chain state; forwarding checks fresh state and the contract rejects stale signatures. Original expiry refunds, arbitration and timeout allocations remain available under their original conditions. Those exits create a final receipt with their actual outcome and `instruction_id:null`; `prior_instruction_id` preserves the unexecuted acceptance reference. A failed acceptance or proposal produces no payment receipt.
63
+
64
+ ## Compatibility and limits
65
+
66
+ 0.6 reuses the 0.5 `A423Escrow` contract. No new deployment is needed for a verified 0.5 deployment. Existing 0.4/older contracts do not support these methods; retain their original adapter until custody is closed. Existing records without subflows remain readable; a cancellation child is added when negotiation starts.
67
+
68
+ The legacy `accept_settlement` action remains available for a direct counterparty transaction or dual-signature relay. It can produce a final receipt without a separately saved accepted instruction. Use the new staged flow when the UI needs acceptance before execution.
69
+
70
+ Amounts use raw integer token units, never floating point. Signatures and claims are public authorization material, not private keys. Persist records securely and avoid exposing participant-specific endpoints without authentication. Pure JSON constructors validate structure/binding; they do not independently prove cryptographic consent or chain finality. The reference adapters perform those checks. No live-chain deployment, external app migration or independent security audit is implied.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Add a persisted cancellation child flow with proposal, acceptance, escrow execution and confirmed receipt stages.
6
+ - Verify both wallet signatures before saving the immutable JSON instruction; encode it into the existing `settleByConsent` call.
7
+ - Add typed client/HTTP/chat actions, accepted-instruction and fresh final-receipt routes, keeper forwarding and dashboard controls.
8
+ - Preserve locked balances through rejected/expired execution; retain arbitration and original deadline exits.
9
+ - Reuse the 0.5 contract without Solidity changes. Earlier contracts still require the 0.5 deployment upgrade.
10
+
3
11
  ## 0.5.0
4
12
 
5
13
  - Add reference-contract closing proposals, counterproposals, exact counterpart acceptance and dual-signature permissionless settlement.
package/INTEGRATION.md CHANGED
@@ -1,3 +1,7 @@
1
+ # Cancellation subflows in 0.6
2
+
3
+ Use `workflow.acceptCancellation` or `POST /api/a423/jobs/:id/cancellation/accept` with both wallet signatures. Acceptance is persisted before forwarding; show the subflow status and issue the final receipt only after confirmation. See [CANCELLATION.md](./CANCELLATION.md) for routes, retry behavior and migration. The actual remote application is not modified by installing this package.
4
+
1
5
  # Closing settlements in 0.5
2
6
 
3
7
  Read SETTLEMENT.md. Use a new local/testnet reference deployment, preserve older funded contracts with their original adapters, wire proposal/counter/accept actions and the settlement-consent endpoint, and display work status/escrow ledger/negotiation separately. Test partial compensation, stale and expired approvals, arbitration, original deadlines and crash recovery. No real-chain deployment is implicit.
@@ -13,7 +17,7 @@ The remote MVP was not available in the SDK workspace; these utilities do not im
13
17
  Install after the maintainer publishes:
14
18
 
15
19
  ```sh
16
- npm install @allorc/a423-sdk@0.5.0
20
+ npm install @allorc/a423-sdk@0.6.0
17
21
  ```
18
22
 
19
23
  For testing before publication, install the maintainer's `npm pack` tarball instead. Do not silently install 0.1 and assume the workflow entry point exists.
package/OPERATIONS.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # A423 operational integration
2
2
 
3
+ For the 0.6 accepted cancellation JSON → escrow execution → confirmed receipt subflow, see [CANCELLATION.md](./CANCELLATION.md). The 0.5 contract is unchanged; an accepted instruction is not proof of payment.
4
+
3
5
  Version 0.5 adds closing negotiation, a paused-work state, a separate escrow ledger and proposal history. See SETTLEMENT.md before deploying the new contract. Execution callbacks receive an AbortSignal and must honor it for cooperative cancellation.
4
6
 
5
7
  This guide covers concrete SDK modules and their deployment boundaries. The remote MVP's source was unavailable during this SDK change, so its chat tools, dashboard, environment, reset route and testnet deployment have not been modified. Use the supplied bridge in that repository, then run the actual app's integration tests. Existing contract-level constraints remain explicit.
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
- # A423 SDK — 0.5.0
1
+ # A423 SDK — 0.6.0
2
+
3
+ Version 0.6 adds a durable **cancellation subflow**: accepted signed JSON → escrow execution → confirmed cancellation receipt. See [CANCELLATION.md](./CANCELLATION.md). Acceptance records never claim funds have moved. It reuses the 0.5 reference contract.
2
4
 
3
5
  Version 0.5 adds **negotiated closing settlements**: partial-work/expense claims, counterproposals, exact acceptance, on-chain release blocking during negotiation, arbitration and unchanged deadline fallbacks. See [SETTLEMENT.md](./SETTLEMENT.md) for the complete flow, signatures, ledger and upgrade requirements. It closes the job; it does not add milestone payments.
4
6
 
5
7
  Experimental Node.js/Next.js middleware for agent tasks, agreements and escrow. Version 0.4 adds working persistence, authority credentials and operational services around the configurable workflow and reference contract. Installing the SDK never publishes endpoints or deploys a contract automatically.
6
8
 
7
9
  ```sh
8
- npm install @allorc/a423-sdk@0.5.0 ethers@^6.15.0 pdf-lib@^1.17.1
10
+ npm install @allorc/a423-sdk@0.6.0 ethers@^6.15.0 pdf-lib@^1.17.1
9
11
  ```
10
12
 
11
13
  Node.js 22.13+ is required for the SQLite module. Node 24 is the tested runtime. `ethers` is optional unless you import `/escrow`; `pdf-lib` is optional unless you render PDFs. Core/workflow remain framework-independent.
package/SECURITY.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Security
2
2
 
3
+ For the 0.6 accepted cancellation JSON → escrow execution → confirmed receipt subflow, see [CANCELLATION.md](./CANCELLATION.md). The 0.5 contract is unchanged; an accepted instruction is not proof of payment.
4
+
3
5
  Experimental SDK and independently unaudited reference escrow. Use local/testnet evaluation until the deployment and operational model receive independent review.
4
6
 
5
7
  Reference-contract custody is distinct from the legacy root API, which has no funded escrow. Authority credentials identify keys approved by your operator registry; they are not proof of legal-company status. Administrative enrollment/revocation capabilities must not be exposed to untrusted callers. Use explicit trust roots, TLS, short credential lifetimes, revocation checks and rate-limited proof endpoints.
package/SETTLEMENT.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Negotiated closing settlements — 0.5.0
2
2
 
3
+ For the 0.6 accepted cancellation JSON → escrow execution → confirmed receipt subflow, see [CANCELLATION.md](./CANCELLATION.md). The 0.5 contract is unchanged; an accepted instruction is not proof of payment.
4
+
3
5
  This release adds a closing negotiation for cancellation and partial-work compensation. A party proposes a worker payment, the other can counter or accept, and the contract pays the agreed amount and refunds the remainder atomically. It closes the whole job. **It does not introduce partial releases while continuing work or milestone payments.**
4
6
 
5
7
  ## Separate facts
@@ -7,10 +7,10 @@
7
7
  const $=id=>document.getElementById(id);let credential='';
8
8
  async function api(path,body,key){const response=await fetch(path,{method:body===undefined?'GET':'POST',headers:{authorization:'Bearer '+credential,...(body===undefined?{}:{'content-type':'application/json'}),...(key?{'idempotency-key':key}:{})},body:body===undefined?undefined:JSON.stringify(body)});const data=await response.json();if(!response.ok)throw Error(JSON.stringify(data));return data;}
9
9
  function show(error){$('status').textContent=error.message||String(error);}
10
- async function load(){credential=$('token').value;const data=await api('/api/state');$('jobs').replaceChildren();for(const job of data.a423.jobs){const article=document.createElement('article');const title=document.createElement('h2');title.textContent=job.id+' · '+job.state+(job.safety?.blocked?' · SAFETY HOLD':'')+(job.pending?' · '+job.pending.action+' pending':'');article.append(title);const info=document.createElement('pre');info.textContent=JSON.stringify({work_status:job.work_status,escrow_ledger:job.escrow_ledger,negotiation:job.negotiation,safety:job.safety,participants:job.participants,agreement:job.agreement,submission:job.submission,evidence:job.evidence},null,2);article.append(info);
11
- const action=document.createElement('select');for(const value of ['quote','accept','fund','start','submit','verify','release','refund','dispute','resolve','timeout','propose_settlement','counter_settlement','accept_settlement','cancel','expire']){const o=document.createElement('option');o.textContent=value;action.append(o);}const input=document.createElement('textarea');input.value='{}';action.onchange=()=>{const p=job.negotiation?.current;if(['propose_settlement','counter_settlement'].includes(action.value))input.value=JSON.stringify({expected_revision:p?.escrow_version||0,worker_amount:p?.worker_amount||'0',expires_at:Math.min(Math.floor(Date.now()/1000)+3600,job.agreement?.terms.escrow.resolution_deadline||0),reason:'Explain completed work and proposed compensation',claims:[]},null,2);if(action.value==='accept_settlement'&&p)input.value=JSON.stringify({expected_revision:p.escrow_version,proposal_hash:p.proposal_hash},null,2);};const key=document.createElement('input');key.value=crypto.randomUUID();key.title='Keep this key when retrying the same action';const run=document.createElement('button');run.textContent='Run action';run.onclick=async()=>{try{await api('/api/a423/jobs/'+job.id+'/actions',{action:action.value,input:JSON.parse(input.value)},key.value);await load();}catch(e){show(e);}};
10
+ async function load(){credential=$('token').value;const data=await api('/api/state');$('jobs').replaceChildren();for(const job of data.a423.jobs){const article=document.createElement('article');const title=document.createElement('h2');title.textContent=job.id+' · '+job.state+(job.safety?.blocked?' · SAFETY HOLD':'')+(job.pending?' · '+job.pending.action+' pending':'');article.append(title);const info=document.createElement('pre');info.textContent=JSON.stringify({work_status:job.work_status,escrow_ledger:job.escrow_ledger,negotiation:job.negotiation,subflows:job.subflows,active_subflow_id:job.active_subflow_id,safety:job.safety,participants:job.participants,agreement:job.agreement,submission:job.submission,evidence:job.evidence},null,2);article.append(info);
11
+ const action=document.createElement('select');for(const value of ['quote','accept','fund','start','submit','verify','release','refund','dispute','resolve','timeout','propose_settlement','counter_settlement','approve_cancellation','execute_cancellation','accept_settlement','cancel','expire']){const o=document.createElement('option');o.textContent=value;action.append(o);}const input=document.createElement('textarea');input.value='{}';action.onchange=()=>{const p=job.negotiation?.current;if(['propose_settlement','counter_settlement'].includes(action.value))input.value=JSON.stringify({expected_revision:p?.escrow_version||0,worker_amount:p?.worker_amount||'0',expires_at:Math.min(Math.floor(Date.now()/1000)+3600,job.agreement?.terms.escrow.resolution_deadline||0),reason:'Explain completed work and proposed compensation',claims:[]},null,2);if(['accept_settlement','approve_cancellation'].includes(action.value)&&p)input.value=JSON.stringify({expected_revision:p.escrow_version,proposal_hash:p.proposal_hash},null,2);if(action.value==='execute_cancellation')input.value=JSON.stringify({instruction_id:job.subflows?.find(s=>s.kind==='cancellation')?.instruction?.id||''},null,2);};const key=document.createElement('input');key.value=crypto.randomUUID();key.title='Keep this key when retrying the same action';const run=document.createElement('button');run.textContent='Run action';run.onclick=async()=>{try{await api('/api/a423/jobs/'+job.id+(action.value==='approve_cancellation'?'/cancellation/accept':'/actions'),action.value==='approve_cancellation'?JSON.parse(input.value):{action:action.value,input:JSON.parse(input.value)},key.value);await load();}catch(e){show(e);}};
12
12
  const consent=document.createElement('button');consent.textContent='Sign worker consent with wallet';consent.onclick=async()=>{try{if(!window.ethereum)throw Error('EIP-1193 wallet required');const typed=await api('/api/a423-worker-consent/'+job.id);const accounts=await window.ethereum.request({method:'eth_requestAccounts'});if(accounts[0].toLowerCase()!==typed.value.worker.toLowerCase())throw Error('Choose the agreed worker account');const signature=await window.ethereum.request({method:'eth_signTypedData_v4',params:[accounts[0],JSON.stringify({domain:typed.domain,primaryType:'Terms',types:{EIP712Domain:[{name:'name',type:'string'},{name:'version',type:'string'},{name:'chainId',type:'uint256'},{name:'verifyingContract',type:'address'}],...typed.types},message:typed.value})]});input.value=JSON.stringify({worker_signature:signature},null,2);action.value='fund';show('Worker signed. Share the public signature with the authenticated payer to fund.');}catch(e){show(e);}};
13
13
  const receipts=document.createElement('button');receipts.textContent='Fresh JSON receipt';receipts.onclick=async()=>{try{info.textContent=JSON.stringify(await api('/api/a423-receipts/'+job.id),null,2);}catch(e){show(e);}};const pdf=document.createElement('button');pdf.textContent='Fresh PDF';pdf.onclick=async()=>{try{const r=await fetch('/api/a423-receipts/'+job.id+'.pdf',{headers:{authorization:'Bearer '+credential}});if(!r.ok)throw Error('Evidence unavailable');const url=URL.createObjectURL(await r.blob());const a=document.createElement('a');a.href=url;a.download=job.id+'.pdf';a.click();setTimeout(()=>URL.revokeObjectURL(url),10000);}catch(e){show(e);}};
14
- const closing=document.createElement('button');closing.textContent='Sign current closing split';closing.onclick=async()=>{try{if(!window.ethereum)throw Error('EIP-1193 wallet required');const typed=await api('/api/a423-settlement-consent/'+job.id);const accounts=await window.ethereum.request({method:'eth_requestAccounts'});const address=accounts[0].toLowerCase(),terms=job.agreement.terms.escrow;const field=address===terms.payer.toLowerCase()?'payer_signature':address===terms.worker.toLowerCase()?'worker_signature':null;if(!field)throw Error('Choose the agreed payer or worker wallet');if(!confirm('Close this job: '+typed.value.workerAmount+' raw units to worker, '+typed.value.payerAmount+' refunded. Review the claim and wallet prompt.'))return;const signature=await window.ethereum.request({method:'eth_signTypedData_v4',params:[accounts[0],JSON.stringify({domain:typed.domain,primaryType:'ClosingSettlement',types:{EIP712Domain:[{name:'name',type:'string'},{name:'version',type:'string'},{name:'chainId',type:'uint256'},{name:'verifyingContract',type:'address'}],...typed.types},message:typed.value})]});let previous={};try{previous=JSON.parse(input.value);}catch{}input.value=JSON.stringify({...previous,expected_revision:typed.value.escrowVersion,proposal_hash:typed.value.proposalHash,[field]:signature},null,2);action.value='accept_settlement';show('Signed closing split. Combine both parties’ signatures to relay, or let the counterparty accept directly.');}catch(e){show(e);}};article.append(action,input,key,run,consent,closing,receipts,pdf);$('jobs').append(article);}show('Loaded '+data.a423.jobs.length+' workflows');}
14
+ const closing=document.createElement('button');closing.textContent='Sign current closing split';closing.onclick=async()=>{try{if(!window.ethereum)throw Error('EIP-1193 wallet required');const typed=await api('/api/a423-settlement-consent/'+job.id);const accounts=await window.ethereum.request({method:'eth_requestAccounts'});const address=accounts[0].toLowerCase(),terms=job.agreement.terms.escrow;const field=address===terms.payer.toLowerCase()?'payer_signature':address===terms.worker.toLowerCase()?'worker_signature':null;if(!field)throw Error('Choose the agreed payer or worker wallet');if(!confirm('Close this job: '+typed.value.workerAmount+' raw units to worker, '+typed.value.payerAmount+' refunded. Review the claim and wallet prompt.'))return;const signature=await window.ethereum.request({method:'eth_signTypedData_v4',params:[accounts[0],JSON.stringify({domain:typed.domain,primaryType:'ClosingSettlement',types:{EIP712Domain:[{name:'name',type:'string'},{name:'version',type:'string'},{name:'chainId',type:'uint256'},{name:'verifyingContract',type:'address'}],...typed.types},message:typed.value})]});let previous={};try{previous=JSON.parse(input.value);}catch{}input.value=JSON.stringify({...previous,expected_revision:typed.value.escrowVersion,proposal_hash:typed.value.proposalHash,[field]:signature},null,2);action.value='approve_cancellation';show('Signed closing split. Combine both wallet signatures, then Run action to save acceptance and forward to escrow.');}catch(e){show(e);}};const cancellation=document.createElement('button');cancellation.textContent='Confirmed cancellation receipt';cancellation.onclick=async()=>{try{info.textContent=JSON.stringify(await api('/api/a423/jobs/'+job.id+'/cancellation/receipt'),null,2);}catch(e){show(e);}};const instruction=document.createElement('button');instruction.textContent='Accepted cancellation JSON';instruction.onclick=async()=>{try{info.textContent=JSON.stringify(await api('/api/a423/jobs/'+job.id+'/cancellation/instruction'),null,2);}catch(e){show(e);}};article.append(action,input,key,run,consent,closing,instruction,cancellation,receipts,pdf);$('jobs').append(article);}show('Loaded '+data.a423.jobs.length+' workflows');}
15
15
  $('refresh').onclick=()=>load().catch(show);$('createButton').onclick=async()=>{try{credential=$('token').value;await api('/api/a423/jobs',JSON.parse($('create').value));await load();}catch(e){show(e);}};
16
16
  </script></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allorc/a423-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Experimental Next.js-friendly agent job SDK with typed HTTP client and pluggable storage, authentication and settlement",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -73,7 +73,9 @@
73
73
  "LEGACY.md",
74
74
  "scripts/operations-worker.mjs",
75
75
  "src/closing.cjs",
76
- "SETTLEMENT.md"
76
+ "SETTLEMENT.md",
77
+ "src/cancellation.cjs",
78
+ "CANCELLATION.md"
77
79
  ],
78
80
  "engines": {
79
81
  "node": ">=22.13"
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ const {createHash}=require('node:crypto');
3
+ const {hashAgreement}=require('./workflow.cjs');
4
+ const {settlementProposal}=require('./closing.cjs');
5
+ const clone=structuredClone;
6
+ function check(ok,message){if(!ok)throw new TypeError(message);}
7
+ function cancellationSubflowId(job){check(job.agreement,'Agreement required');return 'cancellation_'+hashAgreement([job.id,job.agreement.hash]);}
8
+ function getCancellationSubflow(job){return clone((job.subflows||[]).find(s=>s.kind==='cancellation')||null);}
9
+ function canonicalProposal(job){
10
+ const p=job.negotiation?.current;check(p,'Closing proposal required');
11
+ const canonical=settlementProposal(job,{worker_amount:p.worker_amount,payer_amount:p.payer_amount,expected_revision:p.escrow_version-1,expires_at:p.expires_at,reason:p.reason,claims:p.claims});
12
+ check(canonical.proposal_hash===p.proposal_hash,'Proposal claims or allocation changed');return canonical;
13
+ }
14
+ /** Constructs a JSON instruction. Cryptographic approval is checked separately by the escrow adapter. */
15
+ function createCancellationInstruction(job,{payer_signature,worker_signature}={}){
16
+ const p=canonicalProposal(job),e=job.agreement.terms.escrow;
17
+ const address=x=>{check(typeof x==='string'&&/^0x[0-9a-fA-F]{40}$/.test(x),'Invalid escrow address');return x.toLowerCase();};
18
+ const signature=x=>check(typeof x==='string'&&x.length<=32768&&/^0x(?:[0-9a-fA-F]{2})*$/.test(x),'Both wallet signature proofs required');
19
+ signature(payer_signature);signature(worker_signature);
20
+ const body={version:'a423.cancellation_instruction.v1',purpose:'close_job',funds_status:'not_executed',job_id:job.id,subflow_id:cancellationSubflowId(job),agreement_hash:'0x'+job.agreement.hash,chain_id:e.chain_id,escrow_contract:address(e.contract),token:address(e.token),onchain_job_id:'0x'+createHash('sha256').update('a423:escrow:v1:'+job.id).digest('hex'),escrow_version:p.escrow_version,proposal_hash:p.proposal_hash,expires_at:p.expires_at,payer:{agent_id:job.participants.requester,address:address(e.payer),amount:p.payer_amount},worker:{agent_id:job.participants.worker,address:address(e.worker),amount:p.worker_amount},proposal:p};
21
+ return {id:'cancel_instruction_'+hashAgreement(body),...body,authorization:{scheme:'eip712_closing_settlement_v1',payer_signature,worker_signature}};
22
+ }
23
+ function validateCancellationInstruction(job,instruction){
24
+ check(instruction&&instruction.authorization?.scheme==='eip712_closing_settlement_v1','Cancellation authorization required');
25
+ const expected=createCancellationInstruction(job,instruction.authorization);
26
+ check(hashAgreement(expected)===hashAgreement(instruction),'Cancellation instruction changed or does not match this proposal');
27
+ return clone(instruction);
28
+ }
29
+ function createCancellationReceipt(job,evidence,instruction=null){
30
+ const e=job.agreement?.terms.escrow;
31
+ check(e&&evidence?.event==='Settled'&&[5,6,7].includes(evidence.state)&&/^0x[0-9a-fA-F]{64}$/.test(evidence.transaction_hash),'Confirmed closing transaction required');
32
+ check(evidence.chain_id===e.chain_id&&evidence.contract.toLowerCase()===e.contract.toLowerCase()&&evidence.token.toLowerCase()===e.token.toLowerCase()&&evidence.agreement_hash==='0x'+job.agreement.hash,'Closing evidence destination mismatch');
33
+ check(evidence.job_id==='0x'+createHash('sha256').update('a423:escrow:v1:'+job.id).digest('hex'),'Closing evidence job mismatch');
34
+ const amount=BigInt(e.amount),paid=BigInt(evidence.worker_paid);
35
+ check(evidence.amount===e.amount&&paid>=0n&&paid<=amount&&((evidence.state===5&&paid===amount)||(evidence.state===6&&paid===0n)||(evidence.state===7&&paid>0n&&paid<amount)),'Invalid actual allocation');
36
+ check(evidence.payer.toLowerCase()===e.payer.toLowerCase()&&evidence.worker.toLowerCase()===e.worker.toLowerCase(),'Closing evidence parties mismatch');
37
+ const reason=evidence.settlement_reason;check([0,1,2,3,4].includes(reason),'Unknown closing outcome');
38
+ const mutual=reason===4;
39
+ if(mutual&&instruction){validateCancellationInstruction(job,instruction);check(instruction.worker.amount===paid.toString()&&instruction.proposal_hash===evidence.proposal_hash&&instruction.escrow_version===evidence.escrow_version,'Instruction was not the executed allocation');}
40
+ const proof=Object.fromEntries(Object.entries(evidence).filter(([key])=>!['observed_at','instruction_id'].includes(key)));
41
+ const body={version:'a423.cancellation_receipt.v1',status:'confirmed',job_id:job.id,subflow_id:cancellationSubflowId(job),agreement_hash:'0x'+job.agreement.hash,outcome:['verified_payment','expiry_refund','arbitrated_split','timeout_allocation','mutual_cancellation'][reason],instruction_id:mutual&&instruction?instruction.id:null,prior_instruction_id:instruction?.id||null,allocations:{worker:{address:e.worker.toLowerCase(),amount:paid.toString()},payer:{address:e.payer.toLowerCase(),amount:(amount-paid).toString()}},remaining_locked:'0',evidence:proof};
42
+ return {id:'cancel_receipt_'+hashAgreement(body),...body};
43
+ }
44
+ function validateCancellationReceipt(job,receipt){
45
+ check(receipt?.version==='a423.cancellation_receipt.v1','Final cancellation receipt required');
46
+ const instruction=getCancellationSubflow(job)?.instruction||null;
47
+ const expected=createCancellationReceipt(job,receipt.evidence,instruction);
48
+ check(hashAgreement(expected)===hashAgreement(receipt),'Final cancellation receipt changed');return clone(receipt);
49
+ }
50
+ function updateCancellationSubflow(previous,next,{action,actor,phase,input={},evidence={},instruction,error,at}){
51
+ const old=getCancellationSubflow(previous);
52
+ if(!old&&!['propose_settlement','counter_settlement','approve_cancellation','execute_cancellation','accept_settlement'].includes(action))return;
53
+ const s=old||{id:cancellationSubflowId(previous),kind:'cancellation',parent_job_id:previous.id,status:'negotiating',started_at:at,updated_at:at,proposal_revision:previous.negotiation?.current?.escrow_version||0,instruction:null,receipt:null,events:[]};
54
+ if(phase==='requested'){
55
+ if(['propose_settlement','counter_settlement'].includes(action))s.status='proposal_pending';
56
+ if(action==='approve_cancellation')s.status='acceptance_pending';
57
+ if(action==='execute_cancellation')s.status='execution_pending';
58
+ }
59
+ if(phase==='pending'&&action==='execute_cancellation')s.execution_reference=evidence.reference||null;
60
+ if(phase==='rejected'){
61
+ s.status=action==='execute_cancellation'?'execution_failed':s.instruction?'accepted':previous.negotiation?.current?'negotiating':'proposal_rejected';s.last_error=error||'Operation rejected';
62
+ }
63
+ if(phase==='confirmed'){
64
+ if(['propose_settlement','counter_settlement'].includes(action)){s.status='negotiating';s.proposal_revision=evidence.escrow_version;s.instruction=null;s.last_error=null;}
65
+ if(action==='approve_cancellation'){s.status='accepted';s.instruction=clone(instruction);s.accepted_at=at;s.approved_by=[previous.participants.requester,previous.participants.worker];s.last_error=null;}
66
+ if(action==='dispute')s.status='disputed';
67
+ if(['execute_cancellation','accept_settlement','refund','resolve','timeout','release'].includes(action)&&evidence.event==='Settled'){
68
+ s.receipt=createCancellationReceipt(previous,evidence,s.instruction);s.status='completed';s.completed_at=at;s.execution_reference=evidence.transaction_hash;s.closing_action=action;s.last_error=null;
69
+ }
70
+ }
71
+ s.updated_at=at;s.events.push({sequence:s.events.length+1,type:action+'.'+phase,actor_id:actor,at,data:{proposal_hash:evidence.proposal_hash||previous.negotiation?.current?.proposal_hash||null,instruction_id:s.instruction?.id||null,receipt_id:s.receipt?.id||null,error:error||null}});
72
+ next.subflows=[...(next.subflows||previous.subflows||[]).filter(flow=>flow.id!==s.id),s];next.active_subflow_id=['completed','proposal_rejected'].includes(s.status)?null:s.id;
73
+ }
74
+ module.exports={cancellationSubflowId,getCancellationSubflow,createCancellationInstruction,validateCancellationInstruction,createCancellationReceipt,validateCancellationReceipt,updateCancellationSubflow};
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ const {Contract,TypedDataEncoder,verifyTypedData,getAddress}=require('ethers');
3
+ const {createCancellationInstruction,validateCancellationInstruction}=require('../cancellation.cjs');
4
+ function check(ok,message){if(!ok)throw new TypeError(message);}
5
+ function cancellationCall(instruction){
6
+ check(instruction?.version==='a423.cancellation_instruction.v1'&&instruction.authorization?.scheme==='eip712_closing_settlement_v1','Signed cancellation instruction required');
7
+ return {functionName:'settleByConsent',args:[{jobId:instruction.onchain_job_id,agreementHash:instruction.agreement_hash,escrowVersion:instruction.escrow_version,workerAmount:BigInt(instruction.worker.amount),payerAmount:BigInt(instruction.payer.amount),proposalHash:instruction.proposal_hash,expiresAt:instruction.expires_at},instruction.authorization.payer_signature,instruction.authorization.worker_signature]};
8
+ }
9
+ async function verifyCancellationAuthorization(job,instruction,{provider,chainId,contractAddress,tokenAddress}={}){
10
+ validateCancellationInstruction(job,instruction);
11
+ const {escrowTerms,ESCROW_ABI,settlementConsent}=require('./index.cjs');
12
+ const config={chainId,contractAddress,tokenAddress},terms=escrowTerms(job,config),typed=settlementConsent(job,config);
13
+ if(Number((await provider.getNetwork()).chainId)!==chainId)throw Error('RPC chain mismatch');
14
+ const escrow=new Contract(contractAddress,ESCROW_ABI,provider),block=await provider.getBlock('latest');
15
+ if(getAddress(await escrow.token())!==getAddress(tokenAddress))throw Error('Escrow token mismatch');
16
+ const snapshot=await escrow.getJob(terms.jobId,{blockTag:block.number});
17
+ const invalid=message=>({valid:false,reason:message});
18
+ for(const [key,value] of Object.entries(terms))if(String(snapshot.terms[key]).toLowerCase()!==String(value).toLowerCase())return invalid('On-chain agreement mismatch');
19
+ if(Number(snapshot.state)!==8)return invalid('Escrow is not negotiating');
20
+ const p=snapshot.proposal;
21
+ if(Number(p.revision)!==instruction.escrow_version||p.proposalHash!==instruction.proposal_hash||p.workerAmount.toString()!==instruction.worker.amount||Number(p.expiresAt)!==instruction.expires_at)return invalid('On-chain cancellation proposal changed');
22
+ const proposer=job.negotiation.current.proposer_id===job.participants.requester?terms.payer:job.negotiation.current.proposer_id===job.participants.worker?terms.worker:null;
23
+ if(!proposer||getAddress(p.proposer)!==getAddress(proposer))return invalid('Proposal owner mismatch');
24
+ if(block.timestamp>instruction.expires_at||block.timestamp>terms.resolutionDeadline)return invalid('Cancellation approval expired');
25
+ const digest=TypedDataEncoder.hash(typed.domain,typed.types,typed.value);
26
+ for(const [account,signature] of [[terms.payer,instruction.authorization.payer_signature],[terms.worker,instruction.authorization.worker_signature]]){
27
+ if(await provider.getCode(account,block.number)!=='0x'){
28
+ const wallet=new Contract(account,['function isValidSignature(bytes32,bytes) view returns(bytes4)'],provider);
29
+ try{if(await wallet.isValidSignature(digest,signature,{blockTag:block.number})!=='0x1626ba7e')return invalid('Invalid smart-wallet approval');}catch(error){if(error.code==='CALL_EXCEPTION')return invalid('Smart-wallet approval rejected');throw error;}
30
+ }else{try{if(getAddress(verifyTypedData(typed.domain,typed.types,typed.value,signature))!==getAddress(account))return invalid('Invalid party approval');}catch{return invalid('Invalid party approval');}}
31
+ }
32
+ return {valid:true,checked_block:block.number};
33
+ }
34
+ module.exports={createCancellationInstruction,validateCancellationInstruction,cancellationCall,verifyCancellationAuthorization};
@@ -6,6 +6,8 @@ const ABI = artifact.abi;
6
6
  const TOKEN_ABI = ['function allowance(address,address) view returns(uint256)','function approve(address,uint256) returns(bool)'];
7
7
  const TERMS_TYPES = { Terms: [ ['jobId','bytes32'],['payer','address'],['worker','address'],['verifier','address'],['arbitrator','address'],['amount','uint256'],['workDeadline','uint64'],['reviewDeadline','uint64'],['resolutionDeadline','uint64'],['timeoutWorkerBps','uint16'],['agreementHash','bytes32'] ].map(([name,type])=>({name,type})) };
8
8
  const transitions = {
9
+ approve_cancellation: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
10
+ execute_cancellation: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settled',effect:true},
9
11
  propose_settlement: {from:['funded','running','submitted','verified'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
10
12
  counter_settlement: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
11
13
  accept_settlement: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settled',effect:true},
@@ -128,6 +130,36 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
128
130
  }
129
131
  const proposeAdapter=adapter(async(ctx,r)=>{ctx.settlementActorAddress=getAddress(await addressFor(ctx.operation.actor));return propose(ctx,r);});
130
132
  return {
133
+ approve_cancellation:adapter(async ctx=>{
134
+ await context(ctx.job);const p=ctx.job.negotiation?.current;
135
+ if(!p||ctx.input.expected_revision!==p.escrow_version||ctx.input.proposal_hash!==p.proposal_hash)return {status:'rejected',reason:'Approve the exact current proposal'};
136
+ const {createCancellationInstruction,verifyCancellationAuthorization}=require('./cancellation.cjs');let instruction;
137
+ try{instruction=createCancellationInstruction(ctx.job,ctx.input);}catch(error){return {status:'rejected',reason:error.message};}
138
+ const verified=await verifyCancellationAuthorization(ctx.job,instruction,{provider,...config});
139
+ if(!verified.valid)return {status:'rejected',reason:verified.reason};
140
+ return {status:'confirmed',evidence:{kind:'a423.cancellation_acceptance.v1',instruction,checked_block:verified.checked_block,funds_moved:false}};
141
+ }),
142
+ execute_cancellation:adapter(async(ctx,r)=>{
143
+ const t=await context(ctx.job);const {getCancellationSubflow,validateCancellationInstruction}=require('../cancellation.cjs');
144
+ const instruction=getCancellationSubflow(ctx.job)?.instruction;
145
+ if(!instruction||ctx.input.instruction_id!==instruction.id)return {status:'rejected',reason:'Accepted cancellation instruction required'};
146
+ try{validateCancellationInstruction(ctx.job,instruction);}catch(error){return {status:'rejected',reason:error.message};}
147
+ const {verifyCancellationAuthorization,cancellationCall}=require('./cancellation.cjs');
148
+ if(!ctx.input.transaction_hash&&!await transactions.get(ctx.operation.id+':execute_cancellation')){
149
+ const verified=await verifyCancellationAuthorization(ctx.job,instruction,{provider,...config});if(!verified.valid)return {status:'rejected',reason:verified.reason};
150
+ }
151
+ const call=cancellationCall(instruction);
152
+ const result=await observe(ctx,t,'execute_cancellation',call.functionName,call.args,'Settled',(s,e)=>{
153
+ settled(s,e,t);check(Number(e.args.reason)===4&&e.args.workerAmount.toString()===instruction.worker.amount&&e.args.payerAmount.toString()===instruction.payer.amount&&Number(s.proposal.revision)===instruction.escrow_version&&s.proposal.proposalHash===instruction.proposal_hash,'Instruction allocation mismatch');
154
+ },r);
155
+ if(result.status==='confirmed'){
156
+ const receipt=await provider.getTransactionReceipt(result.evidence.transaction_hash);
157
+ const accepted=receipt.logs.filter(l=>getAddress(l.address)===config.contractAddress).map(l=>{try{return iface.parseLog(l);}catch{return null;}}).find(e=>e?.name==='SettlementAccepted'&&e.args.jobId===t.jobId);
158
+ check(accepted&&Number(accepted.args.revision)===instruction.escrow_version&&accepted.args.proposalHash===instruction.proposal_hash,'Instruction acceptance event missing');
159
+ result.evidence.instruction_id=instruction.id;
160
+ }
161
+ return result;
162
+ }),
131
163
  propose_settlement:proposeAdapter,
132
164
  counter_settlement:proposeAdapter,
133
165
  accept_settlement:adapter(async(ctx,r)=>{
@@ -180,4 +212,4 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
180
212
  }
181
213
  module.exports={createEscrowAdapters,createMemoryTransactionStore,workerConsent,escrowTerms,escrowTransitions:transitions,ESCROW_ABI:ABI,ESCROW_BYTECODE:artifact.bytecode};
182
214
 
183
- Object.assign(module.exports,require('./services.cjs'),require('./negotiation.cjs'));
215
+ Object.assign(module.exports,require('./services.cjs'),require('./negotiation.cjs'),require('./cancellation.cjs'));
@@ -27,7 +27,7 @@ export interface EscrowAdapterOptions extends EscrowConfiguration {
27
27
  confirmations: number;
28
28
  validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
29
29
  }
30
- export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout'|'propose_settlement'|'counter_settlement'|'accept_settlement', EscrowAdapter>;
30
+ export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout'|'propose_settlement'|'counter_settlement'|'accept_settlement'|'approve_cancellation'|'execute_cancellation', EscrowAdapter>;
31
31
  export function createMemoryTransactionStore(): TransactionStore;
32
32
  export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
33
33
  export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
@@ -49,3 +49,7 @@ export function createEscrowIndexer(options: {provider: Provider;chainId: number
49
49
 
50
50
  export function settlementProposal(job: WorkflowJob,input: {worker_amount: string;payer_amount?: string;expected_revision: number;expires_at: number;reason: string;claims?: import('../workflow.js').SettlementClaim[]}): import('../workflow.js').SettlementProposal;
51
51
  export function settlementConsent(job: WorkflowJob,configuration: EscrowConfiguration): {domain: {name: string;version: string;chainId: number;verifyingContract: string};types: Record<string,TypedDataField[]>;value: {jobId: string;agreementHash: string;escrowVersion: number;workerAmount: bigint;payerAmount: bigint;proposalHash: string;expiresAt: number}};
52
+
53
+ export {createCancellationInstruction,validateCancellationInstruction} from '../workflow.js';
54
+ export function cancellationCall(instruction: import('../workflow.js').CancellationInstruction): {functionName: 'settleByConsent';args: [ReturnType<typeof settlementConsent>['value'],string,string]};
55
+ export function verifyCancellationAuthorization(job: WorkflowJob,instruction: import('../workflow.js').CancellationInstruction,options: EscrowConfiguration & {provider: Provider}): Promise<{valid: boolean;checked_block?: number;reason?: string}>;
@@ -27,7 +27,7 @@ export interface EscrowAdapterOptions extends EscrowConfiguration {
27
27
  confirmations: number;
28
28
  validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
29
29
  }
30
- export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout'|'propose_settlement'|'counter_settlement'|'accept_settlement', EscrowAdapter>;
30
+ export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout'|'propose_settlement'|'counter_settlement'|'accept_settlement'|'approve_cancellation'|'execute_cancellation', EscrowAdapter>;
31
31
  export function createMemoryTransactionStore(): TransactionStore;
32
32
  export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
33
33
  export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
@@ -49,3 +49,7 @@ export function createEscrowIndexer(options: {provider: Provider;chainId: number
49
49
 
50
50
  export function settlementProposal(job: WorkflowJob,input: {worker_amount: string;payer_amount?: string;expected_revision: number;expires_at: number;reason: string;claims?: import('../workflow.js').SettlementClaim[]}): import('../workflow.js').SettlementProposal;
51
51
  export function settlementConsent(job: WorkflowJob,configuration: EscrowConfiguration): {domain: {name: string;version: string;chainId: number;verifyingContract: string};types: Record<string,TypedDataField[]>;value: {jobId: string;agreementHash: string;escrowVersion: number;workerAmount: bigint;payerAmount: bigint;proposalHash: string;expiresAt: number}};
52
+
53
+ export {createCancellationInstruction,validateCancellationInstruction} from '../workflow.js';
54
+ export function cancellationCall(instruction: import('../workflow.js').CancellationInstruction): {functionName: 'settleByConsent';args: [ReturnType<typeof settlementConsent>['value'],string,string]};
55
+ export function verifyCancellationAuthorization(job: WorkflowJob,instruction: import('../workflow.js').CancellationInstruction,options: EscrowConfiguration & {provider: Provider}): Promise<{valid: boolean;checked_block?: number;reason?: string}>;
@@ -1,2 +1,2 @@
1
1
  import escrow from './index.cjs';
2
- export const { createEscrowAdapters, createMemoryTransactionStore, workerConsent, escrowTerms, escrowTransitions, ESCROW_ABI, ESCROW_BYTECODE, createEscrowRegistry, createEvidenceRevalidator, createManagedSigner, createDelegationAuthorizer, replacePendingTransaction, createFeeReplacementWorker, createEscrowIndexer, settlementProposal, settlementConsent } = escrow;
2
+ export const { createEscrowAdapters, createMemoryTransactionStore, workerConsent, escrowTerms, escrowTransitions, ESCROW_ABI, ESCROW_BYTECODE, createEscrowRegistry, createEvidenceRevalidator, createManagedSigner, createDelegationAuthorizer, replacePendingTransaction, createFeeReplacementWorker, createEscrowIndexer, settlementProposal, settlementConsent, createCancellationInstruction, validateCancellationInstruction, cancellationCall, verifyCancellationAuthorization } = escrow;
@@ -5,7 +5,7 @@ function check(ok,message){if(!ok)throw new TypeError(message);}
5
5
  const {settlementProposal}=require('../closing.cjs');
6
6
  function settlementConsent(job,configuration){
7
7
  const {escrowTerms}=require('./index.cjs');const t=escrowTerms(job,configuration),p=job.negotiation?.current;
8
- check(p&&job.negotiation.status==='open','Open settlement proposal required');
8
+ check(p&&['open','accepted'].includes(job.negotiation.status),'Open settlement proposal required');
9
9
  const canonical=settlementProposal(job,{worker_amount:p.worker_amount,payer_amount:p.payer_amount,expected_revision:p.escrow_version-1,expires_at:p.expires_at,reason:p.reason,claims:p.claims});
10
10
  check(canonical.proposal_hash===p.proposal_hash,'Stored proposal claims or allocation changed');
11
11
  return {domain:{name:'A423Escrow',version:'1',chainId:configuration.chainId,verifyingContract:getAddress(configuration.contractAddress)},types:TYPES,value:{jobId:t.jobId,agreementHash:t.agreementHash,escrowVersion:p.escrow_version,workerAmount:BigInt(p.worker_amount),payerAmount:BigInt(p.payer_amount),proposalHash:p.proposal_hash,expiresAt:p.expires_at}};
@@ -5,7 +5,7 @@ const {validateEscrowEvidence}=require('../operations/index.cjs');
5
5
  function check(ok,message){if(!ok)throw Error(message);}
6
6
  function createEscrowRegistry(configurations){
7
7
  const map=new Map();for(const c of configurations){const key=c.chainId+':'+getAddress(c.contractAddress).toLowerCase();check(!map.has(key),'Duplicate escrow configuration');map.set(key,createEscrowAdapters(c));}
8
- const actions=['fund','start','submit','verify','release','refund','dispute','resolve','timeout','propose_settlement','counter_settlement','accept_settlement'];const result={};for(const action of actions){result[action]={};for(const method of ['execute','reconcile'])result[action][method]=ctx=>{const e=ctx.job.agreement?.terms.escrow;const adapter=map.get(e?.chain_id+':'+getAddress(e?.contract).toLowerCase());check(adapter,'Escrow not registered');return adapter[action][method](ctx);};}return result;
8
+ const actions=['fund','start','submit','verify','release','refund','dispute','resolve','timeout','propose_settlement','counter_settlement','accept_settlement','approve_cancellation','execute_cancellation'];const result={};for(const action of actions){result[action]={};for(const method of ['execute','reconcile'])result[action][method]=ctx=>{const e=ctx.job.agreement?.terms.escrow;const adapter=map.get(e?.chain_id+':'+getAddress(e?.contract).toLowerCase());check(adapter,'Escrow not registered');return adapter[action][method](ctx);};}return result;
9
9
  }
10
10
  /** Fresh historical evidence verification. Returns invalid, insufficient_confirmations, or valid; RPC failures throw. */
11
11
  function createEvidenceRevalidator({provider,chainId,contractAddress,tokenAddress,confirmations}={}){
@@ -8,13 +8,16 @@ function createChatTools({workflow,store,queue,actor}={}){
8
8
  let job=await workflow.create({id,participants:{requester:actor,worker:worker_id,verifier:verifier_id,arbitrator:arbitrator_id},metadata},actor);
9
9
  if(terms)job=await workflow.act(job.id,'quote',{terms},{actor,idempotencyKey:'initial_quote'});return job;
10
10
  },
11
- propose_quote:act('quote'),accept_quote:act('accept'),fund_escrow:act('fund'),submit_job:act('submit'),verify_job:act('verify'),release_escrow:act('release'),refund_escrow:act('refund'),dispute_job:act('dispute'),resolve_job:act('resolve'),timeout_job:act('timeout'),propose_settlement:act('propose_settlement'),counter_settlement:act('counter_settlement'),accept_settlement:act('accept_settlement'),request_cancellation:act('propose_settlement'),cancel_job:act('cancel'),
11
+ propose_quote:act('quote'),accept_quote:act('accept'),fund_escrow:act('fund'),submit_job:act('submit'),verify_job:act('verify'),release_escrow:act('release'),refund_escrow:act('refund'),dispute_job:act('dispute'),resolve_job:act('resolve'),timeout_job:act('timeout'),propose_settlement:act('propose_settlement'),counter_settlement:act('counter_settlement'),accept_settlement:act('accept_settlement'),accept_cancellation:async({job_id,input={},idempotency_key})=>workflow.acceptCancellation(job_id,input,{actor,idempotencyKey:idempotency_key}),execute_cancellation:act('execute_cancellation'),request_cancellation:act('propose_settlement'),cancel_job:act('cancel'),
12
12
  work_on_job:async(input)=>{const job=await act('start')(input);if(!job.pending&&job.state==='running')await enqueueRunningJobs({store,queue});return job;}
13
13
  };
14
14
  }
15
- function createApplicationHandler({workflow,store,queue,authenticate,revalidate,consentFor,settlementConsentFor,legacyState=async()=>({}),authorityHandler,basePath='/api/a423'}={}){
15
+ function createApplicationHandler({workflow,store,queue,authenticate,revalidate,consentFor,settlementConsentFor,legacyState=async()=>({}),authorityHandler,forwardCancellations=true,basePath='/api/a423'}={}){
16
16
  const {createEventStream,freshReceipts,renderReceiptPdf}=require('./index.cjs');
17
- const protocol=createWorkflowHandler({workflow,authenticate,basePath});const events=createEventStream({store,authenticate});
17
+ const protocol=createWorkflowHandler({workflow,authenticate,basePath,forwardCancellations,revalidateCancellationReceipt:async({job,receipt})=>{
18
+ const {validateCancellationReceipt}=require('../cancellation.cjs');validateCancellationReceipt(job,receipt);
19
+ try{const check=await revalidate(job,receipt.evidence);if(check.status!=='valid')throw Error('Evidence is not valid');}catch{const {A423Error}=require('../index.cjs');throw new A423Error('EVIDENCE_UNAVAILABLE','Cancellation receipt evidence is unavailable or changed',503);}
20
+ }});const events=createEventStream({store,authenticate});
18
21
  return async request=>{
19
22
  const path=new URL(request.url).pathname;
20
23
  if(path.startsWith('/api/a423-auth/')&&authorityHandler)return authorityHandler(request);
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  const {createWorkflow,hashAgreement}=require('../workflow.cjs');
3
3
  const {createCredentialAuthenticator,createAuthorityHandler}=require('../authority/index.cjs');
4
- function createEscrowApplication({storage,authority,escrows,signerFor,addressFor,limitsFor,approveSigning,validateArtifact,execute,keeperEnabled=false,allowAuthorityRequest=async()=>false,requireIndependentArbitrator=true}={}){
4
+ function createEscrowApplication({storage,authority,escrows,signerFor,addressFor,limitsFor,approveSigning,validateArtifact,execute,keeperEnabled=false,forwardCancellations=true,allowAuthorityRequest=async()=>false,requireIndependentArbitrator=true}={}){
5
5
  const {createEscrowRegistry,createEvidenceRevalidator,escrowTerms,workerConsent,settlementConsent,escrowTransitions}=require('../escrow/index.cjs');
6
6
  const {validateTask,validateEscrowEvidence,createSpendingAuthorizer,createExpiryAdapter,createApplicationHandler,createChatTools}=require('./index.cjs');
7
7
  if(!storage||!authority||!Array.isArray(escrows)||!escrows.length||typeof approveSigning!=='function'||typeof limitsFor!=='function')throw new TypeError('Explicit storage, authority, escrow configurations and spending/signing approvals required');
@@ -24,7 +24,7 @@ function createEscrowApplication({storage,authority,escrows,signerFor,addressFor
24
24
  const validators=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),createEvidenceRevalidator(c)]));
25
25
  const revalidate=(job,evidence)=>validators.get(String(configFor(job).chainId)+':'+configFor(job).contractAddress.toLowerCase())(job,evidence);
26
26
  const authenticate=createCredentialAuthenticator({authority});
27
- const applicationOptions={workflow,store:storage.jobs,queue:storage.queue,authenticate,revalidate,consentFor:async job=>workerConsent(job,configFor(job)),settlementConsentFor:async job=>settlementConsent(job,configFor(job)),authorityHandler:createAuthorityHandler({authority,allowRequest:allowAuthorityRequest})};
27
+ const applicationOptions={forwardCancellations,workflow,store:storage.jobs,queue:storage.queue,authenticate,revalidate,consentFor:async job=>workerConsent(job,configFor(job)),settlementConsentFor:async job=>settlementConsent(job,configFor(job)),authorityHandler:createAuthorityHandler({authority,allowRequest:allowAuthorityRequest})};
28
28
  const workerOptions={authority,actorFor:async job=>job.pending?.actor||job.participants.requester,allowExecute:keeperEnabled,
29
29
  execute,beforeExecute:async job=>{await workflow.assertSafe(job.id,job.participants.worker);if(!await authority.isActive(job.participants.worker))return false;const result=await adapters.start.execute({job,input:{},operation:{actor:job.participants.worker}});return result.status==='confirmed';}
30
30
  };
@@ -39,6 +39,9 @@ async function runMaintenanceOnce({workflow,store,actorFor,allowExecute=false,no
39
39
  const actor=await actorFor(job);if(!actor)continue;
40
40
  let result=job;
41
41
  if(job.pending){result=await workflow.reconcile(job.id,actor);if(result.pending&&allowExecute)result=await workflow.resume(job.id,actor);}
42
+ else if((job.subflows||[]).some(s=>s.kind==='cancellation'&&s.status==='accepted')&&allowExecute){
43
+ const subflow=job.subflows.find(s=>s.kind==='cancellation');result=await workflow.act(job.id,'execute_cancellation',{instruction_id:subflow.instruction.id},{actor,idempotencyKey:'cancel_forward_'+hash(JSON.stringify(subflow.instruction.id))});
44
+ }
42
45
  else if(job.agreement){const e=job.agreement.terms.escrow;const seconds=Math.floor(now/1000);let action;
43
46
  if(e&&(['funded','running'].includes(job.state)||(['settlement_negotiation','disputed'].includes(job.state)&&!job.submission))&&seconds>e.work_deadline)action='timeout';
44
47
  if(e&&['submitted','verified','disputed','settlement_negotiation'].includes(job.state)&&seconds>e.resolution_deadline)action='timeout';
@@ -104,3 +107,10 @@ module.exports.migrateUnfundedLegacy=migrateUnfundedLegacy;
104
107
  Object.assign(module.exports,require('./application.cjs'));
105
108
 
106
109
  Object.assign(module.exports,require('./factory.cjs'));
110
+
111
+ async function freshCancellationReceipt({workflow,id,actor,revalidate}){
112
+ const job=await workflow.get(id,actor),receipt=await workflow.cancellationReceipt(id,actor);
113
+ require('../cancellation.cjs').validateCancellationReceipt(job,receipt);
114
+ const check=await revalidate(job,receipt.evidence);assert(check.status==='valid','Cancellation receipt evidence is not currently valid');return receipt;
115
+ }
116
+ module.exports.freshCancellationReceipt=freshCancellationReceipt;
@@ -24,6 +24,8 @@ export function renderReceiptPdf(receipt: object): Promise<Uint8Array>;
24
24
  export function planLegacyMigration(records: Array<{id: string;state: string}>): Array<{id: string;state: string;automatic: false;reason: string}>;
25
25
  export function migrateUnfundedLegacy(options: {records: Array<{id: string;state: string}>;workflow: Workflow;actor: string;map(source: any): Promise<Parameters<Workflow['create']>[0]>}): Promise<WorkflowJob[]>;
26
26
  export function createChatTools(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;actor: string}): Record<string,(input: any) => Promise<WorkflowJob>>;
27
- export function createApplicationHandler(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;authenticate(request: Request): Promise<string | null>;revalidate: Revalidator;consentFor?(job: WorkflowJob): Promise<object>;settlementConsentFor?(job: WorkflowJob): Promise<object>;legacyState?(actor: string): Promise<object>;authorityHandler?(request: Request): Promise<Response>;basePath?: string}): (request: Request) => Promise<Response>;
27
+ export function createApplicationHandler(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;authenticate(request: Request): Promise<string | null>;revalidate: Revalidator;consentFor?(job: WorkflowJob): Promise<object>;settlementConsentFor?(job: WorkflowJob): Promise<object>;legacyState?(actor: string): Promise<object>;authorityHandler?(request: Request): Promise<Response>;forwardCancellations?: boolean;basePath?: string}): (request: Request) => Promise<Response>;
28
28
 
29
- export function createEscrowApplication(options: {storage: import('../storage/index.js').SqliteStorage;authority: import('../authority/index.js').Authority;escrows: Array<any>;signerFor(actor: string): Promise<any>;addressFor(actor: string): Promise<string>;limitsFor(context: SigningContext): Promise<{perTransaction: string;daily: string;total: string}>;approveSigning(context: SigningContext): Promise<boolean>;validateArtifact(context: {job: WorkflowJob;resultHash: string}): Promise<boolean>;execute?(context: {job: WorkflowJob;idempotencyKey: string;signal: AbortSignal}): Promise<{result_hash: string;[key: string]: Json}>;keeperEnabled?: boolean;allowAuthorityRequest?(request: Request): Promise<boolean>;requireIndependentArbitrator?: boolean}): {workflow: Workflow;applicationOptions: Parameters<typeof createApplicationHandler>[0];workerOptions: any;handler: (request: Request) => Promise<Response>;chatTools(actor: string): ReturnType<typeof createChatTools>};
29
+ export function createEscrowApplication(options: {storage: import('../storage/index.js').SqliteStorage;authority: import('../authority/index.js').Authority;escrows: Array<any>;signerFor(actor: string): Promise<any>;addressFor(actor: string): Promise<string>;limitsFor(context: SigningContext): Promise<{perTransaction: string;daily: string;total: string}>;approveSigning(context: SigningContext): Promise<boolean>;validateArtifact(context: {job: WorkflowJob;resultHash: string}): Promise<boolean>;execute?(context: {job: WorkflowJob;idempotencyKey: string;signal: AbortSignal}): Promise<{result_hash: string;[key: string]: Json}>;keeperEnabled?: boolean;forwardCancellations?: boolean;allowAuthorityRequest?(request: Request): Promise<boolean>;requireIndependentArbitrator?: boolean}): {workflow: Workflow;applicationOptions: Parameters<typeof createApplicationHandler>[0];workerOptions: any;handler: (request: Request) => Promise<Response>;chatTools(actor: string): ReturnType<typeof createChatTools>};
30
+
31
+ export function freshCancellationReceipt(options: {workflow: Workflow;id: string;actor: string;revalidate: Revalidator}): Promise<import('../workflow.js').CancellationReceipt>;
@@ -1,2 +1,2 @@
1
1
  import api from './index.cjs';
2
- export const { validateTask, validateEscrowEvidence, createSpendingAuthorizer, runMaintenanceOnce, createExpiryAdapter, runExecutionOnce, enqueueRunningJobs, createEventStream, createWebhookDispatcher, watchEvidenceOnce, freshReceipts, renderReceiptPdf, planLegacyMigration, migrateUnfundedLegacy, createChatTools, createApplicationHandler, createEscrowApplication } = api;
2
+ export const { validateTask, validateEscrowEvidence, createSpendingAuthorizer, runMaintenanceOnce, createExpiryAdapter, runExecutionOnce, enqueueRunningJobs, createEventStream, createWebhookDispatcher, watchEvidenceOnce, freshReceipts, renderReceiptPdf, planLegacyMigration, migrateUnfundedLegacy, createChatTools, createApplicationHandler, createEscrowApplication, freshCancellationReceipt } = api;
package/src/workflow.cjs CHANGED
@@ -62,11 +62,13 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
62
62
  canonical(result);
63
63
  if (result.status === 'pending') {
64
64
  const next = copy(job); next.pending.observation = copy(result);
65
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'pending',evidence:result,at:stamp()});
65
66
  return save(job, next, pending.action + '.pending', pending.actor, result);
66
67
  }
67
68
  if (result.status === 'rejected') {
68
69
  const next = copy(job); next.pending = null;
69
70
  next.operations[pending.key] = { request_hash: pending.request_hash, status: 'rejected', result: copy(result) };
71
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'rejected',error:result.reason,at:stamp()});
70
72
  return save(job, next, pending.action + '.rejected', pending.actor, result);
71
73
  }
72
74
  if (!result.evidence || typeof result.evidence !== 'object' || Array.isArray(result.evidence)) error('INVALID_ADAPTER_RESULT', 'Confirmed effects require structured evidence', 502);
@@ -83,7 +85,12 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
83
85
  next.negotiation={status:'open',origin_state:job.negotiation?.origin_state||job.state,current:record,history:[...(job.negotiation?.history||[]),record]};
84
86
  next.work_status='paused';eventData={...eventData,proposal:record};
85
87
  }
86
- if(pending.action==='accept_settlement'){
88
+ if(pending.action==='approve_cancellation'){
89
+ const instruction=require('./cancellation.cjs').validateCancellationInstruction(job,result.evidence.instruction);
90
+ next.negotiation={...copy(job.negotiation),status:'accepted',acceptance_basis:'dual_wallet_signatures'};next.work_status='paused';
91
+ eventData={...eventData,instruction_id:instruction.id};
92
+ }
93
+ if(['accept_settlement','execute_cancellation'].includes(pending.action)){
87
94
  const p=job.negotiation?.current;
88
95
  if(!p || result.evidence.settlement_reason!==4 || result.evidence.worker_paid!==p.worker_amount || result.evidence.proposal_hash!==p.proposal_hash || result.evidence.escrow_version!==p.escrow_version)error('INVALID_ADAPTER_RESULT','Closing settlement evidence mismatch',502);
89
96
  next.negotiation={...copy(job.negotiation),status:'settled',accepted_by:pending.actor,settled_at:stamp()};next.work_status='stopped';
@@ -99,6 +106,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
99
106
  const closed=[5,6,7].includes(e.state);
100
107
  next.escrow_ledger={funded_amount:e.amount,worker_paid:e.worker_paid,payer_refunded:closed?(BigInt(e.amount)-BigInt(e.worker_paid)).toString():'0',locked_amount:closed?'0':e.amount,escrow_version:e.escrow_version??0};
101
108
  }
109
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'confirmed',evidence:result.evidence,instruction:result.evidence.instruction,at:stamp()});
102
110
  return save(job, next, pending.action + '.confirmed', pending.actor, eventData);
103
111
  }
104
112
  async function execute(job, reconcile = false) {
@@ -110,7 +118,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
110
118
  const result = await fn({ job: copy(job), input: copy(pending.input), operation: copy(pending) });
111
119
  return complete(job, result);
112
120
  }
113
- return {
121
+ const workflow={
114
122
  async create({ id = randomUUID(), participants, metadata = {} }, actor) {
115
123
  identifier(id); identifier(actor);
116
124
  if (!participants || !participants.requester || !participants.worker || !participants.verifier) throw new TypeError('Supply requester, worker and verifier participants');
@@ -118,7 +126,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
118
126
  if (participants.requester === participants.worker || actor !== participants.requester) error('FORBIDDEN', 'Requester must create the job and differ from worker', 403);
119
127
  canonical(metadata); canonical(participants);
120
128
  const at = stamp();
121
- const job = { work_status:'not_started',negotiation:null,escrow_ledger:null,id, protocol: 'a423.workflow.v1', version: 1, state: 'draft', participants: copy(participants), metadata: copy(metadata), agreement: null, submission: null, pending: null, operations: {}, evidence: [], events: [{ sequence: 1, type: 'created', actor_id: actor, at, data: {} }], created_at: at, updated_at: at };
129
+ const job = { subflows:[],active_subflow_id:null,work_status:'not_started',negotiation:null,escrow_ledger:null,id, protocol: 'a423.workflow.v1', version: 1, state: 'draft', participants: copy(participants), metadata: copy(metadata), agreement: null, submission: null, pending: null, operations: {}, evidence: [], events: [{ sequence: 1, type: 'created', actor_id: actor, at, data: {} }], created_at: at, updated_at: at };
122
130
  if (authorize && await authorize({ job: copy(job), action: 'create', actor, input: {} }) !== true) error('FORBIDDEN', 'Application policy rejected creation', 403);
123
131
  if (!await store.create(job)) error('CONFLICT', 'Job ID already exists');
124
132
  return copy(job);
@@ -143,6 +151,9 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
143
151
  if (job.pending.key !== key || job.pending.request_hash !== requestHash) error('OPERATION_PENDING', 'Reconcile or retry the pending operation first');
144
152
  return execute(job);
145
153
  }
154
+ const cancellation=require('./cancellation.cjs').getCancellationSubflow(job);
155
+ if(['propose_settlement','counter_settlement'].includes(action)&&cancellation?.instruction)error('CANCELLATION_ACCEPTED','An accepted instruction must be executed or resolved before renegotiating',409);
156
+ if(action==='approve_cancellation'&&cancellation?.instruction){const candidate=require('./cancellation.cjs').createCancellationInstruction(job,input);if(input.expected_revision!==job.negotiation.current.escrow_version||input.proposal_hash!==job.negotiation.current.proposal_hash||hashAgreement(candidate)!==hashAgreement(cancellation.instruction))error('CANCELLATION_CONFLICT','Another instruction is already accepted',409);return job;}
146
157
  if (!rule.from.includes(job.state)) error('INVALID_STATE', `Cannot ${action} from ${job.state}`);
147
158
  let next = copy(job);
148
159
  if (action === 'quote') {
@@ -157,6 +168,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
157
168
  if (rule.effect) {
158
169
  if (!adapters[action]) error('ADAPTER_REQUIRED', `Configure ${action} adapter`, 501);
159
170
  next.pending = { id: `a423:${id}:${key}`, key, action, actor, input: copy(input), request_hash: requestHash, target: rule.to, created_at: stamp(), observation: null };
171
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action,actor,phase:'requested',input,at:stamp()});
160
172
  job = await save(job, next, action + '.requested', actor);
161
173
  return execute(job);
162
174
  }
@@ -178,13 +190,25 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
178
190
  await allowed(job, job.pending.action, actor, job.pending.input, rules[job.pending.action]);
179
191
  return execute(job, true);
180
192
  },
193
+ async cancellation(id,actor){return require('./cancellation.cjs').getCancellationSubflow(await get(id,actor));},
194
+ async cancellationInstruction(id,actor){const job=await get(id,actor),subflow=require('./cancellation.cjs').getCancellationSubflow(job);if(!subflow?.instruction)error('INSTRUCTION_UNAVAILABLE','No accepted cancellation instruction',409);return require('./cancellation.cjs').validateCancellationInstruction(job,subflow.instruction);},
195
+ async cancellationReceipt(id,actor){const job=await get(id,actor),subflow=require('./cancellation.cjs').getCancellationSubflow(job);if(subflow?.status!=='completed'||!subflow.receipt)error('RECEIPT_UNAVAILABLE','Cancellation payment is not confirmed',409);return require('./cancellation.cjs').validateCancellationReceipt(job,subflow.receipt);},
196
+ async acceptCancellation(id,input,{actor,idempotencyKey,forward=true}={}){
197
+ if(typeof forward!=='boolean')throw new TypeError('forward must be a boolean');
198
+ let job=await workflow.act(id,'approve_cancellation',input,{actor,idempotencyKey});
199
+ const subflow=require('./cancellation.cjs').getCancellationSubflow(job);
200
+ if(!forward||job.pending||!subflow?.instruction||subflow.status!=='accepted')return job;
201
+ return workflow.act(id,'execute_cancellation',{instruction_id:subflow.instruction.id},{actor,idempotencyKey:'cancel_forward_'+digest(subflow.instruction.id)});
202
+ },
181
203
  async receipts(id, actor) {
182
204
  const job = await get(id, actor);
183
- return { protocol: job.protocol, job_id: id, state: job.state, agreement: job.agreement, submission: job.submission, work_status:job.work_status,negotiation:job.negotiation,escrow_ledger:job.escrow_ledger,evidence: job.evidence, events: job.events, pending: job.pending };
205
+ return { protocol: job.protocol, job_id: id, state: job.state, agreement: job.agreement, submission: job.submission, subflows:job.subflows||[],active_subflow_id:job.active_subflow_id||null,work_status:job.work_status,negotiation:job.negotiation,escrow_ledger:job.escrow_ledger,evidence: job.evidence, events: job.events, pending: job.pending };
184
206
  }
185
207
  };
208
+ return workflow;
186
209
  }
187
- function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError } = {}) {
210
+ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError, forwardCancellations = true, revalidateCancellationReceipt } = {}) {
211
+ if(typeof forwardCancellations!=='boolean')throw new TypeError('forwardCancellations must be a boolean');
188
212
  if (!workflow || typeof authenticate !== 'function' || !/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(basePath)) throw new TypeError('Supply workflow, authenticate and valid basePath');
189
213
  if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || maxBodyBytes > 1048576) throw new TypeError('Invalid maxBodyBytes');
190
214
  const respond = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
@@ -192,10 +216,10 @@ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423',
192
216
  try {
193
217
  const path = new URL(request.url).pathname;
194
218
  if (!path.startsWith(basePath + '/')) error('NOT_FOUND', 'Route not found', 404);
195
- const match = /^\/jobs(?:\/([A-Za-z0-9_-]{1,128})(?:\/(actions|reconcile|receipts))?)?$/.exec(path.slice(basePath.length));
219
+ const match = /^\/jobs(?:\/([A-Za-z0-9_-]{1,128})(?:\/(actions|reconcile|receipts|subflows|cancellation(?:\/(?:accept|execute|instruction|receipt))?))?)?$/.exec(path.slice(basePath.length));
196
220
  if (!match) error('NOT_FOUND', 'Route not found', 404);
197
221
  const [, id, action] = match;
198
- const method = !id || action === 'actions' || action === 'reconcile' ? 'POST' : 'GET';
222
+ const method = !id || ['actions','reconcile','cancellation/accept','cancellation/execute'].includes(action) ? 'POST' : 'GET';
199
223
  if (request.method !== method) { const r = respond({ error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method}` } }, 405); r.headers.set('allow', method); return r; }
200
224
  const actor = await authenticate(request);
201
225
  if (!actor) error('UNAUTHENTICATED', 'Authentication required', 401);
@@ -208,6 +232,16 @@ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423',
208
232
  try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { error('INVALID_INPUT', 'Invalid JSON', 400); }
209
233
  }
210
234
  if (!id) return respond({ job: await workflow.create(body, actor) }, 201);
235
+ if(action==='subflows'){const job=await workflow.get(id,actor);return respond({job_id:id,subflows:job.subflows||[],active_subflow_id:job.active_subflow_id||null});}
236
+ if(action==='cancellation')return respond({job_id:id,subflow:await workflow.cancellation(id,actor)});
237
+ if(action==='cancellation/instruction')return respond({instruction:await workflow.cancellationInstruction(id,actor)});
238
+ if(action==='cancellation/receipt'){
239
+ const receipt=await workflow.cancellationReceipt(id,actor);
240
+ if(revalidateCancellationReceipt)await revalidateCancellationReceipt({job:await workflow.get(id,actor),receipt,actor});
241
+ return respond({receipt});
242
+ }
243
+ if(action==='cancellation/accept')return respond({job:await workflow.acceptCancellation(id,body,{actor,idempotencyKey:request.headers.get('idempotency-key'),forward:forwardCancellations})});
244
+ if(action==='cancellation/execute')return respond({job:await workflow.act(id,'execute_cancellation',body,{actor,idempotencyKey:request.headers.get('idempotency-key')})});
211
245
  if (action === 'actions') return respond({ job: await workflow.act(id, body.action, body.input ?? {}, { actor, idempotencyKey: request.headers.get('idempotency-key') }) });
212
246
  if (action === 'reconcile') return respond({ job: await workflow.reconcile(id, actor) });
213
247
  if (action === 'receipts') return respond(await workflow.receipts(id, actor));
@@ -240,12 +274,24 @@ function createWorkflowClient({ baseUrl, headers, fetch: fetchImpl = globalThis.
240
274
  return data;
241
275
  }
242
276
  const path = id => '/jobs/' + identifier(id);
243
- return {
277
+ const workflow={
244
278
  async create(input) { return (await request('/jobs',input)).job; },
245
279
  async get(id) { return (await request(path(id))).job; },
246
280
  async act(id,action,input,{idempotencyKey}={}) { identifier(action);identifier(idempotencyKey);return (await request(path(id)+'/actions',{action,input},idempotencyKey)).job; },
247
281
  async reconcile(id) { return (await request(path(id)+'/reconcile',{})).job; },
282
+ async cancellation(id){return (await request(path(id)+'/cancellation')).subflow;},
283
+ async cancellationInstruction(id){return (await request(path(id)+'/cancellation/instruction')).instruction;},
284
+ async cancellationReceipt(id){return (await request(path(id)+'/cancellation/receipt')).receipt;},
285
+ async subflows(id){return (await request(path(id)+'/subflows')).subflows;},
286
+ async acceptCancellation(id,input,{idempotencyKey}={}){identifier(idempotencyKey);return (await request(path(id)+'/cancellation/accept',input,idempotencyKey)).job;},
287
+ async executeCancellation(id,input,{idempotencyKey}={}){identifier(idempotencyKey);return (await request(path(id)+'/cancellation/execute',input,idempotencyKey)).job;},
248
288
  async receipts(id) { return request(path(id)+'/receipts'); }
249
289
  };
290
+ return workflow;
250
291
  }
251
292
  module.exports.createWorkflowClient = createWorkflowClient;
293
+
294
+ module.exports.getCancellationSubflow=(job)=>require('./cancellation.cjs').getCancellationSubflow(job);
295
+ module.exports.createCancellationInstruction=(job,proofs)=>require('./cancellation.cjs').createCancellationInstruction(job,proofs);
296
+ module.exports.validateCancellationInstruction=(job,instruction)=>require('./cancellation.cjs').validateCancellationInstruction(job,instruction);
297
+ module.exports.validateCancellationReceipt=(job,receipt)=>require('./cancellation.cjs').validateCancellationReceipt(job,receipt);
package/src/workflow.d.ts CHANGED
@@ -4,9 +4,23 @@ export interface Operation { id: string; key: string; action: string; actor: str
4
4
  export interface AdapterResult { status: 'pending' | 'confirmed' | 'rejected'; evidence?: { [key: string]: Json }; [key: string]: Json | undefined }
5
5
  export interface SettlementClaim {kind: 'work' | 'expense' | 'other';description: string;amount?: string;evidence?: string[]}
6
6
  export interface SettlementProposal {version: 'a423.closing.v1';job_id: string;agreement_hash: string;escrow_version: number;worker_amount: string;payer_amount: string;expires_at: number;reason: string;claims: SettlementClaim[];closes_job: true;proposal_hash: string;proposer_id?: string;at?: string}
7
- export interface Negotiation {status: 'open' | 'disputed' | 'settled';origin_state: string;current: SettlementProposal;history: SettlementProposal[];accepted_by?: string;settled_at?: string}
7
+ export interface Negotiation {status: 'open' | 'accepted' | 'disputed' | 'settled';acceptance_basis?: 'dual_wallet_signatures';origin_state: string;current: SettlementProposal;history: SettlementProposal[];accepted_by?: string;settled_at?: string}
8
8
  export interface EscrowLedger {funded_amount: string;worker_paid: string;payer_refunded: string;locked_amount: string;escrow_version: number}
9
+ export interface CancellationInstruction {
10
+ id: string;version: 'a423.cancellation_instruction.v1';purpose: 'close_job';funds_status: 'not_executed';job_id: string;subflow_id: string;agreement_hash: string;chain_id: string;escrow_contract: string;token: string;onchain_job_id: string;escrow_version: number;proposal_hash: string;expires_at: number;
11
+ payer: {agent_id: string;address: string;amount: string};worker: {agent_id: string;address: string;amount: string};proposal: SettlementProposal;
12
+ authorization: {scheme: 'eip712_closing_settlement_v1';payer_signature: string;worker_signature: string};
13
+ }
14
+ export interface CancellationReceipt {
15
+ id: string;version: 'a423.cancellation_receipt.v1';status: 'confirmed';job_id: string;subflow_id: string;agreement_hash: string;outcome: string;instruction_id: string | null;prior_instruction_id: string | null;
16
+ allocations: {worker: {address: string;amount: string};payer: {address: string;amount: string}};remaining_locked: '0';evidence: {[key: string]: Json};
17
+ }
18
+ export interface CancellationSubflow {
19
+ id: string;kind: 'cancellation';parent_job_id: string;status: 'proposal_pending'|'proposal_rejected'|'negotiating'|'acceptance_pending'|'accepted'|'execution_pending'|'execution_failed'|'completed'|'disputed';started_at: string;updated_at: string;proposal_revision: number;instruction: CancellationInstruction | null;receipt: CancellationReceipt | null;events: Array<{sequence: number;type: string;actor_id: string;at: string;data: Json}>;accepted_at?: string;approved_by?: string[];completed_at?: string;execution_reference?: string | null;closing_action?: string;last_error?: string | null;
20
+ }
21
+ export interface CancellationApproval {expected_revision: number;proposal_hash: string;payer_signature: string;worker_signature: string}
9
22
  export interface WorkflowJob {
23
+ subflows?: CancellationSubflow[];active_subflow_id?: string | null;
10
24
  work_status?: string;negotiation?: Negotiation | null;escrow_ledger?: EscrowLedger | null;
11
25
  id: string; protocol: 'a423.workflow.v1'; version: number; state: string;
12
26
  participants: { requester: string; worker: string; verifier: string; [role: string]: string };
@@ -43,13 +57,17 @@ export interface Workflow {
43
57
  act(id: string, action: string, input: Json, options: { actor: string; idempotencyKey: string }): Promise<WorkflowJob>;
44
58
  resume(id: string, actor: string): Promise<WorkflowJob>;
45
59
  reconcile(id: string, actor: string): Promise<WorkflowJob>;
46
- receipts(id: string, actor: string): Promise<Pick<WorkflowJob, 'protocol' | 'state' | 'agreement' | 'submission' | 'evidence' | 'events' | 'pending' | 'work_status' | 'negotiation' | 'escrow_ledger'> & { job_id: string }>;
60
+ cancellation(id: string, actor: string): Promise<CancellationSubflow | null>;
61
+ cancellationInstruction(id: string, actor: string): Promise<CancellationInstruction>;
62
+ cancellationReceipt(id: string, actor: string): Promise<CancellationReceipt>;
63
+ acceptCancellation(id: string,input: CancellationApproval,options: {actor: string;idempotencyKey: string;forward?: boolean}): Promise<WorkflowJob>;
64
+ receipts(id: string, actor: string): Promise<Pick<WorkflowJob, 'protocol' | 'state' | 'agreement' | 'submission' | 'evidence' | 'events' | 'pending' | 'work_status' | 'negotiation' | 'escrow_ledger' | 'subflows' | 'active_subflow_id'> & { job_id: string }>;
47
65
  }
48
66
  export function hashAgreement(terms: Json): string;
49
67
  export function createWorkflow(options: WorkflowOptions): Workflow;
50
68
  export function createWorkflowHandler(options: {
51
69
  workflow: Workflow; authenticate(request: Request): string | null | Promise<string | null>;
52
- basePath?: string; maxBodyBytes?: number; onError?(error: unknown): void | Promise<void>;
70
+ basePath?: string; maxBodyBytes?: number; forwardCancellations?: boolean; revalidateCancellationReceipt?(context: {job: WorkflowJob;receipt: CancellationReceipt;actor: string}): Promise<void>; onError?(error: unknown): void | Promise<void>;
53
71
  }): (request: Request) => Promise<Response>;
54
72
  export interface WorkflowClient {
55
73
  /** Supply a stable id so creation retries can be resolved with get(id). */
@@ -57,9 +75,20 @@ export interface WorkflowClient {
57
75
  get(id: string): Promise<WorkflowJob>;
58
76
  act(id: string, action: string, input: Json, options: { idempotencyKey: string }): Promise<WorkflowJob>;
59
77
  reconcile(id: string): Promise<WorkflowJob>;
78
+ cancellation(id: string): Promise<CancellationSubflow | null>;
79
+ cancellationInstruction(id: string): Promise<CancellationInstruction>;
80
+ cancellationReceipt(id: string): Promise<CancellationReceipt>;
81
+ subflows(id: string): Promise<CancellationSubflow[]>;
82
+ acceptCancellation(id: string,input: CancellationApproval,options: {idempotencyKey: string}): Promise<WorkflowJob>;
83
+ executeCancellation(id: string,input: {instruction_id: string;transaction_hash?: string},options: {idempotencyKey: string}): Promise<WorkflowJob>;
60
84
  receipts(id: string): ReturnType<Workflow['receipts']>;
61
85
  }
62
86
  export function createWorkflowClient(options: {
63
87
  baseUrl: string; headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
64
88
  fetch?: typeof globalThis.fetch; timeoutMs?: number;
65
89
  }): WorkflowClient;
90
+
91
+ export function getCancellationSubflow(job: WorkflowJob): CancellationSubflow | null;
92
+ export function createCancellationInstruction(job: WorkflowJob,proofs: {payer_signature: string;worker_signature: string}): CancellationInstruction;
93
+ export function validateCancellationInstruction(job: WorkflowJob,instruction: CancellationInstruction): CancellationInstruction;
94
+ export function validateCancellationReceipt(job: WorkflowJob,receipt: CancellationReceipt): CancellationReceipt;
package/src/workflow.mjs CHANGED
@@ -1,2 +1,2 @@
1
1
  import workflow from './workflow.cjs';
2
- export const { createWorkflow, createWorkflowHandler, createWorkflowClient, hashAgreement } = workflow;
2
+ export const { createWorkflow, createWorkflowHandler, createWorkflowClient, hashAgreement, getCancellationSubflow, createCancellationInstruction, validateCancellationInstruction, validateCancellationReceipt } = workflow;