Skip to content

feat(spec)!: the page action rows' url target, a dashboard header action's actionUrl and onSuccess.navigate apply the shared url-target refusal - #23101

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-23077-url-target-doors
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-23077-url-target-doors

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23077

Clause-②: no (narrowing)

The rest of ruling A+'s url-target family. Ruling A+ on objectstack-ai/objectui#12191 says the scheme set "is stated once in the contract and the runtime fulfils exactly that". PR #23076 (9b37edbd7) stated it once, as navigationTargetRefusal in packages/spec/src/ui/navigation-target.ts, and applied it at the two url-action doors. This PR applies the same function, with no second URL grammar, at the three doors the triage direction (comment 6129646171) names. A target that opens with ${ keeps the predicate's own rule: it is left alone at the door and judged by the runner after interpolation.

What changed

  1. Page action rows (packages/spec/src/ui/component.zod.ts). One refinement factory, refuseUnopenableRowUrlTarget, is applied to ActionButtonPropsSchema and ActionIconPropsSchema (when actionType: 'url') and to the action:group and action:menu members (when type: 'url'). It judges target and refuses at target. On every other executor target names a script, flow, page, endpoint or form, so it is not judged, and a row with no target is not judged either. os validate's props gate (@objectstack/lint validateComponentProps) parses properties against these same ComponentPropsMap rows, so it now reports the refusal at the row's target path, with the parse's own first sentence. The refusal is a component-props-invalid finding, a warning, because warning is that gate's only tier (see its module header). The card puts the gate's upgrade to error out of scope. The lint package changes no code; one test file pins the gate's reading.
  2. Dashboard header actions (packages/spec/src/ui/dashboard.zod.ts). DashboardHeaderActionSchema judges actionUrl when actionType is 'url' or omitted, and refuses at actionUrl. On flow / script / modal / api / form, actionUrl is untouched. The refusal's subject names both spellings of the url route ("a url target when actionType is 'url' or omitted"), so an author who meant a flow learns that declaring actionType is the way out.
  3. Post-success navigation (packages/spec/src/ui/action.zod.ts). The onSuccess block judges navigate on every value and refuses at onSuccess.navigate. ${result.redirectUrl} stays accepted and /apps/crm/tasks/${result.id} stays accepted. tasks/${result.id} is refused, because the runner refuses tasks/42 after interpolation too.

The describe text and TSDoc on each key now state the set. Three D3 entries are added, one per door: ui-page-action-row-url-target-scheme-set-closed, ui-dashboard-header-action-url-scheme-set-closed and ui-action-on-success-navigate-scheme-set-closed. There is one changeset, .changeset/23077-url-target-doors-scheme-set.md: @objectstack/spec minor, BREAKING, with a FROM → TO table and the one-line fix, Clause-②: no (narrowing), and ADR-0087 registered with the three ids.

Empty values. At the three doors that have no target-required rule (dashboard actionUrl, onSuccess.navigate, a row's target), the predicate judges an empty string like any other value and refuses it. This keeps the predicate's own rule. The runtime does nothing with an empty value either: the dashboard renderer warns on an empty actionUrl, readOnSuccessNavigation drops an empty navigate, and executeUrl fails with "No URL provided". The changeset's FROM → TO table names this case.

The dispatch's mechanism assumptions, as measured

  • A1 (lines). Every door was a free string at 0aef78881. dashboard.zod.ts:109 actionUrl: z.string() and the three component.zod.ts rows at :3834 / :3931 / :4114 matched. onSuccess.navigate was at action.zod.ts:1785, not :941. :941 is the top-level navigate guidance key, a different thing.
  • A2 (red at base). Reproduced through one-time ablations, recorded below. With each door's refinement removed, which is the base behaviour, these pins read red:
    • the schema-level refusals;
    • the ObjectStackDefinitionSchema.safeParse / defineStack reach pins for the dashboard and onSuccess doors;
    • the props-gate pins, where the expected finding is missing because the gate answered 0 findings.
  • A3 (omitted actionType). The contract states no default for actionType. Its two readers that assign one both assign 'url': objectui DashboardRenderer builds the header ActionDef with type: (a.actionType) || 'url', and this repo's validateDashboardActionRefs uses strName(action.actionType) ?? 'url'. Pinned reading: an omitted actionType is the url route and is judged. The renderer's click handler, read at objectui origin/main 7af0ab37 and at the pin 7578323, returns early with a console warning on an omitted actionType. So a refused target never works on either reading, and nothing that works today is refused. The click handler's split is an Acceptance note below.
  • A4 (deferred ${). Held: ${result.redirectUrl} is accepted at onSuccess.navigate, and that is pinned.

File surface beyond the claim's literal list (declared)

  • packages/spec/dropped-refinements.baseline.json. build-schemas.ts refuses a published schema that gains a refinement it cannot project unless the new site is declared in this ledger. Its own prescription is "Declare it by adding…", and it says "⛔ do not delete or weaken" the rule. The ledger gains 21 sites: 17 in 13 existing entries, and 4 new entries (ui/DashboardHeaderAction, ui/DashboardHeader, ui/ActionButtonProps, ui/ActionIconProps). The measured header totals are updated to match its body (231 schemas / 734 sites), which scripts/dropped-refinements.test.ts pins. No site is removed.
  • The three D3 entry files. check-adr-0087-registration needs them: a changeset with a FROM → TO prescription cannot claim no-migration-prescription.
  • content/docs/references/ui/{action,dashboard,component}.mdx, regenerated by gen:docs. check:generated named only check:docs.

Verification record

Readings are at branch head ed59283ff unless stated. The branch merged origin/main 6662e3f59 (after #23080 landed as a22e67b3c) through scripts/pm/os-regen-merge.sh, and the merge commit is f7f23a07d. All heavy runs went through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/spec build: exit 0. check:generated: "All 14 generated artifacts are up to date", exit 0.
  • pnpm --filter @objectstack/spec typecheck and pnpm --filter @objectstack/lint typecheck: exit 0.
  • @objectstack/spec full suite (vitest run --project local): 658 files, 19919 passed, 1 todo, exit 0. The new src/ui/url-target-doors.test.ts holds 25 tests.
  • @objectstack/lint full suite at beade18b6, which differs from head only by the regenerated component.mdx: 138 files, 6590 passed, 5 skipped. The new src/validate-component-props-url-target.test.ts holds 8 tests.
  • @objectstack/platform-objects full suite: 70 files, 1096 passed.
  • Narrowed, declared. These runs cover only the test files that author an affected key:
    • @objectstack/runtime, 8 files, 87 passed: app-plugin-converted-collections, app-plugin-converted-readers, dispatcher-plugin.routes, http-dispatcher.actions-doubled-redirect (which writes onSuccess.navigate), notification-schema-conformance, packages-unscoped-environment-binding, standalone-stack-option-b-forward-conversion and standalone-stack-seeder-declaration-copy;
    • @objectstack/metadata-protocol protocol.meta-types-unauthorable-columns, 8 passed.
    • The population: every test file outside spec and lint that names actionUrl, onSuccess, navigate or an action:button / action:icon / action:group / action:menu row. That is 22 files. Most of them are notify-node actionUrl or React onSuccess callbacks, which are different keys.
    • The static census behind it. Every actionUrl:, navigate: and url-adjacent target: literal in this repository, tests included, is either admitted or not on a url route. The exceptions are this PR's own pins and three raw-object fixtures in validate-dashboard-action-refs.test.ts, which the spec never parses (see the Acceptance notes).
    • Blind spot: a test that builds one of these values from a computed string.
    • turbo ls --affected names 76 packages. That set comes from the package dependency graph, so it is blind to tests that read another package's source from disk. CI runs the full farm, and the @objectstack/cli integration tier and the example suites are declared to CI.
  • Ablations (one-time proofs, not kept, each through scripts/ablation-replace.mjs with its restore trap):
    • Dashboard door at 2be8c3b15: deleted .superRefine(refuseUnopenableHeaderActionUrl) (anchor 1 → 0, blob 517bda8662ed → 1a0794811edb). url-target-doors.test.ts read 4 failed / 9 passed: both refusals and both reach pins.
    • onSuccess door at 2be8c3b15: deleted .superRefine(refuseUnopenableOnSuccessNavigate) (anchor 1 → 0, blob 05f3a2c73867 → 984773e97456). It read 3 failed / 10 passed.
    • Page action rows at ed59283ff: replaced the factory's guard with an always-return carrying the marker ablated-23077 (blob 3308f803bfee → 3c5a701368c4).
      • src leg: 4 failed / 21 passed, the four row refusals.
      • dist leg: after pnpm --filter @objectstack/spec build, ablation-dist-preflight.mjs found the marker in 14 built files. The props-gate test then read 4 failed / 4 passed.
      • restore leg: rebuilt; the marker is absent from all 232 built files; the tree is clean; the props-gate test read 8/8 passed.
    • Every restore put the blob back equal to HEAD with an empty git diff HEAD.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 112 commands at ed59283ff. 111 run, all exit 0. pnpm check:type-check-debt is NOT MEASURED by dispatch order. Two of them first exited 3 on an unmet prerequisite: check:skill-examples (@objectstack/client-react unbuilt) and check:dual-build-cjs-loads (37 unbuilt packages). Both exit 0 after a workspace build through the lock, at the same head. --ran reconciles: "112 derived, 111 run, 1 NOT-MEASURED, 0 UNRUN", and every line carries its exit code. check:nul-bytes is green, and a raw control-byte scan of the 13 touched files finds 0 lines.
  • check-adr-0087-registration: "registered ui-page-action-row-url-target-scheme-set-closed, ui-dashboard-header-action-url-scheme-set-closed, ui-action-on-success-navigate-scheme-set-closed (new here: …)".
  • Producers measured: none refused outside tests. This repository was read at its head; objectui at its pin 7578323 and at origin/main 7af0ab37.
    • The only refused literals are in tests that exercise the runner's own refusal: objectui ActionRunner.test.ts, ActionRunner.onSuccessNavigation.test.ts and element-button-mailto-12191.test.tsx.
    • The other refused-looking hits are _blank / _top nav attributes and non-url executor names.
    • No producer writes actionType: 'navigation' on a page action row.
  • Size: 13 files, +559 / −21 = 580 changed lines (generated files included).
  • origin/main has moved two commits past the merge (1badd3183, 2d21b9c89). Their file set is disjoint from this branch's, and CI's merge-ref run covers the join.
  • Merge round (seat order, state note 6133884874): merged origin/main 4a87d09 (feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098 = 55828c5) at e6d8238; the one conflict, packages/spec/dropped-refinements.baseline.json, took main side whole, and build-schemas re-derived this branch's 21 sites (e9ff53c): 232 schemas / 752 sites, feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098's 18 sites kept. At e9ff53c: spec suite 660 files / 20010 passed / 1 todo; lint suite 138 files / 6617 passed / 5 skipped; dispatch-gates --ran 112 derived, 111 run, 1 NOT-MEASURED (check:type-check-debt, by order), 0 UNRUN.

Acceptance notes

  • objectui's dashboard header click handler bypasses the classifier (read at objectui 7af0ab37, packages/plugin-dashboard/src/DashboardRenderer.tsx:1211-1223). An actionType: 'url' header action is navigated by the renderer itself, not through ActionRunner / classifyNavigationTarget:

    • it calls location.assign for ^https?:// or a //-prefixed target;
    • it calls history.pushState for everything else, which throws on mailto: and admits a bare thanks;
    • an omitted actionType is skipped with a warning, while the same file's headerActionDefs defaults it to 'url'.

    This is a second URL grammar on the runtime side of ruling A+. Reported to the seat with evidence in the dev report; it is not fixed here (objectui).

  • A page action row's onSuccess is z.unknown(), forwarded to the runner's navigateOnSuccess. Measured through the dist: ActionButtonPropsSchema and an action:group member accept onSuccess: { navigate: 'javascript:alert(1)' }, and the props gate answers 0 findings. As a control, ActionSchema refuses the same value at onSuccess.navigate. This is the same family, but declaring the rows' onSuccess shape is a contract choice and not a mechanical fix, so it is reported to the seat and not fixed here.

  • actionType: 'navigation' on a page action row reaches the runner's executeNavigation, an alias of url, and its target is not judged here. The row's actionType is a free string, and no producer writes it. This is a residual of the family; the card and the claim scope the rows to 'url'.

  • Three fixtures in packages/lint/src/validate-dashboard-action-refs.test.ts (//cdn.example.com/y and some-handler with actionType: 'url', and do_something with it omitted) feed the gate raw objects. They stay green, but they now pin inputs that the stack parse refuses before the gate sees them.

  • action.form.ts's help text for onSuccess.navigate still says "A relative value is an in-app route". The describe text now states the set.

  • The props gate's generic component-props-invalid text ("nothing refuses it today, so the renderer receives the props as written") reads loosely for these rows, because the runner does refuse at the click.


Generated by Claude Code

…the shared url-target refusal

The two doors judge their value with navigation-target.ts's
navigationTargetRefusal, the one grammar the console's action runner
applies at the click. A header action's actionUrl is judged when
actionType is 'url' or omitted; onSuccess.navigate on every value, a
template that opens with ${ left to the hop.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <noreply@anthropic.com>
…efinements ledger

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <noreply@anthropic.com>
…target refusal

action:button / action:icon (actionType 'url') and an action:group /
action:menu member (type 'url') judge target with navigation-target.ts's
navigationTargetRefusal; os validate's props gate reports it at the row,
pinned in @objectstack/lint.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <noreply@anthropic.com>
… in the dropped-refinements ledger

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via actionType (literal, a string literal in refuseUnopenableRowUrlTarget; a string literal on a changed line), actionUrl (literal, a string literal in refuseUnopenableHeaderActionUrl))
  • content/docs/deployment/validating-metadata.mdx (via actionType (literal, a string literal in refuseUnopenableRowUrlTarget; a string literal on a changed line), actionUrl (literal, a string literal in refuseUnopenableHeaderActionUrl))
  • content/docs/protocol/kernel/i18n-standard.mdx (via DashboardHeaderActionSchema (symbol, a top-level const), actionUrl (literal, a string literal in refuseUnopenableHeaderActionUrl))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via actionType (literal, a string literal in refuseUnopenableRowUrlTarget; a string literal on a changed line), actionUrl (literal, a string literal in refuseUnopenableHeaderActionUrl))
  • content/docs/releases/v17/17-0.mdx (via actionType (literal, a string literal in refuseUnopenableRowUrlTarget; a string literal on a changed line), actionUrl (literal, a string literal in refuseUnopenableHeaderActionUrl))
  • content/docs/releases/v17/17-6.mdx (via actionType (literal, a string literal in refuseUnopenableRowUrlTarget; a string literal on a changed line))
  • content/docs/releases/v17/17-7.mdx (via actionType (literal, a string literal in refuseUnopenableRowUrlTarget; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 21c86444ec6e1afb3f3d6e608443fcaae6a10f96 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8ac777fba670805e353cbffaffe2458fcbbc2b5 — the merge of head e9ff53c82db9703a18425a4fb2e36fb630ee4a7b into base 21c86444ec6e1afb3f3d6e608443fcaae6a10f96, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d8ac777fba670805e353cbffaffe2458fcbbc2b5 && git checkout d8ac777fba670805e353cbffaffe2458fcbbc2b5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 21c86444ec6e1afb3f3d6e608443fcaae6a10f96 e9ff53c82db9703a18425a4fb2e36fb630ee4a7b && git checkout -B drift-repro 21c86444ec6e1afb3f3d6e608443fcaae6a10f96 && git merge --no-ff e9ff53c82db9703a18425a4fb2e36fb630ee4a7b

node scripts/docs-audit/affected-docs.mjs --json 21c86444ec6e1afb3f3d6e608443fcaae6a10f96

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 21c86444ec6e1afb3f3d6e608443fcaae6a10f96 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ed59283ff50ef870d35db074c121c699bf21cf41
Local-runs: none

Isolated contract-review subagent of domain:spec seat 1 (seat post #6017, account os-warren), rendered 2026-10-12T22:16Z. Read-only. Inputs: card #23077 (body and all five comments: triage 6129646171 and 6130597965, claim 6131231192, dev report 6133317304, the seat's acceptance 6133420836); ruling A+ on objectstack-ai/objectui#12191 (comment 6121792024) with packages/spec/src/ui/navigation-target.ts at main; PR #23101's body, file list (13 files, +559 / −21 = 580 changed lines) and net diff against main; the check-runs on this head; and the two issues the seat filed from the report, objectstack-ai/objectui#12275 and #23103. Producer sweeps were read at objectstack origin/main d0f1c5bfb977 and objectui at the pin 7578323a544e (.objectui-sha). Nothing was built, run or re-run. The dispatch order and the dispatching seat's conclusions were not inputs.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. One grammar, three doors — right. Each new refinement calls navigationTargetRefusal and carries no scheme literal of its own: refuseUnopenableRowUrlTarget in component.zod.ts, refuseUnopenableHeaderActionUrl in dashboard.zod.ts, refuseUnopenableOnSuccessNavigate in action.zod.ts. The only literals are the executor value 'url' and the refusal subjects. No door pre-judges a value that opens with ${; the deferral stays inside the predicate, so ${result.redirectUrl} is admitted by the predicate's own rule, and that is pinned. navigation-target.ts is still reached by relative import only, as its header requires. No export is added, removed or renamed (the three functions and HEADER_ACTION_URL_SUBJECT are module-private), so the published type surface is unchanged.

  2. The page action rows (ActionButtonPropsSchema, ActionIconPropsSchema, the action:group and action:menu member builders) — right. Narrowed only when the executor key reads 'url' and target is a string; a non-url executor, no executor and no target are untouched, all three pinned. A page's properties bag is outside the stack parse, so the door os validate judges is the props gate, which parses against the same ComponentPropsMap rows (pinned in packages/lint with no lint code change). The finding is a component-props-invalid warning, that gate's one tier; the card puts the error upgrade out of scope, and the changeset says the gate "reports" it rather than refuses it, which is the honest claim. The pinned sibling: objectui at 7578323a544e consumes these rows through stripImportedDefaults, whose walker clones each node by patching _zod.def and never rebuilds with z.object(shape), precisely so def.checks survive; read, not executed. The Console Pin Gate is skipped on pull_request by design (ci.yml, full battery only) and judges this after the merge.

  3. DashboardHeaderActionSchema.actionUrl when actionType is 'url' — right. Refused at actionUrl; the five other types are untouched and pinned; the stack-parse path dashboards.0.header.actions.0.actionUrl and defineStack are pinned.

  4. An omitted actionType judged as the url route — right; within the card and within the ruling; not an escalation. The card's own measured defect names this spelling ("with actionType: 'url' or with it omitted") and states the renderer's default ("DashboardRenderer defaults an omitted actionType to 'url'"); the triage direction and the claim name DashboardHeaderActionSchema.actionUrl unscoped. The ruling fixes the set and the principle (stated once in the contract, the runtime fulfils exactly that); which spellings of this door are a url target is the card's finding, and the diff follows the only two readers that give the omission a meaning, both read by me: objectui headerActionDefs at the pin (type: actionType || 'url', line 459) and lint validateDashboardActionRefs ("a missing actionType is treated as a 'url' navigation", line 285). The diff materialises no default (no .default('url'); an admitted value parses byte-identically, pinned), requires nothing new, and refuses nothing that navigates today: the renderer's click handler drops an omitted type with a console warning (pin line 1221), so the omitted-type button is dead on every value before and after. Had the diff required actionType or materialised a default, that would have gone beyond the card; it does neither. The handler's own departure from its headerActionDefs reading is the runtime's, carried by objectui#12275.

  5. ActionSchema.onSuccess.navigate — right. Judged on every value; tasks/${result.id} is refused because the runner refuses tasks/42 after interpolation; /apps/crm/tasks/${result.id}, an https:// URL and ${result.redirectUrl} are pinned admitted. The refinement sits on the onSuccess object itself, so every embedding of ActionSchema gets it (the stack's actions[] and the object-level actions, which the baseline's new sites enumerate); InlineActionSchema picks no onSuccess, so there is no inline door to miss. An empty navigate is refused like any other value, matching the runner's readOnSuccessNavigation, which drops '' and performs no hop (pin line 2535).

  6. The empty string at all three doors — right. The predicate judges '' as a bare relative and refuses it; the runtime does nothing with one anywhere (the header click handler warns on a falsy actionUrl, readOnSuccessNavigation drops it, executeUrl answers "No URL provided"). The changeset's FROM → TO table names the case. No producer writes one: objectui at the pin writes no actionUrl: '', and the DashboardWithConfig seeding its CHANGELOG records was on the retired widget key and is gone.

  7. Producers — none refused. Objectstack at d0f1c5bfb977: the only url header actions outside tests are docs deployment/validating-metadata.mdx and the skill objectstack-ui/rules/dashboards.md, both /reports/forecast; the examples declare no dashboard header at all; the only onSuccess.navigate is the documented /apps/crm/tasks/${result.id}; no url-executor page action row exists outside tests (every type: 'url' hit is a registered action, already closed by feat(spec)!: a type:'url' action's target declares its closed scheme set — http(s)://, mailto: and in-app paths — at both url-action doors #23076, or a navigation item). Objectui at 7578323a544e: no actionType: 'url' producer, no header-action literal, no navigate: literal outside tests; useHomeInbox's actionUrl is the inbox message key, a different shape. The only refused literals anywhere are tests that exercise the refusal.

  8. The describe text, the TSDoc and the three regenerated reference pages — right. Each key now states the set it enforces; content/docs/references/** is generated output, not hand-edited, and check:docs is what the dev's check:generated named.

  9. dropped-refinements.baseline.json, +21 sites, totals 231 / 734 — right and consistent. Counted in the diff: 12 onSuccess positions and 5 header.actions.element positions across 13 existing entries (17), plus 4 new root entries (ui/ActionButtonProps, ui/ActionIconProps, ui/DashboardHeader, ui/DashboardHeaderAction). The member-level row refinement lands on the existing actions.element site of ui/ActionGroupProps and ui/ActionMenuProps, so no new site there. No site is removed, which is the ledger's shrink-only direction; build-schemas enforces it and Build Core is green.

  10. Check-runs on this head, as read at this write: 40 total; 34 success, 5 skipped (Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance, each path- or event-skipped by design), 1 in_progress (Test Core 3/6), 0 failures. Green: Lint & Repo Gates, TypeScript Type Check and its four shards, Build Core, Dogfood Regression Gate, Governed Surface Queue Guard, Check Changeset (which runs check-adr-0087-registration --base MERGE_BASE), Spec property liveness, every Lint shard, and Test Core shards 1, 2, 4, 5 and 6. The queue's own "every check green" condition waits on the last shard; that is the seat's pre-queue read, not this record's.

② Semver level

③ Boundary flags

open_questions: none declared. The dev's mechanism readings A1 (the onSuccess.navigate line corrected to action.zod.ts:1785), A2 (red at base shown through three one-time ablations, each restored to HEAD) and A4 (the ${ deferral pinned) are accepted as read; A3 is judged in ① item 4. The six out-of-scope findings, each answered:

  1. objectui DashboardRenderer's header click handler keeps its own url grammar and bypasses the ActionRunner (a protocol-relative //host is assigned, mailto: throws at pushState, a bare relative is admitted). Answered; carried by plugin-dashboard: a header action's click handler keeps its own url grammar and bypasses the ActionRunner (a protocol-relative //host is assigned, mailto: throws) — and it drops an omitted actionType that its own headerActionDefs reads as 'url' objectui#12275, which I read: it asks for the click to go through the runner with no scheme list of its own, and for an omitted actionType to mean 'url' at the click, with pins. The contract door is right to match navigationTargetRefusal and the runner's classifyNavigationTarget rather than this handler; a door written to the handler would be the second grammar the ruling forbids. Stated plainly: until fix(plugin-sharing): require warn on the orphan sweep logger and pin every required warn #12275 lands, a mailto: header action parses clean and throws at the dashboard click, the ruling's own dead-button class, and the omitted-type button stays dead. That is the runtime half, objectui's under ruling A+, and not a condition of this verdict.
  2. The omitted-type dead button. Folded into objectui#12275; same handler, same fix. Answered.
  3. The rows' onSuccess is z.unknown(): onSuccess: { navigate: 'javascript:…' } on an action:button parses clean and the props gate answers 0 findings. Answered; carried by spec: a page action row's onSuccess is z.unknown(), so onSuccess.navigate: 'javascript:…' on an action:button / action:icon / action:group or action:menu member parses and validates clean — the fourth door of ruling A+'s url-target family #23103, which I read. A fourth door, outside this card's three; it needs a contract choice for the rows' onSuccess shape, a claim of its own and not a rider here.
  4. actionType: 'navigation' on a page action row reaches executeNavigation, an alias of url, with target unjudged. Answered as a residual, not a blocker; a carrier is owed. The rows' executor key is a free z.string(), so the alias spelling admits what the click refuses (executeNavigation to navigateTo to classifyNavigationTarget, pin lines 1150, 2221 and 1964). The card and the claim scope the rows to 'url', and no producer writes it, so it is outside this PR. Because it is a measured contract-admits-what-the-runtime-refuses shape, it belongs on spec: a page action row's onSuccess is z.unknown(), so onSuccess.navigate: 'javascript:…' on an action:button / action:icon / action:group or action:menu member parses and validates clean — the fourth door of ruling A+'s url-target family #23103's family thread or a finding of its own rather than in Acceptance notes alone. The seat's call; it does not change this verdict.
  5. Three validate-dashboard-action-refs.test.ts fixtures now pin raw objects the stack parse refuses first. Answered. They feed the lint rule directly and pin that rule's own reading (actionType ?? 'url'; a protocol-relative value treated as external and skipped); they remain valid unit pins and no longer describe a reachable stack. Test hygiene, not a contract surface.
  6. action.form.ts's help text ("A relative value is an in-app route") and the props gate's generic component-props-invalid sentence read loosely. Answered as drift. The help text is Studio copy under packages/spec/src/ui/ and now points toward a bare relative value the parse refuses; the parse's own message carries the correct prescription, so an author is not left without the fix. Owed on the next edit, as the seat recorded; not a condition of this verdict.

File surface beyond the claim's literal list: the baseline ledger (hand-edited by design, no gen: script), the three D3 entries (owed by check-adr-0087-registration once the changeset carries a FROM → TO prescription) and the three regenerated reference pages. Each is a consequence the claim's "generated artifacts check:generated names" and the changeset's registration imply, and each is declared in the PR body and the report. No governed path is touched (the file list has no .claude/**, docs/adr/**, skills/**, AGENTS.md or CLAUDE.md), so this record serves the published-schema face and the (narrowing) declaration, not Tier S.

Implemented-by: claude/issue-23077-url-target-doors
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS


Generated by Claude Code

…l-target-doors

packages/spec/dropped-refinements.baseline.json conflicted (both sides add sites). The merge takes main's side whole; the next commit re-derives this branch's sites from build-schemas' own printed entries on the merged tree.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <noreply@anthropic.com>
…onent reference

The merge took main's ledger side whole (228 schemas / 731 sites, #23098's
18 emptyState.link.url sites among them). build-schemas on the merged tree
then printed this branch's 21 sites as undeclared or moved: 4 new entries
and 17 sites in 13 existing entries. They are applied exactly as printed,
and the header totals now match the body: 232 schemas / 752 sites.
gen:docs regenerates ui/component.mdx from the merged tree.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e9ff53c82db9703a18425a4fb2e36fb630ee4a7b
Local-runs: none

Delta record on the merged head, rendered 2026-10-13T01:55Z by an isolated contract-review subagent of domain:spec seat 1 (account os-warren). It re-judges the previous record 6133623543 (PASS on ed59283ff) after the dev merged origin/main 4a87d0908 at e6d823803 and re-derived the ledger and one reference page at e9ff53c82. Read-only. Inputs: the previous record; card #23077's comments from the acceptance 6133420836 on (the seat's state note 6133884874 and the dev's merge-round report 6136235748); the PR's body and REST state; the net diff against main at the merge base 4a87d0908; the delta ed59283ff..e9ff53c82, read as the merge commit's two parent diffs and its conflict resolution plus the regeneration commit whole; and the check-runs on this head. The ledger versions were compared as JSON site sets, and the three files both sides changed were three-way text-merged from git show blobs in scratch; both are diff reads. Nothing was built, run or re-run, and no worktree was made. The dispatch order and the dispatching seat's conclusions were not inputs.

① Derived judgments

The question for a delta record is whether the merged head changes any accept set or public surface relative to what 6133623543 judged. It does not. The predecessor's items 1 through 9 carry forward by reference, re-anchored below where the delta touches them.

  1. The net diff against main is still this card's 13 files, +559 / −21 = 580 changed lines, and the published source is the reviewed change. git diff 4a87d0908 e9ff53c82 names the same 13 paths as git diff 6662e3f59 ed59283ff. Ten of the thirteen blobs are identical at ed59283ff and e9ff53c82: the changeset, the three D3 entries, action.zod.ts, dashboard.zod.ts, both new test files, action.mdx and dashboard.mdx. Of the three that changed, component.zod.ts is a clean three-way text merge (main's window carried docs(spec): PageHeaderProps.actions says an authored list is the whole header #23102's PageHeaderProps.actions TSDoc and describe, in a different region from the four target rows); its per-hunk net change against main is identical to the reviewed one apart from line numbers, +35 / −7 both times, and none of main's lines is removed. The other two are the ledger and component.mdx, judged next. navigation-target.ts is one blob (4e17e5e83) at the merge base, at main and at the head, so the shared grammar every door calls is unchanged. The delta adds, removes and renames no export.

  2. The ledger re-derivation is exact: main's sites are all kept, the branch's 21 are the four new schemas plus the moved sites, nothing is dropped, and the header equals the body. Read as sets of (schema, site): base 6662e3f59 227 / 713; branch ed59283ff 231 / 734 (+21, −0 against base); main 4a87d0908 228 / 731 (+18, −0 against base, all 18 ending in url: 17 emptyState.link.url positions and the new root entry ui/EmptyState with link.url, feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098's); head e9ff53c82 232 / 752. The head's set is exactly the union of main's and the branch's; every main site and every branch site is present; the head's additions against main are the identical 21 the branch added against base; the two addition sets do not intersect. The 21: four new entries with one site each (ui/ActionButtonProps and ui/ActionIconProps at the root, ui/DashboardHeader at actions.element, ui/DashboardHeaderAction at the root) and 17 sites in 13 existing entries (an actions.element.in.onSuccess position in 12 entries, a dashboards.element.header.actions.element position in five of those, and header.actions.element on ui/Dashboard), matching the report's "4 undeclared schemas and 13 entries with moved sites (17 sites)". In every version the measured header equals the body it describes (232 schemas, 752 sites at the head; refinementSitesThatDidProject 369 throughout), the entries and the sites within them are in sorted order, and the description text is main's. The merge commit's ledger blob is main's (217d99991 at both 4a87d0908 and e6d823803), as the report says; a plain three-way text merge of the three ledger blobs conflicts only on the header's two counts, so the conflict the state note named is real and was the only one. Build Core and Test Core are green on this head, where build-schemas and scripts/dropped-refinements.test.ts hold the ledger to the built tree.

  3. content/docs/references/ui/component.mdx at the head is the generated merge of both sides. A plain three-way text merge of the base, branch and main blobs is clean and byte-identical to the head's blob; the regeneration commit's change to the file is, hunk for hunk, main's own window change to it (feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098's emptyState.link row and the link?: object member on ObjectGridProps.emptyState, docs(spec): PageHeaderProps.actions says an authored list is the whole header #23102's PageHeaderProps.actions sentence); the net change against main is the branch's four target describes and nothing else, the same hunks the predecessor read. One precision note on the report's wording: the committed merge e6d823803 holds the branch's blob of this file (e111c5fa0, the os-regen driver's "our side"), not main's; main's side went to the hand-off worktree only, which is what os-regen-merge.sh step 2 does, and the regeneration commit restores main's rows. That is the deferral sequence AGENTS.md prescribes for a routed path, and the head is what this record judges. The merge commit differs from both its parents at exactly one path, component.zod.ts, whose blob equals the clean text merge; every other path is one parent's side.

  4. What main brought that touches a url-bearing key, and whether it needs the shared grammar here: nothing does. The window 6662e3f59..4a87d0908 changed nine non-test files under packages/spec/src; none is dashboard.zod.ts, action.zod.ts or navigation-target.ts, and the only door file of this card it touched is component.zod.ts, in PageHeaderProps. Of the url-bearing keys new on main: (a) feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098's emptyState.link.url (view.zod.ts, taken by reference from page.zod.ts) already calls navigationTargetRefusal by relative import with its own subject, declares no second predicate, and its own test pins the link's verdict equal to a type: 'url' action's, sentence for sentence. It is a further door of ruling A+'s family already on the one grammar, and it needs nothing from this PR. The one difference is at the empty string, where feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098 answers with .min(1) ("is empty") before the predicate and this PR's three doors let the predicate refuse it as a bare relative: the same verdict, a different first sentence; harmonising that one sentence across the family would be a separate card, not a rider here. (b) docs(spec): branding.logo is the app's one square icon (1:1, SVG preferred, at least 64 px) #23095's branding.logo is a describe-only change to an existing z.string() naming an image asset the app draws, not a target the action runner opens; it is outside the family. (c) docs(spec): PageHeaderProps.actions says an authored list is the whole header #23102's PageHeaderProps.actions is a list of action ids with a new describe; no url. The ActionSchema and dashboard embeddings that main's object.zod.ts and manifest.zod.ts edits pass through are intact, which the ledger shows by carrying the new onSuccess and header.actions.element sites on data/Object, kernel/MetadataTypeRegistryEntry and the four api/*Package* entries. In packages/lint, the window touched neither validate-component-props.ts nor validate-dashboard-action-refs.ts; checked-parse.ts gained one exported string. The new lint test and the 25-test spec file are unchanged blobs and ran green on this head.

  5. Check-runs on this head, as read at this write: 48 total, 41 success, 7 skipped, 0 failures, 0 in progress. Green: Lint & Repo Gates and its eight shards, TypeScript Type Check and its four, Build Core, Build Docs, Test Core and all six shards, Dogfood Regression Gate, Governed Surface Queue Guard, Check Changeset (twice; it runs check-adr-0087-registration against the merge base), Spec property liveness, the four claim checks (each run twice), Check PR Size, Auto Label, Check Documentation Links, Flag docs affected by code changes and filter. Skipped by design: Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance (each path- or event-skipped on pull_request), and the second Auto Label and Check PR Size runs from the body edit at 2026-10-13T01:43Z, whose first runs are green. The last check-run completed at 2026-10-13T01:45Z.

② Semver level

Unchanged. The delta changes nothing that is published: the changeset .changeset/23077-url-target-doors-scheme-set.md is the same blob (@objectstack/spec minor, the BREAKING banner, Clause-②: no (narrowing), adr-0087: registered naming the three D3 ids), the three D3 entry files are the same blobs, and the published schema source differs from the reviewed diff only in component.zod.ts's line numbers. The PR body still carries Clause-②: no (narrowing). Main's window added seven changesets and no migration entry, so the 18.* ids collide with nothing; Check Changeset is green on this head. The predecessor's ② reasoning on the arm and the level stands as written.

③ Boundary flags

The merge-round report 6136235748 declares open_questions: [] and one out_of_scope_findings row, "nothing new, round 1's findings stand". Accepted: the predecessor's six ③ answers carry forward by reference (objectui#12275 for the header click handler and the omitted-type dead button, #23103 for the rows' onSuccess, and the three residuals kept in Acceptance notes), and the delta changes none of them. The report's own claims, each checked against the bytes:

  1. Two commits on top of ed59283ff, no rebase, no force-push: the PR's eleven commits keep the nine earlier shas; e6d823803 has parents ed59283ff and 4a87d0908; both new commits carry the model-free session and co-author trailer pair. Held.
  2. The one conflict was the ledger, resolved by taking main's side whole and re-deriving: held (① items 2 and 3).
  3. The counts 227 / 713, 231 / 734, 228 / 731 and 232 / 752, "feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098's 18 kept 18/18, this branch's 21 re-derived 21/21": held, by set comparison.
  4. "The delta against the merged main is unchanged, 13 files, +559 / −21": held.
  5. "The PR reads mergeable_state: clean": held on a re-read immediately before this write (clean, mergeable: true, not draft, auto-merge off). The first read of this round had answered unstable with the combined commit status pending over zero statuses and no red or pending check-run behind it; the body had been edited at 2026-10-13T01:43Z (the merge-round line the report proposed is now in the Verification record), which re-ran the edit-triggered workflows, and the earlier value was that window's cache. The seat's pre-queue read re-reads it; it is not a condition of this record, whose condition is the check-runs.
  6. "origin/main has since moved 11 commits further (bcebd8409); only chore(objectui): move the console pin to 1fb1ced36243 (objectui main tip, Half 1 of #23104) #23110 overlaps a branch file, comment-only": held at this read. The eleven commits touch two of the thirteen files, action.zod.ts and component.zod.ts, with zero non-comment lines changed (the objectui pin citations move from 7578323a5 to 1fb1ced36); a plain three-way text merge of each over 4a87d0908 is clean, and no os-regen-routed path is changed on both sides, so the queue's server-side rebuild faces no driver question. One consequence for the record: the predecessor's item 2 read objectui's stripImportedDefaults at the pin 7578323a5; the pin on main is now 1fb1ced36, and whether the cited files are byte-identical across the hop is chore(objectui): move the console pin to 1fb1ced36243 (objectui main tip, Half 1 of #23104) #23110's own stated reading, judged by the Console Pin Gate after the merge and not re-read here.
  7. The test and gate figures (spec 660 files / 20010 passed, lint 138 / 6617, dispatch-gates 111 of 112 run with check:type-check-debt not measured by order) are the dev's own evidence; the verdicts this record takes are the head's check-runs, all green.

File surface and governance are as before: no governed path among the 13 files (no .claude/**, docs/adr/**, skills/**, AGENTS.md or CLAUDE.md), head repo equals base repo, 580 changed lines under the human-merge threshold. This record serves the published-schema face and the (narrowing) declaration, not Tier S.

Implemented-by: claude/issue-23077-url-target-doors
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit f931e76 Oct 13, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23077-url-target-doors branch October 13, 2026 02:25
os-justin pushed a commit that referenced this pull request Oct 13, 2026
…rom the merged tree

origin/main (287fd6e) brought PR #23101's component rows; the branch's
retirement removes the view-level api arm from the same page. Regenerated
with gen:docs on the merged tree per scripts/pm/os-regen-merge.sh step 4.

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

2 participants