@emilia-protocol/gate 0.11.0 → 0.13.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 +58 -0
- package/README.md +252 -0
- package/action-control-manifest.js +3 -268
- package/action-escrow-custodian.js +3 -394
- package/action-escrow-evidence.js +3 -858
- package/action-escrow-package.js +3 -1668
- package/action-escrow-postgres.js +3 -337
- package/action-escrow-state.js +3 -396
- package/action-escrow-verifiers.js +3 -331
- package/action-escrow.js +3 -2447
- package/action-packs.js +3 -140
- package/adapters/_kit.js +3 -104
- package/adapters/aws.js +3 -108
- package/adapters/cloudflare.js +3 -54
- package/adapters/gcp.js +3 -70
- package/adapters/github.js +3 -109
- package/adapters/jira.js +3 -55
- package/adapters/k8s.js +3 -70
- package/adapters/linear.js +3 -55
- package/adapters/salesforce.js +3 -55
- package/adapters/stripe.js +3 -81
- package/adapters/supabase.js +3 -121
- package/adapters/terraform.js +3 -59
- package/adapters/vercel.js +3 -84
- package/aec-execution.js +3 -312
- package/breakglass.js +3 -582
- package/capability-receipt.js +4 -0
- package/cf1-conformance.js +3 -106
- package/challenge-store.js +3 -69
- package/control-plane.js +3 -340
- package/coverage.js +3 -721
- package/demo.mjs +1 -1
- package/deployment-attestation.js +3 -247
- package/dist/action-control-manifest.d.ts +82 -0
- package/dist/action-control-manifest.d.ts.map +1 -0
- package/dist/action-control-manifest.js +422 -0
- package/dist/action-control-manifest.js.map +1 -0
- package/dist/action-escrow-custodian.d.ts +74 -0
- package/dist/action-escrow-custodian.d.ts.map +1 -0
- package/dist/action-escrow-custodian.js +363 -0
- package/dist/action-escrow-custodian.js.map +1 -0
- package/dist/action-escrow-evidence.d.ts +130 -0
- package/dist/action-escrow-evidence.d.ts.map +1 -0
- package/dist/action-escrow-evidence.js +1012 -0
- package/dist/action-escrow-evidence.js.map +1 -0
- package/dist/action-escrow-package.d.ts +10 -0
- package/dist/action-escrow-package.d.ts.map +1 -0
- package/dist/action-escrow-package.js +1583 -0
- package/dist/action-escrow-package.js.map +1 -0
- package/dist/action-escrow-postgres.d.ts +62 -0
- package/dist/action-escrow-postgres.d.ts.map +1 -0
- package/dist/action-escrow-postgres.js +339 -0
- package/dist/action-escrow-postgres.js.map +1 -0
- package/dist/action-escrow-state.d.ts +141 -0
- package/dist/action-escrow-state.d.ts.map +1 -0
- package/dist/action-escrow-state.js +413 -0
- package/dist/action-escrow-state.js.map +1 -0
- package/dist/action-escrow-verifiers.d.ts +65 -0
- package/dist/action-escrow-verifiers.d.ts.map +1 -0
- package/dist/action-escrow-verifiers.js +421 -0
- package/dist/action-escrow-verifiers.js.map +1 -0
- package/dist/action-escrow.d.ts +112 -0
- package/dist/action-escrow.d.ts.map +1 -0
- package/dist/action-escrow.js +2661 -0
- package/dist/action-escrow.js.map +1 -0
- package/dist/action-packs.d.ts +54 -0
- package/dist/action-packs.d.ts.map +1 -0
- package/dist/action-packs.js +143 -0
- package/dist/action-packs.js.map +1 -0
- package/dist/adapters/_kit.d.ts +50 -0
- package/dist/adapters/_kit.d.ts.map +1 -0
- package/dist/adapters/_kit.js +98 -0
- package/dist/adapters/_kit.js.map +1 -0
- package/dist/adapters/aws.d.ts +158 -0
- package/dist/adapters/aws.d.ts.map +1 -0
- package/dist/adapters/aws.js +136 -0
- package/dist/adapters/aws.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +116 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +75 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/gcp.d.ts +139 -0
- package/dist/adapters/gcp.d.ts.map +1 -0
- package/dist/adapters/gcp.js +69 -0
- package/dist/adapters/gcp.js.map +1 -0
- package/dist/adapters/github.d.ts +123 -0
- package/dist/adapters/github.d.ts.map +1 -0
- package/dist/adapters/github.js +105 -0
- package/dist/adapters/github.js.map +1 -0
- package/dist/adapters/jira.d.ts +109 -0
- package/dist/adapters/jira.d.ts.map +1 -0
- package/dist/adapters/jira.js +54 -0
- package/dist/adapters/jira.js.map +1 -0
- package/dist/adapters/k8s.d.ts +139 -0
- package/dist/adapters/k8s.d.ts.map +1 -0
- package/dist/adapters/k8s.js +69 -0
- package/dist/adapters/k8s.js.map +1 -0
- package/dist/adapters/linear.d.ts +109 -0
- package/dist/adapters/linear.d.ts.map +1 -0
- package/dist/adapters/linear.js +54 -0
- package/dist/adapters/linear.js.map +1 -0
- package/dist/adapters/salesforce.d.ts +109 -0
- package/dist/adapters/salesforce.d.ts.map +1 -0
- package/dist/adapters/salesforce.js +54 -0
- package/dist/adapters/salesforce.js.map +1 -0
- package/dist/adapters/stripe.d.ts +116 -0
- package/dist/adapters/stripe.d.ts.map +1 -0
- package/dist/adapters/stripe.js +77 -0
- package/dist/adapters/stripe.js.map +1 -0
- package/dist/adapters/supabase.d.ts +127 -0
- package/dist/adapters/supabase.d.ts.map +1 -0
- package/dist/adapters/supabase.js +112 -0
- package/dist/adapters/supabase.js.map +1 -0
- package/dist/adapters/terraform.d.ts +109 -0
- package/dist/adapters/terraform.d.ts.map +1 -0
- package/dist/adapters/terraform.js +58 -0
- package/dist/adapters/terraform.js.map +1 -0
- package/dist/adapters/vercel.d.ts +114 -0
- package/dist/adapters/vercel.d.ts.map +1 -0
- package/dist/adapters/vercel.js +81 -0
- package/dist/adapters/vercel.js.map +1 -0
- package/dist/aec-execution.d.ts +107 -0
- package/dist/aec-execution.d.ts.map +1 -0
- package/dist/aec-execution.js +334 -0
- package/dist/aec-execution.js.map +1 -0
- package/dist/breakglass.d.ts +205 -0
- package/dist/breakglass.d.ts.map +1 -0
- package/dist/breakglass.js +627 -0
- package/dist/breakglass.js.map +1 -0
- package/dist/capability-receipt.d.ts +601 -0
- package/dist/capability-receipt.d.ts.map +1 -0
- package/dist/capability-receipt.js +1177 -0
- package/dist/capability-receipt.js.map +1 -0
- package/dist/cf1-conformance.d.ts +83 -0
- package/dist/cf1-conformance.d.ts.map +1 -0
- package/dist/cf1-conformance.js +94 -0
- package/dist/cf1-conformance.js.map +1 -0
- package/dist/challenge-store.d.ts +20 -0
- package/dist/challenge-store.d.ts.map +1 -0
- package/dist/challenge-store.js +62 -0
- package/dist/challenge-store.js.map +1 -0
- package/dist/control-plane.d.ts +136 -0
- package/dist/control-plane.d.ts.map +1 -0
- package/dist/control-plane.js +349 -0
- package/dist/control-plane.js.map +1 -0
- package/dist/coverage.d.ts +156 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +768 -0
- package/dist/coverage.js.map +1 -0
- package/dist/deployment-attestation.d.ts +93 -0
- package/dist/deployment-attestation.d.ts.map +1 -0
- package/dist/deployment-attestation.js +262 -0
- package/dist/deployment-attestation.js.map +1 -0
- package/dist/eg1-conformance.d.ts +286 -0
- package/dist/eg1-conformance.d.ts.map +1 -0
- package/dist/eg1-conformance.js +379 -0
- package/dist/eg1-conformance.js.map +1 -0
- package/dist/enterprise.d.ts +88 -0
- package/dist/enterprise.d.ts.map +1 -0
- package/dist/enterprise.js +194 -0
- package/dist/enterprise.js.map +1 -0
- package/dist/evidence-postgres.d.ts +123 -0
- package/dist/evidence-postgres.d.ts.map +1 -0
- package/dist/evidence-postgres.js +343 -0
- package/dist/evidence-postgres.js.map +1 -0
- package/dist/evidence.d.ts +113 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +369 -0
- package/dist/evidence.js.map +1 -0
- package/dist/execution-binding.d.ts +49 -0
- package/dist/execution-binding.d.ts.map +1 -0
- package/dist/execution-binding.js +243 -0
- package/dist/execution-binding.js.map +1 -0
- package/dist/formal-runtime-map.d.ts +42 -0
- package/dist/formal-runtime-map.d.ts.map +1 -0
- package/dist/formal-runtime-map.js +23 -0
- package/dist/formal-runtime-map.js.map +1 -0
- package/dist/index.d.ts +1002 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1736 -0
- package/dist/index.js.map +1 -0
- package/dist/key-registry.d.ts +60 -0
- package/dist/key-registry.d.ts.map +1 -0
- package/dist/key-registry.js +144 -0
- package/dist/key-registry.js.map +1 -0
- package/dist/mcp.d.ts +53 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +112 -0
- package/dist/mcp.js.map +1 -0
- package/dist/metering.d.ts +80 -0
- package/dist/metering.d.ts.map +1 -0
- package/dist/metering.js +227 -0
- package/dist/metering.js.map +1 -0
- package/dist/metrics.d.ts +68 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +242 -0
- package/dist/metrics.js.map +1 -0
- package/dist/network-witness.d.ts +167 -0
- package/dist/network-witness.d.ts.map +1 -0
- package/dist/network-witness.js +520 -0
- package/dist/network-witness.js.map +1 -0
- package/dist/protocol-state.d.ts +141 -0
- package/dist/protocol-state.d.ts.map +1 -0
- package/dist/protocol-state.js +106 -0
- package/dist/protocol-state.js.map +1 -0
- package/dist/receipt-program.d.ts +59 -0
- package/dist/receipt-program.d.ts.map +1 -0
- package/dist/receipt-program.js +1053 -0
- package/dist/receipt-program.js.map +1 -0
- package/dist/reliance-kernel.d.ts +66 -0
- package/dist/reliance-kernel.d.ts.map +1 -0
- package/dist/reliance-kernel.js +103 -0
- package/dist/reliance-kernel.js.map +1 -0
- package/dist/reliance-packet.d.ts +67 -0
- package/dist/reliance-packet.d.ts.map +1 -0
- package/dist/reliance-packet.js +176 -0
- package/dist/reliance-packet.js.map +1 -0
- package/dist/remedy-program-postgres.d.ts +37 -0
- package/dist/remedy-program-postgres.d.ts.map +1 -0
- package/dist/remedy-program-postgres.js +291 -0
- package/dist/remedy-program-postgres.js.map +1 -0
- package/dist/remedy-program-receipt.d.ts +97 -0
- package/dist/remedy-program-receipt.d.ts.map +1 -0
- package/dist/remedy-program-receipt.js +817 -0
- package/dist/remedy-program-receipt.js.map +1 -0
- package/dist/remedy-program.d.ts +76 -0
- package/dist/remedy-program.d.ts.map +1 -0
- package/dist/remedy-program.js +1346 -0
- package/dist/remedy-program.js.map +1 -0
- package/dist/reports/art14.d.ts +101 -0
- package/dist/reports/art14.d.ts.map +1 -0
- package/dist/reports/art14.js +340 -0
- package/dist/reports/art14.js.map +1 -0
- package/dist/reports/assurance-package.d.ts +197 -0
- package/dist/reports/assurance-package.d.ts.map +1 -0
- package/dist/reports/assurance-package.js +313 -0
- package/dist/reports/assurance-package.js.map +1 -0
- package/dist/reports/auditor-workpaper.d.ts +194 -0
- package/dist/reports/auditor-workpaper.d.ts.map +1 -0
- package/dist/reports/auditor-workpaper.js +545 -0
- package/dist/reports/auditor-workpaper.js.map +1 -0
- package/dist/reports/external-verification.d.ts +76 -0
- package/dist/reports/external-verification.d.ts.map +1 -0
- package/dist/reports/external-verification.js +209 -0
- package/dist/reports/external-verification.js.map +1 -0
- package/dist/reports/reperform.d.ts +122 -0
- package/dist/reports/reperform.d.ts.map +1 -0
- package/dist/reports/reperform.js +432 -0
- package/dist/reports/reperform.js.map +1 -0
- package/dist/reports/underwriter.d.ts +127 -0
- package/dist/reports/underwriter.d.ts.map +1 -0
- package/dist/reports/underwriter.js +348 -0
- package/dist/reports/underwriter.js.map +1 -0
- package/dist/retention.d.ts +88 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +90 -0
- package/dist/retention.js.map +1 -0
- package/dist/roster.d.ts +113 -0
- package/dist/roster.d.ts.map +1 -0
- package/dist/roster.js +261 -0
- package/dist/roster.js.map +1 -0
- package/dist/runtime-monitor.d.ts +132 -0
- package/dist/runtime-monitor.d.ts.map +1 -0
- package/dist/runtime-monitor.js +197 -0
- package/dist/runtime-monitor.js.map +1 -0
- package/dist/settlement.d.ts +68 -0
- package/dist/settlement.d.ts.map +1 -0
- package/dist/settlement.js +306 -0
- package/dist/settlement.js.map +1 -0
- package/dist/siem.d.ts +261 -0
- package/dist/siem.d.ts.map +1 -0
- package/dist/siem.js +247 -0
- package/dist/siem.js.map +1 -0
- package/dist/store-postgres.d.ts +107 -0
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +152 -0
- package/dist/store-postgres.js.map +1 -0
- package/dist/store.d.ts +95 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +202 -0
- package/dist/store.js.map +1 -0
- package/dist/strict-json.d.ts +14 -0
- package/dist/strict-json.d.ts.map +1 -0
- package/dist/strict-json.js +132 -0
- package/dist/strict-json.js.map +1 -0
- package/dist/trust-program-adapters.d.ts +122 -0
- package/dist/trust-program-adapters.d.ts.map +1 -0
- package/dist/trust-program-adapters.js +802 -0
- package/dist/trust-program-adapters.js.map +1 -0
- package/dist/trust-program-postgres.d.ts +41 -0
- package/dist/trust-program-postgres.d.ts.map +1 -0
- package/dist/trust-program-postgres.js +346 -0
- package/dist/trust-program-postgres.js.map +1 -0
- package/dist/trust-program-revocation.d.ts +206 -0
- package/dist/trust-program-revocation.d.ts.map +1 -0
- package/dist/trust-program-revocation.js +600 -0
- package/dist/trust-program-revocation.js.map +1 -0
- package/dist/trust-program.d.ts +197 -0
- package/dist/trust-program.d.ts.map +1 -0
- package/dist/trust-program.js +1351 -0
- package/dist/trust-program.js.map +1 -0
- package/dist/witness-postgres.d.ts +43 -0
- package/dist/witness-postgres.d.ts.map +1 -0
- package/dist/witness-postgres.js +92 -0
- package/dist/witness-postgres.js.map +1 -0
- package/dist/zk-range-proof.d.ts +78 -0
- package/dist/zk-range-proof.d.ts.map +1 -0
- package/dist/zk-range-proof.js +228 -0
- package/dist/zk-range-proof.js.map +1 -0
- package/eg1-conformance.js +3 -404
- package/enterprise.js +3 -180
- package/ep-assure.mjs +1 -0
- package/evidence-postgres.js +3 -356
- package/evidence.js +4 -360
- package/execution-binding.js +3 -212
- package/formal-runtime-map.js +4 -0
- package/index.js +4 -1249
- package/key-registry.js +3 -134
- package/mcp.js +3 -109
- package/metering.js +3 -226
- package/metrics.js +3 -231
- package/network-witness.js +3 -499
- package/package.json +31 -2
- package/receipt-program.js +4 -0
- package/reliance-kernel.js +3 -112
- package/reliance-packet.js +3 -175
- package/remedy-program-postgres.js +4 -0
- package/remedy-program-receipt.js +4 -0
- package/remedy-program.js +4 -0
- package/reports/art14.js +0 -0
- package/reports/assurance-package.js +2 -312
- package/reports/auditor-workpaper.js +3 -537
- package/reports/external-verification.js +3 -222
- package/reports/reperform.js +3 -424
- package/reports/underwriter.js +3 -339
- package/retention.js +3 -84
- package/roster.js +3 -264
- package/runtime-monitor.js +4 -0
- package/settlement.js +3 -299
- package/siem.js +3 -235
- package/src/action-control-manifest.ts +424 -0
- package/src/action-escrow-custodian.ts +427 -0
- package/src/action-escrow-evidence.ts +1178 -0
- package/src/action-escrow-package.ts +1821 -0
- package/src/action-escrow-postgres.ts +366 -0
- package/src/action-escrow-state.ts +498 -0
- package/src/action-escrow-verifiers.ts +484 -0
- package/src/action-escrow.ts +3039 -0
- package/src/action-packs.ts +170 -0
- package/src/adapters/_kit.ts +117 -0
- package/src/adapters/aws.ts +142 -0
- package/src/adapters/cloudflare.ts +78 -0
- package/src/adapters/gcp.ts +71 -0
- package/src/adapters/github.ts +110 -0
- package/src/adapters/jira.ts +56 -0
- package/src/adapters/k8s.ts +71 -0
- package/src/adapters/linear.ts +56 -0
- package/src/adapters/salesforce.ts +56 -0
- package/src/adapters/stripe.ts +82 -0
- package/src/adapters/supabase.ts +122 -0
- package/src/adapters/terraform.ts +60 -0
- package/src/adapters/vercel.ts +85 -0
- package/src/aec-execution.ts +354 -0
- package/src/breakglass.ts +647 -0
- package/src/capability-receipt.ts +1258 -0
- package/src/cf1-conformance.ts +120 -0
- package/src/challenge-store.ts +70 -0
- package/src/control-plane.ts +374 -0
- package/src/coverage.ts +832 -0
- package/src/deployment-attestation.ts +259 -0
- package/src/eg1-conformance.ts +471 -0
- package/src/enterprise.ts +195 -0
- package/src/evidence-postgres.ts +374 -0
- package/src/evidence.ts +367 -0
- package/src/execution-binding.ts +236 -0
- package/src/formal-runtime-map.ts +24 -0
- package/src/index.ts +2122 -0
- package/src/key-registry.ts +150 -0
- package/src/mcp.ts +120 -0
- package/src/metering.ts +237 -0
- package/src/metrics.ts +232 -0
- package/src/network-witness.ts +585 -0
- package/src/protocol-state.ts +143 -0
- package/src/protocol-state.typecheck.ts +94 -0
- package/src/receipt-program.ts +1105 -0
- package/src/reliance-kernel.ts +119 -0
- package/src/reliance-packet.ts +206 -0
- package/src/remedy-program-postgres.ts +367 -0
- package/src/remedy-program-receipt.ts +891 -0
- package/src/remedy-program.ts +1388 -0
- package/src/reports/art14.ts +361 -0
- package/src/reports/assurance-package.ts +330 -0
- package/src/reports/auditor-workpaper.ts +558 -0
- package/src/reports/external-verification.ts +232 -0
- package/src/reports/reperform.ts +441 -0
- package/src/reports/underwriter.ts +356 -0
- package/src/retention.ts +100 -0
- package/src/roster.ts +273 -0
- package/src/runtime-monitor.ts +217 -0
- package/src/settlement.ts +313 -0
- package/src/siem.ts +247 -0
- package/src/store-postgres.ts +167 -0
- package/src/store.ts +214 -0
- package/src/strict-json.ts +115 -0
- package/src/trust-program-adapters.ts +847 -0
- package/src/trust-program-postgres.ts +429 -0
- package/src/trust-program-revocation.ts +751 -0
- package/src/trust-program.ts +1460 -0
- package/src/witness-postgres.ts +106 -0
- package/src/zk-range-proof.ts +250 -0
- package/store-postgres.js +3 -161
- package/store.js +4 -198
- package/strict-json.js +3 -102
- package/trust-program-adapters.js +4 -0
- package/trust-program-postgres.js +4 -0
- package/trust-program-revocation.js +4 -0
- package/trust-program.js +4 -0
- package/witness-postgres.js +3 -96
- package/zk-range-proof.js +4 -0
|
@@ -0,0 +1,3039 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure orchestration kernel for contractor milestone release.
|
|
4
|
+
*
|
|
5
|
+
* The kernel owns no keys, documents, funds, or provider credentials. It joins
|
|
6
|
+
* construction-pinned verification results under a durable expected-revision
|
|
7
|
+
* CAS and refuses every state or external-effect ambiguity.
|
|
8
|
+
*/
|
|
9
|
+
import { canonicalize, hashCanonical } from './execution-binding.js';
|
|
10
|
+
import {
|
|
11
|
+
ACTION_ESCROW_CONTRACTOR_TEMPLATE_VERSION,
|
|
12
|
+
validateActionEscrowReleaseTemplate,
|
|
13
|
+
} from './action-escrow-verifiers.js';
|
|
14
|
+
|
|
15
|
+
export const ACTION_ESCROW_STATE_VERSION = 'EP-ACTION-ESCROW-STATE-v1';
|
|
16
|
+
export const ACTION_ESCROW_OUTCOME_VERSION = 'EP-ACTION-ESCROW-OUTCOME-v1';
|
|
17
|
+
export const ACTION_ESCROW_PROFILE_VERSION = 'EP-ACTION-ESCROW-PROFILE-v1';
|
|
18
|
+
|
|
19
|
+
export const ACTION_ESCROW_STATES = Object.freeze([
|
|
20
|
+
'draft',
|
|
21
|
+
'awaiting_acceptance',
|
|
22
|
+
'effective',
|
|
23
|
+
'awaiting_funding',
|
|
24
|
+
'funded',
|
|
25
|
+
'milestone_submitted',
|
|
26
|
+
'release_reserved',
|
|
27
|
+
'released',
|
|
28
|
+
'disputed',
|
|
29
|
+
'amendment_pending',
|
|
30
|
+
'cancelled',
|
|
31
|
+
'completed',
|
|
32
|
+
'release_indeterminate',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const ACTION_ESCROW_TRANSITIONS = Object.freeze({
|
|
36
|
+
draft: Object.freeze(['awaiting_acceptance', 'cancelled']),
|
|
37
|
+
awaiting_acceptance: Object.freeze(['effective', 'cancelled']),
|
|
38
|
+
effective: Object.freeze(['awaiting_funding', 'amendment_pending', 'cancelled']),
|
|
39
|
+
awaiting_funding: Object.freeze(['funded']),
|
|
40
|
+
funded: Object.freeze(['milestone_submitted', 'disputed']),
|
|
41
|
+
milestone_submitted: Object.freeze([
|
|
42
|
+
'release_reserved',
|
|
43
|
+
'disputed',
|
|
44
|
+
]),
|
|
45
|
+
release_reserved: Object.freeze(['released', 'release_indeterminate', 'milestone_submitted']),
|
|
46
|
+
released: Object.freeze(['completed']),
|
|
47
|
+
disputed: Object.freeze([]),
|
|
48
|
+
amendment_pending: Object.freeze(['effective', 'cancelled']),
|
|
49
|
+
cancelled: Object.freeze([]),
|
|
50
|
+
completed: Object.freeze([]),
|
|
51
|
+
release_indeterminate: Object.freeze([
|
|
52
|
+
'released',
|
|
53
|
+
'milestone_submitted',
|
|
54
|
+
]),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const RESOLUTION_VERSION = 'EP-RESOLUTION-v1';
|
|
58
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
59
|
+
const MAX_CAS_ATTEMPTS = 4;
|
|
60
|
+
const MAX_OPERATIONS = 256;
|
|
61
|
+
const MAX_HISTORY = 512;
|
|
62
|
+
const MAX_SUPERSEDED_BINDINGS = 64;
|
|
63
|
+
const MAX_STATE_BYTES = 4 * 1024 * 1024;
|
|
64
|
+
const DEFAULT_PROVIDER_TIMEOUT_MS = 30_000;
|
|
65
|
+
|
|
66
|
+
const STORE_CAPABILITIES = Object.freeze([
|
|
67
|
+
'durable',
|
|
68
|
+
'atomicExpectedRevisionCas',
|
|
69
|
+
'linearizableReads',
|
|
70
|
+
'monotonicRevisions',
|
|
71
|
+
'nonExpiring',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const COMMON_INPUT_KEYS = Object.freeze([
|
|
75
|
+
'agreement_digest',
|
|
76
|
+
'document_action_binding_digest',
|
|
77
|
+
'milestone_id',
|
|
78
|
+
'release_action_digest',
|
|
79
|
+
'parties',
|
|
80
|
+
'profile',
|
|
81
|
+
'idempotency_key',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const RECORD_KEYS = new Set([
|
|
85
|
+
'@version',
|
|
86
|
+
'escrow_key',
|
|
87
|
+
'revision',
|
|
88
|
+
'state',
|
|
89
|
+
'agreement_digest',
|
|
90
|
+
'document_action_binding_digest',
|
|
91
|
+
'milestone_id',
|
|
92
|
+
'release_action_digest',
|
|
93
|
+
'parties',
|
|
94
|
+
'parties_digest',
|
|
95
|
+
'profile',
|
|
96
|
+
'profile_digest',
|
|
97
|
+
'document_action_binding',
|
|
98
|
+
'agreement_acceptances',
|
|
99
|
+
'funding',
|
|
100
|
+
'milestone_evidence',
|
|
101
|
+
'release_approvals',
|
|
102
|
+
'release',
|
|
103
|
+
'dispute',
|
|
104
|
+
'cancellation',
|
|
105
|
+
'completion',
|
|
106
|
+
'pending_amendment',
|
|
107
|
+
'superseded_bindings',
|
|
108
|
+
'operations',
|
|
109
|
+
'history',
|
|
110
|
+
'created_at',
|
|
111
|
+
'updated_at',
|
|
112
|
+
]);
|
|
113
|
+
const CONTAINER_KEYS = new Set(['artifact', 'verification']);
|
|
114
|
+
const FUNDING_CONTAINER_KEYS = new Set(['statement', 'verification']);
|
|
115
|
+
const ACCEPTANCE_KEYS = new Set(['party_id', 'artifact', 'verification']);
|
|
116
|
+
const RELEASE_APPROVAL_KEYS = new Set(['party_id', 'resolution', 'verification']);
|
|
117
|
+
const CORE_VERIFICATION_KEYS = new Set([
|
|
118
|
+
'valid',
|
|
119
|
+
'agreement_digest',
|
|
120
|
+
'document_action_binding_digest',
|
|
121
|
+
'milestone_id',
|
|
122
|
+
'release_action_digest',
|
|
123
|
+
'parties_digest',
|
|
124
|
+
'profile_digest',
|
|
125
|
+
]);
|
|
126
|
+
const ACCEPTANCE_VERIFICATION_KEYS = new Set([
|
|
127
|
+
...CORE_VERIFICATION_KEYS,
|
|
128
|
+
'party_id',
|
|
129
|
+
'principal_key_id',
|
|
130
|
+
'acceptance_digest',
|
|
131
|
+
]);
|
|
132
|
+
const FUNDING_VERIFICATION_KEYS = new Set([
|
|
133
|
+
...CORE_VERIFICATION_KEYS,
|
|
134
|
+
'authenticated',
|
|
135
|
+
'provider_id',
|
|
136
|
+
'statement_type',
|
|
137
|
+
'status',
|
|
138
|
+
'provider_transaction_id',
|
|
139
|
+
'provider_milestone_id',
|
|
140
|
+
'amount',
|
|
141
|
+
'currency',
|
|
142
|
+
'destination_id',
|
|
143
|
+
'statement_digest',
|
|
144
|
+
]);
|
|
145
|
+
const MILESTONE_VERIFICATION_KEYS = new Set([
|
|
146
|
+
...CORE_VERIFICATION_KEYS,
|
|
147
|
+
'evidence_digest',
|
|
148
|
+
'submitter_party_id',
|
|
149
|
+
'observed_at',
|
|
150
|
+
]);
|
|
151
|
+
const RELEASE_APPROVAL_VERIFICATION_KEYS = new Set([
|
|
152
|
+
...CORE_VERIFICATION_KEYS,
|
|
153
|
+
'authorizes_action',
|
|
154
|
+
'outcome',
|
|
155
|
+
'party_role',
|
|
156
|
+
'principal_key_id',
|
|
157
|
+
'nonce',
|
|
158
|
+
'issued_at',
|
|
159
|
+
'expires_at',
|
|
160
|
+
'resolution_digest',
|
|
161
|
+
'evidence_digest',
|
|
162
|
+
]);
|
|
163
|
+
const PROVIDER_VERIFICATION_KEYS = new Set([
|
|
164
|
+
...CORE_VERIFICATION_KEYS,
|
|
165
|
+
'authenticated',
|
|
166
|
+
'provider_id',
|
|
167
|
+
'provider_idempotency_key',
|
|
168
|
+
'provider_request_digest',
|
|
169
|
+
'provider_transaction_id',
|
|
170
|
+
'provider_milestone_id',
|
|
171
|
+
'amount',
|
|
172
|
+
'currency',
|
|
173
|
+
'destination_id',
|
|
174
|
+
'statement_type',
|
|
175
|
+
'status',
|
|
176
|
+
'statement_digest',
|
|
177
|
+
]);
|
|
178
|
+
const OPERATION_KEYS = new Set([
|
|
179
|
+
'idempotency_key',
|
|
180
|
+
'operation',
|
|
181
|
+
'request_digest',
|
|
182
|
+
'code',
|
|
183
|
+
'ok',
|
|
184
|
+
'outcome',
|
|
185
|
+
'state',
|
|
186
|
+
'at',
|
|
187
|
+
]);
|
|
188
|
+
const HISTORY_KEYS = new Set(['from', 'to', 'operation', 'idempotency_key', 'at']);
|
|
189
|
+
const KERNEL_OPERATIONS = new Set([
|
|
190
|
+
'create',
|
|
191
|
+
'begin_acceptance',
|
|
192
|
+
'accept_agreement',
|
|
193
|
+
'request_funding',
|
|
194
|
+
'record_funding',
|
|
195
|
+
'submit_milestone',
|
|
196
|
+
'approve_release',
|
|
197
|
+
'release',
|
|
198
|
+
'reconcile_release',
|
|
199
|
+
'open_dispute',
|
|
200
|
+
'propose_amendment',
|
|
201
|
+
'accept_amendment',
|
|
202
|
+
'cancel',
|
|
203
|
+
'complete',
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
function isPlainObject(value) {
|
|
207
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
208
|
+
const prototype = Object.getPrototypeOf(value);
|
|
209
|
+
return prototype === Object.prototype || prototype === null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function exactKeys(value, allowed, required = allowed) {
|
|
213
|
+
if (!isPlainObject(value)) return false;
|
|
214
|
+
const keys = Object.keys(value);
|
|
215
|
+
return keys.every((key) => allowed.has(key))
|
|
216
|
+
&& [...required].every((key) => Object.hasOwn(value, key));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validString(value, max = 512) {
|
|
220
|
+
return typeof value === 'string'
|
|
221
|
+
&& value.length > 0
|
|
222
|
+
&& value.length <= max
|
|
223
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function validDigest(value) {
|
|
227
|
+
return typeof value === 'string' && DIGEST_RE.test(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validInstant(value) {
|
|
231
|
+
if (!validString(value, 64)) return false;
|
|
232
|
+
const parsed = Date.parse(value);
|
|
233
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function canonicalSnapshot(value) {
|
|
237
|
+
return JSON.parse(canonicalize(value));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function canonicalDigest(value) {
|
|
241
|
+
return `sha256:${hashCanonical(value)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function resolutionBindingInput(record) {
|
|
245
|
+
return {
|
|
246
|
+
agreement_digest: record.agreement_digest,
|
|
247
|
+
document_action_binding_digest: record.document_action_binding_digest,
|
|
248
|
+
milestone_id: record.milestone_id,
|
|
249
|
+
release_action_digest: record.release_action_digest,
|
|
250
|
+
profile_digest: record.profile_digest,
|
|
251
|
+
evidence_digest: record.milestone_evidence?.verification?.evidence_digest,
|
|
252
|
+
release_action_template:
|
|
253
|
+
record.document_action_binding?.verification?.release_action_template,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build the exact human-facing envelope signed by an Action Escrow approver.
|
|
259
|
+
*
|
|
260
|
+
* The release action digest remains the normative machine-action binding. This
|
|
261
|
+
* envelope binds the document, evidence, and material release fields that the
|
|
262
|
+
* approver reviews before selecting the approval option.
|
|
263
|
+
*/
|
|
264
|
+
export function createActionEscrowReleaseBindingMoment(input) {
|
|
265
|
+
try {
|
|
266
|
+
if (!isPlainObject(input)
|
|
267
|
+
|| !validDigest(input.agreement_digest)
|
|
268
|
+
|| !validDigest(input.document_action_binding_digest)
|
|
269
|
+
|| !validString(input.milestone_id, 256)
|
|
270
|
+
|| !validDigest(input.release_action_digest)
|
|
271
|
+
|| !validDigest(input.profile_digest)
|
|
272
|
+
|| !validDigest(input.evidence_digest)
|
|
273
|
+
|| !isPlainObject(input.release_action_template)) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const template = input.release_action_template;
|
|
277
|
+
if (template.action_type !== 'escrow.milestone.release'
|
|
278
|
+
|| !validString(template.amount, 128)
|
|
279
|
+
|| !validString(template.currency, 16)
|
|
280
|
+
|| !validString(template.payee_id, 512)
|
|
281
|
+
|| !validString(template.destination_id, 512)
|
|
282
|
+
|| !validDigest(template.document_sha256)
|
|
283
|
+
|| !validDigest(template.material_terms_sha256)
|
|
284
|
+
|| template.completion_evidence_sha256 !== input.evidence_digest
|
|
285
|
+
|| !Number.isSafeInteger(template.amendment_version)
|
|
286
|
+
|| template.amendment_version < 1) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return deepFreeze(canonicalSnapshot({
|
|
290
|
+
synopsis: `Authorize one ${template.amount} ${template.currency} milestone release.`,
|
|
291
|
+
findings: [
|
|
292
|
+
`Agreement digest: ${input.agreement_digest}`,
|
|
293
|
+
`Document-action binding digest: ${input.document_action_binding_digest}`,
|
|
294
|
+
`Release action digest: ${input.release_action_digest}`,
|
|
295
|
+
`Milestone evidence digest: ${input.evidence_digest}`,
|
|
296
|
+
`Milestone: ${input.milestone_id}`,
|
|
297
|
+
`Payee: ${template.payee_id}`,
|
|
298
|
+
`Destination: ${template.destination_id}`,
|
|
299
|
+
`Amendment version: ${template.amendment_version}`,
|
|
300
|
+
],
|
|
301
|
+
recommendations: [
|
|
302
|
+
'Verify the exact document, amount, destination, and completion evidence before approving.',
|
|
303
|
+
],
|
|
304
|
+
offer: 'Decline if any material field is unexpected; amendments require a new binding and fresh approvals.',
|
|
305
|
+
question: {
|
|
306
|
+
stem: `Authorize release of ${template.amount} ${template.currency} for milestone ${input.milestone_id} to ${template.payee_id} at ${template.destination_id}?`,
|
|
307
|
+
options: [
|
|
308
|
+
{
|
|
309
|
+
label: 'Approve exact release',
|
|
310
|
+
reasoning: 'Authorize only the action identified by the signed action digest.',
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
label: 'Decline release',
|
|
314
|
+
reasoning: 'Do not authorize this action or any custodian effect.',
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
recommended_idx: 1,
|
|
318
|
+
hatches: {
|
|
319
|
+
free_text: false,
|
|
320
|
+
dialogue: false,
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
meta: {
|
|
324
|
+
decision_class: 'escrow.milestone.release',
|
|
325
|
+
calibration_note: 'No approval recommendation; verify every material field.',
|
|
326
|
+
},
|
|
327
|
+
}));
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function computeActionEscrowReleaseBindingMomentDigest(input) {
|
|
334
|
+
const bindingMoment = createActionEscrowReleaseBindingMoment(input);
|
|
335
|
+
return bindingMoment === null ? null : canonicalDigest(bindingMoment);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Return the relying-party-pinned nonce for one party and one exact release.
|
|
340
|
+
*
|
|
341
|
+
* It is stable while both parties approve, changes with any material action or
|
|
342
|
+
* evidence change, and is consumed by the durable approval CAS.
|
|
343
|
+
*/
|
|
344
|
+
export function computeActionEscrowResolutionNonce(input, partyId) {
|
|
345
|
+
if (!validString(partyId, 256)) return null;
|
|
346
|
+
const bindingMomentDigest = computeActionEscrowReleaseBindingMomentDigest(input);
|
|
347
|
+
if (bindingMomentDigest === null) return null;
|
|
348
|
+
try {
|
|
349
|
+
return `ep-ae-resolution:${hashCanonical({
|
|
350
|
+
'@version': 'EP-ACTION-ESCROW-RESOLUTION-NONCE-v1',
|
|
351
|
+
party_id: partyId,
|
|
352
|
+
binding_moment_digest: bindingMomentDigest,
|
|
353
|
+
release_action_digest: input.release_action_digest,
|
|
354
|
+
evidence_digest: input.evidence_digest,
|
|
355
|
+
})}`;
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function deepFreeze(value) {
|
|
362
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
363
|
+
Object.freeze(value);
|
|
364
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function sameStringSet(left, right) {
|
|
369
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
370
|
+
const values = new Set(left);
|
|
371
|
+
return values.size === left.length && right.every((value) => values.has(value));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function validateParties(parties) {
|
|
375
|
+
if (!Array.isArray(parties) || parties.length < 2 || parties.length > 16) {
|
|
376
|
+
return 'parties_invalid';
|
|
377
|
+
}
|
|
378
|
+
const ids: string[] = [];
|
|
379
|
+
for (const party of parties) {
|
|
380
|
+
if (!exactKeys(party, new Set(['party_id', 'role']))
|
|
381
|
+
|| !validString(party.party_id, 256)
|
|
382
|
+
|| !validString(party.role, 128)) {
|
|
383
|
+
return 'party_invalid';
|
|
384
|
+
}
|
|
385
|
+
ids.push(party.party_id);
|
|
386
|
+
}
|
|
387
|
+
return new Set(ids).size === ids.length ? null : 'party_duplicate';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function validateProfileShape(profile) {
|
|
391
|
+
if (!exactKeys(profile, new Set([
|
|
392
|
+
'@version',
|
|
393
|
+
'profile_id',
|
|
394
|
+
'provider_id',
|
|
395
|
+
'required_acceptance_party_ids',
|
|
396
|
+
'required_release_approver_party_ids',
|
|
397
|
+
'prohibit_self_approval',
|
|
398
|
+
]))) {
|
|
399
|
+
return 'profile_shape_invalid';
|
|
400
|
+
}
|
|
401
|
+
if (profile['@version'] !== ACTION_ESCROW_PROFILE_VERSION
|
|
402
|
+
|| !validString(profile.profile_id, 256)
|
|
403
|
+
|| !validString(profile.provider_id, 256)) {
|
|
404
|
+
return 'profile_identity_invalid';
|
|
405
|
+
}
|
|
406
|
+
for (const field of [
|
|
407
|
+
'required_acceptance_party_ids',
|
|
408
|
+
'required_release_approver_party_ids',
|
|
409
|
+
]) {
|
|
410
|
+
const roster = profile[field];
|
|
411
|
+
if (!Array.isArray(roster) || roster.length === 0 || roster.length > 16
|
|
412
|
+
|| roster.some((partyId) => !validString(partyId, 256))
|
|
413
|
+
|| new Set(roster).size !== roster.length) {
|
|
414
|
+
return `${field}_invalid`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (profile.prohibit_self_approval !== true
|
|
418
|
+
&& profile.prohibit_self_approval !== false) {
|
|
419
|
+
return 'profile_self_approval_policy_invalid';
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function validateProfileForParties(profile, parties) {
|
|
425
|
+
const shapeError = validateProfileShape(profile);
|
|
426
|
+
if (shapeError) return shapeError;
|
|
427
|
+
const partyIds = parties.map((party) => party.party_id);
|
|
428
|
+
if (!sameStringSet(profile.required_acceptance_party_ids, partyIds)) {
|
|
429
|
+
return 'mutual_acceptance_roster_invalid';
|
|
430
|
+
}
|
|
431
|
+
if (!profile.required_release_approver_party_ids.every((partyId) => partyIds.includes(partyId))) {
|
|
432
|
+
return 'release_approval_roster_invalid';
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function escrowKey(context) {
|
|
438
|
+
return `ep-action-escrow:${hashCanonical({
|
|
439
|
+
'@version': 'EP-ACTION-ESCROW-KEY-v1',
|
|
440
|
+
agreement_digest: context.agreement_digest,
|
|
441
|
+
milestone_id: context.milestone_id,
|
|
442
|
+
})}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function releaseReservationKey(context) {
|
|
446
|
+
return `ep-ae-reservation:${hashCanonical({
|
|
447
|
+
'@version': 'EP-ACTION-ESCROW-RELEASE-KEY-v1',
|
|
448
|
+
agreement_digest: context.agreement_digest,
|
|
449
|
+
document_action_binding_digest: context.document_action_binding_digest,
|
|
450
|
+
milestone_id: context.milestone_id,
|
|
451
|
+
release_action_digest: context.release_action_digest,
|
|
452
|
+
profile_digest: context.profile_digest,
|
|
453
|
+
})}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function providerIdempotencyKey(context) {
|
|
457
|
+
return `ep-ae-release:${hashCanonical({
|
|
458
|
+
'@version': 'EP-ACTION-ESCROW-PROVIDER-IDEMPOTENCY-v1',
|
|
459
|
+
agreement_digest: context.agreement_digest,
|
|
460
|
+
document_action_binding_digest: context.document_action_binding_digest,
|
|
461
|
+
milestone_id: context.milestone_id,
|
|
462
|
+
release_action_digest: context.release_action_digest,
|
|
463
|
+
profile_digest: context.profile_digest,
|
|
464
|
+
})}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function expectedBindings(context) {
|
|
468
|
+
return {
|
|
469
|
+
agreement_digest: context.agreement_digest,
|
|
470
|
+
document_action_binding_digest: context.document_action_binding_digest,
|
|
471
|
+
milestone_id: context.milestone_id,
|
|
472
|
+
release_action_digest: context.release_action_digest,
|
|
473
|
+
parties_digest: context.parties_digest,
|
|
474
|
+
profile_digest: context.profile_digest,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function normalizedInput(
|
|
479
|
+
operation,
|
|
480
|
+
input,
|
|
481
|
+
extraAllowed: string[] = [],
|
|
482
|
+
extraRequired: string[] = extraAllowed,
|
|
483
|
+
) {
|
|
484
|
+
try {
|
|
485
|
+
const snapshot = canonicalSnapshot(input);
|
|
486
|
+
const allowed = new Set([...COMMON_INPUT_KEYS, ...extraAllowed]);
|
|
487
|
+
const required = new Set([...COMMON_INPUT_KEYS, ...extraRequired]);
|
|
488
|
+
if (!exactKeys(snapshot, allowed, required)) {
|
|
489
|
+
return { error: 'invalid_operation_input' };
|
|
490
|
+
}
|
|
491
|
+
if (!validDigest(snapshot.agreement_digest)
|
|
492
|
+
|| !validDigest(snapshot.document_action_binding_digest)
|
|
493
|
+
|| !validDigest(snapshot.release_action_digest)
|
|
494
|
+
|| !validString(snapshot.milestone_id, 256)
|
|
495
|
+
|| !validString(snapshot.idempotency_key, 256)) {
|
|
496
|
+
return { error: 'invalid_operation_binding' };
|
|
497
|
+
}
|
|
498
|
+
const partiesError = validateParties(snapshot.parties);
|
|
499
|
+
if (partiesError) return { error: partiesError };
|
|
500
|
+
const profileError = validateProfileForParties(snapshot.profile, snapshot.parties);
|
|
501
|
+
if (profileError) return { error: profileError };
|
|
502
|
+
|
|
503
|
+
const context = {
|
|
504
|
+
agreement_digest: snapshot.agreement_digest,
|
|
505
|
+
document_action_binding_digest: snapshot.document_action_binding_digest,
|
|
506
|
+
milestone_id: snapshot.milestone_id,
|
|
507
|
+
release_action_digest: snapshot.release_action_digest,
|
|
508
|
+
parties: snapshot.parties,
|
|
509
|
+
parties_digest: canonicalDigest(snapshot.parties),
|
|
510
|
+
profile: snapshot.profile,
|
|
511
|
+
profile_digest: canonicalDigest(snapshot.profile),
|
|
512
|
+
};
|
|
513
|
+
return {
|
|
514
|
+
snapshot,
|
|
515
|
+
context,
|
|
516
|
+
escrowKey: escrowKey(context),
|
|
517
|
+
requestDigest: canonicalDigest({ operation, input: snapshot }),
|
|
518
|
+
};
|
|
519
|
+
} catch {
|
|
520
|
+
return { error: 'invalid_operation_input' };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function outcome({
|
|
525
|
+
ok = false,
|
|
526
|
+
type = 'refused',
|
|
527
|
+
code,
|
|
528
|
+
operation = null,
|
|
529
|
+
record = null,
|
|
530
|
+
details = null,
|
|
531
|
+
}: {
|
|
532
|
+
ok?: boolean;
|
|
533
|
+
type?: string;
|
|
534
|
+
code?: string;
|
|
535
|
+
operation?: string | null;
|
|
536
|
+
record?: Record<string, any> | null;
|
|
537
|
+
details?: any;
|
|
538
|
+
}) {
|
|
539
|
+
try {
|
|
540
|
+
return deepFreeze(canonicalSnapshot({
|
|
541
|
+
'@version': ACTION_ESCROW_OUTCOME_VERSION,
|
|
542
|
+
ok,
|
|
543
|
+
outcome: type,
|
|
544
|
+
code,
|
|
545
|
+
operation,
|
|
546
|
+
state: record?.state ?? null,
|
|
547
|
+
revision: record?.revision ?? null,
|
|
548
|
+
escrow_key: record?.escrow_key ?? null,
|
|
549
|
+
details,
|
|
550
|
+
record,
|
|
551
|
+
}));
|
|
552
|
+
} catch {
|
|
553
|
+
return Object.freeze({
|
|
554
|
+
'@version': ACTION_ESCROW_OUTCOME_VERSION,
|
|
555
|
+
ok: false,
|
|
556
|
+
outcome: 'refused',
|
|
557
|
+
code: 'closed_outcome_encoding_failed',
|
|
558
|
+
operation,
|
|
559
|
+
state: null,
|
|
560
|
+
revision: null,
|
|
561
|
+
escrow_key: null,
|
|
562
|
+
details: null,
|
|
563
|
+
record: null,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function operationBindingMatches(record, normalized, mode = 'active') {
|
|
569
|
+
const binding = mode === 'pending'
|
|
570
|
+
? record.pending_amendment
|
|
571
|
+
: record;
|
|
572
|
+
if (!binding) return false;
|
|
573
|
+
return record.agreement_digest === normalized.context.agreement_digest
|
|
574
|
+
&& record.milestone_id === normalized.context.milestone_id
|
|
575
|
+
&& binding.document_action_binding_digest
|
|
576
|
+
=== normalized.context.document_action_binding_digest
|
|
577
|
+
&& binding.release_action_digest === normalized.context.release_action_digest
|
|
578
|
+
&& record.parties_digest === normalized.context.parties_digest
|
|
579
|
+
&& record.profile_digest === normalized.context.profile_digest;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function boundVerificationMatches(result, expected) {
|
|
583
|
+
return isPlainObject(result)
|
|
584
|
+
&& result.valid === true
|
|
585
|
+
&& result.agreement_digest === expected.agreement_digest
|
|
586
|
+
&& result.document_action_binding_digest === expected.document_action_binding_digest
|
|
587
|
+
&& result.milestone_id === expected.milestone_id
|
|
588
|
+
&& result.release_action_digest === expected.release_action_digest
|
|
589
|
+
&& result.parties_digest === expected.parties_digest
|
|
590
|
+
&& result.profile_digest === expected.profile_digest;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function boundVerificationSummary(
|
|
594
|
+
result: any,
|
|
595
|
+
expected: any,
|
|
596
|
+
extras: Record<string, any> = {},
|
|
597
|
+
): Record<string, any> {
|
|
598
|
+
return {
|
|
599
|
+
valid: true,
|
|
600
|
+
...expectedBindings(expected),
|
|
601
|
+
...extras,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function bindingVerificationDetails(result, expected) {
|
|
606
|
+
try {
|
|
607
|
+
if (!boundVerificationMatches(result, expectedBindings(expected))
|
|
608
|
+
|| !validDigest(result.verification_digest)
|
|
609
|
+
|| !validDigest(result.document_digest)
|
|
610
|
+
|| !validString(result.agreement_id, 256)
|
|
611
|
+
|| !validString(result.binding_id, 256)
|
|
612
|
+
|| !isPlainObject(result.release_action_template)) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
// Passed through an explicitly Record<string, any>-typed local (rather than
|
|
616
|
+
// as an inline object literal) so this call isn't subject to TypeScript's
|
|
617
|
+
// excess-property check against validateActionEscrowReleaseTemplate's
|
|
618
|
+
// options type, which (in action-escrow-verifiers.ts, out of scope here)
|
|
619
|
+
// is currently under-typed relative to the properties it actually reads.
|
|
620
|
+
const releaseActionTemplateOptions: Record<string, any> = {
|
|
621
|
+
profileDigest: expected.profile_digest,
|
|
622
|
+
agreementId: result.agreement_id,
|
|
623
|
+
agreementDigest: expected.agreement_digest,
|
|
624
|
+
milestoneId: expected.milestone_id,
|
|
625
|
+
documentDigest: result.document_digest,
|
|
626
|
+
contractorProjectSource:
|
|
627
|
+
result.release_action_template.action_escrow_template_profile
|
|
628
|
+
=== ACTION_ESCROW_CONTRACTOR_TEMPLATE_VERSION,
|
|
629
|
+
};
|
|
630
|
+
const releaseActionTemplate = validateActionEscrowReleaseTemplate(
|
|
631
|
+
result.release_action_template,
|
|
632
|
+
releaseActionTemplateOptions,
|
|
633
|
+
);
|
|
634
|
+
if (!releaseActionTemplate) return null;
|
|
635
|
+
return {
|
|
636
|
+
verification_digest: result.verification_digest,
|
|
637
|
+
document_digest: result.document_digest,
|
|
638
|
+
agreement_id: result.agreement_id,
|
|
639
|
+
binding_id: result.binding_id,
|
|
640
|
+
release_action_template: releaseActionTemplate,
|
|
641
|
+
};
|
|
642
|
+
} catch {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function storedBindingVerificationValid(container, expected) {
|
|
648
|
+
if (!isPlainObject(container)
|
|
649
|
+
|| !isPlainObject(container.artifact)
|
|
650
|
+
|| !isPlainObject(container.verification)
|
|
651
|
+
|| container.verification.valid !== true) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
return bindingVerificationDetails(container.verification, expected) !== null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function coreVerificationValid(value, record, allowedKeys, overrides = {}) {
|
|
658
|
+
const expected = {
|
|
659
|
+
agreement_digest: record.agreement_digest,
|
|
660
|
+
document_action_binding_digest: record.document_action_binding_digest,
|
|
661
|
+
milestone_id: record.milestone_id,
|
|
662
|
+
release_action_digest: record.release_action_digest,
|
|
663
|
+
parties_digest: record.parties_digest,
|
|
664
|
+
profile_digest: record.profile_digest,
|
|
665
|
+
...overrides,
|
|
666
|
+
};
|
|
667
|
+
return exactKeys(value, allowedKeys, CORE_VERIFICATION_KEYS)
|
|
668
|
+
&& value.valid === true
|
|
669
|
+
&& Object.entries(expected).every(([key, entry]) => value[key] === entry);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function storedAcceptancesValid(entries, record, {
|
|
673
|
+
documentActionBindingDigest = record.document_action_binding_digest,
|
|
674
|
+
releaseActionDigest = record.release_action_digest,
|
|
675
|
+
} = {}) {
|
|
676
|
+
if (!Array.isArray(entries) || entries.length > record.profile.required_acceptance_party_ids.length) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
const required = new Set(record.profile.required_acceptance_party_ids);
|
|
680
|
+
const parties = new Set(record.parties.map((party) => party.party_id));
|
|
681
|
+
const seenParties = new Set();
|
|
682
|
+
const seenKeys = new Set();
|
|
683
|
+
for (const entry of entries) {
|
|
684
|
+
const verification = entry?.verification;
|
|
685
|
+
if (!exactKeys(entry, ACCEPTANCE_KEYS)
|
|
686
|
+
|| !isPlainObject(entry.artifact)
|
|
687
|
+
|| !isPlainObject(verification)
|
|
688
|
+
|| !coreVerificationValid(verification, record, ACCEPTANCE_VERIFICATION_KEYS, {
|
|
689
|
+
document_action_binding_digest: documentActionBindingDigest,
|
|
690
|
+
release_action_digest: releaseActionDigest,
|
|
691
|
+
})
|
|
692
|
+
|| !parties.has(entry.party_id)
|
|
693
|
+
|| !required.has(entry.party_id)
|
|
694
|
+
|| seenParties.has(entry.party_id)
|
|
695
|
+
|| verification.party_id !== entry.party_id
|
|
696
|
+
|| !validString(verification.principal_key_id, 512)
|
|
697
|
+
|| seenKeys.has(verification.principal_key_id)
|
|
698
|
+
|| !validDigest(verification.acceptance_digest)) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
seenParties.add(entry.party_id);
|
|
702
|
+
seenKeys.add(verification.principal_key_id);
|
|
703
|
+
}
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function storedFundingValid(record) {
|
|
708
|
+
if (record.funding === null) return true;
|
|
709
|
+
const template = record.document_action_binding.verification.release_action_template;
|
|
710
|
+
const verification = record.funding?.verification;
|
|
711
|
+
return exactKeys(record.funding, FUNDING_CONTAINER_KEYS)
|
|
712
|
+
&& isPlainObject(record.funding.statement)
|
|
713
|
+
&& coreVerificationValid(verification, record, FUNDING_VERIFICATION_KEYS)
|
|
714
|
+
&& verification.authenticated === true
|
|
715
|
+
&& verification.provider_id === record.profile.provider_id
|
|
716
|
+
&& verification.statement_type === 'funding'
|
|
717
|
+
&& verification.status === 'funded'
|
|
718
|
+
&& verification.provider_transaction_id === template.custodian_transaction_id
|
|
719
|
+
&& verification.provider_milestone_id === template.custodian_milestone_id
|
|
720
|
+
&& verification.amount === template.amount
|
|
721
|
+
&& verification.currency === template.currency
|
|
722
|
+
&& verification.destination_id === template.destination_id
|
|
723
|
+
&& validDigest(verification.statement_digest);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function storedMilestoneEvidenceValid(record) {
|
|
727
|
+
if (record.milestone_evidence === null) return true;
|
|
728
|
+
const verification = record.milestone_evidence?.verification;
|
|
729
|
+
return exactKeys(record.milestone_evidence, CONTAINER_KEYS)
|
|
730
|
+
&& isPlainObject(record.milestone_evidence.artifact)
|
|
731
|
+
&& coreVerificationValid(verification, record, MILESTONE_VERIFICATION_KEYS)
|
|
732
|
+
&& validDigest(verification.evidence_digest)
|
|
733
|
+
&& record.parties.some((party) => (
|
|
734
|
+
party.party_id === verification.submitter_party_id
|
|
735
|
+
))
|
|
736
|
+
&& validInstant(verification.observed_at);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function storedReleaseApprovalsValid(record) {
|
|
740
|
+
if (!Array.isArray(record.release_approvals)
|
|
741
|
+
|| record.release_approvals.length > record.profile.required_release_approver_party_ids.length
|
|
742
|
+
|| (record.release_approvals.length > 0 && record.milestone_evidence === null)) {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
const required = new Set(record.profile.required_release_approver_party_ids);
|
|
746
|
+
const parties = new Map<string, any>(record.parties.map((party) => [party.party_id, party]));
|
|
747
|
+
const seenParties = new Set();
|
|
748
|
+
const seenKeys = new Set();
|
|
749
|
+
const evidenceDigest = record.milestone_evidence?.verification?.evidence_digest;
|
|
750
|
+
const bindingInput = resolutionBindingInput(record);
|
|
751
|
+
const bindingMomentDigest = computeActionEscrowReleaseBindingMomentDigest(bindingInput);
|
|
752
|
+
if (record.release_approvals.length > 0
|
|
753
|
+
&& (!validDigest(evidenceDigest) || !validDigest(bindingMomentDigest))) {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
for (const entry of record.release_approvals) {
|
|
757
|
+
const party = parties.get(entry?.party_id);
|
|
758
|
+
const verification = entry?.verification;
|
|
759
|
+
const context = entry?.resolution?.signoff?.context;
|
|
760
|
+
if (!exactKeys(entry, RELEASE_APPROVAL_KEYS)
|
|
761
|
+
|| !party
|
|
762
|
+
|| !required.has(entry.party_id)
|
|
763
|
+
|| seenParties.has(entry.party_id)
|
|
764
|
+
|| !isPlainObject(entry.resolution)
|
|
765
|
+
|| entry.resolution.profile !== RESOLUTION_VERSION
|
|
766
|
+
|| !isPlainObject(context)
|
|
767
|
+
|| !isPlainObject(context.resolution)
|
|
768
|
+
|| !coreVerificationValid(verification, record, RELEASE_APPROVAL_VERIFICATION_KEYS)
|
|
769
|
+
|| verification.authorizes_action !== true
|
|
770
|
+
|| verification.outcome !== 'approved'
|
|
771
|
+
|| verification.party_role !== party.role
|
|
772
|
+
|| !validString(verification.principal_key_id, 512)
|
|
773
|
+
|| seenKeys.has(verification.principal_key_id)
|
|
774
|
+
|| !validString(verification.nonce, 512)
|
|
775
|
+
|| !validInstant(verification.issued_at)
|
|
776
|
+
|| !validInstant(verification.expires_at)
|
|
777
|
+
|| verification.resolution_digest !== canonicalDigest(entry.resolution)
|
|
778
|
+
|| verification.evidence_digest !== evidenceDigest
|
|
779
|
+
|| context.principal !== entry.party_id
|
|
780
|
+
|| context.principal_key_id !== verification.principal_key_id
|
|
781
|
+
|| context.envelope_hash !== bindingMomentDigest
|
|
782
|
+
|| context.action_hash !== record.release_action_digest
|
|
783
|
+
|| context.initiator !== record.milestone_evidence.verification.submitter_party_id
|
|
784
|
+
|| context.nonce !== computeActionEscrowResolutionNonce(bindingInput, entry.party_id)
|
|
785
|
+
|| context.nonce !== verification.nonce
|
|
786
|
+
|| context.issued_at !== verification.issued_at
|
|
787
|
+
|| context.expires_at !== verification.expires_at
|
|
788
|
+
|| context.resolution.outcome !== 'approved'
|
|
789
|
+
|| context.resolution.selected_option !== 0) {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
seenParties.add(entry.party_id);
|
|
793
|
+
seenKeys.add(verification.principal_key_id);
|
|
794
|
+
}
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function storedHistoryValid(record) {
|
|
799
|
+
if (!Array.isArray(record.operations)
|
|
800
|
+
|| record.operations.length === 0
|
|
801
|
+
|| !Array.isArray(record.history)
|
|
802
|
+
|| record.history.length === 0
|
|
803
|
+
|| record.operations.length > MAX_OPERATIONS
|
|
804
|
+
|| record.history.length > MAX_HISTORY) {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
const operations = new Map();
|
|
808
|
+
for (const entry of record.operations) {
|
|
809
|
+
if (!exactKeys(entry, OPERATION_KEYS)
|
|
810
|
+
|| !validString(entry.idempotency_key, 512)
|
|
811
|
+
|| operations.has(entry.idempotency_key)
|
|
812
|
+
|| !validString(entry.operation, 128)
|
|
813
|
+
|| !KERNEL_OPERATIONS.has(entry.operation)
|
|
814
|
+
|| !validDigest(entry.request_digest)
|
|
815
|
+
|| !validString(entry.code, 256)
|
|
816
|
+
|| typeof entry.ok !== 'boolean'
|
|
817
|
+
|| !validString(entry.outcome, 128)
|
|
818
|
+
|| !ACTION_ESCROW_STATES.includes(entry.state)
|
|
819
|
+
|| !validInstant(entry.at)) {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
operations.set(entry.idempotency_key, entry);
|
|
823
|
+
}
|
|
824
|
+
let previousState = null;
|
|
825
|
+
for (const [index, entry] of record.history.entries()) {
|
|
826
|
+
const operation = operations.get(entry?.idempotency_key);
|
|
827
|
+
if (!exactKeys(entry, HISTORY_KEYS)
|
|
828
|
+
|| !ACTION_ESCROW_STATES.includes(entry.to)
|
|
829
|
+
|| (entry.from !== null && !ACTION_ESCROW_STATES.includes(entry.from))
|
|
830
|
+
|| !validString(entry.operation, 128)
|
|
831
|
+
|| !validString(entry.idempotency_key, 512)
|
|
832
|
+
|| !validInstant(entry.at)
|
|
833
|
+
|| !operation
|
|
834
|
+
|| operation.operation !== entry.operation
|
|
835
|
+
|| entry.from !== previousState
|
|
836
|
+
|| (index > 0 && !ACTION_ESCROW_TRANSITIONS[entry.from]?.includes(entry.to))) {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
if (index === 0 && (entry.from !== null || entry.to !== 'draft' || entry.operation !== 'create')) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
previousState = entry.to;
|
|
843
|
+
}
|
|
844
|
+
return previousState === record.state;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function recordShapeValid(record, revision) {
|
|
848
|
+
if (!exactKeys(record, RECORD_KEYS)
|
|
849
|
+
|| record['@version'] !== ACTION_ESCROW_STATE_VERSION
|
|
850
|
+
|| record.revision !== revision
|
|
851
|
+
|| !Number.isSafeInteger(record.revision)
|
|
852
|
+
|| record.revision < 0
|
|
853
|
+
|| !ACTION_ESCROW_STATES.includes(record.state)
|
|
854
|
+
|| !validString(record.escrow_key, 256)
|
|
855
|
+
|| !validDigest(record.agreement_digest)
|
|
856
|
+
|| !validDigest(record.document_action_binding_digest)
|
|
857
|
+
|| !validDigest(record.release_action_digest)
|
|
858
|
+
|| !validString(record.milestone_id, 256)
|
|
859
|
+
|| !validDigest(record.parties_digest)
|
|
860
|
+
|| !validDigest(record.profile_digest)
|
|
861
|
+
|| validateParties(record.parties)
|
|
862
|
+
|| validateProfileForParties(record.profile, record.parties)
|
|
863
|
+
|| record.parties_digest !== canonicalDigest(record.parties)
|
|
864
|
+
|| record.profile_digest !== canonicalDigest(record.profile)
|
|
865
|
+
|| record.escrow_key !== escrowKey(record)
|
|
866
|
+
|| !storedBindingVerificationValid(record.document_action_binding, record)
|
|
867
|
+
|| !Array.isArray(record.agreement_acceptances)
|
|
868
|
+
|| !Array.isArray(record.release_approvals)
|
|
869
|
+
|| !Array.isArray(record.superseded_bindings)
|
|
870
|
+
|| !Array.isArray(record.operations)
|
|
871
|
+
|| !Array.isArray(record.history)
|
|
872
|
+
|| record.operations.length > MAX_OPERATIONS
|
|
873
|
+
|| record.history.length > MAX_HISTORY
|
|
874
|
+
|| record.superseded_bindings.length > MAX_SUPERSEDED_BINDINGS
|
|
875
|
+
|| !validInstant(record.created_at)
|
|
876
|
+
|| !validInstant(record.updated_at)
|
|
877
|
+
|| !storedHistoryValid(record)
|
|
878
|
+
|| !storedAcceptancesValid(record.agreement_acceptances, record)
|
|
879
|
+
|| !storedFundingValid(record)
|
|
880
|
+
|| !storedMilestoneEvidenceValid(record)
|
|
881
|
+
|| !storedReleaseApprovalsValid(record)) {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
if (['release_reserved', 'released', 'release_indeterminate'].includes(record.state)
|
|
885
|
+
&& !isPlainObject(record.release)) {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function existingOperation(record, normalized, operation) {
|
|
892
|
+
const found = record.operations.find(
|
|
893
|
+
(entry) => entry.idempotency_key === normalized.snapshot.idempotency_key,
|
|
894
|
+
);
|
|
895
|
+
if (!found) return null;
|
|
896
|
+
if (found.operation !== operation || found.request_digest !== normalized.requestDigest) {
|
|
897
|
+
return { conflict: true };
|
|
898
|
+
}
|
|
899
|
+
return { conflict: false, entry: found };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function idempotentResult(record, normalized, operation) {
|
|
903
|
+
const existing = existingOperation(record, normalized, operation);
|
|
904
|
+
if (!existing) return null;
|
|
905
|
+
if (existing.conflict) {
|
|
906
|
+
return outcome({
|
|
907
|
+
code: 'idempotency_key_conflict',
|
|
908
|
+
operation,
|
|
909
|
+
record,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
if (operation === 'release'
|
|
913
|
+
&& ['release_reserved', 'release_indeterminate'].includes(record.state)) {
|
|
914
|
+
return outcome({
|
|
915
|
+
code: 'release_reconciliation_required',
|
|
916
|
+
operation,
|
|
917
|
+
record,
|
|
918
|
+
type: 'indeterminate',
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return outcome({
|
|
922
|
+
ok: foundOperationOk(existing.entry),
|
|
923
|
+
type: foundOperationOk(existing.entry)
|
|
924
|
+
? 'idempotent'
|
|
925
|
+
: existing.entry.outcome ?? 'refused',
|
|
926
|
+
code: existing.entry.code,
|
|
927
|
+
operation,
|
|
928
|
+
record,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function foundOperationOk(entry) {
|
|
933
|
+
return entry.ok === undefined ? true : entry.ok === true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function appendOperation(
|
|
937
|
+
next: Record<string, any>,
|
|
938
|
+
record: Record<string, any>,
|
|
939
|
+
normalized: Record<string, any>,
|
|
940
|
+
operation: string,
|
|
941
|
+
code: string | undefined,
|
|
942
|
+
at: string,
|
|
943
|
+
result: Record<string, any> = {},
|
|
944
|
+
) {
|
|
945
|
+
if (record.operations.length >= MAX_OPERATIONS) {
|
|
946
|
+
return 'operation_history_limit_reached';
|
|
947
|
+
}
|
|
948
|
+
next.operations.push({
|
|
949
|
+
idempotency_key: normalized.snapshot.idempotency_key,
|
|
950
|
+
operation,
|
|
951
|
+
request_digest: normalized.requestDigest,
|
|
952
|
+
code,
|
|
953
|
+
ok: result.ok ?? true,
|
|
954
|
+
outcome: result.type ?? 'applied',
|
|
955
|
+
state: next.state,
|
|
956
|
+
at,
|
|
957
|
+
});
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* @param {Record<string, any>} record
|
|
963
|
+
* @param {Record<string, any>} normalized
|
|
964
|
+
* @param {string} operation
|
|
965
|
+
* @param {string | undefined} code
|
|
966
|
+
* @param {string} at
|
|
967
|
+
* @param {(next: Record<string, any>) => void} mutate
|
|
968
|
+
* @param {Record<string, any>} [result]
|
|
969
|
+
*/
|
|
970
|
+
function finalizeMutation(record, normalized, operation, code, at, mutate, result = {}) {
|
|
971
|
+
const next = canonicalSnapshot(record);
|
|
972
|
+
const fromState = next.state;
|
|
973
|
+
mutate(next);
|
|
974
|
+
next.revision = record.revision + 1;
|
|
975
|
+
next.updated_at = at;
|
|
976
|
+
const operationError = appendOperation(
|
|
977
|
+
next,
|
|
978
|
+
record,
|
|
979
|
+
normalized,
|
|
980
|
+
operation,
|
|
981
|
+
code,
|
|
982
|
+
at,
|
|
983
|
+
result,
|
|
984
|
+
);
|
|
985
|
+
if (operationError) return { error: operationError };
|
|
986
|
+
if (fromState !== next.state) {
|
|
987
|
+
if (next.history.length >= MAX_HISTORY) return { error: 'state_history_limit_reached' };
|
|
988
|
+
next.history.push({
|
|
989
|
+
from: fromState,
|
|
990
|
+
to: next.state,
|
|
991
|
+
operation,
|
|
992
|
+
idempotency_key: normalized.snapshot.idempotency_key,
|
|
993
|
+
at,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
return { next };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* @param {Record<string, any>} record
|
|
1001
|
+
*/
|
|
1002
|
+
function providerExpected(record) {
|
|
1003
|
+
const template = record.document_action_binding.verification.release_action_template;
|
|
1004
|
+
return {
|
|
1005
|
+
...expectedBindings(record),
|
|
1006
|
+
parties: record.parties,
|
|
1007
|
+
profile: record.profile,
|
|
1008
|
+
provider_id: record.profile.provider_id,
|
|
1009
|
+
provider_idempotency_key: record.release?.provider_idempotency_key ?? null,
|
|
1010
|
+
provider_request_digest: record.release?.provider_request?.request_digest ?? null,
|
|
1011
|
+
provider_transaction_id: template.custodian_transaction_id,
|
|
1012
|
+
provider_milestone_id: template.custodian_milestone_id,
|
|
1013
|
+
amount: template.amount,
|
|
1014
|
+
currency: template.currency,
|
|
1015
|
+
destination_id: template.destination_id,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
type ActionEscrowVerifierFn = (artifact: any, expected: any) => any;
|
|
1020
|
+
|
|
1021
|
+
type ActionEscrowKernelOptions = {
|
|
1022
|
+
store?: Record<string, any>;
|
|
1023
|
+
provider?: Record<string, any>;
|
|
1024
|
+
verifyDocumentActionBinding?: ActionEscrowVerifierFn;
|
|
1025
|
+
verifyAgreementAcceptance?: ActionEscrowVerifierFn;
|
|
1026
|
+
verifyMilestoneEvidence?: ActionEscrowVerifierFn;
|
|
1027
|
+
verifyResolutionReceipt?: ActionEscrowVerifierFn;
|
|
1028
|
+
verifyProviderStatement?: ActionEscrowVerifierFn;
|
|
1029
|
+
verifyStateCommand?: ActionEscrowVerifierFn;
|
|
1030
|
+
profilesById?: Record<string, any>;
|
|
1031
|
+
resolveProfile?: (profileId: string, context: Record<string, any>) => any;
|
|
1032
|
+
now?: () => (Date | number | string);
|
|
1033
|
+
providerTimeoutMs?: number;
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Create a fail-closed Action Escrow kernel.
|
|
1038
|
+
*
|
|
1039
|
+
* Store contract:
|
|
1040
|
+
* durable === true
|
|
1041
|
+
* atomicExpectedRevisionCas === true
|
|
1042
|
+
* linearizableReads === true
|
|
1043
|
+
* monotonicRevisions === true
|
|
1044
|
+
* nonExpiring === true
|
|
1045
|
+
* read(key) -> null | { revision, value: canonicalJsonText }
|
|
1046
|
+
* compareAndSwap(key, expectedRevision|null, nextValue)
|
|
1047
|
+
* -> { applied, revision }
|
|
1048
|
+
*/
|
|
1049
|
+
export function createActionEscrowKernel(options: ActionEscrowKernelOptions = {}) {
|
|
1050
|
+
let configurationError: string | null = null;
|
|
1051
|
+
let readStore:
|
|
1052
|
+
| ((key: string) => Promise<{ revision: number; value: string } | null>)
|
|
1053
|
+
| null
|
|
1054
|
+
| undefined = null;
|
|
1055
|
+
let compareAndSwap:
|
|
1056
|
+
| ((
|
|
1057
|
+
key: string,
|
|
1058
|
+
expectedRevision: number | null,
|
|
1059
|
+
nextValue: string,
|
|
1060
|
+
) => Promise<{ applied: boolean; revision: number | null }>)
|
|
1061
|
+
| null
|
|
1062
|
+
| undefined = null;
|
|
1063
|
+
let releaseProvider: ((request: Record<string, any>) => any) | null | undefined = null;
|
|
1064
|
+
let getProviderRelease: ((request: Record<string, any>) => any) | null | undefined = null;
|
|
1065
|
+
let verifyDocumentActionBinding: ActionEscrowVerifierFn | null | undefined = null;
|
|
1066
|
+
let verifyAgreementAcceptance: ActionEscrowVerifierFn | null | undefined = null;
|
|
1067
|
+
let verifyMilestoneEvidence: ActionEscrowVerifierFn | null | undefined = null;
|
|
1068
|
+
let verifyResolutionReceipt: ActionEscrowVerifierFn | null | undefined = null;
|
|
1069
|
+
let verifyProviderStatement: ActionEscrowVerifierFn | null | undefined = null;
|
|
1070
|
+
let verifyStateCommand: ActionEscrowVerifierFn | null | undefined = null;
|
|
1071
|
+
let resolveProfile:
|
|
1072
|
+
| ((profileId: string, context: Record<string, any>) => any)
|
|
1073
|
+
| null
|
|
1074
|
+
| undefined = null;
|
|
1075
|
+
let pinnedProfiles: Record<string, any> | null = null;
|
|
1076
|
+
let now: (() => (Date | number | string)) | null | undefined = null;
|
|
1077
|
+
let providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS;
|
|
1078
|
+
|
|
1079
|
+
try {
|
|
1080
|
+
if (!isPlainObject(options)) throw new Error('invalid options');
|
|
1081
|
+
const store = options.store;
|
|
1082
|
+
if (!STORE_CAPABILITIES.every((capability) => store?.[capability] === true)
|
|
1083
|
+
|| typeof store?.read !== 'function'
|
|
1084
|
+
|| typeof store?.compareAndSwap !== 'function') {
|
|
1085
|
+
configurationError = 'durable_cas_store_required';
|
|
1086
|
+
} else {
|
|
1087
|
+
readStore = store.read.bind(store);
|
|
1088
|
+
compareAndSwap = store.compareAndSwap.bind(store);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const provider = options.provider;
|
|
1092
|
+
if (!configurationError
|
|
1093
|
+
&& (typeof provider?.release !== 'function'
|
|
1094
|
+
|| typeof provider?.getRelease !== 'function')) {
|
|
1095
|
+
configurationError = 'release_provider_required';
|
|
1096
|
+
} else if (!configurationError) {
|
|
1097
|
+
// The `if` above only takes this `else` when both provider?.release and
|
|
1098
|
+
// provider?.getRelease are functions, which is impossible if provider
|
|
1099
|
+
// itself were undefined (optional chaining would yield undefined, not
|
|
1100
|
+
// 'function', for typeof).
|
|
1101
|
+
releaseProvider = provider!.release.bind(provider);
|
|
1102
|
+
getProviderRelease = provider!.getRelease.bind(provider);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const verifierEntries = [
|
|
1106
|
+
['verifyDocumentActionBinding', options.verifyDocumentActionBinding],
|
|
1107
|
+
['verifyAgreementAcceptance', options.verifyAgreementAcceptance],
|
|
1108
|
+
['verifyMilestoneEvidence', options.verifyMilestoneEvidence],
|
|
1109
|
+
['verifyResolutionReceipt', options.verifyResolutionReceipt],
|
|
1110
|
+
['verifyProviderStatement', options.verifyProviderStatement],
|
|
1111
|
+
['verifyStateCommand', options.verifyStateCommand],
|
|
1112
|
+
];
|
|
1113
|
+
if (!configurationError && verifierEntries.some(([, verifier]) => typeof verifier !== 'function')) {
|
|
1114
|
+
configurationError = 'pinned_verifiers_required';
|
|
1115
|
+
} else if (!configurationError) {
|
|
1116
|
+
verifyDocumentActionBinding = options.verifyDocumentActionBinding;
|
|
1117
|
+
verifyAgreementAcceptance = options.verifyAgreementAcceptance;
|
|
1118
|
+
verifyMilestoneEvidence = options.verifyMilestoneEvidence;
|
|
1119
|
+
verifyResolutionReceipt = options.verifyResolutionReceipt;
|
|
1120
|
+
verifyProviderStatement = options.verifyProviderStatement;
|
|
1121
|
+
verifyStateCommand = options.verifyStateCommand;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (!configurationError && options.profilesById !== undefined) {
|
|
1125
|
+
const snapshot: Record<string, any> = canonicalSnapshot(options.profilesById);
|
|
1126
|
+
if (!isPlainObject(snapshot) || Object.keys(snapshot).length === 0) {
|
|
1127
|
+
configurationError = 'pinned_profile_configuration_invalid';
|
|
1128
|
+
} else {
|
|
1129
|
+
for (const [profileId, profile] of Object.entries(snapshot)) {
|
|
1130
|
+
if (profileId !== profile.profile_id || validateProfileShape(profile)) {
|
|
1131
|
+
configurationError = 'pinned_profile_configuration_invalid';
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (!configurationError) pinnedProfiles = deepFreeze(snapshot);
|
|
1136
|
+
}
|
|
1137
|
+
} else if (!configurationError && typeof options.resolveProfile === 'function') {
|
|
1138
|
+
resolveProfile = options.resolveProfile;
|
|
1139
|
+
} else if (!configurationError) {
|
|
1140
|
+
configurationError = 'pinned_profile_resolver_required';
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (!configurationError && options.now !== undefined && typeof options.now !== 'function') {
|
|
1144
|
+
configurationError = 'clock_required';
|
|
1145
|
+
}
|
|
1146
|
+
now = typeof options.now === 'function' ? options.now : Date.now;
|
|
1147
|
+
|
|
1148
|
+
if (options.providerTimeoutMs !== undefined) {
|
|
1149
|
+
if (!Number.isSafeInteger(options.providerTimeoutMs)
|
|
1150
|
+
|| options.providerTimeoutMs <= 0
|
|
1151
|
+
|| options.providerTimeoutMs > 300_000) {
|
|
1152
|
+
configurationError ??= 'provider_timeout_invalid';
|
|
1153
|
+
} else {
|
|
1154
|
+
providerTimeoutMs = options.providerTimeoutMs;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
} catch {
|
|
1158
|
+
configurationError ??= 'invalid_kernel_configuration';
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* @param {string} operation
|
|
1163
|
+
* @param {() => Promise<any>} task
|
|
1164
|
+
*/
|
|
1165
|
+
async function safe(operation, task) {
|
|
1166
|
+
try {
|
|
1167
|
+
return await task();
|
|
1168
|
+
} catch {
|
|
1169
|
+
return outcome({
|
|
1170
|
+
code: 'kernel_internal_refusal',
|
|
1171
|
+
operation,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* @param {string} operation
|
|
1178
|
+
*/
|
|
1179
|
+
function configurationRefusal(operation) {
|
|
1180
|
+
return configurationError
|
|
1181
|
+
? outcome({ code: configurationError, operation })
|
|
1182
|
+
: null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function instant() {
|
|
1186
|
+
try {
|
|
1187
|
+
// now is always assigned a real function during construction (either
|
|
1188
|
+
// options.now or the Date.now fallback) unless construction threw
|
|
1189
|
+
// first, in which case configurationError is set and callers refuse
|
|
1190
|
+
// via configurationRefusal() before reaching here.
|
|
1191
|
+
const candidate = now!();
|
|
1192
|
+
const date = candidate instanceof Date ? candidate : new Date(candidate);
|
|
1193
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function operationInstant(
|
|
1200
|
+
record: Record<string, any> | null = null,
|
|
1201
|
+
): { error: string; at?: undefined } | { error?: undefined; at: string } {
|
|
1202
|
+
const at = instant();
|
|
1203
|
+
if (!at) return { error: 'invalid_clock' };
|
|
1204
|
+
if (record !== null && Date.parse(at) < Date.parse(record.updated_at)) {
|
|
1205
|
+
return { error: 'clock_regression' };
|
|
1206
|
+
}
|
|
1207
|
+
return { at };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function withProviderTimeout(task) {
|
|
1211
|
+
let timer;
|
|
1212
|
+
try {
|
|
1213
|
+
return await Promise.race([
|
|
1214
|
+
Promise.resolve().then(task),
|
|
1215
|
+
new Promise((_, reject) => {
|
|
1216
|
+
timer = setTimeout(
|
|
1217
|
+
() => reject(new Error('provider_timeout')),
|
|
1218
|
+
providerTimeoutMs,
|
|
1219
|
+
);
|
|
1220
|
+
}),
|
|
1221
|
+
]);
|
|
1222
|
+
} finally {
|
|
1223
|
+
clearTimeout(timer);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function readRecord(key) {
|
|
1228
|
+
let envelope;
|
|
1229
|
+
try {
|
|
1230
|
+
// readStore is always assigned once configurationError is null (store
|
|
1231
|
+
// validation above either sets configurationError or assigns
|
|
1232
|
+
// readStore); callers refuse via configurationRefusal() otherwise.
|
|
1233
|
+
envelope = await readStore!(key);
|
|
1234
|
+
} catch {
|
|
1235
|
+
return { error: 'store_unavailable' };
|
|
1236
|
+
}
|
|
1237
|
+
if (envelope === null) return { record: null };
|
|
1238
|
+
try {
|
|
1239
|
+
if (!exactKeys(envelope, new Set(['revision', 'value']))
|
|
1240
|
+
|| !Number.isSafeInteger(envelope.revision)
|
|
1241
|
+
|| envelope.revision < 0
|
|
1242
|
+
|| typeof envelope.value !== 'string'
|
|
1243
|
+
|| Buffer.byteLength(envelope.value, 'utf8') > MAX_STATE_BYTES) {
|
|
1244
|
+
return { error: 'store_read_invalid' };
|
|
1245
|
+
}
|
|
1246
|
+
const record = JSON.parse(envelope.value);
|
|
1247
|
+
if (canonicalize(record) !== envelope.value
|
|
1248
|
+
|| !recordShapeValid(record, envelope.revision)) {
|
|
1249
|
+
return { error: 'store_record_invalid' };
|
|
1250
|
+
}
|
|
1251
|
+
return { record: deepFreeze(record) };
|
|
1252
|
+
} catch {
|
|
1253
|
+
return { error: 'store_record_invalid' };
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function writeRecord(key, expectedRevision, next) {
|
|
1258
|
+
let value;
|
|
1259
|
+
try {
|
|
1260
|
+
value = canonicalize(next);
|
|
1261
|
+
} catch {
|
|
1262
|
+
return { error: 'state_encoding_failed' };
|
|
1263
|
+
}
|
|
1264
|
+
let acknowledgement;
|
|
1265
|
+
try {
|
|
1266
|
+
// compareAndSwap is always assigned once configurationError is null,
|
|
1267
|
+
// for the same reason readStore is (see readRecord above).
|
|
1268
|
+
acknowledgement = await compareAndSwap!(key, expectedRevision, value);
|
|
1269
|
+
} catch {
|
|
1270
|
+
return { error: 'store_unavailable' };
|
|
1271
|
+
}
|
|
1272
|
+
try {
|
|
1273
|
+
const expectedNext = expectedRevision === null ? 0 : expectedRevision + 1;
|
|
1274
|
+
if (!exactKeys(acknowledgement, new Set(['applied', 'revision']))
|
|
1275
|
+
|| (acknowledgement.applied !== true && acknowledgement.applied !== false)
|
|
1276
|
+
|| (acknowledgement.revision !== null
|
|
1277
|
+
&& (!Number.isSafeInteger(acknowledgement.revision)
|
|
1278
|
+
|| acknowledgement.revision < 0))) {
|
|
1279
|
+
return { error: 'store_acknowledgement_invalid' };
|
|
1280
|
+
}
|
|
1281
|
+
if (acknowledgement.applied === true) {
|
|
1282
|
+
if (acknowledgement.revision !== expectedNext || next.revision !== expectedNext) {
|
|
1283
|
+
return { error: 'store_revision_invalid' };
|
|
1284
|
+
}
|
|
1285
|
+
return { applied: true };
|
|
1286
|
+
}
|
|
1287
|
+
return { applied: false };
|
|
1288
|
+
} catch {
|
|
1289
|
+
return { error: 'store_acknowledgement_invalid' };
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function invokeVerifier(verifier, artifact, expected) {
|
|
1294
|
+
try {
|
|
1295
|
+
const artifactCopy = deepFreeze(canonicalSnapshot(artifact));
|
|
1296
|
+
const expectedCopy = deepFreeze(canonicalSnapshot(expected));
|
|
1297
|
+
const result = canonicalSnapshot(await verifier(artifactCopy, expectedCopy));
|
|
1298
|
+
if (!isPlainObject(result)) return { error: 'verifier_result_invalid' };
|
|
1299
|
+
return { artifact: artifactCopy, result };
|
|
1300
|
+
} catch {
|
|
1301
|
+
return { error: 'verifier_failed' };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function verifyStoredReleaseInputs(record, at) {
|
|
1306
|
+
const expectedCore = {
|
|
1307
|
+
...expectedBindings(record),
|
|
1308
|
+
parties: record.parties,
|
|
1309
|
+
profile: record.profile,
|
|
1310
|
+
};
|
|
1311
|
+
const lastSupersession = record.superseded_bindings.at(-1);
|
|
1312
|
+
const expectedSupersedes = lastSupersession?.superseded_by_binding_digest
|
|
1313
|
+
=== record.document_action_binding_digest
|
|
1314
|
+
? lastSupersession.document_action_binding_digest
|
|
1315
|
+
: undefined;
|
|
1316
|
+
const bindingExpected = expectedSupersedes === undefined
|
|
1317
|
+
? expectedCore
|
|
1318
|
+
: {
|
|
1319
|
+
...expectedCore,
|
|
1320
|
+
supersedes_document_action_binding_digest: expectedSupersedes,
|
|
1321
|
+
};
|
|
1322
|
+
const binding = await invokeVerifier(
|
|
1323
|
+
verifyDocumentActionBinding,
|
|
1324
|
+
record.document_action_binding.artifact,
|
|
1325
|
+
bindingExpected,
|
|
1326
|
+
);
|
|
1327
|
+
const bindingDetails = binding.error
|
|
1328
|
+
? null
|
|
1329
|
+
: bindingVerificationDetails(binding.result, bindingExpected);
|
|
1330
|
+
const storedBindingDetails = bindingVerificationDetails(
|
|
1331
|
+
record.document_action_binding.verification,
|
|
1332
|
+
record,
|
|
1333
|
+
);
|
|
1334
|
+
if (!bindingDetails
|
|
1335
|
+
|| !storedBindingDetails
|
|
1336
|
+
|| canonicalize(bindingDetails) !== canonicalize(storedBindingDetails)
|
|
1337
|
+
|| (binding.result.supersedes_document_action_binding_digest ?? null)
|
|
1338
|
+
!== (record.document_action_binding.verification
|
|
1339
|
+
.supersedes_document_action_binding_digest ?? null)) {
|
|
1340
|
+
return { code: binding.error ?? 'document_action_binding_stale' };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const template = record.document_action_binding.verification.release_action_template;
|
|
1344
|
+
const fundingExpected = {
|
|
1345
|
+
...expectedCore,
|
|
1346
|
+
provider_id: record.profile.provider_id,
|
|
1347
|
+
statement_type: 'funding',
|
|
1348
|
+
expected_status: 'funded',
|
|
1349
|
+
provider_transaction_id: template.custodian_transaction_id,
|
|
1350
|
+
provider_milestone_id: template.custodian_milestone_id,
|
|
1351
|
+
amount: template.amount,
|
|
1352
|
+
currency: template.currency,
|
|
1353
|
+
destination_id: template.destination_id,
|
|
1354
|
+
};
|
|
1355
|
+
const funding = await invokeVerifier(
|
|
1356
|
+
verifyProviderStatement,
|
|
1357
|
+
record.funding.statement,
|
|
1358
|
+
fundingExpected,
|
|
1359
|
+
);
|
|
1360
|
+
if (funding.error
|
|
1361
|
+
|| !boundVerificationMatches(funding.result, expectedBindings(fundingExpected))
|
|
1362
|
+
|| funding.result.authenticated !== true
|
|
1363
|
+
|| funding.result.provider_id !== fundingExpected.provider_id
|
|
1364
|
+
|| funding.result.statement_type !== 'funding'
|
|
1365
|
+
|| funding.result.status !== 'funded'
|
|
1366
|
+
|| funding.result.provider_transaction_id !== fundingExpected.provider_transaction_id
|
|
1367
|
+
|| funding.result.provider_milestone_id !== fundingExpected.provider_milestone_id
|
|
1368
|
+
|| funding.result.amount !== fundingExpected.amount
|
|
1369
|
+
|| funding.result.currency !== fundingExpected.currency
|
|
1370
|
+
|| funding.result.destination_id !== fundingExpected.destination_id
|
|
1371
|
+
|| funding.result.statement_digest !== record.funding.verification.statement_digest) {
|
|
1372
|
+
return { code: funding.error ?? 'funding_statement_stale' };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const milestone = await invokeVerifier(
|
|
1376
|
+
verifyMilestoneEvidence,
|
|
1377
|
+
record.milestone_evidence.artifact,
|
|
1378
|
+
expectedCore,
|
|
1379
|
+
);
|
|
1380
|
+
const expectedEvidenceDigest = template.completion_evidence_sha256;
|
|
1381
|
+
if (milestone.error
|
|
1382
|
+
|| !boundVerificationMatches(milestone.result, expectedBindings(expectedCore))
|
|
1383
|
+
|| !validDigest(milestone.result.evidence_digest)
|
|
1384
|
+
|| milestone.result.evidence_digest !== expectedEvidenceDigest
|
|
1385
|
+
|| milestone.result.evidence_digest
|
|
1386
|
+
!== record.milestone_evidence.verification.evidence_digest
|
|
1387
|
+
|| !record.parties.some((party) => (
|
|
1388
|
+
party.party_id === milestone.result.submitter_party_id
|
|
1389
|
+
))
|
|
1390
|
+
|| !validInstant(milestone.result.observed_at)
|
|
1391
|
+
|| Date.parse(milestone.result.observed_at) > Date.parse(at)) {
|
|
1392
|
+
return { code: milestone.error ?? 'milestone_evidence_stale' };
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const bindingInput = resolutionBindingInput(record);
|
|
1396
|
+
const bindingMoment = createActionEscrowReleaseBindingMoment(bindingInput);
|
|
1397
|
+
const bindingMomentDigest = bindingMoment === null
|
|
1398
|
+
? null
|
|
1399
|
+
: canonicalDigest(bindingMoment);
|
|
1400
|
+
const expectedInitiator = record.milestone_evidence.verification.submitter_party_id;
|
|
1401
|
+
if (bindingMoment === null
|
|
1402
|
+
|| bindingMomentDigest === null
|
|
1403
|
+
|| !validString(expectedInitiator, 256)) {
|
|
1404
|
+
return { code: 'release_approval_context_invalid' };
|
|
1405
|
+
}
|
|
1406
|
+
for (const entry of record.release_approvals) {
|
|
1407
|
+
const context = entry.resolution.signoff.context;
|
|
1408
|
+
const expectedNonce = computeActionEscrowResolutionNonce(
|
|
1409
|
+
bindingInput,
|
|
1410
|
+
entry.party_id,
|
|
1411
|
+
);
|
|
1412
|
+
const expected = {
|
|
1413
|
+
...expectedCore,
|
|
1414
|
+
party_id: entry.party_id,
|
|
1415
|
+
evidence_digest: record.milestone_evidence.verification.evidence_digest,
|
|
1416
|
+
binding_moment: bindingMoment,
|
|
1417
|
+
binding_moment_digest: bindingMomentDigest,
|
|
1418
|
+
expected_selected_option: 0,
|
|
1419
|
+
expected_initiator: expectedInitiator,
|
|
1420
|
+
expected_nonce: expectedNonce,
|
|
1421
|
+
evaluation_time: at,
|
|
1422
|
+
};
|
|
1423
|
+
const approval = await invokeVerifier(
|
|
1424
|
+
verifyResolutionReceipt,
|
|
1425
|
+
entry.resolution,
|
|
1426
|
+
expected,
|
|
1427
|
+
);
|
|
1428
|
+
if (approval.error
|
|
1429
|
+
|| approval.result.valid !== true
|
|
1430
|
+
|| approval.result.authorizes_action !== true
|
|
1431
|
+
|| approval.result.outcome !== 'approved'
|
|
1432
|
+
|| !boundVerificationMatches(approval.result, expectedBindings(expected))
|
|
1433
|
+
|| approval.result.party_id !== entry.party_id
|
|
1434
|
+
|| approval.result.party_role
|
|
1435
|
+
!== record.parties.find((party) => party.party_id === entry.party_id)?.role
|
|
1436
|
+
|| approval.result.principal_key_id !== context.principal_key_id
|
|
1437
|
+
|| approval.result.nonce !== context.nonce
|
|
1438
|
+
|| approval.result.issued_at !== context.issued_at
|
|
1439
|
+
|| approval.result.expires_at !== context.expires_at
|
|
1440
|
+
|| approval.result.evidence_digest !== expected.evidence_digest
|
|
1441
|
+
|| approval.result.principal_key_id !== entry.verification.principal_key_id
|
|
1442
|
+
|| approval.result.nonce !== entry.verification.nonce
|
|
1443
|
+
|| approval.result.issued_at !== entry.verification.issued_at
|
|
1444
|
+
|| approval.result.expires_at !== entry.verification.expires_at) {
|
|
1445
|
+
return {
|
|
1446
|
+
code: approval.error ?? 'release_approval_stale',
|
|
1447
|
+
details: { party_id: entry.party_id },
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
async function verifyCommandAuthorization(
|
|
1455
|
+
artifact,
|
|
1456
|
+
record,
|
|
1457
|
+
command,
|
|
1458
|
+
partyId,
|
|
1459
|
+
details,
|
|
1460
|
+
) {
|
|
1461
|
+
if (!record.parties.some((party) => party.party_id === partyId)) {
|
|
1462
|
+
return { error: 'command_party_invalid' };
|
|
1463
|
+
}
|
|
1464
|
+
const detailsDigest = canonicalDigest(details);
|
|
1465
|
+
const expected: Record<string, any> = {
|
|
1466
|
+
...expectedBindings(record),
|
|
1467
|
+
profile: record.profile,
|
|
1468
|
+
parties: record.parties,
|
|
1469
|
+
command,
|
|
1470
|
+
party_id: partyId,
|
|
1471
|
+
details_digest: detailsDigest,
|
|
1472
|
+
};
|
|
1473
|
+
expected.command_digest = canonicalDigest({
|
|
1474
|
+
'@version': 'EP-ACTION-ESCROW-COMMAND-v1',
|
|
1475
|
+
...expectedBindings(record),
|
|
1476
|
+
command,
|
|
1477
|
+
party_id: partyId,
|
|
1478
|
+
details_digest: detailsDigest,
|
|
1479
|
+
});
|
|
1480
|
+
const verified = await invokeVerifier(verifyStateCommand, artifact, expected);
|
|
1481
|
+
if (verified.error
|
|
1482
|
+
|| verified.result.valid !== true
|
|
1483
|
+
|| verified.result.authorizes_command !== true
|
|
1484
|
+
|| verified.result.command !== command
|
|
1485
|
+
|| verified.result.party_id !== partyId
|
|
1486
|
+
|| verified.result.details_digest !== detailsDigest
|
|
1487
|
+
|| verified.result.command_digest !== expected.command_digest
|
|
1488
|
+
|| !boundVerificationMatches(verified.result, expectedBindings(record))) {
|
|
1489
|
+
return { error: verified.error ?? 'command_authorization_refused' };
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
artifact: verified.artifact,
|
|
1493
|
+
verification: {
|
|
1494
|
+
valid: true,
|
|
1495
|
+
authorizes_command: true,
|
|
1496
|
+
...expectedBindings(record),
|
|
1497
|
+
command,
|
|
1498
|
+
party_id: partyId,
|
|
1499
|
+
details_digest: detailsDigest,
|
|
1500
|
+
command_digest: expected.command_digest,
|
|
1501
|
+
},
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
async function selectedProfile(normalized) {
|
|
1506
|
+
try {
|
|
1507
|
+
let selected;
|
|
1508
|
+
if (pinnedProfiles) {
|
|
1509
|
+
if (!Object.hasOwn(pinnedProfiles, normalized.snapshot.profile.profile_id)) {
|
|
1510
|
+
return { error: 'profile_not_pinned' };
|
|
1511
|
+
}
|
|
1512
|
+
selected = pinnedProfiles[normalized.snapshot.profile.profile_id];
|
|
1513
|
+
} else {
|
|
1514
|
+
// Construction guarantees exactly one of pinnedProfiles/resolveProfile
|
|
1515
|
+
// is set once configurationError is null (see the profilesById /
|
|
1516
|
+
// resolveProfile branch above); pinnedProfiles is falsy here, so
|
|
1517
|
+
// resolveProfile must be the one that was assigned.
|
|
1518
|
+
selected = canonicalSnapshot(await resolveProfile!(
|
|
1519
|
+
normalized.snapshot.profile.profile_id,
|
|
1520
|
+
deepFreeze(canonicalSnapshot({
|
|
1521
|
+
agreement_digest: normalized.context.agreement_digest,
|
|
1522
|
+
milestone_id: normalized.context.milestone_id,
|
|
1523
|
+
parties: normalized.context.parties,
|
|
1524
|
+
})),
|
|
1525
|
+
));
|
|
1526
|
+
}
|
|
1527
|
+
if (validateProfileForParties(selected, normalized.context.parties)
|
|
1528
|
+
|| canonicalDigest(selected) !== normalized.context.profile_digest) {
|
|
1529
|
+
return { error: 'profile_not_pinned' };
|
|
1530
|
+
}
|
|
1531
|
+
return { profile: deepFreeze(canonicalSnapshot(selected)) };
|
|
1532
|
+
} catch {
|
|
1533
|
+
return { error: 'profile_resolution_failed' };
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
async function create(input = {}) {
|
|
1538
|
+
const operation = 'create';
|
|
1539
|
+
return safe(operation, async () => {
|
|
1540
|
+
const normalized = normalizedInput(
|
|
1541
|
+
operation,
|
|
1542
|
+
input,
|
|
1543
|
+
['document_action_binding'],
|
|
1544
|
+
);
|
|
1545
|
+
if (normalized.error) return outcome({ code: normalized.error, operation });
|
|
1546
|
+
const config = configurationRefusal(operation);
|
|
1547
|
+
if (config) return config;
|
|
1548
|
+
|
|
1549
|
+
const pinned = await selectedProfile(normalized);
|
|
1550
|
+
if (pinned.error) return outcome({ code: pinned.error, operation });
|
|
1551
|
+
|
|
1552
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
1553
|
+
const loaded = await readRecord(normalized.escrowKey);
|
|
1554
|
+
if (loaded.error) return outcome({ code: loaded.error, operation });
|
|
1555
|
+
if (loaded.record) {
|
|
1556
|
+
if (!operationBindingMatches(loaded.record, normalized)) {
|
|
1557
|
+
return outcome({
|
|
1558
|
+
code: 'operation_binding_mismatch',
|
|
1559
|
+
operation,
|
|
1560
|
+
record: loaded.record,
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
return idempotentResult(loaded.record, normalized, operation)
|
|
1564
|
+
?? outcome({ code: 'escrow_already_exists', operation, record: loaded.record });
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const at = instant();
|
|
1568
|
+
if (!at) return outcome({ code: 'invalid_clock', operation });
|
|
1569
|
+
const expected = {
|
|
1570
|
+
...normalized.context,
|
|
1571
|
+
profile: pinned.profile,
|
|
1572
|
+
};
|
|
1573
|
+
const verification = await invokeVerifier(
|
|
1574
|
+
verifyDocumentActionBinding,
|
|
1575
|
+
normalized.snapshot.document_action_binding,
|
|
1576
|
+
expected,
|
|
1577
|
+
);
|
|
1578
|
+
const bindingDetails = verification.error
|
|
1579
|
+
? null
|
|
1580
|
+
: bindingVerificationDetails(verification.result, expected);
|
|
1581
|
+
if (!bindingDetails) {
|
|
1582
|
+
return outcome({
|
|
1583
|
+
code: verification.error ?? 'document_action_binding_invalid',
|
|
1584
|
+
operation,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// normalizedInput() only omits `context` on its early `{ error }`
|
|
1589
|
+
// returns, all of which are handled above via the `normalized.error`
|
|
1590
|
+
// checks; once past those, `context` is always populated.
|
|
1591
|
+
const normalizedContext = normalized.context as Record<string, any>;
|
|
1592
|
+
const record = {
|
|
1593
|
+
'@version': ACTION_ESCROW_STATE_VERSION,
|
|
1594
|
+
escrow_key: normalized.escrowKey,
|
|
1595
|
+
revision: 0,
|
|
1596
|
+
state: 'draft',
|
|
1597
|
+
agreement_digest: normalizedContext.agreement_digest,
|
|
1598
|
+
document_action_binding_digest:
|
|
1599
|
+
normalizedContext.document_action_binding_digest,
|
|
1600
|
+
milestone_id: normalizedContext.milestone_id,
|
|
1601
|
+
release_action_digest: normalizedContext.release_action_digest,
|
|
1602
|
+
parties: normalizedContext.parties,
|
|
1603
|
+
parties_digest: normalizedContext.parties_digest,
|
|
1604
|
+
profile: pinned.profile,
|
|
1605
|
+
profile_digest: normalizedContext.profile_digest,
|
|
1606
|
+
document_action_binding: {
|
|
1607
|
+
artifact: verification.artifact,
|
|
1608
|
+
verification: boundVerificationSummary(
|
|
1609
|
+
verification.result,
|
|
1610
|
+
expected,
|
|
1611
|
+
bindingDetails,
|
|
1612
|
+
),
|
|
1613
|
+
},
|
|
1614
|
+
agreement_acceptances: [],
|
|
1615
|
+
funding: null,
|
|
1616
|
+
milestone_evidence: null,
|
|
1617
|
+
release_approvals: [],
|
|
1618
|
+
release: null,
|
|
1619
|
+
dispute: null,
|
|
1620
|
+
cancellation: null,
|
|
1621
|
+
completion: null,
|
|
1622
|
+
pending_amendment: null,
|
|
1623
|
+
superseded_bindings: [],
|
|
1624
|
+
operations: [{
|
|
1625
|
+
idempotency_key: normalized.snapshot.idempotency_key,
|
|
1626
|
+
operation,
|
|
1627
|
+
request_digest: normalized.requestDigest,
|
|
1628
|
+
code: 'escrow_created',
|
|
1629
|
+
ok: true,
|
|
1630
|
+
outcome: 'applied',
|
|
1631
|
+
state: 'draft',
|
|
1632
|
+
at,
|
|
1633
|
+
}],
|
|
1634
|
+
history: [{
|
|
1635
|
+
from: null,
|
|
1636
|
+
to: 'draft',
|
|
1637
|
+
operation,
|
|
1638
|
+
idempotency_key: normalized.snapshot.idempotency_key,
|
|
1639
|
+
at,
|
|
1640
|
+
}],
|
|
1641
|
+
created_at: at,
|
|
1642
|
+
updated_at: at,
|
|
1643
|
+
};
|
|
1644
|
+
const written = await writeRecord(normalized.escrowKey, null, record);
|
|
1645
|
+
if (written.error) return outcome({ code: written.error, operation });
|
|
1646
|
+
if (written.applied) {
|
|
1647
|
+
return outcome({
|
|
1648
|
+
ok: true,
|
|
1649
|
+
type: 'applied',
|
|
1650
|
+
code: 'escrow_created',
|
|
1651
|
+
operation,
|
|
1652
|
+
record,
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
return outcome({ code: 'store_conflict', operation });
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
async function mutate(
|
|
1661
|
+
operation: string,
|
|
1662
|
+
input: Record<string, any>,
|
|
1663
|
+
{
|
|
1664
|
+
extraAllowed = [],
|
|
1665
|
+
extraRequired = extraAllowed,
|
|
1666
|
+
bindingMode = 'active',
|
|
1667
|
+
transform,
|
|
1668
|
+
}: {
|
|
1669
|
+
extraAllowed?: string[];
|
|
1670
|
+
extraRequired?: string[];
|
|
1671
|
+
bindingMode?: string;
|
|
1672
|
+
transform: (draft: Record<string, any>, normalized: Record<string, any>, at: string) => any;
|
|
1673
|
+
},
|
|
1674
|
+
) {
|
|
1675
|
+
return safe(operation, async () => {
|
|
1676
|
+
const normalized = normalizedInput(
|
|
1677
|
+
operation,
|
|
1678
|
+
input,
|
|
1679
|
+
extraAllowed,
|
|
1680
|
+
extraRequired,
|
|
1681
|
+
);
|
|
1682
|
+
if (normalized.error) return outcome({ code: normalized.error, operation });
|
|
1683
|
+
const config = configurationRefusal(operation);
|
|
1684
|
+
if (config) return config;
|
|
1685
|
+
|
|
1686
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
1687
|
+
const loaded = await readRecord(normalized.escrowKey);
|
|
1688
|
+
if (loaded.error) return outcome({ code: loaded.error, operation });
|
|
1689
|
+
if (!loaded.record) return outcome({ code: 'escrow_not_found', operation });
|
|
1690
|
+
const record = loaded.record;
|
|
1691
|
+
if (!operationBindingMatches(record, normalized, bindingMode)) {
|
|
1692
|
+
return outcome({
|
|
1693
|
+
code: 'operation_binding_mismatch',
|
|
1694
|
+
operation,
|
|
1695
|
+
record,
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
const repeated = idempotentResult(record, normalized, operation);
|
|
1699
|
+
if (repeated) return repeated;
|
|
1700
|
+
const operationTime = operationInstant(record);
|
|
1701
|
+
if (operationTime.error) {
|
|
1702
|
+
return outcome({ code: operationTime.error, operation, record });
|
|
1703
|
+
}
|
|
1704
|
+
// operationInstant() only omits `at` on its `{ error }` return,
|
|
1705
|
+
// already handled above, so `at` is guaranteed present here.
|
|
1706
|
+
const { at } = operationTime as { at: string };
|
|
1707
|
+
|
|
1708
|
+
const draft = canonicalSnapshot(record);
|
|
1709
|
+
const decision = await transform(draft, normalized, at);
|
|
1710
|
+
if (decision?.refusal) {
|
|
1711
|
+
return outcome({
|
|
1712
|
+
code: decision.refusal,
|
|
1713
|
+
operation,
|
|
1714
|
+
record,
|
|
1715
|
+
details: decision.details ?? null,
|
|
1716
|
+
type: decision.type ?? 'refused',
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
const code = decision?.code ?? `${operation}_applied`;
|
|
1720
|
+
const finalized = finalizeMutation(
|
|
1721
|
+
record,
|
|
1722
|
+
normalized,
|
|
1723
|
+
operation,
|
|
1724
|
+
code,
|
|
1725
|
+
at,
|
|
1726
|
+
(next) => {
|
|
1727
|
+
Object.assign(next, draft);
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
ok: decision?.ok ?? true,
|
|
1731
|
+
type: decision?.type ?? 'applied',
|
|
1732
|
+
},
|
|
1733
|
+
);
|
|
1734
|
+
if (finalized.error) {
|
|
1735
|
+
return outcome({ code: finalized.error, operation, record });
|
|
1736
|
+
}
|
|
1737
|
+
const written = await writeRecord(
|
|
1738
|
+
normalized.escrowKey,
|
|
1739
|
+
record.revision,
|
|
1740
|
+
finalized.next,
|
|
1741
|
+
);
|
|
1742
|
+
if (written.error) return outcome({ code: written.error, operation, record });
|
|
1743
|
+
if (written.applied) {
|
|
1744
|
+
return outcome({
|
|
1745
|
+
ok: decision?.ok ?? true,
|
|
1746
|
+
type: decision?.type ?? 'applied',
|
|
1747
|
+
code,
|
|
1748
|
+
operation,
|
|
1749
|
+
record: finalized.next,
|
|
1750
|
+
details: decision?.details ?? null,
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
return outcome({ code: 'store_conflict', operation });
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
async function beginAcceptance(input = {}) {
|
|
1759
|
+
return mutate('begin_acceptance', input, {
|
|
1760
|
+
transform(draft) {
|
|
1761
|
+
if (draft.state !== 'draft') {
|
|
1762
|
+
return { refusal: 'invalid_state_transition' };
|
|
1763
|
+
}
|
|
1764
|
+
draft.state = 'awaiting_acceptance';
|
|
1765
|
+
return { code: 'acceptance_requested' };
|
|
1766
|
+
},
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
async function acceptAgreement(input = {}) {
|
|
1771
|
+
return mutate('accept_agreement', input, {
|
|
1772
|
+
extraAllowed: ['party_id', 'agreement_acceptance'],
|
|
1773
|
+
async transform(draft, normalized, at) {
|
|
1774
|
+
if (draft.state !== 'awaiting_acceptance') {
|
|
1775
|
+
return { refusal: 'invalid_state_transition' };
|
|
1776
|
+
}
|
|
1777
|
+
const partyId = normalized.snapshot.party_id;
|
|
1778
|
+
if (!draft.profile.required_acceptance_party_ids.includes(partyId)) {
|
|
1779
|
+
return { refusal: 'acceptance_party_not_required' };
|
|
1780
|
+
}
|
|
1781
|
+
if (draft.agreement_acceptances.some((entry) => entry.party_id === partyId)) {
|
|
1782
|
+
return { refusal: 'agreement_already_accepted' };
|
|
1783
|
+
}
|
|
1784
|
+
const expected = {
|
|
1785
|
+
...normalized.context,
|
|
1786
|
+
party_id: partyId,
|
|
1787
|
+
};
|
|
1788
|
+
const verified = await invokeVerifier(
|
|
1789
|
+
verifyAgreementAcceptance,
|
|
1790
|
+
normalized.snapshot.agreement_acceptance,
|
|
1791
|
+
expected,
|
|
1792
|
+
);
|
|
1793
|
+
if (verified.error
|
|
1794
|
+
|| !boundVerificationMatches(verified.result, expectedBindings(expected))
|
|
1795
|
+
|| verified.result.party_id !== partyId
|
|
1796
|
+
|| !validString(verified.result.principal_key_id, 512)
|
|
1797
|
+
|| draft.agreement_acceptances.some(
|
|
1798
|
+
(entry) => entry.verification?.principal_key_id
|
|
1799
|
+
=== verified.result.principal_key_id,
|
|
1800
|
+
)
|
|
1801
|
+
|| !validDigest(verified.result.acceptance_digest)) {
|
|
1802
|
+
return { refusal: verified.error ?? 'agreement_acceptance_invalid' };
|
|
1803
|
+
}
|
|
1804
|
+
draft.agreement_acceptances.push({
|
|
1805
|
+
party_id: partyId,
|
|
1806
|
+
artifact: verified.artifact,
|
|
1807
|
+
verification: boundVerificationSummary(
|
|
1808
|
+
verified.result,
|
|
1809
|
+
expected,
|
|
1810
|
+
{
|
|
1811
|
+
party_id: partyId,
|
|
1812
|
+
principal_key_id: verified.result.principal_key_id,
|
|
1813
|
+
acceptance_digest: verified.result.acceptance_digest,
|
|
1814
|
+
},
|
|
1815
|
+
),
|
|
1816
|
+
});
|
|
1817
|
+
const accepted = new Set(draft.agreement_acceptances.map((entry) => entry.party_id));
|
|
1818
|
+
if (draft.profile.required_acceptance_party_ids.every((id) => accepted.has(id))) {
|
|
1819
|
+
draft.state = 'effective';
|
|
1820
|
+
return { code: 'agreement_effective' };
|
|
1821
|
+
}
|
|
1822
|
+
return { code: 'agreement_acceptance_recorded' };
|
|
1823
|
+
},
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
async function requestFunding(input = {}) {
|
|
1828
|
+
return mutate('request_funding', input, {
|
|
1829
|
+
transform(draft) {
|
|
1830
|
+
if (draft.state !== 'effective') {
|
|
1831
|
+
return { refusal: 'invalid_state_transition' };
|
|
1832
|
+
}
|
|
1833
|
+
draft.state = 'awaiting_funding';
|
|
1834
|
+
return { code: 'funding_requested' };
|
|
1835
|
+
},
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async function recordFunding(input = {}) {
|
|
1840
|
+
return mutate('record_funding', input, {
|
|
1841
|
+
extraAllowed: ['provider_statement'],
|
|
1842
|
+
async transform(draft, normalized, at) {
|
|
1843
|
+
if (draft.state !== 'awaiting_funding') {
|
|
1844
|
+
return { refusal: 'invalid_state_transition' };
|
|
1845
|
+
}
|
|
1846
|
+
const template = draft.document_action_binding.verification.release_action_template;
|
|
1847
|
+
const expected = {
|
|
1848
|
+
...normalized.context,
|
|
1849
|
+
provider_id: draft.profile.provider_id,
|
|
1850
|
+
statement_type: 'funding',
|
|
1851
|
+
expected_status: 'funded',
|
|
1852
|
+
provider_transaction_id: template.custodian_transaction_id,
|
|
1853
|
+
provider_milestone_id: template.custodian_milestone_id,
|
|
1854
|
+
amount: template.amount,
|
|
1855
|
+
currency: template.currency,
|
|
1856
|
+
destination_id: template.destination_id,
|
|
1857
|
+
};
|
|
1858
|
+
const verified = await invokeVerifier(
|
|
1859
|
+
verifyProviderStatement,
|
|
1860
|
+
normalized.snapshot.provider_statement,
|
|
1861
|
+
expected,
|
|
1862
|
+
);
|
|
1863
|
+
if (verified.error
|
|
1864
|
+
|| !boundVerificationMatches(verified.result, expectedBindings(expected))
|
|
1865
|
+
|| verified.result.authenticated !== true
|
|
1866
|
+
|| verified.result.provider_id !== draft.profile.provider_id
|
|
1867
|
+
|| verified.result.statement_type !== 'funding'
|
|
1868
|
+
|| verified.result.status !== 'funded'
|
|
1869
|
+
|| verified.result.provider_transaction_id !== expected.provider_transaction_id
|
|
1870
|
+
|| verified.result.provider_milestone_id !== expected.provider_milestone_id
|
|
1871
|
+
|| verified.result.amount !== expected.amount
|
|
1872
|
+
|| verified.result.currency !== expected.currency
|
|
1873
|
+
|| verified.result.destination_id !== expected.destination_id
|
|
1874
|
+
|| !validDigest(verified.result.statement_digest)) {
|
|
1875
|
+
return { refusal: verified.error ?? 'funding_statement_invalid' };
|
|
1876
|
+
}
|
|
1877
|
+
draft.funding = {
|
|
1878
|
+
statement: verified.artifact,
|
|
1879
|
+
verification: boundVerificationSummary(
|
|
1880
|
+
verified.result,
|
|
1881
|
+
expected,
|
|
1882
|
+
{
|
|
1883
|
+
authenticated: true,
|
|
1884
|
+
provider_id: draft.profile.provider_id,
|
|
1885
|
+
statement_type: 'funding',
|
|
1886
|
+
status: 'funded',
|
|
1887
|
+
provider_transaction_id: expected.provider_transaction_id,
|
|
1888
|
+
provider_milestone_id: expected.provider_milestone_id,
|
|
1889
|
+
amount: expected.amount,
|
|
1890
|
+
currency: expected.currency,
|
|
1891
|
+
destination_id: expected.destination_id,
|
|
1892
|
+
statement_digest: verified.result.statement_digest,
|
|
1893
|
+
},
|
|
1894
|
+
),
|
|
1895
|
+
};
|
|
1896
|
+
draft.state = 'funded';
|
|
1897
|
+
return { code: 'funding_confirmed' };
|
|
1898
|
+
},
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
async function submitMilestone(input = {}) {
|
|
1903
|
+
return mutate('submit_milestone', input, {
|
|
1904
|
+
extraAllowed: ['milestone_evidence'],
|
|
1905
|
+
async transform(draft, normalized, at) {
|
|
1906
|
+
if (draft.state !== 'funded') {
|
|
1907
|
+
return { refusal: 'invalid_state_transition' };
|
|
1908
|
+
}
|
|
1909
|
+
const expected = normalized.context;
|
|
1910
|
+
const verified = await invokeVerifier(
|
|
1911
|
+
verifyMilestoneEvidence,
|
|
1912
|
+
normalized.snapshot.milestone_evidence,
|
|
1913
|
+
expected,
|
|
1914
|
+
);
|
|
1915
|
+
const partyIds = draft.parties.map((party) => party.party_id);
|
|
1916
|
+
const expectedEvidenceDigest = draft.document_action_binding
|
|
1917
|
+
.verification.release_action_template.completion_evidence_sha256;
|
|
1918
|
+
if (verified.error
|
|
1919
|
+
|| !boundVerificationMatches(verified.result, expectedBindings(expected))
|
|
1920
|
+
|| !validDigest(verified.result.evidence_digest)
|
|
1921
|
+
|| verified.result.evidence_digest !== expectedEvidenceDigest
|
|
1922
|
+
|| !partyIds.includes(verified.result.submitter_party_id)
|
|
1923
|
+
|| !validInstant(verified.result.observed_at)
|
|
1924
|
+
|| Date.parse(verified.result.observed_at) > Date.parse(at)) {
|
|
1925
|
+
return { refusal: verified.error ?? 'milestone_evidence_invalid' };
|
|
1926
|
+
}
|
|
1927
|
+
draft.milestone_evidence = {
|
|
1928
|
+
artifact: verified.artifact,
|
|
1929
|
+
verification: boundVerificationSummary(
|
|
1930
|
+
verified.result,
|
|
1931
|
+
expected,
|
|
1932
|
+
{
|
|
1933
|
+
evidence_digest: verified.result.evidence_digest,
|
|
1934
|
+
submitter_party_id: verified.result.submitter_party_id,
|
|
1935
|
+
observed_at: verified.result.observed_at,
|
|
1936
|
+
},
|
|
1937
|
+
),
|
|
1938
|
+
};
|
|
1939
|
+
draft.release_approvals = [];
|
|
1940
|
+
draft.state = 'milestone_submitted';
|
|
1941
|
+
return { code: 'milestone_evidence_recorded' };
|
|
1942
|
+
},
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
async function approveRelease(input = {}) {
|
|
1947
|
+
return mutate('approve_release', input, {
|
|
1948
|
+
extraAllowed: ['party_id', 'resolution'],
|
|
1949
|
+
async transform(draft, normalized, at) {
|
|
1950
|
+
if (draft.state !== 'milestone_submitted') {
|
|
1951
|
+
return { refusal: 'invalid_state_transition' };
|
|
1952
|
+
}
|
|
1953
|
+
const partyId = normalized.snapshot.party_id;
|
|
1954
|
+
if (!draft.profile.required_release_approver_party_ids.includes(partyId)) {
|
|
1955
|
+
return { refusal: 'release_approval_party_not_required' };
|
|
1956
|
+
}
|
|
1957
|
+
const party = draft.parties.find((entry) => entry.party_id === partyId);
|
|
1958
|
+
if (draft.profile.prohibit_self_approval
|
|
1959
|
+
&& partyId === draft.milestone_evidence?.verification?.submitter_party_id) {
|
|
1960
|
+
return { refusal: 'self_approval_refused' };
|
|
1961
|
+
}
|
|
1962
|
+
if (draft.release_approvals.some((entry) => entry.party_id === partyId)) {
|
|
1963
|
+
return { refusal: 'release_approval_already_recorded' };
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
const artifact = normalized.snapshot.resolution;
|
|
1967
|
+
const bindingInput = resolutionBindingInput(draft);
|
|
1968
|
+
const bindingMoment = createActionEscrowReleaseBindingMoment(bindingInput);
|
|
1969
|
+
const bindingMomentDigest = bindingMoment === null
|
|
1970
|
+
? null
|
|
1971
|
+
: canonicalDigest(bindingMoment);
|
|
1972
|
+
const expectedNonce = computeActionEscrowResolutionNonce(bindingInput, partyId);
|
|
1973
|
+
const expectedInitiator = draft.milestone_evidence?.verification?.submitter_party_id;
|
|
1974
|
+
if (!isPlainObject(artifact)
|
|
1975
|
+
|| artifact.profile !== RESOLUTION_VERSION
|
|
1976
|
+
|| !isPlainObject(artifact.signoff)
|
|
1977
|
+
|| !isPlainObject(artifact.signoff.context)
|
|
1978
|
+
|| bindingMoment === null
|
|
1979
|
+
|| bindingMomentDigest === null
|
|
1980
|
+
|| expectedNonce === null
|
|
1981
|
+
|| !validString(expectedInitiator, 256)) {
|
|
1982
|
+
return { refusal: 'resolution_profile_invalid' };
|
|
1983
|
+
}
|
|
1984
|
+
const resolutionContext = artifact.signoff.context;
|
|
1985
|
+
if (resolutionContext.principal !== partyId) {
|
|
1986
|
+
return { refusal: 'resolution_party_mismatch' };
|
|
1987
|
+
}
|
|
1988
|
+
if (resolutionContext.envelope_hash !== bindingMomentDigest) {
|
|
1989
|
+
return { refusal: 'resolution_binding_mismatch' };
|
|
1990
|
+
}
|
|
1991
|
+
if (resolutionContext.action_hash !== draft.release_action_digest) {
|
|
1992
|
+
return { refusal: 'resolution_action_mismatch' };
|
|
1993
|
+
}
|
|
1994
|
+
if (resolutionContext.initiator !== expectedInitiator) {
|
|
1995
|
+
return { refusal: 'resolution_initiator_mismatch' };
|
|
1996
|
+
}
|
|
1997
|
+
if (resolutionContext.nonce !== expectedNonce) {
|
|
1998
|
+
return { refusal: 'resolution_nonce_mismatch' };
|
|
1999
|
+
}
|
|
2000
|
+
if (resolutionContext.resolution?.outcome !== 'approved') {
|
|
2001
|
+
return { refusal: 'resolution_not_approved' };
|
|
2002
|
+
}
|
|
2003
|
+
if (!validString(resolutionContext.principal_key_id, 512)
|
|
2004
|
+
|| !validString(resolutionContext.nonce, 512)
|
|
2005
|
+
|| !validInstant(resolutionContext.issued_at)
|
|
2006
|
+
|| !validInstant(resolutionContext.expires_at)
|
|
2007
|
+
|| Date.parse(resolutionContext.issued_at)
|
|
2008
|
+
< Date.parse(draft.milestone_evidence.verification.observed_at)
|
|
2009
|
+
|| Date.parse(resolutionContext.issued_at) > Date.parse(at)
|
|
2010
|
+
|| Date.parse(resolutionContext.expires_at) <= Date.parse(at)
|
|
2011
|
+
|| Date.parse(resolutionContext.expires_at)
|
|
2012
|
+
<= Date.parse(resolutionContext.issued_at)) {
|
|
2013
|
+
return { refusal: 'resolution_freshness_invalid' };
|
|
2014
|
+
}
|
|
2015
|
+
if (draft.release_approvals.some(
|
|
2016
|
+
(entry) => entry.verification?.principal_key_id
|
|
2017
|
+
=== resolutionContext.principal_key_id,
|
|
2018
|
+
)) {
|
|
2019
|
+
return { refusal: 'resolution_key_already_counted' };
|
|
2020
|
+
}
|
|
2021
|
+
const expected = {
|
|
2022
|
+
...normalized.context,
|
|
2023
|
+
party_id: partyId,
|
|
2024
|
+
evidence_digest: draft.milestone_evidence.verification.evidence_digest,
|
|
2025
|
+
binding_moment: bindingMoment,
|
|
2026
|
+
binding_moment_digest: bindingMomentDigest,
|
|
2027
|
+
expected_selected_option: 0,
|
|
2028
|
+
expected_initiator: expectedInitiator,
|
|
2029
|
+
expected_nonce: expectedNonce,
|
|
2030
|
+
evaluation_time: at,
|
|
2031
|
+
};
|
|
2032
|
+
const verified = await invokeVerifier(
|
|
2033
|
+
verifyResolutionReceipt,
|
|
2034
|
+
artifact,
|
|
2035
|
+
expected,
|
|
2036
|
+
);
|
|
2037
|
+
if (verified.error
|
|
2038
|
+
|| verified.result.valid !== true
|
|
2039
|
+
|| verified.result.authorizes_action !== true
|
|
2040
|
+
|| verified.result.outcome !== 'approved'
|
|
2041
|
+
|| !boundVerificationMatches(verified.result, expectedBindings(expected))
|
|
2042
|
+
|| verified.result.party_id !== partyId
|
|
2043
|
+
|| verified.result.party_role !== party.role
|
|
2044
|
+
|| verified.result.principal_key_id !== resolutionContext.principal_key_id
|
|
2045
|
+
|| verified.result.nonce !== resolutionContext.nonce
|
|
2046
|
+
|| verified.result.issued_at !== resolutionContext.issued_at
|
|
2047
|
+
|| verified.result.expires_at !== resolutionContext.expires_at
|
|
2048
|
+
|| verified.result.evidence_digest !== expected.evidence_digest) {
|
|
2049
|
+
return { refusal: verified.error ?? 'resolution_verification_refused' };
|
|
2050
|
+
}
|
|
2051
|
+
draft.release_approvals.push({
|
|
2052
|
+
party_id: partyId,
|
|
2053
|
+
resolution: verified.artifact,
|
|
2054
|
+
verification: {
|
|
2055
|
+
valid: true,
|
|
2056
|
+
authorizes_action: true,
|
|
2057
|
+
outcome: 'approved',
|
|
2058
|
+
party_role: party.role,
|
|
2059
|
+
principal_key_id: resolutionContext.principal_key_id,
|
|
2060
|
+
nonce: resolutionContext.nonce,
|
|
2061
|
+
issued_at: resolutionContext.issued_at,
|
|
2062
|
+
expires_at: resolutionContext.expires_at,
|
|
2063
|
+
resolution_digest: canonicalDigest(verified.artifact),
|
|
2064
|
+
agreement_digest: draft.agreement_digest,
|
|
2065
|
+
document_action_binding_digest: draft.document_action_binding_digest,
|
|
2066
|
+
milestone_id: draft.milestone_id,
|
|
2067
|
+
release_action_digest: draft.release_action_digest,
|
|
2068
|
+
parties_digest: draft.parties_digest,
|
|
2069
|
+
profile_digest: draft.profile_digest,
|
|
2070
|
+
evidence_digest: draft.milestone_evidence.verification.evidence_digest,
|
|
2071
|
+
},
|
|
2072
|
+
});
|
|
2073
|
+
return { code: 'release_approval_recorded' };
|
|
2074
|
+
},
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
async function releasePreconditions(record, at) {
|
|
2079
|
+
if (record.funding?.verification?.status !== 'funded') {
|
|
2080
|
+
return { code: 'funding_not_verified' };
|
|
2081
|
+
}
|
|
2082
|
+
if (!validDigest(record.milestone_evidence?.verification?.evidence_digest)) {
|
|
2083
|
+
return { code: 'milestone_evidence_not_verified' };
|
|
2084
|
+
}
|
|
2085
|
+
const approved = new Set(record.release_approvals
|
|
2086
|
+
.filter((entry) => entry.verification?.authorizes_action === true
|
|
2087
|
+
&& entry.verification?.document_action_binding_digest
|
|
2088
|
+
=== record.document_action_binding_digest
|
|
2089
|
+
&& entry.verification?.release_action_digest === record.release_action_digest
|
|
2090
|
+
&& entry.verification?.evidence_digest
|
|
2091
|
+
=== record.milestone_evidence.verification.evidence_digest)
|
|
2092
|
+
.map((entry) => entry.party_id));
|
|
2093
|
+
const missing = record.profile.required_release_approver_party_ids
|
|
2094
|
+
.filter((partyId) => !approved.has(partyId));
|
|
2095
|
+
if (missing.length > 0) {
|
|
2096
|
+
return { code: 'release_approval_missing', details: { missing_party_ids: missing } };
|
|
2097
|
+
}
|
|
2098
|
+
const evaluationTime = Date.parse(at);
|
|
2099
|
+
const stale = record.release_approvals
|
|
2100
|
+
.filter((entry) => (
|
|
2101
|
+
!validInstant(entry.verification?.issued_at)
|
|
2102
|
+
|| !validInstant(entry.verification?.expires_at)
|
|
2103
|
+
|| Date.parse(entry.verification.issued_at) > evaluationTime
|
|
2104
|
+
|| Date.parse(entry.verification.expires_at) <= evaluationTime
|
|
2105
|
+
))
|
|
2106
|
+
.map((entry) => entry.party_id);
|
|
2107
|
+
if (stale.length > 0) {
|
|
2108
|
+
return { code: 'release_approval_expired', details: { party_ids: stale } };
|
|
2109
|
+
}
|
|
2110
|
+
return verifyStoredReleaseInputs(record, at);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
function providerRequestFor(record) {
|
|
2114
|
+
const bindingVerification = record.document_action_binding.verification;
|
|
2115
|
+
const request = {
|
|
2116
|
+
method: 'POST',
|
|
2117
|
+
provider_id: record.profile.provider_id,
|
|
2118
|
+
agreement_digest: record.agreement_digest,
|
|
2119
|
+
document_action_binding_digest: record.document_action_binding_digest,
|
|
2120
|
+
milestone_id: record.milestone_id,
|
|
2121
|
+
release_action_digest: record.release_action_digest,
|
|
2122
|
+
parties: record.parties,
|
|
2123
|
+
parties_digest: record.parties_digest,
|
|
2124
|
+
profile: record.profile,
|
|
2125
|
+
profile_digest: record.profile_digest,
|
|
2126
|
+
agreement_id: bindingVerification.agreement_id,
|
|
2127
|
+
binding_id: bindingVerification.binding_id,
|
|
2128
|
+
document_digest: bindingVerification.document_digest,
|
|
2129
|
+
release_action_template: bindingVerification.release_action_template,
|
|
2130
|
+
release_key: releaseReservationKey(record),
|
|
2131
|
+
idempotency_key: providerIdempotencyKey(record),
|
|
2132
|
+
};
|
|
2133
|
+
return {
|
|
2134
|
+
...request,
|
|
2135
|
+
request_digest: canonicalDigest({
|
|
2136
|
+
'@version': 'EP-ACTION-ESCROW-PROVIDER-REQUEST-v1',
|
|
2137
|
+
...request,
|
|
2138
|
+
}),
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
async function authoritativeProviderRelease(record: Record<string, any>): Promise<
|
|
2143
|
+
| { error: string; artifact?: undefined; verification?: undefined }
|
|
2144
|
+
| { error?: undefined; artifact: any; verification: Record<string, any> }
|
|
2145
|
+
> {
|
|
2146
|
+
const request = {
|
|
2147
|
+
...record.release.provider_request,
|
|
2148
|
+
method: 'GET',
|
|
2149
|
+
};
|
|
2150
|
+
let response;
|
|
2151
|
+
try {
|
|
2152
|
+
// getProviderRelease is always assigned once configurationError is
|
|
2153
|
+
// null (see the provider validation above); callers refuse via
|
|
2154
|
+
// configurationRefusal() otherwise.
|
|
2155
|
+
response = canonicalSnapshot(await withProviderTimeout(
|
|
2156
|
+
() => getProviderRelease!(deepFreeze(canonicalSnapshot(request))),
|
|
2157
|
+
));
|
|
2158
|
+
} catch {
|
|
2159
|
+
return { error: 'provider_reconciliation_failed' };
|
|
2160
|
+
}
|
|
2161
|
+
if (!exactKeys(response, new Set(['authenticated', 'statement']))
|
|
2162
|
+
|| response.authenticated !== true) {
|
|
2163
|
+
return { error: 'provider_reconciliation_unauthenticated' };
|
|
2164
|
+
}
|
|
2165
|
+
const expected = providerExpected(record);
|
|
2166
|
+
const verified = await invokeVerifier(
|
|
2167
|
+
verifyProviderStatement,
|
|
2168
|
+
response.statement,
|
|
2169
|
+
expected,
|
|
2170
|
+
);
|
|
2171
|
+
if (verified.error
|
|
2172
|
+
|| !boundVerificationMatches(verified.result, expectedBindings(expected))
|
|
2173
|
+
|| verified.result.authenticated !== true
|
|
2174
|
+
|| verified.result.provider_id !== expected.provider_id
|
|
2175
|
+
|| verified.result.statement_type !== 'release'
|
|
2176
|
+
|| !['released', 'not_released', 'pending'].includes(verified.result.status)
|
|
2177
|
+
|| !validDigest(verified.result.statement_digest)
|
|
2178
|
+
|| verified.result.provider_idempotency_key !== expected.provider_idempotency_key
|
|
2179
|
+
|| verified.result.provider_request_digest !== expected.provider_request_digest
|
|
2180
|
+
|| verified.result.provider_transaction_id !== expected.provider_transaction_id
|
|
2181
|
+
|| verified.result.provider_milestone_id !== expected.provider_milestone_id
|
|
2182
|
+
|| verified.result.amount !== expected.amount
|
|
2183
|
+
|| verified.result.currency !== expected.currency
|
|
2184
|
+
|| verified.result.destination_id !== expected.destination_id) {
|
|
2185
|
+
return { error: verified.error ?? 'provider_release_statement_invalid' };
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
artifact: verified.artifact,
|
|
2189
|
+
verification: boundVerificationSummary(
|
|
2190
|
+
verified.result,
|
|
2191
|
+
expected,
|
|
2192
|
+
{
|
|
2193
|
+
authenticated: true,
|
|
2194
|
+
provider_id: expected.provider_id,
|
|
2195
|
+
provider_idempotency_key: expected.provider_idempotency_key,
|
|
2196
|
+
provider_request_digest: expected.provider_request_digest,
|
|
2197
|
+
provider_transaction_id: expected.provider_transaction_id,
|
|
2198
|
+
provider_milestone_id: expected.provider_milestone_id,
|
|
2199
|
+
amount: expected.amount,
|
|
2200
|
+
currency: expected.currency,
|
|
2201
|
+
destination_id: expected.destination_id,
|
|
2202
|
+
statement_type: 'release',
|
|
2203
|
+
status: verified.result.status,
|
|
2204
|
+
statement_digest: verified.result.statement_digest,
|
|
2205
|
+
},
|
|
2206
|
+
),
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function updateReleaseOperation(next, idempotencyKey, code, state) {
|
|
2211
|
+
const entry = next.operations.find(
|
|
2212
|
+
(operation) => operation.operation === 'release'
|
|
2213
|
+
&& operation.idempotency_key === idempotencyKey,
|
|
2214
|
+
);
|
|
2215
|
+
if (entry) {
|
|
2216
|
+
entry.code = code;
|
|
2217
|
+
entry.ok = state === 'released';
|
|
2218
|
+
entry.outcome = state === 'released'
|
|
2219
|
+
? 'applied'
|
|
2220
|
+
: state === 'release_indeterminate'
|
|
2221
|
+
? 'indeterminate'
|
|
2222
|
+
: 'refused';
|
|
2223
|
+
entry.state = state;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
function internalReleaseTransition(
|
|
2228
|
+
record: Record<string, any>,
|
|
2229
|
+
targetState: string,
|
|
2230
|
+
code: string | undefined,
|
|
2231
|
+
at: string,
|
|
2232
|
+
providerResult: { artifact: any; verification: any } | null = null,
|
|
2233
|
+
) {
|
|
2234
|
+
const next = canonicalSnapshot(record);
|
|
2235
|
+
const from = next.state;
|
|
2236
|
+
next.state = targetState;
|
|
2237
|
+
next.revision = record.revision + 1;
|
|
2238
|
+
next.updated_at = at;
|
|
2239
|
+
next.release.status = targetState === 'released'
|
|
2240
|
+
? 'released'
|
|
2241
|
+
: targetState === 'milestone_submitted'
|
|
2242
|
+
? 'not_released'
|
|
2243
|
+
: 'indeterminate';
|
|
2244
|
+
if (providerResult) {
|
|
2245
|
+
next.release.reconciled_at = at;
|
|
2246
|
+
next.release.provider_statement = providerResult.artifact;
|
|
2247
|
+
next.release.provider_verification = providerResult.verification;
|
|
2248
|
+
}
|
|
2249
|
+
updateReleaseOperation(
|
|
2250
|
+
next,
|
|
2251
|
+
next.release.operation_idempotency_key,
|
|
2252
|
+
code,
|
|
2253
|
+
targetState,
|
|
2254
|
+
);
|
|
2255
|
+
if (from !== targetState) {
|
|
2256
|
+
if (next.history.length >= MAX_HISTORY) return { error: 'state_history_limit_reached' };
|
|
2257
|
+
next.history.push({
|
|
2258
|
+
from,
|
|
2259
|
+
to: targetState,
|
|
2260
|
+
operation: 'release',
|
|
2261
|
+
idempotency_key: next.release.operation_idempotency_key,
|
|
2262
|
+
at,
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
return { next };
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* @param {Record<string, any>} record
|
|
2270
|
+
*/
|
|
2271
|
+
async function readLatestRelease(record) {
|
|
2272
|
+
const loaded = await readRecord(record.escrow_key);
|
|
2273
|
+
return loaded.error ? { error: loaded.error } : { record: loaded.record };
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* @param {Record<string, any>} record
|
|
2278
|
+
* @param {string} operation
|
|
2279
|
+
* @param {string | undefined} code
|
|
2280
|
+
* @param {{ artifact: any, verification: any } | null} providerResult
|
|
2281
|
+
*/
|
|
2282
|
+
async function freezeIndeterminate(record, operation, code, providerResult = null) {
|
|
2283
|
+
let current = record;
|
|
2284
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
2285
|
+
if (current?.state === 'released') {
|
|
2286
|
+
return outcome({
|
|
2287
|
+
ok: true,
|
|
2288
|
+
type: 'reconciled',
|
|
2289
|
+
code: 'release_committed',
|
|
2290
|
+
operation,
|
|
2291
|
+
record: current,
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
if (current?.state === 'release_indeterminate') {
|
|
2295
|
+
return outcome({
|
|
2296
|
+
type: 'indeterminate',
|
|
2297
|
+
code,
|
|
2298
|
+
operation,
|
|
2299
|
+
record: current,
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
if (current?.state !== 'release_reserved') {
|
|
2303
|
+
return outcome({
|
|
2304
|
+
type: 'indeterminate',
|
|
2305
|
+
code,
|
|
2306
|
+
operation,
|
|
2307
|
+
record: current,
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
const operationTime = operationInstant(current);
|
|
2311
|
+
if (operationTime.error) {
|
|
2312
|
+
return outcome({
|
|
2313
|
+
type: 'indeterminate',
|
|
2314
|
+
code: operationTime.error,
|
|
2315
|
+
operation,
|
|
2316
|
+
record: current,
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
const { at } = operationTime as { at: string };
|
|
2320
|
+
const transition = internalReleaseTransition(
|
|
2321
|
+
current,
|
|
2322
|
+
'release_indeterminate',
|
|
2323
|
+
code,
|
|
2324
|
+
at,
|
|
2325
|
+
providerResult,
|
|
2326
|
+
);
|
|
2327
|
+
if (transition.error) {
|
|
2328
|
+
return outcome({
|
|
2329
|
+
type: 'indeterminate',
|
|
2330
|
+
code: transition.error,
|
|
2331
|
+
operation,
|
|
2332
|
+
record: current,
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
const written = await writeRecord(
|
|
2336
|
+
current.escrow_key,
|
|
2337
|
+
current.revision,
|
|
2338
|
+
transition.next,
|
|
2339
|
+
);
|
|
2340
|
+
if (written.applied) {
|
|
2341
|
+
return outcome({
|
|
2342
|
+
type: 'indeterminate',
|
|
2343
|
+
code,
|
|
2344
|
+
operation,
|
|
2345
|
+
record: transition.next,
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
const latest = await readLatestRelease(current);
|
|
2349
|
+
if (latest.error) {
|
|
2350
|
+
return outcome({
|
|
2351
|
+
type: 'indeterminate',
|
|
2352
|
+
code,
|
|
2353
|
+
operation,
|
|
2354
|
+
record: current,
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
current = latest.record;
|
|
2358
|
+
}
|
|
2359
|
+
return outcome({
|
|
2360
|
+
type: 'indeterminate',
|
|
2361
|
+
code,
|
|
2362
|
+
operation,
|
|
2363
|
+
record: current,
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* @param {Record<string, any>} record
|
|
2369
|
+
* @param {{ artifact: any, verification: any }} providerResult
|
|
2370
|
+
* @param {string} operation
|
|
2371
|
+
*/
|
|
2372
|
+
async function commitReleaseResult(record, providerResult, operation) {
|
|
2373
|
+
const status = providerResult.verification.status;
|
|
2374
|
+
const operationTime = operationInstant(record);
|
|
2375
|
+
if (operationTime.error) {
|
|
2376
|
+
return freezeIndeterminate(
|
|
2377
|
+
record,
|
|
2378
|
+
operation,
|
|
2379
|
+
operationTime.error,
|
|
2380
|
+
providerResult,
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
const { at } = operationTime as { at: string };
|
|
2384
|
+
const targetState = status === 'released'
|
|
2385
|
+
? 'released'
|
|
2386
|
+
: status === 'not_released'
|
|
2387
|
+
? 'milestone_submitted'
|
|
2388
|
+
: 'release_indeterminate';
|
|
2389
|
+
const code = status === 'released'
|
|
2390
|
+
? 'release_committed'
|
|
2391
|
+
: status === 'not_released'
|
|
2392
|
+
? 'provider_release_not_released'
|
|
2393
|
+
: 'release_effect_indeterminate';
|
|
2394
|
+
const transition = internalReleaseTransition(
|
|
2395
|
+
record,
|
|
2396
|
+
targetState,
|
|
2397
|
+
code,
|
|
2398
|
+
at,
|
|
2399
|
+
providerResult,
|
|
2400
|
+
);
|
|
2401
|
+
if (transition.error) {
|
|
2402
|
+
return freezeIndeterminate(
|
|
2403
|
+
record,
|
|
2404
|
+
operation,
|
|
2405
|
+
'release_commit_indeterminate',
|
|
2406
|
+
providerResult,
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
2409
|
+
const written = await writeRecord(record.escrow_key, record.revision, transition.next);
|
|
2410
|
+
if (written.applied) {
|
|
2411
|
+
return outcome({
|
|
2412
|
+
ok: status === 'released',
|
|
2413
|
+
type: status === 'released' ? 'applied' : status === 'pending' ? 'indeterminate' : 'refused',
|
|
2414
|
+
code,
|
|
2415
|
+
operation,
|
|
2416
|
+
record: transition.next,
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const latest = await readLatestRelease(record);
|
|
2421
|
+
if (!latest.error
|
|
2422
|
+
&& latest.record?.state === 'released'
|
|
2423
|
+
&& latest.record.release?.release_key === record.release.release_key) {
|
|
2424
|
+
return outcome({
|
|
2425
|
+
ok: true,
|
|
2426
|
+
type: 'idempotent',
|
|
2427
|
+
code: 'release_committed',
|
|
2428
|
+
operation,
|
|
2429
|
+
record: latest.record,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
return freezeIndeterminate(
|
|
2433
|
+
latest.record ?? record,
|
|
2434
|
+
operation,
|
|
2435
|
+
'release_commit_indeterminate',
|
|
2436
|
+
providerResult,
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
async function release(input = {}) {
|
|
2441
|
+
const operation = 'release';
|
|
2442
|
+
return safe(operation, async () => {
|
|
2443
|
+
const normalized = normalizedInput(operation, input);
|
|
2444
|
+
if (normalized.error) return outcome({ code: normalized.error, operation });
|
|
2445
|
+
const config = configurationRefusal(operation);
|
|
2446
|
+
if (config) return config;
|
|
2447
|
+
|
|
2448
|
+
let reserved: Record<string, any> | null = null;
|
|
2449
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
2450
|
+
const loaded = await readRecord(normalized.escrowKey);
|
|
2451
|
+
if (loaded.error) return outcome({ code: loaded.error, operation });
|
|
2452
|
+
if (!loaded.record) return outcome({ code: 'escrow_not_found', operation });
|
|
2453
|
+
const record = loaded.record;
|
|
2454
|
+
if (!operationBindingMatches(record, normalized)) {
|
|
2455
|
+
return outcome({ code: 'operation_binding_mismatch', operation, record });
|
|
2456
|
+
}
|
|
2457
|
+
const repeated = idempotentResult(record, normalized, operation);
|
|
2458
|
+
if (repeated) return repeated;
|
|
2459
|
+
if (['released', 'completed'].includes(record.state)) {
|
|
2460
|
+
return outcome({ code: 'release_already_applied', operation, record });
|
|
2461
|
+
}
|
|
2462
|
+
if (['release_reserved', 'release_indeterminate'].includes(record.state)) {
|
|
2463
|
+
return outcome({
|
|
2464
|
+
code: 'release_reconciliation_required',
|
|
2465
|
+
operation,
|
|
2466
|
+
record,
|
|
2467
|
+
type: 'indeterminate',
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
if (record.state !== 'milestone_submitted') {
|
|
2471
|
+
return outcome({ code: 'invalid_state_transition', operation, record });
|
|
2472
|
+
}
|
|
2473
|
+
const operationTime = operationInstant(record);
|
|
2474
|
+
if (operationTime.error) {
|
|
2475
|
+
return outcome({ code: operationTime.error, operation, record });
|
|
2476
|
+
}
|
|
2477
|
+
const { at } = operationTime as { at: string };
|
|
2478
|
+
const precondition = await releasePreconditions(record, at);
|
|
2479
|
+
if (precondition) {
|
|
2480
|
+
return outcome({
|
|
2481
|
+
code: precondition.code,
|
|
2482
|
+
operation,
|
|
2483
|
+
record,
|
|
2484
|
+
details: precondition.details ?? null,
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
const request = providerRequestFor(record);
|
|
2488
|
+
const finalized = finalizeMutation(
|
|
2489
|
+
record,
|
|
2490
|
+
normalized,
|
|
2491
|
+
operation,
|
|
2492
|
+
'release_reserved',
|
|
2493
|
+
at,
|
|
2494
|
+
(next) => {
|
|
2495
|
+
next.state = 'release_reserved';
|
|
2496
|
+
next.release = {
|
|
2497
|
+
release_key: request.release_key,
|
|
2498
|
+
provider_idempotency_key: request.idempotency_key,
|
|
2499
|
+
operation_idempotency_key: normalized.snapshot.idempotency_key,
|
|
2500
|
+
status: 'reserved',
|
|
2501
|
+
reserved_at: at,
|
|
2502
|
+
reconciled_at: null,
|
|
2503
|
+
provider_request: request,
|
|
2504
|
+
provider_statement: null,
|
|
2505
|
+
provider_verification: null,
|
|
2506
|
+
};
|
|
2507
|
+
},
|
|
2508
|
+
{ ok: false, type: 'reserved' },
|
|
2509
|
+
);
|
|
2510
|
+
if (finalized.error) {
|
|
2511
|
+
return outcome({ code: finalized.error, operation, record });
|
|
2512
|
+
}
|
|
2513
|
+
const written = await writeRecord(
|
|
2514
|
+
normalized.escrowKey,
|
|
2515
|
+
record.revision,
|
|
2516
|
+
finalized.next,
|
|
2517
|
+
);
|
|
2518
|
+
if (written.error) return outcome({ code: written.error, operation, record });
|
|
2519
|
+
if (written.applied) {
|
|
2520
|
+
reserved = deepFreeze(finalized.next);
|
|
2521
|
+
break;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
if (!reserved) return outcome({ code: 'store_conflict', operation });
|
|
2525
|
+
|
|
2526
|
+
try {
|
|
2527
|
+
// reserved was just checked non-null above; releaseProvider is
|
|
2528
|
+
// always assigned once configurationError is null (see the provider
|
|
2529
|
+
// validation above). Both checks precede this closure, which (being
|
|
2530
|
+
// a plain arrow function, not a hoisted declaration) still needs
|
|
2531
|
+
// explicit assertions since `reserved` is a mutable `let`.
|
|
2532
|
+
await withProviderTimeout(
|
|
2533
|
+
() => releaseProvider!(
|
|
2534
|
+
deepFreeze(canonicalSnapshot(reserved!.release.provider_request)),
|
|
2535
|
+
),
|
|
2536
|
+
);
|
|
2537
|
+
} catch {
|
|
2538
|
+
return freezeIndeterminate(
|
|
2539
|
+
reserved,
|
|
2540
|
+
operation,
|
|
2541
|
+
'release_effect_indeterminate',
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
const reconciled = await authoritativeProviderRelease(reserved);
|
|
2546
|
+
if (reconciled.error) {
|
|
2547
|
+
return freezeIndeterminate(
|
|
2548
|
+
reserved,
|
|
2549
|
+
operation,
|
|
2550
|
+
'release_effect_indeterminate',
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
return commitReleaseResult(
|
|
2554
|
+
reserved,
|
|
2555
|
+
/** @type {{artifact: any, verification: any}} */ (reconciled),
|
|
2556
|
+
operation,
|
|
2557
|
+
);
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
async function reconcileRelease(input = {}) {
|
|
2562
|
+
const operation = 'reconcile_release';
|
|
2563
|
+
return safe(operation, async () => {
|
|
2564
|
+
const normalized = normalizedInput(operation, input);
|
|
2565
|
+
if (normalized.error) return outcome({ code: normalized.error, operation });
|
|
2566
|
+
const config = configurationRefusal(operation);
|
|
2567
|
+
if (config) return config;
|
|
2568
|
+
|
|
2569
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
2570
|
+
const loaded = await readRecord(normalized.escrowKey);
|
|
2571
|
+
if (loaded.error) return outcome({ code: loaded.error, operation });
|
|
2572
|
+
if (!loaded.record) return outcome({ code: 'escrow_not_found', operation });
|
|
2573
|
+
const record = loaded.record;
|
|
2574
|
+
if (!operationBindingMatches(record, normalized)) {
|
|
2575
|
+
return outcome({ code: 'operation_binding_mismatch', operation, record });
|
|
2576
|
+
}
|
|
2577
|
+
const repeated = idempotentResult(record, normalized, operation);
|
|
2578
|
+
if (repeated) return repeated;
|
|
2579
|
+
if (record.state === 'released') {
|
|
2580
|
+
return outcome({
|
|
2581
|
+
ok: true,
|
|
2582
|
+
type: 'idempotent',
|
|
2583
|
+
code: 'release_already_applied',
|
|
2584
|
+
operation,
|
|
2585
|
+
record,
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
if (!['release_reserved', 'release_indeterminate'].includes(record.state)) {
|
|
2589
|
+
return outcome({ code: 'invalid_state_transition', operation, record });
|
|
2590
|
+
}
|
|
2591
|
+
if (record.release?.provider_idempotency_key !== providerIdempotencyKey(record)
|
|
2592
|
+
|| record.release?.release_key !== releaseReservationKey(record)) {
|
|
2593
|
+
return outcome({ code: 'release_binding_corrupt', operation, record });
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
const providerResult = await authoritativeProviderRelease(record);
|
|
2597
|
+
if (providerResult.error) {
|
|
2598
|
+
return outcome({
|
|
2599
|
+
code: providerResult.error,
|
|
2600
|
+
operation,
|
|
2601
|
+
record,
|
|
2602
|
+
type: 'indeterminate',
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
const operationTime = operationInstant(record);
|
|
2606
|
+
if (operationTime.error) {
|
|
2607
|
+
return outcome({
|
|
2608
|
+
code: operationTime.error,
|
|
2609
|
+
operation,
|
|
2610
|
+
record,
|
|
2611
|
+
type: 'indeterminate',
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
const { at } = operationTime as { at: string };
|
|
2615
|
+
const providerVerification = providerResult.verification as { status: string };
|
|
2616
|
+
const status = providerVerification.status;
|
|
2617
|
+
const targetState = status === 'released'
|
|
2618
|
+
? 'released'
|
|
2619
|
+
: status === 'not_released'
|
|
2620
|
+
? record.pending_amendment ? 'amendment_pending' : 'milestone_submitted'
|
|
2621
|
+
: 'release_indeterminate';
|
|
2622
|
+
const code = status === 'released'
|
|
2623
|
+
? 'release_reconciled_released'
|
|
2624
|
+
: status === 'not_released'
|
|
2625
|
+
? 'release_reconciled_not_released'
|
|
2626
|
+
: 'release_still_indeterminate';
|
|
2627
|
+
const draft = canonicalSnapshot(record);
|
|
2628
|
+
draft.state = targetState;
|
|
2629
|
+
draft.release.status = status === 'pending' ? 'indeterminate' : status;
|
|
2630
|
+
draft.release.reconciled_at = at;
|
|
2631
|
+
draft.release.provider_statement = providerResult.artifact;
|
|
2632
|
+
draft.release.provider_verification = providerVerification;
|
|
2633
|
+
if (status === 'not_released' && draft.pending_amendment) {
|
|
2634
|
+
draft.funding = null;
|
|
2635
|
+
draft.milestone_evidence = null;
|
|
2636
|
+
draft.release_approvals = [];
|
|
2637
|
+
}
|
|
2638
|
+
updateReleaseOperation(
|
|
2639
|
+
draft,
|
|
2640
|
+
draft.release.operation_idempotency_key,
|
|
2641
|
+
code,
|
|
2642
|
+
targetState,
|
|
2643
|
+
);
|
|
2644
|
+
const finalized = finalizeMutation(
|
|
2645
|
+
record,
|
|
2646
|
+
normalized,
|
|
2647
|
+
operation,
|
|
2648
|
+
code,
|
|
2649
|
+
at,
|
|
2650
|
+
(next) => Object.assign(next, draft),
|
|
2651
|
+
{
|
|
2652
|
+
ok: status !== 'pending',
|
|
2653
|
+
type: status === 'pending' ? 'indeterminate' : 'reconciled',
|
|
2654
|
+
},
|
|
2655
|
+
);
|
|
2656
|
+
if (finalized.error) {
|
|
2657
|
+
return outcome({
|
|
2658
|
+
code: finalized.error,
|
|
2659
|
+
operation,
|
|
2660
|
+
record,
|
|
2661
|
+
type: 'indeterminate',
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
const written = await writeRecord(
|
|
2665
|
+
normalized.escrowKey,
|
|
2666
|
+
record.revision,
|
|
2667
|
+
finalized.next,
|
|
2668
|
+
);
|
|
2669
|
+
if (written.error) {
|
|
2670
|
+
return outcome({
|
|
2671
|
+
code: written.error,
|
|
2672
|
+
operation,
|
|
2673
|
+
record,
|
|
2674
|
+
type: 'indeterminate',
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
if (written.applied) {
|
|
2678
|
+
return outcome({
|
|
2679
|
+
ok: status !== 'pending',
|
|
2680
|
+
type: status === 'pending' ? 'indeterminate' : 'reconciled',
|
|
2681
|
+
code,
|
|
2682
|
+
operation,
|
|
2683
|
+
record: finalized.next,
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
return outcome({
|
|
2688
|
+
code: 'store_conflict',
|
|
2689
|
+
operation,
|
|
2690
|
+
type: 'indeterminate',
|
|
2691
|
+
});
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
async function openDispute(input = {}) {
|
|
2696
|
+
return mutate('open_dispute', input, {
|
|
2697
|
+
extraAllowed: ['party_id', 'reason', 'command_authorization'],
|
|
2698
|
+
async transform(draft, normalized, at) {
|
|
2699
|
+
if (!['funded', 'milestone_submitted'].includes(draft.state)) {
|
|
2700
|
+
return { refusal: 'invalid_state_transition' };
|
|
2701
|
+
}
|
|
2702
|
+
const partyId = normalized.snapshot.party_id;
|
|
2703
|
+
if (!draft.parties.some(
|
|
2704
|
+
/** @param {{ party_id: string, role: string }} party */
|
|
2705
|
+
(party) => party.party_id === partyId,
|
|
2706
|
+
)
|
|
2707
|
+
|| !validString(normalized.snapshot.reason, 2048)) {
|
|
2708
|
+
return { refusal: 'dispute_input_invalid' };
|
|
2709
|
+
}
|
|
2710
|
+
const authorization = await verifyCommandAuthorization(
|
|
2711
|
+
normalized.snapshot.command_authorization,
|
|
2712
|
+
draft,
|
|
2713
|
+
'open_dispute',
|
|
2714
|
+
partyId,
|
|
2715
|
+
{ reason: normalized.snapshot.reason },
|
|
2716
|
+
);
|
|
2717
|
+
if (authorization.error) return { refusal: authorization.error };
|
|
2718
|
+
draft.dispute = {
|
|
2719
|
+
party_id: partyId,
|
|
2720
|
+
reason: normalized.snapshot.reason,
|
|
2721
|
+
authorization,
|
|
2722
|
+
opened_at: at,
|
|
2723
|
+
};
|
|
2724
|
+
draft.state = 'disputed';
|
|
2725
|
+
return { code: 'dispute_opened' };
|
|
2726
|
+
},
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
async function proposeAmendment(input = {}) {
|
|
2731
|
+
return mutate('propose_amendment', input, {
|
|
2732
|
+
extraAllowed: [
|
|
2733
|
+
'party_id',
|
|
2734
|
+
'command_authorization',
|
|
2735
|
+
'next_document_action_binding_digest',
|
|
2736
|
+
'next_release_action_digest',
|
|
2737
|
+
'next_document_action_binding',
|
|
2738
|
+
],
|
|
2739
|
+
async transform(draft, normalized, at) {
|
|
2740
|
+
if (draft.state === 'awaiting_funding') {
|
|
2741
|
+
return { refusal: 'amendment_requires_funding_reconciliation' };
|
|
2742
|
+
}
|
|
2743
|
+
if (draft.funding !== null
|
|
2744
|
+
|| draft.release !== null
|
|
2745
|
+
|| ['funded', 'milestone_submitted', 'disputed'].includes(draft.state)) {
|
|
2746
|
+
return { refusal: 'amendment_requires_custodian_unwind' };
|
|
2747
|
+
}
|
|
2748
|
+
if (draft.state !== 'effective'
|
|
2749
|
+
|| draft.pending_amendment) {
|
|
2750
|
+
return { refusal: 'invalid_state_transition' };
|
|
2751
|
+
}
|
|
2752
|
+
const nextBindingDigest =
|
|
2753
|
+
normalized.snapshot.next_document_action_binding_digest;
|
|
2754
|
+
const nextActionDigest = normalized.snapshot.next_release_action_digest;
|
|
2755
|
+
if (!validDigest(nextBindingDigest)
|
|
2756
|
+
|| !validDigest(nextActionDigest)
|
|
2757
|
+
|| nextBindingDigest === draft.document_action_binding_digest) {
|
|
2758
|
+
return { refusal: 'amendment_binding_invalid' };
|
|
2759
|
+
}
|
|
2760
|
+
const partyId = normalized.snapshot.party_id;
|
|
2761
|
+
const authorization = await verifyCommandAuthorization(
|
|
2762
|
+
normalized.snapshot.command_authorization,
|
|
2763
|
+
draft,
|
|
2764
|
+
'propose_amendment',
|
|
2765
|
+
partyId,
|
|
2766
|
+
{
|
|
2767
|
+
next_document_action_binding_digest: nextBindingDigest,
|
|
2768
|
+
next_release_action_digest: nextActionDigest,
|
|
2769
|
+
},
|
|
2770
|
+
);
|
|
2771
|
+
if (authorization.error) return { refusal: authorization.error };
|
|
2772
|
+
const nextContext = {
|
|
2773
|
+
...normalized.context,
|
|
2774
|
+
document_action_binding_digest: nextBindingDigest,
|
|
2775
|
+
release_action_digest: nextActionDigest,
|
|
2776
|
+
supersedes_document_action_binding_digest:
|
|
2777
|
+
draft.document_action_binding_digest,
|
|
2778
|
+
};
|
|
2779
|
+
const verified = await invokeVerifier(
|
|
2780
|
+
verifyDocumentActionBinding,
|
|
2781
|
+
normalized.snapshot.next_document_action_binding,
|
|
2782
|
+
nextContext,
|
|
2783
|
+
);
|
|
2784
|
+
const bindingDetails = verified.error
|
|
2785
|
+
? null
|
|
2786
|
+
: bindingVerificationDetails(verified.result, nextContext);
|
|
2787
|
+
if (!bindingDetails
|
|
2788
|
+
|| verified.result.supersedes_document_action_binding_digest
|
|
2789
|
+
!== draft.document_action_binding_digest
|
|
2790
|
+
) {
|
|
2791
|
+
return { refusal: verified.error ?? 'amendment_document_binding_invalid' };
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
draft.pending_amendment = {
|
|
2795
|
+
from_state: draft.state,
|
|
2796
|
+
document_action_binding_digest: nextBindingDigest,
|
|
2797
|
+
release_action_digest: nextActionDigest,
|
|
2798
|
+
document_action_binding: {
|
|
2799
|
+
artifact: verified.artifact,
|
|
2800
|
+
verification: boundVerificationSummary(
|
|
2801
|
+
verified.result,
|
|
2802
|
+
nextContext,
|
|
2803
|
+
{
|
|
2804
|
+
...bindingDetails,
|
|
2805
|
+
supersedes_document_action_binding_digest:
|
|
2806
|
+
draft.document_action_binding_digest,
|
|
2807
|
+
},
|
|
2808
|
+
),
|
|
2809
|
+
},
|
|
2810
|
+
agreement_acceptances: [],
|
|
2811
|
+
proposer_party_id: partyId,
|
|
2812
|
+
proposal_authorization: authorization,
|
|
2813
|
+
proposed_at: at,
|
|
2814
|
+
};
|
|
2815
|
+
draft.funding = null;
|
|
2816
|
+
draft.milestone_evidence = null;
|
|
2817
|
+
draft.release_approvals = [];
|
|
2818
|
+
draft.release = null;
|
|
2819
|
+
draft.dispute = null;
|
|
2820
|
+
draft.cancellation = null;
|
|
2821
|
+
draft.state = 'amendment_pending';
|
|
2822
|
+
return { code: 'amendment_pending' };
|
|
2823
|
+
},
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
async function acceptAmendment(input = {}) {
|
|
2828
|
+
return mutate('accept_amendment', input, {
|
|
2829
|
+
extraAllowed: ['party_id', 'agreement_acceptance'],
|
|
2830
|
+
bindingMode: 'pending',
|
|
2831
|
+
async transform(draft, normalized, at) {
|
|
2832
|
+
if (draft.state !== 'amendment_pending' || !draft.pending_amendment) {
|
|
2833
|
+
return { refusal: 'invalid_state_transition' };
|
|
2834
|
+
}
|
|
2835
|
+
const partyId = normalized.snapshot.party_id;
|
|
2836
|
+
if (!draft.profile.required_acceptance_party_ids.includes(partyId)) {
|
|
2837
|
+
return { refusal: 'acceptance_party_not_required' };
|
|
2838
|
+
}
|
|
2839
|
+
if (draft.pending_amendment.agreement_acceptances
|
|
2840
|
+
.some((entry) => entry.party_id === partyId)) {
|
|
2841
|
+
return { refusal: 'amendment_already_accepted' };
|
|
2842
|
+
}
|
|
2843
|
+
const expected = {
|
|
2844
|
+
...normalized.context,
|
|
2845
|
+
party_id: partyId,
|
|
2846
|
+
};
|
|
2847
|
+
const verified = await invokeVerifier(
|
|
2848
|
+
verifyAgreementAcceptance,
|
|
2849
|
+
normalized.snapshot.agreement_acceptance,
|
|
2850
|
+
expected,
|
|
2851
|
+
);
|
|
2852
|
+
if (verified.error
|
|
2853
|
+
|| !boundVerificationMatches(verified.result, expectedBindings(expected))
|
|
2854
|
+
|| verified.result.party_id !== partyId
|
|
2855
|
+
|| !validString(verified.result.principal_key_id, 512)
|
|
2856
|
+
|| draft.pending_amendment.agreement_acceptances.some(
|
|
2857
|
+
(entry) => entry.verification?.principal_key_id
|
|
2858
|
+
=== verified.result.principal_key_id,
|
|
2859
|
+
)
|
|
2860
|
+
|| !validDigest(verified.result.acceptance_digest)) {
|
|
2861
|
+
return { refusal: verified.error ?? 'amendment_acceptance_invalid' };
|
|
2862
|
+
}
|
|
2863
|
+
draft.pending_amendment.agreement_acceptances.push({
|
|
2864
|
+
party_id: partyId,
|
|
2865
|
+
artifact: verified.artifact,
|
|
2866
|
+
verification: boundVerificationSummary(
|
|
2867
|
+
verified.result,
|
|
2868
|
+
expected,
|
|
2869
|
+
{
|
|
2870
|
+
party_id: partyId,
|
|
2871
|
+
principal_key_id: verified.result.principal_key_id,
|
|
2872
|
+
acceptance_digest: verified.result.acceptance_digest,
|
|
2873
|
+
},
|
|
2874
|
+
),
|
|
2875
|
+
});
|
|
2876
|
+
const accepted = new Set(
|
|
2877
|
+
draft.pending_amendment.agreement_acceptances
|
|
2878
|
+
.map((entry) => entry.party_id),
|
|
2879
|
+
);
|
|
2880
|
+
if (!draft.profile.required_acceptance_party_ids
|
|
2881
|
+
.every((id) => accepted.has(id))) {
|
|
2882
|
+
return { code: 'amendment_acceptance_recorded' };
|
|
2883
|
+
}
|
|
2884
|
+
if (draft.superseded_bindings.length >= MAX_SUPERSEDED_BINDINGS) {
|
|
2885
|
+
return { refusal: 'supersession_history_limit_reached' };
|
|
2886
|
+
}
|
|
2887
|
+
const pending = draft.pending_amendment;
|
|
2888
|
+
draft.superseded_bindings.push({
|
|
2889
|
+
document_action_binding_digest: draft.document_action_binding_digest,
|
|
2890
|
+
release_action_digest: draft.release_action_digest,
|
|
2891
|
+
superseded_by_binding_digest: pending.document_action_binding_digest,
|
|
2892
|
+
superseded_at: at,
|
|
2893
|
+
});
|
|
2894
|
+
draft.document_action_binding_digest =
|
|
2895
|
+
pending.document_action_binding_digest;
|
|
2896
|
+
draft.release_action_digest = pending.release_action_digest;
|
|
2897
|
+
draft.document_action_binding = pending.document_action_binding;
|
|
2898
|
+
draft.agreement_acceptances = pending.agreement_acceptances;
|
|
2899
|
+
draft.funding = null;
|
|
2900
|
+
draft.milestone_evidence = null;
|
|
2901
|
+
draft.release_approvals = [];
|
|
2902
|
+
draft.release = null;
|
|
2903
|
+
draft.dispute = null;
|
|
2904
|
+
draft.cancellation = null;
|
|
2905
|
+
draft.pending_amendment = null;
|
|
2906
|
+
draft.state = 'effective';
|
|
2907
|
+
return { code: 'amendment_effective' };
|
|
2908
|
+
},
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
async function cancel(input = {}) {
|
|
2913
|
+
return mutate('cancel', input, {
|
|
2914
|
+
extraAllowed: ['party_id', 'reason', 'command_authorization'],
|
|
2915
|
+
extraRequired: ['party_id', 'command_authorization'],
|
|
2916
|
+
async transform(draft, normalized, at) {
|
|
2917
|
+
if (draft.state === 'awaiting_funding') {
|
|
2918
|
+
return { refusal: 'cancellation_requires_funding_reconciliation' };
|
|
2919
|
+
}
|
|
2920
|
+
if (draft.funding !== null || draft.release !== null) {
|
|
2921
|
+
return { refusal: 'cancellation_requires_custodian_unwind' };
|
|
2922
|
+
}
|
|
2923
|
+
if (![
|
|
2924
|
+
'draft',
|
|
2925
|
+
'awaiting_acceptance',
|
|
2926
|
+
'effective',
|
|
2927
|
+
'amendment_pending',
|
|
2928
|
+
].includes(draft.state)) {
|
|
2929
|
+
return { refusal: 'invalid_state_transition' };
|
|
2930
|
+
}
|
|
2931
|
+
if (normalized.snapshot.reason !== undefined
|
|
2932
|
+
&& !validString(normalized.snapshot.reason, 2048)) {
|
|
2933
|
+
return { refusal: 'cancellation_reason_invalid' };
|
|
2934
|
+
}
|
|
2935
|
+
const authorization = await verifyCommandAuthorization(
|
|
2936
|
+
normalized.snapshot.command_authorization,
|
|
2937
|
+
draft,
|
|
2938
|
+
'cancel',
|
|
2939
|
+
normalized.snapshot.party_id,
|
|
2940
|
+
{ reason: normalized.snapshot.reason ?? null },
|
|
2941
|
+
);
|
|
2942
|
+
if (authorization.error) return { refusal: authorization.error };
|
|
2943
|
+
draft.state = 'cancelled';
|
|
2944
|
+
draft.cancellation = {
|
|
2945
|
+
party_id: normalized.snapshot.party_id,
|
|
2946
|
+
reason: normalized.snapshot.reason ?? null,
|
|
2947
|
+
authorization,
|
|
2948
|
+
cancelled_at: at,
|
|
2949
|
+
};
|
|
2950
|
+
return { code: 'escrow_cancelled' };
|
|
2951
|
+
},
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
async function complete(input = {}) {
|
|
2956
|
+
return mutate('complete', input, {
|
|
2957
|
+
extraAllowed: ['party_id', 'command_authorization'],
|
|
2958
|
+
extraRequired: ['party_id', 'command_authorization'],
|
|
2959
|
+
async transform(draft, normalized, at) {
|
|
2960
|
+
if (draft.state !== 'released') {
|
|
2961
|
+
return { refusal: 'invalid_state_transition' };
|
|
2962
|
+
}
|
|
2963
|
+
const authorization = await verifyCommandAuthorization(
|
|
2964
|
+
normalized.snapshot.command_authorization,
|
|
2965
|
+
draft,
|
|
2966
|
+
'complete',
|
|
2967
|
+
normalized.snapshot.party_id,
|
|
2968
|
+
{ meaning: 'administrative_archive_only' },
|
|
2969
|
+
);
|
|
2970
|
+
if (authorization.error) return { refusal: authorization.error };
|
|
2971
|
+
draft.state = 'completed';
|
|
2972
|
+
draft.completion = {
|
|
2973
|
+
party_id: normalized.snapshot.party_id,
|
|
2974
|
+
meaning: 'administrative_archive_only',
|
|
2975
|
+
authorization,
|
|
2976
|
+
completed_at: at,
|
|
2977
|
+
};
|
|
2978
|
+
return { code: 'escrow_completed' };
|
|
2979
|
+
},
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
const methods = {
|
|
2984
|
+
create,
|
|
2985
|
+
beginAcceptance,
|
|
2986
|
+
acceptAgreement,
|
|
2987
|
+
requestFunding,
|
|
2988
|
+
recordFunding,
|
|
2989
|
+
submitMilestone,
|
|
2990
|
+
approveRelease,
|
|
2991
|
+
release,
|
|
2992
|
+
reconcileRelease,
|
|
2993
|
+
openDispute,
|
|
2994
|
+
proposeAmendment,
|
|
2995
|
+
acceptAmendment,
|
|
2996
|
+
cancel,
|
|
2997
|
+
complete,
|
|
2998
|
+
};
|
|
2999
|
+
|
|
3000
|
+
async function apply(operation, input = {}) {
|
|
3001
|
+
try {
|
|
3002
|
+
const selected = methods[operation];
|
|
3003
|
+
if (typeof selected !== 'function') {
|
|
3004
|
+
return outcome({
|
|
3005
|
+
code: 'unknown_operation',
|
|
3006
|
+
operation: validString(operation, 128) ? operation : null,
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
return selected(input);
|
|
3010
|
+
} catch {
|
|
3011
|
+
return outcome({
|
|
3012
|
+
code: 'invalid_operation_input',
|
|
3013
|
+
operation: null,
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
return Object.freeze({
|
|
3019
|
+
ready: configurationError === null,
|
|
3020
|
+
configuration: Object.freeze({
|
|
3021
|
+
ok: configurationError === null,
|
|
3022
|
+
reason: configurationError,
|
|
3023
|
+
}),
|
|
3024
|
+
apply,
|
|
3025
|
+
...methods,
|
|
3026
|
+
});
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
export default Object.freeze({
|
|
3030
|
+
ACTION_ESCROW_STATE_VERSION,
|
|
3031
|
+
ACTION_ESCROW_OUTCOME_VERSION,
|
|
3032
|
+
ACTION_ESCROW_PROFILE_VERSION,
|
|
3033
|
+
ACTION_ESCROW_STATES,
|
|
3034
|
+
ACTION_ESCROW_TRANSITIONS,
|
|
3035
|
+
createActionEscrowReleaseBindingMoment,
|
|
3036
|
+
computeActionEscrowReleaseBindingMomentDigest,
|
|
3037
|
+
computeActionEscrowResolutionNonce,
|
|
3038
|
+
createActionEscrowKernel,
|
|
3039
|
+
});
|