Skip to content

feat(security): the record field-access answer says whether the record itself is writable - #27232

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-askd75
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-askd75

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #27175

Ruling-ref: 6457467702
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — skills/** names no reader of the record field-access answer (git grep -n "field-access\|getRecordFieldAccess\|fieldAccess" -- skills/ is empty); the hand-written page that does, content/docs/permissions/field-level-security.mdx, is updated here

维护者速读

  • 改了什么:记录的字段访问答案(GET /data/{object}/{id}/field-access)新增 writable: boolean,表示「这条记录本身能不能被更新」。
  • 怎么算的:不是另写一套判断。plugin-security 保留它注册到引擎上的那个授权中间件函数,对这条记录跑一次「按 id 更新、空 payload」的模拟操作,在链尾、引擎执行器(钩子、校验、写库、审计)之前截停;链尾再问 plugin-sharing 的按 id 写闸(抽成 SharingService.admitsByIdWrite,其中间件也只调用它)。任何一处拒绝即 false。
  • 风险与回滚:探测不写任何东西:上下文用副本,不推进权限缓存 epoch(单测钉住),dogfood 钉住无审计行、记录不变。editable 不变。回滚即 revert。
  • 先看:packages/plugins/plugin-security/src/record-row-write-probe.ts,security-plugin.ts 的 isRecordRowWritable,plugin-sharing/src/sharing-service.ts 的 admitsByIdWrite。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts,预期 10/10(他人拥有的 public_read 行 writable: false 且 PATCH 403;可写行 true 且 PATCH 200)。
  • 需要你做的:无,席位落地;随后 ui 席位接 Record edit dialog: on a row the caller may read but not write (sharing / RLS / ownership), the fields are offered as editable and the save is refused whole — the dialog never asks whether the row is writable #27138。

What changes

One row-level verdict on the record projection. GetRecordFieldAccessResponseSchema (packages/spec/src/api/protocol.zod.ts) gains writable: boolean: whether the caller may update this record at all, judged by the write gate on the stored row. false means an update is refused whole, whatever it changes. The describe names in customer language what true does not cover. editable is unchanged and stays field-level; its describe now points at writable. ISecurityService.getRecordFieldAccess (contracts/security-service.ts) documents the member. The shape is a boolean only: the gate has no closed row-reason vocabulary (its refusals are PERMISSION_DENIED with a catalog sentence, or sharing's FORBIDDEN), and a boolean discloses nothing more than the PATCH would.

The verdict is the gate's own code, run in a would-pass mode. A by-id update is refused for its row by two engine middlewares, so the verdict asks both, through their own code:

  • plugin-security's authorization middleware. registerAuthzMiddleware now returns the handler it registers, and SecurityPlugin keeps it (authzMiddleware). isRecordRowWritable hands that same function a synthetic by-id update of the row (empty payload, a copy of the caller's context, the opCtx shape engine.update() builds) and a terminal next() that ends the probe with a sentinel only probeRecordRowWrite catches (record-row-write-probe.ts). So every step a real update meets runs: the engine-owned write guard, the delegated-admin gate, principal and posture resolution, the requiredPermissions AND-gate (1.5), the CRUD grant (2), the access-depth stamp (2.6), the RLS pre-image with the ownership floor and its sharing deferral (2.7), the controlled-by-parent master (2.8), and the payload gates over an empty payload (2.5, 3.5, 3.6, 3.7). Any throw is false. The handler body is unchanged except for one guard (below).
  • plugin-sharing's middleware. Its by-id gate (canEdit / canDelete, the delegator's gate, then the probeAuthoredRowWrite deferral) moved into one method, SharingService.admitsByIdWrite(object, id, verb, context). The middleware now calls it and refuses exactly when it answers false; the refusal messages and log lines are unchanged. The probe's terminal asks the same method with the context as the security middleware stamped it (__writeScope, an external principal's grant), which is what the sharing middleware reads in the real chain (it registers at kernel:ready, after the security middleware, so it runs inside it). byIdWriteGateInstalled is set when the middleware is installed; under enforce: false no by-id write meets the gate, and the probe skips it too.

A transferred record shows why both legs are needed: its former creator passes the security middleware's ownership floor (created_by == current_user.id) and is refused only by sharing.

No side effects. The engine's executor, where beforeUpdate hooks, validation, the driver write, afterUpdate hooks and audit run, sits after the last middleware, so the probe never reaches it. Before that point the handler refuses, reads, and stamps the operation and the context; the probe owns the operation and passes a copy of the context. Read through the closure for anything else that writes: no emits, no audit, no counters. The one write to shared state is the permission-set memo epoch the handler advances on a write when the engine does not own that seam; it is now skipped for a probe (isRowWriteProbe, a module-private symbol on the operation context that no request can carry). Caches that any read fills (permission-set resolution) fill as they would on a read.

Not in the verdict (declared residuals, named in the describe):

  • a pending approval's record lock: a beforeUpdate hook in plugin-approvals, judged on the values sent (the status mirror field stays writable), with its own reader, IApprovalService.getRecordLock, which the record detail page already reads;
  • conditional read-only rules and validation rules on the values a save sends;
  • an external datasource that refuses writes (engine assertWriteAllowed, object-level);
  • other plugins' middlewares for their own objects (mail, attachments).
    So true never means "this save will succeed"; false always means the gate refuses the whole row.

Known effect: a probe that is refused logs the gate's usual refusal warn line (for example "Access denied: not permitted to update this 'rfa_notice' record (row-level security)"), as a refused PATCH does. Suppressing it would mean probe-awareness at every refusal site; left as is.

Files:

  • packages/spec/src/api/protocol.zod.ts, packages/spec/src/contracts/security-service.ts; regenerated content/docs/references/api/protocol.mdx, packages/spec/authorable-surface/api.json.
  • packages/plugins/plugin-security/src/record-row-write-probe.ts (new), security-plugin-authz-middleware.ts (returns the handler; the probe epoch guard; header note), security-plugin.ts (authzMiddleware, isRecordRowWritable, writable in getRecordFieldAccess).
  • packages/plugins/plugin-sharing/src/sharing-service.ts (admitsByIdWrite, byIdWriteGateInstalled), sharing-plugin.ts (the middleware calls it; the flag is set where the middleware is installed).
  • packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts, fixtures/record-field-access-fixture.ts.
  • content/docs/permissions/field-level-security.mdx: the projection bullet now describes writable.
  • Changesets (root .changeset/): @objectstack/spec, @objectstack/plugin-security, @objectstack/plugin-sharing, each minor, Clause-②: yes (widening), Metadata-protocol: added. The spec changeset carries a "Supersedes, in the same release" paragraph for the unreleased Record form: a row-conditioned field lock (when) disables the field on every row, though the write gate admits it on rows the condition does not name #26594 note that lists the answer's old shape.

@objectstack/client (data.fieldAccess) and @objectstack/rest relay the answer unchanged and pick the member up from the spec type; their src does not move.

Why

#27175 / #27138: a user who may read a shared record but not change it opens Edit, fills the form, saves, and loses the work to a refusal of the whole update. The permission contract promises a client never advertises an edit the gate refuses. Ruling 6457467702 (letter B) forbids a client-side re-derivation, so the server has to say it, and triage 6465932161 asks for the gate's own computation returned as one boolean. With writable, ui seat 2 can dispatch #27138: RecordFieldAccessScope reads it and the edit surfaces show the record read-only when it is false.

Verification

Head 58e92a401 (four commits plus a merge of origin/main fd73f2b39, which touched none of these files). Run on the shared container under os-verify-lock.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 108 derived commands, each run and its exit recorded. --ran reconciliation: 108 run, 0 NOT-MEASURED, 0 UNRUN. 106 exited 0 on the first run. spec check:skill-examples and check:dual-build-cjs-loads exited 3 (their prerequisite, built dist of client-react, studio and the knowledge/embedder packages, was absent); after building those packages both exited 0. Nothing in the diff changed between the runs.
  • Build: turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/plugin-sharing --filter=@objectstack/plugin-security exit 0, again after the main merge. pnpm --filter @objectstack/spec check:generated exit 0 (it found content/docs/references/** stale once; --fix regenerated only that, committed).
  • Tests: plugin-security 236 files, 5104 passed / 45 skipped; plugin-sharing 57 files, 1280 passed; rest record-field-access-route.test.ts 5/5; typecheck of spec, plugin-security and plugin-sharing exit 0.
  • Dogfood: pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts 10/10. New legs: the public_read row another user owns answers writable: false and both PATCHes answer 403; the draft and approved contracts and the member's own notice answer true and their PATCH answers 200; three probes leave the stored row equal and add no sys_audit_log update row, while one real save adds exactly one (the AuditPlugin is booted for this).
  • Unit pins (security-plugin.test.ts, [#27175] writable): writable equals a by-id update through the same handler, inside and outside the RLS write scope, and with an object grant lacking edit; the epoch is not advanced and the caller's context is not stamped (a real update through the same handler does advance it); the sharing leg is asked with __writeScope stamped and decides the verdict both ways, and is skipped when byIdWriteGateInstalled is false; no registered middleware answers false. sharing-service.test.ts: the middleware refuses exactly where admitsByIdWrite answers false, for update and delete, four (row, user) pairs each, and asks the method once per write.
  • Ablations (each from a committed state, restored with git restore, rebuilt): probeRecordRowWrite forced to true reds 3 unit pins and the dogfood public_read leg; the epoch guard removed reds the no-side-effect pin alone.

Out-of-scope findings (for seat 1 to file)

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (No formatter ran. pnpm lint is CI's run.)
  • 4. One issue, one branch off an up-to-date main, one pull request. (The branch is the harness-pinned claude/project-thread-askd75, named in the claim.)
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The 维护者速读 block is in Chinese by the repo's dispatch rule.)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy

…tself is writable

The record projection gains writable: the verdict of the gate itself on a
by-id update of the stored row. plugin-security runs the authorization
middleware it registered over a synthetic by-id update with an empty
payload and ends the chain before the engine executor; plugin-sharing's
by-id gate is now one service method its middleware refuses on, asked at
the chain's end with the stamped context.

Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy
Co-Authored-By: Claude <noreply@anthropic.com>
… the probe's lack of side effects

Unit pins in plugin-security and plugin-sharing, the dogfood legs on the
public_read row and its controls, the docs paragraph and the changesets.

Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy
Co-Authored-By: Claude <noreply@anthropic.com>
… created notice id as the other legs do

Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/attachments-access.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class), data.fieldAccess (sdk, the route ledger binds it to GET /api/v1/data/:object/:id/field-access, selected by route anchor /:object/:id/field-access), fieldAccess (sdk, the bare tail of client method data.fieldAccess, bound to GET /api/v1/data/:object/:id/field-access), /:object/:id/field-access (route, bridged from symbol getRecordFieldAccess — its route source's handler names it))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via buildSharingMiddleware (symbol, a top-level function), registerAuthzMiddleware (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/protocol/objectql/security.mdx (via /:object/:id/field-access (route, bridged from symbol getRecordFieldAccess — its route source's handler names it))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 8 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-3.mdx (via SharingService (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-6.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-7.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v18/18-0.mdx (via ISecurityService (symbol, a top-level interface), SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v18/18-1.mdx (via SharingService (symbol, a top-level class))

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
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 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 — 151 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 9ff8154cf694c616de5d467b35af21e037de1509 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9ff8154cf694c616de5d467b35af21e037de1509

⚠️ 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 9ff8154cf694c616de5d467b35af21e037de1509 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 58e92a4013f94552a9511684aeed262a1c9c1411
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts, packages/spec/src/contracts/security-service.ts + packages/plugins/plugin-security/src/record-row-write-probe.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts, packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts, packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/objectql/src/engine.ts (update(), executeWithMiddleware), packages/rest/src/data-action-routes.ts (the field-access route), packages/metadata-protocol/src/protocol.ts (updateData), packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts, packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts, the three .changeset/27175-* files. Every other engine middleware registration on the head was read to find a refusal the probe would miss (organizations, auth, approvals, audit, mailbox, storage, runtime, the security start hooks). The rest of the diff (generated protocol.mdx, api.json, field-level-security.mdx, the unit tests) is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs: card #27175 (body; triage 6465932161; claim 6466687477; amendment 6466781443; dev report 6468120932), PR #27232 (body, 17-file list, net diff against main), the check-runs on the head. Rulings read: 6457467702 (#26594 B, no second evaluator), #26368 A (protocol addition is p1).

Check-runs on the head at render time: green — Lint & Repo Gates, every Lint · lane, Type Check · source gates / consumer gates / debt ledger, Build Core, Build Docs, Build UI Deps, Build & E2E UI, Test UI, Dogfood Regression Gate, Governed Surface Queue Guard, Check Changeset, Check PR Size, Spec property liveness, PR-body guards. Still in_progress: Test Core (1/8 … 8/8), Type Check · workspace, Type Check UI. No red. The landing waits on those three families as it would on any PR; this record judges the tree, not the run.

① Derived judgments

  1. GetRecordFieldAccessResponseSchema gains a required writable: boolean — a widening of a published response (GET /api/v1/data/{object}/{id}/field-access); editable, readable, recordId, object unchanged. Right: the card and triage ask for exactly one boolean, no closed reason (the gate has no row-reason vocabulary; the PR shows why). The describe's pointer from editable to writable is right. The inferred GetRecordFieldAccessResponse type is the contract's return type, so ISecurityService.getRecordFieldAccess carries the member without a signature edit; the only producer in the tree (SecurityPlugin.getRecordFieldAccess) supplies it on every branch, including the mask.kind === 'answer' branch. A third-party ISecurityService implementing the optional method now owes the member — a type-level consequence of a response widening, declared by Clause-②: yes (widening), and the REST consumer feature-detects the method, not the member. Right.
  2. @objectstack/plugin-sharing: SharingService.admitsByIdWrite(object, recordId, verb, context) and byIdWriteGateInstalled are new public members on an exported class; the middleware body moved into the method verbatim (canEdit/canDelete, the ADR-0090 D10 delegator leg with __delegatorWriteScope, the On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493 probeAuthoredRowWrite deferral, in the same order; refusal messages and log lines unchanged). Right, and pinned (sharing-service.test.ts: both verbs, four pairs, the middleware asks the method once per write).
  3. @objectstack/plugin-security: registerAuthzMiddleware returns the handler it registers (not re-exported; surface unchanged); record-row-write-probe.ts is not on src/index.ts, so ROW_WRITE_PROBE, isRowWriteProbe and probeRecordRowWrite are package-internal. Right. The epoch bump is the one guard added inside the handler body; everything else the handler does is unchanged.
  4. getRecordFieldAccess now performs the probe on every call, before the mask.kind branch, so a system context and a row-less answer both carry it. Right (the unit pin on { isSystem: true } answers writable: true, which is what the chain does: the handler returns next() at once and sharing's canEdit admits a system context).
  5. The dogfood leg now boots AuditPlugin to pin "no audit row" — a fixture widening in packages/qa/dogfood, in scope of the card's own named test. Right.
  6. Generated artifacts regenerated: content/docs/references/api/protocol.mdx, packages/spec/authorable-surface/api.json (api/GetRecordFieldAccessResponse:writable). Judged by the gates (Type Check · source gates green; · workspace pending).
  7. Hand-written docs: content/docs/permissions/field-level-security.mdx projection bullet; Skills-docs: none with the measurement stated (skills/** names no reader). Right.

② Semver level

Three changesets, each minor: @objectstack/spec, @objectstack/plugin-security, @objectstack/plugin-sharing. Each carries Clause-②: yes (widening) and Metadata-protocol: added, matching the PR body. The spec changeset's "Supersedes, in the same release" paragraph names the unreleased #26594 note's old shape. The level matches what the diff publishes: one response member added, two public service members added, nothing removed or narrowed, so minor on all three and no BREAKING banner or ADR-0087 marker owed. On the Metadata-protocol arm: a response member is not a key an author writes, so the strict reading of the checklist's definition is none; the claim and triage asked for added, the generated authorable-surface register did gain the key, and minor is the floor either way — accepted as declared, no change asked.

Clause-②: yes (widening)

③ Boundary flags

④ Design judgment

packages/spec/src/api/protocol.zod.ts — writable against its ruling, its producer and its first consumer. The schema says: whether the caller may update this record at all, judged by the write gate on the stored row (object edit permission, sharing and ownership, RLS, controlled-by-parent); false = refused whole; true ≠ a save will succeed (approval lock, readonlyWhen/validation on the values sent, a datasource that refuses writes). I read the producer to make that sentence false.

What a real PATCH /data/{object}/{id} meets, in order (metadata-protocol.updateData → engine.update(object, { ...body, id }, { context })): assertWriteAllowed (datasource, object-level), enforceTransactionOrigin, option folding, resolveEngineUpdateDispatch (by-id from the payload id), then executeWithMiddleware — every registered middleware in registration order, then the executor (hooks, validation, driver, audit). The probe hands the security handler { object, operation: 'update', data: {}, options: { where: { id }, context: copy }, context: copy, [ROW_WRITE_PROBE]: true } and a terminal next() that asks SharingService.admitsByIdWrite with the stamped copy and then throws a private sentinel.

Where the two could disagree, and what the tree says:

  • Id addressing. The handler reads the row id through extractSingleId (payload id, else options.where.id) and resolveEngineUpdateDispatch(data, options); both answer the same row for { where: { id } } as for { id } in the payload. Same row judged.
  • Pre-image (2.7) and the ownership floor. Same findOne({ $and: [{ id }, ...writeParts] }, context) as the caller, same resolvePreImageFloorDrop asking sharing's checkEdit; a hidden row answers the same 404/403. A transferred row passes here and is refused only by the sharing leg — the amendment's reason for the second leg, and the sharing leg is asked with __writeScope/__delegatorWriteScope as the handler stamped them on the copy (pinned in security-plugin.test.ts). The sharing middleware's own path for an update is inferTargetId → admitsByIdWrite → refuse or next(), nothing before it, so asking the method directly skips no early return.
  • Controlled-by-parent (2.8). With {} the master is read from the stored row (as system), writesMasterReference and askBodyNamedMaster do nothing — exactly a PATCH that leaves the FK alone.
  • Payload gates (2.5, 3.5, 3.6, 3.7) over {}. FLS forbids nothing; the owner/queue guard skips (rows.some(writesOwner) false); the tenant wall's preTest runs and judgePayload([{}]) judges no value; step 3.6 builds postImage = { ...pre, ...{} } — the stored row — and judges it against the check policies (see S2).
  • Chain order. Every other middleware on the head was read: organizations A is insert-only, B/C are post-next(); auth's two are pass-through or post-next(); the security start hooks are object-scoped (sys_user_position, sys_user_permission_set, sys_member) or post-next(); approvals', audit's, mailbox's and storage's are read-side or scoped to their own objects (sys_comment, sys_attachment, sys_mail_message, sys_approval_request, the ledger); runtime's and sharing's second one are post-next() on sys_organization. For a business object no third middleware refuses a by-id update at row level: the two legs are the whole row gate.
  • Engine-side judgements the probe never reaches. The stored-row check seam (security: a by-id UPDATE's row-level check is judged on the pre-hook change set, so a beforeUpdate stamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989) and the stored-row tenant wall (plugin-security: the Layer 0 tenant write wall (step 3.7) judges only an organization_id present in the payload as sent, so a beforeUpdate hook that writes organization_id itself can store the row in another tenant #20013) run in the executor; on an empty payload the pre-next() 3.6 judgement already judged the stored row, and a row the caller can read is inside the Layer 0 filter the write wall reuses, so neither flips a true. beforeUpdate hooks are the declared class (approvals lock; the auth/sharing/email/webhook guards are on their own sys_ objects).
  • Side effects (seat 1's condition 1). The executor is never reached (sentinel thrown from the terminal next(), caught only by probeRecordRowWrite; await next() at the handler's end is not wrapped, so the post-next() seam assertion never runs and never converts the sentinel). The engine's own writeEpoch.bump lives in executeWithMiddleware, which the probe bypasses; the handler's bump is guarded by isRowWriteProbe (pinned both ways). The handler stamps __writeScope, __preImage, writePrecondition, postHookWriteImageCheck, tenantLayer0Verdict on the probe's own opCtx or on the context copy; the caller's context is pinned unchanged. No emit, no report sink, no durable write anywhere on the refusal paths (the only channel is ctx.logger.warn). Memo fills (permission-set resolution, field-name cache) are the reads' own. The dogfood leg pins the stored row equal and no sys_audit_log row across three probes, with one real save as the control. Met.
  • Approvals lock (condition 2). A beforeUpdate hook judged on the values sent, with its own reader getRecordLock; the describe, the contract TSDoc and the probe header name it as a residual. Met.
  • describe() names the residuals (condition 3). Names the approval lock, readonlyWhen/validation on the values sent, a datasource that refuses writes. Does not name the amendment's "other plugins' middlewares" (own-object gates) nor the REST door's object enable exposure policy. Partly met — S1.
  • Fail-closed. No handler (null until start(), or no engine), a handler that answers without passing on, an unexpected throw, a sharing service without the method: all false. enforce: false sharing: byIdWriteGateInstalled stays false and the leg is skipped, as the chain skips it. Right.

Findings:

  • S1 (should-fix) — protocol.zod.ts writable describe, mirrored in security-service.ts: name the two residuals the tree has and the amendment declared. (a) A platform object's own gates (sys_comment, sys_attachment, sys_mail_message, sys_approval_request): their middlewares and hooks live outside the two legs, so true on such a row is the two legs' answer only. (b) The object's REST exposure policy (enable, judged by enforceApiAccess at the door, 'get' on this route and 'update' on the PATCH): an object exposed read-only answers writable: true and refuses the PATCH before the engine. The PR body and the probe header name (a); the published describe, which the ui reads, names neither. One sentence each.
  • S2 (should-fix) — protocol.zod.ts writable describe: "False means an update of this record is refused whole, whatever it changes" over-claims for a row-level check policy. Step 3.6 judges { ...storedRow, ...{} } against the check filter, so a stored row outside its check scope (showcase's check: 'owner == current_user.email' on a row owned by someone the caller may otherwise edit) answers false, while a save that re-points the checked field into scope is admitted by the same step and by the engine seam. The verdict is the gate's own and the card's "iff" is met; the sentence is not. Say "or the stored row fails a row-level check the save would have to satisfy" (or drop "whatever it changes"). Not pinned by any test (the unit pins use using only; the dogfood fixture has no check).
  • S3 (should-fix) — security-plugin-authz-middleware.ts (the 2 and 2.7 refusal sites) and sharing-plugin.ts (the by-id refusal site): a refused probe logs the gate's refusal warn with operation: 'update', so every open of a read-only record's form now writes a line indistinguishable from a denied write attempt. The symbol is already on opCtx for the one guard; tagging the three log metas with probe: true (or dropping the level to debug for a probe) costs three lines and keeps the security log honest. The PR declares the effect and leaves it; AGENTS.md's own argument about a warning channel trained to be ignored applies.
  • Noted, no finding: the field-access call now costs the route's own read plus the projection read plus 2–4 reads inside the probe (the 2.7 re-read, the 3.6 pre-image when a check exists, the CBP master read, sharing's row and share reads). Per form open, acceptable.

packages/spec/src/contracts/security-service.ts — against its implementer and its first consumer. The TSDoc says writable is the gate's own verdict and lists what true does not promise; the return type is the spec-inferred shape, so the member is typed, and the REST route (data-action-routes.ts) relays the object whole after its own read of the row (getData first, so no projection discloses a hidden row). null/undefined semantics unchanged. S1 applies to this TSDoc too. Holds.

No must-fix. The verdict's two legs are the write's two legs, run as the write runs them, on a row address the write resolves identically, with nothing written and nothing bumped; the three should-fixes are wording on the published describe and a log line, none of which makes writable disagree with a PATCH that the describe does not already caveat.

Implemented-by: claude/project-thread-askd75
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

Rendered 2026-10-28T11:56Z.

…debug

The writable describe, the contract TSDoc, the docs page and the spec
changeset now say what false means for a stored row outside a row-level
check (an update leaving the row as stored is refused; a save moving the
checked value back inside can pass), and name two more things true does
not cover: a platform object's own gates and an object the API serves
read-only.

A refused probe logged the gate's refusal warning with operation
'update', so every open of a read-only record looked like a denied write.
The refusal sites a probe can reach now go through logGateRefusal, which
logs a probe at debug with probe: true and a real write at warn as before.
The sharing leg is asked through admitsByIdWrite, which logs no refusal,
so no sharing line fires for a probe.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy
…-askd75

# Conflicts:
#	content/docs/permissions/field-level-security.mdx
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-28T13:17Z

Head 7047dcbc9ba4b828db0e9da0bd62b87f6911555f. Card #27175, claim 6466687477, amendment 6466781443, dev report 6468120932.

  • Contract review: owed at CONTRACT_REVIEW_TIER, schema face. Record 6468362768 at 58e92a40 is a PASS with no must-fix. All three seat conditions are met:
  • Should-fixes closed in 3c6d9ca7:
    • S1: the writable describe and the contract TSDoc now name the platform's own rules for its own records (comments, attachments, mail messages, approval requests) and an object the API serves read-only.
    • S2: false is worded for a row-level check policy: a save that moves the checked value back inside the check can still pass. A new unit pin with a check policy covers it.
    • S3: every security refusal site a probe can reach goes through logGateRefusal. A probe is logged at debug with probe: true, while a real write still logs at warn. A pin checks that a refused probe logs no "Access denied" warning, with a real refused update as the control. sharing-plugin.ts is unchanged, which is right: the probe calls admitsByIdWrite directly, and that method logs no refusal. The seat read the hunks.
  • Delta: e1fb8b67 merges main (no rebase; one prose conflict in field-level-security.mdx resolved). 7047dcbc regenerates the protocol reference.
  • Dev readings on this head: plugin-security passes 5106 tests and plugin-sharing 1280. Dogfood file 10/10. Derived gates: 108 run, 0 NOT-MEASURED, all exit 0.
  • Gates: check-runs on this head are 41 success, 9 skipped and 0 red. mergeable_state is clean.
  • Landing: check-governed-merges --pr 27232: NOT governed, 693 changed lines. Tier S. pr_ready + automerge_enable in this act.

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

Projects

None yet

2 participants