@allorc/a423-sdk 0.6.0 → 0.8.1
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.
- package/CANCELLATION.md +36 -0
- package/CHANGELOG.md +28 -0
- package/ESCROW.md +2 -0
- package/INTEGRATION.md +11 -1
- package/OPERATIONS.md +2 -0
- package/PROFIT-POLICY.md +154 -0
- package/README.md +605 -64
- package/SECURITY.md +4 -0
- package/SETTLEMENT.md +2 -0
- package/examples/README.md +29 -0
- package/examples/operations-dashboard.html +3 -3
- package/examples/quickstart.mjs +33 -0
- package/package.json +12 -3
- package/src/escrow/index.cjs +8 -1
- package/src/escrow/index.d.mts +1 -1
- package/src/escrow/index.d.ts +1 -1
- package/src/escrow/services.cjs +1 -1
- package/src/operations/application.cjs +10 -5
- package/src/operations/factory.cjs +6 -4
- package/src/operations/index.d.ts +3 -3
- package/src/policy/index.cjs +94 -0
- package/src/policy/index.d.ts +23 -0
- package/src/policy/index.mjs +2 -0
- package/src/workflow.cjs +45 -7
- package/src/workflow.d.ts +7 -3
package/SECURITY.md
CHANGED
|
@@ -17,3 +17,7 @@ Reorg watchers quarantine stale/unavailable evidence; they cannot guarantee fina
|
|
|
17
17
|
Report vulnerabilities privately through the repository's configured security reporting channel. Do not post credentials, customer records or live exploit details publicly. No completed independent audit is claimed.
|
|
18
18
|
|
|
19
19
|
Closing settlements in 0.5 require a new deployment and exact current proposal acceptance. Publishing freezes ordinary release, but cannot reverse a release already mined. Proposals never extend original refund/resolution deadlines. Unsubmitted partial-work claims do not remove the original expiry refund right; arbitrary expenses are not automatically owed. First valid closing settlement/refund/arbitration/timeout wins; no later operation can settle the same job again. See SETTLEMENT.md.
|
|
20
|
+
|
|
21
|
+
## Off-chain profit policy (0.8)
|
|
22
|
+
|
|
23
|
+
Private cost snapshots and exception decisions live in backend KV, not agreement terms/events. Configure role-aware projectors and never share worker-private responses through public caches. The policy is opt-in and cannot constrain direct external contract calls or prove costs are real. Owner exceptions must be checked by a trusted server approval system, never client flags. Malformed-pending recovery requires an adapter impossibility proof and refuses any known signed/external intent; do not add permissive recovery hooks. See PROFIT-POLICY.md.
|
package/SETTLEMENT.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Negotiated closing settlements — 0.5.0
|
|
2
2
|
|
|
3
|
+
[Start here: installation, arguments and lifecycle maps](./README.md) · [All examples](./examples/README.md)
|
|
4
|
+
|
|
3
5
|
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
6
|
|
|
5
7
|
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.**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# A423 examples
|
|
2
|
+
|
|
3
|
+
Start with the [main README](../README.md) for installation, configuration arguments and lifecycle diagrams. These examples target the source version in `package.json`; check the installed npm version before copying newer APIs.
|
|
4
|
+
|
|
5
|
+
| Example | Purpose | Chain activity |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| [quickstart.mjs](./quickstart.mjs) | Runnable in-memory agreement and pure profit calculation | None; no wallet or funding adapter |
|
|
8
|
+
| [operations-server.mjs](./operations-server.mjs) | Localhost HTTP shell around a configured application handler | Only through your explicitly configured adapters/signers |
|
|
9
|
+
| [operations-dashboard.html](./operations-dashboard.html) | Reference UI for states, wallet consent, cancellation JSON, receipts and private economics | Actions use the authenticated backend |
|
|
10
|
+
| [nextjs/lib/a423.ts](./nextjs/lib/a423.ts) | Older root API example | No settlement adapter; records approval only |
|
|
11
|
+
| [client.mjs](./client.mjs) | Older root API client example | Depends on the configured server; not the reference workflow client |
|
|
12
|
+
|
|
13
|
+
Run the no-chain example from the source repository:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
node examples/quickstart.mjs
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
After installing the package in another app, copy the quickstart file and run it there; it imports public package entry points.
|
|
20
|
+
|
|
21
|
+
For the modern escrow path, construct `createEscrowApplication` with your authority, durable storage, deployment registry, signing/spending approvals and artifact validator. Mount its handler in Next.js with the Node runtime or use the local shell. The old `examples/nextjs` code intentionally demonstrates the preserved root API; do not mistake it for the new escrow workflow.
|
|
22
|
+
|
|
23
|
+
The shell requires your own configuration module exporting `applicationOptions`:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
A423_CONFIG_MODULE=/absolute/path/config.mjs node examples/operations-server.mjs
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The operational worker separately requires `workerOptions`; see [OPERATIONS.md](../OPERATIONS.md). Installing a package, mounting an HTTP handler and running a keeper are separate steps. No example supplies default credentials, signing keys or a deployed contract.
|
|
@@ -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,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);}};
|
|
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);if(job.profit){const economics=document.createElement('p');const p=job.profit;economics.textContent=p.cost_basis_raw!==undefined?'Raw token units · Cost: '+p.cost_basis_raw+' · Target payout: '+p.target_payout+' · Minimum: '+p.min_payout+' · Current: '+p.projected_payout_raw+' · '+(p.receipt_confirmed?'Realized':'Projected')+' profit: '+(p.receipt_confirmed?p.realized_profit_raw:p.projected_profit_raw)+' · Margin bps: '+(p.receipt_confirmed?p.realized_margin_bps:p.projected_margin_bps)+' · '+p.target_status:'Proposed payout: '+(p.proposed_payout_raw??'—')+' · Confirmed payout: '+(p.realized_payout_raw??'pending');article.append(economics);}const info=document.createElement('pre');info.textContent=JSON.stringify({epoch:job.epoch,pre_funding_cancellations:job.pre_funding_cancellations,profit:job.profit,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','decline','reopen','expire']){const o=document.createElement('option');o.textContent=value;action.append(o);}const input=document.createElement('textarea');input.value='{}';action.onchange=()=>{input.value=JSON.stringify({expected_epoch:job.epoch||0},null,2);if(action.value==='reopen')input.value=JSON.stringify({cancellation_id:job.pre_funding_cancellations?.at(-1)?.id},null,2);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'?{expected_epoch:job.epoch||0,...JSON.parse(input.value)}:{action:action.value,input:{expected_epoch:job.epoch||0,...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='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');}
|
|
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={expected_epoch:job.epoch||0,...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>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// In-memory workflow and policy example. No on-chain transactions or keys.
|
|
2
|
+
import {createMemoryStore} from '@allorc/a423-sdk';
|
|
3
|
+
import {createWorkflow} from '@allorc/a423-sdk/workflow';
|
|
4
|
+
import {createSettlementPolicy} from '@allorc/a423-sdk/policy';
|
|
5
|
+
|
|
6
|
+
const workflow = createWorkflow({store: createMemoryStore()});
|
|
7
|
+
const id = 'research-001';
|
|
8
|
+
await workflow.create({
|
|
9
|
+
id,
|
|
10
|
+
participants: {requester: 'buyer', worker: 'researcher', verifier: 'reviewer'},
|
|
11
|
+
}, 'buyer');
|
|
12
|
+
|
|
13
|
+
let job = await workflow.act(id, 'quote', {
|
|
14
|
+
terms: {description: 'Prepare a research brief', amount_raw: '200'},
|
|
15
|
+
}, {actor: 'researcher', idempotencyKey: 'quote-1'});
|
|
16
|
+
|
|
17
|
+
const acceptance = {
|
|
18
|
+
revision: job.agreement.revision,
|
|
19
|
+
agreement_hash: job.agreement.hash,
|
|
20
|
+
};
|
|
21
|
+
await workflow.act(id, 'accept', acceptance, {
|
|
22
|
+
actor: 'buyer', idempotencyKey: 'buyer-accept-1',
|
|
23
|
+
});
|
|
24
|
+
job = await workflow.act(id, 'accept', acceptance, {
|
|
25
|
+
actor: 'researcher', idempotencyKey: 'worker-accept-1',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const plan = createSettlementPolicy().evaluate({
|
|
29
|
+
cost_basis_raw: '100', budget_raw: '300', escrow_raw: '300',
|
|
30
|
+
});
|
|
31
|
+
console.log(job.state); // accepted; no funding has occurred
|
|
32
|
+
console.log(plan.target_payout); // 200: 50% gross-margin target
|
|
33
|
+
console.log(plan.recommended_payout);// 200: fits budget and escrow caps
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allorc/a423-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Experimental
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Experimental backend SDK for agent job agreements, durable workflows, ERC-20 escrow, negotiated settlement and configurable profit policies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"main": "./src/index.cjs",
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
"types": "./src/operations/index.d.ts",
|
|
42
42
|
"import": "./src/operations/index.mjs",
|
|
43
43
|
"require": "./src/operations/index.cjs"
|
|
44
|
+
},
|
|
45
|
+
"./policy": {
|
|
46
|
+
"types": "./src/policy/index.d.ts",
|
|
47
|
+
"import": "./src/policy/index.mjs",
|
|
48
|
+
"require": "./src/policy/index.cjs"
|
|
44
49
|
}
|
|
45
50
|
},
|
|
46
51
|
"files": [
|
|
@@ -75,7 +80,11 @@
|
|
|
75
80
|
"src/closing.cjs",
|
|
76
81
|
"SETTLEMENT.md",
|
|
77
82
|
"src/cancellation.cjs",
|
|
78
|
-
"CANCELLATION.md"
|
|
83
|
+
"CANCELLATION.md",
|
|
84
|
+
"src/policy",
|
|
85
|
+
"PROFIT-POLICY.md",
|
|
86
|
+
"examples/quickstart.mjs",
|
|
87
|
+
"examples/README.md"
|
|
79
88
|
],
|
|
80
89
|
"engines": {
|
|
81
90
|
"node": ">=22.13"
|
package/src/escrow/index.cjs
CHANGED
|
@@ -201,7 +201,14 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
|
|
|
201
201
|
return observe(ctx,t,'fund','fund',[t,ctx.input.worker_signature],'Funded',(s,e)=>{check(Number(s.state)>0,'missing funded job');check(e.args.amount===t.amount && getAddress(e.args.payer)===t.payer && getAddress(e.args.worker)===t.worker && e.args.agreementHash===t.agreementHash,'funding event mismatch');},reconcile);
|
|
202
202
|
}),
|
|
203
203
|
start:adapter(async ctx=>{const t=await context(ctx.job);const block=await provider.getBlock('latest');const safeNumber=block.number-confirmations+1;check(safeNumber>=0,'insufficient confirmation depth');const safe=await provider.getBlock(safeNumber);const confirmed=await contract.getJob(t.jobId,{blockTag:safeNumber});match(confirmed,t);state(confirmed,1);const s=await contract.getJob(t.jobId,{blockTag:block.number});match(s,t);state(s,1);check(block.timestamp<=t.workDeadline,'work deadline passed');return {status:'confirmed',evidence:{chain_id:String(chainId),contract:config.contractAddress,job_id:t.jobId,block_number:safe.number,block_hash:safe.hash,confirmations,amount:t.amount.toString(),agreement_hash:t.agreementHash}};}),
|
|
204
|
-
submit:adapter(async(ctx,r)=>{const t=await context(ctx.job);const hash=bytes32(ctx.input.result_hash);return observe(ctx,t,'submit','submit',[t.jobId,hash],'Submitted',(s,e)=>{check(Number(s.state)>=2,'missing submission');check(s.resultHash===hash && e.args.resultHash===hash,'artifact commitment mismatch');},r);}),
|
|
204
|
+
submit:Object.assign(adapter(async(ctx,r)=>{const t=await context(ctx.job);const hash=bytes32(ctx.input.result_hash);return observe(ctx,t,'submit','submit',[t.jobId,hash],'Submitted',(s,e)=>{check(Number(s.state)>=2,'missing submission');check(s.resultHash===hash && e.args.resultHash===hash,'artifact commitment mismatch');},r);}),{rejectPending:async ctx=>{
|
|
205
|
+
const t=await context(ctx.job);let malformed=false;try{bytes32(ctx.input.result_hash);}catch{malformed=true;}
|
|
206
|
+
check(malformed&&!ctx.input.transaction_hash&&!ctx.operation.observation?.reference&&!ctx.operation.observation?.transaction_hash&&!ctx.operation.observation?.evidence?.transaction_hash,'Only syntactically impossible submissions without external transaction references can be rejected');
|
|
207
|
+
check(typeof transactions.list==='function','Transaction journal inventory required');
|
|
208
|
+
check(!(await transactions.list()).some(record=>record.id===ctx.operation.id||record.id.startsWith(ctx.operation.id+':')),'Journaled intent exists; reconcile instead');
|
|
209
|
+
const snapshot=await contract.getJob(t.jobId);match(snapshot,t);check(snapshot.resultHash===ZeroHash,'On-chain submission exists; reconcile instead');
|
|
210
|
+
return {status:'rejected',never_executable:true,reason:'Malformed result_hash cannot encode a submit transaction',checked_chain_id:String(chainId)};
|
|
211
|
+
}}),
|
|
205
212
|
verify:adapter(async(ctx,r)=>{const t=await context(ctx.job);const hash=bytes32(ctx.job.submission?.result_hash);if(!r && !await transactions.get(ctx.operation.id+':verify')){check(typeof validateArtifact==='function','validateArtifact callback required');if(await validateArtifact({job:structuredClone(ctx.job),resultHash:hash})!==true)return {status:'rejected',reason:'Artifact verification failed'};}return observe(ctx,t,'verify','verify',[t.jobId,hash],'Verified',(s,e)=>{check(Number(s.state)>=3,'missing verification');check(s.resultHash===hash && e.args.resultHash===hash,'verified artifact mismatch');},r);}),
|
|
206
213
|
release:adapter(async(ctx,r)=>{const t=await context(ctx.job);return observe(ctx,t,'release','release',[t.jobId],'Settled',(s,e)=>{state(s,5);settled(s,e,t);check(e.args.workerAmount===t.amount && Number(e.args.reason)===0,'not a full release');},r);}),
|
|
207
214
|
refund:adapter(async(ctx,r)=>{const t=await context(ctx.job);return observe(ctx,t,'refund','refundExpired',[t.jobId],'Settled',(s,e)=>{state(s,6);settled(s,e,t);check(e.args.payerAmount===t.amount && Number(e.args.reason)===1,'not an expiry refund');},r);}),
|
package/src/escrow/index.d.mts
CHANGED
|
@@ -16,7 +16,7 @@ export interface TransactionStore {
|
|
|
16
16
|
/** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
|
|
17
17
|
reserveNonce(scope: string, minimum: number, operationId: string): Promise<number>;
|
|
18
18
|
}
|
|
19
|
-
export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult> }
|
|
19
|
+
export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult>;rejectPending?(context: AdapterContext): Promise<AdapterResult> }
|
|
20
20
|
export interface EscrowAdapterOptions extends EscrowConfiguration {
|
|
21
21
|
provider: Provider;
|
|
22
22
|
/** Requires populateTransaction + offline signTransaction support; no silent wallet approval. */
|
package/src/escrow/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface TransactionStore {
|
|
|
16
16
|
/** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
|
|
17
17
|
reserveNonce(scope: string, minimum: number, operationId: string): Promise<number>;
|
|
18
18
|
}
|
|
19
|
-
export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult> }
|
|
19
|
+
export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult>;rejectPending?(context: AdapterContext): Promise<AdapterResult> }
|
|
20
20
|
export interface EscrowAdapterOptions extends EscrowConfiguration {
|
|
21
21
|
provider: Provider;
|
|
22
22
|
/** Requires populateTransaction + offline signTransaction support; no silent wallet approval. */
|
package/src/escrow/services.cjs
CHANGED
|
@@ -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','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;
|
|
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','rejectPending'])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');check(typeof adapter[action][method]==='function','Recovery unsupported for this action');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}={}){
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const {createWorkflowHandler}=require('../workflow.cjs');
|
|
3
|
-
function createChatTools({workflow,store,queue,actor}={}){
|
|
3
|
+
function createChatTools({workflow,store,queue,actor,profitFor,policyPromptFor}={}){
|
|
4
4
|
const {enqueueRunningJobs}=require('./index.cjs');
|
|
5
5
|
const act=(action)=>async({job_id,input={},idempotency_key})=>workflow.act(job_id,action,input,{actor,idempotencyKey:idempotency_key});
|
|
6
6
|
return {
|
|
7
|
+
list_jobs:async()=>Promise.all((await store.list({actor,limit:1000})).map(async job=>profitFor?{...job,profit:await profitFor(job,actor)}:job)),
|
|
8
|
+
inspect_settlement_policy:async({job_id})=>{const job=await workflow.get(job_id,actor);if(!profitFor)throw Error('Settlement policy not configured');return {profit:await profitFor(job,actor),prompt:policyPromptFor?policyPromptFor(job,actor):null};},
|
|
9
|
+
reconcile_job:async({job_id})=>workflow.reconcile(job_id,actor),
|
|
7
10
|
send_job_offer:async({id,worker_id,verifier_id,arbitrator_id,terms,metadata={}})=>{
|
|
8
11
|
let job=await workflow.create({id,participants:{requester:actor,worker:worker_id,verifier:verifier_id,arbitrator:arbitrator_id},metadata},actor);
|
|
9
12
|
if(terms)job=await workflow.act(job.id,'quote',{terms},{actor,idempotencyKey:'initial_quote'});return job;
|
|
10
13
|
},
|
|
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'),
|
|
14
|
+
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'),decline_job:act('decline'),reopen_job:act('reopen'),
|
|
12
15
|
work_on_job:async(input)=>{const job=await act('start')(input);if(!job.pending&&job.state==='running')await enqueueRunningJobs({store,queue});return job;}
|
|
13
16
|
};
|
|
14
17
|
}
|
|
15
|
-
function createApplicationHandler({workflow,store,queue,authenticate,revalidate,consentFor,settlementConsentFor,legacyState=async()=>({}),authorityHandler,forwardCancellations=true,basePath='/api/a423'}={}){
|
|
18
|
+
function createApplicationHandler({workflow,store,queue,authenticate,revalidate,consentFor,settlementConsentFor,profitFor,legacyState=async()=>({}),authorityHandler,forwardCancellations=true,basePath='/api/a423'}={}){
|
|
16
19
|
const {createEventStream,freshReceipts,renderReceiptPdf}=require('./index.cjs');
|
|
17
20
|
const protocol=createWorkflowHandler({workflow,authenticate,basePath,forwardCancellations,revalidateCancellationReceipt:async({job,receipt})=>{
|
|
18
21
|
const {validateCancellationReceipt}=require('../cancellation.cjs');validateCancellationReceipt(job,receipt);
|
|
@@ -27,11 +30,13 @@ function createApplicationHandler({workflow,store,queue,authenticate,revalidate,
|
|
|
27
30
|
const actor=await authenticate(request);if(!actor)return Response.json({error:'unauthenticated'},{status:401});
|
|
28
31
|
try{
|
|
29
32
|
if(path==='/api/state'){
|
|
30
|
-
const jobs=await Promise.all((await store.list({actor,limit:1000})).map(async job=>({...job,safety:await store.getSafety(job.id)})));const wallet_transactions=jobs.flatMap(j=>j.evidence.filter(e=>e.evidence.transaction_hash).map(e=>({job_id:j.id,action:e.action,...e.evidence})));
|
|
33
|
+
const jobs=await Promise.all((await store.list({actor,limit:1000})).map(async job=>({...job,profit:profitFor?await profitFor(job,actor):null,safety:await store.getSafety(job.id)})));const wallet_transactions=jobs.flatMap(j=>j.evidence.filter(e=>e.evidence.transaction_hash).map(e=>({job_id:j.id,action:e.action,...e.evidence})));
|
|
31
34
|
return Response.json({...await legacyState(actor),a423:{jobs,wallet_transactions}},{headers:{'cache-control':'no-store'}});
|
|
32
35
|
}
|
|
36
|
+
const profit=/^\/api\/a423-profit\/([A-Za-z0-9_-]{1,128})$/.exec(path);
|
|
37
|
+
if(profit){const job=await workflow.get(profit[1],actor);if(!profitFor)return Response.json({error:'POLICY_NOT_CONFIGURED'},{status:404});return Response.json(await profitFor(job,actor),{headers:{'cache-control':'no-store'}});}
|
|
33
38
|
const receipt=/^\/api\/a423-receipts\/([A-Za-z0-9_-]{1,128})(\.pdf)?$/.exec(path);
|
|
34
|
-
if(receipt){const data=await freshReceipts({workflow,id:receipt[1],actor,revalidate});if(receipt[2])return new Response(Buffer.from(await renderReceiptPdf(data)),{headers:{'content-type':'application/pdf','cache-control':'no-store'}});return Response.json(data,{headers:{'cache-control':'no-store'}});}
|
|
39
|
+
if(receipt){const data=await freshReceipts({workflow,id:receipt[1],actor,revalidate});if(profitFor)data.profit=await profitFor(await workflow.get(receipt[1],actor),actor);if(receipt[2])return new Response(Buffer.from(await renderReceiptPdf(data)),{headers:{'content-type':'application/pdf','cache-control':'no-store'}});return Response.json(data,{headers:{'cache-control':'no-store'}});}
|
|
35
40
|
const closing=/^\/api\/a423-settlement-consent\/([A-Za-z0-9_-]{1,128})$/.exec(path);
|
|
36
41
|
if(closing){const job=await workflow.get(closing[1],actor);if(![job.participants.requester,job.participants.worker].includes(actor))return Response.json({error:'settlement party required'},{status:403});if(!settlementConsentFor)throw Error('Settlement consent not configured');const typed=await settlementConsentFor(job);return Response.json(JSON.parse(JSON.stringify(typed,(_,v)=>typeof v==='bigint'?v.toString():v)),{headers:{'cache-control':'no-store'}});}
|
|
37
42
|
const consent=/^\/api\/a423-worker-consent\/([A-Za-z0-9_-]{1,128})$/.exec(path);
|
|
@@ -1,10 +1,11 @@
|
|
|
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,forwardCancellations=true,allowAuthorityRequest=async()=>false,requireIndependentArbitrator=true}={}){
|
|
4
|
+
function createEscrowApplication({storage,authority,escrows,signerFor,addressFor,limitsFor,approveSigning,validateArtifact,execute,settlementPolicy,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');
|
|
8
|
+
const profit=settlementPolicy?require('../policy/index.cjs').createProfitSettlementService({...settlementPolicy,kv:storage.kv}):null;
|
|
8
9
|
const registry=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),c]));
|
|
9
10
|
const configFor=job=>{const e=job.agreement?.terms.escrow;const c=registry.get(e?.chain_id+':'+e?.contract?.toLowerCase());if(!c)throw Error('Unregistered escrow');return c;};
|
|
10
11
|
const checkPolicy=async context=>{
|
|
@@ -17,17 +18,18 @@ function createEscrowApplication({storage,authority,escrows,signerFor,addressFor
|
|
|
17
18
|
const authorizeTransaction=createSpendingAuthorizer({spending:storage.spending,limitsFor,authorize:checkPolicy});
|
|
18
19
|
const adapters=createEscrowRegistry(escrows.map(c=>({...c,signerFor,addressFor,transactions:storage.transactions,authorizeTransaction,validateArtifact})));
|
|
19
20
|
const workflow=createWorkflow({store:storage.jobs,adapters:{...adapters,expire:createExpiryAdapter()},transitions:escrowTransitions,
|
|
20
|
-
|
|
21
|
+
prepareInput:profit?async context=>{if(await authority.isActive(context.actor)!==true){const {A423Error}=require('../index.cjs');throw new A423Error('FORBIDDEN','Active identity required',403);}return profit.prepare(context);}:undefined,
|
|
22
|
+
authorize:async context=>await authority.isActive(context.actor)===true&&(context.phase==='reconcile'||!profit||await profit.authorize(context)),
|
|
21
23
|
validateTerms:terms=>{validateTask(terms.task);const job={id:'terms_validation',agreement:{terms,hash:hashAgreement(terms)}};escrowTerms(job,configFor(job));},
|
|
22
24
|
validateEvidence:({evidence})=>{if(evidence.transaction_hash)validateEscrowEvidence(evidence);}
|
|
23
25
|
});
|
|
24
26
|
const validators=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),createEvidenceRevalidator(c)]));
|
|
25
27
|
const revalidate=(job,evidence)=>validators.get(String(configFor(job).chainId)+':'+configFor(job).contractAddress.toLowerCase())(job,evidence);
|
|
26
28
|
const authenticate=createCredentialAuthenticator({authority});
|
|
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})};
|
|
29
|
+
const applicationOptions={profitFor:profit?(job,actor)=>profit.project(job,actor,{revalidate}):undefined,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
30
|
const workerOptions={authority,actorFor:async job=>job.pending?.actor||job.participants.requester,allowExecute:keeperEnabled,
|
|
29
31
|
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
32
|
};
|
|
31
|
-
return {workflow,applicationOptions,workerOptions,handler:createApplicationHandler(applicationOptions),chatTools:actor=>createChatTools({workflow,store:storage.jobs,queue:storage.queue,actor})};
|
|
33
|
+
return {profit,workflow,applicationOptions,workerOptions,handler:createApplicationHandler(applicationOptions),chatTools:actor=>createChatTools({workflow,store:storage.jobs,queue:storage.queue,actor,profitFor:applicationOptions.profitFor,policyPromptFor:profit?(job,actor)=>profit.prompt({role:actor===job.participants.worker?'worker':'requester'}):undefined})};
|
|
32
34
|
}
|
|
33
35
|
module.exports={createEscrowApplication};
|
|
@@ -23,9 +23,9 @@ export function freshReceipts(options: {workflow: Workflow;id: string;actor: str
|
|
|
23
23
|
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
|
-
export function createChatTools(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;actor: string}): Record<string,(input: any) => Promise<
|
|
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>;
|
|
26
|
+
export function createChatTools(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;actor: string;profitFor?(job: WorkflowJob,actor: string): Promise<object | null>;policyPromptFor?(job: WorkflowJob,actor: string): string}): Record<string,(input: any) => Promise<any>>;
|
|
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>;profitFor?(job: WorkflowJob,actor: string): Promise<object | null>;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;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>};
|
|
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}>;settlementPolicy?: Omit<import('../policy/index.js').ProfitServiceOptions, 'kv'>;keeperEnabled?: boolean;forwardCancellations?: boolean;allowAuthorityRequest?(request: Request): Promise<boolean>;requireIndependentArbitrator?: boolean}): {profit: ReturnType<typeof import('../policy/index.js').createProfitSettlementService> | null;workflow: Workflow;applicationOptions: Parameters<typeof createApplicationHandler>[0];workerOptions: any;handler: (request: Request) => Promise<Response>;chatTools(actor: string): ReturnType<typeof createChatTools>};
|
|
30
30
|
|
|
31
31
|
export function freshCancellationReceipt(options: {workflow: Workflow;id: string;actor: string;revalidate: Revalidator}): Promise<import('../workflow.js').CancellationReceipt>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {hashAgreement}=require('../workflow.cjs');
|
|
3
|
+
const BPS=10000n,MAX=(1n<<256n)-1n;
|
|
4
|
+
function check(ok,message){if(!ok)throw new TypeError(message);}
|
|
5
|
+
function raw(value,name){check(typeof value==='string'&&/^(0|[1-9][0-9]*)$/.test(value),name+' must be an unsigned raw-unit integer string');const n=BigInt(value);check(n<=MAX,name+' exceeds uint256');return n;}
|
|
6
|
+
const min=(a,b)=>a<b?a:b,ceil=(a,b)=>(a+b-1n)/b;
|
|
7
|
+
const floor=(a,b)=>a>=0n?a/b:-ceil(-a,b);
|
|
8
|
+
function margin(cost,payout){return payout===0n?null:(floor((payout-cost)*BPS,payout)).toString();}
|
|
9
|
+
function createSettlementPolicy(options={}){
|
|
10
|
+
check(options&&Object.getPrototypeOf(options)===Object.prototype,'Policy thresholds must be a plain object');
|
|
11
|
+
const keys=['target_margin_bps','min_margin_bps','max_margin_bps','require_counter'];
|
|
12
|
+
check(Object.keys(options).every(key=>keys.includes(key)),'Unknown policy threshold; use target_margin_bps, min_margin_bps, max_margin_bps or require_counter');
|
|
13
|
+
const {target_margin_bps=5000,min_margin_bps=1000,max_margin_bps=5000,require_counter=true}=options;
|
|
14
|
+
for(const v of [target_margin_bps,min_margin_bps,max_margin_bps])check(Number.isSafeInteger(v)&&v>=0&&v<10000,'Margins must be integer basis points between 0 and 9999');
|
|
15
|
+
check(min_margin_bps<=target_margin_bps&&target_margin_bps<=max_margin_bps,'Require minimum <= target <= maximum');check(typeof require_counter==='boolean','require_counter must be boolean');
|
|
16
|
+
const configuration={target_margin_bps,min_margin_bps,max_margin_bps,require_counter};
|
|
17
|
+
function evaluate({cost_basis_raw,budget_raw,escrow_raw,offer_raw,negotiation_round=0}){
|
|
18
|
+
const cost=raw(cost_basis_raw,'cost_basis_raw'),budget=raw(budget_raw,'budget_raw'),escrow=raw(escrow_raw,'escrow_raw'),affordable=min(budget,escrow);
|
|
19
|
+
check(Number.isSafeInteger(negotiation_round)&&negotiation_round>=0,'negotiation_round must be nonnegative');
|
|
20
|
+
const target=ceil(cost*BPS,BPS-BigInt(target_margin_bps)),minimum=ceil(cost*BPS,BPS-BigInt(min_margin_bps)),maximum=ceil(cost*BPS,BPS-BigInt(max_margin_bps));
|
|
21
|
+
const recommended=min(target,affordable),offer=offer_raw===undefined?null:raw(offer_raw,'offer_raw');
|
|
22
|
+
const viability=cost===0n?'no_cost':affordable<cost?'below_cost':affordable<minimum?'below_minimum':affordable<target?'below_target':'target_affordable';
|
|
23
|
+
const offerIssue=offer===null?null:offer>affordable?'above_budget_or_escrow':offer>maximum?'above_maximum':offer<cost?'below_cost':offer<minimum?'below_minimum':null;
|
|
24
|
+
const requires=cost>0n&&(affordable<minimum||['below_cost','below_minimum'].includes(offerIssue));
|
|
25
|
+
let decision=cost===0n?'refund':requires?'owner_approval_required':offer===null?'propose':offerIssue?'counter':require_counter&&negotiation_round===0?'counter':'accept';
|
|
26
|
+
if(cost===0n&&offer!==null&&offer!==0n)decision='counter';
|
|
27
|
+
return {version:'a423.profit_policy.v1',...configuration,cost_basis_raw,budget_raw,escrow_raw,affordable:affordable.toString(),target_payout:target.toString(),min_payout:minimum.toString(),max_payout:maximum.toString(),recommended_payout:recommended.toString(),expected_profit:(recommended-cost).toString(),expected_margin_bps:margin(cost,recommended),viability,decision,requires_owner_approval:requires,offer_issue:offerIssue,offer_raw:offer?.toString()??null,offer_profit_raw:offer===null?null:(offer-cost).toString(),offer_margin_bps:offer===null?null:margin(cost,offer),negotiation_round};
|
|
28
|
+
}
|
|
29
|
+
return Object.freeze({configuration:Object.freeze(configuration),evaluate});
|
|
30
|
+
}
|
|
31
|
+
function profitProjection({evaluation,payout_raw,confirmed=false}){
|
|
32
|
+
check(typeof confirmed==='boolean','confirmed must be boolean');const payout=raw(payout_raw,'payout_raw'),cost=raw(evaluation.cost_basis_raw,'cost_basis_raw');
|
|
33
|
+
const projected={projected_payout_raw:payout_raw,projected_profit_raw:(payout-cost).toString(),projected_margin_bps:margin(cost,payout),target_status:payout>=BigInt(evaluation.target_payout)?'target_met':'below_target'};
|
|
34
|
+
return {...evaluation,...projected,advisory:true,receipt_confirmed:confirmed,realized_payout_raw:confirmed?payout_raw:null,realized_profit_raw:confirmed?projected.projected_profit_raw:null,realized_margin_bps:confirmed?projected.projected_margin_bps:null};
|
|
35
|
+
}
|
|
36
|
+
function settlementPolicyPrompt({role='worker',policy=createSettlementPolicy(),override}={}){
|
|
37
|
+
check(['worker','requester'].includes(role),'Unknown policy role');if(override!==undefined){check(typeof override==='string','Prompt override must be text');return override;}
|
|
38
|
+
const c=policy.configuration;
|
|
39
|
+
const shared='Use list_jobs to discover unknown IDs; never invent IDs. Reconcile OPERATION_PENDING before another action. Both parties approve the exact revision/hash. Acceptance is not payment; require a freshly confirmed receipt. The runner submits artifacts. Simulated costs are estimates, not invoices.';
|
|
40
|
+
return (role==='worker'?`Profit is the primary objective. Inspect cost basis before quoting or proposing a closing split. Target ${c.target_margin_bps} basis points gross margin on revenue, minimum ${c.min_margin_bps}, maximum ${c.max_margin_bps}. Use the policy recommendation within budget and escrow caps. Never silently accept below cost or minimum; obtain explicit owner approval. ${c.require_counter?'Counter before accepting the first buyer offer. ':''}No cost means a full refund. Publicly share progress, work claims and proposed amounts only; never disclose internal cost, token counts or margin calculations.`:'Evaluate proposals against completed work and the original budget. Do not demand zero solely because work is incomplete; use progress and claims. Never exceed the original budget. Internal worker cost/margins are private.')+' '+shared;
|
|
41
|
+
}
|
|
42
|
+
function createProfitSettlementService({kv,costBasisFor,budgetFor,policy,thresholds,approveException,canViewPrivate=async()=>false}={}){
|
|
43
|
+
check(policy===undefined||thresholds===undefined,'Supply either thresholds or policy, not both');
|
|
44
|
+
policy=policy===undefined?createSettlementPolicy(thresholds):policy;
|
|
45
|
+
check(policy&&typeof policy.evaluate==='function'&&policy.configuration,'A valid SettlementPolicy is required');
|
|
46
|
+
// Capture and validate configuration at initialization, before any job or cost lookup.
|
|
47
|
+
const configuration=createSettlementPolicy(policy.configuration).configuration;
|
|
48
|
+
check(kv&&typeof kv.create==='function'&&typeof costBasisFor==='function'&&typeof budgetFor==='function','Durable private KV, costBasisFor and budgetFor required');
|
|
49
|
+
const key=job=>hashAgreement([job.id,job.agreement?.hash,job.epoch||0]);
|
|
50
|
+
async function basis(job,freeze=false){
|
|
51
|
+
check(job.agreement?.terms.escrow,'Escrow agreement required');const id=key(job),stored=await kv.get('a423_profit_basis',id);if(stored)return stored;
|
|
52
|
+
const [cost,budget]=await Promise.all([costBasisFor(structuredClone(job)),budgetFor(structuredClone(job))]);
|
|
53
|
+
const snapshot={cost_basis_raw:cost,budget_raw:budget,escrow_raw:job.agreement.terms.escrow.amount,configuration:{...configuration},frozen_at:new Date().toISOString()};
|
|
54
|
+
policy.evaluate(snapshot);
|
|
55
|
+
if(!freeze)return snapshot;await kv.create('a423_profit_basis',id,snapshot);return await kv.get('a423_profit_basis',id);
|
|
56
|
+
}
|
|
57
|
+
async function evaluate(job,{freeze=false,offer_raw}={}){const snapshot=await basis(job,freeze);const history=job.negotiation?.history||[],firstBuyer=history.findIndex(p=>p.proposer_id===job.participants.requester);const round=firstBuyer<0?0:history.filter((p,i)=>i>firstBuyer&&p.proposer_id===job.participants.worker).length;return createSettlementPolicy(snapshot.configuration).evaluate({...snapshot,offer_raw,negotiation_round:round});}
|
|
58
|
+
async function prepare({job,actor,action,input}){
|
|
59
|
+
if(!['propose_settlement','counter_settlement'].includes(action))return input;
|
|
60
|
+
const e=await evaluate(job,{freeze:true});return {...input,worker_amount:input.worker_amount===undefined?(actor===job.participants.worker?e.recommended_payout:'0'):input.worker_amount};
|
|
61
|
+
}
|
|
62
|
+
async function authorize({job,actor,action,input}){
|
|
63
|
+
const workerProposal=['propose_settlement','counter_settlement'].includes(action)&&actor===job.participants.worker;
|
|
64
|
+
const acceptance=['accept_settlement','approve_cancellation','execute_cancellation'].includes(action);
|
|
65
|
+
if(!workerProposal&&!acceptance)return true;
|
|
66
|
+
// Adopting already executed chain evidence is observation, not authorization to spend.
|
|
67
|
+
if(['accept_settlement','execute_cancellation'].includes(action)&&/^0x[0-9a-fA-F]{64}$/.test(input.transaction_hash||''))return true;
|
|
68
|
+
const payout=workerProposal?input.worker_amount:job.negotiation?.current?.worker_amount;
|
|
69
|
+
if(payout===undefined)return true; // The protocol adapter rejects malformed or absent proposals.
|
|
70
|
+
const e=await evaluate(job,{freeze:true,offer_raw:payout});
|
|
71
|
+
if(['above_budget_or_escrow','above_maximum'].includes(e.offer_issue))return false;
|
|
72
|
+
if(e.requires_owner_approval){
|
|
73
|
+
if(typeof approveException!=='function')return false;
|
|
74
|
+
const approval=await approveException({job:structuredClone(job),actor,action,payout_raw:payout,evaluation:structuredClone(e),proposal_hash:job.negotiation?.current?.proposal_hash||null});
|
|
75
|
+
if(approval?.approved!==true||typeof approval.approval_id!=='string'||!approval.approval_id.trim())return false;
|
|
76
|
+
await kv.create('a423_profit_approvals',hashAgreement([key(job),action,payout,job.negotiation?.current?.proposal_hash||null,approval.approval_id]),{job_id:job.id,agreement_hash:job.agreement.hash,action,actor,proposal_hash:job.negotiation?.current?.proposal_hash||null,approval_id:approval.approval_id,payout_raw:payout,at:new Date().toISOString()});
|
|
77
|
+
}
|
|
78
|
+
if(acceptance&&job.negotiation?.current?.proposer_id!==job.participants.worker&&e.require_counter&&e.cost_basis_raw!=='0'&&e.negotiation_round===0)return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
async function project(job,actor,{revalidate}={}){
|
|
82
|
+
if(!job.agreement?.terms.escrow)return null;
|
|
83
|
+
const proof=[...job.evidence].reverse().find(item=>item.evidence.event==='Settled')?.evidence;
|
|
84
|
+
let confirmed=false,evidence_status=proof?'unavailable':'not_settled';
|
|
85
|
+
if(proof&&revalidate){try{const result=await revalidate(job,proof);confirmed=result.status==='valid';evidence_status=result.status;}catch{evidence_status='unavailable';}}
|
|
86
|
+
const payout=confirmed?proof.worker_paid:job.negotiation?.current?.worker_amount||'0';
|
|
87
|
+
// Never call the cost provider or disclose its output to the requester.
|
|
88
|
+
if(actor!==job.participants.worker&&await canViewPrivate({job:structuredClone(job),actor})!==true)return {advisory:true,proposed_payout_raw:job.negotiation?.current?.worker_amount||null,realized_payout_raw:confirmed?payout:null,receipt_confirmed:confirmed,evidence_status};
|
|
89
|
+
const e=await evaluate(job);const frozen=!!await kv.get('a423_profit_basis',key(job));
|
|
90
|
+
return {...profitProjection({evaluation:e,payout_raw:payout,confirmed}),basis_frozen:frozen,evidence_status};
|
|
91
|
+
}
|
|
92
|
+
return {configuration,evaluate,prepare,authorize,project,prompt:options=>settlementPolicyPrompt({...options,policy:createSettlementPolicy(configuration)})};
|
|
93
|
+
}
|
|
94
|
+
module.exports={createSettlementPolicy,profitProjection,settlementPolicyPrompt,createProfitSettlementService};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {WorkflowJob,Json} from '../workflow.js';
|
|
2
|
+
import type {KeyValueStore} from '../storage/index.js';
|
|
3
|
+
export interface PolicyConfiguration {target_margin_bps?: number;min_margin_bps?: number;max_margin_bps?: number;require_counter?: boolean}
|
|
4
|
+
export interface PolicyInput {cost_basis_raw: string;budget_raw: string;escrow_raw: string;offer_raw?: string;negotiation_round?: number}
|
|
5
|
+
export interface PolicyEvaluation extends Required<PolicyConfiguration> {
|
|
6
|
+
version: 'a423.profit_policy.v1';cost_basis_raw: string;budget_raw: string;escrow_raw: string;affordable: string;target_payout: string;min_payout: string;max_payout: string;recommended_payout: string;expected_profit: string;expected_margin_bps: string | null;viability: 'no_cost'|'below_cost'|'below_minimum'|'below_target'|'target_affordable';decision: 'refund'|'propose'|'counter'|'accept'|'owner_approval_required';requires_owner_approval: boolean;offer_issue: string | null;offer_raw: string | null;offer_profit_raw: string | null;offer_margin_bps: string | null;negotiation_round: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SettlementPolicy {readonly configuration: Readonly<Required<PolicyConfiguration>>;evaluate(input: PolicyInput): PolicyEvaluation}
|
|
9
|
+
export type CostBasisProvider=(job: WorkflowJob)=>Promise<string>;
|
|
10
|
+
export interface ProfitProjection extends PolicyEvaluation {advisory: true;receipt_confirmed: boolean;projected_payout_raw: string;projected_profit_raw: string;projected_margin_bps: string | null;target_status: 'target_met'|'below_target';realized_payout_raw: string | null;realized_profit_raw: string | null;realized_margin_bps: string | null}
|
|
11
|
+
export interface PolicyContext {job: WorkflowJob;actor: string;action: string;input: any}
|
|
12
|
+
export interface ProfitServiceOptions {kv: KeyValueStore;costBasisFor: CostBasisProvider;budgetFor(job: WorkflowJob): Promise<string>;policy?: SettlementPolicy;thresholds?: PolicyConfiguration;approveException?(context: {job: WorkflowJob;actor: string;action: string;payout_raw: string;evaluation: PolicyEvaluation;proposal_hash: string | null}): Promise<{approved: boolean;approval_id?: string} | null>;canViewPrivate?(context: {job: WorkflowJob;actor: string}): Promise<boolean>}
|
|
13
|
+
export function createSettlementPolicy(options?: PolicyConfiguration): SettlementPolicy;
|
|
14
|
+
export function profitProjection(input: {evaluation: PolicyEvaluation;payout_raw: string;confirmed?: boolean}): ProfitProjection;
|
|
15
|
+
export function settlementPolicyPrompt(options?: {role?: 'worker'|'requester';policy?: SettlementPolicy;override?: string}): string;
|
|
16
|
+
export function createProfitSettlementService(options: ProfitServiceOptions): {
|
|
17
|
+
readonly configuration: Readonly<Required<PolicyConfiguration>>;
|
|
18
|
+
evaluate(job: WorkflowJob,options?: {freeze?: boolean;offer_raw?: string}): Promise<PolicyEvaluation>;
|
|
19
|
+
prepare(context: PolicyContext): Promise<Json>;
|
|
20
|
+
authorize(context: PolicyContext): Promise<boolean>;
|
|
21
|
+
project(job: WorkflowJob,actor: string,options?: {revalidate?(job: WorkflowJob,evidence: any): Promise<{status: string}>}): Promise<(ProfitProjection & {basis_frozen: boolean;evidence_status: string}) | {advisory: true;proposed_payout_raw: string | null;realized_payout_raw: string | null;receipt_confirmed: boolean;evidence_status: string} | null>;
|
|
22
|
+
prompt(options?: {role?: 'worker'|'requester';override?: string}): string;
|
|
23
|
+
};
|
package/src/workflow.cjs
CHANGED
|
@@ -24,6 +24,8 @@ const RULES = {
|
|
|
24
24
|
verify: { from: ['submitted'], roles: ['verifier'], to: 'verified', effect: true },
|
|
25
25
|
release: { from: ['verified'], roles: ['verifier'], to: 'paid', effect: true },
|
|
26
26
|
refund: { from: ['funded', 'running'], roles: ['requester'], to: 'refunded', effect: true },
|
|
27
|
+
decline: { from: ['draft', 'quoted', 'accepted'], roles: ['worker'], to: 'cancelled' },
|
|
28
|
+
reopen: { from: ['cancelled'], roles: ['requester', 'worker'], to: 'draft' },
|
|
27
29
|
cancel: { from: ['draft', 'quoted', 'accepted'], roles: ['requester'], to: 'cancelled' },
|
|
28
30
|
expire: { from: ['draft', 'quoted', 'accepted'], roles: ['requester', 'worker'], to: 'expired', effect: true },
|
|
29
31
|
dispute: { from: ['submitted', 'verified'], roles: ['requester', 'worker', 'verifier'], to: 'disputed' },
|
|
@@ -31,7 +33,7 @@ const RULES = {
|
|
|
31
33
|
};
|
|
32
34
|
/** Pure canonical JSON SHA-256. This is an integrity commitment, not authentication. */
|
|
33
35
|
function hashAgreement(terms) { return digest(terms); }
|
|
34
|
-
function createWorkflow({ store, adapters = {}, authorize, validateTerms, validateEvidence, transitions = {} } = {}) {
|
|
36
|
+
function createWorkflow({ store, adapters = {}, authorize, prepareInput, validateTerms, validateEvidence, transitions = {} } = {}) {
|
|
35
37
|
if (!store || ['get', 'create', 'compareAndSet'].some(k => typeof store[k] !== 'function')) throw new TypeError('Supply an atomic workflow store');
|
|
36
38
|
const rules = copy(RULES);
|
|
37
39
|
for (const [action, rule] of Object.entries(transitions)) {
|
|
@@ -52,9 +54,9 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
52
54
|
if (!job || !Object.values(job.participants).includes(actor)) error('NOT_FOUND', 'Job not found', 404);
|
|
53
55
|
return copy(job);
|
|
54
56
|
}
|
|
55
|
-
async function allowed(job, action, actor, input, rule) {
|
|
57
|
+
async function allowed(job, action, actor, input, rule, phase='execute') {
|
|
56
58
|
if (!rule.roles.some(role => job.participants[role] === actor)) error('FORBIDDEN', 'Actor cannot perform this action', 403);
|
|
57
|
-
if (authorize && await authorize({ job: copy(job), action, actor, input: copy(input) }) !== true) error('FORBIDDEN', 'Application policy rejected action', 403);
|
|
59
|
+
if (authorize && await authorize({ job: copy(job), action, actor, input: copy(input), phase }) !== true) error('FORBIDDEN', 'Application policy rejected action', 403);
|
|
58
60
|
}
|
|
59
61
|
async function complete(job, result) {
|
|
60
62
|
const pending = job.pending;
|
|
@@ -126,7 +128,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
126
128
|
if (participants.requester === participants.worker || actor !== participants.requester) error('FORBIDDEN', 'Requester must create the job and differ from worker', 403);
|
|
127
129
|
canonical(metadata); canonical(participants);
|
|
128
130
|
const at = stamp();
|
|
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 };
|
|
131
|
+
const job = { epoch:0,agreement_revision:0,pre_funding_cancellations:[],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 };
|
|
130
132
|
if (authorize && await authorize({ job: copy(job), action: 'create', actor, input: {} }) !== true) error('FORBIDDEN', 'Application policy rejected creation', 403);
|
|
131
133
|
if (!await store.create(job)) error('CONFLICT', 'Job ID already exists');
|
|
132
134
|
return copy(job);
|
|
@@ -140,6 +142,8 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
140
142
|
if(safety?.blocked) error('SAFETY_HOLD','Evidence requires reconciliation before further actions',409);
|
|
141
143
|
const rule = Object.hasOwn(rules,action) ? rules[action] : null;
|
|
142
144
|
if (!rule) error('UNKNOWN_ACTION', 'Unknown workflow action', 400);
|
|
145
|
+
if(!rule.roles.some(role=>job.participants[role]===actor))error('FORBIDDEN','Actor cannot perform this action',403);
|
|
146
|
+
if(prepareInput){input=await prepareInput({job:copy(job),action,actor,input:copy(input)});canonical(input);}
|
|
143
147
|
await allowed(job, action, actor, input, rule);
|
|
144
148
|
const key = digest([actor, idempotencyKey]);
|
|
145
149
|
const requestHash = digest([action, input]);
|
|
@@ -156,10 +160,30 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
156
160
|
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;}
|
|
157
161
|
if (!rule.from.includes(job.state)) error('INVALID_STATE', `Cannot ${action} from ${job.state}`);
|
|
158
162
|
let next = copy(job);
|
|
163
|
+
const fundingStarted=Object.values(job.operations).some(op=>op.result?.evidence?.event==='Funded') || job.evidence.some(e=>e.action==='fund') || job.events.some(e=>e.type==='fund.requested') || !!job.escrow_ledger;
|
|
164
|
+
if(['cancel','decline','reopen'].includes(action)&&fundingStarted)error('FUNDING_STARTED','Funding activity must be reconciled and closed; this job cannot be reopened',409);
|
|
165
|
+
if((job.epoch||0)>0&&['quote','accept','fund','cancel','decline'].includes(action)&&input?.expected_epoch!==job.epoch)error('STALE_EPOCH','Reload the job and supply its current expected_epoch',409);
|
|
166
|
+
if(['cancel','decline'].includes(action)){
|
|
167
|
+
if(input?.reason!==undefined&&(typeof input.reason!=='string'||!input.reason.trim()||input.reason.length>4000))error('INVALID_INPUT','Cancellation reason must be nonempty text, at most 4000 characters',400);
|
|
168
|
+
const record={id:randomUUID(),epoch:job.epoch||0,action,actor_id:actor,prior_state:job.state,accepted_by:copy(job.agreement?.accepted_by||[]),agreement:copy(job.agreement),reason:input?.reason||null,at:stamp(),reopen_approvals:[],reopened_at:null};
|
|
169
|
+
next.pre_funding_cancellations=[...(job.pre_funding_cancellations||[]),record];next.work_status='stopped';
|
|
170
|
+
}
|
|
171
|
+
if(action==='reopen'){
|
|
172
|
+
const history=copy(job.pre_funding_cancellations||[]),record=history.at(-1);
|
|
173
|
+
if(!record||record.reopened_at||input?.cancellation_id!==record.id)error('CANCELLATION_MISMATCH','Supply the current pre-funding cancellation_id',409);
|
|
174
|
+
const required=[...new Set([job.participants.requester,record.actor_id,...([job.participants.requester,job.participants.worker].every(id=>record.accepted_by.includes(id))?[job.participants.worker]:[])])];
|
|
175
|
+
if(!required.includes(actor))error('FORBIDDEN','This actor is not a required reopening approver',403);
|
|
176
|
+
record.reopen_approvals=[...new Set([...record.reopen_approvals,actor])];next.pre_funding_cancellations=history;
|
|
177
|
+
if(!required.every(id=>record.reopen_approvals.includes(id))){next.operations[key]={request_hash:requestHash,status:'confirmed',result:{status:'confirmed'}};return save(job,next,'reopen.approved',actor,{cancellation_id:record.id,required_approvers:required});}
|
|
178
|
+
record.reopened_at=stamp();next.epoch=(job.epoch||0)+1;next.agreement_revision=Math.max(job.agreement_revision||0,job.agreement?.revision||0);next.agreement=null;next.work_status='not_started';
|
|
179
|
+
}
|
|
159
180
|
if (action === 'quote') {
|
|
160
181
|
if (!input || !input.terms || typeof input.terms !== 'object' || Array.isArray(input.terms)) error('INVALID_INPUT', 'Supply a terms object', 400);
|
|
161
182
|
if (validateTerms) await validateTerms(copy(input.terms));
|
|
162
|
-
|
|
183
|
+
const terms=copy(input.terms);
|
|
184
|
+
if((job.epoch||0)>0)terms._a423_reopen={job_id:job.id,epoch:job.epoch};
|
|
185
|
+
next.agreement_revision=Math.max(job.agreement_revision||0,job.agreement?.revision||0)+1;
|
|
186
|
+
next.agreement = { revision: next.agreement_revision, terms, hash: hashAgreement(terms), accepted_by: [] };
|
|
163
187
|
}
|
|
164
188
|
if (action === 'accept') {
|
|
165
189
|
if (!input || input.agreement_hash !== job.agreement.hash || input.revision !== job.agreement.revision) error('AGREEMENT_MISMATCH', 'Accept the current agreement hash and revision');
|
|
@@ -176,6 +200,20 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
176
200
|
next.operations[key] = { request_hash: requestHash, status: 'confirmed', result: { status: 'confirmed' } };
|
|
177
201
|
return save(job, next, action, actor, input);
|
|
178
202
|
},
|
|
203
|
+
async rejectPendingOperation(id,{operation_id,reason},{actor}={}) {
|
|
204
|
+
const job=await get(id,actor),pending=job.pending;
|
|
205
|
+
if((store.getSafety?await store.getSafety(id):null)?.blocked)error('SAFETY_HOLD','Evidence requires reconciliation before recovery',409);
|
|
206
|
+
if(!pending||pending.id!==operation_id)error('PENDING_MISMATCH','Supply the current pending operation ID',409);
|
|
207
|
+
if(typeof reason!=='string'||!reason.trim()||reason.length>4000)error('INVALID_INPUT','Recovery reason required',400);
|
|
208
|
+
const rule=rules[pending.action];if(!rule)error('RECOVERY_UNSUPPORTED','Unknown pending action',409);
|
|
209
|
+
await allowed(job,pending.action,actor,pending.input,rule);
|
|
210
|
+
if(pending.request_hash!==digest([pending.action,pending.input]))error('RECOVERY_UNSAFE','Pending input integrity mismatch; manual investigation required',409);
|
|
211
|
+
const inspect=adapters[pending.action]?.rejectPending;
|
|
212
|
+
if(typeof inspect!=='function')error('RECOVERY_UNSUPPORTED','Adapter does not prove this operation is never executable',409);
|
|
213
|
+
const proof=await inspect({job:copy(job),input:copy(pending.input),operation:copy(pending)});
|
|
214
|
+
if(proof?.status!=='rejected'||proof.never_executable!==true)error('RECOVERY_UNSAFE','Operation might execute; reconcile instead',409);
|
|
215
|
+
return complete(job,{status:'rejected',reason,recovery:{operation_id,proof:copy(proof)}});
|
|
216
|
+
},
|
|
179
217
|
async resume(id, actor) {
|
|
180
218
|
const job=await get(id,actor);
|
|
181
219
|
if(!job.pending)return job;
|
|
@@ -187,7 +225,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
187
225
|
async reconcile(id, actor) {
|
|
188
226
|
const job = await get(id, actor);
|
|
189
227
|
if (!job.pending) return job;
|
|
190
|
-
await allowed(job, job.pending.action, actor, job.pending.input, rules[job.pending.action]);
|
|
228
|
+
await allowed(job, job.pending.action, actor, job.pending.input, rules[job.pending.action],'reconcile');
|
|
191
229
|
return execute(job, true);
|
|
192
230
|
},
|
|
193
231
|
async cancellation(id,actor){return require('./cancellation.cjs').getCancellationSubflow(await get(id,actor));},
|
|
@@ -202,7 +240,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
202
240
|
},
|
|
203
241
|
async receipts(id, actor) {
|
|
204
242
|
const job = await get(id, actor);
|
|
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 };
|
|
243
|
+
return { epoch:job.epoch||0,pre_funding_cancellations:job.pre_funding_cancellations||[],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 };
|
|
206
244
|
}
|
|
207
245
|
};
|
|
208
246
|
return workflow;
|