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.
Files changed (58) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/README.md +99 -4
  3. package/package.json +4 -3
  4. package/packages/cli/dist/commands/init.js +43 -7
  5. package/packages/cli/dist/commands/memory.js +182 -0
  6. package/packages/cli/dist/commands/setup.js +45 -0
  7. package/packages/cli/dist/commands/upgrade.js +22 -4
  8. package/packages/cli/dist/index.js +110 -3
  9. package/packages/cli/dist/lib/manifest.js +23 -5
  10. package/packages/cli/dist/lib/subsystems.js +269 -0
  11. package/packages/cli/dist/lib/version.js +15 -0
  12. package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
  13. package/packages/cli/dist/policy/core/coverage.js +253 -0
  14. package/packages/cli/dist/policy/core/decision-record.js +130 -44
  15. package/packages/cli/dist/policy/core/declaration.js +58 -17
  16. package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
  17. package/packages/cli/dist/policy/core/probe.js +442 -0
  18. package/packages/cli/dist/policy/core/validation.js +194 -1
  19. package/packages/cli/dist/policy/core/vocabulary.js +70 -3
  20. package/packages/cli/dist/policy/harness/claude.js +9 -1
  21. package/packages/cli/dist/policy/harness/codex.js +48 -1
  22. package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
  23. package/packages/cli/dist/policy/index.js +9 -2
  24. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
  25. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
  26. package/templates/agent-os/subagent-routing.json +32 -0
  27. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
  31. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
  32. package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
  33. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
  34. package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
  35. package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
  37. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
  40. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
  41. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +269 -32
  42. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
  43. package/templates/agent-os/universal/.claude/settings.json +16 -0
  44. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
  45. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
  46. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
  47. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
  48. package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
  49. package/templates/agent-os/universal/.codex/config.toml +3 -0
  50. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
  51. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
  52. package/templates/agent-os/universal/layers.json +4 -0
  53. package/templates/hash-history.json +71 -22
  54. package/templates/release-ledger.json +3 -1
  55. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
  56. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
  57. package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
  58. 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
+ }