fix: enforce portable upstream identifier paths#2058
Conversation
# Conflicts: # src/lib/testing/storyboard/runner.ts # src/lib/testing/storyboard/types.ts # test/lib/storyboard-runner-output-contract-v2-runner.test.js
There was a problem hiding this comment.
Right direction — replacing the silent $. strip with hard rejection is the witness-not-translator shape — but two things to fix before merging.
MUST FIX
-
Missing changeset. PR touches
src/lib/testing/storyboard/{path,runner,validations,types}.ts— published surface (dist/lib/**/*ships viapackage.json#files, andbin/adcp.jsimports the storyboard runner directly). CLAUDE.md is unambiguous: "ALWAYS create a changeset for ANY library/CLI code change before pushing a PR. This is mandatory." This is a behavior change for adopters whose storyboards currently use$.-prefixed paths (silently normalized today, authoring error after this lands), so the changeset should beminorand call out the break.npm run changeset. -
Cross-validation suppression in
prefetchUpstreamTraffic—src/lib/testing/storyboard/runner.ts:5123. One invalid path in anyupstream_trafficcheck returnsundefinedfrom the prefetch for the entire step, so sibling validations with valid paths fall throughvalidateUpstreamTraffic'sif (!upstream || !upstream.advertised)branch atvalidations.ts:2657and gradenot_applicablewith the misleading rationale "adopter did not advertise query_upstream_traffic." The invalid path is correctly flagged as authoring error atvalidations.ts:2631; the valid siblings should still run. Fix: either filter the prefetch input to only the checks whoseidentifier_pathsare all valid, or drop the early-return entirely and let the per-validation authoring-error branch handle the invalid ones (it already does). The author-facing diagnostic stays correct either way; the difference is whether unrelated valid assertions in the same step keep running.
Follow-ups (non-blocking — file as issues)
- Loader / runner gate disagreement.
src/lib/testing/storyboard/loader.ts:128still silently strips$.at storyboard load time. A storyboard authored with$.audiences[*].add[*].hashed_emailpasses the loader after silent normalization, then fails at validation with "must be request-payload-relative" — two gates, two opinions, confusing for authors.validateIdentifierPathSyntaxshould callvalidatePortableIdentifierPathso the loader and the runner agree on the grammar. some(path => validatePortableIdentifierPath(path))truthiness atrunner.ts:5123. Functionally correct (string truthy vsnullfalsy) but reads inverted at a glance and breaks if the return type ever moves to a discriminated object.validatePortableIdentifierPath(path) !== nullmakes the intent obvious.- Test gaps. No boundary at
MAX_PATH_LENGTH = 1024(exactly 1024 ok, 1025 rejected). NoresolvePortableIdentifierPathAlltest forobj === undefined/ empty arrays / missing intermediate keys. No assertion that__proto__/constructor/prototypeare rejected by name (they match the segment regex but should hitFORBIDDEN_KEYS). - Defense-in-depth comment.
FORBIDDEN_KEYS.has(segment.key)atpath.ts:201is unreachable after validation rejects those names, but worth a one-line comment matchingwalk()'s rationale so a future cleanup doesn't strip it.
Minor nits (non-blocking)
- JSDoc precision.
path.ts:154says "at most one[*]wildcard suffix per segment." The regex actually forbids[*]anywhere except the suffix. "At most one trailing[*]per segment" reads more precisely.
Things I checked
- Regex
/^[A-Za-z_][A-Za-z0-9_]*(?:\[\*\])?$/correctly rejectsaudiences[*]foo,audiences[*][*], trailing whitespace,foo$bar, empty segments. Leading$is caught before the per-segment loop. resolvePortableIdentifierPathAllcaps wildcard fan-out at every segment vianext.length >= RESOLVE_PATH_ALL_MAXinside the inner loop, plus the post-segment.slice(0, RESOLVE_PATH_ALL_MAX). No off-by-one. The cap test atRESOLVE_PATH_ALL_MAX + 5000items verifies this.- All ten invalid path forms in
storyboard-runner-output-contract-v2.test.js(explicit$., reserved roots, numeric indexes, bracket-quoted keys, empty segments, recursive descent) hitvalidateUpstreamTraffic's authoring-error early-return — good unhappy-path coverage. - Spec lineage: portable grammar derives from adcontextprotocol/adcp#3816. The prohibition on numeric indexes, bracket-quoted keys, and recursive descent is spec-grounded; the prohibition on
$.explicit roots andrequest.*/response.*/context.*reserved roots is not obviously spelled out in the spec body but is consistent with "paths resolve against the request payload root."
Request changes pending (1) and (2). Sign off after the changeset lands and prefetch suppression no longer masks valid sibling validations.
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Right shape on direction — kills the silent $.-prefix translator at the validator seam (normalizeStoryboardJsonPath was the textbook "translator, not witness" smell), short-circuits prefetch when the controller call would be wasted, and surfaces author bugs loudly with an invalid_identifier_paths field on the result.
Things I checked
src/lib/testing/storyboard/path.ts:137-180— new grammar correctly forbids$, leading/trailing/empty segments, bracket-quoted keys, numeric indexes, reserved roots, andFORBIDDEN_KEYS(__proto__/constructor/prototype). Own-property gate and theRESOLVE_PATH_ALL_MAX = 10000cap matchresolvePathAll's posture.src/lib/testing/storyboard/runner.ts:5123— short-circuit returnsundefinedso the controller is not queried when any path on the step is invalid. Verified by the new e2e test asserting zerocomply_test_controllercalls.src/lib/testing/storyboard/validations.ts:2628-2651— authoring-error result is constructed BEFORE the!upstream.advertisedopt-out branch, so adopters who haven't advertisedquery_upstream_trafficstill see the authoring error (not a silentnot_applicable).src/lib/testing/storyboard/validations.ts:2783— call site swapped fromresolveJsonPathLitetoresolvePortableIdentifierPathAll. Behavior matches for paths that are valid under the new grammar; invalid paths short-circuit two checks earlier so they never reach the resolver.normalizeStoryboardJsonPathis fully removed; no callers remain.resolveJsonPathLitecorrectly stays alive forpayload_must_contain.path(agent-payload traversal — different concern).- Changeset present and
patchis defensible (tightening to spec-intent, no new public exports leavingstoryboard/index.ts).
Follow-ups (non-blocking — file as issues)
- Confirm the runtime grammar admits spec-legal dashed identifier keys.
PORTABLE_IDENTIFIER_SEGMENT_REatpath.ts:137is[A-Za-z_][A-Za-z0-9_]*— no dash. The loader atloader.ts:22is[A-Za-z_][A-Za-z0-9_-]*— dash allowed. If the AdCP 3.1 normative grammar acceptscreative-id-style keys, the runtime regex needs the dash back, otherwise spec-legal storyboards gradestoryboard authoring errorat runtime. Quick spec re-check before adopters with kebab-case identifier keys hit it. - Update the PR body citation. Body cites
adcontextprotocol/adcp#3816, which is the anti-facade contract PR (theidentifier_pathsreplacement forbuyer_identifier_echo). The grammar pin lives in a later spec PR — update the reference so issue #2057's lineage is clean. - Align loader and runtime grammars. Loader accepts
$.foo, dashes, andfoo[*][*]; runtime rejects all three. Two layers now claim to enforce "portable grammar" with two different definitions. Tighten the loader to the runtime grammar (preferred — fail-load is louder than fail-grade) so authors don't ship YAML that passessync-schemasand dies in CI. - Sibling-validation downgrade carries a misleading note. When the prefetch bails on an invalid path (
runner.ts:5123), any siblingupstream_trafficvalidation on the same step with VALIDidentifier_pathsfalls intovalidations.ts:2657and gradesnot_applicablewith the note "adopter did not advertise query_upstream_traffic" — false, the adopter did advertise. Either bail only when ALL checks on the step are invalid, or thread abailed_due_to_sibling_authoring_errorsignal throughctx.upstreamTrafficso siblings emit a distinct note. Cheap follow-up; happy path is unchanged. - Case sensitivity on the reserved-root check.
path.ts:165doesRESERVED_IDENTIFIER_ROOTS.has(firstKey)against lowercase-only entries;loader.ts:116doesfirstSegment.toLowerCase()first.Request.foo/RESPONSE.fooslip past the runtime today. - Test gap on the loader/runtime delta. Add cases for
foo-bar,foo[*][*],Request.foo,__proto__.foo,[*], andfoo[*]bar(wildcard not at segment end) tostoryboard-runner-output-contract-v2.test.js:823. These are exactly the paths adopters migrating from the looser loader grammar will write. - Test gap on sibling-validation downgrade. No coverage for "one step, two
upstream_trafficvalidations, one with invalididentifier_paths." That's where the misleading-note bug above lives.
Minor nits (non-blocking)
- Predicate polarity at
runner.ts:5123reads inverted..some(path => validatePortableIdentifierPath(path))relies on the validator returning a truthystringfor invalid paths — a future reader will see "validate" +.someand assume "any path validates." Either rename toisPortableIdentifierPathInvalid(returns boolean) or writevalidatePortableIdentifierPath(path) !== nullto match the explicit form used atpath.ts:189andvalidations.ts:2914. path.ts:215—next.slice(0, RESOLVE_PATH_ALL_MAX)is redundant. Every push site already gates onnext.length >= RESOLVE_PATH_ALL_MAX. Drop the slice; keep the.filter(value => value !== undefined).- JSDoc on
validatePortableIdentifierPathatpath.ts:154should note thestring | nullpolarity explicitly — "returns the error reason as a string for invalid paths;nullfor valid." Prevents the runner-site predicate confusion from recurring.
Safe to merge once the spec citation is updated. The grammar-coherence and sibling-note follow-ups can ship as separate PRs — neither blocks the fix for #2057.
There was a problem hiding this comment.
Approving. Right shape: invalid paths fail closed as authoring errors rather than silently resolving zero vectors — that's witness, not translator. Removing normalizeStoryboardJsonPath and routing both prefetch and validator through the same resolvePortableIdentifierPathAll is the right cleanup.
Things I checked
- Grammar coverage in
validatePortableIdentifierPath(src/lib/testing/storyboard/path.ts:154-180). Regex/^[A-Za-z_][A-Za-z0-9_]*(?:\[\*\])?$/plus the staged checks reject every variant the tests assert:$.foo,request|response|contextroots,["x"],[0],.., leading/trailing., and forbidden keys (__proto__|constructor|prototype). - Wildcard fan-out bound.
resolvePortableIdentifierPathAll(path.ts:188-219) checksnext.length >= RESOLVE_PATH_ALL_MAXbefore every push, with a per-segmentslice(0, RESOLVE_PATH_ALL_MAX). The fan-out test attest/lib/storyboard-runner-output-contract-v2.test.js:899-911exercises the 10_000 cap with a 15_000-element array. - Per-check rejection in
validateUpstreamTraffic(validations.ts:2628-2651) grades invalid pathspassed: falsewitherror: "storyboard authoring error..."— failing, notnot_applicable. Anti-façade semantic preserved. normalizeStoryboardJsonPathis fully removed — grep confirms no orphan callers.- New exports (
validatePortableIdentifierPath,resolvePortableIdentifierPathAll,PortableIdentifierPathIssue) are module-internal — not re-exported throughsrc/lib/testing/storyboard/index.ts.patchbump is correct. - Changeset present and descriptively named.
actual.invalid_identifier_pathsis an additive field onvalidation_result.actual— compatible with runner-output-contract v2.0.0. - In-tree fixtures don't trip the new grammar (existing
audiences[*].add[*].hashed_emailshape passes).
Follow-ups (non-blocking — file as issues)
- Prefetch short-circuit is over-broad on multi-check steps.
runner.ts:5122usesupstreamChecks.some(...), so a single invalid check on a step skips prefetch for ALLupstream_trafficchecks on that step. The valid siblings then gradenot_applicablewith note"adopter did not advertise query_upstream_traffic"— wrong story when the adopter actually advertised. Per-check rejection atvalidations.ts:2630already handles invalid paths individually; the prefetch gate should beevery(... === null)semantics (run prefetch unless the step has no work to do because EVERY check is invalid), or just always prefetch and let per-check rejection do its job. Authoring-time scenario, low likelihood. - Grammar is SDK opinion, not spec-pinned. adcp#3816 defines
identifier_pathssemantically but doesn't normatively pin the portable grammar enum, reserved-roots list, or rejection of bracket-quoted keys. The runner now grades storyboards a spec-compliant peer runner might accept. The opinion is right (cross-runner portability matters), but file a spec PR againststoryboard-schema.yamlso the grammar lands in the contract, not as runner-pinned policy. - Reserved-roots check is a blanket-ban, not a syntax detector.
path.ts:165rejects any top-level segment whose key matchesrequest|response|context. That's correct for explicit-root syntax (request.body.audiences[*]) but false-positives on a legitimate payload that happens to have a top-levelrequestfield. Today's in-tree fixtures don't hit this, but external storyboards might. Consider tightening to "leading$.or explicit-root form" rather than blanket-banning the identifier as a payload key. - Test gap on sibling scenario.
test/lib/storyboard-runner-output-contract-v2-runner.test.js:225-261covers single-check invalid paths; add a case asserting valid siblings still grade correctly when their step-mate has an authoring bug — that's where the over-broad short-circuit lives.
Minor nits (non-blocking)
- Truthy-string predicate at
runner.ts:5122.path => validatePortableIdentifierPath(path)relies onnullvs error-string truthiness. Functionally correct, butvalidatePortableIdentifierPath(path) !== nullreads cleaner and matches the inverse=== nullform already used inpath.ts:189. Pick one shape and stick with it.
LGTM. Follow-ups noted above.
Fixes #2057.\n\nThis enforces the portable 3.1 upstream_traffic identifier_paths grammar in the storyboard runner, rejects invalid paths as authoring errors, and prevents invalid paths from triggering query_upstream_traffic prefetches. It also bounds wildcard fan-out in the portable resolver and updates the identifier_paths type comment.\n\nTests: npm run build:lib; NODE_ENV=test node --test-timeout=60000 --test-force-exit --test test/lib/storyboard-runner-output-contract-v2.test.js test/lib/storyboard-runner-output-contract-v2-runner.test.js. Pre-push validation also passed.