Skip to content

fix(spec)!: page action rows carry ActionSchema's declarative-update rules, read from one judge - #23494

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-23427-row-declarative-update-rules
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-23427-row-declarative-update-rules

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23427

Clause-②: no (narrowing)

The page action rows are action:button, action:icon, and each action:group / action:menu member. They now apply ActionSchema's declarative-update rules through one judge that both read. That closes the five members this closing card names and gives every rule in the set a verdict on the rows. Direction of record: the family's triage (6154972534, consistent with #23168 answer C 6148462268) and this card's triage (6164739115).

What changed

  • One judge, in one place. packages/spec/src/ui/action-declarative-update.ts holds the declarative-update rules and their texts. It replaces the table and refinement that lived privately in action.zod.ts. ActionSchema calls it with its own surface, and each row calls it with the row's surface.
    • A surface is the executor key (type / actionType), whether that key defaults to 'script', the onSuccess subject, and whether params has a static-values object form.
    • The module is reached by relative import only, like action-on-success-executor.ts, and is not in the ui barrel.
  • ActionSchema is byte-identical. A one-time corpus dump compared before and after: 152 action shapes covering every rule, every refused key, every type, and their combinations. Before (at 7cfc3400c) and after the extraction, both dumps hold 572 issues with sha256 c359828f357004883ce1b0ac4df598190839bcfdaa87898d094eb7469f54027d, and cmp reports the two files byte-identical. action-row-update.test.ts is unchanged and green.
  • The rows (component.zod.ts) run the judge first, as the runner reads operation first: .superRefine(rowDeclarativeUpdate(...)) on all four rows. A row's patch is now ActionSchema's own record, by reference (ActionSchema.in.shape.patch.unwrap()), the same way spec(ui): a page action row with operation: 'update' and onSuccess parses, while ActionSchema refuses the same shape — the rows type operation as z.unknown() and never carried the rule #23332 took operation.
  • Operation first. Beside operation: 'update', the rows' url-target judgment, the 'navigation' rename and the executor half of the onSuccess rule stand down. The judge refuses the keys they would judge (target, any executor but 'script', onSuccess) whole. So a row is never told both to write actionType: 'url' and to drop the key. Nothing becomes accepted: each standing-down case is refused at the same key by the judge.

H1: every rule of ActionSchema's set, with its verdict on the rows

The rules are enumerated from the code (refuseDeclarativeUpdateContradictions and its refused-key table at 7cfc3400c), not from the docblock.

# ActionSchema rule where it lands verdict on the rows
1 patch without operation: 'update' patch carried on all four rows (card member 1)
2 beside the update, an executor other than 'script' type carried at the row's executor key, actionType on a node and type on a member (card member 3). Reasoned difference, text only: a row's key has no default, so the text says the key "names that route or nothing" where the action's says "stays at its default 'script'". An empty row key names nothing, as effectiveRowExecutor already reads it. A host-handler name is refused too, because the runner never dispatches it beside the update.
3 refused key target target carried (card member 2)
3 method, bodyExtra, bodyShape own key carried on all four rows. Not in the card's five, but in the set.
3 recordIdField own key carried on action:button. Not applicable on action:icon and members, which do not declare it, so the strict row refuses it as an unknown key.
3 onSuccess onSuccess carried since #23332, now read from the same table with the row's subject (one issue, same text as before)
3 body, recordIdParam, outcomeMessages, opensInNewTab, newTabUrl own key not applicable: no row declares them, so the strict row refuses each as unrecognized_keys before any refinement runs (pinned)
4 beside the update, nothing to write patch carried (card member 4). Reasoned difference: on action:button / action:icon a non-empty object params counts. On a node, params IS properties.params, the static values the renderer forwards (readStaticParamValues), and the runner's update merges them over patch (executeUpdateOperation). On a member an object params never reaches a non-api executor, so it does not count.
5 beside the update, a list_toolbar location locations.N carried on all four rows, which all declare locations (card member 5)
— objectName (deliberately not judged in the schema; defineStack's walk judges stack.actions) — not carried, for the action's own reason: a row acts on its own objectName or on the page's object, which the row cannot see. See Acceptance notes.
— the patch key's own schema, a record patch carried by reference. Rule 4 reads a present patch as something to write, which holds only for a record. The runner drops any other patch before the write.

Neighbours outside the set that read operation:

  • The "a script action needs body or target" rule has nothing to judge on a row, because a row with no executor takes the runner's name leg.
  • The undoable rule is not carried by any row; that is outside this family (Acceptance notes).

H2: how the rows spell things (measured)

  • Executor. A node spells its executor actionType and a member spells it type. A node's type is the component discriminator.
  • Keys the rows forward. Every row declares and forwards operation, patch, params, target, method, bodyExtra, bodyShape, locations and onSuccess; action:button also forwards recordIdField. Measured at objectui's .objectui-sha pin b9814b334: the forward blocks in action-button.tsx, action-icon.tsx, action-group.tsx and action-menu.tsx (first line type: schema.actionType / type: action.type).
  • locations. All four rows declare it, the same ActionLocationSchema.
  • What the runtimes read first. The runner's ActionRunner.execute reads operation before any executor (:1384-1388 at the pin, byte-identical at objectui main 524136e). executeUpdateOperation posts through the registered script dispatch and merges patch only when it is a plain object. The platform action route treats operation: 'update' as the write "whatever type says" (isDeclarativeUpdateAction).

H3: why not call refuseDeclarativeUpdateContradictions as-is

Its paths are relative to the refined schema, so a member's issue lands at actions.N.KEY. Four things still kept it from being called on a row unchanged:

  • it read the executor from type;
  • its onSuccess text opens with the action's subject;
  • five of its texts prescribe type: 'api' / type: 'script', which a node must spell actionType: '…', because on a page component type is the component;
  • its nothing-to-write check reads arrays only.

So the judge is extracted and parameterized. Each text is a function of the surface, and on ActionSchema's surface it is the action's text byte-for-byte (proof above). The rows' pins derive the expected text from ActionSchema's own parse; they do not restate it.

H4: producer sweep (counts)

where hits notes
examples/** 0 page action rows with patch or operation Two files carry action rows (app-showcase ui/pages/index.ts, ui/actions/index.ts), and neither has either key. The 6 operation: 'update' hits are bulkActionDefs and a permission set.
CLI corpus (packages/cli/src/lint/corpus.ts) 0 no action rows
dogfood fixtures (packages/qa/dogfood) 0 one ledger names the rows in prose only
packages/** test fixtures 2 landed pins put a non-'script' executor beside the update spec component-action-row-on-success-executor.test.ts: its BESIDE list had api/run, giving 16 cases plus 4 "ONE TEXT" cases that read an api row. lint validate-component-props-row-on-success.test.ts: 2 it.each over 4 rows with api. Both now name 'script', and the other executors beside the update are pinned as refused in the new file. The unknown-members ledger moves patch to patch{} (records).
skills/**, hand-written docs 0
objectui (read-only, after git fetch) 0 spec-parsed rows At the pin b9814b334 and at main 524136e (same list at both), 63 files mention operation: 'update', and 15 of them also mention a page action row. None parses such a row against the spec; the spec reads are key-set reads and safeParse({ size }). One renderer test, action-button-icon-inputs-11168.test.tsx, mounts { actionType: 'script', patch: { stage: 'done' } } (member 1's shape) at runtime as a control that nothing is written. No spec parse judges it, so no objectui test goes red at the next spec bump. objectui's own ActionDefaultInspector already lists the same refused keys and list_toolbar (UPDATE_OPERATION_REFUSES).

ADR-0087

Tests

All readings come from the verify lock on a shared box. Each exit code was captured before any pipe.

At the final head d4f80dc9d (after merging main at 1baf976cf):

  • @objectstack/spec unit layer (vitest run --project local --maxWorkers=2): 677 files, 20,691 passed, 1 todo, exit 0.
  • @objectstack/spec typecheck (tsc + scripts + test-typecheck): exit 0.
  • @objectstack/lint unit (vitest run --maxWorkers=2): 139 files, 6,726 passed, exit 0. This layer includes the props-gate door pins.
  • Full build (turbo run build --filter=!@objectstack/docs --concurrency=2): 75/75 tasks, exit 0.

At 2541bab8c (after merging #23454's step-18 entry):

  • spec unit: 677 files, 20,688 passed.
  • spec typecheck: exit 0.
  • lint unit: 139 files, 6,721 passed, 5 skipped.
  • lint typecheck: exit 0.
  • @objectstack/cli unit layer (--project unit): 284 of 287 files passed. The other three were not a reading of this diff:
    • published-subpath-console.pin and published-subpath-hook-body.pin refused because packages/cli itself was unbuilt (a prerequisite);
    • hook-timeout-override-refusal timed out twice at 5 s while spawning vitest on the loaded box.
    • After pnpm --filter @objectstack/cli build, those three files: 33/33 passed.
  • The integration layer is declared to CI.

The new pins: component-action-row-declarative-update.test.ts, every row, with code and path asserted at the parse:

  • the five members, each a REFUSAL and a CONTROL;
  • ONE TEXT, derived from ActionSchema's own parse;
  • the table's other row keys, and the undeclared keys refused as unrecognized_keys;
  • the executor edges, params as the source of values, and patch as the action's record by identity;
  • the operation-first stand-down, with a control.

Six targeted files (the new pins, #23332's pins, action-row-update.test.ts, the unknown-members ledger and two neighbours): 434 passed.

Ablation (one, on the rows' call)

On the committed tree, scripts/ablation-replace.mjs replaced action:button's superRefine(rowDeclarativeUpdate('actionType', …)) with superRefine(() => undefined).

Gates

Derived set. node scripts/pm/dispatch-gates.mjs --commands (no paths; the change set comes from the merge base) gives 111 families, run at the final head d4f80dc9d with the tool's capture idiom.

  • All 111 exit 0. --ran reconciles them as 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every family recorded an exit code).
  • The run was split by an interrupted session at line 88. Lines 89-111 were re-run on the same head with the same derived list (byte-identical).
  • Two families exited 3 (PREREQUISITE NOT MET) in a first battery at 2541bab8c: check:skill-examples and check:dual-build-cjs-loads. After the full build, both exit 0 at d4f80dc9d.

Targeted spec gates.

  • pnpm --filter @objectstack/spec check:generated: all 14 artifacts up to date.
  • node scripts/check-adr-0087-registration.mjs --base origin/main: registered ui-page-action-row-declarative-update-rules (new here).

Stale tree, declared. origin/main moved 8 commits past this head while the battery ran, with no file in this PR's surface. They changed four inputs of the derivation (scripts/check-durability-degradation-log-level.mjs, scripts/measure-durability-swallow-family.mjs, scripts/engine-double-contract.pinned.json, and one scripts/adr-anchors/ JSON). CI's runs on the merge ref are the reading for those families.

Deviations from the claim's file surface

  • packages/lint/src/validate-component-props-row-on-success.test.ts (fixture only). The dispatch fences packages/lint/** off; os-dev.md's fixture-triage clause requires sweeping by the rule's consumption radius.
    • Its two update pins put api beside operation: 'update', card member 3 itself, so they would go red. They now name 'script'.
    • No lint source changed: the props gate still reads the row schemas as they are.
  • 18.ui-page-action-row-operation-declared.ts: the one-clause amendment above.
  • The row patch by reference. An in-place fix of the same defect class (rows accept what ActionSchema refuses on the declarative update), applied under os-dev's four conditions:

Acceptance notes (observations, not filed)


Generated by Claude Code

…hared module, texts byte-equal

The mixing rule between `operation: 'update'` and the rest of the action
vocabulary now lives in `action-declarative-update.ts`, parameterised by a
surface (the executor key, whether it defaults to 'script', the onSuccess
subject, whether params has a static-values object form). ActionSchema
applies it with its own surface; every issue it raises is unchanged
(152-case corpus, 572 issues, same sha256 before and after).

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…rules from the one judge

action:button, action:icon and each action:group / action:menu member now
apply the shared declarative-update judge on their own surface: patch only
beside operation 'update'; beside it no executor but 'script', no target,
method, bodyExtra, bodyShape or (action:button) recordIdField, something to
write, and no list_toolbar location. A row's patch is ActionSchema's own
record by reference. The rows' url-target, executor-alias and onSuccess
executor judgments stand down beside the operation, which the runner reads
first. Landed pins that put api / a host handler beside the update now name
a valid executor; the unknown-members ledger drops patch for patch{}.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…e version cut wrote it

The merge followed main's rename into .changeset/pre/ and carried an edit
into consumed stock; that text now lives in packages/spec/CHANGELOG.md
(18.0.0-next.1), which a code PR does not edit.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 33 documentable anchor(s).

6 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 ASpelled; a string literal in ActionButtonPropsSchema; a string literal in ActionIconPropsSchema; a string literal in aSpelled; a string literal in executorKey; a string literal in rowDeclarativeUpdate; a string literal on a changed line), recordIdParam (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line))
  • content/docs/deployment/validating-metadata.mdx (via actionType (literal, a string literal in ASpelled; a string literal in ActionButtonPropsSchema; a string literal in ActionIconPropsSchema; a string literal in aSpelled; a string literal in executorKey; a string literal in rowDeclarativeUpdate; a string literal on a changed line))
  • content/docs/protocol/kernel/i18n-standard.mdx (via outcomeMessages (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line))
  • content/docs/protocol/objectui/actions.mdx (via bodyExtra (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), bodyShape (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), list_toolbar (literal, a string literal in refuseDeclarativeUpdateContradictions), newTabUrl (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), opensInNewTab (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), recordIdField (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), recordIdParam (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line))
  • content/docs/ui/actions.mdx (via bodyExtra (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), bodyShape (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), list_toolbar (literal, a string literal in refuseDeclarativeUpdateContradictions), opensInNewTab (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line))
  • content/docs/ui/react-pages.mdx (via onSuccess (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal in refuseRowOnSuccessBesideExecutor; a string literal on a changed line))

⛔ 7 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 ASpelled; a string literal in ActionButtonPropsSchema; a string literal in ActionIconPropsSchema; a string literal in aSpelled; a string literal in executorKey; a string literal in rowDeclarativeUpdate; a string literal on a changed line))
  • content/docs/releases/v17/17-0.mdx (via actionType (literal, a string literal in ASpelled; a string literal in ActionButtonPropsSchema; a string literal in ActionIconPropsSchema; a string literal in aSpelled; a string literal in executorKey; a string literal in rowDeclarativeUpdate; a string literal on a changed line))
  • content/docs/releases/v17/17-1.mdx (via onSuccess (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal in refuseRowOnSuccessBesideExecutor; a string literal on a changed line))
  • content/docs/releases/v17/17-3.mdx (via newTabUrl (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line), onSuccess (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal in refuseRowOnSuccessBesideExecutor; a string literal on a changed line), opensInNewTab (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line))
  • content/docs/releases/v17/17-5.mdx (via bodyExtra (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line))
  • content/docs/releases/v17/17-6.mdx (via actionType (literal, a string literal in ASpelled; a string literal in ActionButtonPropsSchema; a string literal in ActionIconPropsSchema; a string literal in aSpelled; a string literal in executorKey; a string literal in rowDeclarativeUpdate; a string literal on a changed line), outcomeMessages (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal on a changed line))
  • content/docs/releases/v17/17-7.mdx (via actionType (literal, a string literal in ASpelled; a string literal in ActionButtonPropsSchema; a string literal in ActionIconPropsSchema; a string literal in aSpelled; a string literal in executorKey; a string literal in rowDeclarativeUpdate; a string literal on a changed line), list_toolbar (literal, a string literal in refuseDeclarativeUpdateContradictions), onSuccess (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; a string literal in refuseRowOnSuccessBesideExecutor; a string literal on a changed line), outcomeMessages (literal, a string literal in DECLARATIVE_UPDATE_REFUSED_KEYS; 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
  • 4 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 — 142 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 873151021f640a7a2b8675f2c38ff10ec8a7230b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cb634d5858bded54450fe0f02ff7d52aae1643bc — the merge of head d4f80dc9db96796d8ce8623979140d2ec4cac0e8 into base 873151021f640a7a2b8675f2c38ff10ec8a7230b, 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 cb634d5858bded54450fe0f02ff7d52aae1643bc && git checkout cb634d5858bded54450fe0f02ff7d52aae1643bc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 873151021f640a7a2b8675f2c38ff10ec8a7230b d4f80dc9db96796d8ce8623979140d2ec4cac0e8 && git checkout -B drift-repro 873151021f640a7a2b8675f2c38ff10ec8a7230b && git merge --no-ff d4f80dc9db96796d8ce8623979140d2ec4cac0e8

node scripts/docs-audit/affected-docs.mjs --json 873151021f640a7a2b8675f2c38ff10ec8a7230b

⚠️ 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 873151021f640a7a2b8675f2c38ff10ec8a7230b → 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

席位核对

ACCEPT · domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · head d4f80dc9d · 2026-10-14T13:37Z

The PR queues after one CONTRACT_REVIEW_TIER PASS on this head and a green head. The review is owed because the published-schema face is touched: component.zod.ts, action.zod.ts and the new action-declarative-update.ts. Card #23427, claim 6166100957, dev report on #23427.

1. Shape

  • Draft, base main. Line 1 is Fixes #23427 and line 3 is Clause-②: no (narrowing); no other closing keyword sits beside a card number.
  • 11 files, +806 / -194. check-governed-merges --pr 23494 reads NOT governed (1000 lines).
  • The changeset bumps @objectstack/spec minor, BREAKING. It carries a FROM → TO table and the ADR-0087 disposition registered ui-page-action-row-declarative-update-rules, which is new here.
  • The extracted judge action-declarative-update.ts is not exported from ui/index.ts. No published export is added, and Clause-②: no (narrowing) stands.

2. Premises measured

  • Both runtimes read operation before any executor: objectui ActionRunner.execute :1384-1388, byte-identical at objectui main 524136e, and the runtime's isDeclarativeUpdateAction.
  • ActionSchema's issues are byte-identical across the extraction. A dump over 152 shapes gives 572 issues, with sha256 c359828f… equal before and after.
  • Every rule of the set got a verdict (the table is in the PR body):
    • carried: patch without update; any executor but script beside update; target; method; bodyExtra; bodyShape; onSuccess; recordIdField on action:button; nothing to write; list_toolbar;
    • not applicable: keys the strict rows refuse as unrecognized;
    • reasoned differences: rule 2's text, and the nodes' static-values params;
    • not carried: objectName, because a row cannot see its page's object.

3. Gates

  • Check-runs on d4f80dc9d at this act: 17 success, 5 skipped, 16 in progress, 0 failures.
  • Local:
    • --ran over 111 of 111 families, all exit 0. Lines 89–111 were re-run after the API interruption, on the same derived list.
    • Spec unit: 677 files, 20691 passed.
    • Lint unit: 139 files, 6726 passed.
    • Full build: 75 of 75.
  • Ablation on action:button's row judge: 23 red, all of them refusal pins. Restore verified.

4. Deviations accepted

5. Out of scope

6. Landing: Tier S, ordinary queue, after the contract review's PASS on this head and an all-green head.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs: card #23427 (body; comments 6164739115 triage, 6165777405 unlock, 6166100957 claim, 6169586163 os-dev-report, 6169672385 seat pointer); PR #23494 (body, the 11-file list, comments 6169569498 docs-drift and 6169662525 席位核对); the net diff from the merge base 1baf976cf to the head (the same 11 files as the PR's own diff; origin/main at 873151021 is 8 commits past the merge base, none in this surface); the head's check-runs; committed source on origin/main and the branch; objectui read-only at the .objectui-sha pin b9814b334 (unchanged by this PR) and at objectui main 0a19a68. The seat record was judged, not adopted.

Check-runs on the head as found, not awaited: 39 in all — 24 success, 5 skipped (Console Pin Gate, Temporal Conformance, the Dogfood matrix, Dogfood Verify CLI, Packed-tarball smoke), 10 in progress (Test Core 1-6, Build Core, Type Check · workspace, · consumer gates, · debt ledger), 0 failures. Green and load-bearing here: Check Changeset (the job that hosts the ADR-0087 disposition step), Governed Surface Queue Guard, Lint & Repo Gates, Spec property liveness, the two claim checks.

① Derived judgments

(a) The four rows refuse the card's five shapes, plus the carried keys — right. The judge refuseDeclarativeUpdateContradictions(surface) is applied by .superRefine(rowDeclarativeUpdate(…)) first in the chain on ActionButtonPropsSchema and ActionIconPropsSchema (executorKey: 'actionType') and on the action:group / action:menu member schemas (executorKey: 'type'), so a member's issue lands at actions.N.KEY. Rule by rule, the row's verdict against ActionSchema's for the same intent:

  • Member 1, patch without operation: refused at patch with the action's text (the executor spelled actionType: 'api' on a node). Same verdict, same path. Right.
  • Member 2, target beside the update: refused at target from the one table. Same. Right.
  • Member 3, a url executor beside the update: refused at the row's own executor key (actionType on a node, type on a member) where ActionSchema refuses at type. The card's second path for this member, target, is the action's "a url action needs a target" rule, which no row carries; the row refuses the same intent at the one key it has. Right. The disclosed text-only difference ("names that route or nothing" for "stays at its default 'script'") is correct: the row's key has no .default().
  • Member 4, nothing to write: refused at patch. Same. Right.
  • Member 5, list_toolbar: refused at locations.N; all four rows declare locations as ActionLocationSchema. Same. Right.
  • Carried beyond the five: method, bodyExtra, bodyShape (declared on all four rows), onSuccess (declared on all four; now the table's one issue with the row's subject — the text spec(ui): a page action row with operation: 'update' and onSuccess parses, while ActionSchema refuses the same shape — the rows type operation as z.unknown() and never carried the rule #23332 landed, through the same onSuccessBesideUpdateOperationRefusal call), and recordIdField (declared on action:button only). Each refused at its own key with the action's text. Right.
  • "Not applicable": body, recordIdParam, outcomeMessages, opensInNewTab, newTabUrl, and recordIdField off action:button — no row declares them, so the strict row refuses each as unrecognized_keys before any refinement runs (the rows already rely on the unknown-key refusal being terminal, component.zod.ts at the member params refinement). Refused at the same key, stricter than the action. Right.
  • Reasoned difference, params: on a node a non-empty OBJECT params counts as something to write; on a member and on ActionSchema only a non-empty array does. Verified at the pin: a node's properties.params object is read by readStaticParamValues (static-params.ts:98) and forwarded as the runner's params; executeUpdateOperation (ActionRunner.ts:1751) takes a non-array params as collected and posts { …patch, …collected }. The node's object params ARE written, so refusing that row for "nothing to write" would be false. On a member, readActionEntryParamValues (static-params.ts:164) returns an object params only for type: 'api', which the judge refuses beside the update, so the object never reaches the write; not counting it is right. ActionSchema's own params is z.array(ActionParamSchema), so no shape the action accepts gets a different verdict. Right.
  • objectName not carried: ActionSchema does not judge it either (deferred to defineStack's walk, collectGlobalUpdateActionErrors), and a row cannot see its page's object. The row's verdict equals the action's. Right.
  • An empty executor key (actionType: '' / type: '') accepted beside the update: the rows read an empty key as "no executor" (effectiveRowExecutor), the runner never consults it beside operation, and the rows accepted it before this PR. A surface fact with no ActionSchema counterpart (its enum refuses ''); not a widening. Right.

(b) One place — right; ActionSchema unchanged, by reading. The table and the judge moved whole into action-declarative-update.ts; action.zod.ts keeps only the surface literal ACTION_DECLARATIVE_UPDATE (executorKey: 'type', executorDefaultsToScript: true, the same onSuccess subject string, staticParams: false) and the one .superRefine call at the same position in its chain. Read side by side with the merge base: every text is the old string with type: 'X' / a type: 'X' / A type: 'X' replaced by spell / aSpelled / ASpelled, which on executorKey: 'type' reproduce the old characters exactly; issue order (patch-without-operation then return, executor, the table in the same key order, nothing-to-write, list_toolbar) is unchanged; paramsWrite with staticParams: false reduces to the old "non-empty array". One condition changed: the executor test is typeof executor === 'string' && executor !== '' && executor !== 'script' where it was data.type !== undefined && data.type !== 'script'. ActionSchema's type is z.enum(ActionType.options) with a default, so by the time the refinement runs it is a non-empty member string; the two conditions differ only on a type the enum already refused, where the chain's refinements do not run. The dev's byte-identical 152-shape dump (572 issues, one sha256 before and after) is therefore what the code says; I did not re-run it. action-row-update.test.ts is untouched.
Drift: the rules and the texts exist once; the only per-surface inputs are the four fields of DeclarativeUpdateSurface, each pinned — executorKey by every path assertion, executorDefaultsToScript by "no default" on the rows and "stays at its default" on the action, onSuccessSubject by #23332's ONE TEXT pin (now over 'script'), staticParams by the params pins. The row pins derive their expected texts from ActionSchema's own parse (asRowSpells), so a text change reaches both surfaces at once and a text that stopped translating fails loudly. The module is reached by relative import only — outside the ui barrel, and the package exports map has no wildcard — so no third consumer can take a copy. The two surfaces cannot drift silently.

(c) The sixth narrowing — in scope, correctly disclosed. patch on all four rows is now ActionSchema.in.shape.patch.unwrap(), the action's z.record(z.string(), z.unknown()), by reference (pinned by identity: NO SECOND SHAPE). It is the precondition for rule 4's verdict to be "carried": the judge reads a present patch as something to write, which the runner honours only for a plain object (executeUpdateOperation: anything else becomes {}), so a z.unknown() row patch: 'done' would have parsed clean and written nothing — the silently-dropped class the card's rules exist for. It takes the .in.shape.X.unwrap() pattern #23332 took for operation and onSuccess, in a file the claim names, under the same gate families, with no other claim on the file (the claim's serial-constraints line). Disclosed in the PR body (H1's last row; Deviations), the changeset (FROM → TO row 7 and the prose), the D3 entry's surface ("Also a row patch that is not a record of field values"), the unknown-members ledger (patch{} as records) and the regenerated reference (patch typed as a record). Right.

(d) Standing down — nothing widens. Three row judgments now return early on operation === 'update': refuseUnopenableRowUrlTarget, refuseRowUrlExecutorAlias, and the update half of refuseRowOnSuccessBesideExecutor. Each key they judged is refused whole by the judge at the same path whenever the update is present: target (any value, where the url judgment fired only on a string target beside a url executor), the executor key (any non-empty string but 'script', which covers 'navigation'), and onSuccess (any defined value, with the same text as before). Every shape refused before is refused now at the same key; the one change is the text on 'navigation' beside the update ("leave it off" instead of the rename onto 'url'), disclosed in the PR body and the changeset, and pinned with a control (§4 of the new file). Shapes without operation are untouched. No widening.

(e) Runtime alignment — matches. At the pin b9814b334 (.objectui-sha, unchanged by this PR) ActionRunner.execute tests isUpdateOperationAction(action) (operation === 'update', :960) at :1384, before the handler map and the built-in executors; executeUpdateOperation posts through the registered script dispatch, reads no executor, merges a non-array params over an object patch, and drops a non-object patch. objectui main 0a19a68 is byte-identical to the pin on ActionRunner.ts, static-params.ts and action-button.tsx. The platform's isDeclarativeUpdateAction (packages/runtime/src/action-execution.ts:764) is action?.operation === 'update'. objectui's ActionDefaultInspector already clears the same ten keys (UPDATE_OPERATION_REFUSES) and refuses list_toolbar. The narrowed row contract refuses exactly what neither runtime reads beside the operation and accepts exactly what the write consumes (a patch record; an array, or on a node an object, params). Matches.

② Semver level

③ Boundary flags

  1. packages/lint/src/validate-component-props-row-on-success.test.ts, fixture only — answered, accepted. The claim fenced packages/lint/** for the reason "the component-props gate reads the row schemas as they are", and that reason still holds: no lint source changed. The two fixtures put api beside operation: 'update' — card member 3 itself — in a one-finding pin and an accepted control, so both would go red for the right reason; os-dev.md's radius rule (按规则的消费半径扫 fixture,不按被编辑的包) requires the sweep, and the disposition (api to script) keeps each pin's purpose: one finding at onSuccess, and a clean control. Declared as a deviation, not slipped in. The seat accepted it; so do I.
  2. Release text in packages/spec/CHANGELOG.md 18.0.0-next.1 — escalated. Verified on main: the spec(ui): a page action row with operation: 'update' and onSuccess parses, while ActionSchema refuses the same shape — the rows type operation as z.unknown() and never carried the rule #23332 entry's example { actionType: 'api', operation: 'update', patch, onSuccess } with "the same row without onSuccess … parses unchanged" becomes false at this head (that row is refused at actionType). The PR rightly touches neither the CHANGELOG (written by changeset version) nor .changeset/pre/23332-page-action-row-operation.md (byte-identical to main in the diff after the 0c8c54fd8 restore). The seat routes it to the release owner through the round report; since the 18 GA notes are compiled centrally from changesets and the registries, and the pre/ file is the source a pre-mode exit recompiles from, this needs a tracked carrier before the 18 GA cut — a docs-only PR or a release-board row amending the consumed changeset's example to drop the executor. Not this PR's to fix; not blocking.
  3. The sentence PR feat(lint)!: the two page component rules run on the runtime publish door (ruling B) #23418 will make false — answered. The new entry's reason repeats "a page's properties is not parsed on the metadata save or load path", true at this head (PR feat(lint)!: the two page component rules run on the runtime publish door (ruling B) #23418 is open and draft). A single-card read confirms page: delete checkPageComponentViewData once PR #23418 moves the component-props rule onto the save door — ruling 6161173492 item 6, the follow-up for #23357 #23447's body already lists 18.ui-page-action-row-declarative-update-rules.ts among the sentences its follow-up rewrites, added at the ACCEPT. The carrier exists.
  4. NOT MEASURED observations (承接者:无) — answered; two escalated for filing. (i) An update row on an object-less page with no objectName is judged by no page walk: the right verdict for this card (the row's objectName equals the action's — not judged in the schema); a page-level analogue of defineStack's walk is a separate card if the seat wants it carried. (ii) The action route resolves the DECLARED action by name, so a page row's update writes only when its name names a registered update action: a runtime-contract question outside this PR, read-only inference; worth a finding card so it is measured rather than left in Acceptance notes. (iii) objectui's recordIdField reader beside an update: the contract and objectui's inspector both refuse the key, so the read is incidental; nothing to do. (iv) undoable: action:button declares it, and ActionSchema's undoable refinement is a rule about that key's own preconditions (update or api), not a member of the declarative-update set the card nails; the PR gives it a verdict (not carried, outside the family). The same class-(b) shape on another key — a candidate finding, not this card's member.
  5. Stale tree, declared — acceptable. origin/main moved 8 commits past the head with no file in this surface (the merge-base diff and the main-relative diff name the same 11 files); CI's runs on the merge ref are the readings for the four changed derivation inputs.
  6. Governance and the sibling. No governed path among the 11 files; Governed Surface Queue Guard is green; .objectui-sha is unchanged, and the producer sweep found no spec-parsed row in objectui at the pin or at main, so no sibling fix or pin bump is owed.

Implemented-by: claude/issue-23427-row-declarative-update-rules
Reviewed-by: session_01YRp5z4kK6vgYkrdH97ztsy

VERDICT: PASS


Generated by Claude Code

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/xl tests tooling

Projects

None yet

2 participants