Skip to content

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

@os-warren

Measured against @objectstack/spec / @objectstack/rest / @objectstack/runtime 17.2.0, while building the completion interaction of the duly metadata app (objectstack-ai/duly#4): three buttons that each set one field on one record.

The asymmetry

Selection → declarative. A list view's bulkActionDefs expresses it exactly:

{ name: 'duly_task_bulk_complete', operation: 'update', patch: { status: 'done' },
  visible: P`record.status == "open" || record.status == "in_progress"` }

No action, no handler, no code. The write runs on the data plane under the caller's own permissions, hooks and validations fire, and patch merges 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. ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, and no patch-shaped key on ActionSchema. 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' + bodyExtra is 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}'
  • Nothing binds that string to the action's objectName; the object name is duplicated into a path literal.
  • Nothing checks it at author time — objectstack validate passes on any string.
  • It pins the transport. 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.
  • The platform cannot tell it is a record update, which is presumably why 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 an update_record node is genuinely declarative, but it is one flow per button to assign one string, plus a flow-run record per tick, on the surface where validate currently misses bare predicates (#14089).

The cost is authorization, not the one-line write

This is the part that matters. A handler's ctx.engine is system-elevated and RLS/FLS-bypassing by design — buildActionExecutionContext stamps isSystem: true onto the caller's context, and both dispatch surfaces log the write as TRUSTED. visible is 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.record under the caller's scope, swallows a failed load to {}, and then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. We had to key the check on a field that is required: true on the object instead:

const status = text(record.status);
if (!status) throw refuse(`Task ${id} is not available to you.`, 'DULY_TASK_NOT_AVAILABLE', 404);

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 private object.

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 from objectName, and let undoable mean something. Shape is yours to pick; the requirement is that "set status to done on 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.ts there. Fourth platform gap from dogfooding duly — siblings #14087, #14088, #14089.

Activity

  1. huangyiirene commented on Sep 1, 2026

    @huangyiirene
    Collaborator

    裁决:同意(YES)—— 行级声明式字段写入立项(维护者 2026-09-01,总监批 #22)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t · 维护者对本批逐字:「同意」。

    裁定内容:行级 action 获得 bulkActionDefs 的声明式对应物 —— operation: 'update' + patch(+ visible 谓词),单记录、走数据面、用调用者自己的权限、钩子与校验照常触发、undoable 有锚点。键形细节(在 ActionType 加成员还是并列键形、与既有 action 词表的混用规则)由实施 PR 设计并在正文钉死供复审。

    实施约束(随派发单同行):

    1. 条款②:YES(路径肢 packages/spec/src/ui/** + 内容肢扩宽 accept 面)⇒ draft PR + needs:contract-review 随可复审增量同笔挂,changeset minor;
    2. 形状镜像批量侧既有词表,⛔ 不发明第二种拼法(feat(spec,lint): a layout section can reference a declared field group instead of copying its members #13897 的组引用同型纪律);
    3. ctx.record.id 盖章陷阱已拆 action dispatcher stamps ctx.record.id after 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 独立派发,⛔ 不合卡、不重做;
    4. 排除项维持:⛔ 不走 type:'api' 手写路径包装、⛔ 不走一按钮一条 flow;
    5. 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:p1 domain:spec 不动。


    Generated by Claude Code

  2. zhuangjianguo commented on Sep 3, 2026

    @zhuangjianguo
    Collaborator

    Claim: PM loop, domain:spec seat (seat post #6017), R3 of this shift
    Session: session_0174WZTU6XcFcS7g2kykC53i (GitHub zhuangjianguo)
    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 with Blocked-by: lines): packages/spec/src/ui/action.zod.ts — ActionSchema gains the row-level declarative field write ruled in 5494341350, mirroring packages/spec/src/ui/bulk-action.zod.ts's existing vocabulary (operation: 'update' + patch, with the action's existing visible predicate and params bag; undoable gains its anchor), the key shape designed by the implementer and pinned in the PR body for review (an ActionType member versus a parallel key form; exclusivity with type: 'api''s target / method / bodyExtra; whether objectName is required when the action is standalone) · its test file (accept pins, refusal pins with issue code + path + first sentence for the illegal combinations, a positive control) · generated artifacts check:generated --fix proves stale (authorable surface gains keys; the references/ui/action.mdx page; whatever else moves) · .changeset/*.md @objectstack/spec minor (public-surface widening) · needs:contract-review on the PR AND this card in the same stroke once the PR exists. ⛔ bulk-action.zod.ts untouched (the vocabulary is mirrored, not moved); ⛔ the method docblock region of action.zod.ts (:1306-1308, the /api/v1/sys_api_key/{id} example) untouched — #14093 owns it and is serialized behind this card; ⛔ no packages/runtime, packages/objectql, packages/rest or objectui code — the executor halves are downstream cards; ⛔ #14143's ctx.record.id trap 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.ts at 82291fba (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 touches ui/action.zod.ts or its test; same-day churn: none (action.zod.ts last 8a8e5411 2026-09-02 15:50Z); lane siblings in flight (#14074 + #14073 view.zod.ts conditional, #13916 + #14583 field.zod.ts, PR #15029 / #15053 / #15054 queued or landing) file-disjoint; #14093 shares action.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 names action.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; undoable anchored), with five implementation constraints: Clause ② yes with needs:contract-review and a minor changeset; the shape mirrors the bulk vocabulary and invents no second spelling; #14143 stays separate; the type: 'api' hand-written path and one-flow-per-button remain excluded; the provenance / authorization channel follows #14010's ruling direction (5494343943: runAs semantics — 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 on origin/main 82291fba (19:16Z): ActionType at action.zod.ts:530 is ['script', 'url', 'modal', 'flow', 'api', 'form'] — no update member; ActionSchema carries objectName (:927), type (:948), target (:967), params (:1091), undoable (:1145, "single-record update action"), visible (:1213), method (:1310), bodyExtra (:1336) and no operation / patch key; BulkActionDefSchema (bulk-action.zod.ts:156) declares operation (:203, 'update' / 'delete' / 'custom'), a static patch, params, visible, confirmText, with the alias op → operation. Premise holds; nothing of the ruling has landed.


    Generated by Claude Code

  3. zhuangjianguo commented on Sep 3, 2026

    @zhuangjianguo
    Collaborator

    Dispatch (R3 of this shift, 2026-09-03T19:23Z) — domain:spec seat, 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 own Claim: 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 runAs direction is consumed, not reopened):

    • The SPEC half only: ActionSchema gains the row-level declarative field write mirroring bulk-action.zod.ts's spelling (operation: 'update' + static patch merged under the collected params, the action's own visible predicate, undoable anchored); the implementer designs the key shape — ActionType member versus parallel key form, exclusivity with type: 'api''s target / method / bodyExtra, objectName semantics for a standalone action — and pins it in the PR body for review, with refusal pins (issue code + 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; patch under params; undoable captures prior values) — the seat files the runtime and objectui executor cards at ACCEPT with Blocked-by: on this card. New keys land as planned in the liveness ledger per check:liveness's own instruction.
    • Landing: draft PR Fixes #14092, @objectstack/spec minor changeset (widening, no BREAKING), generated artifacts via --fix, two-limb Clause ② line, needs:contract-review on the PR and this card in the same stroke with readbacks, ⛔ no ready flip; report as os-dev-report here. ⛔ bulk-action.zod.ts, the method docblock region (ActionSchema.method's worked example points a type: '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

  4. claude commented on Sep 3, 2026

    @claude
    Contributor

    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; base origin/main 8a96e666)
    Worktree: objectstack-issue-14092

    File 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; the method docblock region (/api/v1/sys_api_key/{id} example) stays byte-identical for ActionSchema.method's worked example points a type: '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 new action-row-update.test.ts with the accept / refusal / positive-control / no-transform pins.
    • packages/spec/src/ui/dashboard.zod.ts — WidgetActionTypeSchema is derived from ActionType by 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-action objectName decision (design point d) lives where standalone-ness is knowable: the cross-reference walk over stack.actions[].
    • packages/spec/liveness/action.json — planned entries for the new keys with an authorHint naming 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/spec minor).

    Untouched: bulk-action.zod.ts, packages/runtime, packages/objectql, packages/rest, packages/lint, objectui, content/docs/releases, skills.
    Clause-②: yes


    Generated by Claude Code

  5. claude commented on Sep 3, 2026

    @claude
    Contributor

    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

  6. zhuangjianguo commented on Sep 3, 2026

    @zhuangjianguo
    Collaborator

    Contract review (Clause ② yes, in-seat at CONTRACT_REVIEW_TIER) — PASS · ACCEPT — PR #15077 (head 44e26d67, Fixes #14092)

    Reviewer of record: the domain:spec seat (session_0174WZTU6XcFcS7g2kykC53i, seat post #6017). Tier fuse: get_session read 2026-09-03T20:30Z — both model fields equal CONTRACT_REVIEW_TIER. Every reading below is against GitHub or origin/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)

    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 operation beside type, not an ActionType member. It is the ruling's own spelling and bulk-action.zod.ts's. Verified on objectui origin/main 3e01cb55 (20:32Z): ActionRunner.builtinExecutors is typed as a Record over RunnableActionType (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 explicit type: 'script' beside operation: 'update' is accepted; the parsed shape is always { type: 'script', operation: 'update', patch }; executors read operation first) 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 / the body carve-out message): accepted — each is the reversible direction (refuse now, allow later is additive), each at the contradicting key with code: 'custom' and the owning executor named in the first sentence. type: 'update' is refused on the enum with the prescription pointing at operation; operation: 'delete' / 'custom' refused with reasons; the bulk def's op / values / set / update shorthands rename onto operation / patch.
    • (c) Stack-level objectName rule (collectGlobalUpdateActionErrors in stack.zod.ts, wired in validateCrossReferences before 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. patch without operation refused at patch keeps the ADR-0078 no-silent-drop rule.

    ③ Boundary flags:

    • Liveness: operation / patch are planned (two ledger entries with authorWarn + authorHint naming the executor halves). Verified on origin/main be416187 (20:32Z) that the interim failure is loud, not silent: packages/runtime/src/action-execution.ts invokeBusinessAction (:1300) dispatches type === 'flow' (:1406), else the registered handler (:1650 → ql.executeAction :1659; a miss is isActionNotRegisteredError :1572). Also read: isHeadlessInvokableAction :525-528 answers false for an update action today (script ⇒ needs target or body) — that is the runtime card's seam, recorded there.
    • Docs: content/docs/protocol/objectui/actions.mdx gained the declarative-write paragraph; its "complete enum" sentence stays true (ActionType did not move); references/** and liveness/state-counts.md regenerated by check:generated --fix (exactly the two stale artifacts the gate named).
    • Refusal pins: action-row-update.test.ts asserts code + path + message on every refusal, not a bare toThrow. 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 head 44e26d67 (git rev-parse --short HEAD after the final commit equals the PR head.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:blocked with a Blocked-by: line; domain:* is triage's)

    Landing

    CI on 44e26d67 at 20:31Z: 35 checks — 13 success, 5 skipped, 17 in progress, 0 red (Type Check · source gates and Build Docs already green). Enqueue rule = every check green: provenance comment → ready → squash auto-merge → confirm the added_to_merge_queue event. On MERGED: strip pm:dispatched, probe action.zod.ts on origin/main, landing note; #14093 becomes dispatchable (serial on action.zod.ts).

    needs:contract-review is cleared from this card and from PR #15077 in this same stroke (single-label removes, each read back).


    Generated by Claude Code

  7. zhuangjianguo commented on Sep 3, 2026

    @zhuangjianguo
    Collaborator

    Landed (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:20Z) — PR #15077 merged as effae801 at 21:17:47Z (squash via the merge queue; enqueued 20:52:13Z, one PR ahead of it). This card closed via Fixes (closed_at 21:17:49Z, completed); pm:dispatched stripped in this stroke (read back: domain:spec / priority:p1 remain).

    Probed on origin/main 6665c5c9 (21:19Z): action.zod.ts carries operation: z.enum(['update'], …) at :1099 and refuseDeclarativeUpdateContradictions at :1624; stack.zod.ts carries collectGlobalUpdateActionErrors at :1605; the changeset .changeset/action-row-update-declarative.md and the pin file packages/spec/src/ui/action-row-update.test.ts are on main; the liveness ledger holds operation and patch as planned (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 stay pm:blocked until their own unlock (#15079 on this card's closure — the unlock scan returns it to pm:queue; objectui#7551 on spec installability).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions