feat(substitution): translateUniversalMacros helper + parser fix (helper 1/2: code)#2263
Conversation
Introduce StoryboardStep fields (source, source_path, macro_template, macro_bindings) and the no_preview_surface detailed skip reason to support the new pseudo-task. The handler reads rendered HTML from a prior step's parsed response body via a JSON Pointer, runs SubstitutionObserver.parse_html + match_bindings, and asserts each configured macro binding was substituted with the correct context value. Absence of a preview surface is a neutral skip (not_applicable), not a conformance failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nner dispatch Import and dispatch the new handler after the webhook-assertion branch in executeStep. Export UNIVERSAL_MACRO_ASSERTION_TASKS and executeUniversalMacroAssertionStep from the storyboard index so callers can identify and invoke the task independently. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eness check Add the new task name to the HARNESS_TASKS allowlist so the structural completeness test does not flag it as a missing-schema task when it appears in storyboard YAML files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-entity check The residual-entity check used RESIDUAL_ENTITY_RE with an optional trailing semicolon on the named-entity branch, so a literal query separator like ?mb=1&pkg=2 (after & decoding) matched on &pkg and the whole URL was dropped as a smuggled entity. Browsers render &pkg literally, so this was a false positive. Require a trailing semicolon for the NAMED branch (browsers only decode a fixed legacy no-semicolon set, none of which are the dangerous URL-scheme chars : / \, which need a semicolon: : / \). Keep the NUMERIC branch matching with an optional semicolon, since browsers decode &adcontextprotocol#58 / : leniently and query keys never start with #. Export __hasResidualEntity for direct unit testing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ution assertion
Add two tests that exercise the canonical real-world case where both
{MEDIA_BUY_ID} and {PACKAGE_ID} appear in a single tracker URL with
& HTML-encoding: a pass case (both macros correct) and a negative
case (pkg= wrong, mb= correct) verifying per-binding granularity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… identifier assertion - resolvePointer now guards against prototype chain traversal using POINTER_FORBIDDEN_KEYS and Object.prototype.hasOwnProperty.call, matching the discipline established in path.ts - getLastPriorResult (renamed getPriorResult) now respects step.triggered_by when set, falling back to the last Map entry otherwise — mirrors the webhook-assertions.ts pattern - Empty/absent macro_bindings now emits a failing ValidationResult explaining the misconfiguration instead of silently grading passed:false with zero validations - __hasResidualEntity removed from the three package-level barrels (observer/index.ts, substitution/index.ts, src/lib/index.ts); tests that need it import via the deep path dist/lib/substitution/observer/html-parser.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements a query-parameter macro translator that accepts a MacroMapping (native raw or RFC 3986 value) and returns the translated URL with dropped_params/unmapped_macros metadata. Reuses encodeUnreserved from rfc3986.ts so produce and verify share one encoding path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…esult shape Reuse the module-level UNIVERSAL_MACRO constant in the replace() call instead of inlining a duplicate literal; fix the misleading comment to accurately describe String.replace single-pass semantics; expand TranslateResult doc comments to note the dropped_params/unmapped_macros asymmetry; add tests for macro tokens in key position (pass-through) and non-ASCII UTF-8 encoding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Approving on the strength of the clean security verdict plus the test coverage. The encoding follow-up below is load-bearing — fix it before this assertion is ever pointed at a macro whose value isn't unreserved-safe.
The substitution invariant holds throughout: value mappings are percent-encoded, native tokens pass raw, observed values are compared verbatim. Witness, not translator.
Things I checked
RESIDUAL_ENTITY_RErelaxation is not a smuggling bypass.security-reviewer: clean. None of the 106 legacy no-semicolon named entities decode to a scheme-relevant char (:,/,\); the dangerous ones (:,/,\,	) all require the semicolon and still trip the named branch. The one borderline case — → U+00A0 — is not stripped by the WHATWG URL parser, so it breaks scheme parsing rather than enablingjavascript:. Numeric branch keeps;?so:still drops.html-parser.ts:291.- The regex change is internally consistent with
decodeHtmlEntities. Line 299 still decodes known semicolon-less legacy entities from the limited table, so dangerous entities the SDK knows (colon,sol,Tab) decode to the real char and get caught by scheme classification — never reaching the residual check. The residual check only fires now on a complete-but-unknown&name;, which is the right shape to drop. translate.tsis sound.valuemappings go through the sharedencodeUnreserved(RFC 3986 unreserved whitelist —&,=,:, braces all encoded, no param injection);nativeinserted raw by design for seller-supplied ad-server tokens.URLSearchParamscorrectly avoided so%%GDPR%%survives. Single-passString.replacemeans a translated value containing{…}is not re-expanded. The shared globalUNIVERSAL_MACROregex is safe — only.match/.replaceconsume it, no.exec/.testto leavelastIndexdangling.- Strict-equality comparison verified against the alignment layer.
match.observed_valuefor query positions is the raw pre-decode wire substring (alignment.ts:240-241,:312-313). For the macros this PR targets —{MEDIA_BUY_ID},{PACKAGE_ID}, server-minted unreserved-safe IDs — raw equals encoded, soobserved === expectedis correct. The author's comment at thecatalogBindingssite documents exactly this assumption. - Changeset type.
minormatches the surface: two new exports (universal_macro_translation+MacroMapping/TranslateResult), one new runner task, one new skip reason. Additive, no breaking change. Correct. - Prototype-pollution guard in
resolvePointer(POINTER_FORBIDDEN_KEYS+ own-property check) is the right shape for a JSON-pointer walk over an untrusted response body.
Follow-ups (non-blocking — file as issues)
- Encoding assumption in the assertion is unenforced.
universal-macro-assertions.tsbuildscatalogBindingswithexpected_encoded: expectedValue(the unencoded context value) and comparesmatch.observed_value === expectedstrictly. For{MEDIA_BUY_ID}/{PACKAGE_ID}this is fine. But the field shape (macro_bindings: Array<{ macro, context_key }>) and the task name invite binding any universal macro — and a context value carrying a space,&, or non-ASCII char would be percent-encoded on the wire, so the strict compare would false-fail a conformant seller. It fails closed (never a false pass), so it's safe to ship, but before this assertion is pointed at a macro whose value isn't unreserved-safe, compare against an encoded expectation viaequalUnderHexCasePolicy(the patternassertions.ts:30already uses) rather than assuming byte-identity.code-reviewerflagged this as a blocker; I'm downgrading it because the shipped, documented scope is correct and the failure is fail-closed and out-of-scope. - Error-report redaction. Failing-binding messages echo the raw
expected/observedbusiness identifiers into validation output. Confirm this honors the sameerror_report_payload_policy(SHA-256 redaction for non-fixture values) the existing custom-vector path uses, so real IDs don't land in CI logs. - Pin the smuggling invariant with a test. The residual defense rests on "no legacy no-semicolon entity decodes to a scheme char" — true today, enforced only by a comment.
security-reviewer(Low): add a regression test (javascript&colonalert,javascript :alert,javascript:alert) so a futureNAMED_ENTITIESexpansion can't silently reopen it.
Minor nits (non-blocking)
noPreviewSurfaceResultsetserror: detailon a passed step.universal-macro-assertions.tsreturnspassed: true, skipped: truealongside a populatederrorfield. Reads as a failure to anything that keys offerrorbeing non-null. The webhook-assertion skip path is the convention to match here — worth confirming it leaveserrorunset on neutral skips.
Approved.
Resolves the high-severity undici advisories (GHSA-p88m-4jfj-68fv, GHSA-vxpw-j846-p89q, GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m) flagged by the Security Audit CI lane. 6.27.0 is within the existing ^6.25.0 range. npm audit --audit-level=high now passes.
There was a problem hiding this comment.
Approving. The residual-entity regex change is the load-bearing part, and it tightens a false-positive without widening the scheme-smuggling surface — fail-closed stays fail-closed.
Things I checked
RESIDUAL_ENTITY_REchange is security-sound (html-parser.ts:291). Requiring a trailing;on the NAMED branch while keeping the numeric branch's;?optional.security-reviewerverified against the WHATWG frozen legacy/no-semicolon named set: every scheme-breakout char (:,/,\, tab/newline) requires a semicolon-terminated ref (:,/,\,	), so the named branch still trips on all of them; the legacy no-semicolon entities decode only to&/</>/Latin-1, none usable to reconstructjavascript:. Layered smuggle (javascript&#58;x) still caught — one decode pass leaves:, which the unchanged numeric branch drops. The new tests atsubstitution-hardening.test.js:759(keep&multi-param) and:780(:numeric smuggle still dropped) pin both directions.translate.tshas no injection path.valueentries go throughencodeUnreserved(RFC 3986 unreserved whitelist), so&/=/:/?/#/{/}are all percent-encoded — a value can't break its query slot or inject a param (a&b=c→a%26b%3Dc,translate.ts:280).nativeentries are raw by design for ad-server tokens, and are seller-self-supplied, not cross-tenant input. Fragment split off first and never touched; single-pass replace, no nested re-expansion.resolvePointerprototype-pollution guards (universal-macro-assertions.ts:461). Read-only traversal, own-propertyhasOwnPropertycheck plusPOINTER_FORBIDDEN_KEYSdeny-list. Belt-and-suspenders, sufficient.passedgate is non-vacuous (universal-macro-assertions.ts:711):validations.length > 0 && every(...). Emptymacro_bindingsand missing context keys both fail-fast before observation runs.- Witness-not-translator holds.
universal_macro_translationis a seller-side producer minting its own outgoing pixel URL — not a path that re-shapes a buyer-facing response. The assertion handler is purely observational. No fabricated fields. - Skip taxonomy is additive (
types.ts):no_preview_surface→not_applicablemirrorsforce_scenario_unsupported/fixture_seed_unsupportedexactly. New detailed reason onto an existing canonical value, no enum break. - Changeset present and correctly typed.
.changeset/universal-macro-substitution.md,minor— right for net-new additive exports (universal_macro_translation,MacroMapping,TranslateResult).package.jsonversion not hand-edited (still9.0.0); thepackage-lock.json9.0.0-beta.31→9.0.0delta is a lockfile resync against the already-released package.json, and theundici6.25.0 → 6.27.0 bump is within the existing^6.25.0range.
Follow-ups (non-blocking — file as issues)
- Unmapped-macro semantics: drop-param vs empty-string.
ad-tech-protocol-expertflagged this as the one genuine policy choice in the helper. Dropping the whole param is the safe choice for a tracking pixel, but VAST 4.x and several OpenRTB macro conventions resolve unknown macros to empty string. Confirmadcontextprotocol/adcp media-buy-identifier-substitution-checkactually specifies drop-param — if the spec is silent or says empty-string, this is SDK opinion baked ahead of the spec. PerCLAUDE.mdtriage order (spec → mock → SDK), worth a one-line spec confirmation before the next consumer leans on the behavior. - Confirm the cited storyboard exists upstream. The string
media-buy-identifier-substitution-checkappears nowhere in this repo'scompliance/cache. If the driving storyboard isn't merged onadcontextprotocol/adcpyet, the assertion ships ahead of the contract it grades — fine to land, but note it.
Minor nits (non-blocking)
- snake_case free-function export.
universal_macro_translation(translate.ts:223) breaks the module convention — every other free function out ofsubstitution/index.tsis camelCase (encodeUnreserved,extractTrackerUrls,matchBindings); only protocol-mirroring methods on the observer use snake_case (parse_html). Cheaper to rename totranslateUniversalMacrosnow than after it ships in aminorand renaming becomes amajor. - Value-only scope undocumented. Macros in key position pass through untranslated (
translate.ts:244), which the key-position test confirms is intended, but the module docstring says "translates universal macros in a pixel URL's query parameters" without scoping to values. One line would close the gap. observed === expectedis raw-vs-encoded (universal-macro-assertions.ts:636,:686). Correct only because business identifiers (alphanumeric + hyphen) encode to themselves — the inline comment acknowledges it. Load-bearing assumption; would silently false-fail if a boundcontext_keyever carried reserved chars. Leave as-is given scope, but the helper doesn't enforce the identifier shape it assumes.asyncwith noawait(universal-macro-assertions.ts:550). The observer calls are synchronous; the keyword is cosmetic for the dispatcher contract. Harmless.
Note for the maintainers: the deferred defense-in-depth (also dropping the legacy no-semicolon entity set) is correctly deferred — adding it would re-introduce the ®= / ©= query-param false positives this PR just fixed. The security review confirms the current boundary is sound without it.
LGTM. Follow-ups noted below.
|
Reviewed against the schemas alongside adcontextprotocol/adcp#5646. The helper ( Two helper polish items (optional, non-blocking):
On the assertion ( Fixture: to stop JS↔Python encoder drift (Python helper filed at adcontextprotocol/adcp-client-python#956), let's pin both SDKs to a language-neutral test-vector file — I have a draft vector set covering the reserved-char / native-raw / drop / no-re-expansion cases. Want me to PR it here or into the adcp repo under |
bokelley
left a comment
There was a problem hiding this comment.
Two inline suggestions on the translate.ts polish items below. Top-level reply to the rest in a moment.
Generated by Claude Code
|
Thanks for the detailed pass, @bokelley. Left two inline suggestions on On deferring On the fixture: leaning toward the spec repo ( Triaged by Claude Code. Session: https://claude.ai/code/session_01LgzNUkvXwbTxpVo4pm864n Generated by Claude Code |
Per maintainer review (adcontextprotocol#2263), pull the expect_universal_macro_substituted assertion and its runner wiring: it observes a build_creative preview, but {MEDIA_BUY_ID}/{PACKAGE_ID} resolve at serve time and aren't serialized on the wire, so the check skip-dominates for conformant sellers. Deferred to the forthcoming Live Integration (decisioning-roundtrip) check, where the alignment logic is reusable. Keep + polish the helper: - rename universal_macro_translation -> translateUniversalMacros (camelCase, to match the substitution module's free-function convention) - add suspect_native_values to flag value entries shaped like native ad-server tokens (%%..%%, {{..}}, ${..}, [..]) that were likely mapped to the wrong arm - document the value-only scope and the dropped-consent-macro privacy hazard The SubstitutionObserver residual-entity parser fix and undici bump are retained. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @bokelley — acted on all of it:
On the test-vector fixture for JS↔Python parity: yes please, and |
…red)
Seller-scoped storyboard asserting {MEDIA_BUY_ID}/{PACKAGE_ID} substitution in
creative tracking URLs. Parked pending the Live Integration check — the driving
assertion was pulled from @adcp/sdk (adcontextprotocol/adcp-client#2263) because a
build_creative preview can't certify serve-time substitution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Clean, contained change: a seller-side producer helper plus the parser false-positive it surfaced. The parser fix tightens the named-entity branch to require a trailing ; — fail-closed where it matters (scheme chars), fail-open only where a browser would render the bytes literally anyway.
Things I checked
- Residual-entity regex relaxation does not reopen scheme smuggling (
src/lib/substitution/observer/html-parser.ts:291). The named branch now demands;; numeric keeps;?.security-reviewerenumerated the full HTML5 legacy no-semicolon named-reference set (~106 names) — every one decodes to a basic/Latin-1 char, none to:/\TAB/LF/CR. The scheme-relevant entities (:/\	
) are all semicolon-mandatory and still tripped. Decode→check ordering is correct:decodeHtmlEntitiesruns before__hasResidualEntity(:321) at the call site. The author's claim holds. __hasResidualEntityis pre-existing, not a new export (:321) — the test just imports it. No surface change there.value-arm encoding cannot break out of its parameter —encodeUnreserved(rfc3986.ts) is a strict RFC 3986 unreserved whitelist, UTF-8-first, uppercase hex, so avaluemacro cannot inject&/=/://. Tests confirm (a&b=c→a%26b%3Dc,café→caf%C3%A9).- No re-expansion of macros inside a substituted value — single-pass
String.replacewith global regex; thehas-{GDPR}-insidetest confirms the inner token encodes as data. - Textual query parsing edge cases trace clean — first-
=split preserves=in values, repeated keys handled independently, key-position macros pass through (matching runs on values only), empty/no-query returns verbatim. - Changeset present,
minor— correct for additive exports (translateUniversalMacros,MacroMapping,TranslateResult) plus a non-breaking false-positive fix. No manualpackage.jsonversion edit — package.json isn't in the diff; the lock's9.0.0is the GA release merged from main. - No witness-violation — the helper translates a caller-supplied mapping over a caller-supplied URL; it does not fabricate or normalize wire responses.
nativeis raw by design,valueis encoded.
Follow-ups (non-blocking — file as issues)
- Consent-macro silent-drop deserves a louder signal. An unmapped
{GDPR_CONSENT}/{US_PRIVACY}drops the whole param but still returns a usableurl; a caller ignoringunmapped_macros/dropped_paramsships a consent-degraded pixel. Thoroughly documented and structurally surfaced, which is the right shape — but a dedicateddropped_consent_macrosfield would keep the compliance case from being lumped in with benign drops.
Minor nits (non-blocking)
suspect_native_valuesis mapping-scoped, not URL-scoped. It iterates the entiremapping, so a suspectvalueentry whose macro never appears in the URL is still reported — asymmetric withunmapped_macros/dropped_params, which are URL-scoped. Defensible as proactive config-lint; worth one doc line so the asymmetry doesn't surprise.NATIVE_TOKEN_SHAPE[.+]branch over-matches. A legitimatevaluelike[1,2,3]or[redacted]flags as suspect. Advisory-only (output unchanged), low impact, but a foreseeable false positive.- Empty query strips the
?.https://px.example/i?→https://px.example/i— a silent normalization the docstring doesn't mention. Harmless for pixels.
Approving on the strength of the enumerated scheme-smuggle proof plus the strict value-arm encoding.
- add dropped_consent_macros: surface dropped consent/privacy macros
({GDPR_CONSENT}, {US_PRIVACY}, …) separately from benign drops
- tighten NATIVE_TOKEN_SHAPE bracket arm to [UPPER_SNAKE] so ordinary
bracketed values ([1,2,3], [redacted]) are no longer flagged suspect
- document that suspect_native_values is mapping-scoped (config-lint), and
that a bare trailing '?' is dropped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Clean. The named-entity-branch semicolon requirement is the right shape — it restores legitimate multi-param tracker URLs without re-opening the scheme-smuggling defense, because the dangerous-char axis is closed under HTML5's frozen legacy no-semicolon set.
Things I checked
- The load-bearing security claim holds.
RESIDUAL_ENTITY_REathtml-parser.ts:291now demands a trailing;on the NAMED branch.security-reviewerconfirmed against the WHATWG reference set: the ~106 legacy no-semicolon named refs decode exclusively to Latin-1 (© ® ° µ Æ ß …) plus the five ASCII basics — none yield:,/, or\. The scheme-breakout chars are reachable only via the;-terminated forms (:,/,\), all already inNAMED_ENTITIES. The NUMERIC branch correctly keeps the optional;, sojavascript:alert(0)still classifies asjavascript:(locked in atsubstitution-hardening.test.js:405). - Single-pass, no re-expansion.
value.replace(UNIVERSAL_MACRO, fn)does not re-scan substituted output; thehas-{GDPR}-insidetest confirms inner braces percent-encode to%7B…%7Drather than re-expanding (translate.ts:349). - Fragment-before-query split ordered correctly (
#first, then?on the remainder) —translate.ts:288-305. - Witness, not translator. Diagnostics arrays carry macro token names (
{GDPR}), never mapped values — a secret mapped into avalueentry never echoes intounmapped_macros/suspect_native_values. No fabrication, no silent normalization. - Consent-macro drop surfaced, not swallowed. Unmapped
{GDPR_CONSENT}/{US_PRIVACY}/{GPP_*}drops land indropped_consent_macrosdistinct from benign drops (translate.ts:338-341). Right call — a silently-dropped consent signal is a compliance hazard, and the API forces it to the caller. - Changeset present, correctly typed.
.changeset/universal-macro-substitution.md—minor, matching three net-new public exports (translateUniversalMacros,MacroMapping,TranslateResult) wired throughsubstitution/index.ts:99-100→index.ts:77,85-86(value + type both present). - package-lock
9.0.0-beta.31→9.0.0is a lockfile sync to main's already-releasedpackage.json, not a hand-edit.package.jsonis not in the diff. undici6.25.0→6.27.0stays inside the existing^6.25.0range.
Follow-ups (non-blocking — file as issues)
- Decoder/residual-check asymmetry is the entire security rationale.
decodeHtmlEntitiesdecodes named entities without a semicolon (html-parser.ts:299), while the residual check now flags them only with one. Safe today — the gap decodes only to Latin-1 — but it is load-bearing: ifNAMED_ENTITIESever gains a non-Latin-1 punctuation mapping, this branch needs a re-review. Worth a comment at the table tying the two together so a future edit trips the right alarm.
Minor nits (non-blocking)
- Redundant
suspectSeendedup.translate.ts:279-285—Object.entries(mapping)yields unique keys, so theSetguard can never fire. Harmless; droppable. ?-strip on leading-empty-param inputs.translate.ts:361-362— a malformed?&x={FOO}where the only real param drops yieldsoutputParts = ['']→ the?is stripped. Consistent with the documented bare-?behavior; cosmetic, only on malformed input.
LGTM. Follow-ups noted below.
…cs) (#5661) * docs(creative): document translateUniversalMacros SDK helper Adds an implementation-guide example under docs/creative/universal-macros showing sales agents how to translate universal macros via the @adcp/sdk helper (value vs native arms, dropped/unmapped reporting, suspect_native_values). Documents the helper added in adcontextprotocol/adcp-client#2263. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: drop changeset (docs-only, non-protocol per repo policy) * docs(creative): mention dropped_consent_macros and tighten native-token shape note --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… vectors (#5650) Pins the universal_macro_translation helper contract (value RFC-3986 unreserved-encoded, native raw, unmapped-macro params dropped, minted params untouched, single-pass no re-expansion) as language-neutral test vectors. Both @adcp/sdk and the Python adcp SDK validate against this file so the two encoders cannot drift — the conformance storyboard only exercises alphanumeric IDs, so cross-language drift is otherwise invisible. Refs adcontextprotocol/adcp-client#2263, adcontextprotocol/adcp-client-python#956 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seller-side universal-macro translation, plus the parser fix it surfaced.
translateUniversalMacros(pixel_url, mapping)(seller helper). Translates universal macros in a pixel URL's query-parameter values from a seller mapping. Two arms:value(literal, RFC-3986 percent-encoded via the shared encoder) andnative(ad-server token, inserted raw). Parameters with an unmapped macro are dropped (reported indropped_params/unmapped_macros); already-minted params pass through untouched; macros in key position are not translated. The result also reportssuspect_native_values—valueentries shaped like a native token (%%…%%,{{…}},${…},[…]), a likely wrong-arm mapping. Docstring calls out the dropped-consent-macro privacy hazard.Parser fix.
SubstitutionObserver's residual-entity check no longer drops legitimate multi-parameter tracker URLs (the named-entity branch now requires a trailing;, matching browser decoding) while preserving the scheme-smuggling defense. Security-reviewed; all hardening/SSRF tests pass.undici 6.25.0 → 6.27.0 (within
^6.25.0) to clear the high-severity advisories flagged by the Security Audit lane.Full lib suite green. Changeset included (minor).
Removed per review: the
expect_universal_macro_substitutedstoryboard assertion and its runner wiring have been pulled from this PR. As @bokelley noted, it observes abuild_creativepreview, but{MEDIA_BUY_ID}/{PACKAGE_ID}resolve at serve time and aren't serialized on the wire, so it skip-dominates for conformant sellers. It's deferred to the forthcoming Live Integration (decisioning-roundtrip) check, where the alignment logic is reusable. This PR is now helper + parser fix only.🤖 Generated with Claude Code