Skip to content

fix(plugin-security): a View All / Modify All set reads every field of its object, so an app field mask no longer hides fields from admins - #29387

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-q91xrq
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-q91xrq

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Part of #29106. This is the runtime half. The ADR-0066 D2 ⑧ note is the card's second PR, governed Tier H, and it opens from this branch once this one merges.

Clause-②: no
Metadata-protocol: none
Ruling-ref: 6573563853

维护者速读

  • 改了什么:在某对象上持「查看全部」(viewAllRecords)的权限集,在字段合并时对该对象每个字段投「可读」;持「修改全部」(modifyAllRecords)的投「可读+可编辑」。判断谁是超级用户只用一个函数,行级和字段级共用。
  • 为什么改:应用权限集遮掉平台对象字段时,同时持有该集的平台管理员也被遮住(列表少列、按该字段排序 403、Setup 页面显示为空)。按裁决 6573563853 选 A。
  • 风险与回滚:只放宽持「查看全部 / 修改全部」者的字段可见性;全仓没有已发布的权限集同时声明这两个位和 readable: false(已在卡上贴查证结果)。回滚 = revert 本 PR。
  • 先看:packages/core/src/security/effective-object-permissions.ts(holdsSuperuserBypass、superuserFieldVote)→ packages/plugins/plugin-security/src/permission-evaluator.ts(getFieldPermissions、superuserBypassSets)→ packages/qa/dogfood/test/fls-superuser-vote.dogfood.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/fls-superuser-vote.dogfood.test.ts,预期 11 条全绿:管理员看到全部字段并能按 code 排序,普通成员仍被遮、排序 403。
  • 需要你做的:无,席位落地。

What changes

  • The super-user vote. getFieldPermissions (per object) and buildEffectiveFieldPermissions (the row-blind /auth/me/permissions merge) apply superuserFieldVote after the declared-entry votes. A set holding viewAllRecords on the object votes readable: true on every field of it. A set holding modifyAllRecords votes readable: true and editable: true. View All never grants an edit. The vote touches only fields the merge already holds an entry for, because an absent field is already unrestricted.
  • One predicate. holdsSuperuserBypass(set, object, bit) is new in @objectstack/core. PermissionEvaluator.superuserBypassSets now reads each set through it, and so does superuserFieldVote. The row bypass and the field vote therefore name the same sets: the set's own entry for the object if it has one, otherwise its '*' wildcard. The private posture never changed that answer, since it withholds only a wildcard that carries neither bit. The new pin the private posture never changes the super-user answer holds it.
  • Every reader agrees. The list projection, record read, sort/filter gate, field-level write gate, /auth/me/permissions and POST /security/explain all read one of those two merges.
  • Unchanged. Union without deny is otherwise unchanged: a caller who holds no super-user set on the object is still masked. A field's requiredPermissions capability gate still applies after the merge.
  • Docs. Two published pages described what the runtime no longer does:
    • content/docs/permissions/permissions-matrix.mdx §3: the Sales Manager column, whose set holds View All on contact and Modify All on opportunity, plus one sentence on why.
    • content/docs/permissions/field-level-security.mdx: the union paragraph now names View All / Modify All.
  • Changeset .changeset/29106-superuser-field-vote.md: patch for @objectstack/plugin-security and @objectstack/core.

Tests

  • packages/plugins/plugin-security/src/fls-superuser-vote.test.ts (new, 25 cases). It covers the card's repro against the shipped admin_full_access, order independence, the member control, the plain-read control, view-only-never-edits, modify reads and edits, a wildcard shadowed by an explicit entry, a vote held on another object, and undeclared fields staying absent. It also pins one predicate (field votes equal the superuserBypassSets answers, and the private posture never changes them) and parity between the row-blind and per-object merges.
  • packages/core/src/security/effective-object-permissions.test.ts: two cases, one for the row-blind vote and one for holdsSuperuserBypass.
  • packages/qa/dogfood/test/fls-superuser-vote.dogfood.test.ts (new, 11 cases, real showcase boot). The card's repro on sys_business_unit: an admin holding admin_full_access plus an app set with readable: false on code, kind, manager_user_id and active. Each door is checked as an admin case and a member control case: /auth/me/permissions, list, record read, sort and filter, and explain.
  • Forced test edit 1 of 2: packages/plugins/plugin-security/src/explain-principal-person.test.ts. Its qa_pp_directory_masked fixture held viewAllRecords on sys_user and readable: false on sys_user.email. Under the ruling that set reads its own masked field, so 4 cases went red. The fixture keeps its intent (every user row, email withheld) by reaching the rows through a select row policy instead of View All. All 16 cases are green, and the "armed" cases measure the read door itself.
  • Forced test edit 2 of 2 (FAIL round 1): packages/services/service-automation/src/resume-decider-identity.integration.test.ts. Its deal_manager fixture held viewAllRecords + modifyAllRecords on deal beside 'deal.restricted_note': { editable: false }. Under the ruling the lock is out-voted by the set's own Modify All, so the decider's write landed and a post-decision node the decider may not perform is refused loudly went red in CI (Test Core 8/8). The manager now reaches every deal through readScope: 'org' / writeScope: 'org' instead of the super-user bits. Every assertion is unchanged. The GUARD case still shows the manager's write rests on the manager's own grants. 21/21 green.

Acceptance notes

  • The precondition grep is posted on the card (issuecomment-6576959092). It found 0 declared sets that hold View All / Modify All beside a readable: false / editable: false entry on the same object, across the default sets, the showcase, the hotcrm fixture, skills and docs code blocks. The one docs near-hit (the permissions matrix) is fixed here at seat 2's request.
  • FAIL round 1, repo-wide fixture sweep (packages, examples, showcase and app metadata, tests included) for a set declaring viewAllRecords / modifyAllRecords beside readable: false / editable: false on the same object. Shipped and app metadata: 0 hits. Tests: the two forced edits above, plus packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts. That file's ald_withhold_set / ald_withhold_only_set pair View All on sys_user with email withheld, so the withheld class no longer applies to those readers. Its assertions are about which ledger rows are served (none about the subject, through every door), not about the field, and it stays green. Noted here, not edited.
  • Every package with a field-permission test fixture beside a security boot was run on the new head: objectql (4 files), rest (4), runtime (1), cli (1), and service-automation in full (260 files / 3338 tests). All green.
  • The /auth/me/permissions merge lives in @objectstack/core (buildEffectiveFieldPermissions), not in current-user-endpoints.ts, so the diff touches packages/core and leaves plugin-hono-server unchanged. Seat 2 accepted this file-surface change.

Verification

Head 252a02f4 (FAIL round 1: one test-fixture commit on top of 718ba1b2; origin/main 47718699 merged in). The gates and the full dogfood battery below ran on 718ba1b2; the round-1 commit changes one service-automation test file only.

Red first on main (5780fa4 + tests only)
  • plugin-security fls-superuser-vote.test.ts: 7 failed / 18 passed. Every admin vote case failed (expected { readable: false, editable: false } to deeply equal { readable: true, editable: true }). Controls and parity were green.
  • dogfood fls-superuser-vote.dogfood.test.ts (first layout): 6 failed / 2 passed. The admin sort was refused with the card's own message: 403 … references field(s) not readable by the caller: code. Explain reported 4 field(s) masked from responses: [code, kind, manager_user_id, active] for the admin. The member's /auth/me/permissions control was green.
Green after the fix
  • plugin-security fls-superuser-vote.test.ts 25/25 · core effective-object-permissions.test.ts 43/43 · dogfood fls-superuser-vote.dogfood.test.ts 11/11.
  • Package suites before the main merge: core 129 files passed; plugin-security 240 files / 5241 tests, with the 4 red cases named above fixed by the forced edit and that file rerun 16/16; plugin-hono-server 38 files / 472 tests passed. Typecheck (tsc + check:test-typecheck) passed for core, plugin-security and plugin-hono-server after a DTS build.
  • eslint --no-inline-config over packages/core/src, packages/plugins/plugin-security/src and the new dogfood file: exit 0.
Ablation of the new vote

superuserFieldVote was made to return null first, through scripts/ablation-replace.mjs. Marker ABLATION_29106_NO_SUPERUSER_VOTE; ablation-dist-preflight showed it in packages/core/dist/index.js and index.cjs.

  • plugin-security unit: 6 failed / 19 passed. Admin vote, view-only, modify and three predicate rows went red; controls stayed green.
  • dogfood: 5 failed / 6 passed. Red: admin /auth/me/permissions, list, record read, sort+filter, explain. Green: premise and all 5 member controls.
  • core: 1 failed / 42 passed (the row-blind vote case).
  • Restore: blob back to HEAD 09e5a75c, core rebuilt, ablation-dist-preflight --absent exit 0, git status --porcelain clean.
Gates and full dogfood (718ba1b)
  • node scripts/pm/dispatch-gates.mjs --commands on 718ba1b2: 94 derived families, all run. 93 exit 0 on the first pass. check-plugin-teardown-shape.mjs --self-test exited 3 because its positive control commit was missing in the shallow clone; after git fetch origin 621a4876… it exits 0 (48 cases). --ran reconciliation: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN.

  • Full dogfood battery on 718ba1b2 after a full turbo run build --filter=./packages/** --filter=./examples/** (10m41s under the lock): pnpm --filter @objectstack/dogfood test → VERDICT command-exit 0 · Test Files 394 passed / 3 skipped (397) · Tests 3254 passed / 28 skipped (3282) · no failures.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmSgjH5Vi4jT3KVJpvt

…29106)

A set holding viewAllRecords/modifyAllRecords on an object votes on every
field of it (ruling of record 6573563853). Pins the evaluator merge, the
row-blind /auth/me/permissions merge, one super-user predicate, and the
HTTP doors on a real boot.

Claude-Session: https://claude.ai/code/session_01ADHkmSgjH5Vi4jT3KVJpvt
Co-authored-by: Claude <noreply@anthropic.com>
…d of its object (#29106)

ADR-0066 D2 ⑧ as amended by the ruling of record 6573563853 (letter A):
in the field-permission merge a set holding viewAllRecords on an object
votes readable on every field of it, and one holding modifyAllRecords
votes readable and editable. Who holds the bypass is read by one function,
holdsSuperuserBypass (@objectstack/core), which superuserBypassSets, the
evaluator's getFieldPermissions and the row-blind /auth/me/permissions
merge (buildEffectiveFieldPermissions) all read. Union without deny is
otherwise unchanged. Two docs pages updated to match.

Claude-Session: https://claude.ai/code/session_01ADHkmSgjH5Vi4jT3KVJpvt
Co-authored-by: Claude <noreply@anthropic.com>
… by a row policy, not View All (#29106)

A set holding viewAllRecords on sys_user now votes readable on every
sys_user field (ruling of record 6573563853), so the fixture's own email
mask could not hold. The fixture's intent (every row, email withheld) is
kept through a select policy instead.

Claude-Session: https://claude.ai/code/session_01ADHkmSgjH5Vi4jT3KVJpvt
Co-authored-by: Claude <noreply@anthropic.com>
…29106)

So an ablation of the super-user vote reds the admin half while the
member control is read green on its own.

Claude-Session: https://claude.ai/code/session_01ADHkmSgjH5Vi4jT3KVJpvt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/security/index.ts) — 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 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 46 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 7f277a86f670a956dcdf677937c1894706992bee → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7f277a86f670a956dcdf677937c1894706992bee

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 718ba1b268b9e60530f964c49d0e9ec148b755a0
Local-runs: none
Scope: none (reviewFace on the PR's file list returns no hits: no packages/spec/src/**, no governed path, changeset Clause-②: no with no (narrowing) arm — served at CONTRACT_REVIEW_TIER because the dispatch brief ordered the tier for an authorization widening the maintainer ruled; a tier above the owed floor is allowed) + packages/core/src/security/effective-object-permissions.ts, packages/core/src/security/index.ts, packages/plugins/plugin-security/src/permission-evaluator.ts, packages/plugins/plugin-security/src/explain-principal-person.test.ts, packages/plugins/plugin-security/src/fls-superuser-vote.test.ts, packages/qa/dogfood/test/fls-superuser-vote.dogfood.test.ts, .changeset/29106-superuser-field-vote.md, content/docs/permissions/permissions-matrix.mdx, content/docs/permissions/field-level-security.mdx; cited code read: packages/plugins/plugin-security/src/security-plugin-field-readers.ts, security-plugin-object-inputs.ts, security-plugin-explain-access.ts, security-plugin-authz-middleware.ts, security-plugin.ts, predicate-guard.ts, field-masker.ts, explain-engine.ts, delegated-admin-gate.ts, access-upgrade-report.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts, packages/spec/src/security/permission.zod.ts (objectPermissionGrants, the when description), packages/core/package.json (exports), docs/adr/0066-unified-authorization-model.md D2 ⑧ and its 2026-10-26 / 2026-10-28 notes, and the seat's prepared note patch /mnt/project-files/server-seat2/29106-adr-0066-note.patch.

Inputs: card #29106 (body, every comment: triage 6572001650, analysis 6572555344, director 6573082985, ruling 6573563853 letter A, claim 6576911116, precondition grep 6576959092, os-dev-report 6578856250, claim amendment 6578888075), PR #29387 body and file list, net diff against merge-base 47718699d6421b44e51a4c497bd8009b7d9fc94f (10 files, +587/−12), the head's check-runs. Author objectstack-fleet[bot]; attribution comment names os-steve.

① Derived judgments

  1. The merge matches ruling A, no wider. superuserFieldVote (core) returns readable: true, editable: false when any set holds viewAllRecords on the object and readable: true, editable: true when any holds modifyAllRecords; it returns null otherwise, and both merges apply it only to entries already present. The ruling binds viewAllRecords → readable: true on every field, modifyAllRecords → editable: true on every field. The implementation's modifyAllRecords → readable: true as well is not a widening: Modify All already implies View All in the row-level reading the ruling says to reuse (superuserBypassSets with bit: 'view' counts modifyAllRecords; spec objectPermissionGrants('allowRead') counts modifyAll), and an editable-but-unreadable field is incoherent. Right.
  2. Edit is widened only for modifyAllRecords, exactly as the ruling and the delta intend ("viewAllRecords gives readable only; it never gives editable. Pin that too."). Pinned three times: core holdsSuperuserBypass case (view never implies modify), core buildEffectiveFieldPermissions case (deal: viewAllRecords → editable: false), plugin-security "viewAllRecords votes readable only — it never grants an edit". Right.
  3. One predicate, behaviour byte-identical for superuserBypassSets. holdsSuperuserBypass reads objects[objectName] || objects['*'] then tests the bit; the replaced body read resolveObjectPermission(ps, objectName, isPrivate) then tested the same bit. The two differ only in that the private posture withholds a wildcard carrying neither bit, which the bit test then refuses anyway, so every input yields the same answer; opts.isPrivate is now accepted and unread, documented in the docblock, and pinned by "the private posture never changes the super-user answer". An explicit {} entry shadows the wildcard in both spellings (truthy test). Right.
  4. Every reader of field permissions agrees — none left on the old merge. Every non-test caller of getFieldPermissions and buildEffectiveFieldPermissions in the tree, named:
    • list projection / readable fields: security-plugin-field-readers.ts:194,218 (getReadableFields, getMetadataReadableFields) — covered;
    • record read, list rows, insert/update results: security-plugin-object-inputs.ts:460,466 (maskOperationResult over find/findOne/insert/update) — covered;
    • sort / filter / aggregate gate and getQueryableFields: security-plugin-object-inputs.ts:264,267 (computeQueryGuardFieldPerms → predicate-guard.ts#assertReadableQueryFields, the 403 the card measured; security-plugin-authz-middleware.ts:1320 aggregate; security-plugin.ts:3131) — covered; the analytics, approvals and audit query guards consume getQueryableFields, not a merge of their own — covered by derivation;
    • field-level write gate and the record-scoped GET /data/:object/:id/field-access: security-plugin-object-inputs.ts:341,344 (computeForbiddenFieldWrites, with judge) — covered;
    • explain (POST /security/explain, os explain): security-plugin-explain-access.ts:396,406,408 (getFieldMask, getPartialMaskRules); explain-engine.ts:3080,3093 reads superuserBypassSets, which now shares the predicate — covered;
    • /auth/me/permissions fields: current-user-endpoints.ts:974 → buildEffectiveFieldPermissions — covered; ResolvedPermissionSetLike carries objects as a parsed record (the sibling buildEffectiveObjectPermissions call reads it), and the dogfood case pins it on a real boot;
    • export: no field path of its own — canExport is object-level (security-plugin-security-service.ts) and export rows flow through find → maskOperationResult — covered by derivation;
    • consumers of the produced maps (field-masker.ts, predicate-guard.ts, explain-engine.ts:1062,2952) hold no merge of their own — out, by construction;
    • delegated-admin-gate.ts:892 reads the wildcard bits for the organization-boundary test, not a field merge — out; access-upgrade-report.ts:501 reports a managed set's declared denies — informational, out;
    • ui/** consumes /auth/me/permissions.fields; the order excludes ui/** — out.
      Delegator intersection (ADR-0090 D10) is preserved: delegator sets are merged by the same function and intersected, so an agent acting for a non-super-user delegator stays masked. Right.
  5. Member controls byte-identical. With no set holding a bit, superuserFieldVote is null and neither merge touches result; the declared-entry loop is unchanged text. Pinned by the mask-only, plain-read and member_default controls (unit) and five member controls on a real boot (dogfood). Right.
  6. Partial masking rules (spec/security: field masking is all-or-nothing — no partial masking (phone last-4, ID middle-8), and maskingRule was pruned as dead in 2026-06 #8993, fieldMaskingRules) are read, not changed: computeReadPartialMaskRules drops a rule only where the base merge says readable: false; a super-user holder now reaches the rule's masked value or the full value by its capabilities, which is the rule's own contract. Not a widening beyond the ruling.
  7. Public surface: @objectstack/core (published, exports['.'] → dist/index, src/index.ts re-exports security/index.js) gains two value exports, holdsSuperuserBypass and superuserFieldVote, and the exported EffectiveFieldPermissionsInputSet gains optional objects. Additive; judged in ②. Right to name; named by the PR body.
  8. Docs match the code. permissions-matrix.mdx §3 Sales Manager column follows §2 (viewAllRecords on contact → contact.ssn Visible; modifyAllRecords on opportunity → opportunity.margin Visible, Editable); every other cell checked against §2 holds. field-level-security.mdx names View All (reads every field) and Modify All (reads and edits). Right.

② Semver level

Clause-②: no — right: packages/spec/src/** is untouched and no accept set moves; the widening is a runtime authorization answer, which Clause-② does not cover. Metadata-protocol: none — right. patch for @objectstack/core and @objectstack/plugin-security — right under AGENTS.md step 3: "none takes patch; a non-protocol change, widening or breaking, ships patch"; the new core exports are additive on a none kind, so the rule yields patch (the 2026-10 sibling exports shipped minor only because their kind was added). The changeset body carries both declaration lines and an upgrade note naming the one authoring combination that changes meaning. plugin-hono-server is untouched, so no changeset is owed there. The changeset says "per ADR-0066 D2 ⑧ as amended (ruling of record 6573563853)" while the note has not landed; the ruling record is the governing text today and is cited, so this is a wording nit (should-fix in ④), not a level error.

③ Boundary flags

  • Deviation: merge lives in core, current-user-endpoints.ts untouched. Correct reading of the tree — /auth/me/permissions calls buildEffectiveFieldPermissions (core); the seat accepted the file-surface change in 6578888075. Answered.
  • Deviation: docs pages. The matrix fix was the seat's request; the field-level-security.mdx sentence is right. content/docs/permissions/authorization.mdx lines 153–159 carry the same union callout without naming View All / Modify All — not wrong, incomplete; should-fix follow-up, see ④.
  • Deviation: forced fixture edit in explain-principal-person.test.ts. Justified: qa_pp_directory_masked held viewAllRecords on sys_user beside readable: false on sys_user.email, a combination the ruling makes unexpressible. The fixture now reaches every row by a select row policy (id != '') with plain allowRead. It hides no regression: the "armed" case measures the read door itself (door(MASKED_ADMIN, ADA) returns the name and a null email), and the two explain cases still assert the email never appears in the decision. The fixture's intent (every user row, email withheld) is measured, not assumed.
  • Deviation: main merged once; 23 later main commits untouched. The net diff is taken at merge-base 47718699; fine.
  • Deviation: first ablation a no-op, retried with a literal marker. Preflight caught it; the reported reading is the second. Fine.
  • Deviation: no assignee or reviewer on the PR. Standing order; fine.
  • open_questions: should ADR-0066's Status line gain an Amended entry beside the D2 ⑧ note? Answer: A, note only. The two Status-line Amended entries record a decision reversed (D4's api clause, 2026-10-29) or a record retired (ADR-0131, 2026-10-14); the two existing ⑧ notes (2026-10-26, 2026-10-28) each recorded a ruling that changed a merge reading and left the Status line alone. This ruling adds a vote and keeps "union without deny" standing, so it is the ⑧-note shape, and the delta binds "add one note". The Tier H approver can ask for a Status entry on the ADR PR if they read it as a reversal.
  • Precondition (ruling's own): posted as 6576959092 — 0 declared sets hold a super-user bit beside a readable: false / editable: false entry on the same object; the one docs near-hit is fixed here. Read as data; the method (default sets, showcase, hotcrm artifact, skills, docs code blocks) covers the places a set is declared.
  • Author: objectstack-fleet[bot]; no extra login note owed.
  • Check-runs on the head, read at 2026-11-02T14:02Z: 30 success, 8 skipped, 24 in progress (Test Core 1–6 and 8, Test UI 1–16, Type Check UI, Type Check · workspace); 0 failures. Concluded green: Build Core, Build and E2E UI, Test Core 7/8, Type Check · source gates / consumer gates / debt ledger, Lint · ESLint, Lint · PM dispatch-gates, Governed Surface Queue Guard, Check Changeset, PR-body guards, Dogfood Regression Gate, Spec property liveness. Landing still owes the full green per the reference's landing check ②; this record does not stand in for it.

④ Design judgment

No hits file; ④ is rendered on the runtime files the scope widens to, against the ruling and the readers they serve.

  • packages/core/src/security/effective-object-permissions.ts — holdsSuperuserBypass is a faithful lift of resolveObjectPermission + bit test (①.3). superuserFieldVote early-returns on the first modify holder and otherwise reports view; order-independent, pinned. The row-blind merge computes one vote per object by splitting the key at its first .; object names are identifiers without dots, and getFieldPermissions keys by startsWith(objectName + '.'), so the two spellings agree — parity pinned over seven set combinations. The vote touches only entries already in the map, so an undeclared field stays absent (unrestricted) and Object.keys(fieldPerms).length triggers are unchanged. Tried: a set with a shadowing explicit entry (no vote, as at row level), a bypass held on another object (no vote), a when-conditioned entry under a Modify All holder (becomes editable — within the ruling, Modify All already bypasses the row), a delegator without the bit (intersection keeps the mask). Holds.
  • packages/core/src/security/index.ts — two exports beside fieldPermissionVote / buildEffectiveFieldPermissions, which plugin-security already imports from core; placing the predicate beside the object-level answer it reads is the right home and is what lets both merges and superuserBypassSets share it. Holds.
  • packages/plugins/plugin-security/src/permission-evaluator.ts — superuserBypassSets now delegates to the shared predicate (byte-identical, ①.3); getFieldPermissions applies the vote after the declared-entry loop and before foldFieldRequiredPermissions in every caller, so the ADR-0066 D3 capability AND-gate still applies to super-users (claimed by the PR, confirmed in object-inputs.ts, field-readers.ts, explain-access.ts). Holds.
  • Tests — unit: the card's repro against the shipped admin_full_access (with a premise guard that it still reaches the object by wildcard), order, two member controls, view-never-edits, modify, shadowed wildcard, other-object, undeclared-absent, predicate parity against superuserBypassSets over six set shapes, private-posture invariance, row-blind parity. Dogfood: each of the five doors as an admin case and a member control on a real showcase boot, the app set authored through the metadata door. Red-first and ablation readings are reported with the expected shape (admin cases red, controls green). Adequate for the acceptance scenario.
  • ADR-0066 D2 ⑧ note deferred to a governed second PR — acceptable. The ruling itself orders "its own PR, the maintainer's click"; docs/adr/** is governed Tier H, and carrying it here would put the runtime fix behind the maintainer's approval the ruling did not require of it. The runtime PR cites the ruling record (Ruling-ref: 6573563853) as its governing text, which is the ADR-0087 / Prime Directive [WIP] Add Chinese version of the documentation #13 requirement until the note lands. The prepared note patch was read: it names the same predicate, both merges and the same readers as the code, is append-only after the 2026-10-28 note, and does not rewrite ⑧'s sentence.

Findings:

  • should-fix (follow-up, prose): content/docs/permissions/authorization.mdx lines 153–159 (the ⑧ callout) should name View All / Modify All as field-level-security.mdx now does; and the published when description in packages/spec/src/security/permission.zod.ts line 984 ("the rule that only declaring sets vote are unchanged", rendered at content/docs/references/api/protocol.mdx line 1670 and content/docs/references/security/permission.mdx lines 72 and 162) now needs "a super-user set on the object votes on every field" — a description string, no accept-set change, but a packages/spec/src/** edit, so it takes its own PR with artifact regeneration.
  • should-fix (follow-up, authoring): a lint finding for a set that declares a super-user bit beside a readable: false / editable: false entry on the same object — the mask is now a no-op for that set's holders; the changeset's upgrade note says it in prose only. Consistent with ⑧/⑨'s "refuse at the producer" stance.
  • should-fix (wording): the changeset and three code comments say "ADR-0066 D2 ⑧ as amended"; until the second PR lands, "ruling of record 6573563853 (ADR-0066 D2 ⑧ note pending)" is the exact citation. Fix at the ADR PR or leave; not a landing condition.
  • No must-fix.

Implemented-by: claude/project-thread-q91xrq
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt

VERDICT: PASS

…rite depth, not Modify All (#29106)

deal_manager held modifyAllRecords beside an editable:false lock on
restricted_note. A set holding the super-user bypass now votes editable on
every field of its object (ruling of record 6573563853), so the lock could
not hold and the refused-node case went red. The manager now reaches every
deal through readScope/writeScope 'org'; every assertion is unchanged and
the GUARD case still holds.

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

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 252a02f4aeec75996fa0ff5443b659b68a1fe1ad
Local-runs: none
Scope: delta review of 718ba1b268b9e60530f964c49d0e9ec148b755a0..252a02f4aeec75996fa0ff5443b659b68a1fe1ad (one commit, one file: packages/services/service-automation/src/resume-decider-identity.integration.test.ts, +11/−5). reviewFace on the PR's file list still returns no hits (no packages/spec/src/**, no governed path, changeset Clause-②: no without a (narrowing) arm); served at CONTRACT_REVIEW_TIER as the dispatch brief orders. Cited code read at the new head: packages/core/src/security/effective-object-permissions.ts (holdsSuperuserBypass, superuserFieldVote), packages/plugins/plugin-security/src/permission-evaluator.ts (getEffectiveScope, getDeclaredScope), packages/spec/src/security/permission.zod.ts (ObjectAccessScopeSchema, readScope/writeScope, the depth-beside-bit refinement at lines 341–370), packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts, packages/plugins/plugin-security/src/explain-principal-person.test.ts, packages/plugins/plugin-security/src/security-plugin.test.ts (1926–1935, 2329–2334), packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts (165–177), packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts (161–166), packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json and its one consumer plugin-artifact-forward-conversion.test.ts, .github/workflows/ci.yml (Dogfood Regression Gate if: and aggregator), the main ruleset's required contexts, and the Test Core (8/8) job log on the new head.

This record supersedes 6579089871 (PASS on 718ba1b2), whose head went red on Test Core (8/8) after it was written. Every ①–④ judgment of that record on the runtime files stands unchanged, because the runtime files are byte-identical between the two heads; this record re-judges only what the delta touches and the CI state it claimed.

Inputs: card #29106 (body and every comment, the ruling 6573563853 letter A and the precondition grep 6576959092 included), PR #29387 body (as amended for FAIL round 1), its file list, the net diff 718ba1b2..252a02f4 and the net diff against merge-base 47718699 (unchanged for every file but the one above), the dev's delta report 6579882902 read as a claim list and nothing more, and the check-runs on 252a02f4. Author objectstack-fleet[bot]; attribution comment names os-steve.

① Derived judgments

  1. The delta is test-only. git diff 718ba1b2..252a02f4 touches one file, a vitest integration test; no src runtime, no packages/spec, no governed path, no changeset, no docs. The accept set and public surface judged in 6579089871 ①.1–①.8 do not move. Right.
  2. The cause of the red was the ruling, not a defect. On 718ba1b2 dealManagerSet held viewAllRecords: true, modifyAllRecords: true on deal beside 'deal.restricted_note': { readable: true, editable: false }. Under ruling A superuserFieldVote returns { readable: true, editable: true } for any set holding modifyAllRecords on the object, applied after the declared-entry loop, so the set's own lock was out-voted and the decider's write landed. My own bit-specific structural sweep on 718ba1b2 returns exactly this one hit (noop-edit=true) and nothing else; on 252a02f4 it returns 0. The dev's reading of the red is right.
  3. The restated fixture reaches the rep's deal by depth, not by bypass. deal is sharingModel: 'private'. getEffectiveScope('write', 'deal', [deal_manager], { isPrivate: true }) resolves the explicit deal entry, finds no modifyAllRecords, reads writeScope: 'org' (rank 4) and returns 'org'; the read leg returns 'org' from readScope. holdsSuperuserBypass(deal_manager, 'deal', 'modify') is now false, so superuserFieldVote returns null and the declared editable: false entry stands in getFieldPermissions. The spec refinement that rejects a depth declared beside a bit (permission.zod.ts 347–370) no longer applies because no bit is declared, and PermissionSetSchema.parse succeeds (the suite boots in CI). Right.
  4. The fixture now really exercises the field lock, not a scope refusal. Three cases in the same describe prove it on the real stack: approve: and reject: have the manager write approved_note / stage on a deal the rep created and assert updated_by === 'usr_mgr', so the manager's write reaches a rep-owned deal through writeScope: 'org'; the refusal case then asserts res.error contains both 'after1' and 'restricted_note', restricted_note stays null, updated_by stays 'usr_rep', res.status === 'stranded', and the run's after1 step is failure. A scope refusal would have failed the approve/reject cases and would not name the field. The CONTROL case (deal_review_restricted resumed with no decider lands under the rep's set) is unchanged and still passes, so the lock is specific to the manager's set. Right; the test's intent is kept.
  5. The GUARD premise still holds. GUARD: the manager's write rests on the manager's own grants is unchanged in its assertion: usr_mgr writing a rep's deal under the rep's sets (deal_rep, no depth, owner-only default) is refused. Only its comment moved from "modifyAllRecords" to "org-wide writeScope", which is now the true reason the manager is admitted below. Right.
  6. Every assertion is unchanged. The diff touches two doc comments, one fixture object literal and one inline comment; no expect line changes. Right.

② Semver level

Unchanged from 6579089871: Clause-②: no, Metadata-protocol: none, patch for @objectstack/core and @objectstack/plugin-security. A test-only delta owes no changeset movement. Right.

③ Boundary flags

  • Dev claim: one test-only commit, no runtime change. Confirmed by the diff (①.1).
  • Dev claim: repo-wide sweep found 2 behaviour hits, both test fixtures, both fixed; admin-ledger a note only; 0 hits in shipped or app metadata. Re-run independently, read-only, over packages, examples, apps, content, docs, skills, .claude, scripts at 252a02f4 (CHANGELOGs and dist excluded): 221 files mention a super-user bit; a brace-aware pass pairing, within one set literal, a viewAllRecords/modifyAllRecords: true object with a readable: false entry (no-op read under A) or a modifyAllRecords: true object with an editable: false entry (no-op edit under A) on the same object, including the '*' wildcard, returns 0 hits on the new head and the 1 fixture hit on the prior head. Composed fixtures (spread, computed keys, helper calls) evade that pass, so every code file that holds both a bit and a false mask was then read by hand: security-plugin.test.ts fixtures carry no bit; write-preview-field-gate-parity.test.ts carries no bit; delete-reference-cleanup-system-identity.test.ts holds its bits on os_ehr_product and masks os_ehr_andon_record.product, a different object, so no vote; explain-principal-person.test.ts already reaches rows by a select policy (forced edit 1, in the prior head); the hotcrm 17.1 built-permissions artifact (6 sets, a fixture for plugin-artifact-forward-conversion.test.ts, a shape test) pairs every editable: false with viewAllRecords only, which never grants an edit, so 0 no-ops; the shipped default sets and the showcase sets match the card's precondition grep. admin-ledger-decision-metadata.dogfood.test.ts 158–180 is a real View-All-beside-readable: false pair (grants holds viewAllRecords on sys_user; withheldField masks sys_user.email; ald_withhold_set and ald_withhold_only_set carry both), which my structural pass could not see because the set is composed from variables. Its assertions, read: the two explicit ledger rows at rest carry no class value in any column; each reader is served the control row alone through every door; the platform admin is served every row; the fixture sets are served by the catalog. None asserts that the withheld reader cannot read email on a served sys_user row, so under A the test's verdict does not move. Note only, as the dev says; see ④ for the fidelity should-fix. The dev's sweep claims hold.
  • Dev claim: hit packages run green locally. Read as data; CI is the gate (below).
  • Dev claim: CI on the new head is all green. Check-runs on 252a02f4, read at 2026-11-02T14:40Z: 55 success, 13 skipped, 0 in progress, 0 failures. The main ruleset's required contexts (TypeScript Type Check, Test Core, Build Core, Lint & Repo Gates, Governed Surface Queue Guard, Lint UI, Build & E2E UI, Type Check UI, Test UI, Live E2E UI) are all completed; nine success, Lint UI skipped by the path filter (satisfies the ruleset). Test Core (1/8)–(8/8) and the aggregate Test Core are success; the (8/8) job log on this head shows ✓ src/resume-decider-identity.integration.test.ts (21 tests) and @objectstack/service-automation:test: Test Files 261 passed (261) · Tests 3342 passed (3342). The Dogfood Regression Gate shards do not run on pull_request events by design (ci.yml 2391); the aggregator reports success on the filter's verdict, so the full dogfood battery on this head rests on the dev's local run on 718ba1b2 plus the fact that the round-1 commit touches no dogfood file and no runtime file. Landing check ② (every check green on the landing head) is satisfied on this head.
  • The prior record's deviations and open_questions answer (ADR-0066 note-only) carry over unchanged.
  • Author: objectstack-fleet[bot]; no extra login note owed.

④ Design judgment

No hits file; ④ is rendered on the one delta file against the scenario it is accepted against (ADR-0066 D2 ⑧ as amended by ruling 6573563853, and the decider-identity scenario the test pins).

  • packages/services/service-automation/src/resume-decider-identity.integration.test.ts — the restated dealManagerSet is the right shape for "a manager who edits every deal but may not edit one field": org-wide depth is the ADR-0057 mechanism for reaching every row without becoming a super-user, and under ruling A it is the only single-set spelling of that intent. The row-reach assertion (approve/reject), the field-lock assertion (refusal strands at after1 naming restricted_note), the GUARD (rep's sets refused) and the CONTROL (submitter's set allows) together prove the lock is the manager's own and nothing else, so the fixture measures what its title says. Tried to break it: a '*' wildcard with a bit elsewhere in the fixture (none; securityDefaultPermissionSets do not hold modifyAllRecords on deal); the private posture withholding the entry (explicit deal entry, not withheld); writeScope beside viewAllRecords (not declared). Holds. The two doc comments cite the ruling record and the ADR note pending, consistent with the prior record's wording should-fix.

Findings:

  • should-fix (follow-up, test fidelity): packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts 158–180 — the withheld class is defined by a mask that ruling A makes a no-op for its own readers (ald_withhold_set, ald_withhold_only_set hold View All on sys_user). The ledger assertions do not depend on it, but the fixture's label now misdescribes what those readers can see. Reach every user row by a select row policy as explain-principal-person.test.ts now does, so the class is real again. Not a landing condition.
  • should-fix (carried from 6579089871, authoring lint): a lint finding for a set declaring a super-user bit beside a readable: false (or, under modifyAllRecords, editable: false) entry on the same object. This round shows why it is worth having: a composed fixture evades a structural grep, and only a parse-time rule sees it.
  • The prior record's two prose/wording should-fixes (authorization.mdx ⑧ callout and the when description in permission.zod.ts; "as amended" wording until the ADR note lands) carry over unchanged.
  • No must-fix.

Implemented-by: claude/project-thread-q91xrq
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

席位核对

ACCEPT · domain:server seat 2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T14:50Z · head 252a02f4 · claimed by seat 2 on #29106 (claim 6576911116, amendment 6578888075)

The maintainer's ruling for this card: 6573563853, letter A, 「同意」 2026-11-02T09:22Z.

  1. Shape: a draft against main, Part of #29106 (the ADR-0066 D2 ⑧ note is the card's second PR, governed Tier H, opened after this merges). Clause-②: no, with a patch for core and plugin-security. check-governed-merges --pr 29387: NOT governed (0 of 10 paths). No packages/spec, no ui/**.
  2. Contract review: the first record (6579089871, head 718ba1b2) said PASS. CI then went red on Test Core (8/8): resume-decider-identity held Modify All beside an editable: false lock that ruling A makes a no-op for the holder. The seat sent it back. The delta review PASS at this head (6580148720) supersedes that record:
    • The delta is test-only, one file.
    • The fixture now reaches deals by readScope/writeScope: 'org', so the case is still a field-lock refusal.
    • The reviewer re-ran the sweep: 0 bit-beside-mask hits in shipped or app metadata.
  3. Acceptance against the ruling:
    • View All votes readable on every field of its object.
    • Modify All votes readable and editable.
    • Union without deny is otherwise unchanged, and member controls are byte-identical.
    • Every field-permission reader is covered: list projection, record read, sort/filter/aggregate guard, write gate, explain and /auth/me/permissions.
    • The precondition grep is reported on the card.
  4. Evidence:
  5. Follow-ups (should-fix, not landing conditions):
    • The spec when description and the authorization.mdx ⑧ callout should name View All / Modify All.
    • A lint finding for a set declaring a superuser bit beside a field mask.
    • The admin-ledger fixture restated with a row policy.
      The seat files these as cards.
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 14:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 14:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 4138ce8 Nov 2, 2026
70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-q91xrq branch November 2, 2026 15:16
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Landing record · PR #29387

domain:server seat 2 · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T15:31Z · card #29106

Reading Probe Result
Merged PR #29387 state merged, merge commit 4138ce862d
On main git merge-base --is-ancestor 4138ce862d origin/main yes
Queue refs git ls-remote origin refs/heads/gh-readonly-queue/* matching 29387 0
Content git grep -l superuserFieldVote origin/main -- packages/core/src present
  • Landed: a set holding View All on an object reads every field of it, and Modify All also edits every field, so an app's field mask no longer hides fields from an admin (ruling 6573563853 letter A). Union without deny is otherwise unchanged.
  • Card: fix(plugin-security): an app permission set's readable:false on a sys_* field also masks platform admins who hold that set #29106 stays open and pm:dispatched: this PR is Part of #29106. The card's second PR, the ADR-0066 D2 ⑧ note (governed Tier H, a draft until a GOVERNED_APPROVERS approval), opens from the same card thread now.
  • Follow-up: the contract reviews' should-fix items (the spec when description and the authorization.mdx ⑧ callout; a lint finding for a superuser bit beside a field mask; the admin-ledger fixture restated with a row policy) are filed as cards by the seat.

Generated by Claude Code

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.

1 participant