@aiwg/cli 2026.9.20 → 2026.9.21
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/agentic/code/providers/capability-matrix.yaml +58 -6
- package/agentic/code/providers/model-capabilities.v1.json +34 -0
- package/agentic/code/providers/model-catalog.v1.json +55 -13
- package/bin/aiwg.mjs +2 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/enrichment/cli.js +1 -1
- package/dist/src/artifacts/views/cli.js +1 -1
- package/dist/src/audit/operator-decision.js +10 -0
- package/dist/src/auth/credential-store.js +127 -27
- package/dist/src/catalog/builtin-models.json +272 -40
- package/dist/src/catalog/cli.js +1 -1
- package/dist/src/catalog/cli.mjs +1 -1
- package/dist/src/cli/handlers/effect.js +872 -0
- package/dist/src/cli/handlers/help.js +2 -0
- package/dist/src/cli/handlers/index.js +4 -2
- package/dist/src/cli/handlers/models.js +41 -10
- package/dist/src/cli/handlers/ralph.js +1 -1
- package/dist/src/cli/handlers/sessions.js +113 -37
- package/dist/src/cli/handlers/subcommands.js +31 -0
- package/dist/src/cli/handlers/use.js +46 -9
- package/dist/src/cli/handlers/utilities.js +10 -0
- package/dist/src/cli/handlers/workspace.js +3 -1
- package/dist/src/cli/scope-resolver.js +112 -9
- package/dist/src/cli/services/deployment-verification.js +5 -0
- package/dist/src/config/aiwg-config.js +3 -0
- package/dist/src/decision/acceptance-rollout.js +93 -0
- package/dist/src/decision/acceptance.js +175 -0
- package/dist/src/decision/adapters/jev.js +234 -13
- package/dist/src/decision/adapters/llm-subagent.js +60 -12
- package/dist/src/decision/admission.js +550 -0
- package/dist/src/decision/batch-benchmark.js +47 -0
- package/dist/src/decision/batch-receipts/accounting.js +75 -0
- package/dist/src/decision/batch-receipts/export.js +40 -0
- package/dist/src/decision/batch-receipts/index.js +9 -0
- package/dist/src/decision/batch-receipts/protection.js +40 -0
- package/dist/src/decision/batch-receipts/receipt.js +44 -0
- package/dist/src/decision/batch-receipts/result-store.js +341 -0
- package/dist/src/decision/batch-receipts/store.js +362 -0
- package/dist/src/decision/batch-receipts/types.js +2 -0
- package/dist/src/decision/batch-receipts/validate.js +127 -0
- package/dist/src/decision/batch.js +68 -0
- package/dist/src/decision/calibration/index.js +4 -0
- package/dist/src/decision/calibration/receipts.js +130 -0
- package/dist/src/decision/calibration/registry.js +258 -0
- package/dist/src/decision/calibration/types.js +2 -0
- package/dist/src/decision/compile-cache/benchmark.js +62 -0
- package/dist/src/decision/compile-cache/compatibility.js +65 -0
- package/dist/src/decision/compile-cache/file-store.js +280 -0
- package/dist/src/decision/compile-cache/identity.js +26 -0
- package/dist/src/decision/compile-cache/index.js +9 -0
- package/dist/src/decision/compile-cache/prefix.js +21 -0
- package/dist/src/decision/compile-cache/records.js +86 -0
- package/dist/src/decision/compile-cache/runtime.js +68 -0
- package/dist/src/decision/compile-cache/store.js +108 -0
- package/dist/src/decision/compile-cache/types.js +2 -0
- package/dist/src/decision/context-plan.js +234 -0
- package/dist/src/decision/context-qualification.js +60 -0
- package/dist/src/decision/ensemble/aggregate.js +215 -0
- package/dist/src/decision/ensemble/contract.js +316 -0
- package/dist/src/decision/ensemble/drift.js +51 -0
- package/dist/src/decision/ensemble/index.js +6 -0
- package/dist/src/decision/ensemble/report.js +71 -0
- package/dist/src/decision/ensemble/types.js +2 -0
- package/dist/src/decision/evaluate.js +1024 -78
- package/dist/src/decision/file-lifecycle-store.js +168 -0
- package/dist/src/decision/graph-beam.js +40 -0
- package/dist/src/decision/graph-budget.js +110 -0
- package/dist/src/decision/graph-decision-bridge.js +93 -0
- package/dist/src/decision/graph-evidence.js +123 -0
- package/dist/src/decision/graph-flow-adapter.js +56 -0
- package/dist/src/decision/graph-flow.js +105 -0
- package/dist/src/decision/graph-parallel.js +79 -0
- package/dist/src/decision/graph-patterns.js +20 -0
- package/dist/src/decision/graph-run-store.js +66 -0
- package/dist/src/decision/graph-run.js +166 -0
- package/dist/src/decision/graph-skill-bridge.js +91 -0
- package/dist/src/decision/graph-templates.js +56 -0
- package/dist/src/decision/graph.js +115 -0
- package/dist/src/decision/index.js +35 -0
- package/dist/src/decision/job-accounting.js +93 -0
- package/dist/src/decision/job-contract.js +186 -0
- package/dist/src/decision/job-effects.js +95 -0
- package/dist/src/decision/job-evaluate.js +80 -0
- package/dist/src/decision/job-gateway.js +121 -0
- package/dist/src/decision/job-http.js +194 -0
- package/dist/src/decision/job-payload-store.js +258 -0
- package/dist/src/decision/job-poll-limiter.js +92 -0
- package/dist/src/decision/job-quota.js +158 -0
- package/dist/src/decision/job-runtime.js +240 -0
- package/dist/src/decision/job-scheduler.js +26 -0
- package/dist/src/decision/job-service.js +117 -0
- package/dist/src/decision/job-store.js +264 -0
- package/dist/src/decision/job-worker.js +141 -0
- package/dist/src/decision/lifecycle.js +148 -0
- package/dist/src/decision/patterns/artifacts.js +173 -0
- package/dist/src/decision/patterns/catalog.js +108 -0
- package/dist/src/decision/patterns/durable-review.js +237 -0
- package/dist/src/decision/patterns/index.js +8 -0
- package/dist/src/decision/patterns/playground.js +401 -0
- package/dist/src/decision/patterns/recorded.js +43 -0
- package/dist/src/decision/patterns/specs.js +174 -0
- package/dist/src/decision/patterns/types.js +2 -0
- package/dist/src/decision/portable-secrets.js +53 -0
- package/dist/src/decision/projection.js +210 -0
- package/dist/src/decision/qualification/capture.js +67 -0
- package/dist/src/decision/qualification/drift.js +68 -0
- package/dist/src/decision/qualification/gate-evidence.js +172 -0
- package/dist/src/decision/qualification/gates.js +83 -0
- package/dist/src/decision/qualification/index.js +12 -0
- package/dist/src/decision/qualification/load.js +275 -0
- package/dist/src/decision/qualification/manifest.js +37 -0
- package/dist/src/decision/qualification/privacy.js +41 -0
- package/dist/src/decision/qualification/quality.js +213 -0
- package/dist/src/decision/qualification/release.js +116 -0
- package/dist/src/decision/qualification/runner.js +408 -0
- package/dist/src/decision/qualification/types.js +2 -0
- package/dist/src/decision/receipts.js +30 -7
- package/dist/src/decision/result-cache/index.js +6 -0
- package/dist/src/decision/result-cache/integrity.js +28 -0
- package/dist/src/decision/result-cache/key.js +28 -0
- package/dist/src/decision/result-cache/service.js +92 -0
- package/dist/src/decision/result-cache/store.js +327 -0
- package/dist/src/decision/result-cache/types.js +3 -0
- package/dist/src/decision/review/authorization.js +110 -0
- package/dist/src/decision/review/effect-ledger-adapter.js +321 -0
- package/dist/src/decision/review/index.js +12 -0
- package/dist/src/decision/review/migrate.js +34 -0
- package/dist/src/decision/review/operator-audit.js +59 -0
- package/dist/src/decision/review/recovery-adapters.js +252 -0
- package/dist/src/decision/review/recovery.js +33 -0
- package/dist/src/decision/review/service.js +498 -0
- package/dist/src/decision/review/store.js +219 -0
- package/dist/src/decision/review/types.js +2 -0
- package/dist/src/decision/review/validate.js +205 -0
- package/dist/src/decision/scheduler.js +115 -0
- package/dist/src/decision/telemetry/context.js +42 -0
- package/dist/src/decision/telemetry/debug-sidecar.js +152 -0
- package/dist/src/decision/telemetry/exporter.js +77 -0
- package/dist/src/decision/telemetry/file-debug-backend.js +112 -0
- package/dist/src/decision/telemetry/index.js +13 -0
- package/dist/src/decision/telemetry/mapping.js +146 -0
- package/dist/src/decision/telemetry/metrics.js +164 -0
- package/dist/src/decision/telemetry/otlp-http.js +183 -0
- package/dist/src/decision/telemetry/redaction.js +93 -0
- package/dist/src/decision/telemetry/retention.js +109 -0
- package/dist/src/decision/telemetry/runtime.js +310 -0
- package/dist/src/decision/telemetry/trace.js +158 -0
- package/dist/src/decision/telemetry/types.js +3 -0
- package/dist/src/decision/validate.js +135 -5
- package/dist/src/effects/checkpoint-sinks.js +94 -0
- package/dist/src/effects/checkpoint.js +76 -0
- package/dist/src/effects/decision-links.js +54 -0
- package/dist/src/effects/errors.js +48 -0
- package/dist/src/effects/identity.js +178 -0
- package/dist/src/effects/index.js +25 -0
- package/dist/src/effects/keyring.js +160 -0
- package/dist/src/effects/keys.js +130 -0
- package/dist/src/effects/ledger.js +435 -0
- package/dist/src/effects/lifecycle.js +110 -0
- package/dist/src/effects/lock-recovery.js +269 -0
- package/dist/src/effects/reader.js +103 -0
- package/dist/src/effects/records.js +114 -0
- package/dist/src/effects/redaction.js +51 -0
- package/dist/src/effects/schema.js +73 -0
- package/dist/src/effects/store.js +239 -0
- package/dist/src/effects/types.js +49 -0
- package/dist/src/effects/verifiers/decision.js +168 -0
- package/dist/src/effects/verifiers/file.js +106 -0
- package/dist/src/effects/verifiers/git.js +206 -0
- package/dist/src/effects/verifiers/index.js +90 -0
- package/dist/src/effects/verifiers/registry.js +148 -0
- package/dist/src/effects/verifiers/review.js +123 -0
- package/dist/src/effects/verifiers/tracker.js +299 -0
- package/dist/src/effects/verifiers/types.js +21 -0
- package/dist/src/effects/verify.js +295 -0
- package/dist/src/evidence/bundle.js +2 -0
- package/dist/src/extensions/commands/definitions.js +28 -0
- package/dist/src/extensions/manifest.js +1 -0
- package/dist/src/marketplace/provenance.js +25 -27
- package/dist/src/mcp/cli.mjs +11 -1
- package/dist/src/mcp/grok-build-config.mjs +189 -0
- package/dist/src/mcp/registry.js +21 -1
- package/dist/src/mcp/registry.mjs +20 -1
- package/dist/src/models/grok-build-models.js +356 -0
- package/dist/src/models/index.js +1 -0
- package/dist/src/models/model-capabilities.v1.json +34 -0
- package/dist/src/models/model-catalog.v1.json +55 -13
- package/dist/src/models/model-discovery.js +41 -5
- package/dist/src/models/provider-policy.js +3 -2
- package/dist/src/plugin/skill-command-translator.js +1 -0
- package/dist/src/providers/capability-matrix.yaml +58 -6
- package/dist/src/providers/grok-build-acp.js +192 -0
- package/dist/src/providers/grok-build-dispatch.js +251 -0
- package/dist/src/providers/grok-build-headless.js +170 -0
- package/dist/src/providers/muse-paths.js +105 -0
- package/dist/src/providers/provider-definitions.js +93 -7
- package/dist/src/providers/provider-definitions.mjs +21 -1
- package/dist/src/providers/provider-inventory.js +3 -0
- package/dist/src/security/artifact-attestation.js +4 -8
- package/dist/src/security/signing.js +119 -0
- package/dist/src/sessions/adapters/grok-build.js +187 -0
- package/dist/src/sessions/adapters/muse.js +412 -0
- package/dist/src/sessions/contracts.js +1 -1
- package/dist/src/sessions/grok-build-cli.js +196 -0
- package/dist/src/sessions/index.js +3 -0
- package/dist/src/sessions/workspace-discovery.js +9 -5
- package/dist/src/skills/deployer.js +33 -5
- package/dist/src/smiths/context-pipeline/claude-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/context-file-safety.js +18 -0
- package/dist/src/smiths/context-pipeline/finalization.js +1 -32
- package/dist/src/smiths/context-pipeline/generator.js +12 -0
- package/dist/src/smiths/context-pipeline/index.js +1 -1
- package/dist/src/smiths/context-pipeline/managed-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/muse-bridge.js +50 -0
- package/dist/src/smiths/context-pipeline/provider-policy.js +5 -0
- package/dist/src/smiths/context-pipeline/workspace-context.js +22 -16
- package/dist/src/smiths/hook-bridge/grok-build-translator.js +64 -0
- package/dist/src/smiths/hook-bridge/index.js +3 -0
- package/dist/src/smiths/hook-bridge/shim.js +2 -0
- package/dist/src/smiths/hook-bridge/types.js +5 -0
- package/dist/src/smiths/skillsmith/collision-detector.js +1 -0
- package/dist/src/storage/protected-files.js +102 -0
- package/dist/src/tracker/forge-http.js +371 -0
- package/dist/src/tracker/project-inputs.js +41 -0
- package/package.json +2 -1
- package/schemas/decision/BatchReceipt.v1.schema.json +50 -0
- package/schemas/decision/CalibrationArtifact.v1.schema.json +76 -0
- package/schemas/decision/CalibrationCompatibility.v1.schema.json +18 -0
- package/schemas/decision/CalibrationGovernanceReceipt.v1.schema.json +44 -0
- package/schemas/decision/DecisionAdmissionEvidence.v1.schema.json +43 -0
- package/schemas/decision/DecisionBinding.schema.json +309 -0
- package/schemas/decision/DecisionBinding.v1alpha2.schema.json +369 -0
- package/schemas/decision/DecisionCacheTelemetry.v1.schema.json +20 -0
- package/schemas/decision/DecisionChampionChallenger.v1.schema.json +100 -0
- package/schemas/decision/DecisionCompileCacheEntry.v1.schema.json +35 -0
- package/schemas/decision/DecisionCompileCacheIdentity.v1.schema.json +26 -0
- package/schemas/decision/DecisionCompileCacheTombstone.v1.schema.json +31 -0
- package/schemas/decision/DecisionContextEvidence.v1.schema.json +136 -0
- package/schemas/decision/DecisionDefinition.schema.json +159 -0
- package/schemas/decision/DecisionDefinition.v1alpha2.schema.json +203 -0
- package/schemas/decision/DecisionDriftResponse.v1.schema.json +55 -0
- package/schemas/decision/DecisionEnsembleAggregate.v1.schema.json +86 -0
- package/schemas/decision/DecisionEnsembleIntegrityReport.v1.schema.json +64 -0
- package/schemas/decision/DecisionEnsemblePolicy.v1.schema.json +130 -0
- package/schemas/decision/DecisionGraph.v1.schema.json +77 -0
- package/schemas/decision/DecisionJob.v1.schema.json +71 -0
- package/schemas/decision/DecisionLifecyclePolicy.v1.schema.json +52 -0
- package/schemas/decision/DecisionProjectionPolicy.v1.schema.json +65 -0
- package/schemas/decision/DecisionProviderPrefixCompatibility.v1.schema.json +45 -0
- package/schemas/decision/DecisionProviderPrefixEvidence.v1.schema.json +17 -0
- package/schemas/decision/DecisionProviderPrefixIdentity.v1.schema.json +36 -0
- package/schemas/decision/DecisionResult.schema.json +524 -0
- package/schemas/decision/DecisionResult.v1alpha2.schema.json +619 -0
- package/schemas/decision/DecisionResultCacheEntry.v1.schema.json +53 -0
- package/schemas/decision/DecisionReview.schema.json +29 -0
- package/schemas/decision/DecisionRuleset.schema.json +313 -0
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +313 -0
- package/schemas/decision/DecisionTelemetryTrace.v1.schema.json +95 -0
- package/schemas/decision/RulesetResult.schema.json +721 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +891 -0
- package/schemas/security/pr-evidence-receipt.v1.schema.json +77 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +71 -1
- package/tools/agents/deploy-agents.mjs +21 -6
- package/tools/agents/providers/base.mjs +7 -4
- package/tools/agents/providers/codex.mjs +1 -1
- package/tools/agents/providers/grok-build.d.mts +7 -0
- package/tools/agents/providers/grok-build.mjs +249 -9
- package/tools/agents/providers/model-role.mjs +6 -3
- package/tools/agents/providers/muse-hooks.mjs +785 -0
- package/tools/agents/providers/muse-paths.mjs +112 -0
- package/tools/agents/providers/muse.mjs +336 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dedicated Ed25519 ledger signing key and its pluggable providers.
|
|
3
|
+
*
|
|
4
|
+
* The private key lives only inside a `LedgerSigningKey` private field. It has
|
|
5
|
+
* no accessor, and JSON serialization and inspection show the public key only,
|
|
6
|
+
* so it cannot reach records, errors or output by accident.
|
|
7
|
+
*
|
|
8
|
+
* Providers:
|
|
9
|
+
* - `credentialStoreKeyProvider`: the host secret service through the
|
|
10
|
+
* configurable credential store (`src/auth/credential-store.ts`). This is the
|
|
11
|
+
* production provider.
|
|
12
|
+
* - `environmentTestKeyProvider`: a key injected through
|
|
13
|
+
* `AIWG_EFFECT_LEDGER_TEST_KEY`, honoured only under a test runner or CI.
|
|
14
|
+
* - `staticKeyProvider`: an in-process key object, for embedding hosts and tests.
|
|
15
|
+
*/
|
|
16
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
|
|
17
|
+
import { inspect } from 'node:util';
|
|
18
|
+
import { createSecretStore } from '../auth/credential-store.js';
|
|
19
|
+
import { signDsseEnvelope } from '../security/signing.js';
|
|
20
|
+
import { EffectLedgerError } from './errors.js';
|
|
21
|
+
import { sha256Digest } from './identity.js';
|
|
22
|
+
const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
23
|
+
export const DEFAULT_LEDGER_KEY_SERVICE = 'effects.aiwg.io';
|
|
24
|
+
export const LEDGER_TEST_KEY_ENV = 'AIWG_EFFECT_LEDGER_TEST_KEY';
|
|
25
|
+
const keyUnavailable = (message = 'Effect ledger signing key is unavailable') => new EffectLedgerError('key-unavailable', message, 'key-unavailable');
|
|
26
|
+
export class LedgerSigningKey {
|
|
27
|
+
keyid;
|
|
28
|
+
/** Standard base64 of the DER SubjectPublicKeyInfo. */
|
|
29
|
+
publicKey;
|
|
30
|
+
#privateKey;
|
|
31
|
+
constructor(privateKey) {
|
|
32
|
+
if (privateKey.type !== 'private' || privateKey.asymmetricKeyType !== 'ed25519')
|
|
33
|
+
throw keyUnavailable('Effect ledger signing key must be an Ed25519 private key');
|
|
34
|
+
this.#privateKey = privateKey;
|
|
35
|
+
const der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
|
|
36
|
+
this.publicKey = der.toString('base64');
|
|
37
|
+
this.keyid = sha256Digest(der);
|
|
38
|
+
}
|
|
39
|
+
/** Signature (standard padded base64) over the DSSE PAE of `payloadType` and `payload`. */
|
|
40
|
+
signPae(payloadType, payload) {
|
|
41
|
+
const envelope = signDsseEnvelope(payloadType, payload, this.#privateKey);
|
|
42
|
+
return envelope.signatures[0].sig;
|
|
43
|
+
}
|
|
44
|
+
toJSON() { return { keyid: this.keyid, publicKey: this.publicKey }; }
|
|
45
|
+
[inspect.custom]() { return `LedgerSigningKey(${this.keyid})`; }
|
|
46
|
+
}
|
|
47
|
+
/** Parse a PKCS#8 PEM, base64 PKCS#8 DER, or 32-byte hex seed into an Ed25519 key. */
|
|
48
|
+
export function parseLedgerPrivateKey(secret) {
|
|
49
|
+
const value = secret.trim();
|
|
50
|
+
try {
|
|
51
|
+
if (value.includes('-----BEGIN'))
|
|
52
|
+
return createPrivateKey(value);
|
|
53
|
+
if (/^[a-f0-9]{64}$/i.test(value)) {
|
|
54
|
+
return createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(value, 'hex')]), format: 'der', type: 'pkcs8' });
|
|
55
|
+
}
|
|
56
|
+
return createPrivateKey({ key: Buffer.from(value, 'base64'), format: 'der', type: 'pkcs8' });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw keyUnavailable('Effect ledger signing key material is not a valid Ed25519 private key');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Generate a fresh Ed25519 ledger key, serialized as base64 PKCS#8 DER for a secret store. */
|
|
63
|
+
export function generateLedgerKeySecret() {
|
|
64
|
+
const { privateKey } = generateKeyPairSync('ed25519');
|
|
65
|
+
return privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64');
|
|
66
|
+
}
|
|
67
|
+
export function staticKeyProvider(privateKey, name = 'static') {
|
|
68
|
+
const key = new LedgerSigningKey(typeof privateKey === 'string' ? parseLedgerPrivateKey(privateKey) : privateKey);
|
|
69
|
+
return { name, load: async () => key };
|
|
70
|
+
}
|
|
71
|
+
/** The production provider: the host secret service through the configurable credential store. */
|
|
72
|
+
export function credentialStoreKeyProvider(options) {
|
|
73
|
+
const service = options.service ?? DEFAULT_LEDGER_KEY_SERVICE;
|
|
74
|
+
let cached;
|
|
75
|
+
return {
|
|
76
|
+
name: 'credential-store',
|
|
77
|
+
async load() {
|
|
78
|
+
if (cached)
|
|
79
|
+
return cached;
|
|
80
|
+
let store;
|
|
81
|
+
try {
|
|
82
|
+
store = options.store ?? createSecretStore({
|
|
83
|
+
service, account: options.account, platform: options.platform, runner: options.runner,
|
|
84
|
+
useFile: options.useFile, allowFile: options.allowFile, pathname: options.pathname,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw keyUnavailable('Effect ledger key store is unavailable on this host');
|
|
89
|
+
}
|
|
90
|
+
let secret;
|
|
91
|
+
try {
|
|
92
|
+
secret = await store.loadSecret();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw keyUnavailable('Effect ledger key could not be read from the host secret service');
|
|
96
|
+
}
|
|
97
|
+
if (!secret) {
|
|
98
|
+
if (!options.provisionIfMissing)
|
|
99
|
+
throw keyUnavailable('Effect ledger key is not provisioned in the host secret service');
|
|
100
|
+
secret = generateLedgerKeySecret();
|
|
101
|
+
try {
|
|
102
|
+
await store.saveSecret(secret);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw keyUnavailable('Effect ledger key could not be stored in the host secret service');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
cached = new LedgerSigningKey(parseLedgerPrivateKey(secret));
|
|
109
|
+
return cached;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function testContext(env) {
|
|
114
|
+
return env.VITEST === 'true' || env.NODE_ENV === 'test' || env.CI === 'true' || env.CI === '1';
|
|
115
|
+
}
|
|
116
|
+
/** Tests and CI only: a key injected through `AIWG_EFFECT_LEDGER_TEST_KEY`. */
|
|
117
|
+
export function environmentTestKeyProvider(env = process.env) {
|
|
118
|
+
return {
|
|
119
|
+
name: 'environment-test-key',
|
|
120
|
+
async load() {
|
|
121
|
+
if (!testContext(env))
|
|
122
|
+
throw keyUnavailable(`${LEDGER_TEST_KEY_ENV} is honoured only under a test runner or CI`);
|
|
123
|
+
const value = env[LEDGER_TEST_KEY_ENV];
|
|
124
|
+
if (!value)
|
|
125
|
+
throw keyUnavailable(`${LEDGER_TEST_KEY_ENV} is not set`);
|
|
126
|
+
return new LedgerSigningKey(parseLedgerPrivateKey(value));
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=keys.js.map
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The effect ledger: intent, outcome, reconcile and lookup over per-writer
|
|
3
|
+
* signed segments, with exclusive-create index files for cross-writer
|
|
4
|
+
* idempotency (first writer wins).
|
|
5
|
+
*
|
|
6
|
+
* The ledger proves what AIWG recorded; it never authorizes or replays an
|
|
7
|
+
* effect. A missing entry is not evidence that the effect did not happen.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/contracts/effect-ledger.v1.md
|
|
10
|
+
* @see docs/architecture/adr-effect-ledger.md
|
|
11
|
+
*/
|
|
12
|
+
import { canonicalJson } from '../security/artifact-trust.js';
|
|
13
|
+
import { gitRefCheckpointSink } from './checkpoint-sinks.js';
|
|
14
|
+
import { conflictError, EFFECT_EXIT_CODES, EffectLedgerError, integrityError, usageError } from './errors.js';
|
|
15
|
+
import { assertEffectId, assertEffectScope, assertPayloadDigest, assertWriterId, effectId as deriveEffectId, defaultDerivationFor, } from './identity.js';
|
|
16
|
+
import { activeKey, assertKeyring, createGenesisKeyring, keyValidAt, rotateKeyring } from './keyring.js';
|
|
17
|
+
import { lineFailures, mergeOrder, readAllSegments, readKeyring, readWriterSegment } from './reader.js';
|
|
18
|
+
import { decodeSegmentLine, signSegmentLine, statementFor } from './records.js';
|
|
19
|
+
import { assertEffectSchema } from './schema.js';
|
|
20
|
+
import { appendSegmentLine, claimIndex, clearPending, ensureLedgerDirectories, indexName, loadIndexKey, readIndexEntry, readPending, readSegment, replaceFile, resolveLedgerPaths, withLedgerLock, writePending, } from './store.js';
|
|
21
|
+
import { publishExclusive } from '../storage/protected-files.js';
|
|
22
|
+
import { createVerifierRegistry, runVerifier } from './verifiers/registry.js';
|
|
23
|
+
import { INDEX_SCHEMA_VERSION, RECORD_SCHEMA_VERSION, VERIFIER_RESULT_SCHEMA_VERSION, } from './types.js';
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Verifier interface (src/effects/verifiers; re-exported for compatibility)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
export { createVerifierRegistry } from './verifiers/registry.js';
|
|
28
|
+
export class EffectLedger {
|
|
29
|
+
projectDir;
|
|
30
|
+
scope;
|
|
31
|
+
writer;
|
|
32
|
+
sink;
|
|
33
|
+
verifiers;
|
|
34
|
+
lockTimeoutMs;
|
|
35
|
+
keyProvider;
|
|
36
|
+
clock;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
if (!options || typeof options.projectDir !== 'string' || !options.projectDir)
|
|
39
|
+
throw usageError('Effect ledger needs a project directory', 'invalid-project');
|
|
40
|
+
assertEffectScope(options.scope);
|
|
41
|
+
assertWriterId(options.writer);
|
|
42
|
+
this.projectDir = options.projectDir;
|
|
43
|
+
this.scope = structuredClone(options.scope);
|
|
44
|
+
this.writer = options.writer;
|
|
45
|
+
this.keyProvider = options.keyProvider;
|
|
46
|
+
this.clock = options.clock ?? Date.now;
|
|
47
|
+
this.sink = options.sink ?? gitRefCheckpointSink({ repoDir: options.projectDir, subsystem: options.scope.subsystem });
|
|
48
|
+
this.verifiers = options.verifiers ?? createVerifierRegistry();
|
|
49
|
+
this.lockTimeoutMs = options.lockTimeoutMs ?? 30_000;
|
|
50
|
+
}
|
|
51
|
+
/** Resolved on every call, so an artifact root that goes away fails closed (exit 7). */
|
|
52
|
+
paths() { return resolveLedgerPaths(this.projectDir, this.scope.subsystem); }
|
|
53
|
+
now() {
|
|
54
|
+
const value = this.clock();
|
|
55
|
+
if (!Number.isFinite(value))
|
|
56
|
+
throw new EffectLedgerError('internal', 'Effect ledger clock returned an invalid time');
|
|
57
|
+
return new Date(value).toISOString();
|
|
58
|
+
}
|
|
59
|
+
async signingKey() {
|
|
60
|
+
if (!this.keyProvider)
|
|
61
|
+
throw new EffectLedgerError('key-unavailable', 'Effect ledger writes need a key provider', 'key-unavailable');
|
|
62
|
+
return this.keyProvider.load();
|
|
63
|
+
}
|
|
64
|
+
/** Switch to another key provider, for example after `rotateKey`. */
|
|
65
|
+
useKeyProvider(provider) { this.keyProvider = provider; }
|
|
66
|
+
toJSON() { return { scope: this.scope, writer: this.writer }; }
|
|
67
|
+
}
|
|
68
|
+
export function openEffectLedger(options) {
|
|
69
|
+
return new EffectLedger(options);
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Internals
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
async function prepareWrite(ledger) {
|
|
75
|
+
const paths = ledger.paths();
|
|
76
|
+
await ensureLedgerDirectories(paths);
|
|
77
|
+
return { paths, indexKey: await loadIndexKey(paths) };
|
|
78
|
+
}
|
|
79
|
+
/** Load the keyring, creating the genesis keyring for this key on first use. */
|
|
80
|
+
async function ensureKeyring(ledger, paths, key, at) {
|
|
81
|
+
let stored = await readKeyring(paths);
|
|
82
|
+
if (stored === null) {
|
|
83
|
+
const genesis = createGenesisKeyring(ledger.scope, key, at);
|
|
84
|
+
assertEffectSchema('keyring', genesis);
|
|
85
|
+
await publishExclusive(paths.root, paths.keyring, `${JSON.stringify(genesis, null, 2)}\n`, 'keyring');
|
|
86
|
+
stored = await readKeyring(paths);
|
|
87
|
+
}
|
|
88
|
+
assertKeyring(stored, ledger.scope);
|
|
89
|
+
return stored;
|
|
90
|
+
}
|
|
91
|
+
function assertSigningKeyActive(keyring, key, at) {
|
|
92
|
+
const active = activeKey(keyring);
|
|
93
|
+
if (active.keyid !== key.keyid || !keyValidAt(active, at)) {
|
|
94
|
+
throw new EffectLedgerError('key-unavailable', 'The loaded signing key is not the active ledger key at this time', 'key-not-active');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function segmentHead(paths, writer) {
|
|
98
|
+
const segment = await readSegment(paths, writer);
|
|
99
|
+
if (!segment)
|
|
100
|
+
return { count: 0, hash: null };
|
|
101
|
+
if (segment.torn)
|
|
102
|
+
throw integrityError('segment-torn', 'Effect ledger segment has a torn tail; run verify');
|
|
103
|
+
if (!segment.lines.length)
|
|
104
|
+
return { count: 0, hash: null };
|
|
105
|
+
const last = decodeSegmentLine(segment.lines.at(-1));
|
|
106
|
+
if ('failure' in last)
|
|
107
|
+
throw integrityError(last.failure, 'Effect ledger segment head is malformed; run verify');
|
|
108
|
+
return { count: segment.lines.length, hash: last.line.recordHash };
|
|
109
|
+
}
|
|
110
|
+
/** Finish an append that crashed between its index claim and the segment write. */
|
|
111
|
+
async function repairPending(paths, writer) {
|
|
112
|
+
const pending = await readPending(paths, writer);
|
|
113
|
+
if (!pending)
|
|
114
|
+
return;
|
|
115
|
+
const line = pending.line;
|
|
116
|
+
const head = await segmentHead(paths, writer);
|
|
117
|
+
const entry = await readIndexEntry(paths, pending.indexName).catch(() => null);
|
|
118
|
+
const decoded = decodeSegmentLine(JSON.stringify(line));
|
|
119
|
+
if (!('failure' in decoded) && entry && entry.recordHash === line.recordHash && entry.writer === writer
|
|
120
|
+
&& line.seq === head.count && decoded.statement.predicate.prev === head.hash) {
|
|
121
|
+
await appendSegmentLine(paths, writer, line);
|
|
122
|
+
}
|
|
123
|
+
await clearPending(paths, writer);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Sign and append one record under this writer's lock. With a `role`, the
|
|
127
|
+
* record is appended only after its exclusive index claim wins; a lost claim
|
|
128
|
+
* returns the winning index entry instead.
|
|
129
|
+
*/
|
|
130
|
+
async function appendRecord(ledger, plan, effectIdValue) {
|
|
131
|
+
const { paths, indexKey } = await prepareWrite(ledger);
|
|
132
|
+
const key = await ledger.signingKey();
|
|
133
|
+
return withLedgerLock(paths, `writer-${ledger.writer}`, ledger.lockTimeoutMs, async () => {
|
|
134
|
+
// Read the clock after the keyring exists, so a genesis keyring published by
|
|
135
|
+
// a concurrent process is never newer than this record.
|
|
136
|
+
const keyring = await ensureKeyring(ledger, paths, key, ledger.now());
|
|
137
|
+
const recordedAt = ledger.now();
|
|
138
|
+
assertSigningKeyActive(keyring, key, recordedAt);
|
|
139
|
+
await repairPending(paths, ledger.writer);
|
|
140
|
+
const head = await segmentHead(paths, ledger.writer);
|
|
141
|
+
const predicate = plan.build(head.count, head.hash, recordedAt);
|
|
142
|
+
const line = signSegmentLine(statementFor(predicate), key);
|
|
143
|
+
if (plan.role) {
|
|
144
|
+
const name = indexName(indexKey, ledger.scope, effectIdValue, plan.role);
|
|
145
|
+
const entry = {
|
|
146
|
+
schemaVersion: INDEX_SCHEMA_VERSION, role: plan.role, effectId: effectIdValue, payloadDigest: predicate.payloadDigest,
|
|
147
|
+
phase: predicate.phase, writer: ledger.writer, seq: predicate.seq, recordHash: line.recordHash,
|
|
148
|
+
};
|
|
149
|
+
await writePending(paths, ledger.writer, { indexName: name, line });
|
|
150
|
+
if (!(await claimIndex(paths, name, entry))) {
|
|
151
|
+
await clearPending(paths, ledger.writer);
|
|
152
|
+
const winner = await readIndexEntry(paths, name);
|
|
153
|
+
if (!winner)
|
|
154
|
+
throw new EffectLedgerError('internal', 'Effect index claim was lost but no winner is readable');
|
|
155
|
+
return { line, lost: winner };
|
|
156
|
+
}
|
|
157
|
+
await appendSegmentLine(paths, ledger.writer, line);
|
|
158
|
+
await clearPending(paths, ledger.writer);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
await appendSegmentLine(paths, ledger.writer, line);
|
|
162
|
+
}
|
|
163
|
+
return { line };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function receiptFromEntry(entry, idDerivation, idempotent) {
|
|
167
|
+
return {
|
|
168
|
+
effectId: entry.effectId, idDerivation, phase: entry.phase, payloadDigest: entry.payloadDigest,
|
|
169
|
+
writer: entry.writer, seq: entry.seq, recordHash: entry.recordHash, idempotent, exitCode: EFFECT_EXIT_CODES.ok,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function receiptFromLine(line, predicate) {
|
|
173
|
+
return {
|
|
174
|
+
effectId: predicate.effectId, idDerivation: predicate.idDerivation, phase: predicate.phase,
|
|
175
|
+
payloadDigest: predicate.payloadDigest, writer: line.writer, seq: line.seq, recordHash: line.recordHash,
|
|
176
|
+
idempotent: false, exitCode: EFFECT_EXIT_CODES.ok,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Decode and verify the intent record an index entry points at. */
|
|
180
|
+
async function intentRecord(ledger, paths, entry) {
|
|
181
|
+
const segment = await readWriterSegment(paths, entry.writer);
|
|
182
|
+
const read = segment?.lines[entry.seq];
|
|
183
|
+
if (!read?.decoded)
|
|
184
|
+
throw integrityError('index-dangling', 'The effect index points at a missing intent record; run verify');
|
|
185
|
+
const keyring = await readKeyring(paths);
|
|
186
|
+
assertKeyring(keyring, ledger.scope);
|
|
187
|
+
const previous = entry.seq === 0 ? null : segment.lines[entry.seq - 1]?.decoded?.line.recordHash ?? null;
|
|
188
|
+
const failures = lineFailures(keyring, ledger.scope, entry.writer, entry.seq, read.decoded, previous);
|
|
189
|
+
if (failures.length || read.decoded.line.recordHash !== entry.recordHash || read.decoded.statement.predicate.effectId !== entry.effectId) {
|
|
190
|
+
throw integrityError(failures[0] ?? 'index-segment-mismatch');
|
|
191
|
+
}
|
|
192
|
+
return read.decoded;
|
|
193
|
+
}
|
|
194
|
+
async function requireIntent(ledger, id) {
|
|
195
|
+
assertEffectId(id);
|
|
196
|
+
const { paths, indexKey } = await prepareWrite(ledger);
|
|
197
|
+
const entry = await readIndexEntry(paths, indexName(indexKey, ledger.scope, id, 'intent'));
|
|
198
|
+
if (!entry)
|
|
199
|
+
throw usageError('No intent is recorded for this effect ID', 'intent-missing');
|
|
200
|
+
const intent = (await intentRecord(ledger, paths, entry)).statement.predicate;
|
|
201
|
+
if (intent.phase === 'tombstone')
|
|
202
|
+
throw usageError('The intent for this effect ID has been purged', 'intent-purged');
|
|
203
|
+
return { paths, indexKey, entry, intent };
|
|
204
|
+
}
|
|
205
|
+
function basePredicate(intent, phase, payloadDigest, links, writer, seq, prev, recordedAt) {
|
|
206
|
+
return {
|
|
207
|
+
schemaVersion: RECORD_SCHEMA_VERSION, effectId: intent.effectId, idDerivation: intent.idDerivation,
|
|
208
|
+
scope: intent.scope, kind: intent.kind, target: intent.target, context: intent.context, phase,
|
|
209
|
+
payloadDigest, writer, seq, prev, recordedAt, links: structuredClone(links ?? {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Append a signed `intent` before the effect. The same ID with the same payload
|
|
214
|
+
* digest returns the first receipt (`idempotent: true`); a different digest is
|
|
215
|
+
* a conflict (exit 5). Nothing is appended in either case.
|
|
216
|
+
*/
|
|
217
|
+
export async function recordIntent(ledger, input) {
|
|
218
|
+
const derivation = input?.derivation ?? defaultDerivationFor(input?.kind);
|
|
219
|
+
const id = deriveEffectId({ scope: ledger.scope, kind: input.kind, target: input.target, context: input.context }, derivation);
|
|
220
|
+
assertPayloadDigest(input.payloadDigest);
|
|
221
|
+
const { paths, indexKey } = await prepareWrite(ledger);
|
|
222
|
+
const existing = await readIndexEntry(paths, indexName(indexKey, ledger.scope, id, 'intent'));
|
|
223
|
+
const settle = (entry) => {
|
|
224
|
+
if (entry.payloadDigest !== input.payloadDigest)
|
|
225
|
+
throw conflictError();
|
|
226
|
+
return receiptFromEntry(entry, derivation, true);
|
|
227
|
+
};
|
|
228
|
+
if (existing)
|
|
229
|
+
return settle(existing);
|
|
230
|
+
const { line, lost } = await appendRecord(ledger, {
|
|
231
|
+
role: 'intent',
|
|
232
|
+
build: (seq, prev, recordedAt) => ({
|
|
233
|
+
schemaVersion: RECORD_SCHEMA_VERSION, effectId: id, idDerivation: derivation, scope: ledger.scope,
|
|
234
|
+
kind: input.kind, target: input.target, context: structuredClone(input.context), phase: 'intent',
|
|
235
|
+
payloadDigest: input.payloadDigest, writer: ledger.writer, seq, prev, recordedAt, links: structuredClone(input.links ?? {}),
|
|
236
|
+
}),
|
|
237
|
+
}, id);
|
|
238
|
+
if (lost)
|
|
239
|
+
return settle(lost);
|
|
240
|
+
return receiptFromLine(line, JSON.parse(Buffer.from(line.envelope.payload, 'base64').toString('utf8')).predicate);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Append the terminal outcome. The first outcome for an ID wins across writers;
|
|
244
|
+
* repeating it returns the existing receipt, and a different terminal outcome
|
|
245
|
+
* or payload digest is a conflict.
|
|
246
|
+
*/
|
|
247
|
+
export async function recordOutcome(ledger, id, input) {
|
|
248
|
+
if (input?.phase !== 'completed' && input?.phase !== 'failed')
|
|
249
|
+
throw usageError('Outcome phase must be completed or failed', 'invalid-phase');
|
|
250
|
+
assertPayloadDigest(input.payloadDigest);
|
|
251
|
+
if (input.phase === 'completed' && input.verification?.result !== 'present')
|
|
252
|
+
throw usageError('A completed outcome needs a present verification', 'completed-without-present');
|
|
253
|
+
if (input.phase === 'failed' && !input.failure)
|
|
254
|
+
throw usageError('A failed outcome needs a failure reason', 'failed-without-reason');
|
|
255
|
+
const { paths, indexKey, entry, intent } = await requireIntent(ledger, id);
|
|
256
|
+
if (entry.payloadDigest !== input.payloadDigest)
|
|
257
|
+
throw conflictError();
|
|
258
|
+
const outcomeName = indexName(indexKey, ledger.scope, id, 'outcome');
|
|
259
|
+
const settle = (winner) => {
|
|
260
|
+
if (winner.phase !== input.phase || winner.payloadDigest !== input.payloadDigest) {
|
|
261
|
+
throw new EffectLedgerError('conflict', 'A different outcome is already recorded for this effect ID', 'outcome-conflict');
|
|
262
|
+
}
|
|
263
|
+
return receiptFromEntry(winner, intent.idDerivation, true);
|
|
264
|
+
};
|
|
265
|
+
const existing = await readIndexEntry(paths, outcomeName);
|
|
266
|
+
if (existing)
|
|
267
|
+
return settle(existing);
|
|
268
|
+
const { line, lost } = await appendRecord(ledger, {
|
|
269
|
+
role: 'outcome',
|
|
270
|
+
build: (seq, prev, recordedAt) => ({
|
|
271
|
+
...basePredicate(intent, input.phase, input.payloadDigest, input.links, ledger.writer, seq, prev, recordedAt),
|
|
272
|
+
...(input.phase === 'completed' ? { verification: structuredClone(input.verification) } : { failure: structuredClone(input.failure) }),
|
|
273
|
+
}),
|
|
274
|
+
}, id);
|
|
275
|
+
if (lost)
|
|
276
|
+
return settle(lost);
|
|
277
|
+
return receiptFromLine(line, { ...intent, phase: input.phase, writer: line.writer, seq: line.seq });
|
|
278
|
+
}
|
|
279
|
+
/** Append one `reconciled` record for one verifier attempt. History is never mutated. */
|
|
280
|
+
export async function recordReconciled(ledger, id, verification, links) {
|
|
281
|
+
const { intent, entry } = await requireIntent(ledger, id);
|
|
282
|
+
const { line } = await appendRecord(ledger, {
|
|
283
|
+
build: (seq, prev, recordedAt) => ({
|
|
284
|
+
...basePredicate(intent, 'reconciled', entry.payloadDigest, links, ledger.writer, seq, prev, recordedAt),
|
|
285
|
+
verification: structuredClone(verification),
|
|
286
|
+
}),
|
|
287
|
+
}, id);
|
|
288
|
+
return receiptFromLine(line, { ...intent, phase: 'reconciled', writer: line.writer, seq: line.seq });
|
|
289
|
+
}
|
|
290
|
+
const EXIT_FOR_RESULT = { present: 0, absent: 3, unknown: 4 };
|
|
291
|
+
/**
|
|
292
|
+
* Reconcile one effect through its kind's verifier. Every call appends one
|
|
293
|
+
* signed `reconciled` record; a `present` result also records `completed`
|
|
294
|
+
* (first outcome wins). Earlier records are never mutated and the effect is
|
|
295
|
+
* never replayed. With no registered verifier the result is `unknown` /
|
|
296
|
+
* `verifier-missing`.
|
|
297
|
+
*/
|
|
298
|
+
export async function reconcileEffect(ledger, id, options = {}) {
|
|
299
|
+
const { intent, entry } = await requireIntent(ledger, id);
|
|
300
|
+
const registry = options.verifiers ?? ledger.verifiers;
|
|
301
|
+
const run = await runVerifier(registry.get(intent.kind), {
|
|
302
|
+
effectId: intent.effectId, scope: structuredClone(intent.scope), kind: intent.kind, target: intent.target,
|
|
303
|
+
context: structuredClone(intent.context), payloadDigest: entry.payloadDigest, intentRecordedAt: intent.recordedAt,
|
|
304
|
+
expected: structuredClone(options.expected ?? {}),
|
|
305
|
+
}, options);
|
|
306
|
+
const verification = { verifier: run.verifier, ...run.observation, checkedAt: ledger.now() };
|
|
307
|
+
const receipt = await recordReconciled(ledger, id, verification, options.links);
|
|
308
|
+
const result = {
|
|
309
|
+
schemaVersion: VERIFIER_RESULT_SCHEMA_VERSION, effectId: intent.effectId, kind: intent.kind,
|
|
310
|
+
verification, exitCode: EXIT_FOR_RESULT[verification.result],
|
|
311
|
+
};
|
|
312
|
+
assertEffectSchema('verifierResult', result);
|
|
313
|
+
let completed = null;
|
|
314
|
+
if (verification.result === 'present') {
|
|
315
|
+
try {
|
|
316
|
+
completed = await recordOutcome(ledger, id, { phase: 'completed', payloadDigest: entry.payloadDigest, verification, links: options.links });
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (!(error instanceof EffectLedgerError) || error.reason !== 'outcome-conflict')
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { result, receipt: { ...receipt, exitCode: result.exitCode }, completed, ...(run.evidence ? { evidence: run.evidence } : {}) };
|
|
324
|
+
}
|
|
325
|
+
function summarize(decoded) {
|
|
326
|
+
const predicate = decoded.statement.predicate;
|
|
327
|
+
return {
|
|
328
|
+
phase: predicate.phase, writer: decoded.line.writer, seq: decoded.line.seq, recordHash: decoded.line.recordHash,
|
|
329
|
+
recordedAt: predicate.recordedAt, payloadDigest: predicate.payloadDigest, keyid: decoded.line.envelope.signatures[0].keyid,
|
|
330
|
+
...(predicate.verification ? { verification: structuredClone(predicate.verification) } : {}),
|
|
331
|
+
...(predicate.failure ? { failure: structuredClone(predicate.failure) } : {}),
|
|
332
|
+
...(predicate.tombstone ? { tombstone: structuredClone(predicate.tombstone) } : {}),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Look up everything recorded for an effect ID, merged across writers. Every
|
|
337
|
+
* returned record is signature- and chain-checked; a failure is exit 6.
|
|
338
|
+
* A tombstoned record is returned without its target, context or outcome body.
|
|
339
|
+
*/
|
|
340
|
+
export async function lookupEffect(ledger, id) {
|
|
341
|
+
assertEffectId(id);
|
|
342
|
+
const paths = ledger.paths();
|
|
343
|
+
const keyring = await readKeyring(paths);
|
|
344
|
+
const none = { effectId: id, found: false, status: 'none', tombstoned: false, records: [], exitCode: EFFECT_EXIT_CODES.absent };
|
|
345
|
+
if (keyring === null)
|
|
346
|
+
return none;
|
|
347
|
+
assertKeyring(keyring, ledger.scope);
|
|
348
|
+
const matches = [];
|
|
349
|
+
for (const segment of await readAllSegments(paths)) {
|
|
350
|
+
let previous = null;
|
|
351
|
+
for (const read of segment.lines) {
|
|
352
|
+
if (!read.decoded)
|
|
353
|
+
throw integrityError(read.failure);
|
|
354
|
+
if (read.decoded.statement.predicate.effectId === id) {
|
|
355
|
+
const failures = lineFailures(keyring, ledger.scope, segment.writer, read.index, read.decoded, previous);
|
|
356
|
+
if (failures.length)
|
|
357
|
+
throw integrityError(failures[0]);
|
|
358
|
+
matches.push(read.decoded);
|
|
359
|
+
}
|
|
360
|
+
previous = read.decoded.line.recordHash;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!matches.length)
|
|
364
|
+
return none;
|
|
365
|
+
matches.sort(mergeOrder);
|
|
366
|
+
const records = matches.map(summarize);
|
|
367
|
+
const live = matches.map(item => item.statement.predicate).filter(predicate => predicate.phase !== 'tombstone');
|
|
368
|
+
const tombstoned = records.some(record => record.phase === 'tombstone');
|
|
369
|
+
const identity = live[0];
|
|
370
|
+
const base = { effectId: id, found: true, tombstoned, records, kind: matches[0].statement.predicate.kind, ...(identity?.target ? { target: identity.target } : {}) };
|
|
371
|
+
const outcomes = records.filter(record => record.phase === 'completed' || record.phase === 'failed' || record.phase === 'reconciled');
|
|
372
|
+
const latest = outcomes.at(-1);
|
|
373
|
+
if (!latest) {
|
|
374
|
+
if (!live.length) {
|
|
375
|
+
const phases = records.map(record => record.tombstone.originalPhase);
|
|
376
|
+
const exitCode = phases.includes('completed') ? EFFECT_EXIT_CODES.ok : phases.includes('failed') ? EFFECT_EXIT_CODES.absent : EFFECT_EXIT_CODES.unknown;
|
|
377
|
+
return { ...base, status: 'tombstoned', exitCode };
|
|
378
|
+
}
|
|
379
|
+
return { ...base, status: 'intent', exitCode: EFFECT_EXIT_CODES.unknown };
|
|
380
|
+
}
|
|
381
|
+
if (latest.phase === 'completed')
|
|
382
|
+
return { ...base, status: 'completed', result: 'present', exitCode: EFFECT_EXIT_CODES.ok };
|
|
383
|
+
if (latest.phase === 'failed')
|
|
384
|
+
return { ...base, status: 'failed', exitCode: EFFECT_EXIT_CODES.absent };
|
|
385
|
+
const result = latest.verification.result;
|
|
386
|
+
return { ...base, status: 'reconciled', result, exitCode: EXIT_FOR_RESULT[result] };
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Create the genesis keyring for the loaded signing key when the ledger has
|
|
390
|
+
* none, and return the keyring. Fails when the loaded key is not the active key.
|
|
391
|
+
* Used by `aiwg effect keys init`; ordinary writes create it on first use.
|
|
392
|
+
*/
|
|
393
|
+
export async function initLedgerKeyring(ledger) {
|
|
394
|
+
const { paths } = await prepareWrite(ledger);
|
|
395
|
+
const key = await ledger.signingKey();
|
|
396
|
+
return withLedgerLock(paths, 'keyring', ledger.lockTimeoutMs, async () => {
|
|
397
|
+
const at = ledger.now();
|
|
398
|
+
const keyring = await ensureKeyring(ledger, paths, key, at);
|
|
399
|
+
assertSigningKeyActive(keyring, key, ledger.now());
|
|
400
|
+
return keyring;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/** The stored keyring (public keys only), or null when the ledger has none. Never needs a signing key. */
|
|
404
|
+
export async function readLedgerKeyring(ledger) {
|
|
405
|
+
const stored = await readKeyring(ledger.paths());
|
|
406
|
+
if (stored === null)
|
|
407
|
+
return null;
|
|
408
|
+
assertKeyring(stored, ledger.scope);
|
|
409
|
+
return stored;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Rotate the ledger key. The rotation is signed by the active (prior) key and
|
|
413
|
+
* by the successor key; the prior key's window closes at `effectiveAt`. The
|
|
414
|
+
* ledger handle switches to the successor provider.
|
|
415
|
+
*/
|
|
416
|
+
export async function rotateKey(ledger, successorProvider, options = {}) {
|
|
417
|
+
const { paths } = await prepareWrite(ledger);
|
|
418
|
+
const prior = await ledger.signingKey();
|
|
419
|
+
const successor = await successorProvider.load();
|
|
420
|
+
const effectiveAt = options.effectiveAt ?? ledger.now();
|
|
421
|
+
const keyring = await withLedgerLock(paths, 'keyring', ledger.lockTimeoutMs, async () => {
|
|
422
|
+
const current = await ensureKeyring(ledger, paths, prior, effectiveAt);
|
|
423
|
+
const next = rotateKeyring(current, prior, successor, effectiveAt, options.reason ?? 'scheduled');
|
|
424
|
+
assertEffectSchema('keyring', next);
|
|
425
|
+
await replaceFile(paths.root, paths.keyring, `${JSON.stringify(next, null, 2)}\n`);
|
|
426
|
+
return next;
|
|
427
|
+
});
|
|
428
|
+
ledger.useKeyProvider(successorProvider);
|
|
429
|
+
return keyring;
|
|
430
|
+
}
|
|
431
|
+
/** Stable JSON for CLI and API output; never contains key material. */
|
|
432
|
+
export function effectOutputJson(value) {
|
|
433
|
+
return canonicalJson(JSON.parse(JSON.stringify(value)));
|
|
434
|
+
}
|
|
435
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D10 retention for effect records. A purge replaces each record's envelope
|
|
3
|
+
* with a signed tombstone that keeps the effect ID, kind, payload digest, chain
|
|
4
|
+
* position, links and subject digest, and names the original record hash and
|
|
5
|
+
* keyid, so the chain still verifies. Target, context and outcome bodies are
|
|
6
|
+
* dropped. The effect-ID index is never deleted, so a purged ID cannot be
|
|
7
|
+
* replayed as new.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/contracts/effect-ledger.v1.md "Retention and tombstones"
|
|
10
|
+
*/
|
|
11
|
+
import { validateDecisionLifecyclePolicy } from '../decision/lifecycle.js';
|
|
12
|
+
import { EffectLedgerError, integrityError, usageError } from './errors.js';
|
|
13
|
+
import { assertEffectId } from './identity.js';
|
|
14
|
+
import { activeKey, assertKeyring, keyValidAt } from './keyring.js';
|
|
15
|
+
import { lookupEffect } from './ledger.js';
|
|
16
|
+
import { lineFailures, readKeyring, readWriterSegment } from './reader.js';
|
|
17
|
+
import { signSegmentLine } from './records.js';
|
|
18
|
+
import { ensureLedgerDirectories, replaceSegment, withLedgerLock } from './store.js';
|
|
19
|
+
import { EFFECT_PREDICATE_TYPE, IN_TOTO_STATEMENT_TYPE } from './types.js';
|
|
20
|
+
/** Ledger records bind the D10 `receipt` surface until a dedicated surface exists. */
|
|
21
|
+
export const EFFECT_RETENTION_POLICY = 'decision-lifecycle/v1#receipt';
|
|
22
|
+
const refused = (reason, message) => new EffectLedgerError('purge-refused', message, reason);
|
|
23
|
+
/**
|
|
24
|
+
* Purge one effect's records to tombstones. Refused while a legal hold applies,
|
|
25
|
+
* before the D10 receipt retention has elapsed, and while the intent has no
|
|
26
|
+
* definitive outcome (completed, failed, or a present/absent reconcile).
|
|
27
|
+
*/
|
|
28
|
+
export async function purgeEffect(ledger, id, options) {
|
|
29
|
+
assertEffectId(id);
|
|
30
|
+
try {
|
|
31
|
+
validateDecisionLifecyclePolicy(options?.lifecycle);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw usageError('Purge needs a complete decision-lifecycle/v1 policy', 'lifecycle-invalid');
|
|
35
|
+
}
|
|
36
|
+
const rule = options.lifecycle.surfaces.receipt;
|
|
37
|
+
if (options.legalHold)
|
|
38
|
+
throw refused('legal-hold', 'A legal hold blocks purging this effect');
|
|
39
|
+
const found = await lookupEffect(ledger, id);
|
|
40
|
+
if (!found.found)
|
|
41
|
+
throw usageError('No records exist for this effect ID', 'effect-not-found');
|
|
42
|
+
const live = found.records.filter(record => record.phase !== 'tombstone');
|
|
43
|
+
const result = { effectId: id, tombstoned: 0, alreadyTombstoned: found.records.length - live.length, retentionPolicy: EFFECT_RETENTION_POLICY };
|
|
44
|
+
if (!live.length)
|
|
45
|
+
return result;
|
|
46
|
+
const definitive = found.records.some(record => record.phase === 'completed' || record.phase === 'failed'
|
|
47
|
+
|| (record.phase === 'reconciled' && record.verification?.result !== 'unknown')
|
|
48
|
+
|| (record.phase === 'tombstone' && (record.tombstone.originalPhase === 'completed' || record.tombstone.originalPhase === 'failed')));
|
|
49
|
+
if (!definitive)
|
|
50
|
+
throw refused('unreconciled-intent', 'An intent without a definitive outcome is never purged; reconcile it first');
|
|
51
|
+
const purgedAt = ledger.now();
|
|
52
|
+
const newest = Math.max(...live.map(record => Date.parse(record.recordedAt)));
|
|
53
|
+
if (Date.parse(purgedAt) - newest < rule.retentionMs)
|
|
54
|
+
throw refused('retention-not-elapsed', 'The D10 receipt retention period has not elapsed for this effect');
|
|
55
|
+
const paths = ledger.paths();
|
|
56
|
+
await ensureLedgerDirectories(paths);
|
|
57
|
+
const key = await ledger.signingKey();
|
|
58
|
+
const writers = [...new Set(live.map(record => record.writer))].sort();
|
|
59
|
+
for (const writer of writers) {
|
|
60
|
+
await withLedgerLock(paths, `writer-${writer}`, ledger.lockTimeoutMs, async () => {
|
|
61
|
+
const keyring = await readKeyring(paths);
|
|
62
|
+
assertKeyring(keyring, ledger.scope);
|
|
63
|
+
const active = activeKey(keyring);
|
|
64
|
+
if (active.keyid !== key.keyid || !keyValidAt(active, purgedAt)) {
|
|
65
|
+
throw new EffectLedgerError('key-unavailable', 'The loaded signing key is not the active ledger key at this time', 'key-not-active');
|
|
66
|
+
}
|
|
67
|
+
const segment = await readWriterSegment(paths, writer);
|
|
68
|
+
if (!segment || segment.torn)
|
|
69
|
+
throw integrityError('segment-torn');
|
|
70
|
+
let previous = null;
|
|
71
|
+
let changed = false;
|
|
72
|
+
const lines = segment.lines.map((read) => {
|
|
73
|
+
if (!read.decoded)
|
|
74
|
+
throw integrityError(read.failure);
|
|
75
|
+
const failures = lineFailures(keyring, ledger.scope, writer, read.index, read.decoded, previous);
|
|
76
|
+
if (failures.length)
|
|
77
|
+
throw integrityError(failures[0]);
|
|
78
|
+
previous = read.decoded.line.recordHash;
|
|
79
|
+
const original = read.decoded.statement;
|
|
80
|
+
if (original.predicate.effectId !== id || original.predicate.phase === 'tombstone')
|
|
81
|
+
return read.text;
|
|
82
|
+
const { target: _target, context: _context, verification: _verification, failure: _failure, ...kept } = original.predicate;
|
|
83
|
+
const predicate = {
|
|
84
|
+
...kept,
|
|
85
|
+
phase: 'tombstone',
|
|
86
|
+
tombstone: {
|
|
87
|
+
originalRecordHash: read.decoded.line.recordHash,
|
|
88
|
+
originalKeyid: read.decoded.line.envelope.signatures[0].keyid,
|
|
89
|
+
originalPhase: original.predicate.phase,
|
|
90
|
+
purgedAt,
|
|
91
|
+
retentionPolicy: EFFECT_RETENTION_POLICY,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const statement = {
|
|
95
|
+
_type: IN_TOTO_STATEMENT_TYPE,
|
|
96
|
+
subject: [{ name: `aiwg-effect:${id}`, digest: structuredClone(original.subject[0].digest) }],
|
|
97
|
+
predicateType: EFFECT_PREDICATE_TYPE,
|
|
98
|
+
predicate,
|
|
99
|
+
};
|
|
100
|
+
changed = true;
|
|
101
|
+
result.tombstoned += 1;
|
|
102
|
+
return JSON.stringify(signSegmentLine(statement, key, read.decoded.line.recordHash));
|
|
103
|
+
});
|
|
104
|
+
if (changed)
|
|
105
|
+
await replaceSegment(paths, writer, lines);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=lifecycle.js.map
|