segreant 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +116 -0
- package/LICENSING.md +62 -0
- package/README.md +315 -2
- package/baselines/lift-baselines.json +22 -0
- package/bin/publication-lock.mjs +748 -0
- package/bin/runtime-snapshot.mjs +266 -0
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +96 -0
- package/dist/alerts/detect.js +245 -0
- package/dist/alerts/notify.js +66 -0
- package/dist/alloc/apply.js +0 -0
- package/dist/alloc/epistemic.js +154 -0
- package/dist/alloc/exact.js +714 -0
- package/dist/alloc/rules.js +0 -0
- package/dist/billing/countermodels.js +123 -0
- package/dist/billing/epistemic.js +636 -0
- package/dist/billing/importer.js +75 -0
- package/dist/billing/mapping.js +239 -0
- package/dist/billing/openaiCosts.js +352 -0
- package/dist/billing/openaiCostsCoverage.js +108 -0
- package/dist/billing/readiness.js +68 -0
- package/dist/billing/reconcile.js +414 -0
- package/dist/billing/scope.js +68 -0
- package/dist/billing/types.js +180 -0
- package/dist/budget/allocate.js +100 -0
- package/dist/budget/capDecision.js +457 -0
- package/dist/budget/controlLock.js +71 -0
- package/dist/budget/enforceability.js +39 -0
- package/dist/budget/guard.js +313 -0
- package/dist/budget/onlineControl.js +404 -0
- package/dist/budget/recommend.js +153 -0
- package/dist/capital.js +220 -0
- package/dist/causal/assignment.js +179 -0
- package/dist/causal/design.js +161 -0
- package/dist/causal/epistemic.js +428 -0
- package/dist/causal/estimand.js +64 -0
- package/dist/causal/estimate.js +237 -0
- package/dist/causal/family.js +61 -0
- package/dist/causal/identity.js +18 -0
- package/dist/causal/inference-ledger.js +400 -0
- package/dist/causal/ledger.js +201 -0
- package/dist/causal/measurement.js +150 -0
- package/dist/causal/ope.js +297 -0
- package/dist/causal/precision.js +206 -0
- package/dist/causal/producer.js +774 -0
- package/dist/causal/protocol.js +962 -0
- package/dist/causal/qualification.js +260 -0
- package/dist/causal/records.js +416 -0
- package/dist/causal/registry.js +58 -0
- package/dist/causal/sequential.js +679 -0
- package/dist/causal/transport.js +138 -0
- package/dist/causal/types.js +10 -0
- package/dist/cli/allocCmd.js +340 -0
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +100 -0
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +1007 -0
- package/dist/cli/capitalCmd.js +65 -0
- package/dist/cli/causalCmd.js +340 -0
- package/dist/cli/connectCmd.js +466 -0
- package/dist/cli/controlCmd.js +341 -0
- package/dist/cli/diagnosticsCmd.js +41 -0
- package/dist/cli/economicCmd.js +384 -0
- package/dist/cli/egressCmd.js +248 -0
- package/dist/cli/evidenceCmd.js +157 -0
- package/dist/cli/featuresCmd.js +53 -0
- package/dist/cli/flags.js +128 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +657 -0
- package/dist/cli/launchCmd.js +90 -0
- package/dist/cli/marketCmd.js +178 -0
- package/dist/cli/opsCmd.js +335 -0
- package/dist/cli/outcomeCmd.js +120 -0
- package/dist/cli/packCmd.js +172 -0
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pluginCmd.js +145 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/retention.js +28 -0
- package/dist/cli/runCmd.js +591 -0
- package/dist/cli/showCmd.js +500 -0
- package/dist/cli/teamCmd.js +760 -0
- package/dist/cli/ui.js +128 -0
- package/dist/cli/valueCmd.js +1001 -0
- package/dist/cli.js +628 -0
- package/dist/config.js +677 -0
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +218 -0
- package/dist/connect/codex.js +320 -0
- package/dist/connect/connectors.js +285 -0
- package/dist/connect/importShared.js +283 -0
- package/dist/connect/opencode.js +214 -0
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/coverage.js +53 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +132 -0
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +997 -0
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +271 -0
- package/dist/dashboard/contracts.js +211 -0
- package/dist/dashboard/routes.js +2004 -0
- package/dist/dashboard/server.js +76 -0
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +109 -0
- package/dist/dashboard/shared-types.js +6 -0
- package/dist/dashboard/static.js +111 -0
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +138 -0
- package/dist/dashboard/web/app/components/drawer.js +284 -0
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +163 -0
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +1021 -0
- package/dist/dashboard/web/app/core/api.js +164 -0
- package/dist/dashboard/web/app/core/chain.js +40 -0
- package/dist/dashboard/web/app/core/claimLayers.js +392 -0
- package/dist/dashboard/web/app/core/claimTypes.js +222 -0
- package/dist/dashboard/web/app/core/dom.js +138 -0
- package/dist/dashboard/web/app/core/fmt.js +159 -0
- package/dist/dashboard/web/app/core/focus.js +10 -0
- package/dist/dashboard/web/app/core/generated-contract.js +211 -0
- package/dist/dashboard/web/app/core/generated-payload-contract.js +4579 -0
- package/dist/dashboard/web/app/core/generated-types.js +8 -0
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +337 -0
- package/dist/dashboard/web/app/core/signal.js +140 -0
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +362 -0
- package/dist/dashboard/web/app/views/allocation.js +278 -0
- package/dist/dashboard/web/app/views/control.js +249 -0
- package/dist/dashboard/web/app/views/data.js +136 -0
- package/dist/dashboard/web/app/views/evidence.js +553 -0
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +143 -0
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +80 -0
- package/dist/dashboard/web/app/views/value.js +534 -0
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +2151 -0
- package/dist/dashboard/web/index.html +51 -0
- package/dist/dashboard/web/styles/app.css +1336 -0
- package/dist/decision/assurance.js +425 -0
- package/dist/decision/control.js +336 -0
- package/dist/decision/countermodels.js +118 -0
- package/dist/decision/engine.js +382 -0
- package/dist/decision/epistemic.js +546 -0
- package/dist/demo/seed.js +741 -0
- package/dist/diagnostics.js +236 -0
- package/dist/economics/attribution.js +201 -0
- package/dist/economics/close.js +133 -0
- package/dist/economics/corrections.js +120 -0
- package/dist/economics/epistemic.js +164 -0
- package/dist/economics/events.js +294 -0
- package/dist/economics/fx.js +245 -0
- package/dist/economics/ledger.js +1516 -0
- package/dist/economics/money.js +177 -0
- package/dist/economics/rate.js +331 -0
- package/dist/economics/request.js +89 -0
- package/dist/economics/serialization.js +99 -0
- package/dist/egress/policy.js +192 -0
- package/dist/egress/proxyHealth.js +36 -0
- package/dist/egress/receipts.js +871 -0
- package/dist/egress/ruleValidation.js +127 -0
- package/dist/egress/transport.js +395 -0
- package/dist/epistemic/abstract.js +432 -0
- package/dist/epistemic/admissibility.js +213 -0
- package/dist/epistemic/assumption.js +134 -0
- package/dist/epistemic/claim-uses.js +202 -0
- package/dist/epistemic/claim.js +264 -0
- package/dist/epistemic/countermodel.js +278 -0
- package/dist/epistemic/dag.js +421 -0
- package/dist/epistemic/derivation.js +391 -0
- package/dist/epistemic/evidence.js +254 -0
- package/dist/epistemic/grain.js +82 -0
- package/dist/epistemic/issuance-map.js +260 -0
- package/dist/epistemic/ledger.js +1489 -0
- package/dist/epistemic/obligation.js +149 -0
- package/dist/epistemic/preservation.js +178 -0
- package/dist/epistemic/profile.js +61 -0
- package/dist/epistemic/revocation.js +65 -0
- package/dist/epistemic/scope.js +66 -0
- package/dist/epistemic/serialization.js +171 -0
- package/dist/epistemic/state.js +46 -0
- package/dist/epistemic/time.js +37 -0
- package/dist/epistemic/ux.js +104 -0
- package/dist/epistemic/witness.js +176 -0
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +36 -0
- package/dist/export/csv.js +52 -0
- package/dist/export/economic.js +105 -0
- package/dist/export/focusBilling.js +66 -0
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/completeness.js +78 -0
- package/dist/git/contribution.js +598 -0
- package/dist/git/correlate.js +286 -0
- package/dist/git/quality.js +467 -0
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/githubActionsEvidence.js +234 -0
- package/dist/guide.js +159 -0
- package/dist/judge/call.js +161 -0
- package/dist/judge/orchestrate.js +127 -0
- package/dist/judge/payload.js +47 -0
- package/dist/judge/tier.js +107 -0
- package/dist/judge/transcript.js +324 -0
- package/dist/market/consensus.js +181 -0
- package/dist/market/market.js +323 -0
- package/dist/market/refresh.js +119 -0
- package/dist/market/sources.js +236 -0
- package/dist/market/zip.js +98 -0
- package/dist/measurement/completeness.js +125 -0
- package/dist/measurement/model.js +113 -0
- package/dist/measurement/registry.js +166 -0
- package/dist/measurement/surrogate.js +325 -0
- package/dist/outcomes/contract.js +107 -0
- package/dist/outcomes/registry.js +259 -0
- package/dist/outcomes/work-unit.js +42 -0
- package/dist/pack/canonical.js +63 -0
- package/dist/pack/export.js +120 -0
- package/dist/pack/index.js +5 -0
- package/dist/pack/manifest.js +77 -0
- package/dist/pack/signature.js +69 -0
- package/dist/pack/types.js +455 -0
- package/dist/pack/verifier.js +275 -0
- package/dist/plans/detect.js +147 -0
- package/dist/plugins/contract.js +497 -0
- package/dist/plugins/host.js +333 -0
- package/dist/plugins/index.js +4 -0
- package/dist/plugins/intake.js +137 -0
- package/dist/plugins/invocation.js +158 -0
- package/dist/plugins/isolation.js +203 -0
- package/dist/proxy/server.js +891 -0
- package/dist/proxy/stream-proposals.js +249 -0
- package/dist/proxy/usage.js +213 -0
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/research/complexity/calibration.js +110 -0
- package/dist/research/complexity/lab.js +134 -0
- package/dist/research/complexity/profile.js +78 -0
- package/dist/research/economics/budgetRisk.js +100 -0
- package/dist/research/economics/capital.js +101 -0
- package/dist/research/economics/decisionLedger.js +103 -0
- package/dist/research/economics/decomposition.js +106 -0
- package/dist/research/economics/execution.js +42 -0
- package/dist/research/economics/frontier.js +113 -0
- package/dist/research/economics/metricSafety.js +74 -0
- package/dist/research/economics/offPolicy.js +120 -0
- package/dist/research/economics/promotion.js +85 -0
- package/dist/scan/knownApps.js +113 -0
- package/dist/scan/scan.js +220 -0
- package/dist/store/allocation.js +543 -0
- package/dist/store/backup.js +368 -0
- package/dist/store/billing.js +919 -0
- package/dist/store/causal.js +2547 -0
- package/dist/store/causalLineage.js +912 -0
- package/dist/store/causalProducer.js +462 -0
- package/dist/store/db.js +2726 -0
- package/dist/store/economicReadModel.js +270 -0
- package/dist/store/ope.js +67 -0
- package/dist/store/realization.js +379 -0
- package/dist/store/rows.js +52 -0
- package/dist/store/schema.js +2218 -0
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/team/ledger-evidence.js +82 -0
- package/dist/team/rollup.js +496 -0
- package/dist/team/standardize.js +115 -0
- package/dist/util/json.js +24 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/quiet.js +18 -0
- package/dist/util/resource-limits.js +113 -0
- package/dist/util/statements.js +61 -0
- package/dist/util/time.js +7 -0
- package/dist/util/transaction.js +68 -0
- package/dist/value/anytime.js +106 -0
- package/dist/value/characterization.js +77 -0
- package/dist/value/cohort.js +212 -0
- package/dist/value/drift.js +211 -0
- package/dist/value/epistemic.js +448 -0
- package/dist/value/frontier.js +513 -0
- package/dist/value/gates.js +276 -0
- package/dist/value/instrumentationSensitivity.js +76 -0
- package/dist/value/lenses.js +398 -0
- package/dist/value/lift.js +206 -0
- package/dist/value/liftBaseline.js +355 -0
- package/dist/value/liftEfficiency.js +68 -0
- package/dist/value/marginal.js +154 -0
- package/dist/value/proposals.js +263 -0
- package/dist/value/realization.js +1159 -0
- package/dist/value/receipt.js +222 -0
- package/dist/value/receiptReconciliation.js +117 -0
- package/dist/value/reliability.js +101 -0
- package/dist/value/report.js +338 -0
- package/dist/value/selfReported.js +224 -0
- package/dist/value/sourceDepth.js +25 -0
- package/dist/value/taskType.js +46 -0
- package/dist/value/timeReclaimed.js +90 -0
- package/dist/value/usage.js +281 -0
- package/dist/version.js +20 -0
- package/docs/ALLOCATION.md +263 -0
- package/docs/ARCHITECTURE.md +452 -0
- package/docs/BILLING-EVIDENCE-IMPORT.md +274 -0
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +220 -0
- package/docs/CAUSAL-EVIDENCE-PROTOCOL.md +295 -0
- package/docs/CAUSAL-PRODUCER-CONTRACT.md +128 -0
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/COMPATIBILITY.md +64 -0
- package/docs/DATA-BOUNDARIES.md +300 -0
- package/docs/EVIDENCE-PROVENANCE.md +40 -0
- package/docs/FAQ.md +275 -0
- package/docs/FOCUS-COMPATIBILITY.md +15 -0
- package/docs/GETTING-STARTED.md +236 -0
- package/docs/GUIDE.md +830 -0
- package/docs/INTEGRATIONS.md +233 -0
- package/docs/MARKET-SOURCES.md +142 -0
- package/docs/METHODOLOGY.md +166 -0
- package/docs/NEUTRALITY.md +95 -0
- package/docs/PRIVACY.md +51 -0
- package/docs/PROVIDER-RECONCILIATION.md +260 -0
- package/docs/README.md +52 -0
- package/docs/REAL-PROVIDER-RECONCILIATION-RUNBOOK.md +44 -0
- package/docs/RELEASE-GATE.md +682 -0
- package/docs/RELEASE-PROCESS.md +68 -0
- package/docs/RELIABILITY-PERFORMANCE.md +149 -0
- package/docs/RETURN-ON-INTELLIGENCE.md +808 -0
- package/docs/SUPPORT.md +37 -0
- package/docs/THE-STANDARD.md +235 -0
- package/docs/THREAT-MODEL.md +114 -0
- package/market/consensus-fits.json +1 -0
- package/market/snapshot.json +1 -0
- package/package.json +79 -4
- package/pricing/models.json +1400 -0
- package/standalone/segreantpack-verifier.d.mts +61 -0
- package/standalone/segreantpack-verifier.mjs +715 -0
- package/web/assets/brand/mark-256.png +0 -0
|
@@ -0,0 +1,2547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite persistence for local causal-study evidence.
|
|
3
|
+
*
|
|
4
|
+
* Rows are append-only after insertion. The store does not accept raw prompts
|
|
5
|
+
* or source text as first-class evidence; the canonical types use identifiers,
|
|
6
|
+
* hashes, and declared numerical outcomes only.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomFillSync } from 'node:crypto';
|
|
9
|
+
import { estimateCausalStudy } from '../causal/estimate.js';
|
|
10
|
+
import { openCausalInferenceLedger, reportCausalStudyEstimate, summarizeInferenceMultiplicity, validateCausalInferencePlan, } from '../causal/inference-ledger.js';
|
|
11
|
+
import { CAUSAL_PROTOCOL_VERSION } from '../causal/types.js';
|
|
12
|
+
import { decodeCausalExecutionV2, decodeCausalTerminalOutcomeV2, } from '../causal/records.js';
|
|
13
|
+
import { canonicalJson, sha256, isCausalIdentifier, verifyCausalEvent, verifyCommittedCausalProtocol, } from '../causal/protocol.js';
|
|
14
|
+
import { CAUSAL_LINEAGE_BINDING_NOT_PERSISTED, CAUSAL_LINEAGE_BINDING_INVALID, CAUSAL_LINEAGE_REALIZATION_IDENTITY_UNVERIFIED, causalLineageBindingsV2, validateCausalLineageBindingV2, } from './causalLineage.js';
|
|
15
|
+
import { causalV2SchemaComplete } from './schema.js';
|
|
16
|
+
class CausalLegacyInspectOnlyError extends Error {
|
|
17
|
+
code = 'CAUSAL_LEGACY_INSPECT_ONLY';
|
|
18
|
+
constructor(operation) {
|
|
19
|
+
super('CAUSAL_LEGACY_INSPECT_ONLY: retained version-1 causal evidence is inspect-only; cannot ' + operation);
|
|
20
|
+
this.name = 'CausalLegacyInspectOnlyError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A registered version-2 study that this build has no analysis path for.
|
|
25
|
+
*
|
|
26
|
+
* NOT THE SAME ANSWER AS "not found", and the difference was costing the
|
|
27
|
+
* operator the wrong investigation. `saveCausalAnalysis` refuses version 1 as
|
|
28
|
+
* inspect-only and then asks `causalStudyData`, which returns null for anything
|
|
29
|
+
* that is not version 1 — so a registered, inspectable, summarised version-2
|
|
30
|
+
* study came back as `causal study was not found`. The study is there. The
|
|
31
|
+
* PROJECTION is deferred, and a refusal has to name the reason it refuses.
|
|
32
|
+
*/
|
|
33
|
+
class CausalV2AnalysisDeferredError extends Error {
|
|
34
|
+
code = 'CAUSAL_V2_ANALYSIS_DEFERRED';
|
|
35
|
+
constructor(studyId) {
|
|
36
|
+
super('CAUSAL_V2_ANALYSIS_DEFERRED: causal study ' + studyId
|
|
37
|
+
+ ' is registered at protocol version 2 and this build has no version-1 analysis path for it; '
|
|
38
|
+
+ 'the study exists and the analysis projection is deferred');
|
|
39
|
+
this.name = 'CausalV2AnalysisDeferredError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function rejectLegacyMutation(protocol, operation) {
|
|
43
|
+
if (protocol.version === 1)
|
|
44
|
+
throw new CausalLegacyInspectOnlyError(operation);
|
|
45
|
+
}
|
|
46
|
+
function parseJson(raw, label) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error('stored causal ' + label + ' is malformed');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
class CausalProtocolIntegrityError extends Error {
|
|
55
|
+
code = 'CAUSAL_INTEGRITY_FAILURE';
|
|
56
|
+
constructor() {
|
|
57
|
+
super('CAUSAL_INTEGRITY_FAILURE: stored causal protocol failed integrity verification');
|
|
58
|
+
this.name = 'CausalProtocolIntegrityError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
class CausalProtocolValidationError extends Error {
|
|
62
|
+
code = 'CAUSAL_PROTOCOL_INVALID';
|
|
63
|
+
constructor() {
|
|
64
|
+
super('CAUSAL_PROTOCOL_INVALID: supplied causal protocol is invalid');
|
|
65
|
+
this.name = 'CausalProtocolValidationError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
class CausalProtocolConflictError extends Error {
|
|
69
|
+
code = 'CAUSAL_IMMUTABLE_CONFLICT';
|
|
70
|
+
constructor() {
|
|
71
|
+
super('CAUSAL_IMMUTABLE_CONFLICT: studyId is already committed with different immutable protocol content');
|
|
72
|
+
this.name = 'CausalProtocolConflictError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const V1_COMMITTED_PROTOCOL_KEYS = [
|
|
76
|
+
'type', 'version', 'studyId', 'createdAtMs', 'question', 'eligibility',
|
|
77
|
+
'arms', 'allocation', 'costOutcome', 'qualityOutcome', 'economicOutcome',
|
|
78
|
+
'analysis', 'lifecycle', 'committedAtMs', 'protocolHash',
|
|
79
|
+
];
|
|
80
|
+
function hasExactKeys(value, expected) {
|
|
81
|
+
if (!isRecord(value))
|
|
82
|
+
return false;
|
|
83
|
+
const actual = Object.keys(value).sort();
|
|
84
|
+
const wanted = [...expected].sort();
|
|
85
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The retained v1 verifier deliberately hashes a projected compatibility
|
|
89
|
+
* material. Store reads additionally require the exact historical schema so
|
|
90
|
+
* canonical-but-extended records cannot acquire public meaning by projection.
|
|
91
|
+
*/
|
|
92
|
+
function hasExactCommittedProtocolV1Shape(value) {
|
|
93
|
+
if (!hasExactKeys(value, V1_COMMITTED_PROTOCOL_KEYS))
|
|
94
|
+
return false;
|
|
95
|
+
if (!hasExactKeys(value.eligibility, ['cohortId', 'unitOfAssignment', 'contextSchemaId']))
|
|
96
|
+
return false;
|
|
97
|
+
if (!Array.isArray(value.arms)
|
|
98
|
+
|| !value.arms.every((arm) => hasExactKeys(arm, ['armId', 'role', 'executionPlanHash', 'providerId', 'modelId']))) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (!hasExactKeys(value.allocation, ['method', 'probabilityPerArm', 'blockSize']))
|
|
102
|
+
return false;
|
|
103
|
+
if (!hasExactKeys(value.costOutcome, ['metricId', 'boundsUsd', 'acceptedSourceClasses'])
|
|
104
|
+
|| !hasExactKeys(value.costOutcome.boundsUsd, ['low', 'high'])) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (!hasExactKeys(value.qualityOutcome, ['metricId', 'bounds', 'evidenceClass', 'nonInferiorityMargin'])
|
|
108
|
+
|| !hasExactKeys(value.qualityOutcome.bounds, ['low', 'high'])) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (value.economicOutcome !== null
|
|
112
|
+
&& (!hasExactKeys(value.economicOutcome, ['metricId', 'boundsUsd', 'evidenceClass', 'fullCostAccountingRequired'])
|
|
113
|
+
|| !hasExactKeys(value.economicOutcome.boundsUsd, ['low', 'high']))) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const analysisKeys = ['estimand', 'confidenceLevel', 'minCompletedPerArm', 'maxMissingFractionPerArm'];
|
|
117
|
+
if (isRecord(value.analysis) && Object.hasOwn(value.analysis, 'jointInference'))
|
|
118
|
+
analysisKeys.push('jointInference');
|
|
119
|
+
return hasExactKeys(value.analysis, analysisKeys);
|
|
120
|
+
}
|
|
121
|
+
const SQLITE_INT64_MIN = -9223372036854775808n;
|
|
122
|
+
const SQLITE_INT64_MAX = 9223372036854775807n;
|
|
123
|
+
const CANONICAL_SIGNED_DECIMAL = /^(?:0|-?[1-9][0-9]*)$/;
|
|
124
|
+
/**
|
|
125
|
+
* Decode SQLite's integer column without asking node:sqlite to materialize an
|
|
126
|
+
* untrusted 64-bit value as a JavaScript Number. Number materialization can
|
|
127
|
+
* throw for values outside the driver's safe range, which would bypass the
|
|
128
|
+
* redacted integrity boundary. The storage class and canonical decimal text
|
|
129
|
+
* are therefore the only physical timestamp inputs accepted here.
|
|
130
|
+
*/
|
|
131
|
+
function decodeStoredIntegerMs(storageClass, decimalText) {
|
|
132
|
+
if (storageClass !== 'integer'
|
|
133
|
+
|| typeof decimalText !== 'string'
|
|
134
|
+
|| !CANONICAL_SIGNED_DECIMAL.test(decimalText)) {
|
|
135
|
+
throw new Error('invalid committed timestamp storage');
|
|
136
|
+
}
|
|
137
|
+
let integer;
|
|
138
|
+
try {
|
|
139
|
+
integer = BigInt(decimalText);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new Error('invalid committed timestamp integer');
|
|
143
|
+
}
|
|
144
|
+
if (integer < SQLITE_INT64_MIN || integer > SQLITE_INT64_MAX) {
|
|
145
|
+
throw new Error('committed timestamp is outside SQLite signed64 range');
|
|
146
|
+
}
|
|
147
|
+
const value = Number(integer);
|
|
148
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
149
|
+
throw new Error('committed timestamp is outside JavaScript safe range');
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
function decodeStoredCommittedAtMs(row) {
|
|
154
|
+
return decodeStoredIntegerMs(row.committed_at_ms_type, row.committed_at_ms_text);
|
|
155
|
+
}
|
|
156
|
+
function decodeStoredAnalysisAtMs(row) {
|
|
157
|
+
if (row.analysis_id === null || row.analysis_id === undefined)
|
|
158
|
+
return null;
|
|
159
|
+
try {
|
|
160
|
+
return decodeStoredIntegerMs(row.analysis_at_type, row.analysis_at_text);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
throw new CausalProtocolIntegrityError();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function decodeSuppliedProtocol(protocolValue) {
|
|
167
|
+
const validationErrors = verifyCommittedCausalProtocol(protocolValue);
|
|
168
|
+
if (validationErrors.length > 0)
|
|
169
|
+
throw new CausalProtocolValidationError();
|
|
170
|
+
if (isRecord(protocolValue)
|
|
171
|
+
&& protocolValue.version === CAUSAL_PROTOCOL_VERSION
|
|
172
|
+
&& !hasExactCommittedProtocolV1Shape(protocolValue)) {
|
|
173
|
+
throw new CausalProtocolValidationError();
|
|
174
|
+
}
|
|
175
|
+
return protocolValue;
|
|
176
|
+
}
|
|
177
|
+
const STORED_PROTOCOL_SELECT = 'SELECT study_id, protocol_hash, typeof(committed_at_ms) AS committed_at_ms_type, ' +
|
|
178
|
+
'CAST(committed_at_ms AS TEXT) AS committed_at_ms_text, protocol_json ';
|
|
179
|
+
/**
|
|
180
|
+
* Authenticate the physical row, exact JSON schema, semantic commitment, and
|
|
181
|
+
* canonical bytes as one boundary. Only an exact valid v2 row may be hidden by
|
|
182
|
+
* retained-v1 readers; every other defect is a typed, redacted integrity error.
|
|
183
|
+
*/
|
|
184
|
+
function decodeStoredProtocolRow(row) {
|
|
185
|
+
try {
|
|
186
|
+
if (typeof row.protocol_json !== 'string')
|
|
187
|
+
throw new Error('invalid protocol JSON storage class');
|
|
188
|
+
const decoded = JSON.parse(row.protocol_json);
|
|
189
|
+
if (!isRecord(decoded))
|
|
190
|
+
throw new Error('invalid protocol root');
|
|
191
|
+
let result;
|
|
192
|
+
if (decoded.version === 1) {
|
|
193
|
+
if (!hasExactCommittedProtocolV1Shape(decoded))
|
|
194
|
+
throw new Error('invalid v1 protocol schema');
|
|
195
|
+
if (verifyCommittedCausalProtocol(decoded).length > 0)
|
|
196
|
+
throw new Error('invalid v1 protocol commitment');
|
|
197
|
+
result = { version: 1, protocol: decoded };
|
|
198
|
+
}
|
|
199
|
+
else if (decoded.version === 2) {
|
|
200
|
+
if (verifyCommittedCausalProtocol(decoded).length > 0)
|
|
201
|
+
throw new Error('invalid v2 protocol commitment');
|
|
202
|
+
result = { version: 2, protocol: decoded };
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
throw new Error('unsupported protocol version');
|
|
206
|
+
}
|
|
207
|
+
const protocol = result.protocol;
|
|
208
|
+
const committedAtMs = decodeStoredCommittedAtMs(row);
|
|
209
|
+
if (row.study_id !== protocol.studyId
|
|
210
|
+
|| row.protocol_hash !== protocol.protocolHash
|
|
211
|
+
|| committedAtMs !== protocol.committedAtMs
|
|
212
|
+
|| row.protocol_json !== canonicalJson(protocol)) {
|
|
213
|
+
throw new Error('protocol physical identity or canonical bytes disagree');
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new CausalProtocolIntegrityError();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function loadProtocol(db, studyId) {
|
|
222
|
+
const row = db.prepare(STORED_PROTOCOL_SELECT + 'FROM causal_protocols WHERE study_id = ?').get(studyId);
|
|
223
|
+
return row ? decodeStoredProtocolRow(row).protocol : null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Persist the one plan that bounds a study's inferential family.
|
|
227
|
+
*
|
|
228
|
+
* A plan is not a mutable setting: once the first act exists, changing it
|
|
229
|
+
* would relabel already-spent error budget. Idempotent re-registration is
|
|
230
|
+
* allowed only for byte-identical content, so a restart or repeated setup call
|
|
231
|
+
* cannot create a second opinion about the same family.
|
|
232
|
+
*/
|
|
233
|
+
export function registerCausalInferencePlan(db, studyId, plan) {
|
|
234
|
+
const protocol = loadProtocol(db, studyId);
|
|
235
|
+
if (protocol === null)
|
|
236
|
+
throw new Error('cannot register an inference plan for an unknown causal study');
|
|
237
|
+
validateCausalInferencePlan(plan);
|
|
238
|
+
const encoded = canonicalJson({ ...plan, sliceIds: [...plan.sliceIds] });
|
|
239
|
+
db.exec('BEGIN IMMEDIATE');
|
|
240
|
+
try {
|
|
241
|
+
const existing = db.prepare('SELECT protocol_hash, plan_json FROM causal_inference_plans WHERE study_id = ?').get(studyId);
|
|
242
|
+
if (existing) {
|
|
243
|
+
if (existing.protocol_hash === protocol.protocolHash && existing.plan_json === encoded) {
|
|
244
|
+
db.exec('COMMIT');
|
|
245
|
+
return 'existing';
|
|
246
|
+
}
|
|
247
|
+
throw new Error('inference plan is already recorded with different immutable content');
|
|
248
|
+
}
|
|
249
|
+
const acts = db.prepare('SELECT 1 AS present FROM causal_inference_acts WHERE study_id = ? LIMIT 1').get(studyId);
|
|
250
|
+
if (acts) {
|
|
251
|
+
throw new Error('cannot register an inference plan after an inferential act exists');
|
|
252
|
+
}
|
|
253
|
+
db.prepare('INSERT INTO causal_inference_plans (study_id, protocol_hash, declared_at_ms, plan_json) VALUES (?, ?, ?, ?)').run(studyId, protocol.protocolHash, plan.declaredAtMs, encoded);
|
|
254
|
+
db.exec('COMMIT');
|
|
255
|
+
return 'created';
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
try {
|
|
259
|
+
db.exec('ROLLBACK');
|
|
260
|
+
}
|
|
261
|
+
catch { /* preserve the registration failure */ }
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function requireStoredProtocol(db, studyId, protocolHash) {
|
|
266
|
+
const protocol = loadProtocol(db, studyId);
|
|
267
|
+
if (!protocol || protocol.protocolHash !== protocolHash) {
|
|
268
|
+
throw new Error('causal event does not bind a registered protocol');
|
|
269
|
+
}
|
|
270
|
+
const errors = verifyCommittedCausalProtocol(protocol);
|
|
271
|
+
if (errors.length > 0)
|
|
272
|
+
throw new Error('stored causal protocol is invalid: ' + errors.join('; '));
|
|
273
|
+
return protocol;
|
|
274
|
+
}
|
|
275
|
+
export function registerCausalProtocol(db, protocolValue) {
|
|
276
|
+
const protocol = decodeSuppliedProtocol(protocolValue);
|
|
277
|
+
rejectLegacyMutation(protocol, 'register a version-1 protocol');
|
|
278
|
+
const encoded = canonicalJson(protocol);
|
|
279
|
+
const existing = db.prepare(STORED_PROTOCOL_SELECT + 'FROM causal_protocols WHERE study_id = ?').get(protocol.studyId);
|
|
280
|
+
if (existing) {
|
|
281
|
+
const decoded = decodeStoredProtocolRow(existing);
|
|
282
|
+
if (decoded.protocol.protocolHash === protocol.protocolHash
|
|
283
|
+
&& decoded.protocol.version === protocol.version
|
|
284
|
+
&& canonicalJson(decoded.protocol) === encoded)
|
|
285
|
+
return 'existing';
|
|
286
|
+
throw new CausalProtocolConflictError();
|
|
287
|
+
}
|
|
288
|
+
db.prepare('INSERT INTO causal_protocols (study_id, protocol_hash, committed_at_ms, protocol_json) VALUES (?, ?, ?, ?)').run(protocol.studyId, protocol.protocolHash, protocol.committedAtMs, encoded);
|
|
289
|
+
return 'created';
|
|
290
|
+
}
|
|
291
|
+
export function saveCausalAssignmentPlan(_db, _plan) {
|
|
292
|
+
throw new CausalLegacyInspectOnlyError('save a version-1 assignment plan');
|
|
293
|
+
}
|
|
294
|
+
function loadDecision(db, decisionId) {
|
|
295
|
+
const row = db.prepare('SELECT event_hash, study_id, protocol_hash FROM causal_decisions WHERE decision_id = ?').get(decisionId);
|
|
296
|
+
return row ? { eventHash: row.event_hash, studyId: row.study_id, protocolHash: row.protocol_hash } : null;
|
|
297
|
+
}
|
|
298
|
+
function loadExecution(db, executionId) {
|
|
299
|
+
const row = db.prepare('SELECT event_hash, study_id, protocol_hash FROM causal_executions WHERE execution_id = ?').get(executionId);
|
|
300
|
+
return row ? { eventHash: row.event_hash, studyId: row.study_id, protocolHash: row.protocol_hash } : null;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Append an execution only when it follows a stored decision. Full outcome and
|
|
304
|
+
* measurement qualification remains a separate, reproducible analysis gate.
|
|
305
|
+
*/
|
|
306
|
+
export function appendCausalExecution(db, record) {
|
|
307
|
+
const protocol = requireStoredProtocol(db, record.studyId, record.protocolHash);
|
|
308
|
+
rejectLegacyMutation(protocol, 'append a version-1 execution');
|
|
309
|
+
const decision = loadDecision(db, record.decisionId);
|
|
310
|
+
if (!decision || decision.studyId !== record.studyId || decision.protocolHash !== record.protocolHash ||
|
|
311
|
+
decision.eventHash !== record.previousEventHash || !verifyCausalEvent(record)) {
|
|
312
|
+
throw new Error('causal execution does not follow its stored decision exactly');
|
|
313
|
+
}
|
|
314
|
+
const encoded = canonicalJson(record);
|
|
315
|
+
const existing = db.prepare('SELECT execution_json FROM causal_executions WHERE execution_id = ?')
|
|
316
|
+
.get(record.executionId);
|
|
317
|
+
if (existing) {
|
|
318
|
+
if (existing.execution_json === encoded)
|
|
319
|
+
return 'existing';
|
|
320
|
+
throw new Error('executionId is already recorded with different immutable content');
|
|
321
|
+
}
|
|
322
|
+
db.prepare('INSERT INTO causal_executions (execution_id, decision_id, study_id, protocol_hash, completed_at_ms, event_hash, execution_json) VALUES (?, ?, ?, ?, ?, ?, ?)').run(record.executionId, record.decisionId, record.studyId, record.protocolHash, record.completedAtMs, record.eventHash, encoded);
|
|
323
|
+
return 'created';
|
|
324
|
+
}
|
|
325
|
+
class CausalExecutionStoreError extends Error {
|
|
326
|
+
code;
|
|
327
|
+
constructor(code, message) {
|
|
328
|
+
super(code + ': ' + message);
|
|
329
|
+
this.name = 'CausalExecutionStoreError';
|
|
330
|
+
this.code = code;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function executionFail(code, message) {
|
|
334
|
+
throw new CausalExecutionStoreError(code, message);
|
|
335
|
+
}
|
|
336
|
+
const STORED_EXECUTION_V2_SELECT = 'SELECT execution_id, decision_id, study_id, protocol_hash, ' +
|
|
337
|
+
'typeof(started_at_ms) AS started_at_ms_type, CAST(started_at_ms AS TEXT) AS started_at_ms_text, ' +
|
|
338
|
+
'typeof(completed_at_ms) AS completed_at_ms_type, CAST(completed_at_ms AS TEXT) AS completed_at_ms_text, ' +
|
|
339
|
+
'previous_event_hash, event_hash, execution_json ';
|
|
340
|
+
function authenticateStoredExecutionV2(row) {
|
|
341
|
+
try {
|
|
342
|
+
if (typeof row.execution_json !== 'string')
|
|
343
|
+
throw new Error('execution json storage class');
|
|
344
|
+
const record = decodeCausalExecutionV2(JSON.parse(row.execution_json));
|
|
345
|
+
if (row.execution_json !== canonicalJson(record))
|
|
346
|
+
throw new Error('execution json is not canonical');
|
|
347
|
+
const startedAtMs = decodeStoredIntegerMs(row.started_at_ms_type, row.started_at_ms_text);
|
|
348
|
+
const completedAtMs = decodeStoredIntegerMs(row.completed_at_ms_type, row.completed_at_ms_text);
|
|
349
|
+
if (row.execution_id !== record.executionId || row.decision_id !== record.decisionId
|
|
350
|
+
|| row.study_id !== record.studyId || row.protocol_hash !== record.protocolHash
|
|
351
|
+
|| startedAtMs !== record.startedAtMs || completedAtMs !== record.completedAtMs
|
|
352
|
+
|| row.previous_event_hash !== record.previousEventHash || row.event_hash !== record.eventHash) {
|
|
353
|
+
throw new Error('execution physical identity');
|
|
354
|
+
}
|
|
355
|
+
return record;
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (error instanceof CausalExecutionStoreError)
|
|
359
|
+
throw error;
|
|
360
|
+
executionFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 execution failed integrity verification');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Re-check the full execution contract whenever a later terminal record relies
|
|
365
|
+
* on a retained execution. A valid event hash authenticates bytes, but it does
|
|
366
|
+
* not by itself prove that those bytes satisfy the committed protocol.
|
|
367
|
+
*/
|
|
368
|
+
function executionSatisfiesStoredV2Protocol(protocol, decision, execution) {
|
|
369
|
+
const arm = protocol.arms.find((candidate) => candidate.armId === decision.assignedArmId);
|
|
370
|
+
if (!arm
|
|
371
|
+
|| execution.studyId !== protocol.studyId
|
|
372
|
+
|| execution.protocolHash !== protocol.protocolHash
|
|
373
|
+
|| execution.decisionId !== decision.decisionId
|
|
374
|
+
|| execution.assignedExecutionPlanDigest !== arm.executionPlanDigest
|
|
375
|
+
|| execution.previousEventHash !== decision.eventHash
|
|
376
|
+
|| execution.startedAtMs < decision.assignedAtMs
|
|
377
|
+
|| execution.completedAtMs < execution.startedAtMs
|
|
378
|
+
|| execution.startedAtMs < protocol.studyWindow.startsAtMs
|
|
379
|
+
|| (protocol.studyWindow.endsAtMs !== null && execution.completedAtMs > protocol.studyWindow.endsAtMs)) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
if (execution.adherence === 'confirmed'
|
|
383
|
+
&& (execution.actualExecutionPlanDigest === null
|
|
384
|
+
|| execution.actualExecutionPlanDigest !== execution.assignedExecutionPlanDigest)) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
if ((execution.adherence === 'incomplete' || execution.adherence === 'unverifiable')
|
|
388
|
+
&& (execution.directAiCostUsd !== null || execution.fullArmCostUsd !== null)) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
const direct = execution.directAiCostUsd;
|
|
392
|
+
if (direct === null) {
|
|
393
|
+
if (execution.directCostSourceClass !== 'incomplete_or_unknown')
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
else if (direct < protocol.costOutcome.boundsUsd.low
|
|
397
|
+
|| direct > protocol.costOutcome.boundsUsd.high
|
|
398
|
+
|| (execution.directCostSourceClass !== 'actual_observed'
|
|
399
|
+
&& execution.directCostSourceClass !== 'actual_reconciled')
|
|
400
|
+
|| !protocol.costOutcome.acceptedSourceClasses.includes(execution.directCostSourceClass)
|
|
401
|
+
|| execution.priceLineageDigests.length === 0) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
const full = execution.fullArmCostUsd;
|
|
405
|
+
if (protocol.question === 'model_cost_quality') {
|
|
406
|
+
if (full !== null || execution.fullCostSourceClass !== 'incomplete_or_unknown')
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
else if (full === null
|
|
410
|
+
|| full < protocol.costOutcome.boundsUsd.low
|
|
411
|
+
|| full > protocol.costOutcome.boundsUsd.high
|
|
412
|
+
|| (execution.fullCostSourceClass !== 'actual_observed'
|
|
413
|
+
&& execution.fullCostSourceClass !== 'actual_reconciled')
|
|
414
|
+
|| !protocol.costOutcome.acceptedSourceClasses.includes(execution.fullCostSourceClass)
|
|
415
|
+
|| execution.priceLineageDigests.length === 0) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
function normalizedExecutionError(error) {
|
|
421
|
+
if (error instanceof CausalExecutionStoreError)
|
|
422
|
+
return error;
|
|
423
|
+
if (error && typeof error === 'object' && 'code' in error
|
|
424
|
+
&& typeof error.code === 'string') {
|
|
425
|
+
const code = error.code;
|
|
426
|
+
if (code === 'CAUSAL_RECORD_INVALID') {
|
|
427
|
+
return new CausalExecutionStoreError('CAUSAL_RECORD_INVALID', 'causal execution record is invalid');
|
|
428
|
+
}
|
|
429
|
+
if (code === 'CAUSAL_INTEGRITY_FAILURE') {
|
|
430
|
+
return new CausalExecutionStoreError('CAUSAL_INTEGRITY_FAILURE', 'stored v2 causal evidence failed integrity verification');
|
|
431
|
+
}
|
|
432
|
+
if (code === 'CAUSAL_NOT_FOUND') {
|
|
433
|
+
return new CausalExecutionStoreError('CAUSAL_RECORD_INVALID', 'causal execution record is invalid');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
437
|
+
if (/database is locked|SQLITE_BUSY/i.test(message)) {
|
|
438
|
+
return new CausalExecutionStoreError('CAUSAL_BUSY', 'execution writer is busy; retry this immutable request');
|
|
439
|
+
}
|
|
440
|
+
if (/UNIQUE constraint failed.*causal_executions_v2/i.test(message)) {
|
|
441
|
+
return new CausalExecutionStoreError('CAUSAL_IMMUTABLE_CONFLICT', 'causal execution conflicts with an existing immutable record');
|
|
442
|
+
}
|
|
443
|
+
return new CausalExecutionStoreError('CAUSAL_APPEND_ROLLED_BACK', 'execution transaction rolled back without disclosing a causal record');
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Append one exact v2 execution record. This is intentionally Store-internal:
|
|
447
|
+
* terminal outcomes, public mutation, lifecycle, and analysis are separate
|
|
448
|
+
* later increments.
|
|
449
|
+
*/
|
|
450
|
+
export function appendCausalExecutionV2(db, recordValue) {
|
|
451
|
+
let committed = false;
|
|
452
|
+
try {
|
|
453
|
+
// Decode before property access, hashing, or database use. All stateful
|
|
454
|
+
// lineage and replay checks below remain inside the write transaction.
|
|
455
|
+
const record = decodeCausalExecutionV2(recordValue);
|
|
456
|
+
const encoded = canonicalJson(record);
|
|
457
|
+
db.prepare('BEGIN IMMEDIATE').run();
|
|
458
|
+
const protocol = requireProtocolV2(db, record.studyId);
|
|
459
|
+
if (record.protocolHash !== protocol.protocolHash) {
|
|
460
|
+
executionFail('CAUSAL_RECORD_INVALID', 'execution does not bind the stored v2 protocol');
|
|
461
|
+
}
|
|
462
|
+
// This scan authenticates the retained v2 assignment artifacts. In
|
|
463
|
+
// particular, no legacy causal_decisions row can become a predecessor.
|
|
464
|
+
let blocks;
|
|
465
|
+
try {
|
|
466
|
+
blocks = scanAssignmentArtifacts(db, protocol);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (error instanceof CausalExecutionStoreError)
|
|
470
|
+
throw error;
|
|
471
|
+
executionFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 assignment evidence failed integrity verification');
|
|
472
|
+
}
|
|
473
|
+
const decision = blocks.flatMap((block) => block.decisions)
|
|
474
|
+
.find((candidate) => candidate.decisionId === record.decisionId);
|
|
475
|
+
if (!decision)
|
|
476
|
+
executionFail('CAUSAL_RECORD_INVALID', 'execution does not bind a stored v2 decision');
|
|
477
|
+
const arm = protocol.arms.find((candidate) => candidate.armId === decision.assignedArmId);
|
|
478
|
+
if (!arm
|
|
479
|
+
|| record.assignedExecutionPlanDigest !== arm.executionPlanDigest
|
|
480
|
+
|| record.previousEventHash !== decision.eventHash
|
|
481
|
+
|| record.startedAtMs < decision.assignedAtMs) {
|
|
482
|
+
executionFail('CAUSAL_RECORD_INVALID', 'execution lineage does not match the stored v2 decision');
|
|
483
|
+
}
|
|
484
|
+
if (record.startedAtMs < protocol.studyWindow.startsAtMs
|
|
485
|
+
|| (protocol.studyWindow.endsAtMs !== null && record.completedAtMs > protocol.studyWindow.endsAtMs)) {
|
|
486
|
+
executionFail('CAUSAL_RECORD_INVALID', 'execution timestamps fall outside the stored study window');
|
|
487
|
+
}
|
|
488
|
+
const directCost = record.directAiCostUsd;
|
|
489
|
+
if (directCost !== null && (!Number.isFinite(directCost)
|
|
490
|
+
|| directCost < protocol.costOutcome.boundsUsd.low
|
|
491
|
+
|| directCost > protocol.costOutcome.boundsUsd.high
|
|
492
|
+
|| !protocol.costOutcome.acceptedSourceClasses.includes(record.directCostSourceClass))) {
|
|
493
|
+
executionFail('CAUSAL_RECORD_INVALID', 'execution does not satisfy the stored protocol');
|
|
494
|
+
}
|
|
495
|
+
const fullCost = record.fullArmCostUsd;
|
|
496
|
+
if (protocol.question === 'model_cost_quality') {
|
|
497
|
+
if (fullCost !== null || record.fullCostSourceClass !== 'incomplete_or_unknown') {
|
|
498
|
+
executionFail('CAUSAL_RECORD_INVALID', 'execution does not satisfy the stored protocol');
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
if (fullCost === null
|
|
503
|
+
|| !Number.isFinite(fullCost)
|
|
504
|
+
|| fullCost < protocol.costOutcome.boundsUsd.low
|
|
505
|
+
|| fullCost > protocol.costOutcome.boundsUsd.high
|
|
506
|
+
|| !protocol.costOutcome.acceptedSourceClasses.includes(record.fullCostSourceClass)) {
|
|
507
|
+
executionFail('CAUSAL_RECORD_INVALID', 'execution does not satisfy the stored protocol');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const byIdRows = db.prepare(STORED_EXECUTION_V2_SELECT + 'FROM causal_executions_v2 WHERE execution_id = ?').all(record.executionId);
|
|
511
|
+
if (byIdRows.length > 1) {
|
|
512
|
+
executionFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 execution identity is duplicated');
|
|
513
|
+
}
|
|
514
|
+
const byId = byIdRows[0];
|
|
515
|
+
if (byId) {
|
|
516
|
+
const existing = authenticateStoredExecutionV2(byId);
|
|
517
|
+
if (canonicalJson(existing) === encoded) {
|
|
518
|
+
db.prepare('COMMIT').run();
|
|
519
|
+
committed = true;
|
|
520
|
+
return 'existing';
|
|
521
|
+
}
|
|
522
|
+
executionFail('CAUSAL_IMMUTABLE_CONFLICT', 'execution conflicts with existing immutable content');
|
|
523
|
+
}
|
|
524
|
+
// Authenticate any competing row before classifying a unique decision or
|
|
525
|
+
// event collision. A corrupt row is never an ordinary duplicate.
|
|
526
|
+
const competing = db.prepare(STORED_EXECUTION_V2_SELECT + 'FROM causal_executions_v2 WHERE decision_id = ? OR event_hash = ?').all(record.decisionId, record.eventHash);
|
|
527
|
+
for (const row of competing)
|
|
528
|
+
authenticateStoredExecutionV2(row);
|
|
529
|
+
if (competing.length > 0) {
|
|
530
|
+
executionFail('CAUSAL_IMMUTABLE_CONFLICT', 'execution conflicts with an existing immutable decision or event');
|
|
531
|
+
}
|
|
532
|
+
db.prepare('INSERT INTO causal_executions_v2 ' +
|
|
533
|
+
'(execution_id, decision_id, study_id, protocol_hash, started_at_ms, completed_at_ms, previous_event_hash, event_hash, execution_json) ' +
|
|
534
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(record.executionId, record.decisionId, record.studyId, record.protocolHash, record.startedAtMs, record.completedAtMs, record.previousEventHash, record.eventHash, encoded);
|
|
535
|
+
const retainedRows = db.prepare(STORED_EXECUTION_V2_SELECT + 'FROM causal_executions_v2 WHERE execution_id = ?').all(record.executionId);
|
|
536
|
+
if (retainedRows.length !== 1) {
|
|
537
|
+
executionFail('CAUSAL_INTEGRITY_FAILURE', 'created v2 execution identity did not reload exactly once');
|
|
538
|
+
}
|
|
539
|
+
const retained = retainedRows[0];
|
|
540
|
+
if (!retained)
|
|
541
|
+
executionFail('CAUSAL_INTEGRITY_FAILURE', 'created v2 execution could not be reloaded');
|
|
542
|
+
const authenticated = authenticateStoredExecutionV2(retained);
|
|
543
|
+
if (canonicalJson(authenticated) !== encoded) {
|
|
544
|
+
executionFail('CAUSAL_INTEGRITY_FAILURE', 'created v2 execution did not replay canonically');
|
|
545
|
+
}
|
|
546
|
+
db.prepare('COMMIT').run();
|
|
547
|
+
committed = true;
|
|
548
|
+
return 'created';
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
if (!committed) {
|
|
552
|
+
try {
|
|
553
|
+
db.prepare('ROLLBACK').run();
|
|
554
|
+
}
|
|
555
|
+
catch { /* no active transaction */ }
|
|
556
|
+
}
|
|
557
|
+
throw normalizedExecutionError(error);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const STORED_TERMINAL_OUTCOME_V2_SELECT = 'SELECT outcome_id, decision_id, study_id, protocol_hash, ' +
|
|
561
|
+
'typeof(observed_at_ms) AS observed_at_ms_type, CAST(observed_at_ms AS TEXT) AS observed_at_ms_text, ' +
|
|
562
|
+
'maturity, previous_event_hash, event_hash, terminal_outcome_json ';
|
|
563
|
+
class CausalTerminalOutcomeStoreError extends Error {
|
|
564
|
+
code;
|
|
565
|
+
constructor(code, message) {
|
|
566
|
+
super(code + ': ' + message);
|
|
567
|
+
this.name = 'CausalTerminalOutcomeStoreError';
|
|
568
|
+
this.code = code;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function terminalOutcomeFail(code, message) {
|
|
572
|
+
throw new CausalTerminalOutcomeStoreError(code, message);
|
|
573
|
+
}
|
|
574
|
+
function authenticateStoredTerminalOutcomeV2(row) {
|
|
575
|
+
try {
|
|
576
|
+
if (typeof row.terminal_outcome_json !== 'string')
|
|
577
|
+
throw new Error('terminal outcome JSON storage class');
|
|
578
|
+
const record = decodeCausalTerminalOutcomeV2(JSON.parse(row.terminal_outcome_json));
|
|
579
|
+
if (row.terminal_outcome_json !== canonicalJson(record))
|
|
580
|
+
throw new Error('terminal outcome JSON is not canonical');
|
|
581
|
+
const observedAtMs = decodeStoredIntegerMs(row.observed_at_ms_type, row.observed_at_ms_text);
|
|
582
|
+
if (row.outcome_id !== record.outcomeId || row.decision_id !== record.decisionId
|
|
583
|
+
|| row.study_id !== record.studyId || row.protocol_hash !== record.protocolHash
|
|
584
|
+
|| observedAtMs !== record.observedAtMs || row.maturity !== record.maturity
|
|
585
|
+
|| row.previous_event_hash !== record.previousEventHash || row.event_hash !== record.eventHash) {
|
|
586
|
+
throw new Error('terminal outcome physical identity');
|
|
587
|
+
}
|
|
588
|
+
return record;
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 terminal outcome failed integrity verification');
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function normalizedTerminalOutcomeError(error) {
|
|
595
|
+
if (error instanceof CausalTerminalOutcomeStoreError) {
|
|
596
|
+
if (error.code === 'CAUSAL_RECORD_INVALID' || error.code === 'CAUSAL_NOT_FOUND') {
|
|
597
|
+
return new CausalTerminalOutcomeStoreError('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
598
|
+
}
|
|
599
|
+
if (error.code === 'CAUSAL_INTEGRITY_FAILURE') {
|
|
600
|
+
return new CausalTerminalOutcomeStoreError('CAUSAL_INTEGRITY_FAILURE', 'stored v2 terminal outcome failed integrity verification');
|
|
601
|
+
}
|
|
602
|
+
return error;
|
|
603
|
+
}
|
|
604
|
+
if (error && typeof error === 'object' && 'code' in error
|
|
605
|
+
&& typeof error.code === 'string') {
|
|
606
|
+
const code = error.code;
|
|
607
|
+
if (code === 'CAUSAL_RECORD_INVALID' || code === 'CAUSAL_NOT_FOUND') {
|
|
608
|
+
return new CausalTerminalOutcomeStoreError('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
609
|
+
}
|
|
610
|
+
if (code === 'CAUSAL_INTEGRITY_FAILURE') {
|
|
611
|
+
return new CausalTerminalOutcomeStoreError('CAUSAL_INTEGRITY_FAILURE', 'stored v2 terminal outcome failed integrity verification');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
615
|
+
if (/database is locked|SQLITE_BUSY/i.test(message)) {
|
|
616
|
+
return new CausalTerminalOutcomeStoreError('CAUSAL_BUSY', 'terminal outcome writer is busy; retry this immutable request');
|
|
617
|
+
}
|
|
618
|
+
if (/UNIQUE constraint failed.*causal_terminal_outcomes_v2/i.test(message)) {
|
|
619
|
+
return new CausalTerminalOutcomeStoreError('CAUSAL_IMMUTABLE_CONFLICT', 'terminal outcome conflicts with an existing immutable record');
|
|
620
|
+
}
|
|
621
|
+
return new CausalTerminalOutcomeStoreError('CAUSAL_APPEND_ROLLED_BACK', 'terminal outcome transaction rolled back without disclosing a causal record');
|
|
622
|
+
}
|
|
623
|
+
class CausalQualificationStoreError extends Error {
|
|
624
|
+
code;
|
|
625
|
+
constructor(code, message) {
|
|
626
|
+
super(code + ': ' + message);
|
|
627
|
+
this.name = 'CausalQualificationStoreError';
|
|
628
|
+
this.code = code;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function qualificationFail(code, message) {
|
|
632
|
+
throw new CausalQualificationStoreError(code, message);
|
|
633
|
+
}
|
|
634
|
+
function normalizedQualificationError(error) {
|
|
635
|
+
if (error instanceof CausalQualificationStoreError)
|
|
636
|
+
return error;
|
|
637
|
+
if (error && typeof error === 'object' && 'code' in error
|
|
638
|
+
&& typeof error.code === 'string') {
|
|
639
|
+
const code = error.code;
|
|
640
|
+
if (code === 'CAUSAL_NOT_FOUND') {
|
|
641
|
+
return new CausalQualificationStoreError('CAUSAL_NOT_FOUND', 'committed causal study was not found');
|
|
642
|
+
}
|
|
643
|
+
if (code === 'CAUSAL_BUSY') {
|
|
644
|
+
return new CausalQualificationStoreError('CAUSAL_BUSY', 'qualification reader is busy; retry this immutable read');
|
|
645
|
+
}
|
|
646
|
+
if (code === 'CAUSAL_INTEGRITY_FAILURE') {
|
|
647
|
+
return new CausalQualificationStoreError('CAUSAL_INTEGRITY_FAILURE', 'stored v2 qualification evidence failed integrity verification');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
651
|
+
if (/database is locked|SQLITE_BUSY/i.test(message)) {
|
|
652
|
+
return new CausalQualificationStoreError('CAUSAL_BUSY', 'qualification reader is busy; retry this immutable read');
|
|
653
|
+
}
|
|
654
|
+
return new CausalQualificationStoreError('CAUSAL_INTEGRITY_FAILURE', 'stored v2 qualification evidence failed integrity verification');
|
|
655
|
+
}
|
|
656
|
+
const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
|
657
|
+
/** The only production clock read used by V2 terminal operations. */
|
|
658
|
+
function readCausalWallClockMs() {
|
|
659
|
+
return Date.now();
|
|
660
|
+
}
|
|
661
|
+
function readCausalClockFloor(db) {
|
|
662
|
+
try {
|
|
663
|
+
const rows = db.prepare('SELECT clock_id, typeof(last_wall_ms) AS wall_type, CAST(last_wall_ms AS TEXT) AS wall_text ' +
|
|
664
|
+
'FROM causal_clock_state ORDER BY rowid').all();
|
|
665
|
+
if (rows.length !== 1)
|
|
666
|
+
throw new Error('clock row cardinality');
|
|
667
|
+
const row = rows[0];
|
|
668
|
+
if (row.clock_id !== 'causal-v2' || row.wall_type !== 'integer'
|
|
669
|
+
|| typeof row.wall_text !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(row.wall_text)) {
|
|
670
|
+
throw new Error('clock row shape');
|
|
671
|
+
}
|
|
672
|
+
const value = BigInt(row.wall_text);
|
|
673
|
+
if (value < 0n || value > MAX_SAFE_INTEGER_BIGINT)
|
|
674
|
+
throw new Error('clock floor range');
|
|
675
|
+
return Number(value);
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 causal clock state failed integrity verification');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function captureCausalAsOfMs(db) {
|
|
682
|
+
const nowMs = readCausalWallClockMs();
|
|
683
|
+
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
|
684
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 causal clock state failed integrity verification');
|
|
685
|
+
}
|
|
686
|
+
const floor = readCausalClockFloor(db);
|
|
687
|
+
if (nowMs < floor) {
|
|
688
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 causal clock state failed integrity verification');
|
|
689
|
+
}
|
|
690
|
+
return nowMs;
|
|
691
|
+
}
|
|
692
|
+
function advanceCausalClockFloor(db, nowMs) {
|
|
693
|
+
const result = db.prepare("UPDATE causal_clock_state SET last_wall_ms = CASE WHEN last_wall_ms < ? THEN ? ELSE last_wall_ms END WHERE clock_id = 'causal-v2'").run(nowMs, nowMs);
|
|
694
|
+
if (Number(result.changes) !== 1 || readCausalClockFloor(db) < nowMs) {
|
|
695
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 causal clock state failed integrity verification');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function checkedFollowUpDeadlineMs(protocol, completedAtMs) {
|
|
699
|
+
if (!Object.hasOwn(protocol, 'followUpWindowMs'))
|
|
700
|
+
return { kind: 'legacy' };
|
|
701
|
+
const followUpWindowMs = protocol.followUpWindowMs;
|
|
702
|
+
if (typeof followUpWindowMs !== 'number'
|
|
703
|
+
|| !Number.isSafeInteger(followUpWindowMs) || followUpWindowMs <= 0
|
|
704
|
+
|| followUpWindowMs > 31_536_000_000
|
|
705
|
+
|| !Number.isSafeInteger(completedAtMs)
|
|
706
|
+
|| completedAtMs < 0
|
|
707
|
+
|| completedAtMs > Number.MAX_SAFE_INTEGER - followUpWindowMs) {
|
|
708
|
+
return { kind: 'impossible' };
|
|
709
|
+
}
|
|
710
|
+
return { kind: 'valid', deadlineMs: completedAtMs + followUpWindowMs };
|
|
711
|
+
}
|
|
712
|
+
/** Append one exact v2 terminal outcome after an authenticated v2 execution. */
|
|
713
|
+
export function appendCausalTerminalOutcomeV2(db, recordValue) {
|
|
714
|
+
let committed = false;
|
|
715
|
+
try {
|
|
716
|
+
const record = decodeCausalTerminalOutcomeV2(recordValue);
|
|
717
|
+
const encoded = canonicalJson(record);
|
|
718
|
+
db.prepare('BEGIN IMMEDIATE').run();
|
|
719
|
+
try {
|
|
720
|
+
if (!causalV2SchemaComplete(db)) {
|
|
721
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 schema authority failed integrity verification');
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
if (error instanceof CausalTerminalOutcomeStoreError)
|
|
726
|
+
throw error;
|
|
727
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 schema authority failed integrity verification');
|
|
728
|
+
}
|
|
729
|
+
const asOfMs = captureCausalAsOfMs(db);
|
|
730
|
+
const protocol = requireProtocolV2(db, record.studyId);
|
|
731
|
+
if (record.protocolHash !== protocol.protocolHash) {
|
|
732
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
733
|
+
}
|
|
734
|
+
// Re-authenticate the V2 assignment lane so no legacy decision/outcome row
|
|
735
|
+
// can become the predecessor for a terminal record.
|
|
736
|
+
let blocks;
|
|
737
|
+
try {
|
|
738
|
+
blocks = scanAssignmentArtifacts(db, protocol);
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
if (error instanceof CausalTerminalOutcomeStoreError)
|
|
742
|
+
throw error;
|
|
743
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 assignment evidence failed integrity verification');
|
|
744
|
+
}
|
|
745
|
+
const decision = blocks.flatMap((block) => block.decisions)
|
|
746
|
+
.find((candidate) => candidate.decisionId === record.decisionId);
|
|
747
|
+
if (!decision)
|
|
748
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
749
|
+
const executionRows = db.prepare(STORED_EXECUTION_V2_SELECT + 'FROM causal_executions_v2 WHERE decision_id = ?').all(record.decisionId);
|
|
750
|
+
if (executionRows.length > 1) {
|
|
751
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 execution predecessor identity is duplicated');
|
|
752
|
+
}
|
|
753
|
+
const executionRow = executionRows[0];
|
|
754
|
+
if (!executionRow)
|
|
755
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
756
|
+
const execution = authenticateStoredExecutionV2(executionRow);
|
|
757
|
+
if (!executionSatisfiesStoredV2Protocol(protocol, decision, execution)) {
|
|
758
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 execution failed protocol verification');
|
|
759
|
+
}
|
|
760
|
+
const followUpDeadline = checkedFollowUpDeadlineMs(protocol, execution.completedAtMs);
|
|
761
|
+
if (followUpDeadline.kind === 'impossible') {
|
|
762
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 follow-up policy failed integrity verification');
|
|
763
|
+
}
|
|
764
|
+
if (record.previousEventHash !== execution.eventHash
|
|
765
|
+
|| record.observedAtMs < execution.completedAtMs
|
|
766
|
+
|| record.observedAtMs > asOfMs) {
|
|
767
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
768
|
+
}
|
|
769
|
+
if (record.maturity === 'matured') {
|
|
770
|
+
const quality = record.qualityValue;
|
|
771
|
+
if (quality === null || quality < protocol.qualityOutcome.bounds.low
|
|
772
|
+
|| quality > protocol.qualityOutcome.bounds.high
|
|
773
|
+
|| record.qualityEvidenceClass !== protocol.qualityOutcome.evidenceClass
|
|
774
|
+
|| record.outcomeEvidenceDigests.length === 0) {
|
|
775
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
776
|
+
}
|
|
777
|
+
const economic = protocol.economicOutcome;
|
|
778
|
+
if (protocol.question === 'model_cost_quality') {
|
|
779
|
+
if (economic !== null || record.economicValueUsd !== null || record.economicEvidenceClass !== null) {
|
|
780
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
else if (economic === null
|
|
784
|
+
|| record.economicValueUsd === null
|
|
785
|
+
|| record.economicValueUsd < economic.boundsUsd.low
|
|
786
|
+
|| record.economicValueUsd > economic.boundsUsd.high
|
|
787
|
+
|| record.economicEvidenceClass !== economic.evidenceClass) {
|
|
788
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
else if (record.maturity === 'censored') {
|
|
792
|
+
if (followUpDeadline.kind !== 'valid'
|
|
793
|
+
|| asOfMs < followUpDeadline.deadlineMs
|
|
794
|
+
|| record.observedAtMs < followUpDeadline.deadlineMs
|
|
795
|
+
|| record.observedAtMs > asOfMs) {
|
|
796
|
+
terminalOutcomeFail('CAUSAL_RECORD_INVALID', 'causal terminal outcome record is invalid');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const byIdRows = db.prepare(STORED_TERMINAL_OUTCOME_V2_SELECT + 'FROM causal_terminal_outcomes_v2 WHERE outcome_id = ?').all(record.outcomeId);
|
|
800
|
+
if (byIdRows.length > 1) {
|
|
801
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 terminal outcome identity is duplicated');
|
|
802
|
+
}
|
|
803
|
+
const byId = byIdRows[0];
|
|
804
|
+
if (byId) {
|
|
805
|
+
const existing = authenticateStoredTerminalOutcomeV2(byId);
|
|
806
|
+
if (canonicalJson(existing) === encoded) {
|
|
807
|
+
advanceCausalClockFloor(db, asOfMs);
|
|
808
|
+
db.prepare('COMMIT').run();
|
|
809
|
+
committed = true;
|
|
810
|
+
return 'existing';
|
|
811
|
+
}
|
|
812
|
+
terminalOutcomeFail('CAUSAL_IMMUTABLE_CONFLICT', 'terminal outcome conflicts with existing immutable content');
|
|
813
|
+
}
|
|
814
|
+
const competing = db.prepare(STORED_TERMINAL_OUTCOME_V2_SELECT + 'FROM causal_terminal_outcomes_v2 WHERE decision_id = ? OR event_hash = ?').all(record.decisionId, record.eventHash);
|
|
815
|
+
for (const row of competing)
|
|
816
|
+
authenticateStoredTerminalOutcomeV2(row);
|
|
817
|
+
if (competing.length > 0) {
|
|
818
|
+
terminalOutcomeFail('CAUSAL_IMMUTABLE_CONFLICT', 'terminal outcome conflicts with an existing immutable decision or event');
|
|
819
|
+
}
|
|
820
|
+
db.prepare('INSERT INTO causal_terminal_outcomes_v2 ' +
|
|
821
|
+
'(outcome_id, decision_id, study_id, protocol_hash, observed_at_ms, maturity, previous_event_hash, event_hash, terminal_outcome_json) ' +
|
|
822
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(record.outcomeId, record.decisionId, record.studyId, record.protocolHash, record.observedAtMs, record.maturity, record.previousEventHash, record.eventHash, encoded);
|
|
823
|
+
const retainedRows = db.prepare(STORED_TERMINAL_OUTCOME_V2_SELECT + 'FROM causal_terminal_outcomes_v2 WHERE outcome_id = ?').all(record.outcomeId);
|
|
824
|
+
if (retainedRows.length !== 1) {
|
|
825
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'created v2 terminal outcome identity did not reload exactly once');
|
|
826
|
+
}
|
|
827
|
+
const retained = retainedRows[0];
|
|
828
|
+
if (!retained)
|
|
829
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'created v2 terminal outcome could not be reloaded');
|
|
830
|
+
const authenticated = authenticateStoredTerminalOutcomeV2(retained);
|
|
831
|
+
if (canonicalJson(authenticated) !== encoded) {
|
|
832
|
+
terminalOutcomeFail('CAUSAL_INTEGRITY_FAILURE', 'created v2 terminal outcome did not replay canonically');
|
|
833
|
+
}
|
|
834
|
+
advanceCausalClockFloor(db, asOfMs);
|
|
835
|
+
db.prepare('COMMIT').run();
|
|
836
|
+
committed = true;
|
|
837
|
+
return 'created';
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (!committed) {
|
|
841
|
+
try {
|
|
842
|
+
db.prepare('ROLLBACK').run();
|
|
843
|
+
}
|
|
844
|
+
catch { /* no active transaction */ }
|
|
845
|
+
}
|
|
846
|
+
throw normalizedTerminalOutcomeError(error);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/** Append a resolved/pending outcome only when it follows a stored execution. */
|
|
850
|
+
export function appendCausalOutcome(db, record) {
|
|
851
|
+
const protocol = requireStoredProtocol(db, record.studyId, record.protocolHash);
|
|
852
|
+
rejectLegacyMutation(protocol, 'append a version-1 outcome');
|
|
853
|
+
const execution = [...db.prepare('SELECT execution_id, event_hash, study_id, protocol_hash FROM causal_executions WHERE decision_id = ?').all(record.decisionId)]
|
|
854
|
+
.find((candidate) => candidate.event_hash === record.previousEventHash &&
|
|
855
|
+
candidate.study_id === record.studyId &&
|
|
856
|
+
candidate.protocol_hash === record.protocolHash);
|
|
857
|
+
if (!execution || !verifyCausalEvent(record)) {
|
|
858
|
+
throw new Error('causal outcome does not follow its stored execution exactly');
|
|
859
|
+
}
|
|
860
|
+
const encoded = canonicalJson(record);
|
|
861
|
+
const existing = db.prepare('SELECT outcome_json FROM causal_outcomes WHERE outcome_id = ?')
|
|
862
|
+
.get(record.outcomeId);
|
|
863
|
+
if (existing) {
|
|
864
|
+
if (existing.outcome_json === encoded)
|
|
865
|
+
return 'existing';
|
|
866
|
+
throw new Error('outcomeId is already recorded with different immutable content');
|
|
867
|
+
}
|
|
868
|
+
db.prepare('INSERT INTO causal_outcomes (outcome_id, decision_id, study_id, protocol_hash, observed_at_ms, event_hash, outcome_json) VALUES (?, ?, ?, ?, ?, ?, ?)').run(record.outcomeId, record.decisionId, record.studyId, record.protocolHash, record.observedAtMs, record.eventHash, encoded);
|
|
869
|
+
return 'created';
|
|
870
|
+
}
|
|
871
|
+
export function causalStudyData(db, studyId) {
|
|
872
|
+
const protocol = loadProtocol(db, studyId);
|
|
873
|
+
if (!protocol)
|
|
874
|
+
return null;
|
|
875
|
+
if (protocol.version !== 1)
|
|
876
|
+
return null;
|
|
877
|
+
const decisions = db.prepare('SELECT decision_json FROM causal_decisions WHERE study_id = ? ORDER BY assigned_at_ms, decision_id').all(studyId);
|
|
878
|
+
const executions = db.prepare('SELECT execution_json FROM causal_executions WHERE study_id = ? ORDER BY completed_at_ms, execution_id').all(studyId);
|
|
879
|
+
const outcomes = db.prepare('SELECT outcome_json FROM causal_outcomes WHERE study_id = ? ORDER BY observed_at_ms, outcome_id').all(studyId);
|
|
880
|
+
return {
|
|
881
|
+
protocol,
|
|
882
|
+
decisions: decisions.map((row) => parseJson(row.decision_json, 'decision')),
|
|
883
|
+
executions: executions.map((row) => parseJson(row.execution_json, 'execution')),
|
|
884
|
+
outcomes: outcomes.map((row) => parseJson(row.outcome_json, 'outcome')),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/** Store-owned v2 allocation: caller supplies no sequence, entropy, or plan. */
|
|
888
|
+
export function assignCausalBlockV2(db, request) {
|
|
889
|
+
return assignCausalBlockV2Store(db, request);
|
|
890
|
+
}
|
|
891
|
+
export function causalAssignmentManifestV2(db, studyId) {
|
|
892
|
+
return readCausalAssignmentManifestV2Store(db, studyId);
|
|
893
|
+
}
|
|
894
|
+
function finiteStoredValue(value, bounds) {
|
|
895
|
+
return value !== null && Number.isFinite(value) && value >= bounds.low && value <= bounds.high;
|
|
896
|
+
}
|
|
897
|
+
const issuedV2QualificationSnapshots = new WeakSet();
|
|
898
|
+
function freezeQualificationSnapshot(value, seen = new WeakSet()) {
|
|
899
|
+
if (typeof value !== 'object' || value === null || seen.has(value))
|
|
900
|
+
return value;
|
|
901
|
+
seen.add(value);
|
|
902
|
+
for (const child of Object.values(value))
|
|
903
|
+
freezeQualificationSnapshot(child, seen);
|
|
904
|
+
return Object.freeze(value);
|
|
905
|
+
}
|
|
906
|
+
function issueQualificationSnapshot(data) {
|
|
907
|
+
const snapshot = JSON.parse(canonicalJson(data));
|
|
908
|
+
freezeQualificationSnapshot(snapshot);
|
|
909
|
+
issuedV2QualificationSnapshots.add(snapshot);
|
|
910
|
+
return snapshot;
|
|
911
|
+
}
|
|
912
|
+
function blankQualificationCountsV2(armIds) {
|
|
913
|
+
return Object.fromEntries(armIds.map((armId) => [armId, {
|
|
914
|
+
assigned: 0,
|
|
915
|
+
pending: 0,
|
|
916
|
+
completed: 0,
|
|
917
|
+
censored: 0,
|
|
918
|
+
invalid: 0,
|
|
919
|
+
}]));
|
|
920
|
+
}
|
|
921
|
+
function structuralQualificationV2(data, state, reasons, countsByArm) {
|
|
922
|
+
return {
|
|
923
|
+
studyId: data.protocol.studyId,
|
|
924
|
+
protocolHash: data.protocol.protocolHash,
|
|
925
|
+
state,
|
|
926
|
+
reasons,
|
|
927
|
+
countsByArm,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
function duplicateQualificationIds(values) {
|
|
931
|
+
return new Set(values).size !== values.length;
|
|
932
|
+
}
|
|
933
|
+
function isQualificationDigest(value) {
|
|
934
|
+
return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Evaluate only structural V2 state after the Store has issued its immutable
|
|
938
|
+
* authenticated snapshot. This function is intentionally module-private.
|
|
939
|
+
*/
|
|
940
|
+
function evaluateQualificationV2(data) {
|
|
941
|
+
if (!issuedV2QualificationSnapshots.has(data)) {
|
|
942
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'authenticated v2 qualification snapshot is required');
|
|
943
|
+
}
|
|
944
|
+
const { protocol, decisions, executions, terminalOutcomes, lineageBindings } = data;
|
|
945
|
+
const countsByArm = blankQualificationCountsV2(protocol.arms.map((arm) => arm.armId));
|
|
946
|
+
const reasons = [];
|
|
947
|
+
const protocolErrors = verifyCommittedCausalProtocol(protocol);
|
|
948
|
+
if (protocolErrors.length > 0) {
|
|
949
|
+
return structuralQualificationV2(data, 'invalid', ['invalid committed V2 protocol'], countsByArm);
|
|
950
|
+
}
|
|
951
|
+
const armIds = new Set(protocol.arms.map((arm) => arm.armId));
|
|
952
|
+
const decisionById = new Map();
|
|
953
|
+
if (duplicateQualificationIds(decisions.map((decision) => decision.decisionId))) {
|
|
954
|
+
reasons.push('duplicate V2 assignment decision');
|
|
955
|
+
}
|
|
956
|
+
for (const decision of decisions) {
|
|
957
|
+
if (decision.type !== 'segreant.causal-decision'
|
|
958
|
+
|| decision.version !== 2
|
|
959
|
+
|| decision.studyId !== protocol.studyId
|
|
960
|
+
|| decision.protocolHash !== protocol.protocolHash
|
|
961
|
+
|| !isCausalIdentifier(decision.decisionId)
|
|
962
|
+
|| !isCausalIdentifier(decision.blockId)
|
|
963
|
+
|| !armIds.has(decision.assignedArmId)
|
|
964
|
+
|| !isQualificationDigest(decision.unitIdDigest)
|
|
965
|
+
|| !isQualificationDigest(decision.blockRoot)
|
|
966
|
+
|| !isQualificationDigest(decision.planHash)
|
|
967
|
+
|| !isQualificationDigest(decision.allocationHash)
|
|
968
|
+
|| !isQualificationDigest(decision.randomizationMaterialDigest)
|
|
969
|
+
|| !isQualificationDigest(decision.previousEventHash)
|
|
970
|
+
|| !isQualificationDigest(decision.eventHash)
|
|
971
|
+
|| decision.propensity !== protocol.allocation.probabilityPerArm
|
|
972
|
+
|| !Number.isSafeInteger(decision.assignedAtMs)
|
|
973
|
+
|| decision.assignedAtMs < protocol.committedAtMs) {
|
|
974
|
+
reasons.push('invalid V2 assignment evidence');
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
decisionById.set(decision.decisionId, decision);
|
|
978
|
+
countsByArm[decision.assignedArmId].assigned += 1;
|
|
979
|
+
}
|
|
980
|
+
if (decisions.length === 0) {
|
|
981
|
+
return structuralQualificationV2(data, 'collecting', ['V2 study has no assignment support'], countsByArm);
|
|
982
|
+
}
|
|
983
|
+
const executionByDecision = new Map();
|
|
984
|
+
const invalidExecutionDecisions = new Set();
|
|
985
|
+
let unresolvedCostVerification = false;
|
|
986
|
+
let missingLineageBinding = false;
|
|
987
|
+
let unverifiedLineageBinding = false;
|
|
988
|
+
let invalidLineageBinding = false;
|
|
989
|
+
const lineageByExecution = new Map();
|
|
990
|
+
const lineageBindingIds = new Set();
|
|
991
|
+
for (const lineage of lineageBindings) {
|
|
992
|
+
if (lineageBindingIds.has(lineage.binding.bindingId))
|
|
993
|
+
invalidLineageBinding = true;
|
|
994
|
+
lineageBindingIds.add(lineage.binding.bindingId);
|
|
995
|
+
const related = lineageByExecution.get(lineage.binding.executionId) ?? [];
|
|
996
|
+
related.push(lineage);
|
|
997
|
+
lineageByExecution.set(lineage.binding.executionId, related);
|
|
998
|
+
const assertedIdentity = lineage.validation.reasonCodes.includes('realization_unit_identity_unverified');
|
|
999
|
+
const validationAccepted = lineage.validation.state === 'valid'
|
|
1000
|
+
|| (lineage.validation.state === 'invalid'
|
|
1001
|
+
&& lineage.validation.reasonCodes.length > 0
|
|
1002
|
+
&& lineage.validation.reasonCodes.every((reason) => reason === 'ledger_verification_unresolved'
|
|
1003
|
+
|| reason === 'realization_unit_identity_unverified')
|
|
1004
|
+
&& assertedIdentity);
|
|
1005
|
+
if (!validationAccepted)
|
|
1006
|
+
invalidLineageBinding = true;
|
|
1007
|
+
if (validationAccepted && assertedIdentity)
|
|
1008
|
+
unverifiedLineageBinding = true;
|
|
1009
|
+
}
|
|
1010
|
+
if (duplicateQualificationIds(executions.map((execution) => execution.executionId))
|
|
1011
|
+
|| duplicateQualificationIds(executions.map((execution) => execution.decisionId))) {
|
|
1012
|
+
reasons.push('duplicate V2 execution identity');
|
|
1013
|
+
}
|
|
1014
|
+
for (const execution of executions) {
|
|
1015
|
+
const decision = decisionById.get(execution.decisionId);
|
|
1016
|
+
const arm = decision && protocol.arms.find((candidate) => candidate.armId === decision.assignedArmId);
|
|
1017
|
+
const valid = decision !== undefined && arm !== undefined
|
|
1018
|
+
&& execution.type === 'segreant.causal-execution'
|
|
1019
|
+
&& execution.version === 2
|
|
1020
|
+
&& execution.studyId === protocol.studyId
|
|
1021
|
+
&& execution.protocolHash === protocol.protocolHash
|
|
1022
|
+
&& isCausalIdentifier(execution.executionId)
|
|
1023
|
+
&& execution.assignedExecutionPlanDigest === arm.executionPlanDigest
|
|
1024
|
+
&& execution.actualExecutionPlanDigest === arm.executionPlanDigest
|
|
1025
|
+
&& execution.adherence === 'confirmed'
|
|
1026
|
+
&& execution.previousEventHash === decision.eventHash
|
|
1027
|
+
&& Number.isSafeInteger(execution.startedAtMs)
|
|
1028
|
+
&& Number.isSafeInteger(execution.completedAtMs)
|
|
1029
|
+
&& execution.startedAtMs >= decision.assignedAtMs
|
|
1030
|
+
&& execution.completedAtMs >= execution.startedAtMs
|
|
1031
|
+
&& execution.startedAtMs >= protocol.studyWindow.startsAtMs
|
|
1032
|
+
&& (protocol.studyWindow.endsAtMs === null || execution.completedAtMs <= protocol.studyWindow.endsAtMs)
|
|
1033
|
+
&& execution.directAiCostUsd !== null
|
|
1034
|
+
&& finiteStoredValue(execution.directAiCostUsd, protocol.costOutcome.boundsUsd)
|
|
1035
|
+
&& (execution.directCostSourceClass === 'actual_observed' || execution.directCostSourceClass === 'actual_reconciled')
|
|
1036
|
+
&& protocol.costOutcome.acceptedSourceClasses.includes(execution.directCostSourceClass)
|
|
1037
|
+
&& execution.priceLineageDigests.length > 0
|
|
1038
|
+
&& execution.priceLineageDigests.every(isQualificationDigest)
|
|
1039
|
+
&& (protocol.question === 'model_cost_quality'
|
|
1040
|
+
? execution.fullArmCostUsd === null
|
|
1041
|
+
&& execution.fullCostSourceClass === 'incomplete_or_unknown'
|
|
1042
|
+
: execution.fullArmCostUsd !== null
|
|
1043
|
+
&& finiteStoredValue(execution.fullArmCostUsd, protocol.costOutcome.boundsUsd)
|
|
1044
|
+
&& (execution.fullCostSourceClass === 'actual_observed'
|
|
1045
|
+
|| execution.fullCostSourceClass === 'actual_reconciled')
|
|
1046
|
+
&& protocol.costOutcome.acceptedSourceClasses.includes(execution.fullCostSourceClass)
|
|
1047
|
+
&& execution.priceLineageDigests.length > 0
|
|
1048
|
+
&& execution.priceLineageDigests.every(isQualificationDigest));
|
|
1049
|
+
if (!valid) {
|
|
1050
|
+
reasons.push('invalid V2 execution evidence');
|
|
1051
|
+
invalidExecutionDecisions.add(execution.decisionId);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
if (execution.ordinaryLedgerVerifier.state === 'unresolved'
|
|
1055
|
+
&& (execution.directAiCostUsd !== null || execution.fullArmCostUsd !== null)) {
|
|
1056
|
+
unresolvedCostVerification = true;
|
|
1057
|
+
}
|
|
1058
|
+
if (execution.directAiCostUsd !== null || execution.fullArmCostUsd !== null) {
|
|
1059
|
+
const related = lineageByExecution.get(execution.executionId) ?? [];
|
|
1060
|
+
if (related.length !== 1)
|
|
1061
|
+
missingLineageBinding = true;
|
|
1062
|
+
}
|
|
1063
|
+
executionByDecision.set(execution.decisionId, execution);
|
|
1064
|
+
}
|
|
1065
|
+
const terminalByDecision = new Map();
|
|
1066
|
+
if (duplicateQualificationIds(terminalOutcomes.map((outcome) => outcome.outcomeId))
|
|
1067
|
+
|| duplicateQualificationIds(terminalOutcomes.map((outcome) => outcome.decisionId))) {
|
|
1068
|
+
reasons.push('duplicate V2 terminal identity');
|
|
1069
|
+
}
|
|
1070
|
+
const invalidTerminalDecisions = new Set();
|
|
1071
|
+
for (const outcome of terminalOutcomes) {
|
|
1072
|
+
const decision = decisionById.get(outcome.decisionId);
|
|
1073
|
+
const execution = executionByDecision.get(outcome.decisionId);
|
|
1074
|
+
const lineageValid = decision !== undefined && execution !== undefined
|
|
1075
|
+
&& outcome.type === 'segreant.causal-terminal-outcome'
|
|
1076
|
+
&& outcome.version === 2
|
|
1077
|
+
&& outcome.studyId === protocol.studyId
|
|
1078
|
+
&& outcome.protocolHash === protocol.protocolHash
|
|
1079
|
+
&& isCausalIdentifier(outcome.outcomeId)
|
|
1080
|
+
&& outcome.previousEventHash === execution.eventHash
|
|
1081
|
+
&& Number.isSafeInteger(outcome.observedAtMs)
|
|
1082
|
+
&& outcome.observedAtMs >= execution.completedAtMs;
|
|
1083
|
+
if (!lineageValid) {
|
|
1084
|
+
reasons.push('invalid V2 terminal outcome evidence');
|
|
1085
|
+
invalidTerminalDecisions.add(outcome.decisionId);
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (outcome.maturity === 'matured') {
|
|
1089
|
+
const qualityValid = finiteStoredValue(outcome.qualityValue, protocol.qualityOutcome.bounds)
|
|
1090
|
+
&& outcome.qualityEvidenceClass === protocol.qualityOutcome.evidenceClass
|
|
1091
|
+
&& outcome.outcomeEvidenceDigests.length > 0
|
|
1092
|
+
&& outcome.outcomeEvidenceDigests.every(isQualificationDigest);
|
|
1093
|
+
const economic = protocol.economicOutcome;
|
|
1094
|
+
const economicValid = protocol.question === 'model_cost_quality'
|
|
1095
|
+
? economic === null && outcome.economicValueUsd === null && outcome.economicEvidenceClass === null
|
|
1096
|
+
: economic !== null
|
|
1097
|
+
&& finiteStoredValue(outcome.economicValueUsd, economic.boundsUsd)
|
|
1098
|
+
&& outcome.economicEvidenceClass === economic.evidenceClass;
|
|
1099
|
+
if (!qualityValid || !economicValid) {
|
|
1100
|
+
reasons.push('invalid V2 matured outcome evidence');
|
|
1101
|
+
invalidTerminalDecisions.add(outcome.decisionId);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
else if (outcome.maturity === 'censored') {
|
|
1106
|
+
if (!Object.hasOwn(protocol, 'followUpWindowMs')) {
|
|
1107
|
+
reasons.push('V2 censored outcome is unsupported without a committed follow-up policy');
|
|
1108
|
+
invalidTerminalDecisions.add(outcome.decisionId);
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
else if (outcome.maturity === 'invalid') {
|
|
1113
|
+
if (outcome.qualityValue !== null || outcome.qualityEvidenceClass !== null
|
|
1114
|
+
|| outcome.economicValueUsd !== null || outcome.economicEvidenceClass !== null
|
|
1115
|
+
|| outcome.outcomeEvidenceDigests.length !== 0 || outcome.invalidReason === null
|
|
1116
|
+
|| outcome.censoredReason !== null) {
|
|
1117
|
+
reasons.push('invalid V2 invalid-outcome evidence');
|
|
1118
|
+
invalidTerminalDecisions.add(outcome.decisionId);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
reasons.push('invalid V2 terminal maturity');
|
|
1124
|
+
invalidTerminalDecisions.add(outcome.decisionId);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
terminalByDecision.set(outcome.decisionId, outcome);
|
|
1128
|
+
}
|
|
1129
|
+
for (const decision of decisionById.values()) {
|
|
1130
|
+
const count = countsByArm[decision.assignedArmId];
|
|
1131
|
+
if (invalidExecutionDecisions.has(decision.decisionId)
|
|
1132
|
+
|| invalidTerminalDecisions.has(decision.decisionId)) {
|
|
1133
|
+
count.invalid += 1;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
const terminal = terminalByDecision.get(decision.decisionId);
|
|
1137
|
+
if (!executionByDecision.has(decision.decisionId) || !terminal) {
|
|
1138
|
+
count.pending += 1;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
if (terminal.maturity === 'matured')
|
|
1142
|
+
count.completed += 1;
|
|
1143
|
+
else if (terminal.maturity === 'censored')
|
|
1144
|
+
count.censored += 1;
|
|
1145
|
+
else
|
|
1146
|
+
count.invalid += 1;
|
|
1147
|
+
}
|
|
1148
|
+
// A sidecar row is useful only when its three immutable anchors point to the
|
|
1149
|
+
// same retained decision/execution/outcome set. Rows with a valid envelope
|
|
1150
|
+
// but a cross-study or orphaned anchor are invalid evidence, not harmless
|
|
1151
|
+
// extras. The per-row semantic validation above also proves request and
|
|
1152
|
+
// scalar-realization digests against the current local ledger.
|
|
1153
|
+
for (const lineage of lineageBindings) {
|
|
1154
|
+
const binding = lineage.binding;
|
|
1155
|
+
const decision = decisionById.get(binding.decisionId);
|
|
1156
|
+
const execution = decision ? executionByDecision.get(decision.decisionId) : undefined;
|
|
1157
|
+
const outcome = decision ? terminalByDecision.get(decision.decisionId) : undefined;
|
|
1158
|
+
if (!decision || !execution || !outcome
|
|
1159
|
+
|| binding.studyId !== protocol.studyId
|
|
1160
|
+
|| binding.protocolHash !== protocol.protocolHash
|
|
1161
|
+
|| binding.unitIdDigest !== decision.unitIdDigest
|
|
1162
|
+
|| binding.executionId !== execution.executionId
|
|
1163
|
+
|| binding.outcomeId !== outcome.outcomeId
|
|
1164
|
+
|| binding.decisionId !== execution.decisionId) {
|
|
1165
|
+
invalidLineageBinding = true;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (invalidLineageBinding)
|
|
1169
|
+
reasons.push(CAUSAL_LINEAGE_BINDING_INVALID);
|
|
1170
|
+
if (Object.values(countsByArm).some((count) => count.invalid > 0)) {
|
|
1171
|
+
reasons.push('V2 invalid terminal outcome present');
|
|
1172
|
+
}
|
|
1173
|
+
if (reasons.length > 0)
|
|
1174
|
+
return structuralQualificationV2(data, 'invalid', reasons, countsByArm);
|
|
1175
|
+
if (Object.values(countsByArm).some((count) => count.pending > 0)) {
|
|
1176
|
+
return structuralQualificationV2(data, 'collecting', ['V2 terminal outcomes are still collecting'], countsByArm);
|
|
1177
|
+
}
|
|
1178
|
+
for (const count of Object.values(countsByArm)) {
|
|
1179
|
+
if (count.assigned > 0 && count.censored / count.assigned > protocol.analysis.maxMissingFractionPerArm) {
|
|
1180
|
+
reasons.push('V2 terminal missingness exceeds the committed limit');
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (reasons.length > 0)
|
|
1184
|
+
return structuralQualificationV2(data, 'invalid', reasons, countsByArm);
|
|
1185
|
+
if (unresolvedCostVerification) {
|
|
1186
|
+
reasons.push('V2 ordinary ledger cost verification is unresolved');
|
|
1187
|
+
}
|
|
1188
|
+
if (missingLineageBinding)
|
|
1189
|
+
reasons.push(CAUSAL_LINEAGE_BINDING_NOT_PERSISTED);
|
|
1190
|
+
if (unverifiedLineageBinding)
|
|
1191
|
+
reasons.push(CAUSAL_LINEAGE_REALIZATION_IDENTITY_UNVERIFIED);
|
|
1192
|
+
if (Object.values(countsByArm).some((count) => count.completed < protocol.analysis.minCompletedPerArm)) {
|
|
1193
|
+
reasons.push('V2 matured completion support is below the committed minimum');
|
|
1194
|
+
}
|
|
1195
|
+
if (reasons.length > 0) {
|
|
1196
|
+
return structuralQualificationV2(data, 'inconclusive', reasons, countsByArm);
|
|
1197
|
+
}
|
|
1198
|
+
return structuralQualificationV2(data, 'qualified', [], countsByArm);
|
|
1199
|
+
}
|
|
1200
|
+
function terminalOutcomeSatisfiesStoredV2Protocol(protocol, execution, outcome, asOfMs) {
|
|
1201
|
+
if (outcome.studyId !== protocol.studyId
|
|
1202
|
+
|| outcome.protocolHash !== protocol.protocolHash
|
|
1203
|
+
|| outcome.decisionId !== execution.decisionId
|
|
1204
|
+
|| outcome.previousEventHash !== execution.eventHash
|
|
1205
|
+
|| !Number.isSafeInteger(outcome.observedAtMs)
|
|
1206
|
+
|| outcome.observedAtMs < execution.completedAtMs
|
|
1207
|
+
|| outcome.observedAtMs > asOfMs) {
|
|
1208
|
+
return false;
|
|
1209
|
+
}
|
|
1210
|
+
if (outcome.maturity === 'censored') {
|
|
1211
|
+
const deadline = checkedFollowUpDeadlineMs(protocol, execution.completedAtMs);
|
|
1212
|
+
return deadline.kind === 'legacy'
|
|
1213
|
+
|| (deadline.kind === 'valid' && outcome.observedAtMs >= deadline.deadlineMs);
|
|
1214
|
+
}
|
|
1215
|
+
if (outcome.maturity !== 'matured')
|
|
1216
|
+
return true;
|
|
1217
|
+
if (!finiteStoredValue(outcome.qualityValue, protocol.qualityOutcome.bounds)
|
|
1218
|
+
|| outcome.qualityEvidenceClass !== protocol.qualityOutcome.evidenceClass
|
|
1219
|
+
|| outcome.outcomeEvidenceDigests.length === 0
|
|
1220
|
+
|| !outcome.outcomeEvidenceDigests.every(digest)) {
|
|
1221
|
+
return false;
|
|
1222
|
+
}
|
|
1223
|
+
if (protocol.question === 'model_cost_quality') {
|
|
1224
|
+
return protocol.economicOutcome === null
|
|
1225
|
+
&& outcome.economicValueUsd === null
|
|
1226
|
+
&& outcome.economicEvidenceClass === null;
|
|
1227
|
+
}
|
|
1228
|
+
const economic = protocol.economicOutcome;
|
|
1229
|
+
return economic !== null
|
|
1230
|
+
&& finiteStoredValue(outcome.economicValueUsd, economic.boundsUsd)
|
|
1231
|
+
&& outcome.economicEvidenceClass === economic.evidenceClass;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Read one coherent, authenticated V2 snapshot for the internal qualification
|
|
1235
|
+
* evaluator. The manifest is the assignment authority; every retained
|
|
1236
|
+
* execution/outcome row is checked against that exact decision set, and
|
|
1237
|
+
* absence is preserved as pending rather than synthesized.
|
|
1238
|
+
*/
|
|
1239
|
+
export function causalQualificationV2(db, studyId) {
|
|
1240
|
+
if (!safeId(studyId)) {
|
|
1241
|
+
qualificationFail('CAUSAL_RECORD_INVALID', 'causal study id is invalid');
|
|
1242
|
+
}
|
|
1243
|
+
let committed = false;
|
|
1244
|
+
let transactionStarted = false;
|
|
1245
|
+
try {
|
|
1246
|
+
db.prepare('BEGIN IMMEDIATE').run();
|
|
1247
|
+
transactionStarted = true;
|
|
1248
|
+
const asOfMs = captureCausalAsOfMs(db);
|
|
1249
|
+
if (!causalV2SchemaComplete(db)) {
|
|
1250
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 qualification schema is not exact');
|
|
1251
|
+
}
|
|
1252
|
+
const protocol = requireProtocolV2(db, studyId);
|
|
1253
|
+
let blocks;
|
|
1254
|
+
let manifest;
|
|
1255
|
+
try {
|
|
1256
|
+
blocks = scanAssignmentArtifacts(db, protocol);
|
|
1257
|
+
manifest = retainedManifest(db, protocol);
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
if (error && typeof error === 'object' && 'code' in error
|
|
1261
|
+
&& error.code === 'CAUSAL_INTEGRITY_FAILURE') {
|
|
1262
|
+
throw error;
|
|
1263
|
+
}
|
|
1264
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 assignment evidence failed integrity verification');
|
|
1265
|
+
}
|
|
1266
|
+
const decisions = blocks.flatMap((block) => block.decisions);
|
|
1267
|
+
const decisionIds = decisions.map((decision) => decision.decisionId);
|
|
1268
|
+
if (new Set(decisionIds).size !== decisionIds.length) {
|
|
1269
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 assignment decisions contain duplicate identities');
|
|
1270
|
+
}
|
|
1271
|
+
if (manifest === null) {
|
|
1272
|
+
if (decisions.length !== 0) {
|
|
1273
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 assignments have no authoritative manifest');
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
if (manifest.studyId !== protocol.studyId
|
|
1278
|
+
|| manifest.protocolHash !== protocol.protocolHash
|
|
1279
|
+
|| manifest.decisionCount !== decisions.length
|
|
1280
|
+
|| manifest.unitCount !== decisions.length
|
|
1281
|
+
|| manifest.planCount !== blocks.length
|
|
1282
|
+
|| manifest.decisions.length !== decisions.length) {
|
|
1283
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 assignment manifest cardinality is not authoritative');
|
|
1284
|
+
}
|
|
1285
|
+
for (const [index, decision] of decisions.entries()) {
|
|
1286
|
+
const manifestDecision = manifest.decisions[index];
|
|
1287
|
+
if (!manifestDecision
|
|
1288
|
+
|| manifestDecision.decisionId !== decision.decisionId
|
|
1289
|
+
|| manifestDecision.blockId !== decision.blockId
|
|
1290
|
+
|| manifestDecision.blockSequence !== decision.blockSequence
|
|
1291
|
+
|| manifestDecision.decisionIndex !== decision.decisionIndex
|
|
1292
|
+
|| manifestDecision.unitIdDigest !== decision.unitIdDigest
|
|
1293
|
+
|| manifestDecision.assignedArmId !== decision.assignedArmId
|
|
1294
|
+
|| manifestDecision.eventHash !== decision.eventHash
|
|
1295
|
+
|| manifestDecision.planHash !== decision.planHash) {
|
|
1296
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 assignment manifest does not bind every decision');
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const decisionById = new Map(decisions.map((decision) => [decision.decisionId, decision]));
|
|
1301
|
+
const decisionPlaceholders = decisionIds.map(() => '?').join(', ');
|
|
1302
|
+
const executionRows = db.prepare(STORED_EXECUTION_V2_SELECT + 'FROM causal_executions_v2 WHERE study_id = ?'
|
|
1303
|
+
+ (decisionIds.length > 0 ? ' OR decision_id IN (' + decisionPlaceholders + ')' : '')).all(studyId, ...decisionIds);
|
|
1304
|
+
const executions = [];
|
|
1305
|
+
const executionByDecision = new Map();
|
|
1306
|
+
const executionIds = new Set();
|
|
1307
|
+
for (const row of executionRows) {
|
|
1308
|
+
const execution = authenticateStoredExecutionV2(row);
|
|
1309
|
+
const decision = decisionById.get(execution.decisionId);
|
|
1310
|
+
if (!decision
|
|
1311
|
+
|| execution.studyId !== protocol.studyId
|
|
1312
|
+
|| execution.protocolHash !== protocol.protocolHash
|
|
1313
|
+
|| executionIds.has(execution.executionId)
|
|
1314
|
+
|| executionByDecision.has(execution.decisionId)
|
|
1315
|
+
|| !executionSatisfiesStoredV2Protocol(protocol, decision, execution)) {
|
|
1316
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 execution lineage is not authoritative');
|
|
1317
|
+
}
|
|
1318
|
+
if (checkedFollowUpDeadlineMs(protocol, execution.completedAtMs).kind === 'impossible') {
|
|
1319
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 qualification evidence failed integrity verification');
|
|
1320
|
+
}
|
|
1321
|
+
executionIds.add(execution.executionId);
|
|
1322
|
+
executionByDecision.set(execution.decisionId, execution);
|
|
1323
|
+
executions.push(execution);
|
|
1324
|
+
}
|
|
1325
|
+
const terminalRows = db.prepare(STORED_TERMINAL_OUTCOME_V2_SELECT + 'FROM causal_terminal_outcomes_v2 WHERE study_id = ?'
|
|
1326
|
+
+ (decisionIds.length > 0 ? ' OR decision_id IN (' + decisionPlaceholders + ')' : '')).all(studyId, ...decisionIds);
|
|
1327
|
+
const terminalOutcomes = [];
|
|
1328
|
+
const terminalByDecision = new Map();
|
|
1329
|
+
const outcomeIds = new Set();
|
|
1330
|
+
for (const row of terminalRows) {
|
|
1331
|
+
const outcome = authenticateStoredTerminalOutcomeV2(row);
|
|
1332
|
+
const decision = decisionById.get(outcome.decisionId);
|
|
1333
|
+
const execution = executionByDecision.get(outcome.decisionId);
|
|
1334
|
+
if (!decision
|
|
1335
|
+
|| !execution
|
|
1336
|
+
|| outcome.studyId !== protocol.studyId
|
|
1337
|
+
|| outcome.protocolHash !== protocol.protocolHash
|
|
1338
|
+
|| outcomeIds.has(outcome.outcomeId)
|
|
1339
|
+
|| terminalByDecision.has(outcome.decisionId)
|
|
1340
|
+
|| !terminalOutcomeSatisfiesStoredV2Protocol(protocol, execution, outcome, asOfMs)) {
|
|
1341
|
+
qualificationFail('CAUSAL_INTEGRITY_FAILURE', 'stored v2 terminal outcome lineage is not authoritative');
|
|
1342
|
+
}
|
|
1343
|
+
outcomeIds.add(outcome.outcomeId);
|
|
1344
|
+
terminalByDecision.set(outcome.decisionId, outcome);
|
|
1345
|
+
terminalOutcomes.push(outcome);
|
|
1346
|
+
}
|
|
1347
|
+
// The sidecar is an authenticated Store-internal join, not a public
|
|
1348
|
+
// qualification input. Include rows whose identity points at this study's
|
|
1349
|
+
// decisions/executions/outcomes even when their stored study_id disagrees;
|
|
1350
|
+
// otherwise a cross-study transplant could masquerade as a missing row.
|
|
1351
|
+
const lineageBindings = causalLineageBindingsV2(db, studyId, {
|
|
1352
|
+
decisionIds,
|
|
1353
|
+
executionIds: [...executionIds],
|
|
1354
|
+
outcomeIds: [...outcomeIds],
|
|
1355
|
+
}).map((binding) => ({
|
|
1356
|
+
binding,
|
|
1357
|
+
validation: validateCausalLineageBindingV2(db, binding),
|
|
1358
|
+
}));
|
|
1359
|
+
const snapshot = issueQualificationSnapshot({
|
|
1360
|
+
protocol,
|
|
1361
|
+
decisions,
|
|
1362
|
+
executions,
|
|
1363
|
+
terminalOutcomes,
|
|
1364
|
+
lineageBindings,
|
|
1365
|
+
});
|
|
1366
|
+
const result = evaluateQualificationV2(snapshot);
|
|
1367
|
+
db.prepare('COMMIT').run();
|
|
1368
|
+
committed = true;
|
|
1369
|
+
return result;
|
|
1370
|
+
}
|
|
1371
|
+
catch (error) {
|
|
1372
|
+
if (transactionStarted && !committed) {
|
|
1373
|
+
try {
|
|
1374
|
+
db.prepare('ROLLBACK').run();
|
|
1375
|
+
}
|
|
1376
|
+
catch { /* no active transaction */ }
|
|
1377
|
+
}
|
|
1378
|
+
throw normalizedQualificationError(error);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
/** Stored randomisation blocks are retained for allocation replay and review. */
|
|
1382
|
+
export function causalAssignmentPlans(db, studyId) {
|
|
1383
|
+
return db.prepare('SELECT plan_json FROM causal_assignment_plans WHERE study_id = ? ORDER BY created_at_ms, block_id').all(studyId).map((row) => parseJson(row.plan_json, 'assignment plan'));
|
|
1384
|
+
}
|
|
1385
|
+
export function saveCausalAnalysis(db, studyId, analysisId, computedAtMs = Date.now()) {
|
|
1386
|
+
if (!isCausalIdentifier(analysisId) || !Number.isInteger(computedAtMs) || computedAtMs <= 0) {
|
|
1387
|
+
throw new Error('analysisId and computedAtMs must be valid local identifiers/timestamps');
|
|
1388
|
+
}
|
|
1389
|
+
const protocol = loadProtocol(db, studyId);
|
|
1390
|
+
if (protocol)
|
|
1391
|
+
rejectLegacyMutation(protocol, 'save a version-1 analysis snapshot');
|
|
1392
|
+
const data = causalStudyData(db, studyId);
|
|
1393
|
+
// Order matters: a registered study whose projection is deferred is a
|
|
1394
|
+
// different answer from a study that is not there, and reporting the second
|
|
1395
|
+
// for the first sends the operator looking for a missing row.
|
|
1396
|
+
if (!data && protocol)
|
|
1397
|
+
throw new CausalV2AnalysisDeferredError(studyId);
|
|
1398
|
+
if (!data)
|
|
1399
|
+
throw new Error('causal study was not found');
|
|
1400
|
+
const estimate = estimateCausalStudy(data);
|
|
1401
|
+
const snapshot = { analysisId, computedAtMs, estimate };
|
|
1402
|
+
const encoded = canonicalJson(snapshot);
|
|
1403
|
+
const existing = db.prepare('SELECT analysis_json FROM causal_analysis_snapshots WHERE analysis_id = ?')
|
|
1404
|
+
.get(analysisId);
|
|
1405
|
+
if (existing) {
|
|
1406
|
+
if (existing.analysis_json === encoded)
|
|
1407
|
+
return snapshot;
|
|
1408
|
+
throw new Error('analysisId is already recorded with different immutable content');
|
|
1409
|
+
}
|
|
1410
|
+
db.prepare('INSERT INTO causal_analysis_snapshots (analysis_id, study_id, protocol_hash, computed_at_ms, state, analysis_json) VALUES (?, ?, ?, ?, ?, ?)').run(analysisId, studyId, data.protocol.protocolHash, computedAtMs, estimate.qualification.state, encoded);
|
|
1411
|
+
return snapshot;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Rebuild the study's inference ledger from the acts on disk.
|
|
1415
|
+
*
|
|
1416
|
+
* The genesis digest is recomputed by `openCausalInferenceLedger` from the
|
|
1417
|
+
* study and protocol rather than stored, so a row that claims to belong to a
|
|
1418
|
+
* different protocol cannot graft itself onto this chain. Nothing here VERIFIES
|
|
1419
|
+
* the chain — that is `summarizeInferenceMultiplicity`'s job and it must stay
|
|
1420
|
+
* there, because a loader that quietly dropped a bad row would turn a detected
|
|
1421
|
+
* break into a smaller look count, which is the failure this whole mechanism
|
|
1422
|
+
* exists to prevent.
|
|
1423
|
+
*
|
|
1424
|
+
* A plan is loaded only when the Store registration boundary wrote one before
|
|
1425
|
+
* the first act. The protocol hash is checked at load time so a plan cannot be
|
|
1426
|
+
* transplanted between studies or silently survive a protocol replacement.
|
|
1427
|
+
*/
|
|
1428
|
+
export function causalInferenceLedger(db, studyId, protocolHash) {
|
|
1429
|
+
const planRow = db.prepare('SELECT protocol_hash, plan_json FROM causal_inference_plans WHERE study_id = ?').get(studyId);
|
|
1430
|
+
let plan = null;
|
|
1431
|
+
if (planRow) {
|
|
1432
|
+
if (planRow.protocol_hash !== protocolHash) {
|
|
1433
|
+
throw new Error('stored inference plan does not bind the requested causal protocol');
|
|
1434
|
+
}
|
|
1435
|
+
plan = parseJson(planRow.plan_json, 'inference plan');
|
|
1436
|
+
validateCausalInferencePlan(plan);
|
|
1437
|
+
}
|
|
1438
|
+
const empty = openCausalInferenceLedger({ studyId, protocolHash, plan });
|
|
1439
|
+
const rows = db.prepare('SELECT act_json FROM causal_inference_acts WHERE study_id = ? ORDER BY sequence').all(studyId);
|
|
1440
|
+
if (rows.length === 0)
|
|
1441
|
+
return empty;
|
|
1442
|
+
const acts = rows.map((row) => parseJson(row.act_json, 'inference act'));
|
|
1443
|
+
return Object.freeze({
|
|
1444
|
+
...empty,
|
|
1445
|
+
acts: Object.freeze(acts),
|
|
1446
|
+
ledgerDigest: acts[acts.length - 1].actDigest,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Report the study, recording the look.
|
|
1451
|
+
*
|
|
1452
|
+
* WHY A READ PATH WRITES. Segreant is read-only by default and `--apply`
|
|
1453
|
+
* persists, and this does not breach that. An inferential act is not a change
|
|
1454
|
+
* to the operator's data, to provider routing, or to budgets — it is an audit
|
|
1455
|
+
* record of something that already happened, in the same category as an egress
|
|
1456
|
+
* receipt, which is appended before the request it describes and whose failure
|
|
1457
|
+
* stops the request. `reportCausalStudyEstimate` says in as many words that
|
|
1458
|
+
* reporting IS the act. Declining to record it would not leave the count
|
|
1459
|
+
* unchanged; it would leave it wrong, which is exactly the state every surface
|
|
1460
|
+
* was in before this existed.
|
|
1461
|
+
*
|
|
1462
|
+
* The acts and the report are written under one transaction so a report can
|
|
1463
|
+
* never be returned on the strength of a look that was not recorded.
|
|
1464
|
+
*
|
|
1465
|
+
* Returns null for a study with no version-1 analysis path, matching
|
|
1466
|
+
* `causalStudyData`: a caller asking about a study this build cannot analyse
|
|
1467
|
+
* has not made an error, and inventing an empty report for it would be the
|
|
1468
|
+
* absence-as-result defect in a new place.
|
|
1469
|
+
*/
|
|
1470
|
+
export function reportCausalStudy(db, studyId, reportedAtMs) {
|
|
1471
|
+
if (!Number.isInteger(reportedAtMs) || reportedAtMs <= 0) {
|
|
1472
|
+
throw new Error('reportedAtMs must be a positive integer millisecond timestamp');
|
|
1473
|
+
}
|
|
1474
|
+
const data = causalStudyData(db, studyId);
|
|
1475
|
+
if (data === null)
|
|
1476
|
+
return null;
|
|
1477
|
+
const before = causalInferenceLedger(db, studyId, data.protocol.protocolHash);
|
|
1478
|
+
const { ledger, report } = reportCausalStudyEstimate(before, data, { reportedAtMs });
|
|
1479
|
+
// A BROKEN CHAIN IS NOT EXTENDED. If the stored acts no longer verify, the
|
|
1480
|
+
// sequence a new act would take is already occupied or already wrong, and
|
|
1481
|
+
// appending would produce a chain that verifies forward from a forged start
|
|
1482
|
+
// -- a smaller look count wearing the appearance of an intact one. The report
|
|
1483
|
+
// is still returned, and it already carries the right answer: the break
|
|
1484
|
+
// propagates through `verifyActChain`, so `chainIntact` is false and the
|
|
1485
|
+
// claim is withheld as `not_established` because the number of looks behind
|
|
1486
|
+
// it is unknown. Withholding is the point; repairing would be the defect.
|
|
1487
|
+
if (before.acts.length > 0 && !summarizeInferenceMultiplicity(before).chainIntact)
|
|
1488
|
+
return report;
|
|
1489
|
+
const insert = db.prepare('INSERT INTO causal_inference_acts '
|
|
1490
|
+
+ '(study_id, sequence, protocol_hash, reported_at_ms, previous_act_digest, act_digest, act_json) '
|
|
1491
|
+
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
1492
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1493
|
+
try {
|
|
1494
|
+
for (const act of ledger.acts.slice(before.acts.length)) {
|
|
1495
|
+
insert.run(act.studyId, act.sequence, act.protocolHash, act.reportedAtMs, act.previousActDigest, act.actDigest, canonicalJson(act));
|
|
1496
|
+
}
|
|
1497
|
+
db.exec('COMMIT');
|
|
1498
|
+
}
|
|
1499
|
+
catch (error) {
|
|
1500
|
+
db.exec('ROLLBACK');
|
|
1501
|
+
throw error;
|
|
1502
|
+
}
|
|
1503
|
+
return report;
|
|
1504
|
+
}
|
|
1505
|
+
export function causalAnalysisSnapshotBasis(db, studyId) {
|
|
1506
|
+
const records = causalAnalysisSnapshots(db, studyId);
|
|
1507
|
+
const protocol = loadProtocol(db, studyId);
|
|
1508
|
+
if (protocol === null) {
|
|
1509
|
+
return Object.freeze({
|
|
1510
|
+
available: false,
|
|
1511
|
+
reason: 'causal study ' + studyId + ' is not registered in this local Store',
|
|
1512
|
+
records,
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
if (protocol.version === 1) {
|
|
1516
|
+
return Object.freeze({
|
|
1517
|
+
available: false,
|
|
1518
|
+
reason: 'retained version-1 causal evidence is inspect-only, so no new analysis snapshot can be written for this study',
|
|
1519
|
+
records,
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
return Object.freeze({
|
|
1523
|
+
available: false,
|
|
1524
|
+
reason: 'version-2 analysis projection is deferred, so no analysis snapshot can be written for this study by this build; '
|
|
1525
|
+
+ 'an empty list here means none CAN be written, not that none has been',
|
|
1526
|
+
records,
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
export function causalAnalysisSnapshots(db, studyId) {
|
|
1530
|
+
return db.prepare('SELECT analysis_json FROM causal_analysis_snapshots WHERE study_id = ? ORDER BY computed_at_ms DESC, analysis_id DESC').all(studyId).map((row) => {
|
|
1531
|
+
const snapshot = parseJson(row.analysis_json, 'analysis snapshot');
|
|
1532
|
+
return {
|
|
1533
|
+
...snapshot,
|
|
1534
|
+
estimate: { ...snapshot.estimate, designEstimatorId: snapshot.estimate.designEstimatorId ?? null },
|
|
1535
|
+
};
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
export function causalStudySummaries(db) {
|
|
1539
|
+
return causalStudyListBasis(db).studies;
|
|
1540
|
+
}
|
|
1541
|
+
export function causalStudyListBasis(db) {
|
|
1542
|
+
const rows = db.prepare('SELECT p.study_id, p.protocol_hash, typeof(p.committed_at_ms) AS committed_at_ms_type, ' +
|
|
1543
|
+
'CAST(p.committed_at_ms AS TEXT) AS committed_at_ms_text, p.protocol_json, ' +
|
|
1544
|
+
'(SELECT COUNT(*) FROM causal_decisions d WHERE d.study_id = p.study_id) AS decisions, ' +
|
|
1545
|
+
'(SELECT COUNT(*) FROM causal_executions e WHERE e.study_id = p.study_id) AS executions, ' +
|
|
1546
|
+
'(SELECT COUNT(*) FROM causal_outcomes o WHERE o.study_id = p.study_id) AS outcomes, ' +
|
|
1547
|
+
'a.analysis_id, a.analysis_at_type, a.analysis_at_text, a.analysis_state ' +
|
|
1548
|
+
'FROM causal_protocols p LEFT JOIN (' +
|
|
1549
|
+
'SELECT latest.study_id, latest.analysis_id, ' +
|
|
1550
|
+
'typeof(latest.computed_at_ms) AS analysis_at_type, ' +
|
|
1551
|
+
'CAST(latest.computed_at_ms AS TEXT) AS analysis_at_text, latest.state AS analysis_state ' +
|
|
1552
|
+
'FROM causal_analysis_snapshots latest WHERE latest.analysis_id = (' +
|
|
1553
|
+
'SELECT candidate.analysis_id FROM causal_analysis_snapshots candidate ' +
|
|
1554
|
+
'WHERE candidate.study_id = latest.study_id ' +
|
|
1555
|
+
'ORDER BY candidate.computed_at_ms DESC, candidate.analysis_id DESC LIMIT 1' +
|
|
1556
|
+
')' +
|
|
1557
|
+
') a ON a.study_id = p.study_id ' +
|
|
1558
|
+
'ORDER BY p.committed_at_ms DESC, p.study_id DESC').all();
|
|
1559
|
+
let omittedCount = 0;
|
|
1560
|
+
const studies = rows.flatMap((row) => {
|
|
1561
|
+
const decoded = decodeStoredProtocolRow(row);
|
|
1562
|
+
// Counted, not included. The omission stands; what changes is that it is
|
|
1563
|
+
// now reported rather than performed in silence.
|
|
1564
|
+
if (decoded.version === 2) {
|
|
1565
|
+
omittedCount += 1;
|
|
1566
|
+
return [];
|
|
1567
|
+
}
|
|
1568
|
+
const protocol = decoded.protocol;
|
|
1569
|
+
const analysisAtMs = decodeStoredAnalysisAtMs(row);
|
|
1570
|
+
return [{
|
|
1571
|
+
studyId: protocol.studyId,
|
|
1572
|
+
protocolHash: protocol.protocolHash,
|
|
1573
|
+
committedAtMs: protocol.committedAtMs,
|
|
1574
|
+
decisions: row.decisions,
|
|
1575
|
+
executions: row.executions,
|
|
1576
|
+
outcomes: row.outcomes,
|
|
1577
|
+
latestAnalysis: row.analysis_id !== null && analysisAtMs !== null && row.analysis_state
|
|
1578
|
+
? { analysisId: row.analysis_id, computedAtMs: analysisAtMs, state: row.analysis_state }
|
|
1579
|
+
: null,
|
|
1580
|
+
// Every row here is a retained version-1 study by construction, so the
|
|
1581
|
+
// reason is the version-1 one. It is stated per row rather than once per
|
|
1582
|
+
// response because the reason is per study -- not registered, version-1
|
|
1583
|
+
// inspect-only and version-2 deferred are three different sentences --
|
|
1584
|
+
// and a figure carries its own basis.
|
|
1585
|
+
analysisBasis: {
|
|
1586
|
+
available: false,
|
|
1587
|
+
reason: 'retained version-1 causal evidence is inspect-only, so no new analysis snapshot can be written for this study',
|
|
1588
|
+
},
|
|
1589
|
+
}];
|
|
1590
|
+
});
|
|
1591
|
+
return {
|
|
1592
|
+
studies,
|
|
1593
|
+
omitted: {
|
|
1594
|
+
count: omittedCount,
|
|
1595
|
+
reason: omittedCount === 0
|
|
1596
|
+
? 'no registered study was omitted from this list'
|
|
1597
|
+
: 'version-2 public projection is deferred, so registered version-2 studies are not listed here; '
|
|
1598
|
+
+ 'this count is how many, not an assertion that they are absent',
|
|
1599
|
+
},
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
// Version-2 assignment persistence is intentionally private to this module.
|
|
1603
|
+
const ID_RE = /^[a-z][a-z0-9._-]{0,31}:[A-Za-z0-9][A-Za-z0-9._-]{0,126}$/;
|
|
1604
|
+
const DIGEST_RE = /^sha256:[a-f0-9]{64}$/;
|
|
1605
|
+
const ENTROPY_BYTES = 32;
|
|
1606
|
+
const INPUT_KEYS = ['blockId', 'sequence', 'createdAtMs', 'unitIdDigests', 'randomizationMaterial'];
|
|
1607
|
+
const REQUEST_KEYS = ['studyId', 'blockId', 'createdAtMs', 'unitIdDigests'];
|
|
1608
|
+
const PLAN_KEYS = [
|
|
1609
|
+
'type', 'version', 'studyId', 'blockId', 'protocolHash', 'sequence',
|
|
1610
|
+
'createdAtMs', 'blockRoot', 'unitIdDigests', 'randomizationMaterialDigest',
|
|
1611
|
+
'allocationHash', 'decisionIds', 'firstDecisionHash', 'lastDecisionHash',
|
|
1612
|
+
'planHash',
|
|
1613
|
+
];
|
|
1614
|
+
const DECISION_KEYS = [
|
|
1615
|
+
'type', 'version', 'decisionId', 'studyId', 'blockId', 'protocolHash',
|
|
1616
|
+
'blockSequence', 'decisionIndex', 'unitIdDigest', 'assignedAtMs',
|
|
1617
|
+
'assignedArmId', 'propensity', 'blockRoot', 'planHash', 'allocationHash',
|
|
1618
|
+
'randomizationMaterialDigest', 'previousEventHash', 'eventHash',
|
|
1619
|
+
];
|
|
1620
|
+
class CausalAssignmentStoreError extends Error {
|
|
1621
|
+
code;
|
|
1622
|
+
constructor(code, message) {
|
|
1623
|
+
super(code + ': ' + message);
|
|
1624
|
+
this.name = 'CausalAssignmentStoreError';
|
|
1625
|
+
this.code = code;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
function fail(code, message) {
|
|
1629
|
+
throw new CausalAssignmentStoreError(code, message);
|
|
1630
|
+
}
|
|
1631
|
+
function isRecord(value) {
|
|
1632
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1633
|
+
}
|
|
1634
|
+
function positiveSafeInteger(value) {
|
|
1635
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
1636
|
+
}
|
|
1637
|
+
function denseArray(value) {
|
|
1638
|
+
if (!Array.isArray(value))
|
|
1639
|
+
return false;
|
|
1640
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1641
|
+
if (!Object.prototype.hasOwnProperty.call(value, index))
|
|
1642
|
+
return false;
|
|
1643
|
+
}
|
|
1644
|
+
return Object.keys(value).every((key) => /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < value.length);
|
|
1645
|
+
}
|
|
1646
|
+
function safeId(value) {
|
|
1647
|
+
return typeof value === 'string'
|
|
1648
|
+
&& value.length >= 3
|
|
1649
|
+
&& value.length <= 160
|
|
1650
|
+
&& ID_RE.test(value)
|
|
1651
|
+
&& !/(?:bearer|basic)(?:[._-]|$)|api[_-]?key|secret|password|token|(?:^|:)(?:sk|rk|pk)-/i.test(value)
|
|
1652
|
+
&& !/^[^.]+\.[^.]+\.[^.]+$/.test(value);
|
|
1653
|
+
}
|
|
1654
|
+
function digest(value) {
|
|
1655
|
+
return typeof value === 'string' && DIGEST_RE.test(value);
|
|
1656
|
+
}
|
|
1657
|
+
function exactRecord(value, keys, label, errors) {
|
|
1658
|
+
if (!isRecord(value)) {
|
|
1659
|
+
errors.push(label + ' must be an object');
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
for (const key of keys) {
|
|
1663
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
1664
|
+
errors.push(label + ' is missing required field: ' + key);
|
|
1665
|
+
}
|
|
1666
|
+
for (const key of Object.keys(value)) {
|
|
1667
|
+
if (!keys.includes(key))
|
|
1668
|
+
errors.push(label + ' has unsupported field: ' + key);
|
|
1669
|
+
}
|
|
1670
|
+
return true;
|
|
1671
|
+
}
|
|
1672
|
+
function uint64Be(value) {
|
|
1673
|
+
const result = Buffer.alloc(8);
|
|
1674
|
+
result.writeBigUInt64BE(BigInt(value));
|
|
1675
|
+
return result;
|
|
1676
|
+
}
|
|
1677
|
+
function domainHash(domain, material) {
|
|
1678
|
+
return 'sha256:' + createHash('sha256')
|
|
1679
|
+
.update(domain + '\n2\n' + canonicalJson(material))
|
|
1680
|
+
.digest('hex');
|
|
1681
|
+
}
|
|
1682
|
+
function blockRoot(protocol, blockId) {
|
|
1683
|
+
return 'sha256:' + sha256(canonicalJson({
|
|
1684
|
+
domain: 'segreant.causal.assignment-block-root',
|
|
1685
|
+
version: 1,
|
|
1686
|
+
studyId: protocol.studyId,
|
|
1687
|
+
protocolHash: protocol.protocolHash,
|
|
1688
|
+
blockId,
|
|
1689
|
+
}));
|
|
1690
|
+
}
|
|
1691
|
+
function materialDigest(material) {
|
|
1692
|
+
return 'sha256:' + createHash('sha256')
|
|
1693
|
+
.update(Buffer.from('segreant.causal.randomization-material\n1\n'))
|
|
1694
|
+
.update(uint64Be(material.byteLength))
|
|
1695
|
+
.update(material)
|
|
1696
|
+
.digest('hex');
|
|
1697
|
+
}
|
|
1698
|
+
function decisionId(protocol, blockId, blockSequence, decisionIndex, unitIdDigest) {
|
|
1699
|
+
return 'decision:' + sha256(canonicalJson({
|
|
1700
|
+
domain: 'segreant.causal.decision-id',
|
|
1701
|
+
version: 1,
|
|
1702
|
+
studyId: protocol.studyId,
|
|
1703
|
+
protocolHash: protocol.protocolHash,
|
|
1704
|
+
blockId,
|
|
1705
|
+
blockSequence,
|
|
1706
|
+
decisionIndex,
|
|
1707
|
+
unitIdDigest,
|
|
1708
|
+
}));
|
|
1709
|
+
}
|
|
1710
|
+
function shuffledArms(protocol, blockId, root, sequence, unitIdDigests, material) {
|
|
1711
|
+
const orderedArmIds = protocol.arms.map((arm) => arm.armId);
|
|
1712
|
+
const arms = [];
|
|
1713
|
+
const repetitions = unitIdDigests.length / orderedArmIds.length;
|
|
1714
|
+
for (const armId of orderedArmIds) {
|
|
1715
|
+
for (let index = 0; index < repetitions; index += 1)
|
|
1716
|
+
arms.push(armId);
|
|
1717
|
+
}
|
|
1718
|
+
const context = canonicalJson({
|
|
1719
|
+
studyId: protocol.studyId,
|
|
1720
|
+
protocolHash: protocol.protocolHash,
|
|
1721
|
+
blockId,
|
|
1722
|
+
blockRoot: root,
|
|
1723
|
+
sequence,
|
|
1724
|
+
unitIdDigests,
|
|
1725
|
+
orderedArmIds,
|
|
1726
|
+
});
|
|
1727
|
+
let counter = 0;
|
|
1728
|
+
const nextWord = () => {
|
|
1729
|
+
const digestBytes = createHash('sha256')
|
|
1730
|
+
.update(Buffer.from('segreant.causal.assignment-shuffle\n2\n'))
|
|
1731
|
+
.update(uint64Be(material.byteLength))
|
|
1732
|
+
.update(material)
|
|
1733
|
+
.update(uint64Be(Buffer.byteLength(context)))
|
|
1734
|
+
.update(context)
|
|
1735
|
+
.update(uint64Be(counter))
|
|
1736
|
+
.digest();
|
|
1737
|
+
try {
|
|
1738
|
+
const result = digestBytes.readUInt32BE(0);
|
|
1739
|
+
counter += 1;
|
|
1740
|
+
return result;
|
|
1741
|
+
}
|
|
1742
|
+
finally {
|
|
1743
|
+
digestBytes.fill(0);
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
for (let index = arms.length - 1; index > 0; index -= 1) {
|
|
1747
|
+
const range = index + 1;
|
|
1748
|
+
const acceptanceLimit = Math.floor(0x1_0000_0000 / range) * range;
|
|
1749
|
+
let word = nextWord();
|
|
1750
|
+
while (word >= acceptanceLimit)
|
|
1751
|
+
word = nextWord();
|
|
1752
|
+
const swapIndex = word % range;
|
|
1753
|
+
const current = arms[index];
|
|
1754
|
+
arms[index] = arms[swapIndex];
|
|
1755
|
+
arms[swapIndex] = current;
|
|
1756
|
+
}
|
|
1757
|
+
return arms;
|
|
1758
|
+
}
|
|
1759
|
+
function allocationHash(protocol, blockId, root, sequence, assignments, randomizationMaterialDigest) {
|
|
1760
|
+
return domainHash('segreant.causal.assignment-allocation', {
|
|
1761
|
+
studyId: protocol.studyId,
|
|
1762
|
+
protocolHash: protocol.protocolHash,
|
|
1763
|
+
blockId,
|
|
1764
|
+
blockRoot: root,
|
|
1765
|
+
sequence,
|
|
1766
|
+
assignments,
|
|
1767
|
+
randomizationMaterialDigest,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
function planHash(protocol, material) {
|
|
1771
|
+
return domainHash('segreant.causal.assignment-plan', {
|
|
1772
|
+
type: 'segreant.causal-assignment-plan',
|
|
1773
|
+
version: 2,
|
|
1774
|
+
studyId: protocol.studyId,
|
|
1775
|
+
protocolHash: protocol.protocolHash,
|
|
1776
|
+
blockId: material.blockId,
|
|
1777
|
+
blockRoot: material.blockRoot,
|
|
1778
|
+
blockSequence: material.sequence,
|
|
1779
|
+
createdAtMs: material.createdAtMs,
|
|
1780
|
+
randomizationMaterialDigest: material.randomizationMaterialDigest,
|
|
1781
|
+
allocationHash: material.allocationHash,
|
|
1782
|
+
unitIdDigests: material.unitIdDigests,
|
|
1783
|
+
decisionIds: material.decisionIds,
|
|
1784
|
+
allocation: {
|
|
1785
|
+
method: protocol.allocation.method,
|
|
1786
|
+
blockSize: protocol.allocation.blockSize,
|
|
1787
|
+
probabilityPerArm: protocol.allocation.probabilityPerArm,
|
|
1788
|
+
orderedArmIds: protocol.arms.map((arm) => arm.armId),
|
|
1789
|
+
},
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
function decisionHash(decision) {
|
|
1793
|
+
const { eventHash: _eventHash, ...material } = decision;
|
|
1794
|
+
return domainHash('segreant.causal.decision', material);
|
|
1795
|
+
}
|
|
1796
|
+
function validateDerivationInput(protocol, input) {
|
|
1797
|
+
const errors = verifyCommittedCausalProtocol(protocol);
|
|
1798
|
+
if (protocol?.version !== 2)
|
|
1799
|
+
errors.push('v2 assignment requires a committed v2 protocol');
|
|
1800
|
+
if (errors.length > 0)
|
|
1801
|
+
return errors;
|
|
1802
|
+
if (!exactRecord(input, INPUT_KEYS, 'v2 assignment input', errors))
|
|
1803
|
+
return errors;
|
|
1804
|
+
if (!safeId(input.blockId))
|
|
1805
|
+
errors.push('blockId must be a safe namespaced identifier');
|
|
1806
|
+
if (!positiveSafeInteger(input.sequence))
|
|
1807
|
+
errors.push('sequence must be a positive safe integer');
|
|
1808
|
+
if (!positiveSafeInteger(input.createdAtMs)) {
|
|
1809
|
+
errors.push('createdAtMs must be a positive safe-integer epoch timestamp');
|
|
1810
|
+
}
|
|
1811
|
+
else {
|
|
1812
|
+
if (input.createdAtMs < protocol.studyWindow.startsAtMs)
|
|
1813
|
+
errors.push('assignment must not precede the study window');
|
|
1814
|
+
if (protocol.studyWindow.endsAtMs !== null && input.createdAtMs > protocol.studyWindow.endsAtMs) {
|
|
1815
|
+
errors.push('assignment must not follow the study window');
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (!denseArray(input.unitIdDigests)) {
|
|
1819
|
+
errors.push('unitIdDigests must be a dense ordered array');
|
|
1820
|
+
}
|
|
1821
|
+
else {
|
|
1822
|
+
if (input.unitIdDigests.length !== protocol.allocation.blockSize) {
|
|
1823
|
+
errors.push('unitIdDigests must contain exactly the committed protocol block size');
|
|
1824
|
+
}
|
|
1825
|
+
if (!input.unitIdDigests.every(digest))
|
|
1826
|
+
errors.push('unitIdDigests must contain lowercase namespaced SHA-256 digests');
|
|
1827
|
+
if (new Set(input.unitIdDigests).size !== input.unitIdDigests.length)
|
|
1828
|
+
errors.push('unitIdDigests must be unique within the ordered block');
|
|
1829
|
+
}
|
|
1830
|
+
if (!(input.randomizationMaterial instanceof Uint8Array) || input.randomizationMaterial.byteLength !== ENTROPY_BYTES) {
|
|
1831
|
+
errors.push('randomization material must be exactly 32 raw bytes');
|
|
1832
|
+
}
|
|
1833
|
+
return errors;
|
|
1834
|
+
}
|
|
1835
|
+
/** Pure formula executor. It is reachable only through this non-package module. */
|
|
1836
|
+
function deriveCausalAssignmentBlockV2Internal(protocol, input) {
|
|
1837
|
+
const errors = validateDerivationInput(protocol, input);
|
|
1838
|
+
if (errors.length > 0)
|
|
1839
|
+
throw new Error('cannot derive v2 blocked causal assignment: ' + errors.join('; '));
|
|
1840
|
+
const material = input.randomizationMaterial;
|
|
1841
|
+
const root = blockRoot(protocol, input.blockId);
|
|
1842
|
+
const randomizationMaterialDigest = materialDigest(material);
|
|
1843
|
+
const assignedArmIds = shuffledArms(protocol, input.blockId, root, input.sequence, input.unitIdDigests, material);
|
|
1844
|
+
const decisionIds = input.unitIdDigests.map((unitIdDigest, index) => decisionId(protocol, input.blockId, input.sequence, index + 1, unitIdDigest));
|
|
1845
|
+
const assignments = input.unitIdDigests.map((unitIdDigest, index) => ({
|
|
1846
|
+
decisionIndex: index + 1,
|
|
1847
|
+
unitIdDigest,
|
|
1848
|
+
assignedArmId: assignedArmIds[index],
|
|
1849
|
+
propensity: 0.5,
|
|
1850
|
+
}));
|
|
1851
|
+
const allocation = allocationHash(protocol, input.blockId, root, input.sequence, assignments, randomizationMaterialDigest);
|
|
1852
|
+
const plan = planHash(protocol, {
|
|
1853
|
+
blockId: input.blockId,
|
|
1854
|
+
blockRoot: root,
|
|
1855
|
+
sequence: input.sequence,
|
|
1856
|
+
createdAtMs: input.createdAtMs,
|
|
1857
|
+
randomizationMaterialDigest,
|
|
1858
|
+
allocationHash: allocation,
|
|
1859
|
+
unitIdDigests: input.unitIdDigests,
|
|
1860
|
+
decisionIds,
|
|
1861
|
+
});
|
|
1862
|
+
const decisions = [];
|
|
1863
|
+
let previousEventHash = root;
|
|
1864
|
+
for (let index = 0; index < assignments.length; index += 1) {
|
|
1865
|
+
const assignment = assignments[index];
|
|
1866
|
+
const materialDecision = {
|
|
1867
|
+
type: 'segreant.causal-decision',
|
|
1868
|
+
version: 2,
|
|
1869
|
+
decisionId: decisionIds[index],
|
|
1870
|
+
studyId: protocol.studyId,
|
|
1871
|
+
blockId: input.blockId,
|
|
1872
|
+
protocolHash: protocol.protocolHash,
|
|
1873
|
+
blockSequence: input.sequence,
|
|
1874
|
+
decisionIndex: index + 1,
|
|
1875
|
+
unitIdDigest: assignment.unitIdDigest,
|
|
1876
|
+
assignedAtMs: input.createdAtMs,
|
|
1877
|
+
assignedArmId: assignment.assignedArmId,
|
|
1878
|
+
propensity: 0.5,
|
|
1879
|
+
blockRoot: root,
|
|
1880
|
+
planHash: plan,
|
|
1881
|
+
allocationHash: allocation,
|
|
1882
|
+
randomizationMaterialDigest,
|
|
1883
|
+
previousEventHash,
|
|
1884
|
+
};
|
|
1885
|
+
const decision = {
|
|
1886
|
+
...materialDecision,
|
|
1887
|
+
eventHash: decisionHash(materialDecision),
|
|
1888
|
+
};
|
|
1889
|
+
decisions.push(decision);
|
|
1890
|
+
previousEventHash = decision.eventHash;
|
|
1891
|
+
}
|
|
1892
|
+
return {
|
|
1893
|
+
plan: {
|
|
1894
|
+
type: 'segreant.causal-assignment-plan',
|
|
1895
|
+
version: 2,
|
|
1896
|
+
studyId: protocol.studyId,
|
|
1897
|
+
blockId: input.blockId,
|
|
1898
|
+
protocolHash: protocol.protocolHash,
|
|
1899
|
+
sequence: input.sequence,
|
|
1900
|
+
createdAtMs: input.createdAtMs,
|
|
1901
|
+
blockRoot: root,
|
|
1902
|
+
unitIdDigests: [...input.unitIdDigests],
|
|
1903
|
+
randomizationMaterialDigest,
|
|
1904
|
+
allocationHash: allocation,
|
|
1905
|
+
decisionIds,
|
|
1906
|
+
firstDecisionHash: decisions[0].eventHash,
|
|
1907
|
+
lastDecisionHash: decisions.at(-1).eventHash,
|
|
1908
|
+
planHash: plan,
|
|
1909
|
+
},
|
|
1910
|
+
decisions,
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
function validatePlanShape(plan, errors) {
|
|
1914
|
+
if (!exactRecord(plan, PLAN_KEYS, 'v2 assignment plan', errors))
|
|
1915
|
+
return false;
|
|
1916
|
+
if (plan.type !== 'segreant.causal-assignment-plan')
|
|
1917
|
+
errors.push('v2 assignment plan type is invalid');
|
|
1918
|
+
if (plan.version !== 2)
|
|
1919
|
+
errors.push('v2 assignment plan version is invalid');
|
|
1920
|
+
for (const [field, value] of [['studyId', plan.studyId], ['blockId', plan.blockId]]) {
|
|
1921
|
+
if (!safeId(value))
|
|
1922
|
+
errors.push('v2 assignment plan ' + field + ' is invalid');
|
|
1923
|
+
}
|
|
1924
|
+
for (const [field, value] of [
|
|
1925
|
+
['protocolHash', plan.protocolHash], ['blockRoot', plan.blockRoot],
|
|
1926
|
+
['randomizationMaterialDigest', plan.randomizationMaterialDigest],
|
|
1927
|
+
['allocationHash', plan.allocationHash], ['firstDecisionHash', plan.firstDecisionHash],
|
|
1928
|
+
['lastDecisionHash', plan.lastDecisionHash], ['planHash', plan.planHash],
|
|
1929
|
+
]) {
|
|
1930
|
+
if (!digest(value))
|
|
1931
|
+
errors.push('v2 assignment plan ' + field + ' is invalid');
|
|
1932
|
+
}
|
|
1933
|
+
if (!positiveSafeInteger(plan.sequence))
|
|
1934
|
+
errors.push('v2 assignment plan sequence must be a positive safe integer');
|
|
1935
|
+
if (!positiveSafeInteger(plan.createdAtMs))
|
|
1936
|
+
errors.push('v2 assignment plan createdAtMs must be a positive safe integer');
|
|
1937
|
+
if (!denseArray(plan.unitIdDigests) || !plan.unitIdDigests.every(digest)) {
|
|
1938
|
+
errors.push('v2 assignment plan unitIdDigests must be a dense digest array');
|
|
1939
|
+
}
|
|
1940
|
+
else if (new Set(plan.unitIdDigests).size !== plan.unitIdDigests.length) {
|
|
1941
|
+
errors.push('v2 assignment plan unitIdDigests contain a duplicate');
|
|
1942
|
+
}
|
|
1943
|
+
if (!denseArray(plan.decisionIds) || !plan.decisionIds.every(safeId)) {
|
|
1944
|
+
errors.push('v2 assignment plan decisionIds must be a dense namespaced-ID array');
|
|
1945
|
+
}
|
|
1946
|
+
else if (new Set(plan.decisionIds).size !== plan.decisionIds.length) {
|
|
1947
|
+
errors.push('v2 assignment plan decisionIds contain a duplicate');
|
|
1948
|
+
}
|
|
1949
|
+
return true;
|
|
1950
|
+
}
|
|
1951
|
+
function validateDecisionShape(decision, index, errors) {
|
|
1952
|
+
const label = 'v2 assignment decision[' + String(index) + ']';
|
|
1953
|
+
if (!exactRecord(decision, DECISION_KEYS, label, errors))
|
|
1954
|
+
return false;
|
|
1955
|
+
if (decision.type !== 'segreant.causal-decision')
|
|
1956
|
+
errors.push(label + ' type is invalid');
|
|
1957
|
+
if (decision.version !== 2)
|
|
1958
|
+
errors.push(label + ' version is invalid');
|
|
1959
|
+
for (const [field, value] of [
|
|
1960
|
+
['decisionId', decision.decisionId], ['studyId', decision.studyId],
|
|
1961
|
+
['blockId', decision.blockId], ['assignedArmId', decision.assignedArmId],
|
|
1962
|
+
]) {
|
|
1963
|
+
if (!safeId(value))
|
|
1964
|
+
errors.push(label + ' ' + field + ' is invalid');
|
|
1965
|
+
}
|
|
1966
|
+
for (const [field, value] of [
|
|
1967
|
+
['protocolHash', decision.protocolHash], ['unitIdDigest', decision.unitIdDigest],
|
|
1968
|
+
['blockRoot', decision.blockRoot], ['planHash', decision.planHash],
|
|
1969
|
+
['allocationHash', decision.allocationHash],
|
|
1970
|
+
['randomizationMaterialDigest', decision.randomizationMaterialDigest],
|
|
1971
|
+
['previousEventHash', decision.previousEventHash], ['eventHash', decision.eventHash],
|
|
1972
|
+
]) {
|
|
1973
|
+
if (!digest(value))
|
|
1974
|
+
errors.push(label + ' ' + field + ' is invalid');
|
|
1975
|
+
}
|
|
1976
|
+
if (!positiveSafeInteger(decision.blockSequence))
|
|
1977
|
+
errors.push(label + ' blockSequence must be a positive safe integer');
|
|
1978
|
+
if (!positiveSafeInteger(decision.decisionIndex))
|
|
1979
|
+
errors.push(label + ' decisionIndex must be a positive safe integer');
|
|
1980
|
+
if (!positiveSafeInteger(decision.assignedAtMs))
|
|
1981
|
+
errors.push(label + ' assignedAtMs must be a positive safe integer');
|
|
1982
|
+
if (decision.propensity !== 0.5)
|
|
1983
|
+
errors.push(label + ' propensity must equal the committed protocol probability 0.5');
|
|
1984
|
+
return true;
|
|
1985
|
+
}
|
|
1986
|
+
/** Replay from Store-retained private entropy; not a supported package API. */
|
|
1987
|
+
function verifyCausalAssignmentBlockV2Internal(protocol, block, randomizationMaterial) {
|
|
1988
|
+
const errors = verifyCommittedCausalProtocol(protocol);
|
|
1989
|
+
if (errors.length > 0)
|
|
1990
|
+
return errors;
|
|
1991
|
+
if (!isRecord(protocol) || protocol.version !== 2) {
|
|
1992
|
+
errors.push('v2 assignment replay requires a committed v2 protocol');
|
|
1993
|
+
return errors;
|
|
1994
|
+
}
|
|
1995
|
+
const protocolV2 = protocol;
|
|
1996
|
+
if (!exactRecord(block, ['plan', 'decisions'], 'v2 assignment block', errors))
|
|
1997
|
+
return errors;
|
|
1998
|
+
const planValid = validatePlanShape(block.plan, errors);
|
|
1999
|
+
if (!denseArray(block.decisions)) {
|
|
2000
|
+
errors.push('v2 assignment block decisions must be a dense ordered array');
|
|
2001
|
+
return errors;
|
|
2002
|
+
}
|
|
2003
|
+
const decisionsValid = block.decisions.map((decision, index) => validateDecisionShape(decision, index, errors));
|
|
2004
|
+
if (!(randomizationMaterial instanceof Uint8Array) || randomizationMaterial.byteLength !== ENTROPY_BYTES) {
|
|
2005
|
+
errors.push('retained private entropy must be exactly 32 raw bytes');
|
|
2006
|
+
}
|
|
2007
|
+
if (!planValid)
|
|
2008
|
+
return errors;
|
|
2009
|
+
const candidate = block.plan;
|
|
2010
|
+
if (candidate.studyId !== protocolV2.studyId)
|
|
2011
|
+
errors.push('assignment plan study identity does not match the committed protocol');
|
|
2012
|
+
if (candidate.protocolHash !== protocolV2.protocolHash)
|
|
2013
|
+
errors.push('assignment plan protocol identity does not match the committed protocol');
|
|
2014
|
+
if (Array.isArray(candidate.unitIdDigests) && candidate.unitIdDigests.length !== protocolV2.allocation.blockSize) {
|
|
2015
|
+
errors.push('assignment plan unit count does not match the committed protocol block size');
|
|
2016
|
+
}
|
|
2017
|
+
if (block.decisions.length !== (Array.isArray(candidate.unitIdDigests) ? candidate.unitIdDigests.length : -1)
|
|
2018
|
+
|| block.decisions.length !== (Array.isArray(candidate.decisionIds) ? candidate.decisionIds.length : -1)) {
|
|
2019
|
+
errors.push('assignment plan↔decision bijection requires exactly one decision per ordered unit and decision ID');
|
|
2020
|
+
}
|
|
2021
|
+
const actualDecisionIds = block.decisions.filter(isRecord).map((decision) => decision.decisionId);
|
|
2022
|
+
if (new Set(actualDecisionIds).size !== actualDecisionIds.length)
|
|
2023
|
+
errors.push('assignment decisions contain a duplicate decisionId');
|
|
2024
|
+
const canDerive = safeId(candidate.blockId)
|
|
2025
|
+
&& positiveSafeInteger(candidate.sequence)
|
|
2026
|
+
&& positiveSafeInteger(candidate.createdAtMs)
|
|
2027
|
+
&& denseArray(candidate.unitIdDigests)
|
|
2028
|
+
&& candidate.unitIdDigests.length === protocolV2.allocation.blockSize
|
|
2029
|
+
&& candidate.unitIdDigests.every(digest)
|
|
2030
|
+
&& new Set(candidate.unitIdDigests).size === candidate.unitIdDigests.length
|
|
2031
|
+
&& randomizationMaterial instanceof Uint8Array
|
|
2032
|
+
&& randomizationMaterial.byteLength === ENTROPY_BYTES;
|
|
2033
|
+
if (!canDerive)
|
|
2034
|
+
return errors;
|
|
2035
|
+
const typedPlan = candidate;
|
|
2036
|
+
let expected;
|
|
2037
|
+
try {
|
|
2038
|
+
expected = deriveCausalAssignmentBlockV2Internal(protocolV2, {
|
|
2039
|
+
blockId: typedPlan.blockId,
|
|
2040
|
+
sequence: typedPlan.sequence,
|
|
2041
|
+
createdAtMs: typedPlan.createdAtMs,
|
|
2042
|
+
unitIdDigests: [...typedPlan.unitIdDigests],
|
|
2043
|
+
randomizationMaterial,
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
catch (error) {
|
|
2047
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
2048
|
+
return errors;
|
|
2049
|
+
}
|
|
2050
|
+
if (typedPlan.blockRoot !== expected.plan.blockRoot)
|
|
2051
|
+
errors.push('assignment block root does not match its study/protocol/block domain');
|
|
2052
|
+
if (typedPlan.randomizationMaterialDigest !== expected.plan.randomizationMaterialDigest) {
|
|
2053
|
+
errors.push('assignment randomization material digest does not match retained private entropy');
|
|
2054
|
+
}
|
|
2055
|
+
if (typedPlan.allocationHash !== expected.plan.allocationHash)
|
|
2056
|
+
errors.push('assignment allocation hash does not replay');
|
|
2057
|
+
if (typedPlan.planHash !== expected.plan.planHash)
|
|
2058
|
+
errors.push('assignment plan hash does not replay');
|
|
2059
|
+
if (canonicalJson(typedPlan.unitIdDigests) !== canonicalJson(expected.plan.unitIdDigests)) {
|
|
2060
|
+
errors.push('assignment ordered unit digests do not replay');
|
|
2061
|
+
}
|
|
2062
|
+
if (canonicalJson(typedPlan.decisionIds) !== canonicalJson(expected.plan.decisionIds)) {
|
|
2063
|
+
errors.push('assignment decisionIds do not preserve the plan↔decision bijection and order');
|
|
2064
|
+
}
|
|
2065
|
+
if (typedPlan.firstDecisionHash !== expected.plan.firstDecisionHash)
|
|
2066
|
+
errors.push('assignment first decision anchor does not replay');
|
|
2067
|
+
if (typedPlan.lastDecisionHash !== expected.plan.lastDecisionHash)
|
|
2068
|
+
errors.push('assignment last decision anchor does not replay');
|
|
2069
|
+
for (let index = 0; index < block.decisions.length; index += 1) {
|
|
2070
|
+
const decision = block.decisions[index];
|
|
2071
|
+
if (!decisionsValid[index] || !isRecord(decision))
|
|
2072
|
+
continue;
|
|
2073
|
+
const expectedDecision = expected.decisions[index];
|
|
2074
|
+
if (!expectedDecision) {
|
|
2075
|
+
errors.push('assignment decision[' + String(index) + '] has no plan entry');
|
|
2076
|
+
continue;
|
|
2077
|
+
}
|
|
2078
|
+
const label = 'assignment decision[' + String(index) + ']';
|
|
2079
|
+
if (decision.decisionId !== typedPlan.decisionIds[index])
|
|
2080
|
+
errors.push(label + ' violates the ordered plan↔decision bijection');
|
|
2081
|
+
if (decision.decisionIndex !== index + 1)
|
|
2082
|
+
errors.push(label + ' decisionIndex is not gap-free plan order');
|
|
2083
|
+
if (decision.unitIdDigest !== typedPlan.unitIdDigests[index])
|
|
2084
|
+
errors.push(label + ' unit digest does not match plan order');
|
|
2085
|
+
if (decision.studyId !== protocolV2.studyId || decision.protocolHash !== protocolV2.protocolHash) {
|
|
2086
|
+
errors.push(label + ' study/protocol identity is contradictory');
|
|
2087
|
+
}
|
|
2088
|
+
if (decision.blockId !== typedPlan.blockId || decision.blockSequence !== typedPlan.sequence) {
|
|
2089
|
+
errors.push(label + ' block identity or sequence is contradictory');
|
|
2090
|
+
}
|
|
2091
|
+
if (decision.blockRoot !== typedPlan.blockRoot)
|
|
2092
|
+
errors.push(label + ' block root is contradictory');
|
|
2093
|
+
if (decision.randomizationMaterialDigest !== typedPlan.randomizationMaterialDigest)
|
|
2094
|
+
errors.push(label + ' material digest is contradictory');
|
|
2095
|
+
if (decision.allocationHash !== typedPlan.allocationHash)
|
|
2096
|
+
errors.push(label + ' allocation hash is contradictory');
|
|
2097
|
+
if (decision.planHash !== typedPlan.planHash)
|
|
2098
|
+
errors.push(label + ' plan hash is contradictory');
|
|
2099
|
+
if (decision.assignedAtMs !== typedPlan.createdAtMs)
|
|
2100
|
+
errors.push(label + ' assignedAtMs must equal assignment plan createdAtMs');
|
|
2101
|
+
const previousDecision = block.decisions[index - 1];
|
|
2102
|
+
const expectedPredecessor = index === 0
|
|
2103
|
+
? typedPlan.blockRoot
|
|
2104
|
+
: isRecord(previousDecision) ? previousDecision.eventHash : undefined;
|
|
2105
|
+
if (decision.previousEventHash !== expectedPredecessor)
|
|
2106
|
+
errors.push(label + ' immediate predecessor is invalid');
|
|
2107
|
+
if (decision.eventHash !== decisionHash(decision))
|
|
2108
|
+
errors.push(label + ' event hash does not verify');
|
|
2109
|
+
if (canonicalJson(decision) !== canonicalJson(expectedDecision))
|
|
2110
|
+
errors.push(label + ' does not replay from the retained entropy and protocol');
|
|
2111
|
+
}
|
|
2112
|
+
const firstActual = block.decisions[0];
|
|
2113
|
+
const lastActual = block.decisions.at(-1);
|
|
2114
|
+
const firstHash = isRecord(firstActual) ? firstActual.eventHash : undefined;
|
|
2115
|
+
const lastHash = isRecord(lastActual) ? lastActual.eventHash : undefined;
|
|
2116
|
+
if (typedPlan.firstDecisionHash !== firstHash)
|
|
2117
|
+
errors.push('assignment firstDecisionHash is not the derived first decision anchor');
|
|
2118
|
+
if (typedPlan.lastDecisionHash !== lastHash)
|
|
2119
|
+
errors.push('assignment lastDecisionHash is not the derived last decision anchor');
|
|
2120
|
+
return errors;
|
|
2121
|
+
}
|
|
2122
|
+
function parseV2Json(raw, label) {
|
|
2123
|
+
if (typeof raw !== 'string') {
|
|
2124
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored ' + label + ' is not canonical JSON text');
|
|
2125
|
+
}
|
|
2126
|
+
try {
|
|
2127
|
+
return JSON.parse(raw);
|
|
2128
|
+
}
|
|
2129
|
+
catch {
|
|
2130
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored ' + label + ' is malformed');
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
function requireProtocolV2(db, studyId) {
|
|
2134
|
+
const row = db.prepare(STORED_PROTOCOL_SELECT + 'FROM causal_protocols WHERE study_id = ?').get(studyId);
|
|
2135
|
+
if (!row)
|
|
2136
|
+
fail('CAUSAL_NOT_FOUND', 'committed causal study was not found');
|
|
2137
|
+
const decoded = decodeStoredProtocolRow(row);
|
|
2138
|
+
if (decoded.version !== 2) {
|
|
2139
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored assignment protocol is not an exact committed v2 protocol');
|
|
2140
|
+
}
|
|
2141
|
+
return decoded.protocol;
|
|
2142
|
+
}
|
|
2143
|
+
function validateRequest(protocol, request) {
|
|
2144
|
+
const errors = [];
|
|
2145
|
+
if (!exactRecord(request, REQUEST_KEYS, 'v2 assignment request', errors)) {
|
|
2146
|
+
fail('CAUSAL_ASSIGNMENT_INVALID', errors.join('; '));
|
|
2147
|
+
}
|
|
2148
|
+
if (!safeId(request.studyId) || request.studyId !== protocol.studyId)
|
|
2149
|
+
errors.push('studyId must exactly identify the stored protocol');
|
|
2150
|
+
if (!safeId(request.blockId))
|
|
2151
|
+
errors.push('blockId must be a safe namespaced identifier');
|
|
2152
|
+
if (!positiveSafeInteger(request.createdAtMs)) {
|
|
2153
|
+
errors.push('createdAtMs must be a positive safe-integer epoch timestamp');
|
|
2154
|
+
}
|
|
2155
|
+
else {
|
|
2156
|
+
if (request.createdAtMs < protocol.studyWindow.startsAtMs)
|
|
2157
|
+
errors.push('assignment must not precede the study window');
|
|
2158
|
+
if (protocol.studyWindow.endsAtMs !== null && request.createdAtMs > protocol.studyWindow.endsAtMs) {
|
|
2159
|
+
errors.push('assignment must not follow the study window');
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
if (!denseArray(request.unitIdDigests)) {
|
|
2163
|
+
errors.push('unitIdDigests must be a dense ordered array');
|
|
2164
|
+
}
|
|
2165
|
+
else {
|
|
2166
|
+
if (request.unitIdDigests.length !== protocol.allocation.blockSize)
|
|
2167
|
+
errors.push('unitIdDigests must equal the committed block size');
|
|
2168
|
+
if (!request.unitIdDigests.every(digest))
|
|
2169
|
+
errors.push('unitIdDigests must be lowercase namespaced SHA-256 digests');
|
|
2170
|
+
if (new Set(request.unitIdDigests).size !== request.unitIdDigests.length)
|
|
2171
|
+
errors.push('unitIdDigests must be unique within the block');
|
|
2172
|
+
}
|
|
2173
|
+
if (errors.length > 0)
|
|
2174
|
+
fail('CAUSAL_ASSIGNMENT_INVALID', errors.join('; '));
|
|
2175
|
+
return request;
|
|
2176
|
+
}
|
|
2177
|
+
const MANIFEST_KEYS = [
|
|
2178
|
+
'type', 'version', 'studyId', 'protocolHash', 'planCount', 'decisionCount',
|
|
2179
|
+
'unitCount', 'plans', 'decisions', 'assignmentManifestHash',
|
|
2180
|
+
];
|
|
2181
|
+
const MANIFEST_PLAN_KEYS = [
|
|
2182
|
+
'blockId', 'sequence', 'blockRoot', 'planHash', 'allocationHash',
|
|
2183
|
+
'firstDecisionHash', 'lastDecisionHash', 'decisionCount',
|
|
2184
|
+
];
|
|
2185
|
+
const MANIFEST_DECISION_KEYS = [
|
|
2186
|
+
'decisionId', 'blockId', 'blockSequence', 'decisionIndex', 'unitIdDigest',
|
|
2187
|
+
'assignedArmId', 'eventHash', 'planHash',
|
|
2188
|
+
];
|
|
2189
|
+
function validateManifestShape(value) {
|
|
2190
|
+
const errors = [];
|
|
2191
|
+
if (!exactRecord(value, MANIFEST_KEYS, 'v2 assignment manifest', errors)) {
|
|
2192
|
+
fail('CAUSAL_INTEGRITY_FAILURE', errors.join('; '));
|
|
2193
|
+
}
|
|
2194
|
+
if (value.type !== 'segreant.causal-assignment-manifest' || value.version !== 2
|
|
2195
|
+
|| !safeId(value.studyId) || !digest(value.protocolHash)
|
|
2196
|
+
|| !positiveSafeInteger(value.planCount) || !positiveSafeInteger(value.decisionCount)
|
|
2197
|
+
|| !positiveSafeInteger(value.unitCount) || !digest(value.assignmentManifestHash)
|
|
2198
|
+
|| !denseArray(value.plans) || !denseArray(value.decisions)) {
|
|
2199
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored assignment manifest scalar or array shape is invalid');
|
|
2200
|
+
}
|
|
2201
|
+
for (const [index, plan] of value.plans.entries()) {
|
|
2202
|
+
if (!exactRecord(plan, MANIFEST_PLAN_KEYS, 'v2 assignment manifest plan[' + String(index) + ']', errors))
|
|
2203
|
+
continue;
|
|
2204
|
+
if (!safeId(plan.blockId) || !positiveSafeInteger(plan.sequence) || !digest(plan.blockRoot)
|
|
2205
|
+
|| !digest(plan.planHash) || !digest(plan.allocationHash) || !digest(plan.firstDecisionHash)
|
|
2206
|
+
|| !digest(plan.lastDecisionHash) || !positiveSafeInteger(plan.decisionCount)) {
|
|
2207
|
+
errors.push('v2 assignment manifest plan[' + String(index) + '] is invalid');
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
for (const [index, decision] of value.decisions.entries()) {
|
|
2211
|
+
if (!exactRecord(decision, MANIFEST_DECISION_KEYS, 'v2 assignment manifest decision[' + String(index) + ']', errors))
|
|
2212
|
+
continue;
|
|
2213
|
+
if (!safeId(decision.decisionId) || !safeId(decision.blockId)
|
|
2214
|
+
|| !positiveSafeInteger(decision.blockSequence) || !positiveSafeInteger(decision.decisionIndex)
|
|
2215
|
+
|| !digest(decision.unitIdDigest) || !safeId(decision.assignedArmId)
|
|
2216
|
+
|| !digest(decision.eventHash) || !digest(decision.planHash)) {
|
|
2217
|
+
errors.push('v2 assignment manifest decision[' + String(index) + '] is invalid');
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
if (errors.length > 0)
|
|
2221
|
+
fail('CAUSAL_INTEGRITY_FAILURE', errors.join('; '));
|
|
2222
|
+
}
|
|
2223
|
+
function canonicalPlan(raw) {
|
|
2224
|
+
const value = parseV2Json(raw, 'causal assignment plan');
|
|
2225
|
+
const errors = [];
|
|
2226
|
+
if (!validatePlanShape(value, errors) || errors.length > 0) {
|
|
2227
|
+
fail('CAUSAL_INTEGRITY_FAILURE', errors.join('; ') || 'stored assignment plan shape is invalid');
|
|
2228
|
+
}
|
|
2229
|
+
if (raw !== canonicalJson(value)) {
|
|
2230
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored causal assignment plan JSON is not canonical');
|
|
2231
|
+
}
|
|
2232
|
+
return value;
|
|
2233
|
+
}
|
|
2234
|
+
function canonicalDecision(raw, index) {
|
|
2235
|
+
const value = parseV2Json(raw, 'causal assignment decision');
|
|
2236
|
+
const errors = [];
|
|
2237
|
+
if (!validateDecisionShape(value, index, errors) || errors.length > 0) {
|
|
2238
|
+
fail('CAUSAL_INTEGRITY_FAILURE', errors.join('; ') || 'stored assignment decision shape is invalid');
|
|
2239
|
+
}
|
|
2240
|
+
if (raw !== canonicalJson(value)) {
|
|
2241
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored causal assignment decision JSON is not canonical');
|
|
2242
|
+
}
|
|
2243
|
+
return value;
|
|
2244
|
+
}
|
|
2245
|
+
function canonicalManifest(raw) {
|
|
2246
|
+
const value = parseV2Json(raw, 'causal assignment manifest');
|
|
2247
|
+
validateManifestShape(value);
|
|
2248
|
+
if (raw !== canonicalJson(value)) {
|
|
2249
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored causal assignment manifest JSON is not canonical');
|
|
2250
|
+
}
|
|
2251
|
+
return value;
|
|
2252
|
+
}
|
|
2253
|
+
function mutableBlob(value, label) {
|
|
2254
|
+
if (!(value instanceof Uint8Array)) {
|
|
2255
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored ' + label + ' is not a byte BLOB');
|
|
2256
|
+
}
|
|
2257
|
+
return value;
|
|
2258
|
+
}
|
|
2259
|
+
function zeroizeMutableBlob(value) {
|
|
2260
|
+
if (value instanceof Uint8Array)
|
|
2261
|
+
value.fill(0);
|
|
2262
|
+
}
|
|
2263
|
+
function loadBlockByPlanRow(db, row) {
|
|
2264
|
+
const plan = canonicalPlan(row.plan_json);
|
|
2265
|
+
const decisions = db.prepare('SELECT decision_json FROM causal_decisions_v2 WHERE study_id = ? AND block_sequence = ? ORDER BY decision_index').all(plan.studyId, plan.sequence).map((decision, index) => canonicalDecision(decision.decision_json, index));
|
|
2266
|
+
const retainedEntropy = mutableBlob(row.entropy_blob, 'causal assignment entropy');
|
|
2267
|
+
let entropy;
|
|
2268
|
+
try {
|
|
2269
|
+
entropy = Buffer.from(retainedEntropy);
|
|
2270
|
+
}
|
|
2271
|
+
finally {
|
|
2272
|
+
retainedEntropy.fill(0);
|
|
2273
|
+
}
|
|
2274
|
+
return { block: { plan, decisions }, entropy };
|
|
2275
|
+
}
|
|
2276
|
+
function manifestFromBlocks(protocol, blocks) {
|
|
2277
|
+
const plans = blocks.map(({ plan, decisions }) => ({
|
|
2278
|
+
blockId: plan.blockId,
|
|
2279
|
+
sequence: plan.sequence,
|
|
2280
|
+
blockRoot: plan.blockRoot,
|
|
2281
|
+
planHash: plan.planHash,
|
|
2282
|
+
allocationHash: plan.allocationHash,
|
|
2283
|
+
firstDecisionHash: plan.firstDecisionHash,
|
|
2284
|
+
lastDecisionHash: plan.lastDecisionHash,
|
|
2285
|
+
decisionCount: decisions.length,
|
|
2286
|
+
}));
|
|
2287
|
+
const decisions = blocks.flatMap((block) => block.decisions.map((decision) => ({
|
|
2288
|
+
decisionId: decision.decisionId,
|
|
2289
|
+
blockId: decision.blockId,
|
|
2290
|
+
blockSequence: decision.blockSequence,
|
|
2291
|
+
decisionIndex: decision.decisionIndex,
|
|
2292
|
+
unitIdDigest: decision.unitIdDigest,
|
|
2293
|
+
assignedArmId: decision.assignedArmId,
|
|
2294
|
+
eventHash: decision.eventHash,
|
|
2295
|
+
planHash: decision.planHash,
|
|
2296
|
+
})));
|
|
2297
|
+
const material = {
|
|
2298
|
+
type: 'segreant.causal-assignment-manifest',
|
|
2299
|
+
version: 2,
|
|
2300
|
+
studyId: protocol.studyId,
|
|
2301
|
+
protocolHash: protocol.protocolHash,
|
|
2302
|
+
planCount: plans.length,
|
|
2303
|
+
decisionCount: decisions.length,
|
|
2304
|
+
unitCount: decisions.length,
|
|
2305
|
+
plans,
|
|
2306
|
+
decisions,
|
|
2307
|
+
};
|
|
2308
|
+
return { ...material, assignmentManifestHash: domainHash('segreant.causal.assignment-manifest', material) };
|
|
2309
|
+
}
|
|
2310
|
+
function scanAssignmentArtifacts(db, protocol) {
|
|
2311
|
+
const plans = db.prepare('SELECT study_id, block_id, protocol_hash, sequence, created_at_ms, block_root, allocation_hash, material_digest, plan_hash, entropy_blob, plan_json ' +
|
|
2312
|
+
'FROM causal_assignment_plans_v2 WHERE study_id = ? ORDER BY sequence').all(protocol.studyId);
|
|
2313
|
+
const decisions = db.prepare('SELECT decision_id, study_id, block_id, block_sequence, decision_index, unit_id_digest, assigned_arm_id, event_hash, decision_json ' +
|
|
2314
|
+
'FROM causal_decisions_v2 WHERE study_id = ? ORDER BY block_sequence, decision_index').all(protocol.studyId);
|
|
2315
|
+
const units = db.prepare('SELECT study_id, unit_id_digest, decision_id, block_id, block_sequence, claimed_at_ms ' +
|
|
2316
|
+
'FROM causal_assignment_units_v2 WHERE study_id = ? ORDER BY block_sequence, decision_id').all(protocol.studyId);
|
|
2317
|
+
try {
|
|
2318
|
+
const blocks = [];
|
|
2319
|
+
let consumedDecisions = 0;
|
|
2320
|
+
for (let index = 0; index < plans.length; index += 1) {
|
|
2321
|
+
const row = plans[index];
|
|
2322
|
+
const expectedSequence = index + 1;
|
|
2323
|
+
const plan = canonicalPlan(row.plan_json);
|
|
2324
|
+
if (row.study_id !== protocol.studyId || row.protocol_hash !== protocol.protocolHash
|
|
2325
|
+
|| row.block_id !== plan.blockId || row.sequence !== plan.sequence || row.sequence !== expectedSequence
|
|
2326
|
+
|| row.created_at_ms !== plan.createdAtMs || row.block_root !== plan.blockRoot
|
|
2327
|
+
|| row.allocation_hash !== plan.allocationHash || row.material_digest !== plan.randomizationMaterialDigest
|
|
2328
|
+
|| row.plan_hash !== plan.planHash) {
|
|
2329
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment plan physical columns contradict exact plan JSON or sequence');
|
|
2330
|
+
}
|
|
2331
|
+
const decisionRows = decisions.filter((decision) => decision.block_sequence === expectedSequence);
|
|
2332
|
+
const decoded = decisionRows.map((decisionRow, decisionIndex) => {
|
|
2333
|
+
const decision = canonicalDecision(decisionRow.decision_json, decisionIndex);
|
|
2334
|
+
if (decisionRow.decision_id !== decision.decisionId || decisionRow.study_id !== decision.studyId
|
|
2335
|
+
|| decisionRow.block_id !== decision.blockId || decisionRow.block_sequence !== decision.blockSequence
|
|
2336
|
+
|| decisionRow.decision_index !== decision.decisionIndex || decisionRow.decision_index !== decisionIndex + 1
|
|
2337
|
+
|| decisionRow.unit_id_digest !== decision.unitIdDigest || decisionRow.assigned_arm_id !== decision.assignedArmId
|
|
2338
|
+
|| decisionRow.event_hash !== decision.eventHash) {
|
|
2339
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment decision physical columns contradict exact decision JSON or order');
|
|
2340
|
+
}
|
|
2341
|
+
return decision;
|
|
2342
|
+
});
|
|
2343
|
+
consumedDecisions += decisionRows.length;
|
|
2344
|
+
const retainedEntropy = mutableBlob(row.entropy_blob, 'causal assignment entropy');
|
|
2345
|
+
let entropy;
|
|
2346
|
+
try {
|
|
2347
|
+
entropy = Buffer.from(retainedEntropy);
|
|
2348
|
+
}
|
|
2349
|
+
finally {
|
|
2350
|
+
retainedEntropy.fill(0);
|
|
2351
|
+
}
|
|
2352
|
+
try {
|
|
2353
|
+
const block = { plan, decisions: decoded };
|
|
2354
|
+
const errors = verifyCausalAssignmentBlockV2Internal(protocol, block, entropy);
|
|
2355
|
+
if (errors.length > 0)
|
|
2356
|
+
fail('CAUSAL_INTEGRITY_FAILURE', errors.join('; '));
|
|
2357
|
+
blocks.push(block);
|
|
2358
|
+
}
|
|
2359
|
+
finally {
|
|
2360
|
+
entropy.fill(0);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
if (consumedDecisions !== decisions.length) {
|
|
2364
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment decision table contains orphan or extra rows');
|
|
2365
|
+
}
|
|
2366
|
+
const expectedDecisions = blocks.flatMap((block) => block.decisions);
|
|
2367
|
+
if (units.length !== expectedDecisions.length) {
|
|
2368
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment unit claims do not form a complete decision bijection');
|
|
2369
|
+
}
|
|
2370
|
+
const claimByDecision = new Map(units.map((unit) => [unit.decision_id, unit]));
|
|
2371
|
+
if (claimByDecision.size !== units.length)
|
|
2372
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment unit claims contain duplicates');
|
|
2373
|
+
for (const decision of expectedDecisions) {
|
|
2374
|
+
const claim = claimByDecision.get(decision.decisionId);
|
|
2375
|
+
if (!claim || claim.study_id !== protocol.studyId || claim.unit_id_digest !== decision.unitIdDigest
|
|
2376
|
+
|| claim.block_id !== decision.blockId || claim.block_sequence !== decision.blockSequence
|
|
2377
|
+
|| claim.claimed_at_ms !== decision.assignedAtMs) {
|
|
2378
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment unit claim physical row contradicts its exact decision');
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
return blocks;
|
|
2382
|
+
}
|
|
2383
|
+
finally {
|
|
2384
|
+
// node:sqlite returns mutable JS BLOB views. Clear every returned view,
|
|
2385
|
+
// including rows not reached when exact decoding fails early.
|
|
2386
|
+
for (const row of plans)
|
|
2387
|
+
zeroizeMutableBlob(row.entropy_blob);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
function recomputeManifest(db, protocol) {
|
|
2391
|
+
return manifestFromBlocks(protocol, scanAssignmentArtifacts(db, protocol));
|
|
2392
|
+
}
|
|
2393
|
+
function retainedManifest(db, protocol) {
|
|
2394
|
+
const blocks = scanAssignmentArtifacts(db, protocol);
|
|
2395
|
+
const rows = db.prepare('SELECT study_id, generation, protocol_hash, manifest_hash, manifest_json ' +
|
|
2396
|
+
'FROM causal_assignment_manifests_v2 WHERE study_id = ? ORDER BY generation').all(protocol.studyId);
|
|
2397
|
+
if (blocks.length === 0) {
|
|
2398
|
+
if (rows.length !== 0)
|
|
2399
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'first assignment requires zero prior manifest rows');
|
|
2400
|
+
return null;
|
|
2401
|
+
}
|
|
2402
|
+
if (rows.length !== blocks.length) {
|
|
2403
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment manifest generations are missing or extra');
|
|
2404
|
+
}
|
|
2405
|
+
let current = null;
|
|
2406
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
2407
|
+
const row = rows[index];
|
|
2408
|
+
const generation = index + 1;
|
|
2409
|
+
const stored = canonicalManifest(row.manifest_json);
|
|
2410
|
+
const expected = manifestFromBlocks(protocol, blocks.slice(0, generation));
|
|
2411
|
+
if (row.study_id !== protocol.studyId || row.generation !== generation
|
|
2412
|
+
|| row.protocol_hash !== protocol.protocolHash || row.manifest_hash !== stored.assignmentManifestHash
|
|
2413
|
+
|| row.manifest_hash !== expected.assignmentManifestHash
|
|
2414
|
+
|| canonicalJson(stored) !== canonicalJson(expected)) {
|
|
2415
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'stored assignment manifest generation or physical columns are not authoritative');
|
|
2416
|
+
}
|
|
2417
|
+
current = expected;
|
|
2418
|
+
}
|
|
2419
|
+
return current;
|
|
2420
|
+
}
|
|
2421
|
+
function currentManifest(db, protocol) {
|
|
2422
|
+
const current = retainedManifest(db, protocol);
|
|
2423
|
+
if (!current)
|
|
2424
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'assignment manifest is missing');
|
|
2425
|
+
return current;
|
|
2426
|
+
}
|
|
2427
|
+
function normalizedAssignmentError(error) {
|
|
2428
|
+
if (error instanceof CausalAssignmentStoreError)
|
|
2429
|
+
return error;
|
|
2430
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2431
|
+
if (/database is locked|SQLITE_BUSY/i.test(message)) {
|
|
2432
|
+
return new CausalAssignmentStoreError('CAUSAL_BUSY', 'assignment writer is busy; retry this immutable request');
|
|
2433
|
+
}
|
|
2434
|
+
if (/UNIQUE constraint failed.*causal_assignment_units_v2/i.test(message)) {
|
|
2435
|
+
return new CausalAssignmentStoreError('CAUSAL_UNIT_ALREADY_ASSIGNED', 'one or more unit digests are already assigned in this study');
|
|
2436
|
+
}
|
|
2437
|
+
return new CausalAssignmentStoreError('CAUSAL_ASSIGNMENT_ROLLED_BACK', 'assignment transaction rolled back without disclosing an allocation');
|
|
2438
|
+
}
|
|
2439
|
+
function assignCausalBlockV2Store(db, requestValue) {
|
|
2440
|
+
let entropy = null;
|
|
2441
|
+
try {
|
|
2442
|
+
db.prepare('BEGIN IMMEDIATE').run();
|
|
2443
|
+
const requestRoot = isRecord(requestValue) ? requestValue.studyId : undefined;
|
|
2444
|
+
if (!safeId(requestRoot))
|
|
2445
|
+
fail('CAUSAL_ASSIGNMENT_INVALID', 'studyId must be a safe namespaced identifier');
|
|
2446
|
+
const protocol = requireProtocolV2(db, requestRoot);
|
|
2447
|
+
const request = validateRequest(protocol, requestValue);
|
|
2448
|
+
// Audit every retained row and exact manifest generation before any new
|
|
2449
|
+
// cryptographic entropy is allocated. The first assignment is valid only
|
|
2450
|
+
// when all four v2 artifact tables are empty for the study.
|
|
2451
|
+
const previousManifest = retainedManifest(db, protocol);
|
|
2452
|
+
const existingRow = db.prepare('SELECT plan_json, entropy_blob FROM causal_assignment_plans_v2 WHERE study_id = ? AND block_id = ?').get(request.studyId, request.blockId);
|
|
2453
|
+
if (existingRow) {
|
|
2454
|
+
const loaded = loadBlockByPlanRow(db, existingRow);
|
|
2455
|
+
try {
|
|
2456
|
+
const sameIntent = loaded.block.plan.createdAtMs === request.createdAtMs
|
|
2457
|
+
&& canonicalJson(loaded.block.plan.unitIdDigests) === canonicalJson(request.unitIdDigests);
|
|
2458
|
+
if (!sameIntent)
|
|
2459
|
+
fail('CAUSAL_IMMUTABLE_CONFLICT', 'blockId already records different immutable assignment intent');
|
|
2460
|
+
const replayErrors = verifyCausalAssignmentBlockV2Internal(protocol, loaded.block, loaded.entropy);
|
|
2461
|
+
if (replayErrors.length > 0)
|
|
2462
|
+
fail('CAUSAL_INTEGRITY_FAILURE', replayErrors.join('; '));
|
|
2463
|
+
if (!previousManifest)
|
|
2464
|
+
fail('CAUSAL_INTEGRITY_FAILURE', 'retained assignment block has no authoritative manifest');
|
|
2465
|
+
db.prepare('COMMIT').run();
|
|
2466
|
+
return { status: 'existing', block: loaded.block, manifest: previousManifest };
|
|
2467
|
+
}
|
|
2468
|
+
finally {
|
|
2469
|
+
loaded.entropy.fill(0);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
const conflicts = db.prepare('SELECT unit_id_digest FROM causal_assignment_units_v2 WHERE study_id = ? AND unit_id_digest IN (' +
|
|
2473
|
+
request.unitIdDigests.map(() => '?').join(',') + ') LIMIT 1').all(request.studyId, ...request.unitIdDigests);
|
|
2474
|
+
if (conflicts.length > 0) {
|
|
2475
|
+
fail('CAUSAL_UNIT_ALREADY_ASSIGNED', 'one or more unit digests are already assigned in this study');
|
|
2476
|
+
}
|
|
2477
|
+
const sequence = (previousManifest?.planCount ?? 0) + 1;
|
|
2478
|
+
const assignedCount = previousManifest?.unitCount ?? 0;
|
|
2479
|
+
if (protocol.stoppingRule.maxAssignments !== null
|
|
2480
|
+
&& assignedCount + request.unitIdDigests.length > protocol.stoppingRule.maxAssignments) {
|
|
2481
|
+
fail('CAUSAL_STOPPING_RULE_REACHED', 'assignment would exceed the committed maximum enrollment');
|
|
2482
|
+
}
|
|
2483
|
+
entropy = Buffer.alloc(ENTROPY_BYTES);
|
|
2484
|
+
randomFillSync(entropy);
|
|
2485
|
+
if (entropy.byteLength !== ENTROPY_BYTES)
|
|
2486
|
+
fail('CAUSAL_RNG_FAILURE', 'cryptographic entropy source returned the wrong byte length');
|
|
2487
|
+
const block = deriveCausalAssignmentBlockV2Internal(protocol, {
|
|
2488
|
+
blockId: request.blockId,
|
|
2489
|
+
sequence,
|
|
2490
|
+
createdAtMs: request.createdAtMs,
|
|
2491
|
+
unitIdDigests: [...request.unitIdDigests],
|
|
2492
|
+
randomizationMaterial: entropy,
|
|
2493
|
+
});
|
|
2494
|
+
db.prepare('INSERT INTO causal_assignment_plans_v2 ' +
|
|
2495
|
+
'(study_id, block_id, protocol_hash, sequence, created_at_ms, block_root, allocation_hash, material_digest, plan_hash, entropy_blob, plan_json) ' +
|
|
2496
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(protocol.studyId, block.plan.blockId, protocol.protocolHash, sequence, block.plan.createdAtMs, block.plan.blockRoot, block.plan.allocationHash, block.plan.randomizationMaterialDigest, block.plan.planHash, entropy, canonicalJson(block.plan));
|
|
2497
|
+
const insertDecision = db.prepare('INSERT INTO causal_decisions_v2 ' +
|
|
2498
|
+
'(decision_id, study_id, block_id, block_sequence, decision_index, unit_id_digest, assigned_arm_id, event_hash, decision_json) ' +
|
|
2499
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
2500
|
+
for (const decision of block.decisions) {
|
|
2501
|
+
insertDecision.run(decision.decisionId, decision.studyId, decision.blockId, decision.blockSequence, decision.decisionIndex, decision.unitIdDigest, decision.assignedArmId, decision.eventHash, canonicalJson(decision));
|
|
2502
|
+
}
|
|
2503
|
+
const insertUnit = db.prepare('INSERT INTO causal_assignment_units_v2 ' +
|
|
2504
|
+
'(study_id, unit_id_digest, decision_id, block_id, block_sequence, claimed_at_ms) VALUES (?, ?, ?, ?, ?, ?)');
|
|
2505
|
+
for (const decision of block.decisions) {
|
|
2506
|
+
insertUnit.run(decision.studyId, decision.unitIdDigest, decision.decisionId, decision.blockId, decision.blockSequence, decision.assignedAtMs);
|
|
2507
|
+
}
|
|
2508
|
+
const manifest = recomputeManifest(db, protocol);
|
|
2509
|
+
db.prepare('INSERT INTO causal_assignment_manifests_v2 ' +
|
|
2510
|
+
'(study_id, generation, protocol_hash, manifest_hash, manifest_json) VALUES (?, ?, ?, ?, ?)').run(protocol.studyId, sequence, protocol.protocolHash, manifest.assignmentManifestHash, canonicalJson(manifest));
|
|
2511
|
+
const authoritativeManifest = currentManifest(db, protocol);
|
|
2512
|
+
db.prepare('COMMIT').run();
|
|
2513
|
+
return { status: 'created', block, manifest: authoritativeManifest };
|
|
2514
|
+
}
|
|
2515
|
+
catch (error) {
|
|
2516
|
+
try {
|
|
2517
|
+
db.prepare('ROLLBACK').run();
|
|
2518
|
+
}
|
|
2519
|
+
catch {
|
|
2520
|
+
// Preserve the original typed assignment failure if SQLite already ended it.
|
|
2521
|
+
}
|
|
2522
|
+
throw normalizedAssignmentError(error);
|
|
2523
|
+
}
|
|
2524
|
+
finally {
|
|
2525
|
+
entropy?.fill(0);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
function readCausalAssignmentManifestV2Store(db, studyId) {
|
|
2529
|
+
if (!safeId(studyId))
|
|
2530
|
+
fail('CAUSAL_ASSIGNMENT_INVALID', 'studyId must be a safe namespaced identifier');
|
|
2531
|
+
db.prepare('BEGIN').run();
|
|
2532
|
+
try {
|
|
2533
|
+
const protocol = requireProtocolV2(db, studyId);
|
|
2534
|
+
const manifest = retainedManifest(db, protocol);
|
|
2535
|
+
db.prepare('COMMIT').run();
|
|
2536
|
+
return manifest;
|
|
2537
|
+
}
|
|
2538
|
+
catch (error) {
|
|
2539
|
+
try {
|
|
2540
|
+
db.prepare('ROLLBACK').run();
|
|
2541
|
+
}
|
|
2542
|
+
catch {
|
|
2543
|
+
// Preserve the integrity failure if SQLite already ended the transaction.
|
|
2544
|
+
}
|
|
2545
|
+
throw error;
|
|
2546
|
+
}
|
|
2547
|
+
}
|