@emilia-protocol/gate 0.10.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 +91 -0
- package/README.md +272 -0
- package/action-control-manifest.js +3 -268
- package/action-escrow-custodian.js +4 -0
- package/action-escrow-evidence.js +4 -0
- package/action-escrow-package.js +4 -0
- package/action-escrow-postgres.js +4 -0
- package/action-escrow-state.js +4 -0
- package/action-escrow-verifiers.js +4 -0
- package/action-escrow.js +4 -0
- 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 +47 -4
- 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 -85
- 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,1388 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate Remedy Program Profile v1.
|
|
4
|
+
*
|
|
5
|
+
* A fail-closed, post-effect compensation state machine. The kernel never
|
|
6
|
+
* rewrites an already-observed effect: it verifies the original effect,
|
|
7
|
+
* records disputes and late revocations, and authorizes separately bound
|
|
8
|
+
* compensating operations through atomic compare-and-swap transitions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { canonicalize } from '../execution-binding.js';
|
|
13
|
+
|
|
14
|
+
export const REMEDY_PROGRAM_VERSION = 'EP-GATE-REMEDY-PROGRAM-PROFILE-v1';
|
|
15
|
+
|
|
16
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
17
|
+
const CAID = /^caid:1:[a-z][a-z0-9.-]*\.[1-9][0-9]*:jcs-sha256:[A-Za-z0-9_-]{43}$/;
|
|
18
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
19
|
+
const MAX_CONTEXT_BYTES = 512;
|
|
20
|
+
const MAX_REMEDY_ATTEMPTS = 1024;
|
|
21
|
+
const DEFAULT_MAX_DISPUTE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
const CONFIG_KEYS = new Set([
|
|
24
|
+
'store', 'verifyOriginalEffect', 'verifyRevocation', 'verifyDispute',
|
|
25
|
+
'verifyRemedyAuthorization', 'verifyRemedyOutcome', 'verifyOriginalReconciliation',
|
|
26
|
+
'verifyResolution',
|
|
27
|
+
'now', 'maxDisputeAgeMs', 'allowEphemeralState', 'production',
|
|
28
|
+
]);
|
|
29
|
+
const CREATE_KEYS = new Set([
|
|
30
|
+
'instanceId', 'tenantId', 'environment', 'audience', 'original',
|
|
31
|
+
'remedyProfileDigest', 'destinationBindingDigest', 'maxRemedyUnits',
|
|
32
|
+
'unit', 'evidence',
|
|
33
|
+
]);
|
|
34
|
+
const ORIGINAL_KEYS = new Set([
|
|
35
|
+
'caid', 'action_digest', 'operation_id', 'consequence_mode',
|
|
36
|
+
'consequence_digest', 'terminal_evidence_digest', 'outcome', 'occurred_at',
|
|
37
|
+
]);
|
|
38
|
+
const VERIFIED_ORIGINAL_KEYS = new Set(['ok', ...ORIGINAL_KEYS, 'evidence_digest']);
|
|
39
|
+
const ORIGINAL_RECONCILIATION_INPUT_KEYS = new Set([
|
|
40
|
+
'tenantId', 'instanceId', 'outcome', 'evidence',
|
|
41
|
+
]);
|
|
42
|
+
const ORIGINAL_RECONCILIATION_EVIDENCE_KEYS = new Set([
|
|
43
|
+
'evidence_id', 'evidence_digest', 'observed_at',
|
|
44
|
+
]);
|
|
45
|
+
const VERIFIED_ORIGINAL_RECONCILIATION_KEYS = new Set([
|
|
46
|
+
'ok', ...ORIGINAL_RECONCILIATION_EVIDENCE_KEYS, 'original_operation_id',
|
|
47
|
+
'original_action_digest', 'terminal_evidence_digest', 'outcome',
|
|
48
|
+
]);
|
|
49
|
+
const REVOCATION_INPUT_KEYS = new Set(['tenantId', 'instanceId', 'evidence']);
|
|
50
|
+
const REVOCATION_EVIDENCE_KEYS = new Set(['id', 'digest']);
|
|
51
|
+
const VERIFIED_REVOCATION_KEYS = new Set([
|
|
52
|
+
'ok', 'evidence_id', 'evidence_digest', 'target_operation_id',
|
|
53
|
+
'action_digest', 'authority_id', 'revoked_at',
|
|
54
|
+
]);
|
|
55
|
+
const DISPUTE_INPUT_KEYS = new Set(['tenantId', 'instanceId', 'dispute']);
|
|
56
|
+
const DISPUTE_KEYS = new Set([
|
|
57
|
+
'dispute_id', 'evidence_id', 'evidence_digest', 'challenger_id',
|
|
58
|
+
'requested_units', 'opened_at',
|
|
59
|
+
]);
|
|
60
|
+
const VERIFIED_DISPUTE_KEYS = new Set([
|
|
61
|
+
'ok', ...DISPUTE_KEYS, 'original_operation_id', 'original_action_digest',
|
|
62
|
+
]);
|
|
63
|
+
const AUTHORIZATION_INPUT_KEYS = new Set(['tenantId', 'instanceId', 'authorization']);
|
|
64
|
+
const AUTHORIZATION_KEYS = new Set([
|
|
65
|
+
'evidence_id', 'evidence_digest', 'remedy_operation_id', 'remedy_caid',
|
|
66
|
+
'remedy_action_digest', 'consequence_mode', 'capability_template_digest',
|
|
67
|
+
'escrow_profile_digest', 'units', 'authorized_at',
|
|
68
|
+
]);
|
|
69
|
+
const VERIFIED_AUTHORIZATION_KEYS = new Set([
|
|
70
|
+
'ok', ...AUTHORIZATION_KEYS, 'dispute_id', 'original_operation_id',
|
|
71
|
+
'destination_binding_digest', 'unit',
|
|
72
|
+
]);
|
|
73
|
+
const CLAIM_KEYS = new Set(['tenantId', 'instanceId', 'remedyOperationId', 'claimToken']);
|
|
74
|
+
const FINALIZE_KEYS = new Set([
|
|
75
|
+
'tenantId', 'instanceId', 'remedyOperationId', 'claimToken', 'outcome', 'evidence',
|
|
76
|
+
]);
|
|
77
|
+
const RECONCILE_KEYS = new Set(['tenantId', 'instanceId', 'remedyOperationId', 'outcome', 'evidence']);
|
|
78
|
+
const OUTCOME_EVIDENCE_KEYS = new Set(['evidence_id', 'evidence_digest', 'observed_at']);
|
|
79
|
+
const VERIFIED_OUTCOME_KEYS = new Set([
|
|
80
|
+
'ok', ...OUTCOME_EVIDENCE_KEYS, 'remedy_operation_id',
|
|
81
|
+
'remedy_action_digest', 'destination_binding_digest', 'units', 'unit',
|
|
82
|
+
'outcome',
|
|
83
|
+
]);
|
|
84
|
+
const RESOLUTION_INPUT_KEYS = new Set(['tenantId', 'instanceId', 'resolution']);
|
|
85
|
+
const RESOLUTION_KEYS = new Set([
|
|
86
|
+
'evidence_id', 'evidence_digest', 'outcome', 'resolved_at',
|
|
87
|
+
]);
|
|
88
|
+
const VERIFIED_RESOLUTION_KEYS = new Set(['ok', ...RESOLUTION_KEYS, 'dispute_id']);
|
|
89
|
+
const STATE_KEYS = new Set([
|
|
90
|
+
'version', 'instance_id', 'tenant_id', 'environment', 'audience', 'status',
|
|
91
|
+
'revision', 'created_at', 'updated_at', 'original', 'remedy_profile_digest',
|
|
92
|
+
'destination_binding_digest', 'max_remedy_units', 'unit', 'remedied_units',
|
|
93
|
+
'remaining_units', 'used_evidence_ids', 'used_evidence_digests',
|
|
94
|
+
'original_reconciliation', 'revocation', 'dispute', 'active_remedy',
|
|
95
|
+
'remedies', 'resolution',
|
|
96
|
+
'create_request_digest',
|
|
97
|
+
]);
|
|
98
|
+
const STORED_ORIGINAL_KEYS = new Set([...ORIGINAL_KEYS, 'evidence_digest']);
|
|
99
|
+
const STORED_ORIGINAL_RECONCILIATION_KEYS = new Set([
|
|
100
|
+
...VERIFIED_ORIGINAL_RECONCILIATION_KEYS,
|
|
101
|
+
'request_digest',
|
|
102
|
+
]);
|
|
103
|
+
STORED_ORIGINAL_RECONCILIATION_KEYS.delete('ok');
|
|
104
|
+
const STORED_REVOCATION_KEYS = new Set([
|
|
105
|
+
'evidence_id', 'evidence_digest', 'target_operation_id', 'action_digest',
|
|
106
|
+
'authority_id', 'revoked_at', 'effect', 'request_digest',
|
|
107
|
+
]);
|
|
108
|
+
const STORED_DISPUTE_KEYS = new Set([
|
|
109
|
+
...DISPUTE_KEYS, 'original_operation_id', 'original_action_digest',
|
|
110
|
+
'request_digest',
|
|
111
|
+
]);
|
|
112
|
+
const ATTEMPT_KEYS = new Set([
|
|
113
|
+
'evidence_id', 'evidence_digest', 'dispute_id', 'original_operation_id',
|
|
114
|
+
'remedy_operation_id', 'remedy_caid', 'remedy_action_digest',
|
|
115
|
+
'consequence_mode', 'capability_template_digest', 'escrow_profile_digest',
|
|
116
|
+
'destination_binding_digest', 'units', 'unit', 'authorized_at',
|
|
117
|
+
'request_digest', 'status', 'claim_token_digest', 'claimed_at',
|
|
118
|
+
'claim_request_digest', 'outcome', 'outcome_evidence',
|
|
119
|
+
'finalize_request_digest', 'reconciliation', 'reconcile_request_digest',
|
|
120
|
+
]);
|
|
121
|
+
const STORED_OUTCOME_KEYS = new Set([
|
|
122
|
+
...OUTCOME_EVIDENCE_KEYS, 'remedy_operation_id', 'remedy_action_digest',
|
|
123
|
+
'destination_binding_digest', 'units', 'unit', 'outcome',
|
|
124
|
+
]);
|
|
125
|
+
const STORED_RESOLUTION_KEYS = new Set([
|
|
126
|
+
...RESOLUTION_KEYS, 'dispute_id', 'request_digest',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
type DataRecord = Record<string, any>;
|
|
130
|
+
type Failure = Readonly<{ ok: false; reason: string }>;
|
|
131
|
+
|
|
132
|
+
export interface RemedyProgramState extends Record<string, unknown> {
|
|
133
|
+
version: string;
|
|
134
|
+
instance_id: string;
|
|
135
|
+
status: string;
|
|
136
|
+
revision: number;
|
|
137
|
+
remedied_units: number;
|
|
138
|
+
remaining_units: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface RemedyProgramResult extends Record<string, unknown> {
|
|
142
|
+
ok: boolean;
|
|
143
|
+
reason?: string;
|
|
144
|
+
state?: RemedyProgramState;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface RemedyProgramStore {
|
|
148
|
+
readonly durable: boolean;
|
|
149
|
+
create(state: RemedyProgramState): Promise<RemedyProgramResult>;
|
|
150
|
+
get(input: { tenantId: string; instanceId: string }): Promise<RemedyProgramResult>;
|
|
151
|
+
compareAndSwap(input: {
|
|
152
|
+
tenantId: string;
|
|
153
|
+
instanceId: string;
|
|
154
|
+
expectedRevision: number;
|
|
155
|
+
state: RemedyProgramState;
|
|
156
|
+
}): Promise<RemedyProgramResult>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface RemedyProgramKernelConfig extends Record<string, unknown> {
|
|
160
|
+
store: RemedyProgramStore;
|
|
161
|
+
verifyOriginalEffect: (input: Readonly<DataRecord>) => unknown | Promise<unknown>;
|
|
162
|
+
verifyRevocation: (input: Readonly<DataRecord>) => unknown | Promise<unknown>;
|
|
163
|
+
verifyDispute: (input: Readonly<DataRecord>) => unknown | Promise<unknown>;
|
|
164
|
+
verifyRemedyAuthorization: (input: Readonly<DataRecord>) => unknown | Promise<unknown>;
|
|
165
|
+
verifyRemedyOutcome: (input: Readonly<DataRecord>) => unknown | Promise<unknown>;
|
|
166
|
+
verifyOriginalReconciliation: (input: Readonly<DataRecord>) => unknown | Promise<unknown>;
|
|
167
|
+
verifyResolution?: (input: Readonly<DataRecord>) => unknown | Promise<unknown>;
|
|
168
|
+
now?: () => number;
|
|
169
|
+
maxDisputeAgeMs?: number;
|
|
170
|
+
allowEphemeralState?: boolean;
|
|
171
|
+
production?: boolean;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function deepFreeze<T>(value: T): Readonly<T> {
|
|
175
|
+
if (value === null || typeof value !== 'object') return value;
|
|
176
|
+
const stack: object[] = [value as object];
|
|
177
|
+
const seen = new WeakSet<object>();
|
|
178
|
+
while (stack.length > 0) {
|
|
179
|
+
const current = stack.pop()!;
|
|
180
|
+
if (seen.has(current)) continue;
|
|
181
|
+
seen.add(current);
|
|
182
|
+
for (const child of Object.values(current)) {
|
|
183
|
+
if (child !== null && typeof child === 'object') stack.push(child);
|
|
184
|
+
}
|
|
185
|
+
Object.freeze(current);
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function snapshot<T>(value: T, label: string): Readonly<T> {
|
|
191
|
+
try {
|
|
192
|
+
canonicalize(value);
|
|
193
|
+
return deepFreeze(structuredClone(value));
|
|
194
|
+
} catch {
|
|
195
|
+
throw new TypeError(`${label} must be bounded canonical JSON`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function clone<T>(value: T): T {
|
|
200
|
+
return structuredClone(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isRecord(value: unknown): value is DataRecord {
|
|
204
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
205
|
+
const prototype = Object.getPrototypeOf(value);
|
|
206
|
+
return prototype === Object.prototype || prototype === null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isDataRecord(value: unknown): value is DataRecord {
|
|
210
|
+
return isRecord(value) && Reflect.ownKeys(value).every((key) => {
|
|
211
|
+
if (typeof key !== 'string') return false;
|
|
212
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
213
|
+
return descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function exactKeys(value: unknown, keys: Set<string>): value is DataRecord {
|
|
218
|
+
return isDataRecord(value)
|
|
219
|
+
&& Reflect.ownKeys(value).length === keys.size
|
|
220
|
+
&& Object.keys(value).every((key) => keys.has(key));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validId(value: unknown): value is string {
|
|
224
|
+
return typeof value === 'string' && ID.test(value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function validContext(value: unknown): value is string {
|
|
228
|
+
return typeof value === 'string' && value.length > 0
|
|
229
|
+
&& Buffer.byteLength(value, 'utf8') <= MAX_CONTEXT_BYTES
|
|
230
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function instant(value: unknown): number {
|
|
234
|
+
if (typeof value !== 'string'
|
|
235
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return NaN;
|
|
236
|
+
const parsed = Date.parse(value);
|
|
237
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value ? parsed : NaN;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function digest(value: unknown): string {
|
|
241
|
+
return `sha256:${createHash('sha256').update(canonicalize(value)).digest('hex')}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function same(left: unknown, right: unknown): boolean {
|
|
245
|
+
try {
|
|
246
|
+
return canonicalize(left) === canonicalize(right);
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fail(reason: string): Failure {
|
|
253
|
+
return Object.freeze({ ok: false, reason });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isFailure(value: Readonly<DataRecord> | Failure): value is Failure {
|
|
257
|
+
return value.ok === false && typeof value.reason === 'string';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pass(fields: DataRecord): Readonly<DataRecord> {
|
|
261
|
+
return deepFreeze(fields);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function withoutOk(projection: DataRecord): DataRecord {
|
|
265
|
+
const result: DataRecord = {};
|
|
266
|
+
for (const [key, value] of Object.entries(projection)) {
|
|
267
|
+
if (key !== 'ok') result[key] = clone(value);
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validOriginal(value: unknown, stored = false): value is DataRecord {
|
|
273
|
+
const keys = stored ? STORED_ORIGINAL_KEYS : ORIGINAL_KEYS;
|
|
274
|
+
return exactKeys(value, keys)
|
|
275
|
+
&& typeof value.caid === 'string' && CAID.test(value.caid)
|
|
276
|
+
&& typeof value.action_digest === 'string' && DIGEST.test(value.action_digest)
|
|
277
|
+
&& validId(value.operation_id)
|
|
278
|
+
&& ['receipt-program', 'action-escrow'].includes(value.consequence_mode)
|
|
279
|
+
&& typeof value.consequence_digest === 'string' && DIGEST.test(value.consequence_digest)
|
|
280
|
+
&& typeof value.terminal_evidence_digest === 'string' && DIGEST.test(value.terminal_evidence_digest)
|
|
281
|
+
&& ['executed', 'indeterminate'].includes(value.outcome)
|
|
282
|
+
&& Number.isFinite(instant(value.occurred_at))
|
|
283
|
+
&& (!stored || (typeof value.evidence_digest === 'string' && DIGEST.test(value.evidence_digest)));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function validOriginalReconciliation(value: unknown): value is DataRecord {
|
|
287
|
+
return exactKeys(value, STORED_ORIGINAL_RECONCILIATION_KEYS)
|
|
288
|
+
&& validEvidencePair(value.evidence_id, value.evidence_digest)
|
|
289
|
+
&& validId(value.original_operation_id)
|
|
290
|
+
&& typeof value.original_action_digest === 'string' && DIGEST.test(value.original_action_digest)
|
|
291
|
+
&& typeof value.terminal_evidence_digest === 'string' && DIGEST.test(value.terminal_evidence_digest)
|
|
292
|
+
&& ['executed', 'proved_no_effect'].includes(value.outcome)
|
|
293
|
+
&& Number.isFinite(instant(value.observed_at))
|
|
294
|
+
&& typeof value.request_digest === 'string' && DIGEST.test(value.request_digest);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validDispute(value: unknown): value is DataRecord {
|
|
298
|
+
return exactKeys(value, STORED_DISPUTE_KEYS)
|
|
299
|
+
&& validId(value.dispute_id) && validId(value.evidence_id)
|
|
300
|
+
&& typeof value.evidence_digest === 'string' && DIGEST.test(value.evidence_digest)
|
|
301
|
+
&& validContext(value.challenger_id)
|
|
302
|
+
&& validId(value.original_operation_id)
|
|
303
|
+
&& typeof value.original_action_digest === 'string' && DIGEST.test(value.original_action_digest)
|
|
304
|
+
&& Number.isSafeInteger(value.requested_units) && value.requested_units > 0
|
|
305
|
+
&& Number.isFinite(instant(value.opened_at))
|
|
306
|
+
&& typeof value.request_digest === 'string' && DIGEST.test(value.request_digest);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validStoredOutcome(value: unknown, reconciliation: boolean): value is DataRecord {
|
|
310
|
+
return exactKeys(value, STORED_OUTCOME_KEYS)
|
|
311
|
+
&& validId(value.evidence_id)
|
|
312
|
+
&& typeof value.evidence_digest === 'string' && DIGEST.test(value.evidence_digest)
|
|
313
|
+
&& validId(value.remedy_operation_id)
|
|
314
|
+
&& typeof value.remedy_action_digest === 'string' && DIGEST.test(value.remedy_action_digest)
|
|
315
|
+
&& typeof value.destination_binding_digest === 'string' && DIGEST.test(value.destination_binding_digest)
|
|
316
|
+
&& Number.isSafeInteger(value.units) && value.units > 0
|
|
317
|
+
&& validContext(value.unit)
|
|
318
|
+
&& (reconciliation
|
|
319
|
+
? ['executed', 'proved_no_effect'].includes(value.outcome)
|
|
320
|
+
: ['executed', 'proved_no_effect', 'indeterminate'].includes(value.outcome))
|
|
321
|
+
&& Number.isFinite(instant(value.observed_at));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validRemedyOwner(value: DataRecord): boolean {
|
|
325
|
+
return (value.consequence_mode === 'receipt-program'
|
|
326
|
+
&& typeof value.capability_template_digest === 'string'
|
|
327
|
+
&& DIGEST.test(value.capability_template_digest)
|
|
328
|
+
&& value.escrow_profile_digest === null)
|
|
329
|
+
|| (value.consequence_mode === 'action-escrow'
|
|
330
|
+
&& value.capability_template_digest === null
|
|
331
|
+
&& typeof value.escrow_profile_digest === 'string'
|
|
332
|
+
&& DIGEST.test(value.escrow_profile_digest));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function validAttempt(value: unknown): value is DataRecord {
|
|
336
|
+
if (!exactKeys(value, ATTEMPT_KEYS)
|
|
337
|
+
|| !validId(value.evidence_id)
|
|
338
|
+
|| typeof value.evidence_digest !== 'string' || !DIGEST.test(value.evidence_digest)
|
|
339
|
+
|| !validId(value.dispute_id) || !validId(value.original_operation_id)
|
|
340
|
+
|| !validId(value.remedy_operation_id)
|
|
341
|
+
|| typeof value.remedy_caid !== 'string' || !CAID.test(value.remedy_caid)
|
|
342
|
+
|| typeof value.remedy_action_digest !== 'string' || !DIGEST.test(value.remedy_action_digest)
|
|
343
|
+
|| !validRemedyOwner(value)
|
|
344
|
+
|| typeof value.destination_binding_digest !== 'string' || !DIGEST.test(value.destination_binding_digest)
|
|
345
|
+
|| !Number.isSafeInteger(value.units) || value.units < 1
|
|
346
|
+
|| !validContext(value.unit) || !Number.isFinite(instant(value.authorized_at))
|
|
347
|
+
|| typeof value.request_digest !== 'string' || !DIGEST.test(value.request_digest)
|
|
348
|
+
|| !['authorized', 'claimed', 'indeterminate', 'executed', 'proved_no_effect'].includes(value.status)) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
const claimed = value.status !== 'authorized';
|
|
352
|
+
if (claimed !== (typeof value.claim_token_digest === 'string' && DIGEST.test(value.claim_token_digest))
|
|
353
|
+
|| claimed !== Number.isFinite(instant(value.claimed_at))
|
|
354
|
+
|| claimed !== (typeof value.claim_request_digest === 'string' && DIGEST.test(value.claim_request_digest))) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
const finalized = ['indeterminate', 'executed', 'proved_no_effect'].includes(value.status);
|
|
358
|
+
if (finalized !== (typeof value.finalize_request_digest === 'string' && DIGEST.test(value.finalize_request_digest))
|
|
359
|
+
|| finalized !== validStoredOutcome(value.outcome_evidence, false)
|
|
360
|
+
|| finalized !== (typeof value.outcome === 'string')) return false;
|
|
361
|
+
const reconciled = value.reconciliation !== null;
|
|
362
|
+
if (reconciled !== (typeof value.reconcile_request_digest === 'string' && DIGEST.test(value.reconcile_request_digest))) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
if (reconciled && !validStoredOutcome(value.reconciliation, true)) return false;
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function validState(value: unknown, tenantId?: string, instanceId?: string): value is DataRecord {
|
|
370
|
+
if (!exactKeys(value, STATE_KEYS)
|
|
371
|
+
|| value.version !== REMEDY_PROGRAM_VERSION
|
|
372
|
+
|| !validId(value.instance_id) || (instanceId !== undefined && value.instance_id !== instanceId)
|
|
373
|
+
|| !validContext(value.tenant_id) || (tenantId !== undefined && value.tenant_id !== tenantId)
|
|
374
|
+
|| !validContext(value.environment) || !validContext(value.audience)
|
|
375
|
+
|| ![
|
|
376
|
+
'effect_executed', 'effect_indeterminate', 'disputed', 'remedy_authorized',
|
|
377
|
+
'remedy_claimed', 'remedy_indeterminate', 'partially_remedied',
|
|
378
|
+
'remedied', 'resolved_no_remedy', 'original_proved_no_effect',
|
|
379
|
+
].includes(value.status)
|
|
380
|
+
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|
|
381
|
+
|| !Number.isFinite(instant(value.created_at)) || !Number.isFinite(instant(value.updated_at))
|
|
382
|
+
|| instant(value.updated_at) < instant(value.created_at)
|
|
383
|
+
|| !validOriginal(value.original, true)
|
|
384
|
+
|| typeof value.remedy_profile_digest !== 'string' || !DIGEST.test(value.remedy_profile_digest)
|
|
385
|
+
|| typeof value.destination_binding_digest !== 'string' || !DIGEST.test(value.destination_binding_digest)
|
|
386
|
+
|| !Number.isSafeInteger(value.max_remedy_units) || value.max_remedy_units < 1
|
|
387
|
+
|| !validContext(value.unit)
|
|
388
|
+
|| !Number.isSafeInteger(value.remedied_units) || value.remedied_units < 0
|
|
389
|
+
|| !Number.isSafeInteger(value.remaining_units) || value.remaining_units < 0
|
|
390
|
+
|| value.remedied_units + value.remaining_units !== value.max_remedy_units
|
|
391
|
+
|| !Array.isArray(value.used_evidence_ids) || !value.used_evidence_ids.every(validId)
|
|
392
|
+
|| new Set(value.used_evidence_ids).size !== value.used_evidence_ids.length
|
|
393
|
+
|| !Array.isArray(value.used_evidence_digests)
|
|
394
|
+
|| !value.used_evidence_digests.every((entry: unknown) => typeof entry === 'string' && DIGEST.test(entry))
|
|
395
|
+
|| new Set(value.used_evidence_digests).size !== value.used_evidence_digests.length
|
|
396
|
+
|| !Array.isArray(value.remedies) || value.remedies.length > MAX_REMEDY_ATTEMPTS
|
|
397
|
+
|| !value.remedies.every(validAttempt)
|
|
398
|
+
|| (value.dispute !== null && !validDispute(value.dispute))
|
|
399
|
+
|| (value.active_remedy !== null && !validAttempt(value.active_remedy))
|
|
400
|
+
|| typeof value.create_request_digest !== 'string' || !DIGEST.test(value.create_request_digest)) {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
if (value.revocation !== null && (!exactKeys(value.revocation, STORED_REVOCATION_KEYS)
|
|
404
|
+
|| !validId(value.revocation.evidence_id)
|
|
405
|
+
|| typeof value.revocation.evidence_digest !== 'string' || !DIGEST.test(value.revocation.evidence_digest)
|
|
406
|
+
|| !validId(value.revocation.target_operation_id)
|
|
407
|
+
|| typeof value.revocation.action_digest !== 'string' || !DIGEST.test(value.revocation.action_digest)
|
|
408
|
+
|| !validContext(value.revocation.authority_id)
|
|
409
|
+
|| !Number.isFinite(instant(value.revocation.revoked_at))
|
|
410
|
+
|| value.revocation.effect !== 'future_authority_only'
|
|
411
|
+
|| typeof value.revocation.request_digest !== 'string' || !DIGEST.test(value.revocation.request_digest))) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
if (value.original_reconciliation !== null
|
|
415
|
+
&& !validOriginalReconciliation(value.original_reconciliation)) return false;
|
|
416
|
+
if (value.original_reconciliation !== null
|
|
417
|
+
&& (value.original.outcome !== 'indeterminate'
|
|
418
|
+
|| value.original_reconciliation.original_operation_id !== value.original.operation_id
|
|
419
|
+
|| value.original_reconciliation.original_action_digest !== value.original.action_digest
|
|
420
|
+
|| value.original_reconciliation.terminal_evidence_digest !== value.original.terminal_evidence_digest
|
|
421
|
+
|| instant(value.original_reconciliation.observed_at) < instant(value.original.occurred_at))) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
if (value.resolution !== null && (!exactKeys(value.resolution, STORED_RESOLUTION_KEYS)
|
|
425
|
+
|| !validId(value.resolution.evidence_id)
|
|
426
|
+
|| typeof value.resolution.evidence_digest !== 'string' || !DIGEST.test(value.resolution.evidence_digest)
|
|
427
|
+
|| value.resolution.outcome !== 'no_remedy'
|
|
428
|
+
|| !Number.isFinite(instant(value.resolution.resolved_at))
|
|
429
|
+
|| !validId(value.resolution.dispute_id)
|
|
430
|
+
|| typeof value.resolution.request_digest !== 'string' || !DIGEST.test(value.resolution.request_digest))) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const committed = value.remedies
|
|
434
|
+
.filter((attempt: DataRecord) => attempt.status === 'executed')
|
|
435
|
+
.reduce((total: number, attempt: DataRecord) => total + attempt.units, 0);
|
|
436
|
+
if (committed !== value.remedied_units || committed > value.max_remedy_units) return false;
|
|
437
|
+
const effectiveOriginalOutcome = value.original.outcome === 'executed'
|
|
438
|
+
? 'executed' : value.original_reconciliation?.outcome ?? 'indeterminate';
|
|
439
|
+
if (value.status === 'effect_indeterminate' && effectiveOriginalOutcome !== 'indeterminate') return false;
|
|
440
|
+
if (value.status === 'original_proved_no_effect') {
|
|
441
|
+
if (effectiveOriginalOutcome !== 'proved_no_effect'
|
|
442
|
+
|| value.active_remedy !== null || value.remedies.length !== 0
|
|
443
|
+
|| value.remedied_units !== 0) return false;
|
|
444
|
+
} else if (effectiveOriginalOutcome === 'proved_no_effect') return false;
|
|
445
|
+
if (!['effect_indeterminate', 'original_proved_no_effect', 'disputed', 'resolved_no_remedy'].includes(value.status)
|
|
446
|
+
&& effectiveOriginalOutcome !== 'executed') return false;
|
|
447
|
+
if (value.status === 'disputed' && effectiveOriginalOutcome === 'proved_no_effect') return false;
|
|
448
|
+
if (['remedy_authorized', 'remedy_claimed', 'remedy_indeterminate'].includes(value.status)
|
|
449
|
+
!== (value.active_remedy !== null)) return false;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function tokenDigest(token: string): string {
|
|
454
|
+
return digest({ claim_token: token });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function validEvidencePair(id: unknown, evidenceDigest: unknown): boolean {
|
|
458
|
+
return validId(id) && typeof evidenceDigest === 'string' && DIGEST.test(evidenceDigest);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function evidenceUsed(state: DataRecord, id: string, evidenceDigest: string): boolean {
|
|
462
|
+
return state.used_evidence_ids.includes(id) || state.used_evidence_digests.includes(evidenceDigest);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function consumeEvidence(state: DataRecord, id: string, evidenceDigest: string): void {
|
|
466
|
+
state.used_evidence_ids.push(id);
|
|
467
|
+
state.used_evidence_digests.push(evidenceDigest);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function expectedContext(state: DataRecord): Readonly<DataRecord> {
|
|
471
|
+
return snapshot({
|
|
472
|
+
instance_id: state.instance_id,
|
|
473
|
+
tenant_id: state.tenant_id,
|
|
474
|
+
environment: state.environment,
|
|
475
|
+
audience: state.audience,
|
|
476
|
+
original: clone(state.original),
|
|
477
|
+
original_reconciliation: state.original_reconciliation === null
|
|
478
|
+
? null : clone(state.original_reconciliation),
|
|
479
|
+
remedy_profile_digest: state.remedy_profile_digest,
|
|
480
|
+
destination_binding_digest: state.destination_binding_digest,
|
|
481
|
+
max_remedy_units: state.max_remedy_units,
|
|
482
|
+
unit: state.unit,
|
|
483
|
+
dispute: state.dispute === null ? null : clone(state.dispute),
|
|
484
|
+
}, 'verifier expectation') as Readonly<DataRecord>;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* In-process atomic CAS store. It is intentionally marked non-durable; callers
|
|
489
|
+
* selecting production mode must provide a durable external CAS store.
|
|
490
|
+
*/
|
|
491
|
+
export function createRemedyMemoryStore(): RemedyProgramStore {
|
|
492
|
+
const records = new Map<string, DataRecord>();
|
|
493
|
+
const storageKey = (tenantId: string, instanceId: string) => `${tenantId.length}:${tenantId}${instanceId}`;
|
|
494
|
+
const evidenceIds = new Set<string>();
|
|
495
|
+
const evidenceDigests = new Set<string>();
|
|
496
|
+
const remedyOperations = new Set<string>();
|
|
497
|
+
const remedyActions = new Set<string>();
|
|
498
|
+
const remedyCaids = new Set<string>();
|
|
499
|
+
const claimKey = (tenantId: string, value: string) => `${tenantId.length}:${tenantId}${value}`;
|
|
500
|
+
const attempts = (state: DataRecord): DataRecord[] => [
|
|
501
|
+
...(state.active_remedy === null ? [] : [state.active_remedy]),
|
|
502
|
+
...state.remedies,
|
|
503
|
+
];
|
|
504
|
+
const claimEvidence = (state: DataRecord) => {
|
|
505
|
+
for (const id of state.used_evidence_ids) evidenceIds.add(claimKey(state.tenant_id, id));
|
|
506
|
+
for (const value of state.used_evidence_digests) {
|
|
507
|
+
evidenceDigests.add(claimKey(state.tenant_id, value));
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
const claimAttempts = (state: DataRecord) => {
|
|
511
|
+
for (const attempt of attempts(state)) {
|
|
512
|
+
remedyOperations.add(claimKey(state.tenant_id, attempt.remedy_operation_id));
|
|
513
|
+
remedyActions.add(claimKey(state.tenant_id, attempt.remedy_action_digest));
|
|
514
|
+
remedyCaids.add(claimKey(state.tenant_id, attempt.remedy_caid));
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
return Object.freeze({
|
|
518
|
+
durable: false,
|
|
519
|
+
async create(state: RemedyProgramState) {
|
|
520
|
+
let candidate: Readonly<DataRecord>;
|
|
521
|
+
try {
|
|
522
|
+
candidate = snapshot(state, 'remedy state') as Readonly<DataRecord>;
|
|
523
|
+
} catch {
|
|
524
|
+
return fail('store_state_invalid');
|
|
525
|
+
}
|
|
526
|
+
if (!validState(candidate)) return fail('store_state_invalid');
|
|
527
|
+
const key = storageKey(candidate.tenant_id, candidate.instance_id);
|
|
528
|
+
if (records.has(key)) return fail('instance_exists');
|
|
529
|
+
if (candidate.used_evidence_ids.some((id: string) => evidenceIds.has(claimKey(candidate.tenant_id, id)))
|
|
530
|
+
|| candidate.used_evidence_digests.some((value: string) => (
|
|
531
|
+
evidenceDigests.has(claimKey(candidate.tenant_id, value))
|
|
532
|
+
))) return fail('evidence_replayed');
|
|
533
|
+
records.set(key, clone(candidate));
|
|
534
|
+
claimEvidence(candidate as DataRecord);
|
|
535
|
+
claimAttempts(candidate as DataRecord);
|
|
536
|
+
return { ok: true, state: clone(candidate) };
|
|
537
|
+
},
|
|
538
|
+
async get({ tenantId, instanceId }: { tenantId: string; instanceId: string }) {
|
|
539
|
+
const state = records.get(storageKey(tenantId, instanceId));
|
|
540
|
+
return state ? { ok: true, state: clone(state) } : fail('instance_not_found');
|
|
541
|
+
},
|
|
542
|
+
async compareAndSwap({ tenantId, instanceId, expectedRevision, state }: DataRecord) {
|
|
543
|
+
const key = storageKey(tenantId, instanceId);
|
|
544
|
+
const current = records.get(key);
|
|
545
|
+
if (!current) return fail('instance_not_found');
|
|
546
|
+
if (current.revision !== expectedRevision) return fail('revision_conflict');
|
|
547
|
+
let candidate: Readonly<DataRecord>;
|
|
548
|
+
try {
|
|
549
|
+
candidate = snapshot(state, 'remedy state') as Readonly<DataRecord>;
|
|
550
|
+
} catch {
|
|
551
|
+
return fail('store_state_invalid');
|
|
552
|
+
}
|
|
553
|
+
if (!validState(candidate, tenantId, instanceId)
|
|
554
|
+
|| candidate.revision !== expectedRevision + 1
|
|
555
|
+
|| instant(candidate.updated_at) < instant(current.updated_at)) {
|
|
556
|
+
return fail('store_state_invalid');
|
|
557
|
+
}
|
|
558
|
+
const newEvidenceIds = candidate.used_evidence_ids.filter((id: string) => (
|
|
559
|
+
!current.used_evidence_ids.includes(id)
|
|
560
|
+
));
|
|
561
|
+
const newEvidenceDigests = candidate.used_evidence_digests.filter((value: string) => (
|
|
562
|
+
!current.used_evidence_digests.includes(value)
|
|
563
|
+
));
|
|
564
|
+
if (newEvidenceIds.some((id: string) => evidenceIds.has(claimKey(tenantId, id)))
|
|
565
|
+
|| newEvidenceDigests.some((value: string) => evidenceDigests.has(claimKey(tenantId, value)))) {
|
|
566
|
+
return fail('evidence_replayed');
|
|
567
|
+
}
|
|
568
|
+
const currentOperations = new Set(attempts(current).map((attempt) => attempt.remedy_operation_id));
|
|
569
|
+
const newAttempts = attempts(candidate as DataRecord).filter((attempt) => (
|
|
570
|
+
!currentOperations.has(attempt.remedy_operation_id)
|
|
571
|
+
));
|
|
572
|
+
if (newAttempts.some((attempt) => (
|
|
573
|
+
remedyOperations.has(claimKey(tenantId, attempt.remedy_operation_id))
|
|
574
|
+
|| remedyActions.has(claimKey(tenantId, attempt.remedy_action_digest))
|
|
575
|
+
|| remedyCaids.has(claimKey(tenantId, attempt.remedy_caid))
|
|
576
|
+
))) return fail('remedy_operation_replayed');
|
|
577
|
+
records.set(key, clone(candidate));
|
|
578
|
+
claimEvidence(candidate as DataRecord);
|
|
579
|
+
claimAttempts(candidate as DataRecord);
|
|
580
|
+
return { ok: true, state: clone(candidate) };
|
|
581
|
+
},
|
|
582
|
+
} as RemedyProgramStore);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function verifierInput(fields: DataRecord): Readonly<DataRecord> {
|
|
586
|
+
return snapshot(fields, 'verifier input') as Readonly<DataRecord>;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function validClock(value: unknown): value is number {
|
|
590
|
+
return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 8_640_000_000_000_000;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Build a remedy kernel with all trust callbacks and store methods pinned. */
|
|
594
|
+
export function createRemedyProgramKernel(options: RemedyProgramKernelConfig) {
|
|
595
|
+
if (!isDataRecord(options) || Object.keys(options).some((key) => !CONFIG_KEYS.has(key))) {
|
|
596
|
+
throw new TypeError('remedy program configuration invalid');
|
|
597
|
+
}
|
|
598
|
+
const sourceStore = options.store;
|
|
599
|
+
if (!sourceStore || typeof sourceStore.create !== 'function'
|
|
600
|
+
|| typeof sourceStore.get !== 'function'
|
|
601
|
+
|| typeof sourceStore.compareAndSwap !== 'function') {
|
|
602
|
+
throw new TypeError('remedy program CAS store required');
|
|
603
|
+
}
|
|
604
|
+
for (const key of [
|
|
605
|
+
'verifyOriginalEffect', 'verifyRevocation', 'verifyDispute',
|
|
606
|
+
'verifyRemedyAuthorization', 'verifyRemedyOutcome', 'verifyOriginalReconciliation',
|
|
607
|
+
] as const) {
|
|
608
|
+
if (typeof options[key] !== 'function') throw new TypeError(`${key} callback required`);
|
|
609
|
+
}
|
|
610
|
+
if (options.verifyResolution !== undefined && typeof options.verifyResolution !== 'function') {
|
|
611
|
+
throw new TypeError('verifyResolution callback invalid');
|
|
612
|
+
}
|
|
613
|
+
if (options.now !== undefined && typeof options.now !== 'function') {
|
|
614
|
+
throw new TypeError('remedy program clock invalid');
|
|
615
|
+
}
|
|
616
|
+
if (options.production === true && options.allowEphemeralState === true) {
|
|
617
|
+
throw new TypeError('production remedy program cannot allow ephemeral state');
|
|
618
|
+
}
|
|
619
|
+
if ((options.production === true || options.allowEphemeralState === false)
|
|
620
|
+
&& sourceStore.durable !== true) {
|
|
621
|
+
throw new TypeError('durable remedy program CAS store required');
|
|
622
|
+
}
|
|
623
|
+
const maxDisputeAgeMs = options.maxDisputeAgeMs ?? DEFAULT_MAX_DISPUTE_AGE_MS;
|
|
624
|
+
if (!Number.isSafeInteger(maxDisputeAgeMs) || maxDisputeAgeMs < 1) {
|
|
625
|
+
throw new TypeError('maxDisputeAgeMs invalid');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const store = Object.freeze({
|
|
629
|
+
durable: sourceStore.durable === true,
|
|
630
|
+
create: sourceStore.create.bind(sourceStore),
|
|
631
|
+
get: sourceStore.get.bind(sourceStore),
|
|
632
|
+
compareAndSwap: sourceStore.compareAndSwap.bind(sourceStore),
|
|
633
|
+
});
|
|
634
|
+
const verifyOriginalEffect = options.verifyOriginalEffect;
|
|
635
|
+
const verifyRevocation = options.verifyRevocation;
|
|
636
|
+
const verifyDispute = options.verifyDispute;
|
|
637
|
+
const verifyRemedyAuthorization = options.verifyRemedyAuthorization;
|
|
638
|
+
const verifyRemedyOutcome = options.verifyRemedyOutcome;
|
|
639
|
+
const verifyOriginalReconciliation = options.verifyOriginalReconciliation;
|
|
640
|
+
const verifyResolution = options.verifyResolution;
|
|
641
|
+
const now = options.now ?? Date.now;
|
|
642
|
+
|
|
643
|
+
const clock = (): number => {
|
|
644
|
+
let value: unknown;
|
|
645
|
+
try {
|
|
646
|
+
value = now();
|
|
647
|
+
} catch {
|
|
648
|
+
return NaN;
|
|
649
|
+
}
|
|
650
|
+
return validClock(value) ? Number(value) : NaN;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const checkedState = (value: unknown, tenantId: string, instanceId: string): RemedyProgramResult => {
|
|
654
|
+
let state: Readonly<DataRecord>;
|
|
655
|
+
try {
|
|
656
|
+
state = snapshot(value, 'stored remedy state') as Readonly<DataRecord>;
|
|
657
|
+
} catch {
|
|
658
|
+
return fail('store_state_invalid');
|
|
659
|
+
}
|
|
660
|
+
return validState(state, tenantId, instanceId)
|
|
661
|
+
? pass({ ok: true, state }) as RemedyProgramResult
|
|
662
|
+
: fail('store_state_invalid');
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const normalizeStoreResult = (
|
|
666
|
+
value: unknown,
|
|
667
|
+
tenantId: string,
|
|
668
|
+
instanceId: string,
|
|
669
|
+
proposedState?: DataRecord,
|
|
670
|
+
): RemedyProgramResult => {
|
|
671
|
+
if (!isDataRecord(value)) return fail('store_response_invalid');
|
|
672
|
+
if (value.ok === true) return checkedState(value.state ?? proposedState, tenantId, instanceId);
|
|
673
|
+
if (value.applied === true && proposedState) return checkedState(value.state ?? proposedState, tenantId, instanceId);
|
|
674
|
+
const reason = value.reason;
|
|
675
|
+
if ([
|
|
676
|
+
'instance_not_found', 'instance_exists', 'revision_conflict', 'clock_regression',
|
|
677
|
+
'evidence_replayed', 'remedy_operation_replayed',
|
|
678
|
+
].includes(reason)) {
|
|
679
|
+
return fail(reason);
|
|
680
|
+
}
|
|
681
|
+
if (value.ok === false || value.applied === false) return fail('store_operation_failed');
|
|
682
|
+
return fail('store_response_invalid');
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const load = async (
|
|
686
|
+
tenantId: unknown,
|
|
687
|
+
instanceId: unknown,
|
|
688
|
+
creationProbe = false,
|
|
689
|
+
): Promise<RemedyProgramResult> => {
|
|
690
|
+
if (!validContext(tenantId) || !validId(instanceId)) return fail('remedy_lookup_invalid');
|
|
691
|
+
try {
|
|
692
|
+
const result = normalizeStoreResult(
|
|
693
|
+
await store.get({ tenantId, instanceId }), tenantId, instanceId,
|
|
694
|
+
);
|
|
695
|
+
return result.reason === 'instance_not_found' && !creationProbe
|
|
696
|
+
? fail('remedy_case_not_found') : result;
|
|
697
|
+
} catch {
|
|
698
|
+
return fail('store_unavailable');
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const commit = async (state: DataRecord, next: DataRecord): Promise<RemedyProgramResult> => {
|
|
703
|
+
try {
|
|
704
|
+
const result = normalizeStoreResult(await store.compareAndSwap({
|
|
705
|
+
tenantId: state.tenant_id,
|
|
706
|
+
instanceId: state.instance_id,
|
|
707
|
+
expectedRevision: state.revision,
|
|
708
|
+
state: next as RemedyProgramState,
|
|
709
|
+
}), state.tenant_id, state.instance_id, next);
|
|
710
|
+
return result.reason === 'revision_conflict' ? fail('state_transition_conflict') : result;
|
|
711
|
+
} catch {
|
|
712
|
+
return fail('store_unavailable');
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const operationInput = (input: unknown, keys: Set<string>, reason: string): Readonly<DataRecord> | Failure => {
|
|
717
|
+
let value: Readonly<DataRecord>;
|
|
718
|
+
try {
|
|
719
|
+
value = snapshot(input, reason) as Readonly<DataRecord>;
|
|
720
|
+
} catch {
|
|
721
|
+
return fail(reason);
|
|
722
|
+
}
|
|
723
|
+
return exactKeys(value, keys) ? value : fail(reason);
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const transitionTime = (state: DataRecord): number | Failure => {
|
|
727
|
+
const current = clock();
|
|
728
|
+
if (!Number.isFinite(current)) return fail('clock_invalid');
|
|
729
|
+
if (current < instant(state.updated_at)) return fail('clock_regression');
|
|
730
|
+
return current;
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
const touch = (state: DataRecord, at: number): DataRecord => {
|
|
734
|
+
const next = clone(state);
|
|
735
|
+
next.revision += 1;
|
|
736
|
+
next.updated_at = new Date(at).toISOString();
|
|
737
|
+
return next;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const invoke = async (
|
|
741
|
+
callback: (input: Readonly<DataRecord>) => unknown | Promise<unknown>,
|
|
742
|
+
input: DataRecord,
|
|
743
|
+
): Promise<Readonly<DataRecord> | null> => {
|
|
744
|
+
try {
|
|
745
|
+
const result = await callback(verifierInput(input));
|
|
746
|
+
return snapshot(result, 'verifier projection') as Readonly<DataRecord>;
|
|
747
|
+
} catch {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
async function create(input: unknown): Promise<RemedyProgramResult> {
|
|
753
|
+
const parsed = operationInput(input, CREATE_KEYS, 'create_input_invalid');
|
|
754
|
+
if (isFailure(parsed)) return parsed;
|
|
755
|
+
const value = parsed as Readonly<DataRecord>;
|
|
756
|
+
if (!validId(value.instanceId) || !validContext(value.tenantId)
|
|
757
|
+
|| !validContext(value.environment) || !validContext(value.audience)
|
|
758
|
+
|| !validOriginal(value.original)
|
|
759
|
+
|| typeof value.remedyProfileDigest !== 'string' || !DIGEST.test(value.remedyProfileDigest)
|
|
760
|
+
|| typeof value.destinationBindingDigest !== 'string' || !DIGEST.test(value.destinationBindingDigest)
|
|
761
|
+
|| !Number.isSafeInteger(value.maxRemedyUnits) || value.maxRemedyUnits < 1
|
|
762
|
+
|| !validContext(value.unit) || !isDataRecord(value.evidence)) {
|
|
763
|
+
return fail('create_input_invalid');
|
|
764
|
+
}
|
|
765
|
+
const requestDigest = digest(value);
|
|
766
|
+
const existing = await load(value.tenantId, value.instanceId, true);
|
|
767
|
+
if (existing.ok) {
|
|
768
|
+
return existing.state!.create_request_digest === requestDigest
|
|
769
|
+
? pass({ ok: true, idempotent: true, state: existing.state }) as RemedyProgramResult
|
|
770
|
+
: fail('instance_exists');
|
|
771
|
+
}
|
|
772
|
+
if (existing.reason !== 'instance_not_found') return existing;
|
|
773
|
+
const current = clock();
|
|
774
|
+
if (!Number.isFinite(current)) return fail('clock_invalid');
|
|
775
|
+
if (instant(value.original.occurred_at) > current) return fail('original_effect_time_invalid');
|
|
776
|
+
const verified = await invoke(verifyOriginalEffect, {
|
|
777
|
+
original: clone(value.original),
|
|
778
|
+
evidence: clone(value.evidence),
|
|
779
|
+
expected: {
|
|
780
|
+
instance_id: value.instanceId,
|
|
781
|
+
tenant_id: value.tenantId,
|
|
782
|
+
environment: value.environment,
|
|
783
|
+
audience: value.audience,
|
|
784
|
+
remedy_profile_digest: value.remedyProfileDigest,
|
|
785
|
+
destination_binding_digest: value.destinationBindingDigest,
|
|
786
|
+
max_remedy_units: value.maxRemedyUnits,
|
|
787
|
+
unit: value.unit,
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_ORIGINAL_KEYS)) {
|
|
791
|
+
return fail('original_effect_invalid');
|
|
792
|
+
}
|
|
793
|
+
if (!ORIGINAL_KEYS.size || [...ORIGINAL_KEYS].some((key) => !same(verified[key], value.original[key]))
|
|
794
|
+
|| verified.evidence_digest !== value.original.terminal_evidence_digest) {
|
|
795
|
+
return fail('original_effect_binding_mismatch');
|
|
796
|
+
}
|
|
797
|
+
const at = new Date(current).toISOString();
|
|
798
|
+
const state: DataRecord = {
|
|
799
|
+
version: REMEDY_PROGRAM_VERSION,
|
|
800
|
+
instance_id: value.instanceId,
|
|
801
|
+
tenant_id: value.tenantId,
|
|
802
|
+
environment: value.environment,
|
|
803
|
+
audience: value.audience,
|
|
804
|
+
status: value.original.outcome === 'executed' ? 'effect_executed' : 'effect_indeterminate',
|
|
805
|
+
revision: 0,
|
|
806
|
+
created_at: at,
|
|
807
|
+
updated_at: at,
|
|
808
|
+
original: withoutOk(verified as DataRecord),
|
|
809
|
+
remedy_profile_digest: value.remedyProfileDigest,
|
|
810
|
+
destination_binding_digest: value.destinationBindingDigest,
|
|
811
|
+
max_remedy_units: value.maxRemedyUnits,
|
|
812
|
+
unit: value.unit,
|
|
813
|
+
remedied_units: 0,
|
|
814
|
+
remaining_units: value.maxRemedyUnits,
|
|
815
|
+
used_evidence_ids: [],
|
|
816
|
+
used_evidence_digests: [value.original.terminal_evidence_digest],
|
|
817
|
+
original_reconciliation: null,
|
|
818
|
+
revocation: null,
|
|
819
|
+
dispute: null,
|
|
820
|
+
active_remedy: null,
|
|
821
|
+
remedies: [],
|
|
822
|
+
resolution: null,
|
|
823
|
+
create_request_digest: requestDigest,
|
|
824
|
+
};
|
|
825
|
+
if (!validState(state, value.tenantId, value.instanceId)) return fail('original_effect_invalid');
|
|
826
|
+
try {
|
|
827
|
+
const result = normalizeStoreResult(
|
|
828
|
+
await store.create(snapshot(state, 'initial remedy state') as RemedyProgramState),
|
|
829
|
+
value.tenantId,
|
|
830
|
+
value.instanceId,
|
|
831
|
+
state,
|
|
832
|
+
);
|
|
833
|
+
if (result.reason !== 'instance_exists') return result;
|
|
834
|
+
const raced = await load(value.tenantId, value.instanceId, true);
|
|
835
|
+
return raced.ok && raced.state!.create_request_digest === requestDigest
|
|
836
|
+
? pass({ ok: true, idempotent: true, state: raced.state }) as RemedyProgramResult
|
|
837
|
+
: fail('instance_exists');
|
|
838
|
+
} catch {
|
|
839
|
+
return fail('store_unavailable');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function reconcileOriginalEffect(input: unknown): Promise<RemedyProgramResult> {
|
|
844
|
+
const parsed = operationInput(
|
|
845
|
+
input,
|
|
846
|
+
ORIGINAL_RECONCILIATION_INPUT_KEYS,
|
|
847
|
+
'original_reconciliation_input_invalid',
|
|
848
|
+
);
|
|
849
|
+
if (isFailure(parsed)) return parsed;
|
|
850
|
+
const value = parsed as Readonly<DataRecord>;
|
|
851
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
852
|
+
|| !['executed', 'proved_no_effect'].includes(value.outcome)
|
|
853
|
+
|| !exactKeys(value.evidence, ORIGINAL_RECONCILIATION_EVIDENCE_KEYS)
|
|
854
|
+
|| !validEvidencePair(value.evidence.evidence_id, value.evidence.evidence_digest)
|
|
855
|
+
|| !Number.isFinite(instant(value.evidence.observed_at))) {
|
|
856
|
+
return fail('original_reconciliation_input_invalid');
|
|
857
|
+
}
|
|
858
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
859
|
+
if (!loaded.ok) return loaded;
|
|
860
|
+
const state = loaded.state as DataRecord;
|
|
861
|
+
const requestDigest = digest(value);
|
|
862
|
+
if (state.original.outcome !== 'indeterminate') return fail('original_effect_already_conclusive');
|
|
863
|
+
if (state.original_reconciliation !== null) {
|
|
864
|
+
return state.original_reconciliation.request_digest === requestDigest
|
|
865
|
+
? pass({ ok: true, idempotent: true, state }) as RemedyProgramResult
|
|
866
|
+
: fail('original_reconciliation_conflict');
|
|
867
|
+
}
|
|
868
|
+
if (!['effect_indeterminate', 'disputed'].includes(state.status)
|
|
869
|
+
|| state.active_remedy !== null || state.remedies.length !== 0) {
|
|
870
|
+
return fail('original_reconciliation_unavailable');
|
|
871
|
+
}
|
|
872
|
+
if (evidenceUsed(state, value.evidence.evidence_id, value.evidence.evidence_digest)) {
|
|
873
|
+
return fail('evidence_replayed');
|
|
874
|
+
}
|
|
875
|
+
const at = transitionTime(state);
|
|
876
|
+
if (typeof at !== 'number') return at;
|
|
877
|
+
const observedAt = instant(value.evidence.observed_at);
|
|
878
|
+
if (observedAt < instant(state.original.occurred_at) || observedAt > at) {
|
|
879
|
+
return fail('original_reconciliation_time_invalid');
|
|
880
|
+
}
|
|
881
|
+
const verified = await invoke(verifyOriginalReconciliation, {
|
|
882
|
+
evidence: clone(value.evidence), outcome: value.outcome,
|
|
883
|
+
expected: expectedContext(state),
|
|
884
|
+
});
|
|
885
|
+
if (!verified || verified.ok !== true
|
|
886
|
+
|| !exactKeys(verified, VERIFIED_ORIGINAL_RECONCILIATION_KEYS)) {
|
|
887
|
+
return fail('original_reconciliation_invalid');
|
|
888
|
+
}
|
|
889
|
+
if (verified.evidence_id !== value.evidence.evidence_id
|
|
890
|
+
|| verified.evidence_digest !== value.evidence.evidence_digest
|
|
891
|
+
|| verified.original_operation_id !== state.original.operation_id
|
|
892
|
+
|| verified.original_action_digest !== state.original.action_digest
|
|
893
|
+
|| verified.terminal_evidence_digest !== state.original.terminal_evidence_digest
|
|
894
|
+
|| verified.outcome !== value.outcome
|
|
895
|
+
|| verified.observed_at !== value.evidence.observed_at) {
|
|
896
|
+
return fail('original_reconciliation_binding_mismatch');
|
|
897
|
+
}
|
|
898
|
+
const next = touch(state, at);
|
|
899
|
+
next.original_reconciliation = {
|
|
900
|
+
...withoutOk(verified as DataRecord), request_digest: requestDigest,
|
|
901
|
+
};
|
|
902
|
+
next.status = value.outcome === 'executed'
|
|
903
|
+
? (state.dispute === null ? 'effect_executed' : 'disputed')
|
|
904
|
+
: 'original_proved_no_effect';
|
|
905
|
+
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
906
|
+
const committed = await commit(state, next);
|
|
907
|
+
return committed.ok ? pass({ ok: true, state: committed.state }) as RemedyProgramResult : committed;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function recordRevocation(input: unknown): Promise<RemedyProgramResult> {
|
|
911
|
+
const parsed = operationInput(input, REVOCATION_INPUT_KEYS, 'revocation_input_invalid');
|
|
912
|
+
if (isFailure(parsed)) return parsed;
|
|
913
|
+
const value = parsed as Readonly<DataRecord>;
|
|
914
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
915
|
+
|| !exactKeys(value.evidence, REVOCATION_EVIDENCE_KEYS)
|
|
916
|
+
|| !validEvidencePair(value.evidence.id, value.evidence.digest)) {
|
|
917
|
+
return fail('revocation_input_invalid');
|
|
918
|
+
}
|
|
919
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
920
|
+
if (!loaded.ok) return loaded;
|
|
921
|
+
const state = loaded.state as DataRecord;
|
|
922
|
+
const requestDigest = digest(value);
|
|
923
|
+
if (state.revocation !== null) {
|
|
924
|
+
return state.revocation.request_digest === requestDigest
|
|
925
|
+
? pass({ ok: true, idempotent: true, code: 'late_revocation_recorded', state }) as RemedyProgramResult
|
|
926
|
+
: fail('revocation_already_recorded');
|
|
927
|
+
}
|
|
928
|
+
if (evidenceUsed(state, value.evidence.id, value.evidence.digest)) return fail('evidence_replayed');
|
|
929
|
+
const at = transitionTime(state);
|
|
930
|
+
if (typeof at !== 'number') return at;
|
|
931
|
+
const verified = await invoke(verifyRevocation, {
|
|
932
|
+
evidence: clone(value.evidence), expected: expectedContext(state),
|
|
933
|
+
});
|
|
934
|
+
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_REVOCATION_KEYS)) {
|
|
935
|
+
return fail('revocation_invalid');
|
|
936
|
+
}
|
|
937
|
+
if (verified.evidence_id !== value.evidence.id
|
|
938
|
+
|| verified.evidence_digest !== value.evidence.digest
|
|
939
|
+
|| verified.target_operation_id !== state.original.operation_id
|
|
940
|
+
|| verified.action_digest !== state.original.action_digest) {
|
|
941
|
+
return fail('revocation_binding_mismatch');
|
|
942
|
+
}
|
|
943
|
+
const revokedAt = instant(verified.revoked_at);
|
|
944
|
+
if (!validContext(verified.authority_id) || !Number.isFinite(revokedAt)
|
|
945
|
+
|| revokedAt < instant(state.original.occurred_at) || revokedAt > at) {
|
|
946
|
+
return fail('revocation_time_invalid');
|
|
947
|
+
}
|
|
948
|
+
const next = touch(state, at);
|
|
949
|
+
next.revocation = {
|
|
950
|
+
...withoutOk(verified as DataRecord),
|
|
951
|
+
effect: 'future_authority_only',
|
|
952
|
+
request_digest: requestDigest,
|
|
953
|
+
};
|
|
954
|
+
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
955
|
+
const committed = await commit(state, next);
|
|
956
|
+
return committed.ok
|
|
957
|
+
? pass({ ok: true, code: 'late_revocation_recorded', state: committed.state }) as RemedyProgramResult
|
|
958
|
+
: committed;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function openDispute(input: unknown): Promise<RemedyProgramResult> {
|
|
962
|
+
const parsed = operationInput(input, DISPUTE_INPUT_KEYS, 'dispute_input_invalid');
|
|
963
|
+
if (isFailure(parsed)) return parsed;
|
|
964
|
+
const value = parsed as Readonly<DataRecord>;
|
|
965
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
966
|
+
|| !exactKeys(value.dispute, DISPUTE_KEYS)
|
|
967
|
+
|| !validId(value.dispute.dispute_id)
|
|
968
|
+
|| !validEvidencePair(value.dispute.evidence_id, value.dispute.evidence_digest)
|
|
969
|
+
|| !validContext(value.dispute.challenger_id)
|
|
970
|
+
|| !Number.isSafeInteger(value.dispute.requested_units) || value.dispute.requested_units < 1
|
|
971
|
+
|| !Number.isFinite(instant(value.dispute.opened_at))) {
|
|
972
|
+
return fail('dispute_input_invalid');
|
|
973
|
+
}
|
|
974
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
975
|
+
if (!loaded.ok) return loaded;
|
|
976
|
+
const state = loaded.state as DataRecord;
|
|
977
|
+
const requestDigest = digest(value);
|
|
978
|
+
if (state.dispute !== null) {
|
|
979
|
+
return state.dispute.request_digest === requestDigest
|
|
980
|
+
? pass({ ok: true, idempotent: true, state }) as RemedyProgramResult
|
|
981
|
+
: fail('dispute_already_open');
|
|
982
|
+
}
|
|
983
|
+
if (!['effect_executed', 'effect_indeterminate'].includes(state.status)) {
|
|
984
|
+
return fail('remedy_case_unavailable');
|
|
985
|
+
}
|
|
986
|
+
if (evidenceUsed(state, value.dispute.evidence_id, value.dispute.evidence_digest)) {
|
|
987
|
+
return fail('evidence_replayed');
|
|
988
|
+
}
|
|
989
|
+
const at = transitionTime(state);
|
|
990
|
+
if (typeof at !== 'number') return at;
|
|
991
|
+
const openedAt = instant(value.dispute.opened_at);
|
|
992
|
+
if (openedAt < instant(state.original.occurred_at) || openedAt > at || at - openedAt > maxDisputeAgeMs) {
|
|
993
|
+
return fail('dispute_not_fresh');
|
|
994
|
+
}
|
|
995
|
+
if (value.dispute.requested_units > state.remaining_units) return fail('dispute_limit_exceeded');
|
|
996
|
+
const verified = await invoke(verifyDispute, {
|
|
997
|
+
dispute: clone(value.dispute), expected: expectedContext(state),
|
|
998
|
+
});
|
|
999
|
+
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_DISPUTE_KEYS)) {
|
|
1000
|
+
return fail('dispute_invalid');
|
|
1001
|
+
}
|
|
1002
|
+
if ([...DISPUTE_KEYS].some((key) => !same(verified[key], value.dispute[key]))
|
|
1003
|
+
|| verified.original_operation_id !== state.original.operation_id
|
|
1004
|
+
|| verified.original_action_digest !== state.original.action_digest) {
|
|
1005
|
+
return fail('dispute_binding_mismatch');
|
|
1006
|
+
}
|
|
1007
|
+
const next = touch(state, at);
|
|
1008
|
+
next.status = 'disputed';
|
|
1009
|
+
next.dispute = { ...withoutOk(verified as DataRecord), request_digest: requestDigest };
|
|
1010
|
+
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
1011
|
+
const committed = await commit(state, next);
|
|
1012
|
+
return committed.ok ? pass({ ok: true, state: committed.state }) as RemedyProgramResult : committed;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const allAttempts = (state: DataRecord): DataRecord[] => [
|
|
1016
|
+
...(state.active_remedy ? [state.active_remedy] : []), ...state.remedies,
|
|
1017
|
+
];
|
|
1018
|
+
|
|
1019
|
+
async function authorizeRemedy(input: unknown): Promise<RemedyProgramResult> {
|
|
1020
|
+
const parsed = operationInput(input, AUTHORIZATION_INPUT_KEYS, 'remedy_authorization_input_invalid');
|
|
1021
|
+
if (isFailure(parsed)) return parsed;
|
|
1022
|
+
const value = parsed as Readonly<DataRecord>;
|
|
1023
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
1024
|
+
|| !exactKeys(value.authorization, AUTHORIZATION_KEYS)
|
|
1025
|
+
|| !validEvidencePair(value.authorization.evidence_id, value.authorization.evidence_digest)
|
|
1026
|
+
|| !validId(value.authorization.remedy_operation_id)
|
|
1027
|
+
|| typeof value.authorization.remedy_caid !== 'string' || !CAID.test(value.authorization.remedy_caid)
|
|
1028
|
+
|| typeof value.authorization.remedy_action_digest !== 'string'
|
|
1029
|
+
|| !DIGEST.test(value.authorization.remedy_action_digest)
|
|
1030
|
+
|| !Number.isSafeInteger(value.authorization.units) || value.authorization.units < 1
|
|
1031
|
+
|| !Number.isFinite(instant(value.authorization.authorized_at))) {
|
|
1032
|
+
return fail('remedy_authorization_input_invalid');
|
|
1033
|
+
}
|
|
1034
|
+
if (!validRemedyOwner(value.authorization)) return fail('remedy_owner_invalid');
|
|
1035
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
1036
|
+
if (!loaded.ok) return loaded;
|
|
1037
|
+
const state = loaded.state as DataRecord;
|
|
1038
|
+
const requestDigest = digest(value);
|
|
1039
|
+
if (state.status === 'original_proved_no_effect') return fail('remedy_case_terminal');
|
|
1040
|
+
if (state.original.outcome === 'indeterminate'
|
|
1041
|
+
&& state.original_reconciliation?.outcome !== 'executed') {
|
|
1042
|
+
return fail('original_effect_indeterminate');
|
|
1043
|
+
}
|
|
1044
|
+
const priorOperation = allAttempts(state).find((attempt) => (
|
|
1045
|
+
attempt.remedy_operation_id === value.authorization.remedy_operation_id
|
|
1046
|
+
));
|
|
1047
|
+
if (priorOperation) {
|
|
1048
|
+
return priorOperation.request_digest === requestDigest
|
|
1049
|
+
? pass({ ok: true, idempotent: true, state }) as RemedyProgramResult
|
|
1050
|
+
: fail('remedy_operation_replayed');
|
|
1051
|
+
}
|
|
1052
|
+
if (state.remaining_units === 0) return fail('remedy_limit_exhausted');
|
|
1053
|
+
if (['remedied', 'resolved_no_remedy'].includes(state.status)) return fail('remedy_case_terminal');
|
|
1054
|
+
if (state.active_remedy !== null) {
|
|
1055
|
+
return state.status === 'remedy_indeterminate'
|
|
1056
|
+
? fail('remedy_indeterminate') : fail('remedy_already_active');
|
|
1057
|
+
}
|
|
1058
|
+
if (!['disputed', 'partially_remedied'].includes(state.status) || state.dispute === null) {
|
|
1059
|
+
return fail('remedy_case_unavailable');
|
|
1060
|
+
}
|
|
1061
|
+
if (evidenceUsed(state, value.authorization.evidence_id, value.authorization.evidence_digest)) {
|
|
1062
|
+
return fail('evidence_replayed');
|
|
1063
|
+
}
|
|
1064
|
+
const at = transitionTime(state);
|
|
1065
|
+
if (typeof at !== 'number') return at;
|
|
1066
|
+
if (instant(value.authorization.authorized_at) > at
|
|
1067
|
+
|| instant(value.authorization.authorized_at) < instant(state.dispute.opened_at)) {
|
|
1068
|
+
return fail('remedy_authorization_time_invalid');
|
|
1069
|
+
}
|
|
1070
|
+
const disputeRemaining = state.dispute.requested_units - state.remedied_units;
|
|
1071
|
+
if (value.authorization.units > state.remaining_units || value.authorization.units > disputeRemaining) {
|
|
1072
|
+
return fail('remedy_limit_exceeded');
|
|
1073
|
+
}
|
|
1074
|
+
if (value.authorization.remedy_operation_id === state.original.operation_id
|
|
1075
|
+
|| value.authorization.remedy_action_digest === state.original.action_digest) {
|
|
1076
|
+
return fail('remedy_must_be_compensating');
|
|
1077
|
+
}
|
|
1078
|
+
const verified = await invoke(verifyRemedyAuthorization, {
|
|
1079
|
+
authorization: clone(value.authorization), expected: expectedContext(state),
|
|
1080
|
+
});
|
|
1081
|
+
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_AUTHORIZATION_KEYS)) {
|
|
1082
|
+
return fail('remedy_authorization_invalid');
|
|
1083
|
+
}
|
|
1084
|
+
if (!validRemedyOwner(verified as DataRecord)) {
|
|
1085
|
+
return fail('remedy_owner_invalid');
|
|
1086
|
+
}
|
|
1087
|
+
if ([...AUTHORIZATION_KEYS].some((key) => !same(verified[key], value.authorization[key]))
|
|
1088
|
+
|| verified.dispute_id !== state.dispute.dispute_id
|
|
1089
|
+
|| verified.original_operation_id !== state.original.operation_id
|
|
1090
|
+
|| verified.destination_binding_digest !== state.destination_binding_digest
|
|
1091
|
+
|| verified.unit !== state.unit) {
|
|
1092
|
+
return fail('remedy_authorization_binding_mismatch');
|
|
1093
|
+
}
|
|
1094
|
+
const next = touch(state, at);
|
|
1095
|
+
next.status = 'remedy_authorized';
|
|
1096
|
+
next.active_remedy = {
|
|
1097
|
+
...withoutOk(verified as DataRecord),
|
|
1098
|
+
request_digest: requestDigest,
|
|
1099
|
+
status: 'authorized',
|
|
1100
|
+
claim_token_digest: null,
|
|
1101
|
+
claimed_at: null,
|
|
1102
|
+
claim_request_digest: null,
|
|
1103
|
+
outcome: null,
|
|
1104
|
+
outcome_evidence: null,
|
|
1105
|
+
finalize_request_digest: null,
|
|
1106
|
+
reconciliation: null,
|
|
1107
|
+
reconcile_request_digest: null,
|
|
1108
|
+
};
|
|
1109
|
+
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
1110
|
+
const committed = await commit(state, next);
|
|
1111
|
+
return committed.ok ? pass({ ok: true, state: committed.state }) as RemedyProgramResult : committed;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function claimRemedy(input: unknown): Promise<RemedyProgramResult> {
|
|
1115
|
+
const parsed = operationInput(input, CLAIM_KEYS, 'remedy_claim_input_invalid');
|
|
1116
|
+
if (isFailure(parsed)) return parsed;
|
|
1117
|
+
const value = parsed as Readonly<DataRecord>;
|
|
1118
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
1119
|
+
|| !validId(value.remedyOperationId)
|
|
1120
|
+
|| !validContext(value.claimToken) || Buffer.byteLength(value.claimToken, 'utf8') > 256) {
|
|
1121
|
+
return fail('remedy_claim_input_invalid');
|
|
1122
|
+
}
|
|
1123
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
1124
|
+
if (!loaded.ok) return loaded;
|
|
1125
|
+
const state = loaded.state as DataRecord;
|
|
1126
|
+
if (state.status === 'original_proved_no_effect') return fail('remedy_case_terminal');
|
|
1127
|
+
if (state.original.outcome === 'indeterminate'
|
|
1128
|
+
&& state.original_reconciliation?.outcome !== 'executed') {
|
|
1129
|
+
return fail('original_effect_indeterminate');
|
|
1130
|
+
}
|
|
1131
|
+
const requestDigest = digest(value);
|
|
1132
|
+
const prior = allAttempts(state).find((attempt) => attempt.remedy_operation_id === value.remedyOperationId);
|
|
1133
|
+
if (prior?.claim_request_digest === requestDigest) {
|
|
1134
|
+
if (state.status === 'remedy_indeterminate' && state.active_remedy === prior) {
|
|
1135
|
+
return fail('remedy_indeterminate');
|
|
1136
|
+
}
|
|
1137
|
+
return pass({ ok: true, idempotent: true, state }) as RemedyProgramResult;
|
|
1138
|
+
}
|
|
1139
|
+
if (state.status === 'remedy_indeterminate') return fail('remedy_indeterminate');
|
|
1140
|
+
if (!state.active_remedy) return prior ? fail('remedy_already_finalized') : fail('remedy_not_authorized');
|
|
1141
|
+
if (state.active_remedy.remedy_operation_id !== value.remedyOperationId) {
|
|
1142
|
+
return fail('remedy_operation_mismatch');
|
|
1143
|
+
}
|
|
1144
|
+
if (state.active_remedy.status === 'claimed') return fail('remedy_claim_owned');
|
|
1145
|
+
if (state.active_remedy.status !== 'authorized' || state.status !== 'remedy_authorized') {
|
|
1146
|
+
return fail('remedy_not_authorized');
|
|
1147
|
+
}
|
|
1148
|
+
const at = transitionTime(state);
|
|
1149
|
+
if (typeof at !== 'number') return at;
|
|
1150
|
+
const next = touch(state, at);
|
|
1151
|
+
next.status = 'remedy_claimed';
|
|
1152
|
+
next.active_remedy.status = 'claimed';
|
|
1153
|
+
next.active_remedy.claim_token_digest = tokenDigest(value.claimToken);
|
|
1154
|
+
next.active_remedy.claimed_at = new Date(at).toISOString();
|
|
1155
|
+
next.active_remedy.claim_request_digest = requestDigest;
|
|
1156
|
+
const committed = await commit(state, next);
|
|
1157
|
+
return committed.ok ? pass({ ok: true, state: committed.state }) as RemedyProgramResult : committed;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const outcomeExpected = (state: DataRecord, attempt: DataRecord): DataRecord => ({
|
|
1161
|
+
...clone(expectedContext(state)),
|
|
1162
|
+
remedy_operation_id: attempt.remedy_operation_id,
|
|
1163
|
+
remedy_caid: attempt.remedy_caid,
|
|
1164
|
+
remedy_action_digest: attempt.remedy_action_digest,
|
|
1165
|
+
destination_binding_digest: attempt.destination_binding_digest,
|
|
1166
|
+
units: attempt.units,
|
|
1167
|
+
unit: attempt.unit,
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
const outcomeBound = (
|
|
1171
|
+
verified: DataRecord,
|
|
1172
|
+
value: DataRecord,
|
|
1173
|
+
attempt: DataRecord,
|
|
1174
|
+
): boolean => verified.evidence_id === value.evidence.evidence_id
|
|
1175
|
+
&& verified.evidence_digest === value.evidence.evidence_digest
|
|
1176
|
+
&& verified.remedy_operation_id === attempt.remedy_operation_id
|
|
1177
|
+
&& verified.remedy_action_digest === attempt.remedy_action_digest
|
|
1178
|
+
&& verified.destination_binding_digest === attempt.destination_binding_digest
|
|
1179
|
+
&& verified.units === attempt.units && verified.unit === attempt.unit
|
|
1180
|
+
&& verified.outcome === value.outcome
|
|
1181
|
+
&& verified.observed_at === value.evidence.observed_at;
|
|
1182
|
+
|
|
1183
|
+
async function finalizeRemedy(input: unknown): Promise<RemedyProgramResult> {
|
|
1184
|
+
const parsed = operationInput(input, FINALIZE_KEYS, 'remedy_outcome_input_invalid');
|
|
1185
|
+
if (isFailure(parsed)) return parsed;
|
|
1186
|
+
const value = parsed as Readonly<DataRecord>;
|
|
1187
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
1188
|
+
|| !validId(value.remedyOperationId)
|
|
1189
|
+
|| !validContext(value.claimToken) || Buffer.byteLength(value.claimToken, 'utf8') > 256
|
|
1190
|
+
|| !['executed', 'proved_no_effect', 'indeterminate'].includes(value.outcome)
|
|
1191
|
+
|| !exactKeys(value.evidence, OUTCOME_EVIDENCE_KEYS)
|
|
1192
|
+
|| !validEvidencePair(value.evidence.evidence_id, value.evidence.evidence_digest)
|
|
1193
|
+
|| !Number.isFinite(instant(value.evidence.observed_at))) {
|
|
1194
|
+
return fail('remedy_outcome_input_invalid');
|
|
1195
|
+
}
|
|
1196
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
1197
|
+
if (!loaded.ok) return loaded;
|
|
1198
|
+
const state = loaded.state as DataRecord;
|
|
1199
|
+
const requestDigest = digest(value);
|
|
1200
|
+
const prior = allAttempts(state).find((attempt) => attempt.remedy_operation_id === value.remedyOperationId);
|
|
1201
|
+
if (prior?.finalize_request_digest === requestDigest) {
|
|
1202
|
+
return pass({ ok: true, idempotent: true, state }) as RemedyProgramResult;
|
|
1203
|
+
}
|
|
1204
|
+
if (state.status === 'remedy_indeterminate') return fail('remedy_indeterminate');
|
|
1205
|
+
if (!state.active_remedy) return prior ? fail('remedy_already_finalized') : fail('remedy_not_authorized');
|
|
1206
|
+
const attempt = state.active_remedy;
|
|
1207
|
+
if (attempt.remedy_operation_id !== value.remedyOperationId) return fail('remedy_operation_mismatch');
|
|
1208
|
+
if (attempt.status !== 'claimed' || state.status !== 'remedy_claimed') return fail('remedy_not_claimed');
|
|
1209
|
+
if (attempt.claim_token_digest !== tokenDigest(value.claimToken)) return fail('remedy_claim_owned');
|
|
1210
|
+
if (evidenceUsed(state, value.evidence.evidence_id, value.evidence.evidence_digest)) {
|
|
1211
|
+
return fail('evidence_replayed');
|
|
1212
|
+
}
|
|
1213
|
+
const at = transitionTime(state);
|
|
1214
|
+
if (typeof at !== 'number') return at;
|
|
1215
|
+
if (instant(value.evidence.observed_at) < instant(attempt.authorized_at)
|
|
1216
|
+
|| instant(value.evidence.observed_at) > at) return fail('remedy_outcome_time_invalid');
|
|
1217
|
+
const verified = await invoke(verifyRemedyOutcome, {
|
|
1218
|
+
evidence: clone(value.evidence), outcome: value.outcome,
|
|
1219
|
+
expected: outcomeExpected(state, attempt), reconciliation: false,
|
|
1220
|
+
});
|
|
1221
|
+
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_OUTCOME_KEYS)) {
|
|
1222
|
+
return fail('remedy_outcome_invalid');
|
|
1223
|
+
}
|
|
1224
|
+
if (!outcomeBound(verified as DataRecord, value as DataRecord, attempt)) {
|
|
1225
|
+
return fail('remedy_outcome_binding_mismatch');
|
|
1226
|
+
}
|
|
1227
|
+
if (value.outcome === 'executed' && attempt.units > state.remaining_units) {
|
|
1228
|
+
return fail('remedy_limit_exceeded');
|
|
1229
|
+
}
|
|
1230
|
+
const next = touch(state, at);
|
|
1231
|
+
const completed = next.active_remedy;
|
|
1232
|
+
completed.status = value.outcome;
|
|
1233
|
+
completed.outcome = value.outcome;
|
|
1234
|
+
completed.outcome_evidence = withoutOk(verified as DataRecord);
|
|
1235
|
+
completed.finalize_request_digest = requestDigest;
|
|
1236
|
+
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
1237
|
+
if (value.outcome === 'indeterminate') {
|
|
1238
|
+
next.status = 'remedy_indeterminate';
|
|
1239
|
+
} else {
|
|
1240
|
+
next.active_remedy = null;
|
|
1241
|
+
next.remedies.push(completed);
|
|
1242
|
+
if (value.outcome === 'executed') {
|
|
1243
|
+
next.remedied_units += completed.units;
|
|
1244
|
+
next.remaining_units -= completed.units;
|
|
1245
|
+
next.status = next.remaining_units === 0 ? 'remedied' : 'partially_remedied';
|
|
1246
|
+
} else {
|
|
1247
|
+
next.status = 'disputed';
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const committed = await commit(state, next);
|
|
1251
|
+
return committed.ok ? pass({ ok: true, state: committed.state }) as RemedyProgramResult : committed;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function reconcileRemedy(input: unknown): Promise<RemedyProgramResult> {
|
|
1255
|
+
const parsed = operationInput(input, RECONCILE_KEYS, 'remedy_reconciliation_input_invalid');
|
|
1256
|
+
if (isFailure(parsed)) return parsed;
|
|
1257
|
+
const value = parsed as Readonly<DataRecord>;
|
|
1258
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
1259
|
+
|| !validId(value.remedyOperationId)
|
|
1260
|
+
|| !['executed', 'proved_no_effect'].includes(value.outcome)
|
|
1261
|
+
|| !exactKeys(value.evidence, OUTCOME_EVIDENCE_KEYS)
|
|
1262
|
+
|| !validEvidencePair(value.evidence.evidence_id, value.evidence.evidence_digest)
|
|
1263
|
+
|| !Number.isFinite(instant(value.evidence.observed_at))) {
|
|
1264
|
+
return fail('remedy_reconciliation_input_invalid');
|
|
1265
|
+
}
|
|
1266
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
1267
|
+
if (!loaded.ok) return loaded;
|
|
1268
|
+
const state = loaded.state as DataRecord;
|
|
1269
|
+
const requestDigest = digest(value);
|
|
1270
|
+
const prior = allAttempts(state).find((attempt) => attempt.remedy_operation_id === value.remedyOperationId);
|
|
1271
|
+
if (prior?.reconcile_request_digest === requestDigest) {
|
|
1272
|
+
return pass({ ok: true, idempotent: true, state }) as RemedyProgramResult;
|
|
1273
|
+
}
|
|
1274
|
+
if (state.status !== 'remedy_indeterminate' || !state.active_remedy
|
|
1275
|
+
|| state.active_remedy.status !== 'indeterminate') {
|
|
1276
|
+
return prior ? fail('remedy_reconciliation_conflict') : fail('remedy_not_indeterminate');
|
|
1277
|
+
}
|
|
1278
|
+
const attempt = state.active_remedy;
|
|
1279
|
+
if (attempt.remedy_operation_id !== value.remedyOperationId) return fail('remedy_operation_mismatch');
|
|
1280
|
+
if (evidenceUsed(state, value.evidence.evidence_id, value.evidence.evidence_digest)) {
|
|
1281
|
+
return fail('evidence_replayed');
|
|
1282
|
+
}
|
|
1283
|
+
const at = transitionTime(state);
|
|
1284
|
+
if (typeof at !== 'number') return at;
|
|
1285
|
+
if (instant(value.evidence.observed_at) < instant(attempt.outcome_evidence.observed_at)
|
|
1286
|
+
|| instant(value.evidence.observed_at) > at) return fail('remedy_reconciliation_time_invalid');
|
|
1287
|
+
const verified = await invoke(verifyRemedyOutcome, {
|
|
1288
|
+
evidence: clone(value.evidence), outcome: value.outcome,
|
|
1289
|
+
expected: outcomeExpected(state, attempt), reconciliation: true,
|
|
1290
|
+
});
|
|
1291
|
+
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_OUTCOME_KEYS)) {
|
|
1292
|
+
return fail('remedy_reconciliation_invalid');
|
|
1293
|
+
}
|
|
1294
|
+
if (!outcomeBound(verified as DataRecord, value as DataRecord, attempt)) {
|
|
1295
|
+
return fail('remedy_reconciliation_binding_mismatch');
|
|
1296
|
+
}
|
|
1297
|
+
if (value.outcome === 'executed' && attempt.units > state.remaining_units) {
|
|
1298
|
+
return fail('remedy_limit_exceeded');
|
|
1299
|
+
}
|
|
1300
|
+
const next = touch(state, at);
|
|
1301
|
+
const completed = next.active_remedy;
|
|
1302
|
+
completed.status = value.outcome;
|
|
1303
|
+
completed.outcome = value.outcome;
|
|
1304
|
+
completed.reconciliation = withoutOk(verified as DataRecord);
|
|
1305
|
+
completed.reconcile_request_digest = requestDigest;
|
|
1306
|
+
next.active_remedy = null;
|
|
1307
|
+
next.remedies.push(completed);
|
|
1308
|
+
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
1309
|
+
if (value.outcome === 'executed') {
|
|
1310
|
+
next.remedied_units += completed.units;
|
|
1311
|
+
next.remaining_units -= completed.units;
|
|
1312
|
+
next.status = next.remaining_units === 0 ? 'remedied' : 'partially_remedied';
|
|
1313
|
+
} else {
|
|
1314
|
+
next.status = 'disputed';
|
|
1315
|
+
}
|
|
1316
|
+
const committed = await commit(state, next);
|
|
1317
|
+
return committed.ok ? pass({ ok: true, state: committed.state }) as RemedyProgramResult : committed;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function resolveDispute(input: unknown): Promise<RemedyProgramResult> {
|
|
1321
|
+
const parsed = operationInput(input, RESOLUTION_INPUT_KEYS, 'resolution_input_invalid');
|
|
1322
|
+
if (isFailure(parsed)) return parsed;
|
|
1323
|
+
const value = parsed as Readonly<DataRecord>;
|
|
1324
|
+
if (!validContext(value.tenantId) || !validId(value.instanceId)
|
|
1325
|
+
|| !exactKeys(value.resolution, RESOLUTION_KEYS)
|
|
1326
|
+
|| !validEvidencePair(value.resolution.evidence_id, value.resolution.evidence_digest)
|
|
1327
|
+
|| value.resolution.outcome !== 'no_remedy'
|
|
1328
|
+
|| !Number.isFinite(instant(value.resolution.resolved_at))) {
|
|
1329
|
+
return fail('resolution_input_invalid');
|
|
1330
|
+
}
|
|
1331
|
+
const loaded = await load(value.tenantId, value.instanceId);
|
|
1332
|
+
if (!loaded.ok) return loaded;
|
|
1333
|
+
const state = loaded.state as DataRecord;
|
|
1334
|
+
const requestDigest = digest(value);
|
|
1335
|
+
if (state.resolution !== null) {
|
|
1336
|
+
return state.resolution.request_digest === requestDigest
|
|
1337
|
+
? pass({ ok: true, idempotent: true, state }) as RemedyProgramResult
|
|
1338
|
+
: fail('remedy_case_terminal');
|
|
1339
|
+
}
|
|
1340
|
+
if (['remedied', 'resolved_no_remedy'].includes(state.status)) return fail('remedy_case_terminal');
|
|
1341
|
+
if (!['disputed', 'partially_remedied'].includes(state.status)
|
|
1342
|
+
|| state.dispute === null || state.active_remedy !== null) {
|
|
1343
|
+
return fail('resolution_unavailable');
|
|
1344
|
+
}
|
|
1345
|
+
if (typeof verifyResolution !== 'function') return fail('resolution_verifier_unavailable');
|
|
1346
|
+
if (evidenceUsed(state, value.resolution.evidence_id, value.resolution.evidence_digest)) {
|
|
1347
|
+
return fail('evidence_replayed');
|
|
1348
|
+
}
|
|
1349
|
+
const at = transitionTime(state);
|
|
1350
|
+
if (typeof at !== 'number') return at;
|
|
1351
|
+
if (instant(value.resolution.resolved_at) < instant(state.dispute.opened_at)
|
|
1352
|
+
|| instant(value.resolution.resolved_at) > at) return fail('resolution_time_invalid');
|
|
1353
|
+
const verified = await invoke(verifyResolution, {
|
|
1354
|
+
resolution: clone(value.resolution), expected: expectedContext(state),
|
|
1355
|
+
});
|
|
1356
|
+
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_RESOLUTION_KEYS)) {
|
|
1357
|
+
return fail('resolution_invalid');
|
|
1358
|
+
}
|
|
1359
|
+
if ([...RESOLUTION_KEYS].some((key) => !same(verified[key], value.resolution[key]))
|
|
1360
|
+
|| verified.dispute_id !== state.dispute.dispute_id) {
|
|
1361
|
+
return fail('resolution_binding_mismatch');
|
|
1362
|
+
}
|
|
1363
|
+
const next = touch(state, at);
|
|
1364
|
+
next.status = 'resolved_no_remedy';
|
|
1365
|
+
next.resolution = { ...withoutOk(verified as DataRecord), request_digest: requestDigest };
|
|
1366
|
+
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
1367
|
+
const committed = await commit(state, next);
|
|
1368
|
+
return committed.ok ? pass({ ok: true, state: committed.state }) as RemedyProgramResult : committed;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function status(input: unknown): Promise<RemedyProgramResult> {
|
|
1372
|
+
const parsed = operationInput(input, new Set(['tenantId', 'instanceId']), 'remedy_lookup_invalid');
|
|
1373
|
+
if (isFailure(parsed)) return parsed;
|
|
1374
|
+
const value = parsed as Readonly<DataRecord>;
|
|
1375
|
+
return load(value.tenantId, value.instanceId);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
return Object.freeze({
|
|
1379
|
+
create, status, recordRevocation, reconcileOriginalEffect, openDispute, authorizeRemedy,
|
|
1380
|
+
claimRemedy, finalizeRemedy, reconcileRemedy, resolveDispute,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
export default {
|
|
1385
|
+
REMEDY_PROGRAM_VERSION,
|
|
1386
|
+
createRemedyMemoryStore,
|
|
1387
|
+
createRemedyProgramKernel,
|
|
1388
|
+
};
|