@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,164 @@
|
|
|
1
|
+
// Free-form adapter/model/version strings require an explicit deployment allowlist.
|
|
2
|
+
// Merely bounding their length does not prevent a short user ID or body canary
|
|
3
|
+
// from becoming a metric label.
|
|
4
|
+
const DYNAMIC_DIMENSIONS = new Set(['aiwg.adapter.id', 'aiwg.adapter.version', 'gen_ai.request.model', 'gen_ai.response.model']);
|
|
5
|
+
const FIXED_DIMENSIONS = {
|
|
6
|
+
'aiwg.decision.status': ['success', 'abstained', 'error', 'unsupported', 'cancelled', 'completed', 'review', 'failed', 'succeeded', 'not-sent'],
|
|
7
|
+
'aiwg.decision.reason': ['none', 'invalid-input', 'invalid-definition', 'digest-mismatch', 'unauthorized',
|
|
8
|
+
'data-boundary-denied', 'unsupported-capability', 'executor-unavailable', 'invalid-output', 'low-confidence',
|
|
9
|
+
'missing-confidence', 'confidence-profile-mismatch', 'insufficient-information', 'timeout', 'network-transient',
|
|
10
|
+
'rate-limited', 'overloaded', 'service-error', 'authentication', 'invalid-request', 'budget-exhausted',
|
|
11
|
+
'cancelled', 'persistence-error', 'replay-mismatch', 'execution-uncertain', 'batch-record-unavailable', 'no-match', 'conflicting-outcomes', 'evaluation-failed',
|
|
12
|
+
'context-plan-stale', 'context-unqualified'],
|
|
13
|
+
'aiwg.acceptance.disposition': ['act', 'review', 'reject', 'fallback'],
|
|
14
|
+
'aiwg.batch.mode': ['native', 'single', 'emulated'],
|
|
15
|
+
// Compile-layer (D30) and result-layer (D15) cache layers share one bounded vocabulary.
|
|
16
|
+
'aiwg.cache.layer': ['definition-compilation', 'adapter-compilation', 'provider-prefix', 'invocation-replay',
|
|
17
|
+
'semantic-result', 'result'],
|
|
18
|
+
// Provider-prefix statuses, compile-cache outcomes, and every D15 result-cache event.
|
|
19
|
+
'aiwg.cache.result': ['hit', 'miss', 'stale', 'unknown', 'bypass', 'rejected', 'unsupported', 'single-flight', 'invalidation'],
|
|
20
|
+
'aiwg.cache.reason': ['verified-hit', 'cold-fill', 'cache-disabled', 'store-rejected', 'provider-report',
|
|
21
|
+
'documented-unsupported', 'policy-bypass', 'unreported'],
|
|
22
|
+
'aiwg.review.status': ['pending', 'approved', 'denied', 'escalated', 'expired'],
|
|
23
|
+
'aiwg.usage.cost_provenance': ['provider-fact', 'client-derived', 'estimate', 'unknown'],
|
|
24
|
+
'aiwg.admission.decision': ['admit', 'defer', 'reject'],
|
|
25
|
+
'aiwg.admission.reason': ['admitted', 'disabled', 'cancelled', 'deadline-exceeded', 'concurrency', 'requests-per-minute',
|
|
26
|
+
'tokens-per-second', 'attempts', 'batch-size', 'cost', 'unknown-cost', 'queue-full', 'queue-timeout', 'invalid-estimate',
|
|
27
|
+
'request-too-large', 'too-many-items', 'retained-work', 'unknown-retained-work', 'retry-after', 'circuit-open',
|
|
28
|
+
'unconfigured-provider'],
|
|
29
|
+
'aiwg.breaker.status': ['closed', 'open', 'half-open'],
|
|
30
|
+
};
|
|
31
|
+
/** Only fixed metric names are accepted; caller-controlled names would defeat cardinality limits. */
|
|
32
|
+
const METRIC_NAMES = new Set([
|
|
33
|
+
'decision.throughput', 'decision.duration', 'decision.attempts', 'decision.retries',
|
|
34
|
+
'decision.fallbacks', 'decision.errors', 'decision.queue_delay', 'decision.coverage',
|
|
35
|
+
'decision.abstention', 'decision.review', 'decision.cost_usd',
|
|
36
|
+
'decision.input_tokens', 'decision.output_tokens', 'decision.cache', 'decision.drift',
|
|
37
|
+
'decision.cache_saved_input_tokens', 'decision.cache_saved_output_tokens',
|
|
38
|
+
'decision.cache_saved_latency_ms', 'decision.cache_saved_cost_usd',
|
|
39
|
+
'decision.admission', 'decision.throttles', 'decision.breaker_transitions',
|
|
40
|
+
]);
|
|
41
|
+
/** Record operational metrics without using result IDs or answer-level batch usage. */
|
|
42
|
+
export function recordDecisionSpanMetrics(span, metrics) {
|
|
43
|
+
const attributes = span.attributes;
|
|
44
|
+
const record = (name, value) => { metrics.record(name, value, attributes); };
|
|
45
|
+
if (span.name === 'decision.workflow') {
|
|
46
|
+
record('decision.throughput', 1);
|
|
47
|
+
record('decision.duration', Math.max(0, span.endTimeUnixMs - span.startTimeUnixMs));
|
|
48
|
+
if (span.status === 'error')
|
|
49
|
+
record('decision.errors', 1);
|
|
50
|
+
}
|
|
51
|
+
if (span.name === 'decision.attempt' || span.name === 'decision.batch.request') {
|
|
52
|
+
record('decision.attempts', 1);
|
|
53
|
+
if (typeof attributes['aiwg.retry.delay_ms'] === 'number')
|
|
54
|
+
record('decision.retries', 1);
|
|
55
|
+
if (attributes['aiwg.route.fallback'] === true)
|
|
56
|
+
record('decision.fallbacks', 1);
|
|
57
|
+
if (span.status === 'error')
|
|
58
|
+
record('decision.errors', 1);
|
|
59
|
+
// Batch usage belongs to the request span only. Never sum linked answer spans.
|
|
60
|
+
const accounted = span.name === 'decision.batch.request'
|
|
61
|
+
? attributes['aiwg.usage.scope'] === 'shared-request'
|
|
62
|
+
: attributes['aiwg.batch.id'] === undefined;
|
|
63
|
+
if (accounted)
|
|
64
|
+
for (const [key, name] of [
|
|
65
|
+
['gen_ai.usage.input_tokens', 'decision.input_tokens'],
|
|
66
|
+
['gen_ai.usage.output_tokens', 'decision.output_tokens'],
|
|
67
|
+
['aiwg.usage.cost_usd', 'decision.cost_usd'],
|
|
68
|
+
]) {
|
|
69
|
+
const value = attributes[key];
|
|
70
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
71
|
+
record(name, value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (span.name === 'decision.review')
|
|
75
|
+
record('decision.review', 1);
|
|
76
|
+
// Result-layer cache events are counted once, from the D15 service sink below.
|
|
77
|
+
if (span.name === 'decision.cache' && attributes['aiwg.cache.layer'] !== 'result')
|
|
78
|
+
record('decision.cache', 1);
|
|
79
|
+
if (span.name === 'decision.admit') {
|
|
80
|
+
record('decision.admission', 1);
|
|
81
|
+
if (attributes['aiwg.admission.decision'] !== 'admit')
|
|
82
|
+
record('decision.throttles', 1);
|
|
83
|
+
const delay = attributes['aiwg.queue.delay_ms'];
|
|
84
|
+
if (typeof delay === 'number' && Number.isFinite(delay) && delay >= 0)
|
|
85
|
+
record('decision.queue_delay', delay);
|
|
86
|
+
for (const event of span.events)
|
|
87
|
+
if (event.name === 'breaker.transition')
|
|
88
|
+
record('decision.breaker_transitions', 1);
|
|
89
|
+
}
|
|
90
|
+
if (span.name === 'decision.accept') {
|
|
91
|
+
const disposition = attributes['aiwg.acceptance.disposition'];
|
|
92
|
+
if (disposition === 'act')
|
|
93
|
+
record('decision.coverage', 1);
|
|
94
|
+
if (disposition === 'reject')
|
|
95
|
+
record('decision.abstention', 1);
|
|
96
|
+
}
|
|
97
|
+
const drift = attributes['aiwg.drift.value'];
|
|
98
|
+
if (typeof drift === 'number' && Number.isFinite(drift) && drift >= 0)
|
|
99
|
+
record('decision.drift', drift);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Feed one D15 result-cache event into the D14 metric pipeline. Only the event name
|
|
103
|
+
* becomes a dimension; the operation ID and reason never do. Savings are recorded for
|
|
104
|
+
* hits only, labelled as estimates, and null usage or cost is skipped, never zeroed.
|
|
105
|
+
*/
|
|
106
|
+
export function recordResultCacheMetrics(event, metrics) {
|
|
107
|
+
const dimensions = { 'aiwg.cache.layer': 'result', 'aiwg.cache.result': event.event };
|
|
108
|
+
metrics.record('decision.cache', 1, dimensions);
|
|
109
|
+
if (event.event !== 'hit' || !event.saved)
|
|
110
|
+
return;
|
|
111
|
+
const estimated = { ...dimensions, 'aiwg.usage.cost_provenance': 'estimate' };
|
|
112
|
+
for (const [value, name] of [
|
|
113
|
+
[event.saved.inputTokens, 'decision.cache_saved_input_tokens'],
|
|
114
|
+
[event.saved.outputTokens, 'decision.cache_saved_output_tokens'],
|
|
115
|
+
[event.saved.latencyMs, 'decision.cache_saved_latency_ms'],
|
|
116
|
+
[event.saved.costUsd, 'decision.cache_saved_cost_usd'],
|
|
117
|
+
]) {
|
|
118
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
119
|
+
metrics.record(name, value, estimated);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** A `DecisionResultCache` telemetry sink that records metrics and then forwards the event. */
|
|
123
|
+
export function resultCacheMetricsSink(metrics, next) {
|
|
124
|
+
return event => {
|
|
125
|
+
try {
|
|
126
|
+
recordResultCacheMetrics(event, metrics);
|
|
127
|
+
}
|
|
128
|
+
catch { /* metrics cannot change a decision */ }
|
|
129
|
+
next?.(event);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export class BoundedDecisionMetrics {
|
|
133
|
+
capacity;
|
|
134
|
+
maximumDimensionValues;
|
|
135
|
+
points = [];
|
|
136
|
+
approvedDimensions;
|
|
137
|
+
constructor(capacity = 1_000, maximumDimensionValues = 100, trustedDimensionValues = {}) {
|
|
138
|
+
this.capacity = capacity;
|
|
139
|
+
this.maximumDimensionValues = maximumDimensionValues;
|
|
140
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1 || !Number.isSafeInteger(maximumDimensionValues) || maximumDimensionValues < 1
|
|
141
|
+
|| Object.entries(trustedDimensionValues).some(([key, values]) => !DYNAMIC_DIMENSIONS.has(key)
|
|
142
|
+
|| !Array.isArray(values) || values.length > capacity
|
|
143
|
+
|| values.some(value => typeof value !== 'string' || value.length < 1 || value.length > 64))) {
|
|
144
|
+
throw new Error('Invalid metric bounds');
|
|
145
|
+
}
|
|
146
|
+
this.approvedDimensions = Object.fromEntries(Object.entries(trustedDimensionValues)
|
|
147
|
+
.map(([key, values]) => [key, [...values]]));
|
|
148
|
+
}
|
|
149
|
+
record(name, value, attributes) {
|
|
150
|
+
if (!METRIC_NAMES.has(name) || !Number.isFinite(value) || this.points.length >= this.capacity)
|
|
151
|
+
return false;
|
|
152
|
+
const dimensions = Object.fromEntries(Object.entries(attributes).filter(([key, dimension]) => typeof dimension === 'string'
|
|
153
|
+
&& (FIXED_DIMENSIONS[key]?.includes(dimension) === true
|
|
154
|
+
|| DYNAMIC_DIMENSIONS.has(key) && this.approvedDimensions[key]?.includes(dimension) === true)));
|
|
155
|
+
const signature = JSON.stringify(dimensions);
|
|
156
|
+
const distinct = new Set(this.points.filter(point => point.name === name).map(point => JSON.stringify(point.dimensions)));
|
|
157
|
+
if (!distinct.has(signature) && distinct.size >= this.maximumDimensionValues)
|
|
158
|
+
return false;
|
|
159
|
+
this.points.push({ name, value, dimensions });
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
snapshot() { return structuredClone(this.points); }
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=metrics.js.map
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { request } from 'node:https';
|
|
3
|
+
import { BlockList, isIP } from 'node:net';
|
|
4
|
+
import { sanitizedTelemetryExport } from './redaction.js';
|
|
5
|
+
/** Metadata-only OTLP/HTTP JSON sink. Credential headers and redirects are never accepted. */
|
|
6
|
+
export class DecisionOtlpHttpSink {
|
|
7
|
+
endpoint;
|
|
8
|
+
maxPayloadBytes;
|
|
9
|
+
transport;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
const endpoint = new URL(options.endpoint);
|
|
12
|
+
if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash
|
|
13
|
+
|| !endpoint.pathname.endsWith('/v1/traces'))
|
|
14
|
+
throw new TypeError('OTLP endpoint must be credential-free HTTPS /v1/traces');
|
|
15
|
+
// DNS resolution and rebinding still require a separately qualified transport.
|
|
16
|
+
// Literal and local-only names cannot be mistaken for approved public collectors.
|
|
17
|
+
const host = endpoint.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
18
|
+
if (isIP(host) || host === 'localhost' || host.endsWith('.localhost')) {
|
|
19
|
+
throw new TypeError('OTLP collector requires a qualified DNS hostname');
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes < 1)
|
|
22
|
+
throw new TypeError('Invalid OTLP payload bound');
|
|
23
|
+
this.endpoint = endpoint.href;
|
|
24
|
+
this.maxPayloadBytes = options.maxPayloadBytes;
|
|
25
|
+
// Native HTTPS connects to the validated address, not a second DNS lookup.
|
|
26
|
+
// Injected transports are for controlled tests only; they do not enforce DNS policy.
|
|
27
|
+
this.transport = options.fetch ?? pinnedHttpsFetch;
|
|
28
|
+
}
|
|
29
|
+
async export(trace, signal) {
|
|
30
|
+
const safe = sanitizedTelemetryExport(trace);
|
|
31
|
+
const payload = JSON.stringify({ resourceSpans: [{ scopeSpans: [{ scope: { name: 'aiwg.decision', version: '1' },
|
|
32
|
+
spans: safe.spans.map(toOtlpSpan) }] }] });
|
|
33
|
+
if (Buffer.byteLength(payload, 'utf8') > this.maxPayloadBytes)
|
|
34
|
+
throw new Error('OTLP payload exceeds configured bound');
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await this.transport(this.endpoint, { method: 'POST', redirect: 'manual', signal,
|
|
38
|
+
headers: { 'content-type': 'application/json' }, body: payload });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// DNS, TLS and injected transport errors may contain private hostnames,
|
|
42
|
+
// paths or payloads. Diagnostics must report failure without echoing them.
|
|
43
|
+
throw new Error('OTLP transport failed');
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
throw new Error(`OTLP exporter returned HTTP ${response.status}`);
|
|
47
|
+
if (!response.body)
|
|
48
|
+
return;
|
|
49
|
+
// OTLP/HTTP may acknowledge a request while rejecting some spans. Never
|
|
50
|
+
// echo collector response text; it can contain arbitrary sensitive data.
|
|
51
|
+
const reader = response.body.getReader();
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let size = 0;
|
|
54
|
+
try {
|
|
55
|
+
while (true) {
|
|
56
|
+
const { done, value } = await reader.read();
|
|
57
|
+
if (done)
|
|
58
|
+
break;
|
|
59
|
+
size += value.byteLength;
|
|
60
|
+
if (size > 4096)
|
|
61
|
+
throw new Error('OTLP response exceeds configured inspection bound');
|
|
62
|
+
chunks.push(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof Error && error.message === 'OTLP response exceeds configured inspection bound')
|
|
67
|
+
throw error;
|
|
68
|
+
throw new Error('OTLP response read failed');
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
reader.releaseLock();
|
|
72
|
+
}
|
|
73
|
+
if (size === 0)
|
|
74
|
+
return;
|
|
75
|
+
let body;
|
|
76
|
+
try {
|
|
77
|
+
body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error('OTLP collector returned an invalid response');
|
|
81
|
+
}
|
|
82
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
83
|
+
throw new Error('OTLP collector returned an invalid response');
|
|
84
|
+
const partial = body.partialSuccess;
|
|
85
|
+
if (partial === undefined)
|
|
86
|
+
return;
|
|
87
|
+
if (!partial || typeof partial !== 'object' || Array.isArray(partial))
|
|
88
|
+
throw new Error('OTLP collector returned an invalid response');
|
|
89
|
+
const rejected = partial.rejectedSpans;
|
|
90
|
+
if (rejected !== undefined && rejected !== 0 && rejected !== '0') {
|
|
91
|
+
// Unknown or non-zero values cannot be treated as a complete export.
|
|
92
|
+
throw new Error('OTLP collector partially rejected spans');
|
|
93
|
+
}
|
|
94
|
+
if (partial.errorMessage)
|
|
95
|
+
throw new Error('OTLP collector reported partial success');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Reject non-global IPv4 space, including documentation, benchmarking and multicast.
|
|
99
|
+
// IPv6 is allowlisted to global unicast; mapped IPv4 and local transition ranges fail closed.
|
|
100
|
+
const forbiddenV4 = new BlockList();
|
|
101
|
+
for (const [address, prefix] of [
|
|
102
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
103
|
+
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
104
|
+
['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
|
|
105
|
+
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
106
|
+
])
|
|
107
|
+
forbiddenV4.addSubnet(address, prefix);
|
|
108
|
+
export function isPublicCollectorAddress(address) {
|
|
109
|
+
if (isIP(address) === 4)
|
|
110
|
+
return !forbiddenV4.check(address, 'ipv4');
|
|
111
|
+
// Exclude IPv4-mapped addresses and translation/tunneling ranges even if
|
|
112
|
+
// their embedded IPv4 address looks public.
|
|
113
|
+
if (isIP(address) === 6)
|
|
114
|
+
return /^2[0-9a-f]{3}:/i.test(address)
|
|
115
|
+
&& !/^2001:(?:0:|db8:)/i.test(address) && !/^2002:/i.test(address);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
export async function resolvePublicCollectorAddress(host, resolver = name => lookup(name, { all: true, verbatim: true })) {
|
|
119
|
+
const addresses = await resolver(host);
|
|
120
|
+
if (!addresses.length || addresses.some(({ address, family }) => isIP(address) !== family || !isPublicCollectorAddress(address))) {
|
|
121
|
+
throw new Error('OTLP collector DNS includes a non-public address');
|
|
122
|
+
}
|
|
123
|
+
return addresses[0];
|
|
124
|
+
}
|
|
125
|
+
function pinnedHttpsFetch(input, options) {
|
|
126
|
+
const endpoint = new URL(input instanceof Request ? input.url : input);
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
// Node's HTTPS request performs TLS verification against the original hostname.
|
|
129
|
+
// A fresh DNS resolution is validated for each connection; no connection pool
|
|
130
|
+
// survives to bypass this policy on subsequent exports.
|
|
131
|
+
const req = request(endpoint, {
|
|
132
|
+
method: 'POST', agent: false, headers: { 'content-type': 'application/json' },
|
|
133
|
+
lookup: (host, _opts, callback) => {
|
|
134
|
+
void resolvePublicCollectorAddress(host).then(({ address, family }) => callback(null, address, family), error => callback(error, '', 4));
|
|
135
|
+
},
|
|
136
|
+
}, response => {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
let size = 0;
|
|
139
|
+
response.on('data', (chunk) => {
|
|
140
|
+
size += chunk.length;
|
|
141
|
+
if (size > 4096) {
|
|
142
|
+
req.destroy(new Error('OTLP response exceeds configured inspection bound'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
chunks.push(chunk);
|
|
146
|
+
});
|
|
147
|
+
response.on('end', () => resolve(new Response([204, 205, 304].includes(response.statusCode ?? 0) ? null : Buffer.concat(chunks), { status: response.statusCode ?? 502 })));
|
|
148
|
+
response.on('error', reject);
|
|
149
|
+
});
|
|
150
|
+
req.on('error', reject);
|
|
151
|
+
const signal = options?.signal;
|
|
152
|
+
if (signal?.aborted) {
|
|
153
|
+
req.destroy(signal.reason);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
signal?.addEventListener('abort', () => req.destroy(new Error('OTLP export aborted')), { once: true });
|
|
157
|
+
req.end(options?.body);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function toOtlpAttribute(key, value) {
|
|
161
|
+
if (typeof value === 'boolean')
|
|
162
|
+
return { key, value: { boolValue: value } };
|
|
163
|
+
if (typeof value === 'number')
|
|
164
|
+
return { key, value: Number.isSafeInteger(value)
|
|
165
|
+
? { intValue: String(value) } : { doubleValue: value } };
|
|
166
|
+
return { key, value: { stringValue: value === null ? 'unknown' : value } };
|
|
167
|
+
}
|
|
168
|
+
function toOtlpSpan(span) {
|
|
169
|
+
return {
|
|
170
|
+
traceId: span.context.traceId, spanId: span.context.spanId,
|
|
171
|
+
...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
|
|
172
|
+
name: span.name,
|
|
173
|
+
startTimeUnixNano: String(Math.trunc(span.startTimeUnixMs * 1_000_000)),
|
|
174
|
+
endTimeUnixNano: String(Math.trunc(span.endTimeUnixMs * 1_000_000)),
|
|
175
|
+
attributes: Object.entries(span.attributes).map(([key, value]) => toOtlpAttribute(key, value)),
|
|
176
|
+
links: span.links.map(link => ({ traceId: link.traceId, spanId: link.spanId,
|
|
177
|
+
attributes: Object.entries(link.attributes ?? {}).map(([key, value]) => toOtlpAttribute(key, value)) })),
|
|
178
|
+
events: span.events.map(event => ({ name: event.name, timeUnixNano: String(Math.trunc(event.timeUnixMs * 1_000_000)),
|
|
179
|
+
attributes: Object.entries(event.attributes).map(([key, value]) => toOtlpAttribute(key, value)) })),
|
|
180
|
+
status: { code: span.status === 'error' ? 2 : span.status === 'ok' ? 1 : 0 },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=otlp-http.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const PROTECTED_KEY = /(?:state|question|prompt|response|answer(?:\.body)?|authorization|api[_-]?key|credential|vault|secret|reasoning|cookie|token(?!s?$))/i;
|
|
2
|
+
const CONTROL = /[\u0000-\u001f\u007f]/g;
|
|
3
|
+
const SAFE_EVENT_NAMES = new Set(['retry.scheduled', 'attempt.terminated', 'breaker.transition']);
|
|
4
|
+
// Only schema-declared metadata may cross the telemetry boundary. A syntactically
|
|
5
|
+
// plausible custom key can still carry body text or PII in its value.
|
|
6
|
+
const SAFE_ATTRIBUTE_KEYS = new Set(`
|
|
7
|
+
aiwg.run.id aiwg.invocation.id aiwg.workflow.status aiwg.evaluation.count
|
|
8
|
+
aiwg.decision.id aiwg.decision.version aiwg.decision.alias aiwg.decision.status aiwg.decision.reason
|
|
9
|
+
aiwg.ruleset.id aiwg.ruleset.version aiwg.binding.id aiwg.binding.version
|
|
10
|
+
aiwg.adapter.id aiwg.adapter.version aiwg.attempt.ordinal aiwg.attempt.count
|
|
11
|
+
aiwg.attempt.duration_ms aiwg.attempt.termination aiwg.retry.delay_ms
|
|
12
|
+
aiwg.acceptance.policy_version aiwg.acceptance.disposition aiwg.acceptance.reason
|
|
13
|
+
aiwg.calibration.compatibility_state aiwg.calibration.compatibility_action
|
|
14
|
+
aiwg.calibration.artifact_id aiwg.calibration.artifact_digest aiwg.calibration.alias_revision
|
|
15
|
+
aiwg.calibration.reason_count aiwg.route.reason aiwg.route.fallback aiwg.primitive.count
|
|
16
|
+
aiwg.validation.outcome aiwg.persistence.result aiwg.cache.source aiwg.cache.layer
|
|
17
|
+
aiwg.cache.result aiwg.cache.version aiwg.cache.saved_tokens aiwg.cache.expires_at_ms
|
|
18
|
+
aiwg.cache.reason aiwg.cache.preparation_ms aiwg.cache.invalidation_reason
|
|
19
|
+
aiwg.batch.id aiwg.batch.mode aiwg.batch.plan_digest aiwg.batch.partition_id
|
|
20
|
+
aiwg.batch.item_count aiwg.batch.result_count aiwg.job.id
|
|
21
|
+
aiwg.job.operation aiwg.job.status aiwg.job.revision aiwg.job.item_count aiwg.job.unknown_count
|
|
22
|
+
aiwg.review.id aiwg.operator_decision.event_id
|
|
23
|
+
aiwg.review.status aiwg.review.event aiwg.review.revision aiwg.effect_receipt.id
|
|
24
|
+
aiwg.provider.request_id aiwg.provider.request_id_source aiwg.remote.execution
|
|
25
|
+
aiwg.usage.cost_usd aiwg.usage.cost_provenance aiwg.usage.scope aiwg.link.state
|
|
26
|
+
aiwg.link.tombstone aiwg.queue.delay_ms aiwg.drift.value
|
|
27
|
+
aiwg.admission.decision aiwg.admission.reason aiwg.admission.estimated_tokens aiwg.admission.estimated_cost_usd
|
|
28
|
+
aiwg.admission.retry_after_ms aiwg.queue.active aiwg.queue.queued aiwg.retry.pressure
|
|
29
|
+
aiwg.breaker.status aiwg.breaker.from aiwg.breaker.to
|
|
30
|
+
aiwg.projection.mode aiwg.projection.outcome aiwg.projection.reason aiwg.projection.field_count
|
|
31
|
+
aiwg.projection.incomplete_context aiwg.projection.automatic_action_allowed
|
|
32
|
+
gen_ai.request.model gen_ai.response.model gen_ai.usage.input_tokens
|
|
33
|
+
gen_ai.usage.output_tokens http.response.status_code
|
|
34
|
+
`.trim().split(/\s+/));
|
|
35
|
+
for (const prefix of ['aiwg.definition', 'aiwg.policy', 'aiwg.calibration']) {
|
|
36
|
+
for (const suffix of ['id', 'version', 'digest'])
|
|
37
|
+
SAFE_ATTRIBUTE_KEYS.add(`${prefix}.${suffix}`);
|
|
38
|
+
}
|
|
39
|
+
// Declared keys whose names match PROTECTED_KEY but carry only fixed metadata.
|
|
40
|
+
// `aiwg.link.state` is the deletion tombstone marker; dropping it would turn an
|
|
41
|
+
// explicit tombstone into an unexplained link in exports.
|
|
42
|
+
const PROTECTED_KEY_EXEMPTIONS = new Set(['gen_ai.response.model', 'http.response.status_code', 'aiwg.link.state']);
|
|
43
|
+
export const DEFAULT_ATTRIBUTE_VALUE_LIMIT = 256;
|
|
44
|
+
export function sanitizeOpaqueValue(value, maximum = DEFAULT_ATTRIBUTE_VALUE_LIMIT) {
|
|
45
|
+
return value.replace(CONTROL, '').slice(0, maximum);
|
|
46
|
+
}
|
|
47
|
+
export function sanitizeAttributes(attributes, options = {}) {
|
|
48
|
+
const output = {};
|
|
49
|
+
for (const [key, original] of Object.entries(attributes)) {
|
|
50
|
+
if (!SAFE_ATTRIBUTE_KEYS.has(key)
|
|
51
|
+
|| (PROTECTED_KEY.test(key) && !PROTECTED_KEY_EXEMPTIONS.has(key))
|
|
52
|
+
|| (options.publicExport && key === 'aiwg.provider.request_id')
|
|
53
|
+
|| options.canaries?.some(canary => canary && key.toLowerCase().includes(canary.toLowerCase())))
|
|
54
|
+
continue;
|
|
55
|
+
let value = original;
|
|
56
|
+
if (typeof value === 'string') {
|
|
57
|
+
const lowered = value.toLowerCase();
|
|
58
|
+
if (options.canaries?.some(canary => canary.length > 0 && lowered.includes(canary.toLowerCase())))
|
|
59
|
+
continue;
|
|
60
|
+
value = sanitizeOpaqueValue(value, options.maximumValueLength);
|
|
61
|
+
}
|
|
62
|
+
output[key] = value;
|
|
63
|
+
}
|
|
64
|
+
return output;
|
|
65
|
+
}
|
|
66
|
+
export function sanitizedTelemetryExport(trace, options = {}) {
|
|
67
|
+
const spans = trace.spans.map(span => {
|
|
68
|
+
const attributes = sanitizeAttributes(span.attributes, { publicExport: !options.includeInternalRequestIds, canaries: options.canaries });
|
|
69
|
+
return {
|
|
70
|
+
...span, attributes,
|
|
71
|
+
// Tracestate is a vendor-defined free-form string, not needed for export;
|
|
72
|
+
// do not copy it into a disclosure even if a caller supplied one.
|
|
73
|
+
context: { traceId: span.context.traceId, spanId: span.context.spanId, traceFlags: span.context.traceFlags },
|
|
74
|
+
provenance: Object.fromEntries(Object.entries(span.provenance).filter(([key]) => Object.hasOwn(attributes, key))),
|
|
75
|
+
events: span.events.filter(event => SAFE_EVENT_NAMES.has(event.name)).map(event => ({ ...event,
|
|
76
|
+
attributes: sanitizeAttributes(event.attributes, { publicExport: true, canaries: options.canaries }) })),
|
|
77
|
+
links: span.links.map(link => ({ ...link, ...(link.attributes ? { attributes: sanitizeAttributes(link.attributes, { publicExport: true, canaries: options.canaries }) } : {}) })),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
const tombstones = trace.tombstones?.map(tombstone => ({
|
|
81
|
+
...tombstone,
|
|
82
|
+
opaqueId: options.canaries?.some(canary => canary && tombstone.opaqueId.toLowerCase().includes(canary.toLowerCase()))
|
|
83
|
+
? 'redacted' : sanitizeOpaqueValue(tombstone.opaqueId),
|
|
84
|
+
reason: options.canaries?.some(canary => canary && tombstone.reason.toLowerCase().includes(canary.toLowerCase()))
|
|
85
|
+
? 'redacted' : sanitizeOpaqueValue(tombstone.reason),
|
|
86
|
+
}));
|
|
87
|
+
return { ...trace, spans, ...(tombstones ? { tombstones } : {}) };
|
|
88
|
+
}
|
|
89
|
+
export function scanTelemetryCanaries(value, canaries) {
|
|
90
|
+
const serialized = JSON.stringify(value).toLowerCase();
|
|
91
|
+
return canaries.filter(canary => canary.length > 0 && serialized.includes(canary.toLowerCase()));
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=redaction.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { DECISION_LIFECYCLE_VERSION, validateDecisionLifecyclePolicy } from '../lifecycle.js';
|
|
2
|
+
export function validateDebugCapturePolicy(policy) {
|
|
3
|
+
if (!policy)
|
|
4
|
+
return null;
|
|
5
|
+
if (!policy.explicitlyAuthorized || !policy.encryption.enabled || !policy.encryption.keyReference
|
|
6
|
+
|| !policy.accessAudit.enabled || !policy.accessAudit.sinkReference || !policy.deletionEnabled
|
|
7
|
+
|| !Number.isSafeInteger(policy.ttlMs) || policy.ttlMs <= 0
|
|
8
|
+
|| !['confidential', 'restricted'].includes(policy.classification)
|
|
9
|
+
|| !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(policy.encryption.keyReference)
|
|
10
|
+
|| !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(policy.accessAudit.sinkReference))
|
|
11
|
+
throw new Error('Sensitive debug capture policy is incomplete');
|
|
12
|
+
return structuredClone(policy);
|
|
13
|
+
}
|
|
14
|
+
/** Lifecycle surfaces whose content telemetry retains or links to. */
|
|
15
|
+
export const TELEMETRY_LIFECYCLE_SURFACES = [
|
|
16
|
+
'trace', 'debug-sidecar', 'export', 'review', 'job', 'cache', 'evaluation',
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Derive telemetry retention from the common `decision-lifecycle/v1` policy (M09).
|
|
20
|
+
* TTLs come from the lifecycle surfaces; linked records use the shortest linked-surface TTL.
|
|
21
|
+
* Legal hold is true only while an authorized lifecycle hold for a telemetry surface is active.
|
|
22
|
+
*/
|
|
23
|
+
export function telemetryRetentionFromLifecyclePolicy(policy, holds = [], now = Date.now()) {
|
|
24
|
+
validateDecisionLifecyclePolicy(policy);
|
|
25
|
+
if (!Number.isSafeInteger(now) || now < 0 || !Array.isArray(holds))
|
|
26
|
+
throw new Error('Invalid telemetry retention policy');
|
|
27
|
+
const surfaces = policy.surfaces;
|
|
28
|
+
const legalHold = holds.some(hold => Number.isSafeInteger(hold?.expiresAt) && hold.expiresAt > now
|
|
29
|
+
&& Array.isArray(hold.scope) && hold.scope.some(surface => TELEMETRY_LIFECYCLE_SURFACES.includes(surface)));
|
|
30
|
+
const retention = {
|
|
31
|
+
traceTtlMs: surfaces.trace.retentionMs,
|
|
32
|
+
debugSidecarTtlMs: surfaces['debug-sidecar'].retentionMs,
|
|
33
|
+
exportTtlMs: surfaces.export.retentionMs,
|
|
34
|
+
linkedRecordTtlMs: Math.min(surfaces.review.retentionMs, surfaces.job.retentionMs, surfaces.cache.retentionMs, surfaces.evaluation.retentionMs),
|
|
35
|
+
deletionEnabled: true,
|
|
36
|
+
tombstonesEnabled: true,
|
|
37
|
+
legalHold,
|
|
38
|
+
lifecycleVersion: DECISION_LIFECYCLE_VERSION,
|
|
39
|
+
};
|
|
40
|
+
validateRetentionPolicy(retention);
|
|
41
|
+
return retention;
|
|
42
|
+
}
|
|
43
|
+
export function validateRetentionPolicy(policy) {
|
|
44
|
+
const ttls = [policy.traceTtlMs, policy.debugSidecarTtlMs, policy.exportTtlMs, policy.linkedRecordTtlMs];
|
|
45
|
+
if (ttls.some(ttl => !Number.isSafeInteger(ttl) || ttl <= 0) || !policy.deletionEnabled || !policy.tombstonesEnabled)
|
|
46
|
+
throw new Error('Invalid telemetry retention policy');
|
|
47
|
+
}
|
|
48
|
+
export function deleteTelemetryReference(trace, referenceType, opaqueId, reason, policy, now = Date.now()) {
|
|
49
|
+
validateRetentionPolicy(policy);
|
|
50
|
+
if (policy.legalHold)
|
|
51
|
+
throw new Error('Telemetry deletion prohibited by legal hold');
|
|
52
|
+
if (!opaqueId || !reason)
|
|
53
|
+
throw new Error('Deletion requires an opaque reference and reason');
|
|
54
|
+
const tombstone = { referenceType, opaqueId, deletedAtUnixMs: now, reason: reason.slice(0, 128) };
|
|
55
|
+
const spans = trace.spans.map(span => ({
|
|
56
|
+
...span,
|
|
57
|
+
links: span.links.map(link => link.attributes && Object.values(link.attributes).includes(opaqueId)
|
|
58
|
+
? { ...link, attributes: { 'aiwg.link.state': 'deleted', 'aiwg.link.tombstone': opaqueId } }
|
|
59
|
+
: link),
|
|
60
|
+
}));
|
|
61
|
+
return { ...trace, spans, tombstones: [...(trace.tombstones ?? []), tombstone] };
|
|
62
|
+
}
|
|
63
|
+
/** Reapply trace TTL after backup/restore before making restored content queryable. */
|
|
64
|
+
export function restoreTelemetryTrace(trace, policy, now = Date.now()) {
|
|
65
|
+
validateRetentionPolicy(policy);
|
|
66
|
+
if (policy.legalHold || trace.spans.length === 0)
|
|
67
|
+
return structuredClone(trace);
|
|
68
|
+
const createdAt = Math.min(...trace.spans.map(span => span.startTimeUnixMs));
|
|
69
|
+
if (now - createdAt <= policy.traceTtlMs)
|
|
70
|
+
return structuredClone(trace);
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: trace.schemaVersion,
|
|
73
|
+
traceId: trace.traceId,
|
|
74
|
+
spans: [],
|
|
75
|
+
tombstones: [...(trace.tombstones ?? []), {
|
|
76
|
+
referenceType: 'trace', opaqueId: trace.traceId, deletedAtUnixMs: now, reason: 'expired during restore',
|
|
77
|
+
}],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const ORPHAN_REFERENCE = {
|
|
81
|
+
review: 'review', job: 'job', cache: 'cache', evaluation: 'evaluation',
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Tombstone links whose target span can no longer be resolved, for example after
|
|
85
|
+
* the linked trace expired, was deleted, or was never exported. An orphan becomes
|
|
86
|
+
* an explicit `orphaned` link state plus a tombstone, never a dangling reference
|
|
87
|
+
* that a later record could silently reuse. Targets inside the same trace resolve
|
|
88
|
+
* locally; `resolves` answers for every other target.
|
|
89
|
+
*/
|
|
90
|
+
export function tombstoneOrphanedLinks(trace, resolves, now = Date.now()) {
|
|
91
|
+
const local = new Set(trace.spans.map(span => `${span.context.traceId}:${span.context.spanId}`));
|
|
92
|
+
const tombstones = structuredClone(trace.tombstones ?? []);
|
|
93
|
+
const spans = structuredClone(trace.spans).map(span => ({
|
|
94
|
+
...span,
|
|
95
|
+
links: span.links.map(link => {
|
|
96
|
+
const state = link.attributes?.['aiwg.link.state'];
|
|
97
|
+
const opaqueId = `${link.traceId}:${link.spanId}`;
|
|
98
|
+
if (state === 'deleted' || state === 'orphaned' || local.has(opaqueId) || resolves(link))
|
|
99
|
+
return link;
|
|
100
|
+
if (!tombstones.some(tombstone => tombstone.opaqueId === opaqueId)) {
|
|
101
|
+
tombstones.push({ referenceType: ORPHAN_REFERENCE[link.relationship] ?? 'trace', opaqueId,
|
|
102
|
+
deletedAtUnixMs: now, reason: 'orphaned link' });
|
|
103
|
+
}
|
|
104
|
+
return { ...link, attributes: { 'aiwg.link.state': 'orphaned', 'aiwg.link.tombstone': opaqueId } };
|
|
105
|
+
}),
|
|
106
|
+
}));
|
|
107
|
+
return { ...trace, spans, tombstones };
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=retention.js.map
|