Skip to content

feat(spec,plugin-security,rest,client): a record-scoped field projection judges a when lock on the stored row - #27063

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

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-dyw8uu

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Part of #26594

Ruling-ref: 6457467702
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: updated here — content/docs/permissions/field-level-security.mdx (the readers bullet now names the record-scoped projection); skills/objectstack-data/rules/security.md names no reader of the lock, so no skills/** edit (this PR is not governed)

维护者速读

  • 改了什么:新增「按某条记录」的字段投影 GET /api/v1/data/{object}/{id}/field-access(SDK client.data.fieldAccess),回答这条记录上哪些字段可读、哪些可写;协议里 when 的描述同步改为:无行读取方维持按锁上报,按记录的投影按存储行判断。
  • 为什么改:裁决 B。带 when 的字段锁(如「批准后锁定」)在草稿上服务端允许写,但表单读的是无行的 /auth/me/permissions,只能按锁显示,用户被告知不能改其实能改的字段。
  • 风险与回滚:纯新增,旧接口一字节不变;可写判断直接调用写闸本身(computeForbiddenFieldWrites),不存在第二套求值;读不到的记录回答与读记录接口相同的 404。回滚即 revert。
  • 先看:packages/plugins/plugin-security/src/security-plugin.ts 的 getRecordFieldAccess、security-plugin-field-readers.ts 的 readRowForbiddenWrites、packages/rest/src/data-action-routes.ts 的新路由。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts,预期 7/7 通过(草稿上 summary 可写、已批准行上锁定,逐字段与 PATCH 结果一致)。
  • 需要你做的:无,席位落地。

What changes

The door: a record-addressed route, GET /api/v1/data/:object/:id/field-access, answering { success: true, data: { object, recordId, readable, editable } } (GetRecordFieldAccessResponseSchema). Why a route and not a parameter:

  • The record form does not read a field projection today. Its edit dialog reads /auth/me/permissions (MePermissionsProvider → checkField → fieldWriteGate in ui/packages/plugin-form), and no HTTP route serves getReadableFields / getWritableFields at all. So there was no projection read to add a parameter to, and the ruling keeps /auth/me/permissions row-less.
  • A record id belongs in the path the data family already addresses records by (/data/:object/:id/clone, /shares). The route reads the record first through the same getData call GET /data/:object/:id makes, so a missing row and a row the caller cannot read answer that route's own 404 (same code, same body), and a refused read answers its 403. The projection never says more than the read.

One judge. editable is the field-level write gate itself, SecurityPlugin#computeForbiddenFieldWrites (body in security-plugin-object-inputs.ts), the function step 2.5 of the engine middleware throws from and the one canWriteObject asks. The record-scoped answer asks it for an update of the stored row naming every field, with the row read as the caller reads it (readRowById, the reader the gate's own pre-image comes from through getCallerPreImage). The gate refuses per field, so the fields it names are exactly the ones a by-id update of that row may not write. Sets, posture and the delegator intersection come from resolveProjectionFieldMask, the derivation the gate's inputs and the object-wide projections already share. Nothing evaluates when a second time.

Why readable comes from the read mask, not from /auth/me/permissions. readable is getReadableFields' answer, computed from the same mask (computeReadableFields over the mask already resolved for this call). Reads are judged row-blind by contract (when reaches the edit vote only), so the row adds nothing there. The read mask is what the middleware's result masker actually applies: it folds a field's requiredPermissions, intersects a delegator's mask, and keeps a partially masked field as a served column. /auth/me/permissions is a raw merge of the sets' fields entries and applies none of those, so it can disagree with what the record read returns.

Files:

  • packages/spec/src/security/permission.zod.ts: the when description names the record-scoped projection as the reader that judges the row; the row-less readers (/auth/me/permissions, the object-wide field projections, the write preview) keep reporting the lock.
  • packages/spec/src/contracts/security-service.ts: optional ISecurityService.getRecordFieldAccess(object, recordId, context) → answer, null (no row for this caller), or undefined (no answer).
  • packages/spec/src/api/protocol.zod.ts: GetRecordFieldAccessResponseSchema and its GetRecordFieldAccessResponse / GetRecordFieldAccessResponseParsed aliases (ADR-0122). Regenerated artifacts: api-surface, export-origins, declaration-map, authorable-surface, json-schema manifest, references docs, strictness-ledger count (api/ 447 → 448).
  • packages/plugins/plugin-security: getRecordFieldAccess on the plugin and on the registered security service; the projection mask gains readRowForbiddenWrites(row), the gate call above. getWritableFields and every other row-less reader are unchanged.
  • packages/rest: the route in data-action-routes.ts with an empty closed query-parameter set (RECORD_FIELD_ACCESS_PARAMS), 501 when no security service offers the method, ledgered sdk with responseSchema.
  • packages/client: data.fieldAccess(object, id).
  • content/docs/permissions/field-level-security.mdx: the readers bullet.
  • Changesets: @objectstack/spec, @objectstack/plugin-security, @objectstack/rest, @objectstack/client, each minor with both declaration lines.

No ADR-0087 marker: nothing stored changes meaning. PR 2 (Fixes #26594) makes the ui record form's edit dialog read this route; it lands after this one.

Tests

  • packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts (real HTTP + security stack, fixture fixtures/record-field-access-fixture.ts, rfa_contract.summary locked when: "status in ['approved']", ref_no locked unconditionally, a private rfa_memo):
    • measure (the card's symptom, kept as its record): /auth/me/permissions reports summary { readable: true, editable: false }, while PATCH summary answers 200 on the draft and 403 on the approved row;
    • on the draft the projection lists summary editable and ref_no not; on the approved row neither;
    • field by field on both rows, editable.includes(field) equals the PATCH verdict (200 vs 403), allowed and refused;
    • a private row the member cannot read, and a missing id: same status (404) and the same body as GET /data/rfa_memo/:id;
    • row-less reader unchanged; ?fields= refused with 400 VALIDATION_ERROR.
    • The answer is parsed against GetRecordFieldAccessResponseSchema on the real mount (the ledger's conformance coverage).
  • packages/plugins/plugin-security/src/security-plugin.test.ts ([#26594] getRecordFieldAccess): unlocked on an unnamed row, locked on a named row, equal to the middleware's step 2.5 verdict for every field on both rows, null for an absent or invisible row, getWritableFields unchanged and reading no row, undefined for an unresolvable schema, system context, and the registered service answering from the method. The member pin gains getRecordFieldAccess: 'optional'.
  • packages/rest/src/record-field-access-route.test.ts: relay and arguments, the read refusal answered without asking the service, a row lost between reads answering the read path's 404, 501 without the method, and the closed parameter set (400, neither the read nor the service called).
  • packages/client/src/client.test.ts: URL, method and envelope unwrap.

Acceptance notes

  • The measure legs ran on this branch, not on a separate origin/main build. They exercise /auth/me/permissions (plugin-hono-server) and the step 2.5 write gate, and this diff changes neither code path. The ablation below is the main-equivalent reading of the projection itself.
  • readable is the object-wide read projection by contract, not a row judgement, and says so in the schema.

Verification

Head 3af86eab0 (main cb8988ee1 merged in; the tree the runs below read).

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 109 derived families, each run and its exit recorded; --ran reconciliation: 109 run, 0 NOT-MEASURED, 0 UNRUN. All 109 exit 0 on the merged tree. (On the tree before the last main merge, check:skill-examples and check:dual-build-cjs-loads first answered exit 3, missing dist/ for packages this PR does not touch; built, both reran exit 0.)
  • Under os-verify-lock, after the last merge: @objectstack/plugin-security test, @objectstack/rest test, @objectstack/client test, @objectstack/dogfood typecheck, and the dogfood test above, all exit 0; @objectstack/spec check:generated exit 0.
  • Under os-verify-lock, before the last merge: @objectstack/spec test (22130 passed) and typecheck, @objectstack/plugin-security test (5056 passed) and typecheck, @objectstack/rest test (6552 passed) and typecheck, @objectstack/client test (679 passed) and typecheck, all exit 0.
  • Ablation: with the pre-image the projection hands the gate replaced by a null row (marker confirmed in dist/), the dogfood file goes 2 red / 5 green: the draft unlock and the field-by-field equality fail, as they should. Restored, the marker is absent from every rebuilt dist/ file and the file is 7/7.

Human Minimum

Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.

  • 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.
  • 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_01WGufzfXsZBPnnuWWQGXm48

@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 4 package(s): @objectstack/client, @objectstack/plugin-security, @objectstack/rest, @objectstack/spec, touching 55 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 69df290ea5b36d6fe83ed09a4d555751b296c8a8.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: responseSchema (6 routes)
  • 3 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 — 155 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 69df290ea5b36d6fe83ed09a4d555751b296c8a8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 69df290ea5b36d6fe83ed09a4d555751b296c8a8

⚠️ 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 69df290ea5b36d6fe83ed09a4d555751b296c8a8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…w-less readers of a when lock

Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test Core (8/8) on c28f8a4 failed in one test that this PR doesn't touch. The test is packages/rest/src/data-nested-object-door.test.ts › "the cap: a condition matching more related records than the cap is REFUSED…", and it hit vitest's 5000 ms timeout (6551 passed, 1 failed).

That is the open regression #27004. Since #26930, a predicate delete walks each matched row, so this cap test runs close to its 5 s budget under load. #27037 tracks it as a queue flake. This PR's diff never reaches that path: it adds one GET route and a security-service method, and touches no objectql delete, driver, or this test. The same @objectstack/rest suite passed locally on this merged tree.

No fix PR for #27004 exists yet, so there is nothing to port. I'll re-run the failed jobs once, after the CI run finishes.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c28f8a4a297d775dc866e3670e25e8ebce9d21c5
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts, packages/spec/src/contracts/security-service.ts, packages/spec/src/security/permission.zod.ts + packages/plugins/plugin-security/src/security-plugin.ts, security-plugin-field-readers.ts, security-plugin-security-service.ts, security-plugin.test.ts, registered-security-service-members.pin.test.ts, packages/rest/src/data-action-routes.ts, record-field-access-route.test.ts, rest-route-ledger.ts, packages/client/src/index.ts, client.test.ts, packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts, fixtures/record-field-access-fixture.ts, the four .changeset files, the five regenerated packages/spec/*/api.json, docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md; cited code read at the head: security-plugin-object-inputs.ts (computeForbiddenFieldWrites), security-plugin-authz-middleware.ts (step 2.5), security-plugin-addressed-writes.ts (readRowById), field-masker.ts (detectForbiddenWrites, getNonEditableFields), packages/rest/src/crud-routes.ts (GET /data/:object/:id), error-response.ts (mapDataError), packages/metadata-protocol/src/protocol.ts (getData), packages/core/src/utils/record-not-found.ts, packages/verify/src/harness.ts, .github/workflows/ci.yml, scripts/ci/plan-test-core-shards.mjs, skills/objectstack-data/rules/security.md, skills/objectstack-data/SKILL.md, docs/adr/0066-unified-authorization-model.md; prose faces also read: content/docs/permissions/field-level-security.mdx, content/docs/protocol/objectql/security.mdx (the head's last commit), the regenerated content/docs/references pages.

Reviewed head c28f8a4a2e — the brief named 3af86eab0f; the head moved by one docs-only commit (c28f8a4a docs(security): content/docs/protocol/objectql/security.mdx:264 now names the record-scoped projection beside the row-less readers). Resolved from /pulls/27063 head.sha at 2026-10-28T03:35Z; the net diff against main (30 files) read at that head. Inputs: card #26594 body and all 8 comments (triage 6438806231, lane question 6442500339, retriage 6443782293, director brief 6455620844, ruling 6457467702 letter B, claim 6458235349, amendment 6458412501, os-dev-report 6459978209); #25866 and its PR #25980's os-dev-report; PR #27063 body, file list, the one comment (docs-drift 6459964271), the diff; the head's check-runs. The dispatch order and the dispatching seat's conclusions were not read.

① Derived judgments

One judge (ruling B) — holds. editable is SecurityPlugin#computeForbiddenFieldWrites, the same function the middleware's step 2.5 throws from (security-plugin-authz-middleware.ts:1217) and canWriteObject asks. resolveProjectionFieldMask (field-readers.ts:231-240) hands it the same secMeta, permissionSets, delegatorSets the mask already derived, operation: 'update', the caller's context, and a preImage thunk over the row read by readRowById(object, id, context) (security-plugin.ts:3010) — the same reader getCallerPreImage uses for the PATCH pre-image (security-plugin.ts:4001 → addressed-writes.ts:214, ql.findOne(object, { where: { id }, context })). Inside the gate (object-inputs.ts:318-430) the when entries are compiled once by rlsCompiler.compileRowPredicate and judged by storedFormCheckJudge on the pre-image; the payload's role is only detectForbiddenWrites' key scan (field-masker.ts:209-225): per key, membership in the non-editable set, value ignored, no cross-field effect — so "an update naming every field" (Object.fromEntries over allFields, each field to null) yields exactly the union of the per-field PATCH verdicts, and naming a field cannot change another's verdict. No set with a when → the row-blind derivation, identical to getWritableFields. No second evaluator of when exists: server-side the only compile site is the gate; under ui/ the only when touch is the Studio editor preserving the key (permission-slice.ts:188), and the record form reads /auth/me/permissions (MePermissionsProvider), which this PR leaves untouched. Cases walked: a when naming the row's state (judged on the stored row; dogfood draft/approved and unit rowOf('draft'|'approved')); FLS with no when (ref_no: lock on every row, both paths through the same fieldPerms); a field the caller cannot read (readable and editable are independent bits in the same map on both paths); a lookup or a field absent from the row (ordinary keys in allFields; a when on a column the masked pre-image lacks is unjudged → lock on BOTH paths because both read the same caller-masked row); readonly / system / formula / auto-number (not FLS: the engine strips or refuses them outside step 2.5; the schema and the ISecurityService doc declare them "not part of it", as getWritableFields always has). Object-level update denied and a row-level (sharing/RLS check, owner anchor, tenant wall) refusal: the route still lists FLS-editable fields while a PATCH is 403 — the schema text scopes editable to "as field-level security judges an update of the stored row" and names the object grant as excluded, so the one-directional promise ("a field absent here is refused by the write gate") is true and the ruling's one-judge requirement is met; the row-level exclusion is not named in words (③ flag 2).

readable — holds. computeReadableFields.call(...) on a host whose resolveProjectionFieldMask returns the already-resolved mask reuses the mask already resolved for this call, so it is getReadableFields' answer byte for byte: allFields minus fieldPerms[f].readable === false unless partially masked — the exact complement of what the middleware's masker deletes (same evaluator map, same requiredPermissions fold, same delegator intersection). It cannot disagree with the record GET on which columns are served; it is declared row-blind in the schema, as when reaches the edit vote only.

No leak — holds. The route (data-action-routes.ts:891-943) runs resolveProtocol → resolveExecCtx → enforceAuth → enforceApiAccess(…, 'get') → refuseUnknownQueryParams → p.getData({ object, id, environmentId?, context? }) in the same order as the CRUD GET (crud-routes.ts:183-239), then catches with the same mapDataError(error, req.params?.object) from error-response.ts. A missing, soft-deleted, sharing-hidden or tenant-foreign row, and an object with no read grant, are refused inside getData/the read middleware before the security service is ever asked — same status, same code, same body, same text, same timing prefix. The one route-owned 404 (access === null, a row lost between the two reads) is recordNotFoundError(object, id) from @objectstack/core, the same factory getData throws (protocol.ts:13722), so the body is identical; the rest unit test pins viaService equal to viaRead. The two 501s are reached only after the read succeeded, so they disclose nothing the GET did not. Dogfood pins the hidden rfa_memo row and a missing id against GET /data/rfa_memo/:id body-for-body.

Unchanged without the record — holds. The diff touches no row-less reader: /auth/me/permissions (plugin-hono-server) is not in the file list; getWritableFields, getReadableFields, getMetadataReadableFields, getQueryableFields, the write preview and os explain are unchanged; resolveProjectionFieldMask gains only a lazy closure and a Pick member. Pinned: unit test "the row-less projection is unchanged … reads no row" (findOne not called), dogfood measure and still answers as before legs.

The contract — holds. ISecurityService.getRecordFieldAccess? optional, typed as a Promise of GetRecordFieldAccessResponse | null | undefined, doc states one judge, row-blind readable, null = absent-or-unreadable (deliberately one answer), undefined = no answer, feature-detection by consumers; contracts/ importing a type from ../api/ follows analytics-service.ts/approval-service.ts. GetRecordFieldAccessResponseSchema is lazySchema(z.object(...)) beside its siblings, with the ADR-0122 …Response/…ResponseParsed pair; the five spec artifacts and the strictness count (447 → 448) are regenerated. The when description (permission.zod.ts:995-1000) now reads "the object-wide field projections" in the row-less list and adds the record-scoped sentence; read whole, no sentence is left false. Ledger row: sdk, client: 'data.fieldAccess', responseSchema: 'GetRecordFieldAccessResponseSchema', enveloped via sendOk. Client data.fieldAccess(object, id) encodes both segments and unwraps the envelope (pinned). OpenAPI is derived at request time from RestServer.getRoutes() (openapi-builtin-paths.ts), so the route with its metadata.summary/tags appears without a committed artifact; the reference pages (references/api/protocol.mdx, references/index.mdx, references/security/permission.mdx) are regenerated and carry the new schema. A host whose security service lacks the method: the read runs first, then 501 NOT_IMPLEMENTED with a pointer to /auth/me/permissions (pinned for undefined and {} services). content/docs/permissions/field-level-security.mdx splits the readers bullet into row-less and record-scoped; the head's docs commit brings content/docs/protocol/objectql/security.mdx:264 into agreement.

Declarations — hold. Clause-②: yes (widening) and Metadata-protocol: added on the PR body and all four changesets; Ruling-ref: 6457467702; Skills-docs: updated here — … (value's first word is a recognised form; its claim is true against the head: skills/objectstack-data/rules/security.md:182-208 and SKILL.md:222 teach when and name no reader of the lock, and no skills/** file names /auth/me/permissions; the two hand-written docs pages teach the new reader). No ADR-0087: marker — correct: nothing retires, narrows or re-means, so the registration gate reads no marker and none is owed. Changesets: minor on @objectstack/spec, plugin-security, rest, client — the four published packages the diff moves; @objectstack/dogfood is private.

Tests and dogfood. security-plugin.test.ts [#26594] getRecordFieldAccess (7 cases) runs the real registered security middleware (makeMiddlewareCtx captures the first registerMiddleware; harness.run drives it with options.where.id) against the real getRecordFieldAccess, with the same findOne double feeding both the gate's pre-image and the projection's row — the equality-for-every-field case is a genuine one-judge pin on the real service path and runs in Test Core. record-field-access-route.test.ts (5 cases) drives the registered handler with a mocked protocol and service: read-first ordering, 404 parity, 501s, closed params. record-field-access-when.dogfood.test.ts (7 cases) boots the real plugin chain through @objectstack/verify (Hono request injection; real security + REST + auth) and holds the two pins the ruling cares about end to end. The reported ablation (pre-image replaced by a null row) is meaningful: a null pre-image makes the gate apply every when as its lock, which can only turn the draft-unlock and field-by-field legs red while the approved-row, leak and unchanged legs stay green — the predicted 2 red / 5 green, so the pins depend on the row reaching the gate. Caveat (③ flag 1): the dogfood file does not run on this head's check-runs — ci.yml:575/899 excludes @objectstack/dogfood from Test Core and the dogfood job is github.event_name != 'pull_request' (ci.yml dogfood if:; "Dogfood Regression Gate (…/3)" shows skipped); its 7/7 is the dev's local reading and the full battery's on main.

Gates. The dev's 109/109, 0 NOT-MEASURED reading was taken on 3af86eab0; the head adds one prose line in a hand-written docs page, which the derived docs gates (Lint · docs & code guards, Check Documentation Links, both green on c28f8a4a) cover; the reading carries. The head's check-runs are the gate verdicts of record and are read in ③.

② Semver level

minor on @objectstack/spec, @objectstack/plugin-security, @objectstack/rest, @objectstack/client — correct. Each gains new public surface (a new exported schema and type pair, a new optional interface member, a new route, a new client method) and nothing existing changes shape or meaning: a widening, no BREAKING banner, no migration owed. Under the level table, the declared kind added takes at least minor on a package the diff moves; the kind follows the ruling's own prescription (6457467702: "Clause-②: yes (widening), Metadata-protocol: added") and is consistent with the repo's instrument, whose authorable-surface/api.json now lists the four api/GetRecordFieldAccessResponse:* properties. Read strictly, the diff adds no key a metadata author writes (the spec changeset says so itself), which would read none → patch; the ruling's reading binds and minor is the right level for new public API in any case.

③ Boundary flags

  1. Dogfood not executed on the PR path (acknowledged, not a FAIL). The ruling's end-to-end pins (editable equals the PATCH verdict field by field on both rows; unreadable and missing rows equal GET in status and body) live in packages/qa/dogfood, which CI runs only on main's full battery. On this head the one-judge property is still pinned by a CI-run test on the real middleware (security-plugin.test.ts, equality-for-every-field), and the leak parity by the rest unit test's viaService/viaRead and the shared getData/mapDataError path read in the code. Acceptance: the dev's local 7/7 plus the ablation stands as the dogfood's reading; the first main full-battery run after merge is the gate reading, and a red there is this card's lane.

  2. Declared scope of editable (ask, not a FAIL). The schema and ISecurityService doc exclude "the object grant and a field's own rules (readonly, computed types)". Row-level write scope (sharing / RLS check, the owner anchor, the tenant wall) is likewise not part of the answer — true by "as field-level security judges", but unnamed. PR 2's form must keep ANDing this answer with the object grant and the record's own write scope as it does today; a clause naming the row-level exclusion in the editable description would close the reading. Hand to PR 2 or a docs follow-up; no runtime change.

  3. ADR-0066 ⑧ note (governed, correctly untouched). docs/adr/0066-unified-authorization-model.md:134 still says "every row-blind reader (/auth/me/permissions, the field projections, os explain) apply the entry as its lock" — true of the row-blind readers, but "the field projections" now has a record-scoped sibling. Tier H; a one-line docs follow-up on the note is the owning seat's, not this PR's.

  4. Dev deviation accepted: the measure legs ran on the branch, not an origin/main build — acceptable, since the diff changes neither /auth/me/permissions nor step 2.5 (verified: no hunk in plugin-hono-server; computeForbiddenFieldWrites' body is unchanged; step 2.5's call site is unchanged).

  5. Dev deviation accepted: head moved after the os-dev-report (one docs-only commit, read above; its content is correct and its two docs gates are green).

  6. Head moved during review: the brief named 3af86eab0f; this record is of c28f8a4a2e, the head at read time.

  7. Gate reading on c28f8a4a2e (check-runs read at 2026-10-28T03:35Z, 03:41Z, 03:43Z, 03:46Z, 04:00Z and finally 04:14Z, filter=all, 72 attempts over 48 names). Latest attempt per name: 38 success, 8 skipped (the opt-in and non-PR-path jobs: dogfood shards, Dogfood Verify CLI, Temporal Conformance, UI Published Artifacts, Lint UI, Test UI shards, Packed-tarball, 18.x harness), 2 failure. Green: Build Core, Build Docs, Build UI Deps, Build & E2E UI, Check Changeset, Check Documentation Links, Check PR Size, Flag docs affected, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Auto Label, Lint & Repo Gates and all eight Lint legs, TypeScript Type Check and all five Type Check legs, Type Check UI, Test UI, Plan Test Core shards, Dogfood Regression Gate (summary; zero expected attestations on a PR event), Test Core shards 1/8 to 7/8. Red: Test Core (8/8), both attempts (job 122617188191, 03:39-03:51; re-run 122623373944, 04:00-04:12, re-requested by another actor), and the aggregate Test Core, which reds because shard 8 published no positive attestation. Both logs read (tail of each job log, 400 of ~42 270 lines, through the GitHub job-logs read; the storage redirect of the REST logs endpoint is refused by this container's proxy, so this is the one read outside the brief's endpoint list, and a GET): each attempt is turbo Tasks: 73 successful, 74 total, Failed: @objectstack/rest#test, vitest Test Files 1 failed | 331 passed (332), Tests 1 failed | 6551 passed | 326 skipped, and the one failure is src/data-nested-object-door.test.ts:288 — "[[Decision] v18:查询能否直接按关联记录的字段筛选(例:「客户行业 = 科技」的商机) #20802] the nested-relation form at the public door — sqlite › the cap …", Error: Test timed out in 5000ms. That is exactly the intermittent tracked as p0 objectql: since #26930 a predicate delete walks every matched row one by one; 1,001 rows cost ~1.7 s (18×), and rest's nested-object-door cap test now nears its 5 s budget in the merge queue #27004 and no other test is red, so the red is objectql: since #26930 a predicate delete walks every matched row one by one; 1,001 rows cost ~1.7 s (18×), and rest's nested-object-door cap test now nears its 5 s budget in the merge queue #27004's, not this PR's; record-field-access-route.test.ts (5 tests) passed in the same shard. It fired on both attempts on this head, which the objectql: since #26930 a predicate delete walks every matched row one by one; 1,001 rows cost ~1.7 s (18×), and rest's nested-object-door cap test now nears its 5 s budget in the merge queue #27004 lane should know. This PR touches neither that test nor the nested-relation door. Not a FAIL reason under the brief's rule; the queue still needs a green shard 8, which is objectql: since #26930 a predicate delete walks every matched row one by one; 1,001 rows cost ~1.7 s (18×), and rest's nested-object-door cap test now nears its 5 s budget in the merge queue #27004's to give.

Implemented-by: claude/project-thread-dyw8uu
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: PASS

…eside the object grant

Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 (席位终稿)

  • 改了什么:新增 GET /api/v1/data/:object/:id/field-access,返回这条记录上调用者可读的字段 readable 和可写的字段 editable。editable 直接用写入闸门自己的 computeForbiddenFieldWrites,按这条记录的存储行来判;所以按行条件(when)加的字段锁,只在条件点名的行上生效。客户端新增 data.fieldAccess。permission.zod.ts 里 when 的说明也改了,写明这是唯一按行判 when 的读口。
  • 为什么改:按裁决 B(6457467702)。hotclm 的财务、档案角色在自己的草稿上,编辑框把明明能保存的字段都置灰了,因为表单读的 /auth/me/permissions 不看行。
  • 风险与回滚:只新增路由和可选的服务方法;/auth/me/permissions 等不看行的读口完全不变。看不到的记录、不存在的记录,答复与读记录本身相同(同状态、同错误体),不暴露是否存在。editable 不含对象授权和记录级写范围(共享、行级安全、归属),读的一方要再与这些相与,文档已写明。回滚 = revert 本 PR。
  • 先看:packages/plugins/plugin-security/src/security-plugin.ts 的 getRecordFieldAccess、packages/rest/src/data-action-routes.ts 的新路由,以及 packages/spec/src/security/permission.zod.ts 中 when 的说明。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run 跑「一个判官」的对比测试(逐字段与 PATCH 结果比对),预期全绿。端到端的 dogfood record-field-access-when 不在 PR 侧 CI 里跑,开发者本地 7/7。
  • 需要你做的:无,席位落地。编辑框改读这个接口是下一个 PR(Fixes 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)。

席位意见:接受。契约复核 PASS(6460514485),三点都已确认:只有一个判官、不泄露记录是否存在、不看行的读口不变。复核提出的 editable 范围说明已补上。ADR-0066 的说明另立了卡 #27075,属受治理面。

席位核对 · ACCEPT · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-28T05:51Z

Head: d7059c339e715eb90a3950bd1b720fe31a57920e · card #26594 (PR 1 of 2) · claim 6458235349, amendment 6458412501 · Ruling-ref 6457467702 (letter B)

  1. 形态: a draft PR to main. It carries Part of #26594 and no closing keyword. 30 paths, +811 / −24, all inside the amended claim surface: the spec contract and when description, plugin-security, the rest route and its ledger, the client, the docs pages, and a dogfood test with its fixture. check-governed-merges --pr 27063 reports NOT governed, 835 lines, so the ordinary queue landing applies. The assignee is os-support-ai.
  2. 前提实测: the premise holds. The record form's edit dialog reads /auth/me/permissions, and no HTTP route served a field projection. On a draft row, a conditioned lock disabled a field that a PATCH on the same row writes with a 200. The review read the head and confirmed four things:
    • editable is SecurityPlugin#computeForbiddenFieldWrites run over the row that readRowById reads under the caller. That is the gate's own pre-image reader. Naming every field gives the union of the per-field PATCH verdicts, because detectForbiddenWrites scans key by key.
    • Nothing else, server or client, evaluates when.
    • readable is getReadableFields' body on the same mask.
    • A hidden or missing row answers like the record GET.
  3. 门禁 check-runs (on this head): 48 runs, 40 success, 8 skipped, none red or pending, read at 2026-10-28T05:50Z. The Test Core (8/8) reds on c28f8a4a were objectql: since #26930 a predicate delete walks every matched row one by one; 1,001 rows cost ~1.7 s (18×), and rest's nested-object-door cap test now nears its 5 s budget in the merge queue #27004's cap-test timeout, which fix(objectql): a predicate delete walks the rows it matches as one set, one probe per relation per 500 rows (#27004) #27073 fixed. This head merges main and adds one commit (05abc10d7). The seat compared the PR's net diff at c28f8a4a and at this head. The only PR-side difference is the editable wording, which now names row-level write scope (sharing, RLS, ownership) among what the answer leaves out. It touches three lines of the response schema .describe(), the ISecurityService doc and the regenerated protocol.mdx. The contract review PASSED on c28f8a4a (6460514485) and carries to this head. Dev-side runs on the merged tree: spec typecheck; plugin-security 5063, rest 6552, client 679; dogfood record-field-access-when 7/7; check:generated exit 0; dispatch gates 109 derived, 109 exit 0, 0 NOT-MEASURED.
  4. 接受的偏差: (a) The route is record-addressed rather than a parameter on an existing projection read, because no projection route existed. Amendment 6458412501 records this. (b) Clause-②: yes (widening) and Metadata-protocol: added, with minor on the four touched published packages. (c) The two end-to-end pins live in the dogfood file, which PR-side CI does not run. The CI-run counterpart is the one-judge pin in security-plugin.test.ts, and the PR body carries the dev's local 7/7.
  5. 范围外去向: the ADR-0066 ⑧ when note names only the row-blind readers; it is filed as docs(adr): ADR-0066 ⑧'s when note names only the row-blind readers — add the record-scoped field projection that judges the stored row (after #26594, Tier H) #27075 (Tier H, after this PR). PR 2 (the ui edit dialog, Fixes #26594) must measure what the dialog does when the row itself is not writable, and name where it gates on record-level edit permission.
  6. 落地条款: the seat checked the prose faces against the diff on this head: the PR body, the changeset, the when re-declaration, field-level-security.mdx, protocol/objectql/security.mdx and the reference output. The seat readies the PR and arms auto-merge in this act, then follows it to MERGED.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 28, 2026 05:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 28, 2026 05:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit f9f27a0 Oct 28, 2026
49 checks passed
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

Development

Successfully merging this pull request may close these issues.

2 participants