@aiwg/cli 2026.9.19 → 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,498 @@
|
|
|
1
|
+
import { assertReviewProjection, currentProposal, ReviewAccessError, ReviewConflictError, reviewDigest, reviewOperatorEventId } from './validate.js';
|
|
2
|
+
import { validateDecisionLifecyclePolicy } from '../lifecycle.js';
|
|
3
|
+
/** Only an executor that can attest zero external effect may report definitive failure. */
|
|
4
|
+
export class ReviewDefinitiveExecutionError extends Error {
|
|
5
|
+
}
|
|
6
|
+
import { DecisionTraceBuilder } from '../telemetry/trace.js';
|
|
7
|
+
import { sanitizeOpaqueValue } from '../telemetry/redaction.js';
|
|
8
|
+
import { replayReviewOperatorAudit } from './operator-audit.js';
|
|
9
|
+
/** Review events that map to a #1567 operator decision record. */
|
|
10
|
+
const OPERATOR_DECISION_EVENTS = new Set(['approved', 'rejected', 'escalated', 'authorization-denied']);
|
|
11
|
+
export class DecisionReviewService {
|
|
12
|
+
store;
|
|
13
|
+
authorization;
|
|
14
|
+
now;
|
|
15
|
+
resumingLeaseMs;
|
|
16
|
+
pollIntervalMs;
|
|
17
|
+
telemetry;
|
|
18
|
+
operatorAudit;
|
|
19
|
+
lifecycleRule;
|
|
20
|
+
auditQueue = Promise.resolve();
|
|
21
|
+
constructor(store, authorization, now = () => Date.now(), options = {}) {
|
|
22
|
+
this.store = store;
|
|
23
|
+
this.authorization = authorization;
|
|
24
|
+
this.now = now;
|
|
25
|
+
this.resumingLeaseMs = options.resumingLeaseMs ?? 30_000;
|
|
26
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 10;
|
|
27
|
+
this.telemetry = options.telemetry;
|
|
28
|
+
this.operatorAudit = options.operatorAudit;
|
|
29
|
+
if (options.lifecycle)
|
|
30
|
+
validateDecisionLifecyclePolicy(options.lifecycle.policy);
|
|
31
|
+
this.lifecycleRule = options.lifecycle ? structuredClone(options.lifecycle.policy.surfaces.review) : null;
|
|
32
|
+
if (!Number.isSafeInteger(this.resumingLeaseMs) || this.resumingLeaseMs < 1)
|
|
33
|
+
throw new Error('resumingLeaseMs must be a positive integer');
|
|
34
|
+
if (!Number.isSafeInteger(this.pollIntervalMs) || this.pollIntervalMs < 1)
|
|
35
|
+
throw new Error('pollIntervalMs must be a positive integer');
|
|
36
|
+
}
|
|
37
|
+
async create(scope, input) {
|
|
38
|
+
await this.allowed(scope, 'create');
|
|
39
|
+
const at = this.now();
|
|
40
|
+
if (input.expiresAtEpochMs <= at)
|
|
41
|
+
throw new ReviewConflictError('Review expiry must be in the future');
|
|
42
|
+
let retentionUntilEpochMs = input.retentionUntilEpochMs;
|
|
43
|
+
if (this.lifecycleRule) {
|
|
44
|
+
// The shared D10 review rule is the retention ceiling; absent a pin, it is the deadline.
|
|
45
|
+
const ceiling = at + this.lifecycleRule.retentionMs;
|
|
46
|
+
retentionUntilEpochMs ??= ceiling;
|
|
47
|
+
if (retentionUntilEpochMs > ceiling || input.expiresAtEpochMs > retentionUntilEpochMs) {
|
|
48
|
+
throw new ReviewConflictError('Review retention exceeds the D10 review lifecycle rule');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const proposal = { version: 1, action: structuredClone(input.action), actionDigest: reviewDigest(input.action), createdAtEpochMs: at, editor: scope.actor, rationale: input.rationale };
|
|
52
|
+
const review = {
|
|
53
|
+
apiVersion: 'decision.aiwg.io/v1alpha1', kind: 'DecisionReview', schema: 'decision-review/v1', revision: 1,
|
|
54
|
+
reviewId: input.reviewId, tenantId: scope.tenantId, projectId: scope.projectId, requesterId: scope.actor.id,
|
|
55
|
+
sourceReceipt: input.sourceReceipt, evidencePins: input.evidencePins, policyPins: input.policyPins,
|
|
56
|
+
reasonCodes: input.reasonCodes, riskTier: input.riskTier, presentation: input.presentation,
|
|
57
|
+
proposals: [proposal], decisions: [], status: 'pending', createdAtEpochMs: at, updatedAtEpochMs: at,
|
|
58
|
+
expiresAtEpochMs: input.expiresAtEpochMs,
|
|
59
|
+
...(retentionUntilEpochMs === undefined ? {} : { retentionUntilEpochMs }),
|
|
60
|
+
...(input.escalationAtEpochMs === undefined ? {} : { escalationAtEpochMs: input.escalationAtEpochMs }),
|
|
61
|
+
quorum: input.quorum ?? 1, continuation: { id: input.continuationId, tokenDigest: reviewDigest(input.resumeToken) },
|
|
62
|
+
events: [{ sequence: 1, type: 'created', atEpochMs: at, actor: scope.actor, proposalVersion: 1,
|
|
63
|
+
rationale: input.rationale, operatorDecisionEventId: reviewOperatorEventId(input.reviewId, 1) }],
|
|
64
|
+
};
|
|
65
|
+
await this.allowed(scope, 'create', review);
|
|
66
|
+
if (!await this.store.create(review))
|
|
67
|
+
throw new ReviewConflictError('Review ID already exists');
|
|
68
|
+
await this.auditReview(review);
|
|
69
|
+
await this.emitTelemetry(review);
|
|
70
|
+
return review;
|
|
71
|
+
}
|
|
72
|
+
async read(scope, reviewId) {
|
|
73
|
+
const review = await this.store.read(reviewId, scope.tenantId, scope.projectId);
|
|
74
|
+
if (!review || !await this.authorization.authorize(scope, 'read', review))
|
|
75
|
+
return null;
|
|
76
|
+
return review.status === 'tombstoned' || this.pastRetention(review) ? null : review;
|
|
77
|
+
}
|
|
78
|
+
async list(scope, options = {}) {
|
|
79
|
+
if (!await this.authorization.authorize(scope, 'list'))
|
|
80
|
+
return [];
|
|
81
|
+
const reviews = await this.store.list(scope.tenantId, scope.projectId);
|
|
82
|
+
const visible = [];
|
|
83
|
+
for (const review of reviews) {
|
|
84
|
+
if ((!options.includeTombstoned && review.status === 'tombstoned') || this.pastRetention(review) ||
|
|
85
|
+
!await this.authorization.authorize(scope, 'read', review))
|
|
86
|
+
continue;
|
|
87
|
+
visible.push(review);
|
|
88
|
+
}
|
|
89
|
+
return visible;
|
|
90
|
+
}
|
|
91
|
+
/** Replay missing operator records after a crash before resuming a continuation. */
|
|
92
|
+
async syncOperatorAudit(scope, id) {
|
|
93
|
+
const review = await this.requireAuthorizedReview(scope, id, 'export');
|
|
94
|
+
await this.auditReview(review);
|
|
95
|
+
}
|
|
96
|
+
async export(scope, id) {
|
|
97
|
+
const review = await this.store.read(id, scope.tenantId, scope.projectId);
|
|
98
|
+
if (!review || !await this.authorization.authorize(scope, 'export', review))
|
|
99
|
+
return null;
|
|
100
|
+
// A D10 rule that denies export, or a review past retention, reads like an absent one.
|
|
101
|
+
if (this.lifecycleRule?.export === 'denied' || this.pastRetention(review))
|
|
102
|
+
return null;
|
|
103
|
+
return structuredClone(review);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Access-audited, retention-bounded view of host-held sensitive material. The
|
|
107
|
+
* access is appended to the review journal (`sensitive-view-accessed`) before
|
|
108
|
+
* the host source is read, so an unaudited read cannot happen. The content is
|
|
109
|
+
* never persisted in the review, its events or telemetry. The granted lifetime
|
|
110
|
+
* never exceeds the review's retention deadline. Missing, out-of-scope,
|
|
111
|
+
* tombstoned, expired-retention and unauthorized reviews all fail as
|
|
112
|
+
* `ReviewAccessError("Review not found")`.
|
|
113
|
+
*/
|
|
114
|
+
async openSensitiveView(scope, id, request, source) {
|
|
115
|
+
if (!request || typeof request.purpose !== 'string' || !request.purpose.trim() ||
|
|
116
|
+
!Number.isSafeInteger(request.ttlMs) || request.ttlMs < 1 || typeof source?.read !== 'function') {
|
|
117
|
+
throw new ReviewConflictError('Invalid sensitive view request');
|
|
118
|
+
}
|
|
119
|
+
assertReviewProjection(request.purpose);
|
|
120
|
+
let grant;
|
|
121
|
+
const audited = await this.mutate(scope, id, 'sensitive-view', review => {
|
|
122
|
+
const deadline = this.retentionDeadline(review);
|
|
123
|
+
const at = this.now();
|
|
124
|
+
if (review.status === 'tombstoned' || (deadline !== undefined && at >= deadline))
|
|
125
|
+
throw this.unavailable();
|
|
126
|
+
if (deadline === undefined)
|
|
127
|
+
throw new ReviewConflictError('Sensitive view requires a pinned retention deadline');
|
|
128
|
+
grant = { at, expires: Math.min(at + request.ttlMs, deadline) };
|
|
129
|
+
return this.append(review, 'sensitive-view-accessed', scope.actor, request.purpose, review.status, { viewExpiresAtEpochMs: grant.expires });
|
|
130
|
+
});
|
|
131
|
+
const content = await source.read({ tenantId: audited.tenantId, projectId: audited.projectId, reviewId: audited.reviewId,
|
|
132
|
+
sourceReceipt: structuredClone(audited.sourceReceipt), evidencePins: structuredClone(audited.evidencePins) });
|
|
133
|
+
if (content === null || content === undefined)
|
|
134
|
+
return null;
|
|
135
|
+
return { reviewId: audited.reviewId, purpose: request.purpose, content, grantedAtEpochMs: grant.at,
|
|
136
|
+
expiresAtEpochMs: grant.expires, auditEventSequence: audited.events.length };
|
|
137
|
+
}
|
|
138
|
+
/** Opaque D10 reference for a review; the host keeps the reverse index, never model-visible state. */
|
|
139
|
+
lifecycleReference(review) {
|
|
140
|
+
return { surface: 'review', opaqueId: reviewDigest({ schema: 'decision-review-lifecycle/v1',
|
|
141
|
+
tenantId: review.tenantId, projectId: review.projectId, reviewId: review.reviewId }) };
|
|
142
|
+
}
|
|
143
|
+
/** D10 `links` support: references for the reviews created from one source receipt that the caller may delete. */
|
|
144
|
+
async lifecycleReferences(scope, sourceReceiptId) {
|
|
145
|
+
this.requireLifecycle();
|
|
146
|
+
if (!await this.authorization.authorize(scope, 'list'))
|
|
147
|
+
return [];
|
|
148
|
+
const references = [];
|
|
149
|
+
for (const review of await this.store.list(scope.tenantId, scope.projectId)) {
|
|
150
|
+
if (review.sourceReceipt.id === sourceReceiptId && await this.authorization.authorize(scope, 'delete', review)) {
|
|
151
|
+
references.push(this.lifecycleReference(review));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return references;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* D10 `erase` support for `eraseDecisionSubject`. Appends a tombstone under
|
|
158
|
+
* delete authority (refused under legal hold) and, when the D10 review rule
|
|
159
|
+
* says `erase`, physically purges the revisions. Erasure never converts an
|
|
160
|
+
* unavailable review into approval: a tombstoned or purged review cannot resume.
|
|
161
|
+
*/
|
|
162
|
+
async eraseLifecycleReference(scope, reference) {
|
|
163
|
+
const rule = this.requireLifecycle();
|
|
164
|
+
if (reference?.surface !== 'review' || !reference.opaqueId)
|
|
165
|
+
throw new ReviewConflictError('Review lifecycle reference invalid');
|
|
166
|
+
const review = (await this.store.list(scope.tenantId, scope.projectId))
|
|
167
|
+
.find(item => this.lifecycleReference(item).opaqueId === reference.opaqueId);
|
|
168
|
+
if (!review) {
|
|
169
|
+
// Only an operator may learn that a reference is already erased.
|
|
170
|
+
if (!await this.authorization.authorize(scope, 'purge'))
|
|
171
|
+
throw this.unavailable();
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
await this.applyTombstone(scope, review.reviewId, 'D10 lifecycle erasure', 'delete');
|
|
175
|
+
if (rule.deletion === 'erase') {
|
|
176
|
+
if (!this.store.purgeTombstoned)
|
|
177
|
+
throw new ReviewConflictError('Physical review purge is unavailable');
|
|
178
|
+
await this.store.purgeTombstoned(review.reviewId, scope.tenantId, scope.projectId);
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
setLegalHold(scope, id, legalHold, rationale) {
|
|
183
|
+
return this.mutate(scope, id, 'legal-hold', review => {
|
|
184
|
+
if (review.status === 'tombstoned')
|
|
185
|
+
throw new ReviewConflictError('Tombstoned review cannot change legal hold');
|
|
186
|
+
if ((review.lifecycle?.legalHold ?? false) === legalHold)
|
|
187
|
+
return review;
|
|
188
|
+
return this.append({ ...review, lifecycle: { ...review.lifecycle, legalHold } }, legalHold ? 'legal-hold-placed' : 'legal-hold-released', scope.actor, rationale, review.status);
|
|
189
|
+
}, true);
|
|
190
|
+
}
|
|
191
|
+
delete(scope, id, rationale) { return this.applyTombstone(scope, id, rationale, 'delete'); }
|
|
192
|
+
tombstone(scope, id, rationale) { return this.applyTombstone(scope, id, rationale, 'tombstone'); }
|
|
193
|
+
/** Cryptographic audit marker remains, but every historical plaintext revision is removed. */
|
|
194
|
+
async purge(scope, id) {
|
|
195
|
+
if (!this.store.purgeTombstoned)
|
|
196
|
+
throw new ReviewConflictError('Physical review purge is unavailable');
|
|
197
|
+
const review = await this.store.read(id, scope.tenantId, scope.projectId);
|
|
198
|
+
// A denied existing review and an absent one fail identically.
|
|
199
|
+
if (!await this.authorization.authorize(scope, review ? 'delete' : 'purge', review ?? undefined))
|
|
200
|
+
throw this.unavailable();
|
|
201
|
+
if (review && (review.status !== 'tombstoned' || review.lifecycle?.legalHold ||
|
|
202
|
+
review.retentionUntilEpochMs === undefined || this.now() < review.retentionUntilEpochMs)) {
|
|
203
|
+
throw new ReviewConflictError('Review retention or legal hold prohibits purge');
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return await this.store.purgeTombstoned(id, scope.tenantId, scope.projectId);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (!review && error instanceof Error && /scope mismatch|requires an unheld tombstone/.test(error.message)) {
|
|
210
|
+
throw this.unavailable();
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
applyTombstone(scope, id, rationale, operation) {
|
|
216
|
+
return this.mutate(scope, id, operation, review => {
|
|
217
|
+
if (review.lifecycle?.legalHold)
|
|
218
|
+
throw new ReviewConflictError('Review deletion prohibited by legal hold');
|
|
219
|
+
if (review.status === 'tombstoned')
|
|
220
|
+
return review;
|
|
221
|
+
const at = this.now();
|
|
222
|
+
return this.append({ ...review, lifecycle: { legalHold: false, tombstonedAtEpochMs: at, tombstoneReason: rationale } }, 'tombstoned', scope.actor, rationale, 'tombstoned');
|
|
223
|
+
}, true);
|
|
224
|
+
}
|
|
225
|
+
claim(scope, id, rationale) {
|
|
226
|
+
return this.mutate(scope, id, 'claim', review => {
|
|
227
|
+
this.requireStatus(review, ['pending', 'claimed']);
|
|
228
|
+
if (review.status === 'claimed') {
|
|
229
|
+
if (review.events.at(-1)?.actor.id === scope.actor.id)
|
|
230
|
+
return review;
|
|
231
|
+
throw new ReviewConflictError('Review already claimed');
|
|
232
|
+
}
|
|
233
|
+
return this.append(review, 'claimed', scope.actor, rationale, 'claimed');
|
|
234
|
+
}, true);
|
|
235
|
+
}
|
|
236
|
+
async decide(scope, id, decision, rationale) {
|
|
237
|
+
return this.mutate(scope, id, 'decide', async (review) => {
|
|
238
|
+
this.requireActive(review);
|
|
239
|
+
const proposal = currentProposal(review);
|
|
240
|
+
if (!await this.authorization.eligible(scope, review, proposal))
|
|
241
|
+
throw new ReviewAccessError('Reviewer is not eligible');
|
|
242
|
+
if (review.requesterId === scope.actor.id)
|
|
243
|
+
throw new ReviewAccessError('Self-approval is denied');
|
|
244
|
+
if (review.decisions.some(item => item.reviewer.id === scope.actor.id && item.proposalVersion === proposal.version))
|
|
245
|
+
throw new ReviewConflictError('Reviewer already decided this proposal');
|
|
246
|
+
const decisions = [...review.decisions, { reviewer: scope.actor, proposalVersion: proposal.version, decision, rationale, atEpochMs: this.now() }];
|
|
247
|
+
const approvals = decisions.filter(item => item.proposalVersion === proposal.version && item.decision === 'approve').length;
|
|
248
|
+
const status = decision === 'reject' ? 'rejected' : approvals >= review.quorum ? 'approved' : 'claimed';
|
|
249
|
+
return this.append({ ...review, decisions }, decision === 'approve' ? 'approved' : 'rejected', scope.actor, rationale, status, { quorumSatisfied: approvals >= review.quorum });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
edit(scope, id, action, rationale, newResumeToken) {
|
|
253
|
+
return this.mutate(scope, id, 'edit', async (review) => {
|
|
254
|
+
this.requireActive(review);
|
|
255
|
+
const at = this.now();
|
|
256
|
+
const version = currentProposal(review).version + 1;
|
|
257
|
+
if (!newResumeToken || reviewDigest(newResumeToken) === review.continuation.tokenDigest)
|
|
258
|
+
throw new ReviewConflictError('Edited proposal requires a fresh resume token');
|
|
259
|
+
const proposal = { version, action: structuredClone(action), actionDigest: reviewDigest(action), createdAtEpochMs: at, editor: scope.actor, rationale };
|
|
260
|
+
if (!await this.authorization.authorizeAction(scope, review, proposal))
|
|
261
|
+
throw new ReviewAccessError('Edited action is not authorized');
|
|
262
|
+
return this.append({ ...review, continuation: { ...review.continuation, tokenDigest: reviewDigest(newResumeToken) },
|
|
263
|
+
proposals: [...review.proposals, proposal] }, 'edited', scope.actor, rationale, 'pending');
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
escalate(scope, id, rationale) {
|
|
267
|
+
return this.mutate(scope, id, 'escalate', review => {
|
|
268
|
+
this.requireStatus(review, ['pending', 'claimed']);
|
|
269
|
+
if (review.escalationAtEpochMs !== undefined && this.now() < review.escalationAtEpochMs)
|
|
270
|
+
throw new ReviewConflictError('Escalation deadline has not arrived');
|
|
271
|
+
return this.append(review, 'escalated', scope.actor, rationale, 'escalated');
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
cancel(scope, id, rationale) {
|
|
275
|
+
return this.mutate(scope, id, 'cancel', review => {
|
|
276
|
+
this.requireStatus(review, ['pending', 'claimed', 'approved', 'escalated']);
|
|
277
|
+
return this.append(review, 'canceled', scope.actor, rationale, 'canceled');
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async expireDue(scope, id) {
|
|
281
|
+
return this.mutate(scope, id, 'decide', review => {
|
|
282
|
+
this.requireStatus(review, ['pending', 'claimed', 'approved', 'escalated']);
|
|
283
|
+
if (this.now() < review.expiresAtEpochMs)
|
|
284
|
+
throw new ReviewConflictError('Review has not expired');
|
|
285
|
+
return this.append(review, 'expired', scope.actor, 'expiry deadline reached', 'expired');
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async resume(scope, id, token, execute, reconcile) {
|
|
289
|
+
for (;;) {
|
|
290
|
+
const review = await this.requireAuthorizedReview(scope, id, 'resume');
|
|
291
|
+
await this.auditReview(review);
|
|
292
|
+
if (review.continuation.tokenDigest !== reviewDigest(token))
|
|
293
|
+
throw new ReviewAccessError('Invalid resume token');
|
|
294
|
+
if (review.effectReceipt)
|
|
295
|
+
return review.effectReceipt;
|
|
296
|
+
const proposal = currentProposal(review);
|
|
297
|
+
const approvals = review.decisions.filter(item => item.proposalVersion === proposal.version && item.decision === 'approve');
|
|
298
|
+
if (approvals.length < review.quorum)
|
|
299
|
+
throw new ReviewConflictError('Approval quorum is no longer satisfied');
|
|
300
|
+
const currentApprovals = await Promise.all(approvals.map(decision => this.authorization.eligibleApproval(scope, review, proposal, decision)));
|
|
301
|
+
if (currentApprovals.filter(Boolean).length < review.quorum ||
|
|
302
|
+
!await this.authorization.eligible(scope, review, proposal) || !await this.authorization.authorizeAction(scope, review, proposal)) {
|
|
303
|
+
const denied = this.append(review, 'authorization-denied', scope.actor, 'resume authorization denied', review.status);
|
|
304
|
+
if (!await this.store.compareAndSwap(id, scope.tenantId, scope.projectId, review.revision, denied))
|
|
305
|
+
continue;
|
|
306
|
+
await this.auditReview(denied);
|
|
307
|
+
await this.emitTelemetry(denied);
|
|
308
|
+
throw new ReviewAccessError('Authorization is no longer valid');
|
|
309
|
+
}
|
|
310
|
+
if (review.status === 'resuming') {
|
|
311
|
+
const acquiredAt = review.events.at(-1)?.atEpochMs ?? review.updatedAtEpochMs;
|
|
312
|
+
if (this.now() - acquiredAt < this.resumingLeaseMs) {
|
|
313
|
+
await new Promise(resolve => setTimeout(resolve, this.pollIntervalMs));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const effectId = reviewDigest({ reviewId: id, continuationId: review.continuation.id, proposalVersion: proposal.version });
|
|
317
|
+
// A stale lease does not prove whether the remote effect ran. Replaying
|
|
318
|
+
// execute with the same ID cannot establish idempotence by itself.
|
|
319
|
+
if (!reconcile)
|
|
320
|
+
throw new ReviewConflictError('Stale continuation requires effect reconciliation');
|
|
321
|
+
const receipt = await reconcile(effectId);
|
|
322
|
+
if (!receipt)
|
|
323
|
+
throw new ReviewConflictError('Effect outcome remains unknown');
|
|
324
|
+
if (receipt.effectId !== effectId || receipt.continuationId !== review.continuation.id ||
|
|
325
|
+
receipt.proposalVersion !== proposal.version)
|
|
326
|
+
throw new ReviewConflictError('Reconciliation receipt mismatch');
|
|
327
|
+
const recovered = this.append(review, 'resumed', scope.actor, 'stale continuation reconciled', 'resuming', { effectId, recovered: true });
|
|
328
|
+
if (!await this.store.compareAndSwap(id, scope.tenantId, scope.projectId, review.revision, recovered))
|
|
329
|
+
continue;
|
|
330
|
+
await this.finish(scope, id, recovered.revision, receipt);
|
|
331
|
+
return receipt;
|
|
332
|
+
}
|
|
333
|
+
if (review.status !== 'approved')
|
|
334
|
+
throw new ReviewConflictError(`Review cannot resume from ${review.status}`);
|
|
335
|
+
if (this.now() >= review.expiresAtEpochMs)
|
|
336
|
+
throw new ReviewConflictError('Review expired before resume');
|
|
337
|
+
const effectId = reviewDigest({ reviewId: id, continuationId: review.continuation.id, proposalVersion: proposal.version });
|
|
338
|
+
const resuming = this.append(review, 'resumed', scope.actor, 'continuation acquired', 'resuming', { effectId });
|
|
339
|
+
if (!await this.store.compareAndSwap(id, scope.tenantId, scope.projectId, review.revision, resuming))
|
|
340
|
+
continue;
|
|
341
|
+
await this.emitTelemetry(resuming);
|
|
342
|
+
return this.executeAndFinish(scope, id, resuming, proposal.action, effectId, execute);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async executeAndFinish(scope, id, resuming, action, effectId, execute) {
|
|
346
|
+
let result;
|
|
347
|
+
try {
|
|
348
|
+
result = await execute(effectId, structuredClone(action));
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
// An arbitrary rejection cannot prove that the remote effect did not run.
|
|
352
|
+
// Leave the lease in resuming for authoritative reconciliation after restart.
|
|
353
|
+
if (error instanceof ReviewDefinitiveExecutionError) {
|
|
354
|
+
const failed = this.append(resuming, 'execution-failed', scope.actor, 'executor failed', 'execution-failed');
|
|
355
|
+
failed.executionError = 'executor-failed';
|
|
356
|
+
await this.store.compareAndSwap(id, scope.tenantId, scope.projectId, resuming.revision, failed);
|
|
357
|
+
}
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
const receipt = { effectId, continuationId: resuming.continuation.id, proposalVersion: currentProposal(resuming).version, completedAtEpochMs: this.now(), result };
|
|
361
|
+
// If persistence fails, DO NOT claim the external effect failed. Reconcile
|
|
362
|
+
// from the executor-owned journal rather than dispatching it a second time.
|
|
363
|
+
await this.finish(scope, id, resuming.revision, receipt);
|
|
364
|
+
const completed = await this.store.read(id, scope.tenantId, scope.projectId);
|
|
365
|
+
if (completed)
|
|
366
|
+
await this.emitTelemetry(completed, receipt.effectId);
|
|
367
|
+
return receipt;
|
|
368
|
+
}
|
|
369
|
+
async finish(scope, id, revision, receipt) {
|
|
370
|
+
const review = await this.requireReview(scope, id);
|
|
371
|
+
if (review.revision !== revision || review.status !== 'resuming')
|
|
372
|
+
throw new ReviewConflictError('Resume ownership was lost');
|
|
373
|
+
const completed = this.append({ ...review, effectReceipt: receipt }, 'execution-completed', scope.actor, 'executor completed', 'completed', { effectId: receipt.effectId });
|
|
374
|
+
if (!await this.store.compareAndSwap(id, scope.tenantId, scope.projectId, revision, completed))
|
|
375
|
+
throw new ReviewConflictError('Could not persist effect receipt');
|
|
376
|
+
await this.auditReview(completed);
|
|
377
|
+
}
|
|
378
|
+
async mutate(scope, id, operation, build, allowNoop = false) {
|
|
379
|
+
for (;;) {
|
|
380
|
+
const review = await this.requireAuthorizedReview(scope, id, operation);
|
|
381
|
+
const next = await build(review);
|
|
382
|
+
if (allowNoop && next === review)
|
|
383
|
+
return review;
|
|
384
|
+
if (await this.store.compareAndSwap(id, scope.tenantId, scope.projectId, review.revision, next)) {
|
|
385
|
+
await this.auditReview(next);
|
|
386
|
+
await this.emitTelemetry(next);
|
|
387
|
+
return next;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async requireReview(scope, id) { const review = await this.store.read(id, scope.tenantId, scope.projectId); if (!review)
|
|
392
|
+
throw this.unavailable(); return review; }
|
|
393
|
+
/** Missing, out-of-scope and unauthorized reviews share one error so callers cannot probe existence. */
|
|
394
|
+
async requireAuthorizedReview(scope, id, operation) {
|
|
395
|
+
const review = await this.store.read(id, scope.tenantId, scope.projectId);
|
|
396
|
+
if (!review || !await this.authorization.authorize(scope, operation, review))
|
|
397
|
+
throw this.unavailable();
|
|
398
|
+
return review;
|
|
399
|
+
}
|
|
400
|
+
unavailable() { return new ReviewAccessError('Review not found'); }
|
|
401
|
+
requireLifecycle() {
|
|
402
|
+
if (!this.lifecycleRule)
|
|
403
|
+
throw new ReviewConflictError('Review service has no D10 lifecycle binding');
|
|
404
|
+
return this.lifecycleRule;
|
|
405
|
+
}
|
|
406
|
+
/** Effective deadline: the review's pin, capped by the D10 review rule when bound. */
|
|
407
|
+
retentionDeadline(review) {
|
|
408
|
+
const ceiling = this.lifecycleRule ? review.createdAtEpochMs + this.lifecycleRule.retentionMs : undefined;
|
|
409
|
+
if (review.retentionUntilEpochMs === undefined)
|
|
410
|
+
return ceiling;
|
|
411
|
+
return ceiling === undefined ? review.retentionUntilEpochMs : Math.min(ceiling, review.retentionUntilEpochMs);
|
|
412
|
+
}
|
|
413
|
+
pastRetention(review) {
|
|
414
|
+
if (!this.lifecycleRule)
|
|
415
|
+
return false;
|
|
416
|
+
const deadline = this.retentionDeadline(review);
|
|
417
|
+
return deadline !== undefined && this.now() >= deadline;
|
|
418
|
+
}
|
|
419
|
+
async allowed(scope, operation, review) { if (!await this.authorization.authorize(scope, operation, review))
|
|
420
|
+
throw new ReviewAccessError('Review access denied'); }
|
|
421
|
+
requireActive(review) { this.requireStatus(review, ['pending', 'claimed']); if (this.now() >= review.expiresAtEpochMs)
|
|
422
|
+
throw new ReviewConflictError('Review is expired'); }
|
|
423
|
+
requireStatus(review, states) { if (!states.includes(review.status))
|
|
424
|
+
throw new ReviewConflictError(`Illegal transition from ${review.status}`); }
|
|
425
|
+
append(review, type, actor, rationale, status, data) {
|
|
426
|
+
const at = Math.max(this.now(), review.updatedAtEpochMs);
|
|
427
|
+
const proposalVersion = currentProposal(review).version;
|
|
428
|
+
return { ...structuredClone(review), revision: review.revision + 1, status, updatedAtEpochMs: at,
|
|
429
|
+
events: [...review.events, { sequence: review.events.length + 1, type, atEpochMs: at, actor, proposalVersion,
|
|
430
|
+
rationale, operatorDecisionEventId: reviewOperatorEventId(review.reviewId, review.events.length + 1), ...(data ? { data } : {}) }] };
|
|
431
|
+
}
|
|
432
|
+
async auditReview(review) {
|
|
433
|
+
if (!this.operatorAudit)
|
|
434
|
+
return;
|
|
435
|
+
const audit = this.operatorAudit;
|
|
436
|
+
const next = this.auditQueue.then(async () => {
|
|
437
|
+
await replayReviewOperatorAudit(review, audit.store, this.auditCorrelation(review), audit.classification);
|
|
438
|
+
});
|
|
439
|
+
this.auditQueue = next.catch(() => { });
|
|
440
|
+
await next;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* The #1567 record and the review span must share one trace identity. When the
|
|
444
|
+
* host supplies a telemetry parent and no explicit trace ID, bind the audit
|
|
445
|
+
* correlation to that W3C trace instead of keeping a parallel identifier.
|
|
446
|
+
*/
|
|
447
|
+
auditCorrelation(review) {
|
|
448
|
+
const correlation = this.operatorAudit.correlation(review);
|
|
449
|
+
const parent = this.telemetry?.parent;
|
|
450
|
+
return parent && correlation.trace_id === undefined ? { ...correlation, trace_id: parent.traceId } : correlation;
|
|
451
|
+
}
|
|
452
|
+
async emitTelemetry(review, effectId) {
|
|
453
|
+
if (!this.telemetry)
|
|
454
|
+
return;
|
|
455
|
+
try {
|
|
456
|
+
const builder = new DecisionTraceBuilder(this.telemetry.ids, this.now);
|
|
457
|
+
const last = review.events.at(-1);
|
|
458
|
+
// Only operator decisions have a #1567 record; other events carry no audit ID.
|
|
459
|
+
const operatorEvent = last && OPERATOR_DECISION_EVENTS.has(last.type) ? last.operatorDecisionEventId : undefined;
|
|
460
|
+
const approval = review.events.filter(event => event.type === 'approved').at(-1)?.operatorDecisionEventId;
|
|
461
|
+
const root = builder.startSpan('decision.review', {
|
|
462
|
+
...(this.telemetry.parent ? { parent: this.telemetry.parent } : {}),
|
|
463
|
+
attributes: {
|
|
464
|
+
'aiwg.review.id': sanitizeOpaqueValue(review.reviewId, 128),
|
|
465
|
+
'aiwg.review.status': review.status,
|
|
466
|
+
'aiwg.review.revision': review.revision,
|
|
467
|
+
'aiwg.review.event': last?.type ?? 'unknown',
|
|
468
|
+
...(operatorEvent ? { 'aiwg.operator_decision.event_id': sanitizeOpaqueValue(operatorEvent, 128) } : {}),
|
|
469
|
+
},
|
|
470
|
+
provenance: {
|
|
471
|
+
'aiwg.review.id': 'client-derived', 'aiwg.review.status': 'client-derived',
|
|
472
|
+
'aiwg.review.revision': 'client-derived', 'aiwg.review.event': 'client-derived',
|
|
473
|
+
'aiwg.operator_decision.event_id': 'client-derived',
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
builder.endSpan(root, review.status === 'execution-failed' ? 'error' : 'ok');
|
|
477
|
+
if (effectId) {
|
|
478
|
+
const action = builder.startSpan('decision.action', { parent: root.context,
|
|
479
|
+
links: [{ ...root.context, relationship: 'review', attributes: { 'aiwg.review.id': sanitizeOpaqueValue(review.reviewId, 128) } }],
|
|
480
|
+
attributes: { 'aiwg.effect_receipt.id': sanitizeOpaqueValue(effectId, 128), 'aiwg.review.id': sanitizeOpaqueValue(review.reviewId, 128),
|
|
481
|
+
...(approval ? { 'aiwg.operator_decision.event_id': sanitizeOpaqueValue(approval, 128) } : {}) },
|
|
482
|
+
provenance: { 'aiwg.effect_receipt.id': 'client-derived', 'aiwg.review.id': 'client-derived',
|
|
483
|
+
'aiwg.operator_decision.event_id': 'client-derived' } });
|
|
484
|
+
builder.endSpan(action, review.status === 'completed' ? 'ok' : 'error');
|
|
485
|
+
}
|
|
486
|
+
for (const span of builder.build().spans) {
|
|
487
|
+
try {
|
|
488
|
+
await this.telemetry.hook.emit(span);
|
|
489
|
+
}
|
|
490
|
+
catch { /* isolate exporter failure */ }
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// Observability must never alter durable review state or action delivery.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
//# sourceMappingURL=service.js.map
|