fix(spec): defineStack cross-reference validation reaches object-embedded actions (#7397) - #7457
Conversation
…dded actions (#7397) `validateCrossReferences` iterated the registered action list and, since #6889, inline page-element actions. Neither walk visited `config.objects[].actions[]`, which carries the full `ActionSchema` — so a modal/flow target authored there was never resolved and a dangling one built clean. The registered walk cannot cover it: validation runs BEFORE `mergeActionsIntoObjects`, and that merge only copies top-level -> object, so an embedded-only action never reaches the validated list. Adds a third traversal applying the SAME two target checks with the same message tail, subject-labeled by the owning object. Modal scope is #6739's ruling: a modal target names a PAGE, only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #7397
defineStack's action cross-reference walk visited the registered list (config.actions) and — since #6889 / PR #7392 — inline page-element actions. Neither walk visited actions authored directly on an object (config.objects[].actions[]), even though that position carries the fullActionSchema: the identical symbol the registered collection uses atstack.zod.ts:256, not a narrower embedded shape. Atype: 'modal'ortype: 'flow'target written there was never resolved, so a dangling one built clean and failed only when a user clicked it.This is #6889's defect one authoring position over, and it rides the same traversal pattern: same rule, same message tail, subject-labeled for the position.
Why the registered walk could not already cover it
validateCrossReferencesruns atstack.zod.ts:1474;mergeActionsIntoObjectsruns at:1503. The merge is top-level to object (byobjectName) and never the reverse, and it runs after validation. An action authored only on the object therefore never appears in the validatedconfig.actionsat all — the probe's stack measuredconfig.actions = undefinedwhile the embedded action kept itstargetintact through the parse.validateCrossReferenceshas exactly one caller, so there is no second path that re-validates an already-merged stack.Probe table — all 10 rows, before and after
Re-measured on
origin/main@d13ce33before the edit (the premise check), then again after. Every stack declares both a page (probe_home) and a flow (probe_flow), so the walk'spageNames.size > 0/flowNames.size > 0plugin escape hatch cannot explain away an acceptance. Rows f–j are registered-position controls built from the same helper with the same arguments.Rows b/f, c/g and d/i were the same action object in two authoring positions with opposite verdicts; a/h and e/j confirm the legitimate shapes survive in both, before and after. The after-state messages for the three flipped rows:
Messages keep the registered wording verbatim from
referencesonward and change only the subject, because an embedded action is located by its owning object.nameis required at this position (actionObject()'sname: SnakeCaseIdentifierSchema), so unlike the inline arm there is no anonymous case to path-label.Contract anchor — #6739's ruling fixes the modal arm
Row d's verdict is not this card's to invent. The maintainer ruled it on #6739 on 2026-08-09, quoted verbatim:
So the embedded modal arm mirrors the registered rule exactly rather than also accepting an object name — closing the d/i split the same way PR #7392 closed the inline A/D one. No residual question is escalated.
Scope — the
objectNamearm is deliberately NOT includedThe registered walk applies three checks; this PR mirrors two. The third (
objectName→ declared object) splits identically at the embedded position (embedded ACCEPTED / registered REJECTED, measured), but mirroring it verbatim is not obviously right:mergeActionsIntoObjectsreadsobjectNameonly from the top-level list and never fromobj.actions[], so the key is inert at this position, and what it should mean on an action already embedded on an object — an existence check, a consistency check against the owning object's name, or ADR-0049 retirement — is an open contract decision.Settling that as a side effect of this walk would be an acceptance-surface change nobody ruled on, so it is filed as #7456 (
finding, unassigned) with the measurement and the three options, and recorded in code at the walk's comment block.Corpus census — 0 shipped stacks newly refuse
Acceptance-face narrowing, so every
defineStackcorpus was censused behaviourally, by building it, per PR #7392's method:examples/app-showcase(17 files / 168 tests)examples/app-crm(2 / 27)examples/app-todo(3 / 105)packages/qa/dogfood(87 / 546, 17defineStacksites)packages/spec(372 / 9740)packages/lint(70 / 1852)packages/cli(108 / 1162)packages/runtime(120 / 1907)No object in the reference corpus hand-authors a
modal- orflow-typed action at the embedded position. The ordinary wayobjects[].actions[]gets populated is the top-level to object merge, which runs after validation and is unaffected — a vacuity guard pins that merged shape so the census cannot go stale silently.One census run initially failed in
app-crmwithFailed to resolve entry for package "@objectstack/objectql"— the fresh-worktree unbuilt-dependency trap (AGENTS.md §9), not this change. It passes afterpnpm --filter '...^...' build.Reverse verification — predicted in writing, then run
Predicted before running, with the direction argued first: no inversion is possible here, because the change only adds refusals — there is no shape that was rejected before and is accepted now. So reverting the walk must turn exactly the rejection-class new pins red (they assert a refusal old code does not produce, so they receive
[]), while every accept-class new pin and all 20 of #7392's existing cases stay green. The prediction enumerated 9 specific cases and totals of9 failed | 33 passed (42).Measured with a checkpoint commit first, then
git checkout origin/main -- packages/spec/src/stack.zod.ts(nevergit stash— shared stack; and on an uncommitted branch the restore would itself have been a second revert):The 9 red were exactly the 9 enumerated cases, no more and no fewer, and the totals matched the prediction exactly. Restoring with
git checkout HEAD -- packages/spec/src/stack.zod.tsreturns 42/42 green.One case is worth naming because it was predicted green and is green for a non-obvious reason: "reports a registered action ONCE, not twice" stays green both ways — the registered walk produces its single error with or without the embedded walk, because the merge that would create a second copy runs after validation either way. It is a regression pin against reordering the merge ahead of validation, not a pin on this traversal.
Tests
packages/spec/src/stack-inline-action-crossref.test.ts— the file the card names as the pin home — grows from 20 to 42 cases; the 20 inline cases are untouched. Rejection cases pin full message text withtoEqual, nottoThrow: a bare throw assertion cannot separate "refused for the right reason" from "refused because the fixture is broken", and each rejection fixture differs from an accepted twin by exactly one string.The 22 new cases cover: both target arms; the b/f, c/g, d/i split parity table and the a/h, e/j accept parity; both size gates (no pages / no flows declared, so the target may come from a plugin — embedded must not be stricter than registered); per-object subject labeling across two objects; multiple offenders on one object; the merge-order regression pin; an embedded action that also names its object; and the four action types that must stay untouched (
form,url,api,script) plus the merged-shape vacuity guard.Gates
pnpm lint(ESLint) exit 0 ·check:adr-0087-registrationOK ·check:nul-bytesOK (6830 files) ·check:stack-collection-maps·check:doc-authoring·check:adr-anchors·check:role-word·check:empty-changeset·check:error-code-casing·check:route-envelope— all OK.pnpm --filter @objectstack/spec typecheckclean (tsc, scripts, and test-layer).No gate demanded a docs or ADR edit;
content/docs/anddocs/adr/**are untouched. Changeset:minoron@objectstack/spec, matching PR #7392's precedent for extending this same cross-reference walk to one more surface.Generated by Claude Code