Skip to content

Commit 45dbf2a

Browse files
committed
feat(testing): fold field_absent + envelope_field_absent into #1045
Per @bokelley's review comment: both checks share the same handler path as `envelope_field_present`, so the runtime delta is a single `validateFieldAbsent` function and two `case` entries in the switch. - `field_absent` / `envelope_field_absent` added to `StoryboardValidationCheck` type union - `validateFieldAbsent`: passes when path is absent (undefined/null), fails when present — mirrors `validateFieldPresent` semantics inverted - Drift detector: both checks collected by `collectFieldValidations` but skip reachability assertions (absence checks have no schema target) - 6 new tests in `storyboard-validations.test.js` (pass/fail/no-path for both `field_absent` and `envelope_field_absent`) - Changeset updated to list all five new check types; removes the "future PR" note since they ship here Unlocks the `v3-envelope-integrity.yaml` TODO block's MUST-NOT assertions (`task_status`/`response_status`) without a second SDK release. https://claude.ai/code/session_01R6VkP124L1RGqVART85gxo
1 parent e0c7551 commit 45dbf2a

5 files changed

Lines changed: 152 additions & 5 deletions

File tree

‎.changeset/envelope-field-present.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,20 @@ feat(testing): add envelope-scoped storyboard validation checks
66

77
Storyboards that assert v3 envelope-level fields (`status`, `task_id`, `message`, `replayed`, `governance_context`, `timestamp`, `context_id`, `push_notification_config`) need a way to tell static drift detection to walk `protocol-envelope.json` instead of the per-tool response schema. The previous un-prefixed checks pointed at the inner response schema, which doesn't contain envelope fields, so the `v3-envelope-integrity.yaml` storyboard required a `VERIFIER_UNREACHABLE` exemption.
88

9-
Adds three envelope-scoped `StoryboardValidationCheck` values:
9+
Adds five new `StoryboardValidationCheck` values:
1010

11+
- `field_absent` — passes when the path is absent; fails when present (companion to `field_present`)
12+
- `envelope_field_absent` — envelope-scoped companion to `field_absent`; signals drift detection to walk `protocol-envelope.json`; absence checks skip reachability assertions by design
1113
- `envelope_field_present` — companion to `field_present`
1214
- `envelope_field_value` — companion to `field_value`
1315
- `envelope_field_value_or_absent` — companion to `field_value_or_absent`
1416

1517
**Runtime**: identical semantics to the un-prefixed checks — `TaskResult` already exposes envelope fields at its surface (`data.status`, `data.task_id`, etc.), so the dispatcher passes through to the existing handlers. Result objects report the original check name verbatim so reporters can distinguish. The same passthrough lands in `scripts/conformance-replay.ts` so storyboard replay grades the new checks.
1618

17-
**Drift detection**: walks `ProtocolEnvelopeSchema` (from `core/protocol-envelope.json`) instead of `TOOL_RESPONSE_SCHEMAS[task]` for envelope-scoped entries. The existing un-prefixed checks stay pinned to inner-response schemas.
19+
**Drift detection**: walks `ProtocolEnvelopeSchema` (from `core/protocol-envelope.json`) instead of `TOOL_RESPONSE_SCHEMAS[task]` for envelope-scoped entries. `field_absent` and `envelope_field_absent` are collected by the drift detector but skip reachability assertions — absence checks have no schema target by design.
1820

1921
**Not envelope fields**: `errors` lives inside `payload` (per the per-tool response schema), and `adcp_version` / `adcp_major_version` are request-side only — these stay on the un-prefixed checks.
2022

21-
Forward-compatible with the current 3.0.1 storyboards (no consumers yet). Lights up when the upstream PR migrates `v3-envelope-integrity.yaml` from `field_present: status` to `envelope_field_present: status` (the `VERIFIER_UNREACHABLE` exemption gets dropped after the next `npm run sync-schemas` post-3.0.2).
22-
23-
A future PR will add `field_absent` + `envelope_field_absent` runner support so `v3-envelope-integrity.yaml` can land its currently-TODO `task_status` / `response_status` MUST-NOT assertions.
23+
Forward-compatible with the current 3.0.1 storyboards. Lights up when the upstream PR migrates `v3-envelope-integrity.yaml` from `field_present: status` to `envelope_field_present: status` (the `VERIFIER_UNREACHABLE` exemption gets dropped after the next `npm run sync-schemas` post-3.0.2). The `task_status` / `response_status` MUST-NOT assertions in `v3-envelope-integrity.yaml` can now land using `field_absent` / `envelope_field_absent` without a further SDK release.
2424

2525
Refs adcp#3429.

‎src/lib/testing/storyboard/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ export interface StoryboardStep {
401401
export type StoryboardValidationCheck =
402402
| 'response_schema'
403403
| 'field_present'
404+
| 'field_absent'
404405
// Envelope-scoped variants — the asserted path lives on the v3 protocol
405406
// envelope (`status`, `task_id`, `message`, `replayed`, `governance_context`,
406407
// `timestamp`, `context_id`, `push_notification_config`) rather than the
@@ -409,6 +410,7 @@ export type StoryboardValidationCheck =
409410
// for static drift detection, which walks `protocol-envelope.json` instead
410411
// of the per-tool response schema. Added per adcp#3429.
411412
| 'envelope_field_present'
413+
| 'envelope_field_absent'
412414
| 'envelope_field_value'
413415
| 'envelope_field_value_or_absent'
414416
| 'field_value'

‎src/lib/testing/storyboard/validations.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ function runValidation(validation: StoryboardValidation, ctx: ValidationContext)
112112
// field_present runs against either MCP task result data OR an HTTP probe body —
113113
// the storyboard's probe_protected_resource validates fields in the RFC 9728 JSON.
114114
return validateFieldPresent(validation, resolveTarget(ctx));
115+
case 'field_absent':
116+
return validateFieldAbsent(validation, resolveTarget(ctx));
115117
case 'envelope_field_present':
118+
case 'envelope_field_absent':
116119
case 'envelope_field_value':
117120
case 'envelope_field_value_or_absent':
118121
// Envelope-scoped variants — runtime semantics identical to the
@@ -121,6 +124,7 @@ function runValidation(validation: StoryboardValidation, ctx: ValidationContext)
121124
// exist primarily so static drift detection can walk the envelope
122125
// schema instead of the per-tool response. See adcp#3429.
123126
if (validation.check === 'envelope_field_present') return validateFieldPresent(validation, resolveTarget(ctx));
127+
if (validation.check === 'envelope_field_absent') return validateFieldAbsent(validation, resolveTarget(ctx));
124128
if (validation.check === 'envelope_field_value') return validateFieldValue(validation, resolveTarget(ctx));
125129
return validateFieldValueOrAbsent(validation, resolveTarget(ctx));
126130
case 'field_value':
@@ -550,6 +554,57 @@ function validateFieldPresent(validation: StoryboardValidation, taskResult: Task
550554
};
551555
}
552556

557+
// ────────────────────────────────────────────────────────────
558+
// field_absent / envelope_field_absent: check a path does NOT exist
559+
//
560+
// Pass when the field is absent (undefined or null). Fail when the field
561+
// is present with any value. The `envelope_field_absent` variant carries
562+
// the same runtime semantics but signals to the drift detector that the
563+
// path lives on the v3 envelope schema rather than the per-tool response.
564+
// Added per adcp#3429 alongside the `envelope_field_present` family.
565+
// ────────────────────────────────────────────────────────────
566+
567+
function validateFieldAbsent(validation: StoryboardValidation, taskResult: TaskResult): ValidationResult {
568+
const checkName = validation.check;
569+
if (!validation.path) {
570+
return {
571+
check: checkName,
572+
passed: false,
573+
description: validation.description,
574+
path: validation.path,
575+
error: `No path specified for ${checkName} validation`,
576+
json_pointer: null,
577+
expected: 'path must be set in storyboard validation entry',
578+
actual: null,
579+
};
580+
}
581+
582+
const value = resolvePath(taskResult.data, validation.path);
583+
const absent = value === undefined || value === null;
584+
const pointer = toJsonPointer(validation.path);
585+
586+
if (absent) {
587+
return {
588+
check: checkName,
589+
passed: true,
590+
description: validation.description,
591+
path: validation.path,
592+
json_pointer: pointer,
593+
};
594+
}
595+
596+
return {
597+
check: checkName,
598+
passed: false,
599+
description: validation.description,
600+
path: validation.path,
601+
error: `Field found at path: ${validation.path} (expected absent)`,
602+
json_pointer: pointer,
603+
expected: null,
604+
actual: value,
605+
};
606+
}
607+
553608
// ────────────────────────────────────────────────────────────
554609
// field_value: check a path equals expected value
555610
// ────────────────────────────────────────────────────────────

‎test/lib/storyboard-drift.test.js‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ function collectFieldValidations(storyboards) {
214214
for (const v of step.validations) {
215215
if (
216216
(v.check === 'field_present' ||
217+
v.check === 'field_absent' ||
217218
v.check === 'envelope_field_present' ||
219+
v.check === 'envelope_field_absent' ||
218220
v.check === 'field_value' ||
219221
v.check === 'envelope_field_value' ||
220222
v.check === 'field_value_or_absent' ||
@@ -325,13 +327,31 @@ describe('storyboard schema drift', () => {
325327
}
326328
});
327329

330+
describe('field_absent / envelope_field_absent are collected but skip reachability', () => {
331+
// Absence checks have no schema target by design — a `field_absent` assertion
332+
// validates that a path does NOT exist, so there is no schema field to walk.
333+
// We still collect them in collectFieldValidations (above) so a future sweep
334+
// can cross-reference storyboard intent, but we do not assert reachability.
335+
const absentValidations = fieldValidations.filter(
336+
v => v.check === 'field_absent' || v.check === 'envelope_field_absent'
337+
);
338+
it('absence-check validations are collected without reachability assertions', () => {
339+
// Structural smoke-test: if any are present, they must have a path.
340+
for (const entry of absentValidations) {
341+
assert.ok(entry.path, `${entry.storyboard}/${entry.step}: field_absent entry missing path`);
342+
}
343+
});
344+
});
345+
328346
describe('envelope-scoped validations resolve in the v3 envelope schema', () => {
329347
// adcp#3429: storyboards assert envelope-level fields (`status`,
330348
// `task_id`, `message`, `replayed`, `governance_context`, `timestamp`,
331349
// `context_id`, `push_notification_config`) using the envelope-scoped
332350
// checks so the drift detector knows to walk `protocol-envelope.json`
333351
// rather than the per-tool response schema. `errors` and `adcp_version`
334352
// are NOT envelope fields — keep them on the un-prefixed checks.
353+
// `envelope_field_absent` is excluded here — absence checks have no schema
354+
// target (see the `field_absent / envelope_field_absent` block above).
335355
const envelopeValidations = fieldValidations.filter(
336356
v =>
337357
v.check === 'envelope_field_present' ||

‎test/lib/storyboard-validations.test.js‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,73 @@ describe('envelope_field_present (adcp#3429)', () => {
233233
assert.match(result.error, /No path specified for envelope_field_present/);
234234
});
235235
});
236+
237+
describe('field_absent / envelope_field_absent (adcp#3429)', () => {
238+
it('passes when the asserted path is absent from the task data', () => {
239+
const taskResult = { success: true, data: { status: 'completed' } };
240+
const [result] = runOne(
241+
[{ check: 'field_absent', path: 'legacy_status', description: 'legacy_status must not appear' }],
242+
'get_adcp_capabilities',
243+
taskResult
244+
);
245+
assert.strictEqual(result.passed, true, result.error);
246+
assert.strictEqual(result.check, 'field_absent');
247+
});
248+
249+
it('fails when the asserted path is present', () => {
250+
const taskResult = { success: true, data: { status: 'completed', legacy_status: 'active' } };
251+
const [result] = runOne(
252+
[{ check: 'field_absent', path: 'legacy_status', description: 'legacy_status must not appear' }],
253+
'get_adcp_capabilities',
254+
taskResult
255+
);
256+
assert.strictEqual(result.passed, false);
257+
assert.strictEqual(result.check, 'field_absent');
258+
assert.match(result.error, /Field found at path: legacy_status/);
259+
});
260+
261+
it('fails with no-path error when path is missing', () => {
262+
const taskResult = { success: true, data: { status: 'completed' } };
263+
const [result] = runOne(
264+
[{ check: 'field_absent', description: 'no path given' }],
265+
'get_adcp_capabilities',
266+
taskResult
267+
);
268+
assert.strictEqual(result.passed, false);
269+
assert.match(result.error, /No path specified for field_absent/);
270+
});
271+
272+
it('envelope_field_absent passes when envelope field is absent', () => {
273+
const taskResult = { success: true, data: { task_id: 'task-1' } };
274+
const [result] = runOne(
275+
[{ check: 'envelope_field_absent', path: 'legacy_status', description: 'envelope must not carry legacy_status' }],
276+
'get_adcp_capabilities',
277+
taskResult
278+
);
279+
assert.strictEqual(result.passed, true, result.error);
280+
assert.strictEqual(result.check, 'envelope_field_absent');
281+
});
282+
283+
it('envelope_field_absent fails when envelope field is present', () => {
284+
const taskResult = { success: true, data: { task_id: 'task-1', legacy_status: 'active' } };
285+
const [result] = runOne(
286+
[{ check: 'envelope_field_absent', path: 'legacy_status', description: 'envelope must not carry legacy_status' }],
287+
'get_adcp_capabilities',
288+
taskResult
289+
);
290+
assert.strictEqual(result.passed, false);
291+
assert.strictEqual(result.check, 'envelope_field_absent');
292+
assert.match(result.error, /Field found at path: legacy_status/);
293+
});
294+
295+
it('envelope_field_absent fails with no-path error when path is missing', () => {
296+
const taskResult = { success: true, data: { status: 'completed' } };
297+
const [result] = runOne(
298+
[{ check: 'envelope_field_absent', description: 'no path given' }],
299+
'get_adcp_capabilities',
300+
taskResult
301+
);
302+
assert.strictEqual(result.passed, false);
303+
assert.match(result.error, /No path specified for envelope_field_absent/);
304+
});
305+
});

0 commit comments

Comments
 (0)