Skip to content

Commit 4d1e4a8

Browse files
feat(spec)!: a page action row's onSuccess is ActionSchema's own block, and its executor key refuses the 'navigation' alias (#23164)
Fixes #23103 Clause-②: no (narrowing) The fourth door of ruling A+'s url-target family (objectstack-ai/objectui#12191: the scheme set "is stated once in the contract and the runtime fulfils exactly that"), and the folded `'navigation'` position (`6133636513`), on the four page action row shapes: `action:button`, `action:icon`, and each `action:group` / `action:menu` member. One grammar, `packages/spec/src/ui/navigation-target.ts`'s `navigationTargetRefusal`; no second shape and no second URL grammar. - **`onSuccess`** on all four rows is now `ActionSchema.onSuccess`'s own block, by reference: the accessor `actionOnSuccessBlock` returns `ActionSchema.in.shape.onSuccess.unwrap()`, read off the object half of the action's pipe. It is the same instance, so a row's verdict on any `onSuccess` value is the action's. `navigate` is judged by `refuseUnopenableOnSuccessNavigate` (since #23101). The block's rename prescriptions and its closed `openIn` come with it. `action.zod.ts` is unchanged, so what `ActionSchema` accepts is unchanged, and no export is added. - **The executor key** (`actionType` on a node, `type` on a member) refuses `'navigation'`, the console runner's local alias of `'url'`, at that key, with the rename onto `'url'`. Every other value stays open: the runner dispatches host-registered handler names first, and this package's released guidance prescribes `actionType: 'navigate_edit'`. ## Mechanism readings (the dispatch's A1 to A3) - **A1 held.** On `main` at `f931e765b`, the three rows declared `onSuccess: z.unknown()`. objectui's runner reads only `ActionSchema`'s block from a row's forwarded `onSuccess` (pin `1fb1ced36243`). `handlePostExecution` runs after every executor, a host handler included. It calls `readOnSuccessNavigation`, which needs a non-array object with a non-empty string `navigate`, and then `navigateOnSuccess`, which classifies the interpolated URL and reads `openIn === 'newTab'`. **No key outside the block is read, so there is no fork.** Sharing needs no import cycle (`component.zod.ts` already imports `action.zod.ts`) and no new export: the pipe's `in.shape` is the pattern `buildObjectFormSectionFieldEntry` already uses for `FormFieldSchema`. - **A2, partly falsified.** `ACTION_TARGET_ALIASES` and `ACTION_NODE_ALIASES` alias KEYS (`url` to `target`, `type` to `actionType`), not executor VALUES. So the contract declares no executor-value set for the rows beyond the row's own describe: six built-ins, plus "a handler registered under another name is dispatched too". The runner's built-in table is the spec's six plus `navigation`, and `navigation` is its only url-like alias. `executeNavigation` reads `to || target || redirect` and hands off to `navigateTo`, the navigator `executeUrl` calls with `target || redirect`. On a row, the alias's own keys are not row keys, so the two spellings run the same navigation on the same `target`. The only contract-side mention of `'navigation'` is `normalizeInlineAction`'s fold on the `element:button` inline action, a bridge kept for cloud tenant pages. - **The A2 choice: the alias is refused; the key is not closed.** Closing the key to `ActionType` would refuse real producers. The console registers `navigate_create`, `navigate_edit`, `create`, `delete`, `navigate`, `refresh`, `crud_success` and `dialog_cancel`, and documents `actionType: 'navigate_create'` on an `action:button`. This package's released CHANGELOG prescribes `actionType: 'navigate_edit'`, and `component-action-member-params-array-only.pin.test.ts` pins it. So that route is a fork, not a fix. Between judging `'navigation'` as a url and refusing it, the four axes give: - **Real demand:** zero rows write the alias, and the rename is lossless. - **Long-term fit:** one spelling per executor. The contract does not encode a renderer-local alias as accepted. - **AI error-proofing:** an AI cannot write the alias, and it gets a loud prescription instead. - **Startup scope:** an alias with no named producer retires at once, with no bridge. All four agree, so the alias is refused. - **A3 held.** `os validate`'s props gate parses `properties` against these same rows, so both refusals reach it with no lint code change. `validate-component-props-row-on-success.test.ts` pins this. ## Producer census Objectstack at `bb41814b8`, objectui at the pin `1fb1ced36243`, tests included: - **`onSuccess` on a row:** no row anywhere writes an `onSuccess` outside the block. - objectui's row tests write `{ navigate: '/app/records/${result.id}', openIn: 'newTab' }`, `{ navigate: '/app/records/${result.id}' }` and `{ navigate: '/tasks' }`; all three are admitted. - The callback shapes (`{ type: 'notify' }`, arrays) appear only in objectui runner tests on `ActionDef`, never on a row. - Objectstack has no row `onSuccess` outside `ActionSchema` contexts. - **`'navigation'` as a row executor:** zero. - Every `type: 'navigation'` hit is an `element:button` inline action, an `InlineActionSchema` test, or an objectui runner or hook test. - In this repo, the only page action rows outside tests are the D2 conversion fixtures in `conversions/registry.ts` (`actionType: 'api'`). - **Executor values outside the six built-ins on rows:** `navigate_edit`, in a spec pin test and in a released CHANGELOG prescription. These stay accepted. - **NOT MEASURED:** cloud, hotcrm and deployed metadata. Neither checkout is in this container. ## FROM → TO | key | before | after | | --- | --- | --- | | `onSuccess` on `action:button` / `action:icon` / `action:group` member / `action:menu` member | any value (`z.unknown()`) | `ActionSchema`'s `{ navigate, openIn? }` block. `navigate` is an `http(s)://` URL, a `mailto:` address, an in-app path starting with `/` or `./`, or a template that opens with `${`. `openIn` is `'self'` (materialised default) or `'newTab'`. Unknown keys get the block's renames (`url`, `redirectUrl`, `to`, … onto `navigate`). | | `actionType` on `action:button` / `action:icon` | any string | any string except `'navigation'`, which is refused at `actionType` with the rename onto `'url'`; `os migrate meta --write` and the stored-row seam rewrite it (D2 `page-action-row-navigation-executor-to-url`) | | `type` on an `action:group` / `action:menu` member | any string | any string except `'navigation'`, which is refused at `type` with the rename onto `'url'`; `os migrate meta --write` and the stored-row seam rewrite it (D2 `page-action-row-navigation-executor-to-url`) | The changeset carries the per-value FROM → TO table and the one-line fix. It is `.changeset/23103-page-action-row-on-success-and-executor.md`: `@objectstack/spec` minor, BREAKING under the launch-window convention. It registers two ADR-0087 D3 entries, `ui-page-action-row-on-success-shape-closed` and `ui-page-action-row-navigation-executor-refused`. The executor leg is lossless, so it also lands as the D2 conversion `page-action-row-navigation-executor-to-url`: `retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`, in `MAJOR_18_CONVERSIONS` at order 63. The D3 entry names it in `conversionIds`. The `onSuccess` leg stays D3 only, because a refused address has no lossless rewrite. ## Verification record ### Fix round, at `f4d39d7aa` All readings are at `f4d39d7aa` unless stated. That head is this branch merged with `origin/main` `d889448b9` through `scripts/pm/os-regen-merge.sh`; three later `main` commits touch none of these 13 files. Every heavy run went through `scripts/pm/os-verify-lock.sh`, and each result is its `VERDICT` line. - **Builds:** - The workspace build: 74 / 74, exit 0. - `@objectstack/spec check:generated`: "All 14 generated artifacts are up to date", exit 0. - The ledger moved only by main's own sites: 234 / 759 after the merge, with this branch's four sites kept. - **Typecheck:** `@objectstack/spec typecheck` and `@objectstack/lint typecheck`, exit 0. - **Tests:** - Spec, full: 663 files, 20149 passed, 1 todo. - Lint, full: 138 files, 6680 passed. - Five spec files, 313 passed: the conversion's own test, `conversions.test.ts`, `retired-after.census.test.ts`, the row pin and `expression-scope-data-root.pin.test.ts`. - The lint row pin: 16 / 16. - `driver-turso`: 88 files, 2373 passed, 33 skipped. - **Ablations:** - Round 1's two, (A) the rows' `onSuccess` block and (B) the alias refusal, stand as recorded at `bb41814b8`. - Round 2 adds two src-only ablations of the conversion, through `scripts/ablation-replace.mjs`, at `de990e9bf`. The node leg's guard was compared to `ablated-23103-conv-node`, and the member leg's container set was emptied. - Each read 3 failed / 261 passed over the conversion test plus `conversions.test.ts`: the fixture pair, and that leg's two stored-row pins. - Each was restored to blob == HEAD with an empty `git diff HEAD`. - **Gates:** `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 111 at `f4d39d7aa`. - 110 ran, all exit 0. - `check:type-check-debt` is NOT MEASURED, by order. - The `--ran` reconcile reads "111 derived, 110 run, 1 NOT-MEASURED, 0 UNRUN". - `check-adr-0087-registration` registers both D3 ids. `check:spec-changes` reads 125 converted / 436 migrated. - **Size:** 13 files, +899 / -37 = 936 changed lines. ### Round 1, at `bb41814b8` (kept as recorded) All readings are at `bb41814b8` (this branch merged with `origin/main` `d206d08ca` through `scripts/pm/os-regen-merge.sh`) unless stated. Heavy runs went through `scripts/pm/os-verify-lock.sh`, and each result is the `VERDICT command-exit` line it printed. - **Builds:** - `@objectstack/spec` build: exit 0. - The workspace build (`turbo run build --filter=!@objectstack/docs --concurrency=2`): 74 / 74 tasks, exit 0. - `@objectstack/spec check:generated`: "All 14 generated artifacts are up to date", exit 0. It named only `content/docs/references/**` as stale before `gen:docs`. - **Ledger:** `build-schemas` refused the four new sites and printed the corrected entries. Those four are the whole ledger diff (232 / 752 to 232 / 756). - **Typecheck:** `@objectstack/spec typecheck` (tsc, scripts, test layer) and `@objectstack/lint typecheck`: exit 0. - **Tests:** - `@objectstack/spec`, full (`vitest run --project local`): 661 files, 20046 passed, 1 todo, exit 0. The new `component-action-row-on-success-executor.test.ts` holds 25 tests. - `@objectstack/lint`, full: 139 files, 6670 passed, exit 0. The new `validate-component-props-row-on-success.test.ts` holds 16 tests. - `@objectstack/platform-objects`, full: 70 files, 1096 passed. - **The affected closure, narrowed and declared:** `turbo ls --affected` names 82 packages. That set comes from the package graph, so it is blind to tests that read another package's source from disk. The run here is spec, lint and platform-objects in full, plus every other test file in the repo that names an action row, `ComponentPropsMap` or `onSuccess`: 9 files in 6 packages, all green. They are `example-showcase` (3 files / 14 tests), `client-react` 1 / 13, `cloud-connection` 1 / 16, `create-objectstack` 1 / 37, `mcp` 1 / 11 and `runtime` 1 / 14. The blind spot is a test that builds such a row from computed values. CI runs the full farm. - **Ablations, one per new refusal mechanism.** Each went through `scripts/ablation-replace.mjs` with its restore trap. Each restore ended at blob == HEAD (`fd59b06cce9e`) with an empty `git diff HEAD`. Each mutation leg rebuilt the spec JS dist with `tsup`, because `gen:schema` would refuse the moved ledger sites under the ablation. `ablation-dist-preflight` then found the marker in 6 dist files before the dist leg ran. - **(A) The rows' `onSuccess` block:** the accessor became `z.unknown()`, marked `ablated-23103-onsuccess`. Spec, src leg: 13 failed / 12 passed. That is every `onSuccess` refusal, accepted control, verdict-parity and identity pin, while the executor pins stay green. Lint, dist leg: 4 failed / 12 passed. With the block ablated back to the base's `z.unknown()`, all four rows answer 0 findings at `onSuccess.navigate`, which is the red half. - **(B) The executor alias refusal:** the guard compared to `ablated-23103-alias`. Spec, src leg: 4 failed / 21 passed. Lint, dist leg: 4 failed / 12 passed. - **Restore leg:** a full spec build, then both markers absent from all 232 dist files, the tree clean against HEAD, spec 94 / 94 (the new file plus `component-props-unknown-members.pin.test.ts`) and lint 16 / 16. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 111 at `bb41814b8`. - 110 ran, every exit recorded before any pipe, all exit 0. - Six first exited 3 on PREREQUISITE NOT MET (unbuilt packages) and exited 0 after the workspace build at the same head. They are lint `check:doc-formula-expressions` and `check:doc-security-posture`, spec `check:skill-examples`, and `check:docs-transcript-drift`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. - `check:type-check-debt` is NOT MEASURED, by order. - The `--ran` reconcile reads "111 derived, 110 run, 1 NOT-MEASURED, 0 UNRUN". - `check-adr-0087-registration` registers both D3 ids (new here). `check:objectui-pin-citations` reports 55 asserting citations matching `1fb1ced36`. `check:nul-bytes` is green, and the raw control-byte scan of the 9 touched files finds 0 lines. - **Size:** 9 files, +528 / -37 = 565 changed lines. ## Acceptance notes - **Rider: #23036's `component.zod.ts` TSDoc twin (`6137432578`), with the addendum from #23146's contract review (`6137629159`, ③).** This is declared, from the `domain:spec` seat. The `page:tabs` item `visibleWhen`: - The TSDoc now says the component-node evaluator mounts no `data` root (objectui#9308 ruling B; `SchemaRenderer.tsx` at `1fb1ced36243`). Its cite moves to `containers.tsx:642-649` at `1fb1ced36243`. - The `.describe()` parenthetical "(not the data-source adapter)" became "(the component-node evaluator mounts no `data` root)". `component.mdx` was regenerated by `gen:docs`. - #23146 landed (`02bec6ca86`), and `expression-scope-data-root.pin.test.ts` gains the TSDoc-face assertion for this `page:tabs` twin. - **Files beyond the claim's literal list:** - `component-props-unknown-members.pin.test.ts`: the `onSuccess` line leaves its `runner` ledger. Its §1 second half refuses a line whose member is no longer `z.unknown()`. - The four ledger sites `build-schemas` named. `ui/ActionButtonProps` and `ui/ActionIconProps` gain `onSuccess`; `ui/ActionGroupProps` and `ui/ActionMenuProps` gain `actions.element.onSuccess`. Header 232 / 756. - The regenerated `component.mdx`, and two D3 entries. - From the fix round on contract review `6138862349`: the D2 entry in `conversions/registry.ts`, its test `page-action-row-navigation-executor-to-url.test.ts`, and its step-18 rationale fragment in `migrations/registry.ts.template`. - **A row's parsed `onSuccess` now carries `openIn: 'self'` when omitted, as an action's does.** The runner reads only `=== 'newTab'`, so behaviour is unchanged. objectui composes these rows through `stripImportedDefaults`, which drops the default on its side. - **Not carried, by design:** `ActionSchema`'s rules ABOUT `onSuccess` are refinements of the action, not of the block, so they stay where they are: - `onSuccess` is refused beside a `type` other than `api` / `script`; - `onSuccess` is refused beside `opensInNewTab: true`. The rows' executor set is open and the runner hops after every executor. So `actionType: 'url'` plus `onSuccess` navigates twice, ending at `navigate`, and the same goes for a host handler. This was read from source, not executed. Whether rows should refuse it is a contract question for its own card. The accessor's TSDoc records this. - **The runner's `name` leg.** `ActionRunner.execute` reads the executor as `type || actionType || name`. The node renderers forward `type: schema.actionType` and the containers forward `type: action.type`, so a row with no executor key and `name: 'url'` (or `'navigation'`) reaches the url navigator, and its `target` is judged only at the click. That runtime half was read from source, not executed. Measured at `bb41814b8`: the props gate answers 0 findings for an `action:button` with `{ name: 'url', target: 'javascript:alert(1)' }`, and for the same with `name: 'navigation'`; the `actionType: 'url'` control answers 1. This is the same family, carried by #23168, and it is not fixed here. `refuseUnopenableRowUrlTarget`'s docblock now scopes its guarantee to the executor key. - **objectui `useObjectActions`' host `navigate` handler** reads `params.url` with its own prefix rule (`startsWith('/')`, else under the app base). This is a host handler outside the contract's executor set, read only, with no producer found. - **`check:type-check-debt` is NOT MEASURED, by order.** - **Owed:** a contract-tier review before the queue (Clause-② suspect on `packages/spec/src/**`). No governed path is touched. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 76815b9 commit 4d1e4a8

13 files changed

Lines changed: 899 additions & 37 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a page action row's `onSuccess` is `ActionSchema`'s own post-success block, so its `navigate` is judged by the url scheme set, and the row's executor key refuses `'navigation'`, the runner's alias of `'url'`
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered ui-page-action-row-on-success-shape-closed, ui-page-action-row-navigation-executor-refused -->
10+
11+
**BREAKING**: an accept-set narrowing on two keys of four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). Each forwards its action keys to the console's action runner. Two of those keys still let a row carry a target the runner refuses at the click:
14+
15+
- **`onSuccess`** was any value (`z.unknown()`). The runner reads only `ActionSchema`'s post-success block, `{ navigate, openIn }`, and classifies `navigate` with the url scheme set. So `onSuccess: { navigate: 'javascript:alert(1)' }` parsed clean, and so did a misspelled key or a callback shape, which the runner gives no reading at all. The rows now hold `ActionSchema.onSuccess`'s own block, the same instance by reference, so a row's verdict on any `onSuccess` value is the action's: the scheme set on `navigate`, the rename prescriptions for `url` / `redirectUrl` / `to` / `route` / `path` / `target` / `navigateTo` / `href`, and the closed `openIn` with its `'self'` default.
16+
- **The executor key** (`actionType` on a node, `type` on a member) is a free string, and the rows judged a url `target` only when it read `'url'`. The runner also dispatches `'navigation'`, its local alias of `'url'`, to the same navigator, so `actionType: 'navigation'` with `target: 'javascript:…'` parsed clean. The url executor now has one spelling on the rows: `'navigation'` is refused at the executor key, with the rename onto `'url'`. The key otherwise stays open: a handler a host registers under another name (`navigate_create`, `navigate_edit`, …) is still dispatched by name.
17+
18+
A page's `properties` bag is outside the stack parse. `os validate` judges it through its props gate, which parses the bag against these same rows, so it reports each refusal at the row's own path with the same text, as a `component-props-invalid` finding (that gate's one tier, a warning). A stored page still saves and loads.
19+
20+
### FROM → TO
21+
22+
| before | what to write instead |
23+
| --- | --- |
24+
| `onSuccess: { navigate: 'javascript:…' }` (or `data:`, `tel:`, any other scheme) | an `https://` page or an in-app route, or drop the `onSuccess` — no hop runs a script or renders content |
25+
| `onSuccess: { navigate: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path |
26+
| `onSuccess: { navigate: 'tasks/${result.id}' }` (a bare relative template) | the rooted route, `'/tasks/${result.id}'`, or a `./` path |
27+
| `onSuccess: { navigate: '' }`, or `onSuccess: {}` | the address the hop was meant to open, or drop the `onSuccess` |
28+
| `onSuccess: { url: '/x' }` (or `redirectUrl`, `to`, `route`, `path`, `target`, `navigateTo`, `href`) | `onSuccess: { navigate: '/x' }` |
29+
| `onSuccess: { navigate: '/x', openIn: 'new-tab' }` | `openIn: 'newTab'` — the block's spelling, not the top-level url switch's |
30+
| `onSuccess: { type: 'notify', … }`, an array of callbacks, or a bare string | drop it: the runner reads only the navigation block, so these never ran |
31+
| `actionType: 'navigation'` on an `action:button` / `action:icon` | `actionType: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read |
32+
| `type: 'navigation'` on an `action:group` / `action:menu` member | `type: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read |
33+
34+
**The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. The executor rename is mechanical: `os migrate meta --from 17 --write` performs it through the ADR-0087 D2 conversion `page-action-row-navigation-executor-to-url`, which is retired from the load path, so an author meets the refusal while stored rows and migrated sources are rewritten. It lists the D3 entries `ui-page-action-row-navigation-executor-refused` (which names that conversion) and `ui-page-action-row-on-success-shape-closed`. An `onSuccess` rewrite stays by hand, because which address a refused `navigate` meant is the author's call.
35+
36+
**What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way.
37+
38+
**Measured producers: none refused.** No page action row in this repository or in objectui (at its pin `1fb1ced3`) writes an `onSuccess` outside the block or the executor `'navigation'`. objectui's row tests write `{ navigate: '/app/records/${result.id}' }`, admitted, and every `type: 'navigation'` there is an `element:button` inline action or a runner test, not a page action row. Host-registered executor names are kept: this package's own released guidance prescribes `actionType: 'navigate_edit'` on an `action:button`.

0 commit comments

Comments
 (0)