@allorc/a423-sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ import type { Workflow, WorkflowJob, Json } from '../workflow.js';
2
+ import type { SqliteJobStore, DurableQueue, KeyValueStore, SpendingStore } from '../storage/index.js';
3
+ export interface Task {
4
+ id: string; type: string; description: string;
5
+ deliverables: Array<{id: string;media_type: string;description: string}>;
6
+ acceptance: {validator: string;version: string;criteria: {[key: string]: Json}};
7
+ inputs?: Json;
8
+ }
9
+ export interface EvidenceCheck {status: 'valid' | 'invalid' | 'insufficient_confirmations' | 'unavailable';[key: string]: unknown}
10
+ export type Revalidator = (job: WorkflowJob,evidence: any) => Promise<EvidenceCheck>;
11
+ export interface SigningContext {job: WorkflowJob;operation: any;actor: string;step: string;to: string;data: string;phase: 'sign' | 'broadcast'}
12
+ export function validateTask(task: unknown): Task;
13
+ export function validateEscrowEvidence(evidence: unknown): {[key: string]: Json};
14
+ export function createSpendingAuthorizer(options: {spending: SpendingStore;limitsFor(context: SigningContext): Promise<{perTransaction: string;daily: string;total: string}>;authorize(context: SigningContext): Promise<boolean>}): (context: SigningContext) => Promise<boolean>;
15
+ export function runMaintenanceOnce(options: {workflow: Workflow;store: SqliteJobStore;actorFor(job: WorkflowJob): Promise<string | null>;allowExecute?: boolean;now?: number}): Promise<Array<{id: string;state?: string;pending?: boolean;error?: string}>>;
16
+ export function createExpiryAdapter(options?: {now?(): number}): {execute(context: {job: WorkflowJob}): Promise<any>;reconcile(context: {job: WorkflowJob}): Promise<any>};
17
+ export function enqueueRunningJobs(options: {store: SqliteJobStore;queue: DurableQueue}): Promise<number>;
18
+ export function runExecutionOnce(options: {workflow: Workflow;queue: DurableQueue;execute(context: {job: WorkflowJob;idempotencyKey: string}): Promise<{result_hash: string;[key: string]: Json}>;beforeExecute(job: WorkflowJob): Promise<boolean>;leaseMs?: number}): Promise<WorkflowJob | null>;
19
+ export function createEventStream(options: {store: SqliteJobStore;authenticate(request: Request): Promise<string | null>;pollMs?: number;maxDurationMs?: number}): (request: Request) => Promise<Response>;
20
+ export function createWebhookDispatcher(options: {queue: DurableQueue;kv: KeyValueStore;subscriptions: Array<{id: string;actor: string;url: string;after?: number}>;secretFor(id: string): Promise<string>;fetch?: typeof fetch;timeoutMs?: number}): {project(store: SqliteJobStore): Promise<void>;deliverOne(): Promise<any>};
21
+ export function watchEvidenceOnce(options: {store: SqliteJobStore;revalidate: Revalidator}): Promise<any[]>;
22
+ export function freshReceipts(options: {workflow: Workflow;id: string;actor: string;revalidate: Revalidator}): Promise<object>;
23
+ export function renderReceiptPdf(receipt: object): Promise<Uint8Array>;
24
+ export function planLegacyMigration(records: Array<{id: string;state: string}>): Array<{id: string;state: string;automatic: false;reason: string}>;
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<WorkflowJob>>;
27
+ export function createApplicationHandler(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;authenticate(request: Request): Promise<string | null>;revalidate: Revalidator;consentFor?(job: WorkflowJob): Promise<object>;legacyState?(actor: string): Promise<object>;authorityHandler?(request: Request): Promise<Response>;basePath?: string}): (request: Request) => Promise<Response>;
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}): Promise<{result_hash: string;[key: string]: Json}>;keeperEnabled?: boolean;allowAuthorityRequest?(request: Request): Promise<boolean>;requireIndependentArbitrator?: boolean}): {workflow: Workflow;applicationOptions: Parameters<typeof createApplicationHandler>[0];workerOptions: any;handler: (request: Request) => Promise<Response>;chatTools(actor: string): ReturnType<typeof createChatTools>};
@@ -0,0 +1,2 @@
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;
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ const { DatabaseSync } = require('node:sqlite');
3
+ const { randomUUID } = require('node:crypto');
4
+ const { mkdirSync, chmodSync } = require('node:fs');
5
+ const { dirname } = require('node:path');
6
+ const json=JSON.stringify, parse=JSON.parse;
7
+ /** Single-host durable SQLite drivers. All writers must use this file on local disk. */
8
+ function createSqliteStorage({filename,busyTimeoutMs=5000}={}) {
9
+ if(typeof filename!=='string'||!filename||!Number.isSafeInteger(busyTimeoutMs)||busyTimeoutMs<0||busyTimeoutMs>60000)throw new TypeError('Supply filename and valid busy timeout');
10
+ if(filename!==':memory:')mkdirSync(dirname(filename),{recursive:true,mode:0o700});
11
+ const db=new DatabaseSync(filename);
12
+ if(filename!==':memory:')chmodSync(filename,0o600);
13
+ db.exec(`PRAGMA busy_timeout=${busyTimeoutMs}; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
14
+ CREATE TABLE IF NOT EXISTS a423_jobs(id TEXT PRIMARY KEY,version INTEGER NOT NULL,payload TEXT NOT NULL);
15
+ CREATE TABLE IF NOT EXISTS a423_events(cursor INTEGER PRIMARY KEY AUTOINCREMENT,job_id TEXT NOT NULL,sequence INTEGER NOT NULL,payload TEXT NOT NULL,UNIQUE(job_id,sequence));
16
+ CREATE TABLE IF NOT EXISTS a423_transactions(id TEXT PRIMARY KEY,payload TEXT NOT NULL);
17
+ CREATE TABLE IF NOT EXISTS a423_nonces(scope TEXT PRIMARY KEY,next_nonce INTEGER NOT NULL);
18
+ CREATE TABLE IF NOT EXISTS a423_nonce_reservations(scope TEXT NOT NULL,operation_id TEXT NOT NULL,nonce INTEGER NOT NULL,PRIMARY KEY(scope,operation_id),UNIQUE(scope,nonce));
19
+ CREATE TABLE IF NOT EXISTS a423_kv(namespace TEXT NOT NULL,id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(namespace,id));
20
+ CREATE TABLE IF NOT EXISTS a423_queue(id TEXT PRIMARY KEY,kind TEXT NOT NULL,payload TEXT NOT NULL,status TEXT NOT NULL,available_at INTEGER NOT NULL,lease TEXT,lease_until INTEGER NOT NULL DEFAULT 0,attempts INTEGER NOT NULL DEFAULT 0,result TEXT);
21
+ CREATE INDEX IF NOT EXISTS a423_queue_ready ON a423_queue(status,available_at,lease_until);
22
+ CREATE TABLE IF NOT EXISTS a423_spend(id TEXT PRIMARY KEY,scope TEXT NOT NULL,amount TEXT NOT NULL,day TEXT NOT NULL,at INTEGER NOT NULL);
23
+ CREATE INDEX IF NOT EXISTS a423_spend_scope ON a423_spend(scope,day);`);
24
+ const atomic=fn=>{db.exec('BEGIN IMMEDIATE');try{const out=fn();db.exec('COMMIT');return out;}catch(e){db.exec('ROLLBACK');throw e;}};
25
+ const kv={
26
+ async get(namespace,id){const row=db.prepare('SELECT payload FROM a423_kv WHERE namespace=? AND id=?').get(namespace,id);return row?parse(row.payload):null;},
27
+ async put(namespace,id,value){db.prepare('INSERT INTO a423_kv VALUES(?,?,?) ON CONFLICT(namespace,id) DO UPDATE SET payload=excluded.payload').run(namespace,id,json(value));},
28
+ async create(namespace,id,value){return !!db.prepare('INSERT OR IGNORE INTO a423_kv VALUES(?,?,?)').run(namespace,id,json(value)).changes;},
29
+ async take(namespace,id){return atomic(()=>{const row=db.prepare('SELECT payload FROM a423_kv WHERE namespace=? AND id=?').get(namespace,id);db.prepare('DELETE FROM a423_kv WHERE namespace=? AND id=?').run(namespace,id);return row?parse(row.payload):null;});},
30
+ async list(namespace){return db.prepare('SELECT id,payload FROM a423_kv WHERE namespace=? ORDER BY id').all(namespace).map(r=>({id:r.id,value:parse(r.payload)}));}
31
+ };
32
+ const project=job=>{
33
+ for(const event of job.events)db.prepare('INSERT OR IGNORE INTO a423_events(job_id,sequence,payload) VALUES(?,?,?)').run(job.id,event.sequence,json({...event,job_id:job.id}));
34
+ };
35
+ const jobs={
36
+ async get(id){const r=db.prepare('SELECT payload FROM a423_jobs WHERE id=?').get(id);return r?parse(r.payload):null;},
37
+ async create(job){return atomic(()=>{const r=db.prepare('INSERT OR IGNORE INTO a423_jobs VALUES(?,?,?)').run(job.id,job.version,json(job));if(r.changes)project(job);return !!r.changes;});},
38
+ async compareAndSet(id,version,job){if(job.id!==id||job.version!==version+1)throw new Error('Invalid CAS record');return atomic(()=>{const r=db.prepare('UPDATE a423_jobs SET version=?,payload=? WHERE id=? AND version=?').run(job.version,json(job),id,version);if(r.changes)project(job);return !!r.changes;});},
39
+ async list({actor,limit=100,after=''}={}){if(!Number.isInteger(limit)||limit<1||limit>1000)throw new TypeError('Invalid limit');const rows=db.prepare('SELECT payload FROM a423_jobs WHERE id>? ORDER BY id').all(after).map(r=>parse(r.payload));return rows.filter(j=>!actor||Object.values(j.participants).includes(actor)).slice(0,limit);},
40
+ async events({actor,after=0,limit=100}={}){if(!actor||!Number.isSafeInteger(after)||after<0||!Number.isSafeInteger(limit)||limit<1||limit>1000)throw new TypeError('Invalid event query');return db.prepare('SELECT cursor,job_id,payload FROM a423_events WHERE cursor>? ORDER BY cursor').all(after).filter(r=>{const j=db.prepare('SELECT payload FROM a423_jobs WHERE id=?').get(r.job_id);return j&&Object.values(parse(j.payload).participants).includes(actor);}).slice(0,limit).map(r=>({cursor:r.cursor,...parse(r.payload)}));},
41
+ async getSafety(id){return kv.get('safety',id);},
42
+ async setSafety(id,value){return kv.put('safety',id,value);}
43
+ };
44
+ const transactions={
45
+ async get(id){const r=db.prepare('SELECT payload FROM a423_transactions WHERE id=?').get(id);return r?parse(r.payload):null;},
46
+ async create(id,record){return !!db.prepare('INSERT OR IGNORE INTO a423_transactions VALUES(?,?)').run(id,json(record)).changes;},
47
+ async list(){return db.prepare('SELECT id,payload FROM a423_transactions ORDER BY id').all().map(r=>({id:r.id,...parse(r.payload)}));},
48
+ async appendReplacement(id,expectedHash,replacement){return atomic(()=>{const row=db.prepare('SELECT payload FROM a423_transactions WHERE id=?').get(id);if(!row)throw Error('Unknown transaction');const record=parse(row.payload);if((record.replacements?.at(-1)?.hash||record.hash)!==expectedHash)throw Error('Replacement conflict');record.replacements=[...(record.replacements||[]),replacement];db.prepare('UPDATE a423_transactions SET payload=? WHERE id=?').run(json(record),id);});},
49
+ async reserveNonce(scope,minimum,operationId){if(!Number.isSafeInteger(minimum)||minimum<0)throw new TypeError('Invalid nonce');return atomic(()=>{const old=db.prepare('SELECT nonce FROM a423_nonce_reservations WHERE scope=? AND operation_id=?').get(scope,operationId);if(old)return old.nonce;const row=db.prepare('SELECT next_nonce FROM a423_nonces WHERE scope=?').get(scope);const nonce=Math.max(minimum,row?.next_nonce||0);db.prepare('INSERT INTO a423_nonces VALUES(?,?) ON CONFLICT(scope) DO UPDATE SET next_nonce=excluded.next_nonce').run(scope,nonce+1);db.prepare('INSERT INTO a423_nonce_reservations VALUES(?,?,?)').run(scope,operationId,nonce);return nonce;});}
50
+ };
51
+ const queue={
52
+ async enqueue({id,kind,payload,availableAt=Date.now()}){if(!id||!kind||!Number.isSafeInteger(availableAt))throw new TypeError('Invalid queue item');return !!db.prepare("INSERT OR IGNORE INTO a423_queue(id,kind,payload,status,available_at) VALUES(?,?,?,'ready',?)").run(id,kind,json(payload),availableAt).changes;},
53
+ async claim({kind,leaseMs=30000,now=Date.now()}={}){if(!Number.isSafeInteger(leaseMs)||leaseMs<1||!Number.isSafeInteger(now))throw new TypeError('Invalid lease');return atomic(()=>{const row=db.prepare("SELECT * FROM a423_queue WHERE kind=? AND ((status='ready' AND available_at<=?) OR (status='leased' AND lease_until<=?)) ORDER BY available_at,id LIMIT 1").get(kind,now,now);if(!row)return null;const lease=randomUUID();db.prepare("UPDATE a423_queue SET status='leased',lease=?,lease_until=?,attempts=attempts+1 WHERE id=?").run(lease,now+leaseMs,row.id);return {id:row.id,kind:row.kind,payload:parse(row.payload),lease,attempts:row.attempts+1,result:row.result?parse(row.result):null};});},
54
+ async heartbeat(id,lease,{leaseMs=30000,now=Date.now()}={}){return !!db.prepare("UPDATE a423_queue SET lease_until=? WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(now+leaseMs,id,lease,now).changes;},
55
+ async checkpoint(id,lease,result,{now=Date.now()}={}){return !!db.prepare("UPDATE a423_queue SET result=? WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(json(result),id,lease,now).changes;},
56
+ async complete(id,lease,result,{now=Date.now()}={}){return !!db.prepare("UPDATE a423_queue SET status='done',result=? WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(json(result),id,lease,now).changes;},
57
+ async retry(id,lease,{delayMs=1000,now=Date.now(),maxAttempts=10}={}){return !!db.prepare("UPDATE a423_queue SET status=CASE WHEN attempts>=? THEN 'failed' ELSE 'ready' END, available_at=?,lease=NULL,lease_until=0 WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(maxAttempts,now+delayMs,id,lease,now).changes;},
58
+ async get(id){const r=db.prepare('SELECT * FROM a423_queue WHERE id=?').get(id);return r?{id:r.id,kind:r.kind,payload:parse(r.payload),status:r.status,attempts:r.attempts,result:r.result?parse(r.result):null}:null;}
59
+ };
60
+ const spending={
61
+ async reserve({id,scope,amount,perTransaction,daily,total,now=Date.now()}){
62
+ const value=x=>{if(typeof x!=='string'||!/^(0|[1-9][0-9]*)$/.test(x))throw new TypeError('Use raw decimal amount strings');return BigInt(x);};
63
+ const n=value(amount),day=new Date(now).toISOString().slice(0,10);if(!id||!scope)throw new TypeError('Spend identity/scope required');
64
+ return atomic(()=>{const old=db.prepare('SELECT * FROM a423_spend WHERE id=?').get(id);if(old){if(old.scope!==scope||old.amount!==amount)throw Error('Spending idempotency conflict');}
65
+ const rows=db.prepare('SELECT amount,day FROM a423_spend WHERE scope=? AND id<>?').all(scope,id);const all=rows.reduce((a,r)=>a+BigInt(r.amount),0n),today=rows.filter(r=>r.day===day).reduce((a,r)=>a+BigInt(r.amount),0n);
66
+ if(perTransaction===undefined||daily===undefined||total===undefined)throw Error('Explicit per-transaction, daily and total caps required');
67
+ if(n>value(perTransaction)||n+today>value(daily)||n+all>value(total)){const error=Error('Spending limit exceeded');error.code='SPENDING_LIMIT_EXCEEDED';throw error;}
68
+ db.prepare('INSERT INTO a423_spend VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET day=excluded.day,at=excluded.at').run(id,scope,amount,day,now);return true;});
69
+ },
70
+ // Conservative reservations persist after approvals and uncertain outcomes. Release only after external reconciliation.
71
+ async list(scope){return db.prepare('SELECT * FROM a423_spend WHERE scope=? ORDER BY at,id').all(scope);},
72
+ async release(id){db.prepare('DELETE FROM a423_spend WHERE id=?').run(id);}
73
+ };
74
+ return {jobs,transactions,queue,spending,kv,
75
+ async resetDevelopmentData({chainId,confirm,assertChainReset,includeAuthority=false,dryRun=true}={}){
76
+ if(![1337,31337].includes(chainId)||confirm!=='RESET_LOCAL_A423'||typeof assertChainReset!=='function'||await assertChainReset()!==true)throw Error('Reset requires confirmed local chain reset');
77
+ const tables=['a423_jobs','a423_events','a423_transactions','a423_nonces','a423_nonce_reservations','a423_queue','a423_spend'];
78
+ const counts=Object.fromEntries(tables.map(name=>[name,db.prepare('SELECT COUNT(*) AS n FROM '+name).get().n]));
79
+ if(!dryRun)atomic(()=>{for(const name of tables)db.exec('DELETE FROM '+name);if(includeAuthority)db.exec('DELETE FROM a423_kv');else db.exec("DELETE FROM a423_kv WHERE namespace NOT LIKE 'authority:%'");});
80
+ return {dryRun,counts,includeAuthority};
81
+ },close(){db.close();}};
82
+ }
83
+ module.exports={createSqliteStorage};
@@ -0,0 +1,37 @@
1
+ import type { WorkflowStore, WorkflowJob, Json } from '../workflow.js';
2
+ export interface TransactionRecord {hash: string;raw: string;to: string;data: string;from: string;created_at?: string;replacements?: TransactionRecord[]}
3
+ export interface TransactionStore {get(id: string): Promise<TransactionRecord | null>;create(id: string,record: TransactionRecord): Promise<boolean>;reserveNonce(scope: string,minimum: number,operationId: string): Promise<number>;list(): Promise<Array<TransactionRecord & {id: string}>>;appendReplacement(id: string,expectedHash: string,replacement: TransactionRecord): Promise<void>}
4
+ export interface Safety { blocked: boolean; checks: Array<{status: string; [key: string]: unknown}>; checked_at: string }
5
+ export interface SqliteJobStore extends WorkflowStore {
6
+ list(options?: {actor?: string; after?: string; limit?: number}): Promise<WorkflowJob[]>;
7
+ events(options: {actor: string; after?: number; limit?: number}): Promise<Array<{cursor: number; job_id: string; sequence: number; type: string; actor_id: string; at: string; data: Json}>>;
8
+ getSafety(id: string): Promise<Safety | null>;
9
+ setSafety(id: string, value: Safety): Promise<void>;
10
+ }
11
+ export interface KeyValueStore {
12
+ get(namespace: string,id: string): Promise<any>;
13
+ put(namespace: string,id: string,value: unknown): Promise<void>;
14
+ create(namespace: string,id: string,value: unknown): Promise<boolean>;
15
+ take(namespace: string,id: string): Promise<any>;
16
+ list(namespace: string): Promise<Array<{id: string; value: any}>>;
17
+ }
18
+ export interface QueueItem {id: string; kind: string; payload: any; lease: string; attempts: number; result: any}
19
+ export interface DurableQueue {
20
+ enqueue(input: {id: string; kind: string; payload: unknown; availableAt?: number}): Promise<boolean>;
21
+ claim(options: {kind: string; leaseMs?: number; now?: number}): Promise<QueueItem | null>;
22
+ heartbeat(id: string, lease: string, options?: {leaseMs?: number; now?: number}): Promise<boolean>;
23
+ checkpoint(id: string, lease: string, result: unknown, options?: {now?: number}): Promise<boolean>;
24
+ complete(id: string, lease: string, result: unknown, options?: {now?: number}): Promise<boolean>;
25
+ retry(id: string, lease: string, options?: {delayMs?: number; now?: number; maxAttempts?: number}): Promise<boolean>;
26
+ get(id: string): Promise<{id: string; kind: string; payload: unknown; status: string; attempts: number; result: unknown} | null>;
27
+ }
28
+ export interface SpendingStore {
29
+ reserve(input: {id: string;scope: string;amount: string;perTransaction: string;daily: string;total: string;now?: number}): Promise<boolean>;
30
+ release(id: string): Promise<void>;
31
+ }
32
+ export interface SqliteStorage {
33
+ jobs: SqliteJobStore; transactions: TransactionStore; kv: KeyValueStore; queue: DurableQueue; spending: SpendingStore;
34
+ resetDevelopmentData(options: {chainId: number;confirm: 'RESET_LOCAL_A423';assertChainReset(): Promise<boolean>;includeAuthority?: boolean;dryRun?: boolean}): Promise<{dryRun: boolean;counts: Record<string,number>;includeAuthority: boolean}>;
35
+ close(): void;
36
+ }
37
+ export function createSqliteStorage(options: {filename: string;busyTimeoutMs?: number}): SqliteStorage;
@@ -0,0 +1,2 @@
1
+ import api from './index.cjs';
2
+ export const { createSqliteStorage } = api;
package/src/workflow.cjs CHANGED
@@ -31,7 +31,7 @@ const RULES = {
31
31
  };
32
32
  /** Pure canonical JSON SHA-256. This is an integrity commitment, not authentication. */
33
33
  function hashAgreement(terms) { return digest(terms); }
34
- function createWorkflow({ store, adapters = {}, authorize, validateTerms, transitions = {} } = {}) {
34
+ function createWorkflow({ store, adapters = {}, authorize, validateTerms, validateEvidence, transitions = {} } = {}) {
35
35
  if (!store || ['get', 'create', 'compareAndSet'].some(k => typeof store[k] !== 'function')) throw new TypeError('Supply an atomic workflow store');
36
36
  const rules = copy(RULES);
37
37
  for (const [action, rule] of Object.entries(transitions)) {
@@ -70,6 +70,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transi
70
70
  return save(job, next, pending.action + '.rejected', pending.actor, result);
71
71
  }
72
72
  if (!result.evidence || typeof result.evidence !== 'object' || Array.isArray(result.evidence)) error('INVALID_ADAPTER_RESULT', 'Confirmed effects require structured evidence', 502);
73
+ if(validateEvidence)await validateEvidence({job:copy(job),action:pending.action,evidence:copy(result.evidence)});
73
74
  const next = copy(job); next.pending = null; next.state = pending.target;
74
75
  next.operations[pending.key] = { request_hash: pending.request_hash, status: 'confirmed', result: copy(result) };
75
76
  next.evidence.push({ action: pending.action, operation_id: pending.id, evidence: copy(result.evidence) });
@@ -99,10 +100,13 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transi
99
100
  return copy(job);
100
101
  },
101
102
  get,
103
+ async assertSafe(id,actor){const job=await get(id,actor);const safety=store.getSafety?await store.getSafety(id):null;if(safety?.blocked)error('SAFETY_HOLD','Evidence requires reconciliation',409);return job;},
102
104
  async act(id, action, input, { actor, idempotencyKey } = {}) {
103
105
  identifier(actor); identifier(idempotencyKey); identifier(action); canonical(input);
104
106
  let job = await get(id, actor);
105
- const rule = rules[action];
107
+ const safety=store.getSafety ? await store.getSafety(id) : null;
108
+ if(safety?.blocked) error('SAFETY_HOLD','Evidence requires reconciliation before further actions',409);
109
+ const rule = Object.hasOwn(rules,action) ? rules[action] : null;
106
110
  if (!rule) error('UNKNOWN_ACTION', 'Unknown workflow action', 400);
107
111
  await allowed(job, action, actor, input, rule);
108
112
  const key = digest([actor, idempotencyKey]);
@@ -136,6 +140,14 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transi
136
140
  next.operations[key] = { request_hash: requestHash, status: 'confirmed', result: { status: 'confirmed' } };
137
141
  return save(job, next, action, actor, input);
138
142
  },
143
+ async resume(id, actor) {
144
+ const job=await get(id,actor);
145
+ if(!job.pending)return job;
146
+ const safety=store.getSafety ? await store.getSafety(id) : null;
147
+ if(safety?.blocked)error('SAFETY_HOLD','Evidence requires reconciliation',409);
148
+ await allowed(job,job.pending.action,actor,job.pending.input,rules[job.pending.action]);
149
+ return execute(job);
150
+ },
139
151
  async reconcile(id, actor) {
140
152
  const job = await get(id, actor);
141
153
  if (!job.pending) return job;
package/src/workflow.d.ts CHANGED
@@ -26,6 +26,7 @@ export interface WorkflowOptions {
26
26
  /** Return true to allow; authenticated actor ID must be supplied by your application. */
27
27
  authorize?(context: { job: WorkflowJob; action: string; actor: string; input: Json }): boolean | Promise<boolean>;
28
28
  /** Throw to reject application-specific terms. */
29
+ validateEvidence?(context: {job: WorkflowJob;action: string;evidence: {[key: string]: Json}}): void | Promise<void>;
29
30
  validateTerms?(terms: { [key: string]: Json }): void | Promise<void>;
30
31
  /** Explicit overrides replace the full rule; application is responsible for its invariants. */
31
32
  transitions?: Record<string, Transition>;
@@ -33,7 +34,9 @@ export interface WorkflowOptions {
33
34
  export interface Workflow {
34
35
  create(input: { id?: string; participants: WorkflowJob['participants']; metadata?: Json }, actor: string): Promise<WorkflowJob>;
35
36
  get(id: string, actor: string): Promise<WorkflowJob>;
37
+ assertSafe(id: string, actor: string): Promise<WorkflowJob>;
36
38
  act(id: string, action: string, input: Json, options: { actor: string; idempotencyKey: string }): Promise<WorkflowJob>;
39
+ resume(id: string, actor: string): Promise<WorkflowJob>;
37
40
  reconcile(id: string, actor: string): Promise<WorkflowJob>;
38
41
  receipts(id: string, actor: string): Promise<Pick<WorkflowJob, 'protocol' | 'state' | 'agreement' | 'submission' | 'evidence' | 'events' | 'pending'> & { job_id: string }>;
39
42
  }