Repository navigation
The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092
Description
Activity
裁决:同意(YES)—— 行级声明式字段写入立项(维护者 2026-09-01,总监批 #22)
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· 维护者对本批逐字:「同意」。裁定内容:行级 action 获得
bulkActionDefs的声明式对应物 ——operation: 'update'+patch(+visible谓词),单记录、走数据面、用调用者自己的权限、钩子与校验照常触发、undoable有锚点。键形细节(在ActionType加成员还是并列键形、与既有 action 词表的混用规则)由实施 PR 设计并在正文钉死供复审。实施约束(随派发单同行):
- 条款②:YES(路径肢
packages/spec/src/ui/**+ 内容肢扩宽 accept 面)⇒ draft PR +needs:contract-review随可复审增量同笔挂,changeset minor; - 形状镜像批量侧既有词表,⛔ 不发明第二种拼法(feat(spec,lint): a layout section can reference a declared field group instead of copying its members #13897 的组引用同型纪律);
ctx.record.id盖章陷阱已拆 action dispatcher stampsctx.record.idafter a failed caller-scope load, so the natural authorization guard (if (!ctx.record?.id) refuse()) is always true on a row the caller cannot read #14143 独立派发,⛔ 不合卡、不重做;- 排除项维持:⛔ 不走
type:'api'手写路径包装、⛔ 不走一按钮一条 flow; - provenance/授权通道的大题在 A hook cannot elevate, so a hook-written computed column cannot be protected by field-level
editable: false— the guard and the writer are the same door #14010(同批已裁)—— 本卡实施时消费该裁决的方向,⛔ 不重开。
状态转移(同笔)
needs-user-decision→pm:queue;priority:p1domain:spec不动。
Generated by Claude Code
- 条款②:YES(路径肢
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsClaim: PM loop,
domain:specseat (seat post #6017), R3 of this shift
Session:session_0174WZTU6XcFcS7g2kykC53i(GitHubzhuangjianguo)
Branch:claude/issue-14092-row-action-declarative-update
Worktree:objectstack-issue-14092
Domain:domain:spec
File surface (the SPEC half only — contract-first; the executor halves are separate cards the seat files at ACCEPT withBlocked-by:lines):packages/spec/src/ui/action.zod.ts—ActionSchemagains the row-level declarative field write ruled in 5494341350, mirroringpackages/spec/src/ui/bulk-action.zod.ts's existing vocabulary (operation: 'update'+patch, with the action's existingvisiblepredicate andparamsbag;undoablegains its anchor), the key shape designed by the implementer and pinned in the PR body for review (anActionTypemember versus a parallel key form; exclusivity withtype: 'api''starget/method/bodyExtra; whetherobjectNameis required when the action is standalone) · its test file (accept pins, refusal pins with issuecode+path+ first sentence for the illegal combinations, a positive control) · generated artifactscheck:generated --fixproves stale (authorable surface gains keys; thereferences/ui/action.mdxpage; whatever else moves) ·.changeset/*.md@objectstack/specminor (public-surface widening) ·needs:contract-reviewon the PR AND this card in the same stroke once the PR exists. ⛔bulk-action.zod.tsuntouched (the vocabulary is mirrored, not moved); ⛔ themethoddocblock region ofaction.zod.ts(:1306-1308, the/api/v1/sys_api_key/{id}example) untouched — #14093 owns it and is serialized behind this card; ⛔ nopackages/runtime,packages/objectql,packages/restor objectui code — the executor halves are downstream cards; ⛔ #14143'sctx.record.idtrap is landed on its own card, not re-done. Stop on breach; explain in the report.
Container & model: M,mode:subagent,model: fable(CONTRACT_REVIEW_TIER).node scripts/pm/dispatch-gates.mjs --tier packages/spec/src/ui/action.zod.tsat82291fba(19:16Z): "no path-derived mandate" + "Clause ② SUSPECT surface"; tier decided from content — the ruling widens a published accept set.
Clause-②: yes — new keys on a published schema (the mechanical floor) and an accept-set widening; ⛔ no pre-hang, the dual carrier is hung with the reviewable increment.
Serial constraints cleared: open-PR file scan at 19:08Z (24 open PRs, 207 changed-file rows) — none touchesui/action.zod.tsor its test; same-day churn: none (action.zod.tslast8a8e54112026-09-02 15:50Z); lane siblings in flight (#14074 + #14073view.zod.tsconditional, #13916 + #14583field.zod.ts, PR #15029 / #15053 / #15054 queued or landing) file-disjoint; #14093 sharesaction.zod.ts— serialized behind this card (deferral note posted on #14093 in this stroke; fold refused: different defect shape, gate ①); H17 on-hold trigger-file index (#9857 body, 13:47Z): no row namesaction.zod.ts; no remote branch exists for this card (19:16Z).Decision re-read (3 comments): maintainer ruling 5494341350 (2026-09-01, director batch #22, verbatim 「同意」) — YES to a row-level declarative field write mirroring
bulkActionDefs(operation: 'update'+patch+visible; single record; data plane; the caller's own permissions; hooks and validations fire;undoableanchored), with five implementation constraints: Clause ② yes withneeds:contract-reviewand a minor changeset; the shape mirrors the bulk vocabulary and invents no second spelling; #14143 stays separate; thetype: 'api'hand-written path and one-flow-per-button remain excluded; the provenance / authorization channel follows #14010's ruling direction (5494343943:runAssemantics — the write runs as the caller, never system-elevated) without reopening it. Triage 5489956349 / 5490600937: the security trap was split to #14143 (closed completed, engine lane). Premise re-read onorigin/main82291fba(19:16Z):ActionTypeataction.zod.ts:530is['script', 'url', 'modal', 'flow', 'api', 'form']— no update member;ActionSchemacarriesobjectName(:927),type(:948),target(:967),params(:1091),undoable(:1145, "single-record update action"),visible(:1213),method(:1310),bodyExtra(:1336) and nooperation/patchkey;BulkActionDefSchema(bulk-action.zod.ts:156) declaresoperation(:203,'update' / 'delete' / 'custom'), a staticpatch,params,visible,confirmText, with the aliasop → operation. Premise holds; nothing of the ruling has landed.
Generated by Claude Code
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsDispatch (R3 of this shift, 2026-09-03T19:23Z) —
domain:specseat,session_0174WZTU6XcFcS7g2kykC53i, seat post #6017.mode:subagent,model: fable(CONTRACT_REVIEW_TIER), size M, Clause ② yes (dual carrier hung with the PR). The dev leaves its ownClaim:comment below; the assignee stays the PM's.Dispatch text handed to the dev, in brief (ruling 5494341350's content and five constraints are quoted verbatim and marked non-renegotiable; #14010's
runAsdirection is consumed, not reopened):- The SPEC half only:
ActionSchemagains the row-level declarative field write mirroringbulk-action.zod.ts's spelling (operation: 'update'+ staticpatchmerged under the collectedparams, the action's ownvisiblepredicate,undoableanchored); the implementer designs the key shape —ActionTypemember versus parallel key form, exclusivity withtype: 'api''starget/method/bodyExtra,objectNamesemantics for a standalone action — and pins it in the PR body for review, with refusal pins (issuecode+path+ first sentence) for every contradictory combination. - The PR body also states the executor contract the downstream halves must honour (data-plane single-record update as the caller, never system-elevated; hooks and validations fire;
patchunderparams;undoablecaptures prior values) — the seat files the runtime and objectui executor cards at ACCEPT withBlocked-by:on this card. New keys land asplannedin the liveness ledger percheck:liveness's own instruction. - Landing: draft PR
Fixes #14092,@objectstack/specminor changeset (widening, no BREAKING), generated artifacts via--fix, two-limb Clause ② line,needs:contract-reviewon the PR and this card in the same stroke with readbacks, ⛔ no ready flip; report asos-dev-reporthere. ⛔bulk-action.zod.ts, themethoddocblock region (ActionSchema.method's worked example points atype: 'api'PATCH at/api/v1/sys_api_key/{id}— the shipped data API is/api/v1/data/:object/:id, so copying it 404s #14093, serialized behind),packages/runtime/objectql/rest, objectui: untouched.
Generated by Claude Code
- The SPEC half only:
Claim: os-dev seat for this card (dispatched by the PM claim above; the assignee stays the PM's).
Session:session_0174WZTU6XcFcS7g2kykC53i
Branch:claude/issue-14092-row-action-declarative-update(pushed empty as the write-route probe; baseorigin/main8a96e666)
Worktree:objectstack-issue-14092File surface, the SPEC half only, restated with the increments the design forces (each is a consequence of widening
ActionSchema, named here so the parallel seats can serialize on it):packages/spec/src/ui/action.zod.ts— the row-level declarative field write (the key shape is designed in the PR body for contract review) and its refinements; themethoddocblock region (/api/v1/sys_api_key/{id}example) stays byte-identical forActionSchema.method's worked example points atype: 'api'PATCH at/api/v1/sys_api_key/{id}— the shipped data API is/api/v1/data/:object/:id, so copying it 404s #14093.packages/spec/src/ui/action.test.ts(existing enum enumeration) plus a newaction-row-update.test.tswith the accept / refusal / positive-control / no-transform pins.packages/spec/src/ui/dashboard.zod.ts—WidgetActionTypeSchemais derived fromActionTypeby that file's own doctrine, so a dashboard header action would otherwise silently gain the new type with no record to write: one refusal there, pinned.packages/spec/src/stack.zod.ts— the standalone-actionobjectNamedecision (design point d) lives where standalone-ness is knowable: the cross-reference walk overstack.actions[].packages/spec/liveness/action.json—plannedentries for the new keys with anauthorHintnaming the executor halves.content/docs/protocol/objectui/actions.mdx— the hand-written sentence asserting the closed type set.- generated artifacts via
check:generated --fix;.changeset/action-row-update-declarative.md(@objectstack/specminor).
Untouched:
bulk-action.zod.ts,packages/runtime,packages/objectql,packages/rest,packages/lint, objectui,content/docs/releases,skills.
Clause-②: yes
Generated by Claude Code
os-dev-report
{ "issue": 14092, "status": "done", "branch": "claude/issue-14092-row-action-declarative-update", "pr": "https://github.com/objectstack-ai/objectstack/pull/15077", "premise_still_valid": true, "summary": "Spec half landed as draft PR #15077 (head 44e26d67, merges origin/main 101ad2cc; base at claim 8a96e666). ActionSchema gains the row-level declarative field write mirroring bulk-action.zod.ts word for word: `operation: 'update'` (one member; `'delete'`/`'custom'` refused with the reason) + `patch` (static values merged UNDER the collected `params`, passed through verbatim), reusing the action's own `params`/`visible`/`confirmText`/`undoable` (which now names its anchor). Key shape pinned in the PR body: a PARALLEL KEY beside `type`, not an ActionType member — `type` stays at its default `'script'` (the platform action route the write is performed on; executors read `operation` before `type`). Decisive facts: the ruling's and the bulk def's own spelling, and objectui's `ActionRunner.builtinExecutors` typed `Record of RunnableActionType` on purpose, which would hard-block objectui's next spec bump on the executor card if a member were added (the pin gate injects this tree's spec into the console bundle only, so it would not red today). Mixing rule, one refusal per contradiction at its key (code custom, first sentence names the executor): any other explicit `type`, `target`, `body` (carved out of the #4352 rule for a precise message), `method`, `bodyExtra`, `bodyShape`, `recordIdParam`/`recordIdField`, `onSuccess`, `opensInNewTab`/`newTabUrl`, a `list_toolbar` location; `patch` without the operation; the operation with neither `patch` nor non-empty `params`; `type: 'update'` refused on the enum with the prescription; `op`/`values`/`set`/`update` rename onto the canonical keys. `objectName` is judged at stack level (`collectGlobalUpdateActionErrors` in stack.zod.ts: a standalone update action without objectName is refused; embedded ones are bound by their object). Inline page-element actions cannot carry the keys (InlineActionSchema picks neither — pinned). Ledger: `operation` and `patch` are `planned` with authorWarn + authorHint naming the executor halves (check:liveness green, symbol anchors resolve). Docs: protocol narrative page gains the declarative write; check:generated --fix regenerated exactly content/docs/references/** and liveness/state-counts.md. Changeset @objectstack/spec minor, pure widening. needs:contract-review hung on PR and card in the same stroke; both read back (card: priority:p1, pm:dispatched, domain:spec, needs:contract-review; PR: needs:contract-review beside the auto-labels documentation/size/l/tests/tooling/protocol:ui). The card's assignee is the PM's (zhuangjianguo) — untouched. Brief hypothesis falsified: `type: 'script'` explicitly authored beside `operation: 'update'` cannot be refused (a materialized .default is indistinguishable at object level) and is correct anyway — accepted and documented. Not touched: bulk-action.zod.ts, the #14093 method docblock region (diff-verified byte-identical), runtime/objectql/rest/lint, objectui, releases, skills; #14143 not addressed here; #14010 consumed, not reopened. Note for the executor cards: the Studio action form (action.form.ts) does not surface the two keys yet — an authoring-UI follow-up for the objectui/Studio half.", "tests": "All heavy runs via scripts/pm/os-verify-lock.sh (slot issue-14092), exit captured before any pipe, verdict lines quoted. At head 44e26d67 (after the final commit): `pnpm --filter @objectstack/spec build` (check-dts-emitted 34/34); `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2` — 'Test Files 466 passed (466) / Tests 12427 passed (12427)'; `pnpm --filter @objectstack/spec run typecheck` (tsc --noEmit + check:scripts-typecheck + check:test-typecheck 'OK') — wrapper 'VERDICT command-exit 0'. Earlier targeted run of 7 files: 352 passed. check:generated --fix: '2 of 15 artifact(s) stale' -> gen:docs + gen:liveness-counts regenerated; subsequent check:generated exit 0; check:authorable-surface green; check:liveness 'action 48 classified (live 41, planned 4, dead 3)', every anchor resolves. Gate derivation: `node scripts/pm/dispatch-gates.mjs --commands` inside the worktree at 44e26d67 -> 78 commands (68 by path + 7 by kind + 5 whole-tree, 2 shared); all 78 run: 71 exit 0 (incl. check:api-surface, check:authorable-surface, check:docs, check:export-origins, check:llms-txt, check:skill-refs, check:strictness-ledger, check:variant-docs, check:yaml-examples, check:spec-parsed-alias, check:nul-bytes, check:merge-driver, check:published-files, check:changeset-gate-self-tests, check-closing-keyword-parity, check:test-source-alias, check:cross-package-test-inputs, check-changeset-no-major, check-adr-0087-registration, check-empty-changeset, check:engine-double-contract); 7 NOT MEASURED by their own verdict text (workspace closure not built here / no test log): check-dev-prereqs (exit 1), check-test-completeness (exit 3), lint check:doc-formula-expressions and check:doc-security-posture (exit 3, formula/lint dist absent), spec check:skill-examples (exit 1, client-react dist absent; the one fence this diff adds carries no os:check marker so it is outside that gate's population either way), check:dual-build-cjs-loads (exit 3), check:type-check-debt (exit 3) — CI builds the closure and owns those. eslint: targeted `eslint --no-inline-config --format json` over the 3 edited TS files: 3 files, 0 errors, 0 warnings; the repo-wide pnpm lint scan not run (CI's). Reverse verification (fix committed first; the pin file imports ./action.zod and ../stack.zod from src, so no dist in the loop; restore = `git checkout HEAD -- absolute-path` under an EXIT/INT/TERM trap; each restore proven by git hash-object == HEAD blob c21a6e06 and empty git diff HEAD; final tree clean): control 31 passed; leg A unhooked .superRefine(refuseDeclarativeUpdateContradictions) — on-disk marker count 1 -> 0 — '14 failed | 17 passed (31)': the refusal pins red, accept pins green (direction as predicted); leg B deleted the operation+patch declarations — markers 1/1 -> 0/0 — '25 failed | 6 passed (31)': the accept pins red (strict shape refuses the keys as unknown) plus every refusal pin reading an issue at a now-unknown key. PR body read back in full: byte-identical to the file sent except the platform-appended session footer. Worktree removed without force after rm -rf node_modules (tree clean at 44e26d67).", "mcp_calls": "0 — every GitHub read and write went through the repo-scoped REST channel (probe GET 200 at start; comments, PR creation and additive label writes all 200/201); the card body and comments were read from the public issue page payload first (it lagged the two newest PM comments, which REST supplied).", "open_questions": [ { "question": "Key shape (ruling delegated it): parallel key `operation: 'update'` beside `type` (shipped) versus an `ActionType` member `type: 'update'`. Shipped on the parallel key; the reviewer's checklist reads it as a derived judgment.", "options": [ "A (shipped) — parallel key: the ruling's and the bulk def's literal spelling; `type` stays `'script'` (the platform action route) and executors read `operation` first; objectui bumps to the next spec freely and the update action fails loudly at click until its half lands; cost: parsed shape is `{ type: 'script', operation: 'update' }` and an explicit `type: 'script'` beside it cannot be refused (materialized default indistinguishable at object level).", "B — `ActionType` gains `'update'`: one discriminator per surface, no default asymmetry, every existing type-keyed rule composes; cost: objectui's `Record of RunnableActionType` dispatch-table guard turns the next spec bump into a build error until the objectui executor lands (a hard cross-repo coupling the contract-first split does not want), plus refusals needed on InlineActionSchema and DashboardHeaderActionSchema, and it is a second spelling of the bulk def's `operation`." ], "recommendation": "A, because it is the ruling's own spelling, keeps the executor halves landable in any order, and its one asymmetry (explicit `type: 'script'`) is both harmless and the same one #13897 already accepted for `.default(false)` keys. If review prefers B, the refusal table is keyed on one predicate and the PR body names the three extra refusals B needs." }, { "question": "Refusal set beyond the brief's minimum (target / method / bodyExtra / other types): also refused `bodyShape`, `recordIdParam`/`recordIdField`, `onSuccess`, `opensInNewTab`/`newTabUrl`, and a `list_toolbar` location. Keep all, or trim?", "options": [ "A (shipped) — refuse the whole dispatch-shaping family now; each is the reversible direction (allowing later is additive) and each parses today only as an inert key on an update action.", "B — trim to the brief's three keys and let the executor halves decide `onSuccess`/redirect semantics for the update result." ], "recommendation": "A, because an inert key that parses green is the ADR-0078 shape this file refuses everywhere else, and `onSuccess` is itself still `planned` so refusing it on the update costs nothing today." }, { "question": "`objectName` for a standalone update action is enforced at the stack level (defineStack cross-reference walk), not the schema (the same schema parses embedded actions where the object is implicit). Acceptable as the spec-half answer to design point (d)?", "options": [ "A (shipped) — stack-level refusal with the two spellings that work; embedded actions never visited.", "B — additionally require `objectName` on the schema, which would force embedded actions to repeat their parent's name." ], "recommendation": "A, because B breaks the embedded form the card's own duly app uses and duplicates a fact the object already states." } ], "out_of_scope_findings": [] }
Generated by Claude Code
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsContract review (Clause ② yes, in-seat at
CONTRACT_REVIEW_TIER) — PASS · ACCEPT — PR #15077 (head44e26d67,Fixes #14092)Reviewer of record: the
domain:specseat (session_0174WZTU6XcFcS7g2kykC53i, seat post #6017). Tier fuse:get_sessionread 2026-09-03T20:30Z — both model fields equalCONTRACT_REVIEW_TIER. Every reading below is against GitHub ororigin/main, not the report's prose. Report comment 5531686101 (mcp_calls 0; no out-of-scope findings filed by the dev).Form and scope (read 2026-09-03T20:31Z–20:35Z)
- PR feat(spec): row-level declarative field write — operation: 'update' + patch on ActionSchema #15077: draft, base
main, first lineFixes #14092— correct: the spec half IS this card; the executor halves are downstream cards (below). Full-body closing-keyword scan: one keyword, one card, noPart of. Two-read done. - 12 files vs
origin/mainbe416187(fetched head44e26d67, 20:35Z):+650/-17; nocontent/docs/releases/**; no unrelated file;.changeset/action-row-update-declarative.md=@objectstack/spec: minor;@objectstack/specis published (17.2.0, noprivatefield) ⇒ changeset required and present. - Serial-queue check: the
ActionSchema.method's worked example points atype: 'api'PATCH at/api/v1/sys_api_key/{id}— the shipped data API is/api/v1/data/:object/:id, so copying it 404s #14093 region ofaction.zod.ts(themethoddocblock with thesys_api_keyexample) has zero hunks in this diff — the serialization holds;ActionSchema.method's worked example points atype: 'api'PATCH at/api/v1/sys_api_key/{id}— the shipped data API is/api/v1/data/:object/:id, so copying it 404s #14093 becomes dispatchable on MERGED. - Governed surfaces: 0 of 12. Trial merge clean (20:27Z).
- Clause-② carriers:
check-clause2-carriers.mjs --pair 15077exited 4 (MISPLACED — the declaration sat on the PM claim, not on the dev's claim) at the first run; the dev appendedClause-②: yesto its claim comment 5531072392 (edited 20:31:48Z, read back 20:32Z); re-run by this seat at 20:32Z: exit 0, both carriers agree. The defect was in the claim template this seat handed the dev (it did not carry the line), not in the delivery.
Contract judgement ①②③
① Derived judgements — the dev's three open questions, each ruled A by this seat (open to the maintainer's veto):
- (a) Parallel key
operationbesidetype, not anActionTypemember. It is the ruling's own spelling andbulk-action.zod.ts's. Verified on objectuiorigin/main3e01cb55(20:32Z):ActionRunner.builtinExecutorsis typed as aRecordoverRunnableActionType(packages/core/src/actions/ActionRunner.ts:863-867, dispatch :1074) — a new enum member would stop the console compiling until an executor exists and so hard-block the next spec bump; the parallel key lets the halves land in any order. The stated consequence (an explicittype: 'script'besideoperation: 'update'is accepted; the parsed shape is always{ type: 'script', operation: 'update', patch }; executors readoperationfirst) is the honest reading of a materialized default and is written into the executor contract. - (b) The full refusal set beyond the brief's minimum (
bodyShape/recordIdParam/recordIdField/onSuccess/opensInNewTab/newTabUrl/list_toolbar/ thebodycarve-out message): accepted — each is the reversible direction (refuse now, allow later is additive), each at the contradicting key withcode: 'custom'and the owning executor named in the first sentence.type: 'update'is refused on the enum with the prescription pointing atoperation;operation: 'delete'/'custom'refused with reasons; the bulk def'sop/values/set/updateshorthands rename ontooperation/patch. - (c) Stack-level
objectNamerule (collectGlobalUpdateActionErrorsinstack.zod.ts, wired invalidateCrossReferencesbefore the object-count early return): accepted — the same schema parses embedded and standalone actions, so the schema cannot judge it; the cross-reference walk is the right home, beside the duplicate-key check.
② Semver: minor, pure widening — verified. Both keys were unknown on the strict shape; every new refusal is keyed on one of them; the one edited existing rule (the #2169 script-executability refinement) only gains a carve-out. Nothing that parsed before stops parsing.
patchwithoutoperationrefused atpatchkeeps the ADR-0078 no-silent-drop rule.③ Boundary flags:
- Liveness:
operation/patchareplanned(two ledger entries withauthorWarn+authorHintnaming the executor halves). Verified onorigin/mainbe416187(20:32Z) that the interim failure is loud, not silent:packages/runtime/src/action-execution.tsinvokeBusinessAction(:1300) dispatchestype === 'flow'(:1406), else the registered handler (:1650 →ql.executeAction:1659; a miss isisActionNotRegisteredError:1572). Also read:isHeadlessInvokableAction:525-528 answers false for an update action today (script⇒ needstargetorbody) — that is the runtime card's seam, recorded there. - Docs:
content/docs/protocol/objectui/actions.mdxgained the declarative-write paragraph; its "complete enum" sentence stays true (ActionTypedid not move);references/**andliveness/state-counts.mdregenerated bycheck:generated --fix(exactly the two stale artifacts the gate named). - Refusal pins:
action-row-update.test.tsassertscode+path+ message on every refusal, not a baretoThrow. Reverse verification legs A (unhook the.superRefine: 14 failed / 17 passed) and B (delete the two declarations: 25 failed / 6 passed) with restore proofs by blob hash — direction as predicted, recorded in the PR body at head44e26d67(git rev-parse --short HEADafter the final commit equals the PRhead.sha). - Not touched, verified by the file list:
bulk-action.zod.ts,packages/runtime,packages/objectql,packages/rest,packages/lint, objectui,content/docs/releases,skills.
Downstream cards filed at this ACCEPT (contract-first; each
pm:blockedwith aBlocked-by:line;domain:*is triage's)- runtime: execute the declarative row-level
operation: 'update'action — the platform action route performs one data-plane update of the current record as the caller (runtime half of #14092) #15079 — runtime half (packages/runtime): the platform action route performs one data-plane update of the current record as the caller; the executor contract is copied there with the seams measured onbe416187. - console: execute the declarative row-level
operation: 'update'action — dispatch onoperationbeforetype, forwardoperation/patchthrough the action forward lists, Studio inspector keys, Undo anchor (objectui half of objectstack#14092) objectui#7551 — console half: dispatch onoperationbeforetype, forwardoperation/patchthrough the action forward lists (the objectui#4050 parity gate), Studio inspector keys, Undo anchor. Unlocks on installability of a spec release carrying the keys, not on merge. - spec liveness: flip
ActionSchema.operation/patchfromplannedtoliveonce the runtime executor lands, evidence anchored on the runtime reads (follow-up of #14092) #15080 — this lane's ledger flip (planned→live, evidence on the runtime reads),Blocked-by: #15079.
Landing
CI on
44e26d67at 20:31Z: 35 checks — 13 success, 5 skipped, 17 in progress, 0 red (Type Check · source gatesandBuild Docsalready green). Enqueue rule = every check green: provenance comment → ready → squash auto-merge → confirm theadded_to_merge_queueevent. On MERGED: strippm:dispatched, probeaction.zod.tsonorigin/main, landing note; #14093 becomes dispatchable (serial onaction.zod.ts).needs:contract-reviewis cleared from this card and from PR #15077 in this same stroke (single-label removes, each read back).
Generated by Claude Code
- PR feat(spec): row-level declarative field write — operation: 'update' + patch on ActionSchema #15077: draft, base
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsLanded (PM seat
domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:20Z) — PR #15077 merged aseffae801at 21:17:47Z (squash via the merge queue; enqueued 20:52:13Z, one PR ahead of it). This card closed viaFixes(closed_at21:17:49Z,completed);pm:dispatchedstripped in this stroke (read back:domain:spec/priority:p1remain).Probed on
origin/main6665c5c9(21:19Z):action.zod.tscarriesoperation: z.enum(['update'], …)at:1099andrefuseDeclarativeUpdateContradictionsat:1624;stack.zod.tscarriescollectGlobalUpdateActionErrorsat:1605; the changeset.changeset/action-row-update-declarative.mdand the pin filepackages/spec/src/ui/action-row-update.test.tsare onmain; the liveness ledger holdsoperationandpatchasplanned(the flip is #15080,Blocked-by: #15079).Unlocked by this landing: #14093 (serialized behind this card on
action.zod.ts) is dispatchable from the queue; the executor halves staypm:blockeduntil their own unlock (#15079 on this card's closure — the unlock scan returns it topm:queue; objectui#7551 on spec installability).
Generated by Claude Code
- added a commit that references this issue
on Sep 5, 2026 - added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Oct 7, 2026
Measured against
@objectstack/spec/@objectstack/rest/@objectstack/runtime17.2.0, while building the completion interaction of thedulymetadata app (objectstack-ai/duly#4): three buttons that each set one field on one record.The asymmetry
Selection → declarative. A list view's
bulkActionDefsexpresses it exactly:No action, no handler, no code. The write runs on the data plane under the caller's own permissions, hooks and validations fire, and
patchmerges under collected params so a fixed value can be declared without exposing it in the dialog. This is the right shape and it works.Row → nothing.
ActionTypeisurl | form | flow | script | api | modal. There is noupdate_recordtype, no actioneffect, and nopatch-shaped key onActionSchema. So the identical intent, one row instead of twenty, has to be hand-written.Why neither near-miss is the declarative form
type: 'api'+method: 'PATCH'+bodyExtrais a declarative HTTP call, not a declarative field write. The author hand-writes the platform's own data-API path into application metadata:target: '/api/v1/data/duly_task/${ctx.recordId}'objectName; the object name is duplicated into a path literal.objectstack validatepasses on any string.enableProjectScoping+projectResolution: 'required'registers only/api/v1/environments/:environmentId/data/:object/:id, so an app that shipped the unscoped path is broken on that host with no diagnostic.undoable("single-record update actions only — captures the record's prior field values") has nothing to key on here.For AI-authored metadata this is the worst available shape: it parses green and 404s at the click. (The spec's own worked example of this route is itself wrong — filed separately.)
type: 'flow'+ a flow with anupdate_recordnode is genuinely declarative, but it is one flow per button to assign one string, plus a flow-run record per tick, on the surface wherevalidatecurrently misses bare predicates (#14089).The cost is authorization, not the one-line write
This is the part that matters. A handler's
ctx.engineis system-elevated and RLS/FLS-bypassing by design —buildActionExecutionContextstampsisSystem: trueonto the caller's context, and both dispatch surfaces log the write as TRUSTED.visibleis a UI hide, not authorization.So every app that wants "tick this row" must hand-write a privileged write and re-establish the authorization the declarative bulk path gets for free. And the obvious guard does not work: the dispatcher loads
ctx.recordunder the caller's scope, swallows a failed load to{}, and then stampsrecord.id = recordIdon unconditionally — soctx.record.idis present even when the caller cannot read the row. We had to key the check on a field that isrequired: trueon the object instead:That is subtle, undocumented, discovered by reading the dispatcher, and every app author — human or AI — has to rediscover it or ship an authorization hole on a
privateobject.What would close it
A row-action counterpart to
bulkActionDefs'operation: 'update'— the platform knowing the action is a single-record field write, so it can route it through the data plane under the caller's own credentials, bind the object fromobjectName, and letundoablemean something. Shape is yours to pick; the requirement is that "setstatustodoneon this record" stops being three files of privileged imperative code with a hand-rolled permission check.Where this came from
objectstack-ai/duly#4. The app took the declarative route for bulk and wrote handlers for the rows; the rationale is in
src/actions/task.handlers.tsthere. Fourth platform gap from dogfoodingduly— siblings #14087, #14088, #14089.