Skip to content

fix(approvals,rest): tell the truth about approval writes — lock policy, dropped fields, and the batch create ingress (#3794, #3835) - #3834

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-3794-objectui-ac32d5
Jul 28, 2026
Merged

fix(approvals,rest): tell the truth about approval writes — lock policy, dropped fields, and the batch create ingress (#3794, #3835)#3834
os-zhuang merged 5 commits into
mainfrom
claude/issue-3794-objectui-ac32d5

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #3794 (the framework half — the Console half is objectstack-ai/objectui#2914) and #3835.

An approval flow reported record writability wrong in both directions: what the user could change said "locked", and what they couldn't said "updated successfully". Neither was wrong behaviour — the server did the right thing and told nobody. Pulling on the second thread turned up a third problem on the same write path, where the server was not doing the right thing (#3835).

1. locks_record on the approval request (#3794 problem 1)

The beforeUpdate lock hook decides on exactly one thing: the node config snapshot's lockRecord (=== false ⇒ the update goes through). Nothing exposed it, so a client saw only "a request is pending" and had to guess whether that means locked — and the Console guessed "locked", every time. A lockRecord: false node exists precisely so an approver can amend the record while deciding on it; painting "Locked for approval" over that hides the whole feature and approvers never try.

rowFromRequest now emits locks_record, read from the same snapshot the hook reads with the same default-true, so the two cannot disagree. It flows through getRequest / listRequests and therefore GET /api/v1/approvals/requests. Optional in the contract for version skew (absent ⇒ assume locked).

2. droppedFields on POST /batch (#3794 problem 2)

The engine strips writes to readonly (#2948) and readonlyWhen-locked (#3042) fields and completes the write without them. Every write path already reports what it dropped (#3431/#3455) — except the cross-object transactional batch, which never wired onFieldsDropped at all.

That is not a marginal path: it is the Console record form's save for a master-detail record, i.e. the one surface where a user edits a readonlyWhen field. They changed it, the form said "updated successfully", the value never moved, and nothing anywhere said so — the exact symptom in #3794.

The response now carries a top-level droppedFields list, each event tagged with the index of the operation that produced it (results entries are bare record echoes, with no envelope to hang a per-row list on). Omitted entirely when nothing was dropped, so the shape stays backward-compatible; the batch still commits either way — a strip is legal semantics, not an error.

3. …and that batch was not enforcing readonly on create at all (#3835)

Wiring up (2) surfaced it: POST /batch called ql.insert directly, and the engine's INSERT path is static-readonly-exempt by design (#3413 — the #3043 strip deliberately lives one layer up, at the protocol's create ingress). So readonly meant two different things depending on which create endpoint you used.

Measured on the showcase, showcase_contact.lead_score (readonly: true), same signed-in non-system user, identical payload:

before after
POST /data/showcase_contact lead_score = null, droppedFields reported unchanged
POST /batch (action: 'create') lead_score = 999 written, nothing said null, droppedFields reported

Create ops now go through p.createData — the ingress itself — rather than a second copy of the strip at the REST layer. Routing beats re-implementing here for a concrete reason: stripReadonlyForInsert carries a deliberate scope carve-out a copy would have lost. Platform objects (sys_ / managedBy) are exempt on purpose, because their own guards must reject a forged value with 403 (ADR-0086's managed_by/package_id, #3004's owner_id anchor) — silently swallowing it would eat the very payload the guard exists to refuse. isSystem is exempt for the same class of reason. That also rules out the "sink the strip into the engine" option floated in #3835: the engine has no notion of that boundary. One create ingress, and a future change to its policy covers the batch for free (AGENTS.md PD #12).

Mechanics that had to keep working, and do: trxCtx is passed as the context so the insert joins this batch's transaction and rolls back with it; { $ref: <opIndex> } still resolves (verified live — an invoice + line batch produced a line pointing at the invoice created in the same request); the ingress's droppedFields fold into the per-op list from (2).

Update ops are untouched throughout — the engine enforces both strips on its own update path.

Tests

New: 3 in approval-service.test.ts (locks_record default-true, explicit false, omitted key — through openNodeRequest / listRequests / getRequest), 5 in rest-batch-endpoint.test.ts (tagged drop events; omit-when-clean; creates go through the ingress with the transaction context; a forged readonly column never reaches the engine and is reported; $ref still resolves through the ingress).

Green: spec 6736 · rest 413 · plugin-approvals 280 · metadata-protocol 71.

Browser-verified on the showcase

Same signed-in user, pnpm dev showcase + the objectui console dev server:

scenario result
showcase_invoice_signoff (lockRecord: false), pending API returns locks_record: false; band reads "审批中(可编辑)"; PATCH 200
showcase_dynamic_approval lead_review (lockRecord: true), pending locks_record: true; band reads "审批中已锁定"; PATCH 400 RECORD_LOCKED
record form: 状态 → Paid + 税率 in one save commits, tax_rate unchanged, and the Console now warns which field did not land
batch create forging lead_score stripped and reported, matching POST /data/:object

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 2:24pm

Request Review

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/spec.

104 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
@baozhoutao baozhoutao changed the title fix(approvals,rest): surface the node's lock policy and the batch's dropped fields (#3794) fix(approvals,rest): tell the truth about approval writes — lock policy, dropped fields, and the batch create ingress (#3794, #3835) Jul 28, 2026
baozhoutao and others added 5 commits July 28, 2026 04:53
…ropped fields (#3794)

An approval flow reported record writability wrong in both directions: what the
user could change said "locked", and what they couldn't said "updated
successfully". Both halves were missing signal, not wrong behaviour — the server
did the right thing and told nobody.

`rowFromRequest` now emits `locks_record`, read from the same `node_config_json`
snapshot the `beforeUpdate` lock hook reads, with the same default-true. A client
could previously only see "a request is pending" and had to guess whether that
locked the record; the Console guessed "locked" every time, which hides the whole
point of a `lockRecord: false` node.

`POST /batch` (cross-object transactional batch) never wired `onFieldsDropped`,
so the one write path the Console's master-detail record form takes was also the
one path with no write-observability — a `readonlyWhen` strip there was
completely silent. It now collects per-op events and returns them as a top-level
`droppedFields` list tagged with each operation's index; omitted when empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dFields (#3794)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ppedFields (#3794)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y` is enforced (#3835)

`POST /batch` called `ql.insert` directly, and the engine's INSERT path is
static-`readonly`-exempt by design (#3413) — the strip that stops a non-system
caller from seeding a read-only column lives at the protocol's create ingress
(#3043). So the same forged value was dropped on `POST /data/:object` and
written through on `/batch`: one rule, two answers.

Create ops now go through `p.createData`, the ingress itself, rather than a
second copy of the strip at the REST layer. One ingress means a future change to
its policy covers the batch for free, and the carve-outs it already encodes stay
intact — the platform-object exemption (a `sys_`/`managedBy` object's own guard
must REJECT a forged value, not silently swallow it) and the `isSystem`
exemption. `trxCtx` is passed as the context, so the insert still joins the batch
transaction and `$ref` resolution is unaffected; the ingress's `droppedFields`
fold into the batch's per-op list.

Update ops are untouched — the engine enforces both strips on its update path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ock_record`

main shipped the same feature first. #3815 (`a6c3f3806`, #3814/objectui#2902)
landed `ApprovalRequestRow.lock_record`, computed `cfg?.lockRecord !== false`
off the `node_config_json` snapshot — byte-for-byte the rule this branch's
`locks_record` computes, from the same snapshot, with the same default.

Only the test file collided textually. `approval-service.ts` and the spec
contract auto-merged *clean*, which is the dangerous part: the result declared
both fields, so `GET /api/v1/approvals/requests` would have shipped one policy
under two names and every client would have had to guess which to read.

Resolved toward main: `lock_record` is already merged and released in a
changeset, so dropping it would be a breaking change layered on a duplicate.
Removed from this branch:

  - `locks_record` on `rowFromRequest` (plugin-approvals)
  - `locks_record?: boolean` on `ApprovalRequestRow` (spec contract)
  - its 3 tests — main's 3 `lock_record` tests are a superset, covering
    `openNodeRequest` / `listRequests` / `getRequest` on all three cases
  - the `locks_record` half of the changeset, which main's
    `approvals-expose-lock-record.md` already describes

The changeset is rewritten to the half that is still this branch's own
(`droppedFields` on `POST /batch`) and renamed to match; `plugin-approvals`
comes off its bump list, because after this resolution the branch no longer
touches that package. #3835 (batch creates through the create ingress) is
untouched and keeps its own changeset.

Console follow-up, NOT covered here: objectstack-ai/objectui#2914 is still open
and reads `locks_record`. It must be repointed at `lock_record` or the band
falls back to "assume locked" — the exact bug #3794 filed.

Verified after resolution: build 71/71 · spec 6836 · rest 419 (incl. all 5 new
batch tests) · plugin-approvals 318 (incl. main's 3 `lock_record` tests) ·
metadata-protocol 71 · no generated-artifact drift.

Co-Authored-By: Claude <noreply@anthropic.com>
@os-zhuang

Copy link
Copy Markdown
Contributor

Merged main — this branch's locks_record is gone; main's lock_record stands

The conflict was not really textual. #3815 (a6c3f3806, #3814/objectui#2902)
landed ApprovalRequestRow.lock_record on main, computed cfg?.lockRecord !== false
off the node_config_json snapshot — the same rule this branch's locks_record
computes, from the same snapshot, with the same default. Same feature, two names,
two PRs in flight at once.

Only approval-service.test.ts collided. approval-service.ts and the spec
contract auto-merged clean
, which is the part worth flagging: the merged result
declared both fields, so GET /api/v1/approvals/requests would have shipped one
policy under two names and every client would have had to guess which to read.

Resolved toward main — lock_record is already merged and has a released
changeset, so dropping it would be a breaking change layered on top of a
duplicate. Removed from this branch:

  • locks_record on rowFromRequest (plugin-approvals)
  • locks_record?: boolean on ApprovalRequestRow (spec contract)
  • its 3 tests — main's 3 lock_record tests are a superset, covering
    openNodeRequest / listRequests / getRequest across all three cases
  • the locks_record half of the changeset, already described by main's
    approvals-expose-lock-record.md

The changeset is rewritten down to the half that is still this branch's own
(droppedFields on POST /batch) and renamed to match. @objectstack/plugin-approvals
comes off its bump list — after this resolution the branch no longer touches that
package. #3835 (batch creates through the create ingress) is untouched and keeps
its own changeset.

⚠️ Console follow-up, not covered here

objectstack-ai/objectui#2914 still reads locks_record, which no longer
exists on the framework side. As-is it would fall back to "assume locked" — the
exact bug #3794 filed. It needs repointing at lock_record before it merges.

Verified after resolution

build 71/71 · spec 6836 · rest 419 (incl. all 5 new batch tests) ·
plugin-approvals 318 (incl. main's 3 lock_record tests) · metadata-protocol 71 ·
no generated-artifact drift.

Note on the description above

The PR body still describes locks_record as problem 1. That section no longer
reflects the branch — read it as superseded by this comment. Problems 2 and 3
(droppedFields, and the batch create ingress) are unchanged and are what this
PR now ships.

@os-zhuang
os-zhuang merged commit 5f9a987 into main Jul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-3794-objectui-ac32d5 branch July 28, 2026 16:04
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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

审批场景下记录可写性的反馈全线失真:能改的显示「已锁定」,改不了的提示「更新成功」

2 participants