create-agent-rig 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +152 -1
- package/README.md +99 -4
- package/package.json +4 -3
- package/packages/cli/dist/commands/init.js +43 -7
- package/packages/cli/dist/commands/memory.js +182 -0
- package/packages/cli/dist/commands/setup.js +45 -0
- package/packages/cli/dist/commands/upgrade.js +22 -4
- package/packages/cli/dist/index.js +110 -3
- package/packages/cli/dist/lib/manifest.js +23 -5
- package/packages/cli/dist/lib/subsystems.js +269 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
- package/packages/cli/dist/policy/core/coverage.js +253 -0
- package/packages/cli/dist/policy/core/decision-record.js +130 -44
- package/packages/cli/dist/policy/core/declaration.js +58 -17
- package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
- package/packages/cli/dist/policy/core/probe.js +442 -0
- package/packages/cli/dist/policy/core/validation.js +194 -1
- package/packages/cli/dist/policy/core/vocabulary.js +70 -3
- package/packages/cli/dist/policy/harness/claude.js +9 -1
- package/packages/cli/dist/policy/harness/codex.js +48 -1
- package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
- package/packages/cli/dist/policy/index.js +9 -2
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
- package/templates/agent-os/subagent-routing.json +32 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
- package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
- package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +269 -32
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
- package/templates/agent-os/universal/.claude/settings.json +16 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
- package/templates/agent-os/universal/.codex/config.toml +3 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
- package/templates/agent-os/universal/layers.json +4 -0
- package/templates/hash-history.json +71 -22
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { CAPABILITY_STATES } from '../core/vocabulary.js';
|
|
2
|
+
export const BENCHMARK_CLASSIFICATIONS = Object.freeze([
|
|
3
|
+
'equivalent',
|
|
4
|
+
'intentional-degradation',
|
|
5
|
+
'unsupported',
|
|
6
|
+
]);
|
|
7
|
+
const MEASUREMENT_KINDS = Object.freeze([
|
|
8
|
+
'capability-probe',
|
|
9
|
+
'guard-enforcement',
|
|
10
|
+
'compatibility-rejection',
|
|
11
|
+
]);
|
|
12
|
+
const REQUIRED_SCENARIO_IDS = [
|
|
13
|
+
'real-wiring',
|
|
14
|
+
'unwired-enforcement',
|
|
15
|
+
'disabled-enforcement',
|
|
16
|
+
'narrowed-enforcement',
|
|
17
|
+
'bypassed-enforcement',
|
|
18
|
+
'unreadable-input',
|
|
19
|
+
'hook-input',
|
|
20
|
+
'protected-rulebook',
|
|
21
|
+
'widening',
|
|
22
|
+
'foreign-major',
|
|
23
|
+
];
|
|
24
|
+
const requiredScenarioIds = new Set(REQUIRED_SCENARIO_IDS);
|
|
25
|
+
const scenario = (id, fixture, mutation, action, measurementKind, expectedStates) => Object.freeze({
|
|
26
|
+
id,
|
|
27
|
+
fixture,
|
|
28
|
+
mutation,
|
|
29
|
+
action,
|
|
30
|
+
measurementKind,
|
|
31
|
+
expectedStates: Object.freeze([...expectedStates]),
|
|
32
|
+
});
|
|
33
|
+
export const BENCHMARK_CORPUS = Object.freeze({
|
|
34
|
+
contractMajor: 1,
|
|
35
|
+
scenarios: Object.freeze([
|
|
36
|
+
scenario('real-wiring', 'universal-rig', 'none', 'exercise protected write', 'capability-probe', ['SUPPORTED']),
|
|
37
|
+
scenario('unwired-enforcement', 'universal-rig', 'remove hook registration', 'exercise protected write', 'capability-probe', ['UNSUPPORTED']),
|
|
38
|
+
scenario('disabled-enforcement', 'universal-rig', 'disable all hooks', 'exercise protected write', 'capability-probe', ['UNSUPPORTED']),
|
|
39
|
+
scenario('narrowed-enforcement', 'universal-rig', 'narrow matcher', 'exercise omitted protected write', 'capability-probe', ['DEGRADED']),
|
|
40
|
+
scenario('bypassed-enforcement', 'universal-rig', 'bypass guard command', 'exercise protected write', 'capability-probe', ['INTEGRATION-FAILED']),
|
|
41
|
+
scenario('unreadable-input', 'universal-rig', 'make snapshot unreadable', 'exercise protected write', 'capability-probe', ['INTEGRATION-FAILED']),
|
|
42
|
+
scenario('hook-input', 'universal-rig', 'make tool_input unreadable', 'exercise protected write', 'guard-enforcement', ['SUPPORTED']),
|
|
43
|
+
scenario('protected-rulebook', 'universal-rig', 'none', 'edit protected rulebook', 'guard-enforcement', ['SUPPORTED']),
|
|
44
|
+
scenario('widening', 'universal-rig', 'widen authorization allow-list', 'attempt unauthorized widening', 'guard-enforcement', ['SUPPORTED']),
|
|
45
|
+
scenario('foreign-major', 'contracts/session-messaging/v1/fixtures/negative/envelope-foreign-major.json', 'foreign schema major', 'check policy compatibility', 'compatibility-rejection', ['UNSUPPORTED']),
|
|
46
|
+
]),
|
|
47
|
+
});
|
|
48
|
+
const canonicalScenarios = new Map(BENCHMARK_CORPUS.scenarios.map((entry) => [entry.id, entry]));
|
|
49
|
+
export const classifyCapability = (state) => {
|
|
50
|
+
switch (state) {
|
|
51
|
+
case 'SUPPORTED':
|
|
52
|
+
return { classification: 'equivalent', integrationFailed: false };
|
|
53
|
+
case 'DEGRADED':
|
|
54
|
+
return { classification: 'intentional-degradation', integrationFailed: false };
|
|
55
|
+
case 'UNSUPPORTED':
|
|
56
|
+
return { classification: 'unsupported', integrationFailed: false };
|
|
57
|
+
case 'INTEGRATION-FAILED':
|
|
58
|
+
return { classification: 'unsupported', integrationFailed: true };
|
|
59
|
+
default:
|
|
60
|
+
throw new Error(`Unknown capability state: ${String(state)}`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const isOwnDataRecord = (value) => {
|
|
64
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
65
|
+
return false;
|
|
66
|
+
const prototype = Object.getPrototypeOf(value);
|
|
67
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
68
|
+
return false;
|
|
69
|
+
return Object.values(Object.getOwnPropertyDescriptors(value)).every((descriptor) => descriptor.enumerable === true && 'value' in descriptor);
|
|
70
|
+
};
|
|
71
|
+
const isDataArray = (value) => {
|
|
72
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype)
|
|
73
|
+
return false;
|
|
74
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
75
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
76
|
+
if (descriptor === undefined || descriptor.enumerable !== true || !('value' in descriptor))
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
const hasOnlyKeys = (value, keys) => {
|
|
82
|
+
const actual = Object.keys(value);
|
|
83
|
+
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
84
|
+
};
|
|
85
|
+
const nonBlank = (value) => typeof value === 'string' && value.trim() !== '';
|
|
86
|
+
const invalidCorpus = (reason) => {
|
|
87
|
+
throw new Error(`Invalid benchmark corpus: ${reason}`);
|
|
88
|
+
};
|
|
89
|
+
export const parseBenchmarkCorpus = (input) => {
|
|
90
|
+
if (!isOwnDataRecord(input) || !hasOnlyKeys(input, ['contractMajor', 'scenarios'])) {
|
|
91
|
+
return invalidCorpus('must be a plain corpus with contractMajor and scenarios');
|
|
92
|
+
}
|
|
93
|
+
if (input.contractMajor !== 1)
|
|
94
|
+
return invalidCorpus('unsupported contract major; expected 1');
|
|
95
|
+
if (!isDataArray(input.scenarios))
|
|
96
|
+
return invalidCorpus('scenarios must be a data array');
|
|
97
|
+
if (input.scenarios.length !== REQUIRED_SCENARIO_IDS.length) {
|
|
98
|
+
return invalidCorpus('must include every required scenario exactly once');
|
|
99
|
+
}
|
|
100
|
+
const ids = new Set();
|
|
101
|
+
const scenarios = [];
|
|
102
|
+
for (const value of input.scenarios) {
|
|
103
|
+
if (!isOwnDataRecord(value) ||
|
|
104
|
+
!hasOnlyKeys(value, [
|
|
105
|
+
'id',
|
|
106
|
+
'fixture',
|
|
107
|
+
'mutation',
|
|
108
|
+
'action',
|
|
109
|
+
'measurementKind',
|
|
110
|
+
'expectedStates',
|
|
111
|
+
])) {
|
|
112
|
+
return invalidCorpus('each scenario must have only id, fixture, mutation, action, measurementKind, and expectedStates');
|
|
113
|
+
}
|
|
114
|
+
const { id, fixture, mutation, action, measurementKind, expectedStates } = value;
|
|
115
|
+
if (!nonBlank(id) || !requiredScenarioIds.has(id))
|
|
116
|
+
return invalidCorpus(`unknown scenario ${String(id)}`);
|
|
117
|
+
if (ids.has(id))
|
|
118
|
+
return invalidCorpus(`scenario ${id} is duplicated`);
|
|
119
|
+
if (!nonBlank(fixture) || !nonBlank(mutation) || !nonBlank(action)) {
|
|
120
|
+
return invalidCorpus(`scenario ${id} has empty metadata`);
|
|
121
|
+
}
|
|
122
|
+
if (typeof measurementKind !== 'string' ||
|
|
123
|
+
!MEASUREMENT_KINDS.includes(measurementKind)) {
|
|
124
|
+
return invalidCorpus(`scenario ${id} has an unknown measurement kind`);
|
|
125
|
+
}
|
|
126
|
+
if (!isDataArray(expectedStates) || expectedStates.length === 0) {
|
|
127
|
+
return invalidCorpus(`scenario ${id} must name expected capability states`);
|
|
128
|
+
}
|
|
129
|
+
const states = new Set();
|
|
130
|
+
for (const expectedState of expectedStates) {
|
|
131
|
+
if (typeof expectedState !== 'string' ||
|
|
132
|
+
!CAPABILITY_STATES.includes(expectedState)) {
|
|
133
|
+
return invalidCorpus(`scenario ${id} has an unknown capability state`);
|
|
134
|
+
}
|
|
135
|
+
if (states.has(expectedState))
|
|
136
|
+
return invalidCorpus(`scenario ${id} repeats a capability state`);
|
|
137
|
+
states.add(expectedState);
|
|
138
|
+
}
|
|
139
|
+
const canonical = canonicalScenarios.get(id);
|
|
140
|
+
if (fixture !== canonical.fixture ||
|
|
141
|
+
mutation !== canonical.mutation ||
|
|
142
|
+
action !== canonical.action ||
|
|
143
|
+
measurementKind !== canonical.measurementKind) {
|
|
144
|
+
return invalidCorpus(`scenario ${id} changes its canonical metadata`);
|
|
145
|
+
}
|
|
146
|
+
if (expectedStates.length !== canonical.expectedStates.length ||
|
|
147
|
+
expectedStates.some((state, index) => state !== canonical.expectedStates[index])) {
|
|
148
|
+
return invalidCorpus(`scenario ${id} changes its expected capability states`);
|
|
149
|
+
}
|
|
150
|
+
ids.add(id);
|
|
151
|
+
scenarios.push(Object.freeze({
|
|
152
|
+
id,
|
|
153
|
+
fixture,
|
|
154
|
+
mutation,
|
|
155
|
+
action,
|
|
156
|
+
measurementKind: measurementKind,
|
|
157
|
+
expectedStates: Object.freeze([...states]),
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
for (const id of REQUIRED_SCENARIO_IDS) {
|
|
161
|
+
if (!ids.has(id))
|
|
162
|
+
return invalidCorpus(`required scenario ${id} is missing`);
|
|
163
|
+
}
|
|
164
|
+
return Object.freeze({ contractMajor: 1, scenarios: Object.freeze(scenarios) });
|
|
165
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability & degradation contract (RP-36): what each declared policy is
|
|
3
|
+
* worth on one harness surface, how that answer is established, and how it is
|
|
4
|
+
* maintained afterwards.
|
|
5
|
+
*
|
|
6
|
+
* ⚠ A LIBRARY, not a running contract. Nothing in this rig calls any of it
|
|
7
|
+
* yet: `doctor` rendering the coverage report is its own task, and so is the
|
|
8
|
+
* caller that would report observed traffic. The present tense below describes
|
|
9
|
+
* what the functions do when called, not something happening today. The
|
|
10
|
+
* rationale and the full list of what this deliberately does not do are in
|
|
11
|
+
* `docs/decisions/capability-coverage.md`.
|
|
12
|
+
*
|
|
13
|
+
* A status is established by `./probe.ts` on an occasion named from
|
|
14
|
+
* `./vocabulary.ts` (`PROBE_TRIGGERS`), and maintained from what real traffic
|
|
15
|
+
* was expected to show and did not. Three rules follow, and they are the whole
|
|
16
|
+
* design.
|
|
17
|
+
*
|
|
18
|
+
* 🔴 **A timer alone never degrades anything.** `observeExpectedSignal` takes
|
|
19
|
+
* an OBSERVATION, never an elapsed time, and this module exports no function
|
|
20
|
+
* that ages, expires or sweeps an entry — pinned by
|
|
21
|
+
* `packages/cli/test/policy-coverage.test.ts` › "exports nothing that ages,
|
|
22
|
+
* expires or sweeps an entry, so only an observation can move it", and
|
|
23
|
+
* measured end-to-end by › "reports no downgrade when a year passes and the
|
|
24
|
+
* same wiring is probed again, because elapsed time is not evidence" against
|
|
25
|
+
* its positive control. No traffic is not a failure: it is no evidence.
|
|
26
|
+
*
|
|
27
|
+
* 🔴 **Traffic lowers a status; it never raises one.** A missing signal is
|
|
28
|
+
* evidence the mechanism did not act. A present signal is evidence it acted
|
|
29
|
+
* once — not that a wiring defect the probe found has been repaired. So the
|
|
30
|
+
* only route back up is another probe: › "never raises a status on traffic: a
|
|
31
|
+
* degraded surface stays degraded until a probe says otherwise".
|
|
32
|
+
*
|
|
33
|
+
* 🔴 **Traffic that CONTRADICTS the record is its own answer.** A signal
|
|
34
|
+
* observed on a surface the map records as `UNSUPPORTED` is not a promotion
|
|
35
|
+
* and not a miss — the wiring and the traffic disagree, which is what
|
|
36
|
+
* `INTEGRATION-FAILED` names: › "a signal observed where the map says nothing
|
|
37
|
+
* is wired is a contradiction, not a pass". It is still not a pass;
|
|
38
|
+
* `qualifierFor` maps it to `UNVERIFIABLE`.
|
|
39
|
+
*
|
|
40
|
+
* **Why this module throws where its neighbours return a `Validation`.** The
|
|
41
|
+
* line is what the value IS, not how bad it is: outside data read off a disk
|
|
42
|
+
* becomes a RESULT — `snapshot: unknown` is never trusted and never throws, it
|
|
43
|
+
* becomes `INTEGRATION-FAILED` — while a value the CALLER chose (a word from a
|
|
44
|
+
* closed vocabulary, its own clock, its own claim about which build it saw) is
|
|
45
|
+
* a programming error and throws. `./evidence-matrix.ts` sits on the first
|
|
46
|
+
* side, this module's two entry points on the second.
|
|
47
|
+
*
|
|
48
|
+
* Two limits worth stating rather than leaving to be discovered. `DEGRADED`
|
|
49
|
+
* carries no verdict qualifier, so an operation on precisely the tool a
|
|
50
|
+
* degraded matcher lost does not require a qualifier from this library — the
|
|
51
|
+
* item scopes the `UNVERIFIABLE` requirement to the unenforceable states; the
|
|
52
|
+
* caller owns the operation verdict. And a `reason`
|
|
53
|
+
* supplied with a miss that does not cross the threshold is not retained;
|
|
54
|
+
* only the miss that degrades records one — › "discards the reason given with
|
|
55
|
+
* a miss that does not degrade, and records the one given with the miss that
|
|
56
|
+
* does".
|
|
57
|
+
*/
|
|
58
|
+
import { probePolicy } from './probe.js';
|
|
59
|
+
import { EXACT_VERSION_EXPECTED, isExactVersion, ISO_8601 } from './validation.js';
|
|
60
|
+
import { PROBE_TRIGGERS, UNENFORCEABLE_STATES } from './vocabulary.js';
|
|
61
|
+
/**
|
|
62
|
+
* How many consecutive expected-but-absent signals degrade a supported policy.
|
|
63
|
+
*
|
|
64
|
+
* Three, not one: a single miss is as easily an operation that never reached
|
|
65
|
+
* the mechanism as a mechanism that failed to act, and a contract that
|
|
66
|
+
* degrades on it reports noise. It is a DEFAULT and not a constant — every
|
|
67
|
+
* caller may set its own, because what counts as enough evidence depends on
|
|
68
|
+
* how much traffic the surface sees.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_DEGRADATION_THRESHOLD = 3;
|
|
71
|
+
/**
|
|
72
|
+
* How much a state can enforce, as an order, for deciding what counts as a
|
|
73
|
+
* fall. `UNSUPPORTED` and `INTEGRATION-FAILED` rank together on purpose: both
|
|
74
|
+
* mean the policy cannot be relied on here, they differ in whether anything is
|
|
75
|
+
* wired at all, and calling a move between them a downgrade would report a
|
|
76
|
+
* change of diagnosis as a loss of capability.
|
|
77
|
+
*
|
|
78
|
+
* This is a DISPLAY ordering and nothing else reads it. What qualifies a
|
|
79
|
+
* verdict is `UNENFORCEABLE_STATES` in `./vocabulary.ts`, which
|
|
80
|
+
* `./decision-record.ts` reads too — keying qualification off a rank would
|
|
81
|
+
* mean a future re-rank silently changed which verdicts are UNVERIFIABLE.
|
|
82
|
+
*/
|
|
83
|
+
const ENFORCEMENT_RANK = {
|
|
84
|
+
SUPPORTED: 0,
|
|
85
|
+
DEGRADED: 1,
|
|
86
|
+
UNSUPPORTED: 2,
|
|
87
|
+
'INTEGRATION-FAILED': 2,
|
|
88
|
+
};
|
|
89
|
+
const requireTimestamp = (field, value) => {
|
|
90
|
+
if (!ISO_8601.test(value)) {
|
|
91
|
+
throw new Error(`${field} must be an ISO-8601 date-time with an explicit zone; got ${JSON.stringify(value)}`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* The probe's answer for every policy, as one map.
|
|
96
|
+
*
|
|
97
|
+
* `trigger` is required, and that is the point: `PROBE_TRIGGERS` names only
|
|
98
|
+
* changes to the surface, so a caller must say which change occasioned this
|
|
99
|
+
* read and cannot name an interval. `at` is the caller's clock — nothing here
|
|
100
|
+
* reads one — and it is validated, because a status whose time cannot be
|
|
101
|
+
* ordered against a change is not auditable. The surface harness and path must
|
|
102
|
+
* also name the adapter doing the read; otherwise valid wiring from one
|
|
103
|
+
* harness could be labeled as support on another.
|
|
104
|
+
*/
|
|
105
|
+
export function coverageFromProbe(args) {
|
|
106
|
+
const { surface, policies, adapter, snapshot, at, trigger, evidencePointer } = args;
|
|
107
|
+
if (!PROBE_TRIGGERS.includes(trigger)) {
|
|
108
|
+
throw new Error(`a probe is occasioned by one of ${PROBE_TRIGGERS.join(', ')}; got ${JSON.stringify(trigger)}`);
|
|
109
|
+
}
|
|
110
|
+
requireTimestamp('at', at);
|
|
111
|
+
if (surface.harness !== adapter.harness || surface.surface !== adapter.surfaceFile) {
|
|
112
|
+
throw new Error(`surface identity must match the adapter being probed; expected ${JSON.stringify(adapter.harness)} at ${JSON.stringify(adapter.surfaceFile)}, got ${JSON.stringify(surface.harness)} at ${JSON.stringify(surface.surface)}`);
|
|
113
|
+
}
|
|
114
|
+
// The identity is the caller's claim about WHICH build was observed, and
|
|
115
|
+
// three documents called it exact while nothing read it. A map is the shape
|
|
116
|
+
// that carries it, so this is where it is refused.
|
|
117
|
+
if (!isExactVersion(surface.harnessVersion)) {
|
|
118
|
+
throw new Error(`surface.harnessVersion ${EXACT_VERSION_EXPECTED}; got ${JSON.stringify(surface.harnessVersion)}`);
|
|
119
|
+
}
|
|
120
|
+
const entries = policies.map((policy) => {
|
|
121
|
+
const result = probePolicy(policy, adapter, snapshot);
|
|
122
|
+
const entry = {
|
|
123
|
+
policyId: policy.policyId,
|
|
124
|
+
policyVersion: policy.policyVersion,
|
|
125
|
+
mechanism: policy.mechanism,
|
|
126
|
+
status: result.state,
|
|
127
|
+
verifiedAt: at,
|
|
128
|
+
verifiedBy: 'probe',
|
|
129
|
+
triggeredBy: trigger,
|
|
130
|
+
consecutiveMisses: 0,
|
|
131
|
+
};
|
|
132
|
+
if (result.reason !== undefined)
|
|
133
|
+
entry.degradationReason = result.reason;
|
|
134
|
+
if (evidencePointer !== undefined)
|
|
135
|
+
entry.evidencePointer = evidencePointer;
|
|
136
|
+
return entry;
|
|
137
|
+
});
|
|
138
|
+
return { surface, entries };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Fold one observation of real traffic into an entry.
|
|
142
|
+
*
|
|
143
|
+
* `seen` is the whole input: the operation the policy judges happened, and the
|
|
144
|
+
* observable signal the mechanism should have produced either appeared or did
|
|
145
|
+
* not. There is no parameter for "and this much time has passed", because time
|
|
146
|
+
* is not evidence about a mechanism.
|
|
147
|
+
*/
|
|
148
|
+
export function observeExpectedSignal(entry, observation) {
|
|
149
|
+
const { seen, at, threshold = DEFAULT_DEGRADATION_THRESHOLD, reason } = observation;
|
|
150
|
+
if (!Number.isInteger(threshold) || threshold < 1) {
|
|
151
|
+
throw new Error(`the degradation threshold must be a whole number of observations, at least 1; got ${String(threshold)}`);
|
|
152
|
+
}
|
|
153
|
+
requireTimestamp('at', at);
|
|
154
|
+
if (seen) {
|
|
155
|
+
const next = {
|
|
156
|
+
...entry,
|
|
157
|
+
consecutiveMisses: 0,
|
|
158
|
+
verifiedAt: at,
|
|
159
|
+
verifiedBy: 'traffic',
|
|
160
|
+
};
|
|
161
|
+
// A signal seen where the record says nothing is wired is not a promotion
|
|
162
|
+
// and not a miss: the wiring and the traffic disagree, and that is the
|
|
163
|
+
// state the item reserves for it. The status is not raised — see the
|
|
164
|
+
// second rule in this file's header — so the only status this branch
|
|
165
|
+
// produces is the one naming the contradiction.
|
|
166
|
+
if (entry.status === 'UNSUPPORTED') {
|
|
167
|
+
next.status = 'INTEGRATION-FAILED';
|
|
168
|
+
next.degradationReason = `the expected signal was observed although the recorded status was UNSUPPORTED, so the wiring and the traffic disagree`;
|
|
169
|
+
}
|
|
170
|
+
return next;
|
|
171
|
+
}
|
|
172
|
+
const consecutiveMisses = entry.consecutiveMisses + 1;
|
|
173
|
+
const degrades = entry.status === 'SUPPORTED' && consecutiveMisses >= threshold;
|
|
174
|
+
const next = {
|
|
175
|
+
...entry,
|
|
176
|
+
consecutiveMisses,
|
|
177
|
+
verifiedAt: at,
|
|
178
|
+
verifiedBy: 'traffic',
|
|
179
|
+
};
|
|
180
|
+
if (!degrades)
|
|
181
|
+
return next;
|
|
182
|
+
next.status = 'DEGRADED';
|
|
183
|
+
next.degradationReason =
|
|
184
|
+
reason ??
|
|
185
|
+
`the expected observable signal was absent on ${String(consecutiveMisses)} consecutive operations`;
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
/** The entry for one policy, or `null` — never an invented status. */
|
|
189
|
+
export function statusOf(map, policyId) {
|
|
190
|
+
return map.entries.find((entry) => entry.policyId === policyId) ?? null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The qualifier a verdict must carry when it was produced under this state.
|
|
194
|
+
*
|
|
195
|
+
* The unenforceable states mean the question could not be put to a working
|
|
196
|
+
* mechanism, so an `allow` under either is UNVERIFIABLE rather than a pass —
|
|
197
|
+
* `./decision-record.ts` refuses the unqualified record, and
|
|
198
|
+
* `packages/cli/test/policy-coverage.test.ts` › "refuses the silent pass an
|
|
199
|
+
* unwired surface would otherwise produce, and accepts it once qualifierFor
|
|
200
|
+
* speaks" holds the two modules to it together. How far that refusal reaches is
|
|
201
|
+
* stated once, at `UNENFORCEABLE_STATES` in `./vocabulary.ts` — read it there.
|
|
202
|
+
* This sentence used to paraphrase it as well, and the paraphrase is what went
|
|
203
|
+
* stale: it still said the refusal was "a property of records built the
|
|
204
|
+
* ordinary way" after RP-153 had made it hold against a hand-built prototype
|
|
205
|
+
* too. A second copy of a fact the same sentence claims is spelled once
|
|
206
|
+
* (`rules/invariants.md`, "One mechanism, one implementation").
|
|
207
|
+
*/
|
|
208
|
+
export function qualifierFor(state) {
|
|
209
|
+
return UNENFORCEABLE_STATES.includes(state)
|
|
210
|
+
? 'UNVERIFIABLE'
|
|
211
|
+
: undefined;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Every policy whose status fell between two maps of the same surface.
|
|
215
|
+
*
|
|
216
|
+
* Only the same policy id and declaration version present in both are compared:
|
|
217
|
+
* an entry that appeared, vanished or changed semantics is a change of what is
|
|
218
|
+
* being measured, not a capability that fell.
|
|
219
|
+
* Two maps of DIFFERENT surfaces are refused rather than diffed — the result
|
|
220
|
+
* is attributed to one surface identity, and attributing one surface's fall to
|
|
221
|
+
* another is a report an operator would act on in the wrong place.
|
|
222
|
+
*/
|
|
223
|
+
export function downgradesBetween(before, after) {
|
|
224
|
+
// Deliberately NOT harnessVersion. A probe before and after an upgrade
|
|
225
|
+
// carries two versions of one surface, and that pair is exactly what the
|
|
226
|
+
// `upgrade` trigger exists to compare — refusing it disabled the comparison
|
|
227
|
+
// for the case it was added for. Harness, surface and OS are what make it
|
|
228
|
+
// the same place.
|
|
229
|
+
const sameSurface = before.surface.harness === after.surface.harness &&
|
|
230
|
+
before.surface.surface === after.surface.surface &&
|
|
231
|
+
before.surface.os === after.surface.os;
|
|
232
|
+
if (!sameSurface) {
|
|
233
|
+
throw new Error('two coverage maps of different surfaces cannot be compared: a downgrade is attributed to one surface, and these name two');
|
|
234
|
+
}
|
|
235
|
+
const downgrades = [];
|
|
236
|
+
for (const next of after.entries) {
|
|
237
|
+
const previous = before.entries.find((entry) => entry.policyId === next.policyId && entry.policyVersion === next.policyVersion) ?? null;
|
|
238
|
+
if (previous === null)
|
|
239
|
+
continue;
|
|
240
|
+
if (ENFORCEMENT_RANK[next.status] <= ENFORCEMENT_RANK[previous.status])
|
|
241
|
+
continue;
|
|
242
|
+
downgrades.push({
|
|
243
|
+
surface: after.surface,
|
|
244
|
+
policyId: next.policyId,
|
|
245
|
+
from: previous.status,
|
|
246
|
+
to: next.status,
|
|
247
|
+
mechanism: next.mechanism,
|
|
248
|
+
reason: next.degradationReason ?? 'the entry recorded no reason',
|
|
249
|
+
at: next.verifiedAt,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return downgrades;
|
|
253
|
+
}
|