@allorc/a423-sdk 0.1.0 → 0.2.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Add opt-in `@allorc/a423-sdk/workflow` exports: workflow engine, Next.js-compatible handler, typed HTTP client and canonical agreement hashing.
6
+ - Add versioned quotes, bilateral acceptance, role checks, application policies and configurable transitions.
7
+ - Cover funding, execution gate, submission, verification, release, refund, cancellation, expiry, disputes and resolution through explicit adapters.
8
+ - Persist pending operations before effects, stable provider idempotency keys, reconciliation observations and structured evidence.
9
+ - Preserve the 0.1 root API unchanged. Its approval/settlement behaviour is distinct from the new workflow.
10
+ - Add migration instructions and lifecycle/recovery/HTTP tests. No chain adapter, database driver, signer, queue or PDF renderer is bundled.
package/INTEGRATION.md ADDED
@@ -0,0 +1,63 @@
1
+ # Integration instructions for the MVP coding agent
2
+
3
+ Install after the maintainer publishes:
4
+
5
+ ```sh
6
+ npm install @allorc/a423-sdk@0.2.0
7
+ ```
8
+
9
+ For testing before publication, install the maintainer's `npm pack` tarball instead. Do not silently install 0.1 and assume the workflow entry point exists.
10
+
11
+ ## Task
12
+
13
+ Integrate the opt-in `@allorc/a423-sdk/workflow` API into the existing MVP. Inspect the installed README, declarations and exports first. Preserve the existing app until a complete equivalent journey passes. Do not introduce demo keys, positional signer lookup, unknown-agent fallback, or authentication based only on x-agent-id. Do not deploy or move real funds as part of integration tests.
14
+
15
+ 1. Implement a durable `WorkflowStore` in the app's database using atomic create and version-checked replacement. Store the whole workflow record together. Keep legacy data separate; explicitly migrate records only after reviewing the mapping. Use stable application-assigned job IDs; recover duplicate creates by loading and comparing the record.
16
+ 2. Implement authentication and bind authenticated agent IDs to the application's wallet/signer registry. Keep `@allorc/identity` as public identity derivation only. Configure participants as requester, worker and verifier. Reject unknown agents before signing anything.
17
+ 3. Define the application's JSON agreement terms and `validateTerms`: task specification, price, raw amount string, token/chain/contract, participant wallets, deadline, artifact requirements and verification/refund/dispute policy. Preserve decimal metadata. Use the returned SHA-256 agreement hash and revision for bilateral acceptance. Do not substitute this for the existing contract's keccak spec hash: explicitly map and verify both formats where needed.
18
+ 4. Implement `fund.execute/reconcile`. Use `operation.id` for persistent deduplication; save provider transaction identifiers before waiting. Validate successful funding event plus actual escrow fields against the agreed terms and trusted chain/contract configuration. Return pending while unconfirmed, confirmed with structured observations only when policy passes, and rejected only on definitive failure. Confirm how the actual ABI exposes specHash; do not skip the comparison silently.
19
+ 5. Implement `start` as a fresh eligibility gate. Fail closed if escrow configuration, evidence or chain access is missing. Match funding and assigned worker before scheduling execution. Dispatch actual work through the app's durable queue using a deduplicated run key. The SDK itself does not execute an AI agent.
20
+ 6. Implement one submission path: store the actual artifact bytes, hash them, persist the manifest/reference, submit the required on-chain commitment, and wait/reconcile before confirming. If durable execution starts asynchronously, it must not report submitted just because the queue accepted it.
21
+ 7. Implement `verify`: validate the exact committed artifact against accepted criteria. Only return confirmed after checks pass. A definitive failure returns rejected, then a separately authorized dispute action records the dispute. Record check results and validator version. If revisions are needed, add explicit transitions and define how they interact with already submitted commitments.
22
+ 8. Implement `release.execute/reconcile`, checking caller role, recorded verification and actual payment evidence. Verify exact amount/recipient and relevant event. The SDK state guard does not change contract rules; document whether the contract itself requires submission. Never infer successful payment from a broadcast hash alone.
23
+ 9. Implement `refund.execute/reconcile` based on actual contract eligibility. The default rule allows requesting a refund while funded/running; the adapter must enforce the deadline and absence of submission. Submitted disputes require an explicit supported resolution policy. Implement `resolve` only when that policy exists; confirmed resolution evidence must state whether money was released, refunded or remains locked. `resolved` alone does not imply payment.
24
+ 10. Add application deadline/expiry checks to authorization and the `expire` adapter. Schedule sweeps with authorized roles. Cancellation only applies before funding in the default workflow. Do not claim automatic sweeps or a dispute timeout exist if they are not implemented.
25
+ 11. Configure each adapter's `reconcile` to inspect the provider using durable operation records. Unknown outcomes remain pending. Poll/reconcile in a durable worker. Handle configured confirmations, disappearing observations and reorgs according to the app's policy; the SDK does not include a chain watcher. Add explicit corrective transitions/policies if required rather than editing records outside CAS.
26
+ 12. Expose `createWorkflowHandler` through a Node-runtime Next.js catch-all route. Use `createWorkflowClient` or documented HTTP endpoints externally. The old root API and new workflow API use distinct models; migrate the client deliberately.
27
+ 13. Build machine-readable receipts through `workflow.receipts`. Revalidate chain evidence before generating chain-backed PDFs; derive amounts/participants from validated observations and use actual token decimals. Distinguish cached observations from freshly checked evidence.
28
+
29
+ ## Adapter shape
30
+
31
+ ```ts
32
+ const adapters = {
33
+ fund: {
34
+ async execute({ job, input, operation }) {
35
+ // Deduplicate operation.id durably, enforce agreed terms and submit/observe.
36
+ return { status: 'pending', reference: 'your-provider-operation-id' };
37
+ },
38
+ async reconcile({ job, operation }) {
39
+ // Query your saved provider operation. Do not blindly resend.
40
+ return { status: 'confirmed', evidence: { /* independently validated fields */ } };
41
+ },
42
+ },
43
+ // start, submit, verify, release, refund, expire, resolve as applicable.
44
+ };
45
+ ```
46
+
47
+ This is a structural example, not a working escrow adapter. Never return confirmed unconditionally in an actual integration.
48
+
49
+ ## Required validation before replacing MVP routes
50
+
51
+ - Happy path: create → quote → both accept same revision/hash → fund confirmed → start gate → submit confirmed → verify → release confirmed → JSON/PDF evidence.
52
+ - Quotes: revised terms clear acceptances; stale revision rejected; funding prevents edits; deadline policy rejects expired quotes.
53
+ - Permissions: anonymous/unknown/unrelated actors blocked; each role enforced; identity hash alone cannot authenticate.
54
+ - Funding: wrong amount, token, chain, contract, payer, worker, verifier, deadline or spec commitment blocks work; missing escrow configuration fails closed.
55
+ - Effects: duplicate requests and simultaneous retries produce one provider operation; differing input with reused key rejected; crashes before/after broadcast and before final DB save recover by reconciliation.
56
+ - Submission: asynchronous execution cannot prematurely submit; bytes match committed digest; missing/failed chain submission stays pending/rejected.
57
+ - Verification: bad output never enables release; dispute recorded; unsupported resolution leaves explicit unresolved state.
58
+ - Refund: before deadline rejected; after deadline/unsubmitted succeeds; submitted work obeys contract restrictions.
59
+ - Exceptions: pre-funding cancellation, unfunded expiry, definitive provider rejection and long pending operations.
60
+ - Receipts: correct raw units/decimals, matching funding/payment/refund events and artifact linkage; provider outage is distinguished from absent evidence.
61
+ - Custom use case: prove a second task type or non-chain provider works without modifying SDK internals.
62
+
63
+ Report changed files, adapter capabilities, test results and unsupported contract guarantees. Keep real-chain tests opt-in. Do not claim every possible workflow or payment provider is implemented merely because adapter hooks exist.
package/README.md CHANGED
@@ -1,3 +1,107 @@
1
+ # Configurable workflows in 0.2.0
2
+
3
+ The new `@allorc/a423-sdk/workflow` entry point provides middleware for agent job lifecycles. It supports different task types and payment systems through application-supplied policies and adapters. It does not assume a particular blockchain, token, wallet, artifact format, database or AI framework. This is experimental software with a reference lifecycle; it does not promise every possible business workflow.
4
+
5
+ The original root exports remain compatible. Choose the workflow entry point for the full lifecycle; do not mix its records with the original root API. See [integration and migration instructions](./INTEGRATION.md).
6
+
7
+ ```ts
8
+ import { createWorkflow, createWorkflowHandler } from '@allorc/a423-sdk/workflow';
9
+
10
+ const workflow = createWorkflow({
11
+ store: durableWorkflowStore,
12
+ adapters: applicationAdapters,
13
+ validateTerms: validateYourTerms,
14
+ authorize: checkYourPolicy,
15
+ });
16
+ const handler = createWorkflowHandler({ workflow, authenticate: authenticateRequest });
17
+ // app/api/a423/[...path]/route.ts
18
+ export const runtime = 'nodejs';
19
+ export { handler as GET, handler as POST };
20
+ ```
21
+
22
+ The names `durableWorkflowStore`, `applicationAdapters`, `validateYourTerms`, `checkYourPolicy` and `authenticateRequest` are application implementations, not SDK exports. Configure them once in a server module and import the handler into your route.
23
+
24
+ ## Workflow coverage
25
+
26
+ | Action | From | Default role | Result | Required adapter |
27
+ |---|---|---|---|---|
28
+ | create | — | requester | draft | none |
29
+ | quote | draft / quoted / accepted | requester or worker | quoted, acceptances reset | none |
30
+ | accept | quoted | requester and worker separately | accepted after both | none |
31
+ | fund | accepted | requester | funded | fund |
32
+ | start | funded | worker | running | start (funding/work gate) |
33
+ | submit | running | worker | submitted | submit |
34
+ | verify | submitted | verifier | verified | verify |
35
+ | release | verified | verifier | paid | release |
36
+ | refund | funded / running | requester | refunded | refund |
37
+ | cancel | draft / quoted / accepted | requester | cancelled | none |
38
+ | expire | draft / quoted / accepted | requester or worker | expired | expire |
39
+ | dispute | submitted / verified | any of the three parties | disputed | none |
40
+ | resolve | disputed | verifier | resolved | resolve |
41
+
42
+ `resolved` records a completed resolution process; it does not mean paid or refunded. The resolution adapter must record its actual outcome in evidence. Refund eligibility, quote expiry, deadlines, spending limits, asset rules and verification semantics belong in application policies/adapters. There is no automatic timer. A scheduler calls the API using an authenticated participant or an explicitly configured scheduler role.
43
+
44
+ ### Terms and acceptance
45
+
46
+ Use JSON terms for your domain: task specification, price, asset, raw amount, deadline, wallet addresses, verifier policy and artifact expectations. Put every term you want committed into this object, including parties and settlement configuration where relevant. The SDK does not invent or validate those domain fields; `validateTerms` should throw on invalid terms. Use decimal strings for raw token amounts, not floating point or JSON bigint.
47
+
48
+ The SDK hashes sorted-key canonical JSON with SHA-256 (array order preserved, no whitespace). Hash inputs must be plain JSON; avoid cyclic structures and sparse arrays. This is the SDK's own format, not a claim of RFC 8785 compatibility. Names and IDs are case-sensitive. A quote stores revision, terms, hash and acceptances. Accept requires both `agreement_hash` and `revision`; editing a quote clears acceptance. Terms cannot be revised after funding begins under the default rules. Hashes establish integrity, not caller identity or legal consent.
49
+
50
+ ### Adapter contract and recovery
51
+
52
+ Each effect adapter is either an async function or `{ execute, reconcile }`. It receives `{ job, input, operation }` and returns:
53
+
54
+ ```js
55
+ { status: 'pending', reference: 'provider-operation-reference' }
56
+ { status: 'confirmed', evidence: { /* domain-specific verified observations */ } }
57
+ { status: 'rejected', reason: 'definitive rejection' }
58
+ ```
59
+
60
+ Confirmed results require an evidence object. The SDK trusts the adapter's verdict; it cannot independently establish payment, artifact quality or chain finality. An arbitrary reference is not payment proof. Chain adapters should compare configured chain/contract, job ID, amount, token, participants, deadline and commitments against the accepted terms, plus relevant successful transaction events and the chosen finality policy. `start` must recheck eligibility before work is dispatched.
61
+
62
+ The SDK atomically stores a pending operation before invoking an effect. Its `operation.id` is stable across retries: use it as the provider idempotency key. **Adapters must durably deduplicate effects, including concurrent calls.** Compare-and-set does not make an external payment exactly-once. If execution throws or returns an invalid result, pending remains; inspect/reconcile it. A pending result retains its observation. A rejected result clears pending without advancing state; repeat the same action key returns the stored job, so use a new key only after a definitive rejection and a deliberate new attempt.
63
+
64
+ `reconcile(id, actor)` calls only the pending adapter's `reconcile`, never its execute method. That implementation should inspect known provider operations, not blindly repeat a transaction. Supply reconciliation for every external effect. Reuse the same adapter configuration and transitions across restarts. Reconciliation can record additional pending observations. No other action runs while an operation remains pending. If a final database save fails, recovery relies on the same persisted operation and provider key.
65
+
66
+ The SDK does not run agents or background queues. Persist intent and schedule work in adapters; use a durable worker to submit actual output. Do not mark work submitted merely because a worker was queued. Custom transitions can add milestones, rejection/revision loops, manual review, nonpayment completion, or alternative arbitration. Override whole rules with `{ from, roles, to, effect }`; applications assume responsibility for the resulting invariants. Effect adapters must be trusted server code.
67
+
68
+ ### Storage, permissions and receipts
69
+
70
+ A workflow store implements `get`, atomic insert-if-absent `create`, and atomic version-checked `compareAndSet`. Persist the entire record (state, pending operation, evidence, events, idempotency history) atomically. Use a separate table/namespace from legacy records. The root `createMemoryStore()` also works structurally at runtime for development, but is not durable or suitable for distributed deployment; a typed workflow store should implement the interface from the workflow entry point.
71
+
72
+ The handler requires authentication. It obtains actor identity from your callback, never from action bodies. Default role checks always run; `authorize` adds application policy and must return exactly true. Creation requires requester authentication, and requester must differ from worker. The verifier may be the requester or a separate participant. Authentication, wallet/key custody, identity-to-wallet binding and proof of ownership remain application responsibilities. `@allorc/identity` only derives public identifiers.
73
+
74
+ Receipts are JSON snapshots of agreement, submission, stored adapter evidence, events and pending state. They are not freshly queried chain proofs or signed attestations. Render PDFs separately, and revalidate provider evidence if fresh verification is required. Protect credentials and never put secrets in terms, evidence, metadata or events: all participants can read these fields. Records/events grow over time; production storage should enforce application quotas. The HTTP client limits responses to 1 MiB.
75
+
76
+ ### HTTP and client
77
+
78
+ | Method | Path beneath `/api/a423` | Body |
79
+ |---|---|---|
80
+ | POST | `/jobs` | `{ id, participants, metadata? }` |
81
+ | GET | `/jobs/:id` | — |
82
+ | POST | `/jobs/:id/actions` | `{ action, input }` plus `Idempotency-Key` |
83
+ | POST | `/jobs/:id/reconcile` | `{}` |
84
+ | GET | `/jobs/:id/receipts` | — |
85
+
86
+ The handler uses standard Web Request/Response, bounded streaming JSON (64 KiB default), explicit errors, and no background HTTP work. IDs and idempotency keys use 1–128 ASCII letters/digits/underscores/hyphens. Create with a stable application-assigned ID; duplicate creation returns conflict, then retrieve and compare the existing record. Every action needs an actor-scoped key; reusing it with different input fails. Transient conflicts should reload and retry using the same key. Authentication callbacks must inspect headers/cookies or a cloned request if they need the body.
87
+
88
+ ```ts
89
+ import { createWorkflowClient } from '@allorc/a423-sdk/workflow';
90
+ const client = createWorkflowClient({
91
+ baseUrl: 'https://your-app.example/api/a423',
92
+ headers: () => ({ Authorization: `Bearer ${serverCredential}` }),
93
+ });
94
+ const quote = await client.act(jobId, 'quote', { terms }, { idempotencyKey: 'quote_1' });
95
+ await client.act(jobId, 'accept', {
96
+ agreement_hash: quote.agreement!.hash,
97
+ revision: quote.agreement!.revision,
98
+ }, { idempotencyKey: 'accept_1' });
99
+ ```
100
+
101
+ Each participant needs its own credentials. The client does not automatically retry. Default timeout is 30 seconds; adapters should return pending quickly for long operations. Use TLS and appropriate CSRF protection for cookie-authenticated mutations.
102
+
103
+ ## Original 0.1 API documentation
104
+
1
105
  # @allorc/a423-sdk
2
106
 
3
107
  One experimental package for exposing an agent job API from a Next.js App Router application. Configure a handler, export it from a catch-all route, and use the typed client to offer, accept, submit and approve work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allorc/a423-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Experimental Next.js-friendly agent job SDK with typed HTTP client and pluggable storage, authentication and settlement",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -11,6 +11,11 @@
11
11
  "types": "./src/index.d.ts",
12
12
  "import": "./src/index.mjs",
13
13
  "require": "./src/index.cjs"
14
+ },
15
+ "./workflow": {
16
+ "types": "./src/workflow.d.ts",
17
+ "import": "./src/workflow.mjs",
18
+ "require": "./src/workflow.cjs"
14
19
  }
15
20
  },
16
21
  "files": [
@@ -22,7 +27,12 @@
22
27
  "examples/nextjs/app/api/a423/*/route.ts",
23
28
  "README.md",
24
29
  "LICENSE",
25
- "SECURITY.md"
30
+ "SECURITY.md",
31
+ "src/workflow.cjs",
32
+ "src/workflow.mjs",
33
+ "src/workflow.d.ts",
34
+ "INTEGRATION.md",
35
+ "CHANGELOG.md"
26
36
  ],
27
37
  "engines": {
28
38
  "node": ">=22"
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+ const { createHash, randomUUID } = require('node:crypto');
3
+ const { A423Error } = require('./index.cjs');
4
+ const copy = value => structuredClone(value);
5
+ const error = (code, message, status = 409) => { throw new A423Error(code, message, status); };
6
+ function canonical(value) {
7
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
8
+ if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value);
9
+ if (Array.isArray(value)) {
10
+ if (Object.keys(value).length !== value.length) throw new TypeError('Use dense JSON arrays');
11
+ return '[' + value.map(item => canonical(item)).join(',') + ']';
12
+ }
13
+ if (value && Object.getPrototypeOf(value) === Object.prototype) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
14
+ throw new TypeError('Use JSON values only (no undefined, bigint, class instances or nonfinite numbers)');
15
+ }
16
+ function digest(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
17
+ function identifier(value) { if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) throw new TypeError('IDs must contain 1–128 letters, digits, underscores or hyphens'); return value; }
18
+ const RULES = {
19
+ quote: { from: ['draft', 'quoted', 'accepted'], roles: ['requester', 'worker'], to: 'quoted' },
20
+ accept: { from: ['quoted'], roles: ['requester', 'worker'], to: 'quoted' },
21
+ fund: { from: ['accepted'], roles: ['requester'], to: 'funded', effect: true },
22
+ start: { from: ['funded'], roles: ['worker'], to: 'running', effect: true },
23
+ submit: { from: ['running'], roles: ['worker'], to: 'submitted', effect: true },
24
+ verify: { from: ['submitted'], roles: ['verifier'], to: 'verified', effect: true },
25
+ release: { from: ['verified'], roles: ['verifier'], to: 'paid', effect: true },
26
+ refund: { from: ['funded', 'running'], roles: ['requester'], to: 'refunded', effect: true },
27
+ cancel: { from: ['draft', 'quoted', 'accepted'], roles: ['requester'], to: 'cancelled' },
28
+ expire: { from: ['draft', 'quoted', 'accepted'], roles: ['requester', 'worker'], to: 'expired', effect: true },
29
+ dispute: { from: ['submitted', 'verified'], roles: ['requester', 'worker', 'verifier'], to: 'disputed' },
30
+ resolve: { from: ['disputed'], roles: ['verifier'], to: 'resolved', effect: true }
31
+ };
32
+ /** Pure canonical JSON SHA-256. This is an integrity commitment, not authentication. */
33
+ function hashAgreement(terms) { return digest(terms); }
34
+ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transitions = {} } = {}) {
35
+ if (!store || ['get', 'create', 'compareAndSet'].some(k => typeof store[k] !== 'function')) throw new TypeError('Supply an atomic workflow store');
36
+ const rules = copy(RULES);
37
+ for (const [action, rule] of Object.entries(transitions)) {
38
+ identifier(action);
39
+ if (!rule || !Array.isArray(rule.from) || !rule.from.length || !Array.isArray(rule.roles) || !rule.roles.length || typeof rule.to !== 'string' || !rule.to) throw new TypeError('Transitions require from, roles and to');
40
+ rules[action] = copy(rule);
41
+ }
42
+ const stamp = () => new Date().toISOString();
43
+ async function save(job, next, type, actor, data = {}) {
44
+ next.version = job.version + 1; next.updated_at = stamp();
45
+ next.events.push({ sequence: next.events.length + 1, type, actor_id: actor, at: next.updated_at, data: copy(data) });
46
+ if (!await store.compareAndSet(job.id, job.version, next)) error('CONFLICT', 'Reload and retry', 409);
47
+ return next;
48
+ }
49
+ async function get(id, actor) {
50
+ identifier(id); identifier(actor);
51
+ const job = await store.get(id);
52
+ if (!job || !Object.values(job.participants).includes(actor)) error('NOT_FOUND', 'Job not found', 404);
53
+ return copy(job);
54
+ }
55
+ async function allowed(job, action, actor, input, rule) {
56
+ 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);
58
+ }
59
+ async function complete(job, result) {
60
+ const pending = job.pending;
61
+ if (!result || !['confirmed', 'pending', 'rejected'].includes(result.status)) error('INVALID_ADAPTER_RESULT', 'Adapter must return confirmed, pending or rejected', 502);
62
+ canonical(result);
63
+ if (result.status === 'pending') {
64
+ const next = copy(job); next.pending.observation = copy(result);
65
+ return save(job, next, pending.action + '.pending', pending.actor, result);
66
+ }
67
+ if (result.status === 'rejected') {
68
+ const next = copy(job); next.pending = null;
69
+ next.operations[pending.key] = { request_hash: pending.request_hash, status: 'rejected', result: copy(result) };
70
+ return save(job, next, pending.action + '.rejected', pending.actor, result);
71
+ }
72
+ if (!result.evidence || typeof result.evidence !== 'object' || Array.isArray(result.evidence)) error('INVALID_ADAPTER_RESULT', 'Confirmed effects require structured evidence', 502);
73
+ const next = copy(job); next.pending = null; next.state = pending.target;
74
+ next.operations[pending.key] = { request_hash: pending.request_hash, status: 'confirmed', result: copy(result) };
75
+ next.evidence.push({ action: pending.action, operation_id: pending.id, evidence: copy(result.evidence) });
76
+ if (pending.action === 'submit') next.submission = copy(pending.input);
77
+ return save(job, next, pending.action + '.confirmed', pending.actor, result.evidence);
78
+ }
79
+ async function execute(job, reconcile = false) {
80
+ const pending = job.pending;
81
+ const adapter = adapters[pending.action];
82
+ const fn = reconcile ? adapter?.reconcile : (typeof adapter === 'function' ? adapter : adapter?.execute);
83
+ if (typeof fn !== 'function') error('ADAPTER_REQUIRED', `Configure ${pending.action}${reconcile ? '.reconcile' : ''}`, 501);
84
+ // Persisted idempotency key is reused even after crashes. Adapters must deduplicate durably.
85
+ const result = await fn({ job: copy(job), input: copy(pending.input), operation: copy(pending) });
86
+ return complete(job, result);
87
+ }
88
+ return {
89
+ async create({ id = randomUUID(), participants, metadata = {} }, actor) {
90
+ identifier(id); identifier(actor);
91
+ if (!participants || !participants.requester || !participants.worker || !participants.verifier) throw new TypeError('Supply requester, worker and verifier participants');
92
+ Object.values(participants).forEach(identifier);
93
+ if (participants.requester === participants.worker || actor !== participants.requester) error('FORBIDDEN', 'Requester must create the job and differ from worker', 403);
94
+ canonical(metadata); canonical(participants);
95
+ const at = stamp();
96
+ const job = { 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 };
97
+ if (authorize && await authorize({ job: copy(job), action: 'create', actor, input: {} }) !== true) error('FORBIDDEN', 'Application policy rejected creation', 403);
98
+ if (!await store.create(job)) error('CONFLICT', 'Job ID already exists');
99
+ return copy(job);
100
+ },
101
+ get,
102
+ async act(id, action, input, { actor, idempotencyKey } = {}) {
103
+ identifier(actor); identifier(idempotencyKey); identifier(action); canonical(input);
104
+ let job = await get(id, actor);
105
+ const rule = rules[action];
106
+ if (!rule) error('UNKNOWN_ACTION', 'Unknown workflow action', 400);
107
+ await allowed(job, action, actor, input, rule);
108
+ const key = digest([actor, idempotencyKey]);
109
+ const requestHash = digest([action, input]);
110
+ if (Object.hasOwn(job.operations, key)) {
111
+ if (job.operations[key].request_hash !== requestHash) error('IDEMPOTENCY_CONFLICT', 'Key used for another request');
112
+ return job;
113
+ }
114
+ if (job.pending) {
115
+ if (job.pending.key !== key || job.pending.request_hash !== requestHash) error('OPERATION_PENDING', 'Reconcile or retry the pending operation first');
116
+ return execute(job);
117
+ }
118
+ if (!rule.from.includes(job.state)) error('INVALID_STATE', `Cannot ${action} from ${job.state}`);
119
+ let next = copy(job);
120
+ if (action === 'quote') {
121
+ if (!input || !input.terms || typeof input.terms !== 'object' || Array.isArray(input.terms)) error('INVALID_INPUT', 'Supply a terms object', 400);
122
+ if (validateTerms) await validateTerms(copy(input.terms));
123
+ next.agreement = { revision: (job.agreement?.revision || 0) + 1, terms: copy(input.terms), hash: hashAgreement(input.terms), accepted_by: [] };
124
+ }
125
+ if (action === 'accept') {
126
+ if (!input || input.agreement_hash !== job.agreement.hash || input.revision !== job.agreement.revision) error('AGREEMENT_MISMATCH', 'Accept the current agreement hash and revision');
127
+ next.agreement.accepted_by = [...new Set([...job.agreement.accepted_by, actor])];
128
+ }
129
+ if (rule.effect) {
130
+ if (!adapters[action]) error('ADAPTER_REQUIRED', `Configure ${action} adapter`, 501);
131
+ next.pending = { id: `a423:${id}:${key}`, key, action, actor, input: copy(input), request_hash: requestHash, target: rule.to, created_at: stamp(), observation: null };
132
+ job = await save(job, next, action + '.requested', actor);
133
+ return execute(job);
134
+ }
135
+ next.state = action === 'accept' && [job.participants.requester, job.participants.worker].every(p => next.agreement.accepted_by.includes(p)) ? 'accepted' : rule.to;
136
+ next.operations[key] = { request_hash: requestHash, status: 'confirmed', result: { status: 'confirmed' } };
137
+ return save(job, next, action, actor, input);
138
+ },
139
+ async reconcile(id, actor) {
140
+ const job = await get(id, actor);
141
+ if (!job.pending) return job;
142
+ await allowed(job, job.pending.action, actor, job.pending.input, rules[job.pending.action]);
143
+ return execute(job, true);
144
+ },
145
+ async receipts(id, actor) {
146
+ const job = await get(id, actor);
147
+ return { protocol: job.protocol, job_id: id, state: job.state, agreement: job.agreement, submission: job.submission, evidence: job.evidence, events: job.events, pending: job.pending };
148
+ }
149
+ };
150
+ }
151
+ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError } = {}) {
152
+ if (!workflow || typeof authenticate !== 'function' || !/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(basePath)) throw new TypeError('Supply workflow, authenticate and valid basePath');
153
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || maxBodyBytes > 1048576) throw new TypeError('Invalid maxBodyBytes');
154
+ const respond = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
155
+ return async request => {
156
+ try {
157
+ const path = new URL(request.url).pathname;
158
+ if (!path.startsWith(basePath + '/')) error('NOT_FOUND', 'Route not found', 404);
159
+ const match = /^\/jobs(?:\/([A-Za-z0-9_-]{1,128})(?:\/(actions|reconcile|receipts))?)?$/.exec(path.slice(basePath.length));
160
+ if (!match) error('NOT_FOUND', 'Route not found', 404);
161
+ const [, id, action] = match;
162
+ const method = !id || action === 'actions' || action === 'reconcile' ? 'POST' : 'GET';
163
+ if (request.method !== method) { const r = respond({ error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method}` } }, 405); r.headers.set('allow', method); return r; }
164
+ const actor = await authenticate(request);
165
+ if (!actor) error('UNAUTHENTICATED', 'Authentication required', 401);
166
+ let body;
167
+ if (method === 'POST') {
168
+ if (!(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) error('INVALID_INPUT', 'Use application/json', 415);
169
+ const reader = request.body?.getReader(); if (!reader) error('INVALID_INPUT', 'JSON body required', 400);
170
+ let size = 0; const chunks = [];
171
+ try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBodyBytes) { await reader.cancel(); error('BODY_TOO_LARGE', 'Body too large', 413); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); }
172
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { error('INVALID_INPUT', 'Invalid JSON', 400); }
173
+ }
174
+ if (!id) return respond({ job: await workflow.create(body, actor) }, 201);
175
+ if (action === 'actions') return respond({ job: await workflow.act(id, body.action, body.input ?? {}, { actor, idempotencyKey: request.headers.get('idempotency-key') }) });
176
+ if (action === 'reconcile') return respond({ job: await workflow.reconcile(id, actor) });
177
+ if (action === 'receipts') return respond(await workflow.receipts(id, actor));
178
+ return respond({ job: await workflow.get(id, actor) });
179
+ } catch (err) {
180
+ if (err instanceof A423Error) return respond({ error: { code: err.code, message: err.message } }, err.status);
181
+ if (err instanceof TypeError) return respond({ error: { code: 'INVALID_INPUT', message: 'Invalid workflow input' } }, 400);
182
+ if (onError) { try { await onError(err); } catch {} }
183
+ return respond({ error: { code: 'INTERNAL_ERROR', message: 'Workflow operation failed; inspect and reconcile pending operations' } }, 500);
184
+ }
185
+ };
186
+ }
187
+ module.exports = { createWorkflow, createWorkflowHandler, hashAgreement };
188
+
189
+ function createWorkflowClient({ baseUrl, headers, fetch: fetchImpl = globalThis.fetch, timeoutMs = 30000 } = {}) {
190
+ const url = new URL(baseUrl);
191
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || typeof fetchImpl !== 'function' || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('Invalid client options');
192
+ const base = url.href.replace(/\/$/, '');
193
+ async function request(path, body, key) {
194
+ const h = new Headers(typeof headers === 'function' ? await headers() : headers);
195
+ h.set('accept', 'application/json');
196
+ if (body !== undefined) h.set('content-type', 'application/json');
197
+ if (key !== undefined) h.set('idempotency-key', identifier(key));
198
+ const response = await fetchImpl(base + path, { method: body === undefined ? 'GET' : 'POST', headers: h, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
199
+ const reader = response.body?.getReader(); if (!reader) error('INVALID_RESPONSE', 'Missing response body', 502);
200
+ const chunks = []; let size = 0;
201
+ try { while (true) { const {done,value} = await reader.read(); if(done) break; size += value.byteLength; if(size > 1048576) { await reader.cancel(); error('INVALID_RESPONSE','Response exceeds 1 MiB',502); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); }
202
+ let data; try { data=JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { error('INVALID_RESPONSE','Invalid JSON response',502); }
203
+ if (!response.ok) error(data.error?.code || 'HTTP_ERROR',data.error?.message || 'Request failed',response.status);
204
+ return data;
205
+ }
206
+ const path = id => '/jobs/' + identifier(id);
207
+ return {
208
+ async create(input) { return (await request('/jobs',input)).job; },
209
+ async get(id) { return (await request(path(id))).job; },
210
+ async act(id,action,input,{idempotencyKey}={}) { identifier(action);identifier(idempotencyKey);return (await request(path(id)+'/actions',{action,input},idempotencyKey)).job; },
211
+ async reconcile(id) { return (await request(path(id)+'/reconcile',{})).job; },
212
+ async receipts(id) { return request(path(id)+'/receipts'); }
213
+ };
214
+ }
215
+ module.exports.createWorkflowClient = createWorkflowClient;
@@ -0,0 +1,57 @@
1
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
2
+ export interface Agreement { revision: number; terms: { [key: string]: Json }; hash: string; accepted_by: string[] }
3
+ export interface Operation { id: string; key: string; action: string; actor: string; input: Json; request_hash: string; target: string; created_at: string; observation: AdapterResult | null }
4
+ export interface AdapterResult { status: 'pending' | 'confirmed' | 'rejected'; evidence?: { [key: string]: Json }; [key: string]: Json | undefined }
5
+ export interface WorkflowJob {
6
+ id: string; protocol: 'a423.workflow.v1'; version: number; state: string;
7
+ participants: { requester: string; worker: string; verifier: string; [role: string]: string };
8
+ metadata: Json; agreement: Agreement | null; submission: Json;
9
+ pending: Operation | null;
10
+ operations: Record<string, { request_hash: string; status: string; result: AdapterResult }>;
11
+ evidence: Array<{ action: string; operation_id: string; evidence: { [key: string]: Json } }>;
12
+ events: Array<{ sequence: number; type: string; actor_id: string; at: string; data: Json }>;
13
+ created_at: string; updated_at: string;
14
+ }
15
+ export interface WorkflowStore {
16
+ get(id: string): Promise<WorkflowJob | null>;
17
+ create(job: WorkflowJob): Promise<boolean>;
18
+ compareAndSet(id: string, expectedVersion: number, job: WorkflowJob): Promise<boolean>;
19
+ }
20
+ export interface AdapterContext { job: WorkflowJob; input: Json; operation: Operation }
21
+ export type Effect = (context: AdapterContext) => Promise<AdapterResult>;
22
+ export interface Transition { from: string[]; roles: string[]; to: string; effect?: boolean }
23
+ export interface WorkflowOptions {
24
+ store: WorkflowStore;
25
+ adapters?: Record<string, Effect | { execute: Effect; reconcile?: Effect }>;
26
+ /** Return true to allow; authenticated actor ID must be supplied by your application. */
27
+ authorize?(context: { job: WorkflowJob; action: string; actor: string; input: Json }): boolean | Promise<boolean>;
28
+ /** Throw to reject application-specific terms. */
29
+ validateTerms?(terms: { [key: string]: Json }): void | Promise<void>;
30
+ /** Explicit overrides replace the full rule; application is responsible for its invariants. */
31
+ transitions?: Record<string, Transition>;
32
+ }
33
+ export interface Workflow {
34
+ create(input: { id?: string; participants: WorkflowJob['participants']; metadata?: Json }, actor: string): Promise<WorkflowJob>;
35
+ get(id: string, actor: string): Promise<WorkflowJob>;
36
+ act(id: string, action: string, input: Json, options: { actor: string; idempotencyKey: string }): Promise<WorkflowJob>;
37
+ reconcile(id: string, actor: string): Promise<WorkflowJob>;
38
+ receipts(id: string, actor: string): Promise<Pick<WorkflowJob, 'protocol' | 'state' | 'agreement' | 'submission' | 'evidence' | 'events' | 'pending'> & { job_id: string }>;
39
+ }
40
+ export function hashAgreement(terms: Json): string;
41
+ export function createWorkflow(options: WorkflowOptions): Workflow;
42
+ export function createWorkflowHandler(options: {
43
+ workflow: Workflow; authenticate(request: Request): string | null | Promise<string | null>;
44
+ basePath?: string; maxBodyBytes?: number; onError?(error: unknown): void | Promise<void>;
45
+ }): (request: Request) => Promise<Response>;
46
+ export interface WorkflowClient {
47
+ /** Supply a stable id so creation retries can be resolved with get(id). */
48
+ create(input: Parameters<Workflow['create']>[0]): Promise<WorkflowJob>;
49
+ get(id: string): Promise<WorkflowJob>;
50
+ act(id: string, action: string, input: Json, options: { idempotencyKey: string }): Promise<WorkflowJob>;
51
+ reconcile(id: string): Promise<WorkflowJob>;
52
+ receipts(id: string): ReturnType<Workflow['receipts']>;
53
+ }
54
+ export function createWorkflowClient(options: {
55
+ baseUrl: string; headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
56
+ fetch?: typeof globalThis.fetch; timeoutMs?: number;
57
+ }): WorkflowClient;
@@ -0,0 +1,2 @@
1
+ import workflow from './workflow.cjs';
2
+ export const { createWorkflow, createWorkflowHandler, createWorkflowClient, hashAgreement } = workflow;