agent-control-plane-core 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -167,6 +167,12 @@ The harness pins both directions and rejects a vacuous suite:
167
167
  3. The fixture set collectively renders an `allow`, a `deny`, an `ask`, **and** a
168
168
  `mutated_input` — so a suite can't pass while skipping a decision the contract
169
169
  requires every adapter to express.
170
+ 4. A deny the adapter cannot enforce (`this_call_vetoable: false`) must render as
171
+ something OTHER than that host's allow signal — an ask, or an advisory body.
172
+ The harness probes this itself on a non-vetoable variant of every pre-tool
173
+ fixture event, so an adapter that collapses the objection onto "run it" fails
174
+ even with no non-vetoable fixture of its own. `observe_only` renders are
175
+ exempt: that transport has no pre-emption channel to differ in.
170
176
 
171
177
  See `src/fixtures/claude.json` and `src/fixtures/codex.json` for the format.
172
178
 
@@ -95,6 +95,48 @@ export function renderHookResponse(
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * A one-millisecond synchronous sleep buffer for the EAGAIN retry in
100
+ * {@link writeAllSync}. Allocated once at module scope: the retry path must not
101
+ * allocate per iteration while it is draining a full pipe.
102
+ */
103
+ const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
104
+
105
+ /**
106
+ * Write `text` to `fd` IN FULL, looping until every byte lands.
107
+ *
108
+ * A single `writeSync` is not enough. When the host captures the hook, fd 1/2 is
109
+ * a pipe, and libuv puts that pipe in NON-BLOCKING mode the moment anything
110
+ * initializes `process.stdout`/`process.stderr` (a `console.log` in a judge, the
111
+ * fail-safe stderr diagnostic in {@link renderHookResponse}). A non-blocking
112
+ * `write(2)` returns a SHORT COUNT once the kernel pipe buffer fills — measured
113
+ * at ~143 KiB here — so a deny body larger than that (a long `reason`, a big
114
+ * `mutated_input`) was silently truncated and the process still exited 0/2 with
115
+ * a half-written JSON the host cannot parse: an enforced deny degrading to a
116
+ * run. Looping restores the blocking-write semantics the caller assumes.
117
+ * @param {number} fd
118
+ * @param {string} text
119
+ */
120
+ function writeAllSync(fd, text) {
121
+ const buf = Buffer.from(text, "utf8");
122
+ let written = 0;
123
+ while (written < buf.length) {
124
+ try {
125
+ written += writeSync(fd, buf, written);
126
+ } catch (err) {
127
+ // EAGAIN is the one recoverable case: the non-blocking pipe is momentarily
128
+ // full because the host has not drained it yet. Sleep 1ms and retry — the
129
+ // same wait a blocking write would have done in the kernel. Every other
130
+ // errno (EPIPE, EBADF, ...) propagates. There is deliberately no retry cap:
131
+ // a cap would reintroduce exactly the silent truncation this loop exists to
132
+ // kill, and a host that never reads would have blocked us forever anyway.
133
+ if (!(err instanceof Error) || /** @type {any} */ (err).code !== "EAGAIN")
134
+ throw err;
135
+ Atomics.wait(RETRY_SLEEP, 0, 0, 1);
136
+ }
137
+ }
138
+ }
139
+
98
140
  /**
99
141
  * Emit a {@link NativeResponse} to the host: write the native stdout body when
100
142
  * the transport has one, then exit with the transport's exit code.
@@ -102,18 +144,18 @@ export function renderHookResponse(
102
144
  * @returns {never}
103
145
  */
104
146
  export function emit(response) {
105
- // `writeSync` (not `process.stdout.write`) so the body is flushed to fd 1
147
+ // `writeAllSync` (not `process.stdout.write`) so the body is flushed to fd 1
106
148
  // BEFORE `process.exit`. On a pipe — which stdout is when the host captures the
107
149
  // hook — `process.stdout.write` is asynchronous and `process.exit` does not
108
150
  // drain it, so an enforced-deny body (Claude/Codex `hookSpecificOutput`) could
109
151
  // be truncated; the host then reads a block-less exit and runs the tool. A
110
- // synchronous write closes that silent deny→allow window.
152
+ // synchronous, fully-drained write closes that silent deny→allow window.
111
153
  if (response.stdout !== undefined)
112
- writeSync(1, JSON.stringify(response.stdout));
154
+ writeAllSync(1, JSON.stringify(response.stdout));
113
155
  // A host that reads the block reason from STDERR (Gemini's exit-2 System Block)
114
156
  // gets it here — written synchronously to fd 2 before exit, for the same
115
157
  // flush-before-exit reason as stdout above, so an enforced deny is never shown
116
158
  // with no rationale.
117
- if (response.stderr !== undefined) writeSync(2, response.stderr);
159
+ if (response.stderr !== undefined) writeAllSync(2, response.stderr);
118
160
  process.exit(response.exit_code);
119
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -20,6 +20,7 @@ import {
20
20
  classifyCallClass,
21
21
  coverageAllowsVeto,
22
22
  canonicalTool,
23
+ lookup,
23
24
  makeEvent,
24
25
  normalizeVerdict,
25
26
  nativeResponse,
@@ -87,6 +88,49 @@ export function parse(native) {
87
88
  });
88
89
  }
89
90
 
91
+ /**
92
+ * The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
93
+ * pure exit-code transport: 0 allow / 1 ask / 2 reject.
94
+ *
95
+ * Written as a total table rather than a ternary chain because the chain's
96
+ * fall-through case was ALLOW: an unenforceable deny — a deny on a call this
97
+ * guardrail cannot veto — silently rendered as exit 0, Amp's "run it". A
98
+ * combination this table forgets is a construction error (below), not a silent
99
+ * approval.
100
+ *
101
+ * The non-vetoable DENY row is exit 1 (ask), not 0. Amp has a real ask tier, so
102
+ * the honest render of "I object but cannot block" is to put the call in front
103
+ * of the human rather than wave it through. It is still `enforced: false` — the
104
+ * guardrail is not claiming a veto it does not have; it is declining to spend
105
+ * its one remaining signal on an approval.
106
+ *
107
+ * @type {Readonly<Record<string, Readonly<Record<string, number>>>>}
108
+ */
109
+ const EXIT_CODE_BY_DECISION = Object.freeze({
110
+ [Decision.ALLOW]: Object.freeze({ true: 0, false: 0 }),
111
+ [Decision.DENY]: Object.freeze({ true: 2, false: 1 }),
112
+ [Decision.ASK]: Object.freeze({ true: 1, false: 1 }),
113
+ });
114
+
115
+ /**
116
+ * Totality check at IMPORT: every {@link Decision} must have a row and every row
117
+ * both vetoable columns. A decision added to the contract breaks this module
118
+ * loudly at load instead of silently defaulting to exit 0.
119
+ */
120
+ for (const decision of Object.values(Decision)) {
121
+ const row = lookup(EXIT_CODE_BY_DECISION, decision);
122
+ if (row === undefined)
123
+ throw new Error(
124
+ `amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
125
+ );
126
+ for (const vetoable of ["true", "false"]) {
127
+ if (typeof lookup(row, vetoable) === "number") continue;
128
+ throw new Error(
129
+ `amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
130
+ );
131
+ }
132
+ }
133
+
90
134
  /**
91
135
  * Render into Amp's pure exit-code transport: the decision is the exit code,
92
136
  * with no stdout body. `reason` has no native channel here, so it is dropped
@@ -99,8 +143,28 @@ export function parse(native) {
99
143
  */
100
144
  export function render(verdict, event) {
101
145
  const vd = normalizeVerdict(verdict);
102
- const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
103
- const exit_code = enforced ? 2 : vd.decision === Decision.ASK ? 1 : 0;
146
+ const vetoable = event.this_call_vetoable;
147
+ // `makeEvent` already rejects a non-boolean, so reaching this means a
148
+ // hand-built event — and guessing whether it can be vetoed is how fail-open
149
+ // starts. The string "true" is the case that makes this a real check rather
150
+ // than a formality: it would otherwise index the vetoable column.
151
+ if (typeof vetoable !== "boolean")
152
+ throw new Error(
153
+ `amp adapter: this_call_vetoable must be a boolean, got ${JSON.stringify(vetoable)}`,
154
+ );
155
+ const enforced = vd.decision === Decision.DENY && vetoable;
156
+ const exit_code = lookup(
157
+ lookup(EXIT_CODE_BY_DECISION, vd.decision) ?? {},
158
+ String(vetoable),
159
+ );
160
+ // Unreachable while the import-time totality check and `normalizeVerdict` both
161
+ // hold; kept because the alternative to throwing is `exit_code: undefined`,
162
+ // which `process.exit` renders as 0 — the exact silent allow this table exists
163
+ // to eliminate.
164
+ if (exit_code === undefined)
165
+ throw new Error(
166
+ `amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
167
+ );
104
168
  return nativeResponse({ transport: INTEGRATION_MODE, exit_code, enforced });
105
169
  }
106
170
 
@@ -1,5 +1,8 @@
1
1
  import {
2
2
  CALL_CLASSES,
3
+ Decision,
4
+ EventKind,
5
+ IntegrationMode,
3
6
  MODELED_TOOL_INPUT_KEYS,
4
7
  TOOL_ALIASES,
5
8
  canonicalTool,
@@ -8,6 +11,16 @@ import {
8
11
  lookup,
9
12
  } from "./control-plane.mjs";
10
13
 
14
+ /**
15
+ * The two probe verdicts rule ⑧ renders against a non-vetoable variant of each
16
+ * pre-tool fixture event. Frozen so a mutating adapter cannot poison later cases.
17
+ */
18
+ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
19
+ decision: Decision.DENY,
20
+ reason: "conformance probe: unenforceable deny",
21
+ });
22
+ const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
23
+
11
24
  /**
12
25
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
13
26
  * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
@@ -66,14 +79,17 @@ export function assertToolAliasesCovered(
66
79
  let witnessed = witnessedByAgent.get(fixtures.agent);
67
80
  if (witnessed === undefined)
68
81
  witnessedByAgent.set(fixtures.agent, (witnessed = new Map()));
69
- const scopedMap = adapterAliases[fixtures.agent] ?? {};
82
+ // `fixtures.agent` and the native tool names below are ADAPTER-SUPPLIED
83
+ // strings; a bare index would resolve `constructor`/`__proto__` to an
84
+ // inherited Object.prototype member instead of missing. See `lookup`.
85
+ const scopedMap = lookup(adapterAliases, fixtures.agent) ?? {};
70
86
  for (const testCase of fixtures.cases) {
71
87
  const nativeTool = testCase.event?.meta?.native_tool;
72
88
  if (typeof nativeTool !== "string") continue;
73
89
  const canon = testCase.event.tool;
74
90
  const allowed = new Set([canonicalTool(nativeTool)]);
75
- if (scopedMap[nativeTool] !== undefined)
76
- allowed.add(scopedMap[nativeTool]);
91
+ const scopedTarget = lookup(scopedMap, nativeTool);
92
+ if (scopedTarget !== undefined) allowed.add(scopedTarget);
77
93
  assert.ok(
78
94
  allowed.has(canon),
79
95
  `fixture '${testCase.name}' (${fixtures.agent}): native_tool ${JSON.stringify(nativeTool)} normalized to ${JSON.stringify(canon)}, but only ${JSON.stringify([...allowed])} are valid canonicalizations for this agent`,
@@ -124,23 +140,34 @@ export function assertToolAliasesCovered(
124
140
  * @param {any} assert node:assert/strict (injected)
125
141
  */
126
142
  export function assertAliasedInputsCanonical(fixturesList, assert) {
127
- for (const fixtures of fixturesList) {
128
- for (const testCase of fixtures.cases) {
129
- const nativeTool = testCase.event?.meta?.native_tool;
130
- const canon = testCase.event?.tool;
131
- if (typeof nativeTool !== "string" || nativeTool === canon) continue;
132
- const key = lookup(
133
- /** @type {Record<string, string>} */ (MODELED_TOOL_INPUT_KEYS),
134
- canon,
135
- );
136
- if (key === undefined) continue;
137
- assert.ok(
138
- testCase.event.input !== undefined &&
139
- Object.hasOwn(testCase.event.input, key),
140
- `fixture '${testCase.name}' (${fixtures.agent}): ${JSON.stringify(nativeTool)} was canonicalized to ${JSON.stringify(canon)}, which advertises input.${key}, but the input carries ${JSON.stringify(Object.keys(testCase.event.input ?? {}))} — rename the input keys or drop the alias`,
141
- );
142
- }
143
- }
143
+ for (const fixtures of fixturesList)
144
+ for (const testCase of fixtures.cases)
145
+ assertAliasedInput(fixtures.agent, testCase.name, testCase.event, assert);
146
+ }
147
+
148
+ /**
149
+ * The per-event half of {@link assertAliasedInputsCanonical}, shared with
150
+ * {@link runAdapterConformance} so the rule holds against a LIVE parse and not
151
+ * only against the fixture file.
152
+ * @param {string} agent
153
+ * @param {string} caseName
154
+ * @param {any} event a normalized ToolCallEvent
155
+ * @param {any} assert node:assert/strict (injected)
156
+ */
157
+ function assertAliasedInput(agent, caseName, event, assert) {
158
+ const nativeTool = event?.meta?.native_tool;
159
+ const canon = event?.tool;
160
+ // Only a RENAME makes the promise; a native-named tool advertises nothing.
161
+ if (typeof nativeTool !== "string" || nativeTool === canon) return;
162
+ const key = lookup(
163
+ /** @type {Record<string, string>} */ (MODELED_TOOL_INPUT_KEYS),
164
+ canon,
165
+ );
166
+ if (key === undefined) return;
167
+ assert.ok(
168
+ event.input !== undefined && Object.hasOwn(event.input, key),
169
+ `'${caseName}' (${agent}): ${JSON.stringify(nativeTool)} was canonicalized to ${JSON.stringify(canon)}, which advertises input.${key}, but the input carries ${JSON.stringify(Object.keys(event.input ?? {}))} — rename the input keys or drop the alias`,
170
+ );
144
171
  }
145
172
 
146
173
  /**
@@ -184,13 +211,21 @@ export function assertAliasedInputsCanonical(fixturesList, assert) {
184
211
  * permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
185
212
  * `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
186
213
  * while parsing its calls as vetoable.
214
+ * 8. unenforceable deny ≠ allow: for every pre-tool fixture event, rendering a
215
+ * DENY over a non-vetoable variant of that event must produce a response
216
+ * that differs from the same adapter's abstaining `allow` render. Rule ⑤
217
+ * only says such a deny is not `enforced`; without this, an adapter is free
218
+ * to collapse it onto the host's literal "run it" signal (Amp's exit 0),
219
+ * throwing away the objection entirely. The strongest honest signal a
220
+ * transport has left — an ask, an advisory body — must survive. An
221
+ * OBSERVE_ONLY render is exempt: it has no pre-emption channel to differ in.
187
222
  *
188
223
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
189
224
  * neutral; it throws on the first mismatch. Returns a summary the caller can
190
225
  * assert further on.
191
226
  *
192
227
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
193
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string> }}
228
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
194
229
  */
195
230
  export function runAdapterConformance({ adapter, fixtures, assert }) {
196
231
  assert.equal(
@@ -208,6 +243,8 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
208
243
  let mutationSeen = false;
209
244
  let enforcedDenySeen = false;
210
245
  let vetoableDenySeen = false;
246
+ let preToolCases = 0;
247
+ let unenforceableDenyChecks = 0;
211
248
  let renders = 0;
212
249
 
213
250
  for (const testCase of fixtures.cases) {
@@ -217,17 +254,26 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
217
254
  testCase.event,
218
255
  `parse mismatch: ${testCase.name}`,
219
256
  );
257
+ // Against what parse ACTUALLY produced, not the fixture's copy of it. The
258
+ // deepEqual above ties the two today, but it is the fixture that gets
259
+ // updated when an adapter changes — so checking the fixture alone would let
260
+ // a rename-without-its-input land by editing the expectation.
261
+ assertAliasedInput(adapter.AGENT, testCase.name, parsed, assert);
220
262
 
221
263
  if (testCase.call_class !== undefined) {
222
264
  assert.ok(
223
265
  CALL_CLASSES.includes(testCase.call_class),
224
266
  `unknown call_class '${testCase.call_class}': ${testCase.name}`,
225
267
  );
226
- if (!coverageAllowsVeto(adapter.COVERAGE[testCase.call_class])) {
268
+ // Fixture-supplied key ⇒ prototype-safe read (see `lookup`): a
269
+ // `call_class` naming an Object.prototype member must MISS, not resolve a
270
+ // function that `coverageAllowsVeto` would then reject or accept by luck.
271
+ const coverage = lookup(adapter.COVERAGE, testCase.call_class);
272
+ if (!coverageAllowsVeto(coverage)) {
227
273
  assert.equal(
228
274
  parsed.this_call_vetoable,
229
275
  false,
230
- `call_class '${testCase.call_class}' is ${adapter.COVERAGE[testCase.call_class]} (no veto) but parsed this_call_vetoable !== false: ${testCase.name}`,
276
+ `call_class '${testCase.call_class}' is ${coverage} (no veto) but parsed this_call_vetoable !== false: ${testCase.name}`,
231
277
  );
232
278
  }
233
279
  coverageClassesChecked.add(testCase.call_class);
@@ -291,6 +337,38 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
291
337
  if (spec.verdict.mutated_input !== undefined) mutationSeen = true;
292
338
  renders += 1;
293
339
  }
340
+
341
+ // Rule ⑧, probed on a synthesized non-vetoable variant rather than on a
342
+ // fixture flag: an adapter whose COVERAGE marks every reachable call class
343
+ // as gated can never PARSE a non-vetoable event, so a fixture-driven check
344
+ // would sit vacuous on exactly the adapters this rule exists for. Only
345
+ // pre-tool events are probed — a post-tool/prompt event has no veto to lose,
346
+ // so its deny and allow renders may legitimately coincide.
347
+ if (parsed.event !== EventKind.PRE_TOOL) continue;
348
+ preToolCases += 1;
349
+ const unenforceable = { ...parsed, this_call_vetoable: false };
350
+ const deniedRender = adapter.render(
351
+ UNENFORCEABLE_DENY_PROBE,
352
+ unenforceable,
353
+ );
354
+ assert.equal(
355
+ deniedRender.enforced,
356
+ false,
357
+ `unenforceable deny rendered as enforced: ${testCase.name}`,
358
+ );
359
+ // Counted before the OBSERVE_ONLY exemption: the honesty half above ran, so
360
+ // an adapter whose every pre-tool case is observe-only has still been
361
+ // checked and must not fail the non-vacuity assertion below.
362
+ unenforceableDenyChecks += 1;
363
+ // An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
364
+ // no pre-emption channel at all, so its deny and allow are identical by
365
+ // construction. Demanding a distinct signal there would demand a fiction.
366
+ if (deniedRender.transport === IntegrationMode.OBSERVE_ONLY) continue;
367
+ assert.notDeepEqual(
368
+ deniedRender,
369
+ adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
370
+ `unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
371
+ );
294
372
  }
295
373
 
296
374
  for (const decision of ["allow", "deny", "ask"]) {
@@ -308,6 +386,15 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
308
386
  "no enforced deny rendered — enforcement honesty is untested",
309
387
  );
310
388
  assert.ok(renders > 0, "conformance fixtures render nothing");
389
+ // EVERY pre-tool case, not merely one: the count is what catches rule ⑧ being
390
+ // skipped by a stray `continue` rather than merely present somewhere. A suite
391
+ // with no pre-tool case has no veto surface to lose, and rule ⑤'s own
392
+ // `vetoableDenySeen` guard already forces enforcement honesty there.
393
+ assert.equal(
394
+ unenforceableDenyChecks,
395
+ preToolCases,
396
+ `rule ⑧ (unenforceable deny ≠ allow) ran on ${unenforceableDenyChecks} of ${preToolCases} pre-tool cases`,
397
+ );
311
398
 
312
399
  return {
313
400
  cases: fixtures.cases.length,
@@ -317,5 +404,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
317
404
  enforcedDenySeen,
318
405
  vetoableDenySeen,
319
406
  coverageClassesChecked,
407
+ unenforceableDenyChecks,
320
408
  };
321
409
  }
@@ -242,11 +242,15 @@ const COVERAGE_STATUS_VALUES = new Set(Object.values(CoverageStatus));
242
242
  * forbid it — an unknown is fail-closed to uncovered, which is the whole point
243
243
  * of the matrix. Throws on an unrecognized status (fail loud — a typo must not
244
244
  * quietly read as "permitted").
245
- * @param {string} status a {@link CoverageStatus} value
245
+ *
246
+ * `undefined` is accepted as an INPUT type — that is what a prototype-safe
247
+ * `lookup` of a missing key yields — and takes the same throw: a coverage a
248
+ * caller could not resolve must never read as "permitted" either.
249
+ * @param {string|undefined} status a {@link CoverageStatus} value
246
250
  * @returns {boolean}
247
251
  */
248
252
  export function coverageAllowsVeto(status) {
249
- if (!COVERAGE_STATUS_VALUES.has(status)) {
253
+ if (!COVERAGE_STATUS_VALUES.has(/** @type {string} */ (status))) {
250
254
  throw new Error(
251
255
  `control-plane: invalid coverage status ${JSON.stringify(status)}`,
252
256
  );
@@ -89,13 +89,21 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
89
89
  * permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
90
90
  * `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
91
91
  * while parsing its calls as vetoable.
92
+ * 8. unenforceable deny ≠ allow: for every pre-tool fixture event, rendering a
93
+ * DENY over a non-vetoable variant of that event must produce a response
94
+ * that differs from the same adapter's abstaining `allow` render. Rule ⑤
95
+ * only says such a deny is not `enforced`; without this, an adapter is free
96
+ * to collapse it onto the host's literal "run it" signal (Amp's exit 0),
97
+ * throwing away the objection entirely. The strongest honest signal a
98
+ * transport has left — an ask, an advisory body — must survive. An
99
+ * OBSERVE_ONLY render is exempt: it has no pre-emption channel to differ in.
92
100
  *
93
101
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
94
102
  * neutral; it throws on the first mismatch. Returns a summary the caller can
95
103
  * assert further on.
96
104
  *
97
105
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
98
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string> }}
106
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
99
107
  */
100
108
  export function runAdapterConformance({ adapter, fixtures, assert }: {
101
109
  adapter: import("./control-plane.mjs").Adapter;
@@ -109,4 +117,5 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
109
117
  enforcedDenySeen: boolean;
110
118
  vetoableDenySeen: boolean;
111
119
  coverageClassesChecked: Set<string>;
120
+ unenforceableDenyChecks: number;
112
121
  };
@@ -38,10 +38,14 @@ export function lookup<T>(map: Record<string, T>, key: string): T | undefined;
38
38
  * forbid it — an unknown is fail-closed to uncovered, which is the whole point
39
39
  * of the matrix. Throws on an unrecognized status (fail loud — a typo must not
40
40
  * quietly read as "permitted").
41
- * @param {string} status a {@link CoverageStatus} value
41
+ *
42
+ * `undefined` is accepted as an INPUT type — that is what a prototype-safe
43
+ * `lookup` of a missing key yields — and takes the same throw: a coverage a
44
+ * caller could not resolve must never read as "permitted" either.
45
+ * @param {string|undefined} status a {@link CoverageStatus} value
42
46
  * @returns {boolean}
43
47
  */
44
- export function coverageAllowsVeto(status: string): boolean;
48
+ export function coverageAllowsVeto(status: string | undefined): boolean;
45
49
  /**
46
50
  * True when `status` is a recognized {@link CoverageStatus} value.
47
51
  * @param {unknown} status