@allorc/a423-sdk 0.5.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 +106 -0
- package/CHANGELOG.md +36 -0
- package/ESCROW.md +2 -0
- package/INTEGRATION.md +15 -1
- package/OPERATIONS.md +4 -0
- package/PROFIT-POLICY.md +154 -0
- package/README.md +606 -63
- package/SECURITY.md +6 -0
- package/SETTLEMENT.md +4 -0
- package/examples/README.md +29 -0
- package/examples/operations-dashboard.html +3 -3
- package/examples/quickstart.mjs +33 -0
- package/package.json +14 -3
- package/src/cancellation.cjs +74 -0
- package/src/escrow/cancellation.cjs +34 -0
- package/src/escrow/index.cjs +41 -2
- package/src/escrow/index.d.mts +6 -2
- package/src/escrow/index.d.ts +6 -2
- package/src/escrow/index.mjs +1 -1
- package/src/escrow/negotiation.cjs +1 -1
- package/src/escrow/services.cjs +1 -1
- package/src/operations/application.cjs +14 -6
- package/src/operations/factory.cjs +6 -4
- package/src/operations/index.cjs +10 -0
- package/src/operations/index.d.ts +5 -3
- package/src/operations/index.mjs +1 -1
- 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 +97 -13
- package/src/workflow.d.ts +38 -5
- package/src/workflow.mjs +1 -1
package/src/operations/index.mjs
CHANGED
|
@@ -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;
|
|
@@ -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;
|
|
@@ -62,11 +64,13 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
62
64
|
canonical(result);
|
|
63
65
|
if (result.status === 'pending') {
|
|
64
66
|
const next = copy(job); next.pending.observation = copy(result);
|
|
67
|
+
require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'pending',evidence:result,at:stamp()});
|
|
65
68
|
return save(job, next, pending.action + '.pending', pending.actor, result);
|
|
66
69
|
}
|
|
67
70
|
if (result.status === 'rejected') {
|
|
68
71
|
const next = copy(job); next.pending = null;
|
|
69
72
|
next.operations[pending.key] = { request_hash: pending.request_hash, status: 'rejected', result: copy(result) };
|
|
73
|
+
require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'rejected',error:result.reason,at:stamp()});
|
|
70
74
|
return save(job, next, pending.action + '.rejected', pending.actor, result);
|
|
71
75
|
}
|
|
72
76
|
if (!result.evidence || typeof result.evidence !== 'object' || Array.isArray(result.evidence)) error('INVALID_ADAPTER_RESULT', 'Confirmed effects require structured evidence', 502);
|
|
@@ -83,7 +87,12 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
83
87
|
next.negotiation={status:'open',origin_state:job.negotiation?.origin_state||job.state,current:record,history:[...(job.negotiation?.history||[]),record]};
|
|
84
88
|
next.work_status='paused';eventData={...eventData,proposal:record};
|
|
85
89
|
}
|
|
86
|
-
if(pending.action==='
|
|
90
|
+
if(pending.action==='approve_cancellation'){
|
|
91
|
+
const instruction=require('./cancellation.cjs').validateCancellationInstruction(job,result.evidence.instruction);
|
|
92
|
+
next.negotiation={...copy(job.negotiation),status:'accepted',acceptance_basis:'dual_wallet_signatures'};next.work_status='paused';
|
|
93
|
+
eventData={...eventData,instruction_id:instruction.id};
|
|
94
|
+
}
|
|
95
|
+
if(['accept_settlement','execute_cancellation'].includes(pending.action)){
|
|
87
96
|
const p=job.negotiation?.current;
|
|
88
97
|
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
98
|
next.negotiation={...copy(job.negotiation),status:'settled',accepted_by:pending.actor,settled_at:stamp()};next.work_status='stopped';
|
|
@@ -99,6 +108,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
99
108
|
const closed=[5,6,7].includes(e.state);
|
|
100
109
|
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
110
|
}
|
|
111
|
+
require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'confirmed',evidence:result.evidence,instruction:result.evidence.instruction,at:stamp()});
|
|
102
112
|
return save(job, next, pending.action + '.confirmed', pending.actor, eventData);
|
|
103
113
|
}
|
|
104
114
|
async function execute(job, reconcile = false) {
|
|
@@ -110,7 +120,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
110
120
|
const result = await fn({ job: copy(job), input: copy(pending.input), operation: copy(pending) });
|
|
111
121
|
return complete(job, result);
|
|
112
122
|
}
|
|
113
|
-
|
|
123
|
+
const workflow={
|
|
114
124
|
async create({ id = randomUUID(), participants, metadata = {} }, actor) {
|
|
115
125
|
identifier(id); identifier(actor);
|
|
116
126
|
if (!participants || !participants.requester || !participants.worker || !participants.verifier) throw new TypeError('Supply requester, worker and verifier participants');
|
|
@@ -118,7 +128,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
118
128
|
if (participants.requester === participants.worker || actor !== participants.requester) error('FORBIDDEN', 'Requester must create the job and differ from worker', 403);
|
|
119
129
|
canonical(metadata); canonical(participants);
|
|
120
130
|
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 };
|
|
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 };
|
|
122
132
|
if (authorize && await authorize({ job: copy(job), action: 'create', actor, input: {} }) !== true) error('FORBIDDEN', 'Application policy rejected creation', 403);
|
|
123
133
|
if (!await store.create(job)) error('CONFLICT', 'Job ID already exists');
|
|
124
134
|
return copy(job);
|
|
@@ -132,6 +142,8 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
132
142
|
if(safety?.blocked) error('SAFETY_HOLD','Evidence requires reconciliation before further actions',409);
|
|
133
143
|
const rule = Object.hasOwn(rules,action) ? rules[action] : null;
|
|
134
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);}
|
|
135
147
|
await allowed(job, action, actor, input, rule);
|
|
136
148
|
const key = digest([actor, idempotencyKey]);
|
|
137
149
|
const requestHash = digest([action, input]);
|
|
@@ -143,12 +155,35 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
143
155
|
if (job.pending.key !== key || job.pending.request_hash !== requestHash) error('OPERATION_PENDING', 'Reconcile or retry the pending operation first');
|
|
144
156
|
return execute(job);
|
|
145
157
|
}
|
|
158
|
+
const cancellation=require('./cancellation.cjs').getCancellationSubflow(job);
|
|
159
|
+
if(['propose_settlement','counter_settlement'].includes(action)&&cancellation?.instruction)error('CANCELLATION_ACCEPTED','An accepted instruction must be executed or resolved before renegotiating',409);
|
|
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;}
|
|
146
161
|
if (!rule.from.includes(job.state)) error('INVALID_STATE', `Cannot ${action} from ${job.state}`);
|
|
147
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
|
+
}
|
|
148
180
|
if (action === 'quote') {
|
|
149
181
|
if (!input || !input.terms || typeof input.terms !== 'object' || Array.isArray(input.terms)) error('INVALID_INPUT', 'Supply a terms object', 400);
|
|
150
182
|
if (validateTerms) await validateTerms(copy(input.terms));
|
|
151
|
-
|
|
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: [] };
|
|
152
187
|
}
|
|
153
188
|
if (action === 'accept') {
|
|
154
189
|
if (!input || input.agreement_hash !== job.agreement.hash || input.revision !== job.agreement.revision) error('AGREEMENT_MISMATCH', 'Accept the current agreement hash and revision');
|
|
@@ -157,6 +192,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
157
192
|
if (rule.effect) {
|
|
158
193
|
if (!adapters[action]) error('ADAPTER_REQUIRED', `Configure ${action} adapter`, 501);
|
|
159
194
|
next.pending = { id: `a423:${id}:${key}`, key, action, actor, input: copy(input), request_hash: requestHash, target: rule.to, created_at: stamp(), observation: null };
|
|
195
|
+
require('./cancellation.cjs').updateCancellationSubflow(job,next,{action,actor,phase:'requested',input,at:stamp()});
|
|
160
196
|
job = await save(job, next, action + '.requested', actor);
|
|
161
197
|
return execute(job);
|
|
162
198
|
}
|
|
@@ -164,6 +200,20 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
164
200
|
next.operations[key] = { request_hash: requestHash, status: 'confirmed', result: { status: 'confirmed' } };
|
|
165
201
|
return save(job, next, action, actor, input);
|
|
166
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
|
+
},
|
|
167
217
|
async resume(id, actor) {
|
|
168
218
|
const job=await get(id,actor);
|
|
169
219
|
if(!job.pending)return job;
|
|
@@ -175,16 +225,28 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
|
|
|
175
225
|
async reconcile(id, actor) {
|
|
176
226
|
const job = await get(id, actor);
|
|
177
227
|
if (!job.pending) return job;
|
|
178
|
-
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');
|
|
179
229
|
return execute(job, true);
|
|
180
230
|
},
|
|
231
|
+
async cancellation(id,actor){return require('./cancellation.cjs').getCancellationSubflow(await get(id,actor));},
|
|
232
|
+
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);},
|
|
233
|
+
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);},
|
|
234
|
+
async acceptCancellation(id,input,{actor,idempotencyKey,forward=true}={}){
|
|
235
|
+
if(typeof forward!=='boolean')throw new TypeError('forward must be a boolean');
|
|
236
|
+
let job=await workflow.act(id,'approve_cancellation',input,{actor,idempotencyKey});
|
|
237
|
+
const subflow=require('./cancellation.cjs').getCancellationSubflow(job);
|
|
238
|
+
if(!forward||job.pending||!subflow?.instruction||subflow.status!=='accepted')return job;
|
|
239
|
+
return workflow.act(id,'execute_cancellation',{instruction_id:subflow.instruction.id},{actor,idempotencyKey:'cancel_forward_'+digest(subflow.instruction.id)});
|
|
240
|
+
},
|
|
181
241
|
async receipts(id, actor) {
|
|
182
242
|
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 };
|
|
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 };
|
|
184
244
|
}
|
|
185
245
|
};
|
|
246
|
+
return workflow;
|
|
186
247
|
}
|
|
187
|
-
function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError } = {}) {
|
|
248
|
+
function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError, forwardCancellations = true, revalidateCancellationReceipt } = {}) {
|
|
249
|
+
if(typeof forwardCancellations!=='boolean')throw new TypeError('forwardCancellations must be a boolean');
|
|
188
250
|
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
251
|
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || maxBodyBytes > 1048576) throw new TypeError('Invalid maxBodyBytes');
|
|
190
252
|
const respond = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
@@ -192,10 +254,10 @@ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423',
|
|
|
192
254
|
try {
|
|
193
255
|
const path = new URL(request.url).pathname;
|
|
194
256
|
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));
|
|
257
|
+
const match = /^\/jobs(?:\/([A-Za-z0-9_-]{1,128})(?:\/(actions|reconcile|receipts|subflows|cancellation(?:\/(?:accept|execute|instruction|receipt))?))?)?$/.exec(path.slice(basePath.length));
|
|
196
258
|
if (!match) error('NOT_FOUND', 'Route not found', 404);
|
|
197
259
|
const [, id, action] = match;
|
|
198
|
-
const method = !id ||
|
|
260
|
+
const method = !id || ['actions','reconcile','cancellation/accept','cancellation/execute'].includes(action) ? 'POST' : 'GET';
|
|
199
261
|
if (request.method !== method) { const r = respond({ error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method}` } }, 405); r.headers.set('allow', method); return r; }
|
|
200
262
|
const actor = await authenticate(request);
|
|
201
263
|
if (!actor) error('UNAUTHENTICATED', 'Authentication required', 401);
|
|
@@ -208,6 +270,16 @@ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423',
|
|
|
208
270
|
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { error('INVALID_INPUT', 'Invalid JSON', 400); }
|
|
209
271
|
}
|
|
210
272
|
if (!id) return respond({ job: await workflow.create(body, actor) }, 201);
|
|
273
|
+
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});}
|
|
274
|
+
if(action==='cancellation')return respond({job_id:id,subflow:await workflow.cancellation(id,actor)});
|
|
275
|
+
if(action==='cancellation/instruction')return respond({instruction:await workflow.cancellationInstruction(id,actor)});
|
|
276
|
+
if(action==='cancellation/receipt'){
|
|
277
|
+
const receipt=await workflow.cancellationReceipt(id,actor);
|
|
278
|
+
if(revalidateCancellationReceipt)await revalidateCancellationReceipt({job:await workflow.get(id,actor),receipt,actor});
|
|
279
|
+
return respond({receipt});
|
|
280
|
+
}
|
|
281
|
+
if(action==='cancellation/accept')return respond({job:await workflow.acceptCancellation(id,body,{actor,idempotencyKey:request.headers.get('idempotency-key'),forward:forwardCancellations})});
|
|
282
|
+
if(action==='cancellation/execute')return respond({job:await workflow.act(id,'execute_cancellation',body,{actor,idempotencyKey:request.headers.get('idempotency-key')})});
|
|
211
283
|
if (action === 'actions') return respond({ job: await workflow.act(id, body.action, body.input ?? {}, { actor, idempotencyKey: request.headers.get('idempotency-key') }) });
|
|
212
284
|
if (action === 'reconcile') return respond({ job: await workflow.reconcile(id, actor) });
|
|
213
285
|
if (action === 'receipts') return respond(await workflow.receipts(id, actor));
|
|
@@ -240,12 +312,24 @@ function createWorkflowClient({ baseUrl, headers, fetch: fetchImpl = globalThis.
|
|
|
240
312
|
return data;
|
|
241
313
|
}
|
|
242
314
|
const path = id => '/jobs/' + identifier(id);
|
|
243
|
-
|
|
315
|
+
const workflow={
|
|
244
316
|
async create(input) { return (await request('/jobs',input)).job; },
|
|
245
317
|
async get(id) { return (await request(path(id))).job; },
|
|
246
318
|
async act(id,action,input,{idempotencyKey}={}) { identifier(action);identifier(idempotencyKey);return (await request(path(id)+'/actions',{action,input},idempotencyKey)).job; },
|
|
247
319
|
async reconcile(id) { return (await request(path(id)+'/reconcile',{})).job; },
|
|
320
|
+
async cancellation(id){return (await request(path(id)+'/cancellation')).subflow;},
|
|
321
|
+
async cancellationInstruction(id){return (await request(path(id)+'/cancellation/instruction')).instruction;},
|
|
322
|
+
async cancellationReceipt(id){return (await request(path(id)+'/cancellation/receipt')).receipt;},
|
|
323
|
+
async subflows(id){return (await request(path(id)+'/subflows')).subflows;},
|
|
324
|
+
async acceptCancellation(id,input,{idempotencyKey}={}){identifier(idempotencyKey);return (await request(path(id)+'/cancellation/accept',input,idempotencyKey)).job;},
|
|
325
|
+
async executeCancellation(id,input,{idempotencyKey}={}){identifier(idempotencyKey);return (await request(path(id)+'/cancellation/execute',input,idempotencyKey)).job;},
|
|
248
326
|
async receipts(id) { return request(path(id)+'/receipts'); }
|
|
249
327
|
};
|
|
328
|
+
return workflow;
|
|
250
329
|
}
|
|
251
330
|
module.exports.createWorkflowClient = createWorkflowClient;
|
|
331
|
+
|
|
332
|
+
module.exports.getCancellationSubflow=(job)=>require('./cancellation.cjs').getCancellationSubflow(job);
|
|
333
|
+
module.exports.createCancellationInstruction=(job,proofs)=>require('./cancellation.cjs').createCancellationInstruction(job,proofs);
|
|
334
|
+
module.exports.validateCancellationInstruction=(job,instruction)=>require('./cancellation.cjs').validateCancellationInstruction(job,instruction);
|
|
335
|
+
module.exports.validateCancellationReceipt=(job,receipt)=>require('./cancellation.cjs').validateCancellationReceipt(job,receipt);
|
package/src/workflow.d.ts
CHANGED
|
@@ -4,9 +4,25 @@ 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}
|
|
22
|
+
export interface PreFundingCancellation {id: string;epoch: number;action: 'cancel'|'decline';actor_id: string;prior_state: string;accepted_by: string[];agreement: Agreement | null;reason: string | null;at: string;reopen_approvals: string[];reopened_at: string | null}
|
|
9
23
|
export interface WorkflowJob {
|
|
24
|
+
epoch?: number;agreement_revision?: number;pre_funding_cancellations?: PreFundingCancellation[];
|
|
25
|
+
subflows?: CancellationSubflow[];active_subflow_id?: string | null;
|
|
10
26
|
work_status?: string;negotiation?: Negotiation | null;escrow_ledger?: EscrowLedger | null;
|
|
11
27
|
id: string; protocol: 'a423.workflow.v1'; version: number; state: string;
|
|
12
28
|
participants: { requester: string; worker: string; verifier: string; [role: string]: string };
|
|
@@ -27,9 +43,10 @@ export type Effect = (context: AdapterContext) => Promise<AdapterResult>;
|
|
|
27
43
|
export interface Transition { from: string[]; roles: string[]; to: string; effect?: boolean }
|
|
28
44
|
export interface WorkflowOptions {
|
|
29
45
|
store: WorkflowStore;
|
|
30
|
-
adapters?: Record<string, Effect | { execute: Effect; reconcile?: Effect }>;
|
|
46
|
+
adapters?: Record<string, Effect | { execute: Effect; reconcile?: Effect; rejectPending?: Effect }>;
|
|
31
47
|
/** Return true to allow; authenticated actor ID must be supplied by your application. */
|
|
32
|
-
authorize?(context: { job: WorkflowJob; action: string; actor: string; input: Json }): boolean | Promise<boolean>;
|
|
48
|
+
authorize?(context: { job: WorkflowJob; action: string; actor: string; input: Json;phase?: 'execute'|'reconcile' }): boolean | Promise<boolean>;
|
|
49
|
+
prepareInput?(context: {job: WorkflowJob;action: string;actor: string;input: Json}): Promise<Json>;
|
|
33
50
|
/** Throw to reject application-specific terms. */
|
|
34
51
|
validateEvidence?(context: {job: WorkflowJob;action: string;evidence: {[key: string]: Json}}): void | Promise<void>;
|
|
35
52
|
validateTerms?(terms: { [key: string]: Json }): void | Promise<void>;
|
|
@@ -41,15 +58,20 @@ export interface Workflow {
|
|
|
41
58
|
get(id: string, actor: string): Promise<WorkflowJob>;
|
|
42
59
|
assertSafe(id: string, actor: string): Promise<WorkflowJob>;
|
|
43
60
|
act(id: string, action: string, input: Json, options: { actor: string; idempotencyKey: string }): Promise<WorkflowJob>;
|
|
61
|
+
rejectPendingOperation(id: string,input: {operation_id: string;reason: string},options: {actor: string}): Promise<WorkflowJob>;
|
|
44
62
|
resume(id: string, actor: string): Promise<WorkflowJob>;
|
|
45
63
|
reconcile(id: string, actor: string): Promise<WorkflowJob>;
|
|
46
|
-
|
|
64
|
+
cancellation(id: string, actor: string): Promise<CancellationSubflow | null>;
|
|
65
|
+
cancellationInstruction(id: string, actor: string): Promise<CancellationInstruction>;
|
|
66
|
+
cancellationReceipt(id: string, actor: string): Promise<CancellationReceipt>;
|
|
67
|
+
acceptCancellation(id: string,input: CancellationApproval,options: {actor: string;idempotencyKey: string;forward?: boolean}): Promise<WorkflowJob>;
|
|
68
|
+
receipts(id: string, actor: string): Promise<Pick<WorkflowJob, 'epoch' | 'pre_funding_cancellations' | 'protocol' | 'state' | 'agreement' | 'submission' | 'evidence' | 'events' | 'pending' | 'work_status' | 'negotiation' | 'escrow_ledger' | 'subflows' | 'active_subflow_id'> & { job_id: string }>;
|
|
47
69
|
}
|
|
48
70
|
export function hashAgreement(terms: Json): string;
|
|
49
71
|
export function createWorkflow(options: WorkflowOptions): Workflow;
|
|
50
72
|
export function createWorkflowHandler(options: {
|
|
51
73
|
workflow: Workflow; authenticate(request: Request): string | null | Promise<string | null>;
|
|
52
|
-
basePath?: string; maxBodyBytes?: number; onError?(error: unknown): void | Promise<void>;
|
|
74
|
+
basePath?: string; maxBodyBytes?: number; forwardCancellations?: boolean; revalidateCancellationReceipt?(context: {job: WorkflowJob;receipt: CancellationReceipt;actor: string}): Promise<void>; onError?(error: unknown): void | Promise<void>;
|
|
53
75
|
}): (request: Request) => Promise<Response>;
|
|
54
76
|
export interface WorkflowClient {
|
|
55
77
|
/** Supply a stable id so creation retries can be resolved with get(id). */
|
|
@@ -57,9 +79,20 @@ export interface WorkflowClient {
|
|
|
57
79
|
get(id: string): Promise<WorkflowJob>;
|
|
58
80
|
act(id: string, action: string, input: Json, options: { idempotencyKey: string }): Promise<WorkflowJob>;
|
|
59
81
|
reconcile(id: string): Promise<WorkflowJob>;
|
|
82
|
+
cancellation(id: string): Promise<CancellationSubflow | null>;
|
|
83
|
+
cancellationInstruction(id: string): Promise<CancellationInstruction>;
|
|
84
|
+
cancellationReceipt(id: string): Promise<CancellationReceipt>;
|
|
85
|
+
subflows(id: string): Promise<CancellationSubflow[]>;
|
|
86
|
+
acceptCancellation(id: string,input: CancellationApproval,options: {idempotencyKey: string}): Promise<WorkflowJob>;
|
|
87
|
+
executeCancellation(id: string,input: {instruction_id: string;transaction_hash?: string},options: {idempotencyKey: string}): Promise<WorkflowJob>;
|
|
60
88
|
receipts(id: string): ReturnType<Workflow['receipts']>;
|
|
61
89
|
}
|
|
62
90
|
export function createWorkflowClient(options: {
|
|
63
91
|
baseUrl: string; headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
64
92
|
fetch?: typeof globalThis.fetch; timeoutMs?: number;
|
|
65
93
|
}): WorkflowClient;
|
|
94
|
+
|
|
95
|
+
export function getCancellationSubflow(job: WorkflowJob): CancellationSubflow | null;
|
|
96
|
+
export function createCancellationInstruction(job: WorkflowJob,proofs: {payer_signature: string;worker_signature: string}): CancellationInstruction;
|
|
97
|
+
export function validateCancellationInstruction(job: WorkflowJob,instruction: CancellationInstruction): CancellationInstruction;
|
|
98
|
+
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;
|