Skip to content

spec(ui): record:details / record:highlights / record:related_list declare enforceFieldSecurity and redactFields — requiredPermissions forks on a measured fail-open gate - #19185

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-18159-record-block-permission-keys
Sep 19, 2026
Merged

os-bill merged 2 commits into
mainfrom
claude/issue-18159-record-block-permission-keys

Conversation

@os-bill

@os-bill os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Part of #18159

Clause-②: yes (widening)

⚠️ Part of, not a closing keyword, and deliberately so. The card routes three keys here and asks for a per-key exit. Two are declared below. The third, requiredPermissions, returns a fork that only a ruling can settle — merging this PR must not close the card, because the half it leaves open is the half with a measured fail-open gate behind it.


What this declares

record:details, record:highlights and record:related_list are strictObjects that declared neither enforceFieldSecurity nor redactFields, while @object-ui/plugin-detail reads and honours both on each of the three. An author who wrote either was refused at publish and the same document was honoured on the raw-node path. Both keys are now declared on all three blocks — optional, no schema default, so an absent key stays absent rather than becoming "the author asked for off".

key what the render path does
enforceFieldSecurity (boolean) Folds the block's field list — the detail body's fields and sections, the highlight chips, the related list's columns — through the caller's field-read permissions (checkField, the platform's own answer) before rendering.
redactFields (string array) Drops the names it lists outright. On record:related_list it is also handed down to RelatedList, because that component derives its own columns when none are authored — filtering the authored array alone let a redacted field return through the derivation (objectui#9053).

The claim is held to what the render path does. Both are presentation filters applied in the browser after the record is fetched: the values are in the page either way, so neither is a data-access control and neither is the object's publicSharing.redactFields, which removes them server-side. Each describe() says that in the text an author reads, rather than leaving the key names to imply it (Prime Directive #10). The gates that do keep a value from a caller are the field's own requiredPermissions / maskingRule (ADR-0066 D3) and the permission set.

What this deliberately does NOT declare — requiredPermissions forks

The card's Zone 3 instruction was to stop rather than declare a key whose honouring is itself the defect. That condition is met for requiredPermissions, and it is met on a measurement, not a reading of intent.

The renderers evaluate it as perms.can(objectName, name) (record-details.tsx, record-highlights.tsx, record-related-list.tsx, each writing name as any). That second parameter is typed PermissionAction — this package's own closed enum, packages/spec/src/kernel/plugin-security-advanced.zod.ts: create · read · update · delete · execute · manage · configure · share · export · import · admin. It is not the ADR-0066 capability set, which is what requiredPermissions means on action, app, field and bulkAction, and which objectui evaluates through a different function entirely (hasCapabilities, over systemPermissions).

Both shipped providers were run, each with self-tests and controls that fire in both directions:

MePermissionsProvider (the backend-backed one, /me/permissions). Its check maps eight spellings to a permission bit and falls through: const k = map[action as string] ?? 'allowRead'. Driven with allowRead: true, every write bit false, and systemPermissions: [] — reported, holding nothing:

SELFTEST ok     granted verb 'read' -> allowed
SELFTEST ok     ungranted verb 'delete' -> denied
  can('contract', "read"                     ) -> true
  can('contract', "create"                   ) -> false
  can('contract', "delete"                   ) -> false
  can('contract', "manage"                   ) -> true     -- in the enum, NOT in the map
  can('contract', "admin"                    ) -> true     -- in the enum, NOT in the map
  can('contract', "crm.manage"               ) -> true     -- a capability nobody holds
  can('contract', "showcase.restricted_ops"  ) -> true     -- a capability nobody holds

PermissionProvider (the role-based one), through its real evaluatePermission:

SELFTEST ok     granted verb 'read' with a config present -> allowed
SELFTEST ok     ungranted verb 'delete' with a config present -> denied
SELFTEST ok     no config for the object at all -> allowed (documented default)
  config PRESENT  can('contract', "crm.manage"  ) -> false   -- denied for EVERYONE
  config ABSENT   can('contract', "crm.manage"  ) -> true    -- allowed for EVERYONE

So one authored requiredPermissions: ['crm.manage'] passes for every reader who can read the object under one provider, is denied to every reader under the other, and consults the caller's actual capability set under neither. Declaring it would mint the ADR-0049 fail-open access gate this repo retired from app.areas[].requiredPermissions in 17.0.0 — whose own conversion-registry entry says it in the same words: "FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user".

⭐ objectui's own pin battery for this gate (record-quick-actions.requiredPermissions-gate.test.tsx) mocks usePermissions with a stub whose can(object, action) is granted.has(action). It pins that the renderer calls can() with the right arguments; it cannot see what can() answers, which is where the defect lives.

The question for the seat is in the report on the card. In short: (A) declare it as object ACTIONS (PermissionActionSchema), which is what record-highlights.tsx's own comment says the author should write — "record:* may declare requiredPermissions like ['read','update']" — and what the backend-backed provider's map can honour; (B) declare it as ADR-0066 capabilities and repair the renderer to evaluate it through hasCapabilities; (C) rule it host-composition surface and retire the reads, the ADR-0049 precedent. ⛔ Not decided here.

Also measured, and left alone

record:quick_actions already declares requiredPermissions (z.array(z.string()), described as "unless the current user holds every named permission on this object") and its renderer performs the identical perms.can(objectName, p as any) read. That declaration is on a published surface and predates this branch; it is not touched here and is reported for filing rather than fixed on a card that does not name it.

Evidence

One instrument, self-tested before it was pointed at the tree, run on the same tree before and after the change. Two positive controls, both unmoved:

                        BEFORE                            AFTER
ComponentPropsMap       48 entries                        48 entries
strict blocks           47 (loose: user:profile)          47 (loose: user:profile)
requiredPermissions     A=1 [record:quick_actions]        A=1 [record:quick_actions]
enforceFieldSecurity    A=0 []                            A=3 [details, related_list, highlights]
redactFields            A=0 []                            A=3 [details, related_list, highlights]
aria   POSITIVE CONTROL A=21                              A=21
fields POSITIVE CONTROL A=7                               A=7

Named refusals on record:related_list, base document legal on its own:

                                 BEFORE                                  AFTER
CONTROL+  +columns               success=true                            success=true
CONTROL-  +zzqx_no_such_key      success=false  unrecognized_keys [...]  success=false  unrecognized_keys [...]
          +requiredPermissions   success=false  unrecognized_keys [...]  success=false  unrecognized_keys [...]
          +enforceFieldSecurity  success=false  unrecognized_keys [...]  success=true
          +redactFields          success=false  unrecognized_keys [...]  success=true
          baseline               success=true                            success=true

packages/spec/authorable-surface/ui.json moved by exactly six lines, all additions, and nothing else.

Verification

Run on 676d44d593, the head this PR carries.

  • pnpm --filter @objectstack/spec exec vitest run src/ui/component-record-block-field-security.test.ts — 18 tests, exit 0. With the three sibling component suites: 4 files / 397 tests, exit 0.
  • pnpm --filter @objectstack/spec typecheck — exit 0 (test layer: 54 files / 259 errors / 144 pinned signatures held, unmoved).
  • pnpm --filter @objectstack/spec check:generated — exit 0. gen:api-surface-declarations and gen:docs were regenerated after the check proved them stale; authorable-surface/ui.json is the build's own output. authorable-surface.base.json is untouched.
  • MANIFEST=$PWD/sdui.manifest.json pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict — exit 0, as lint.yml spells it. ⚠️ Note this gate does not cover these three blocks at all: they are withdrawn from the react tier (REACT_RECORD_BLOCK_ALTERNATIVES), so no spec-only signal arises here — and spec-only is a reported, never-ratcheted signal in any case.
  • pnpm lint — the whole tree, eslint . --no-inline-config, exit 0. No narrowing was needed.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE — 107 derived families accounted for: 106 run, 1 NOT MEASURED. The one is pnpm check:dual-build-cjs-loads, which exits 3 (PREREQUISITE NOT MET) because ~34 packages have no dist/ in this worktree; it needs a whole-workspace build, which CI's Build Core provides. Six other families first exited 3 or 1 on the same prerequisite class and were re-run green after building @objectstack/lint, @objectstack/formula, @objectstack/client-react and @objectstack/driver-turso.

Acceptance notes

Out of scope for this card, noted and not filed from here:

  • record:quick_actions.requiredPermissions carries the same defect on a published surface (z.array(z.string()), described as a capability gate, evaluated against the closed action enum). Reported for filing; deliberately not fixed on a card that does not name it. Successor: the same ruling that settles this card's fork.
  • redactFields neighbours hideFields on record:details, and on a well-formed field list the two remove the same rows. They are different channels — hideFields is the dedupe list the renderer also writes to (live record:highlights registrations, the page-title field), redactFields is the author's deliberate omission and the arm that participates in the fail-closed fold — and the TSDoc says so. Converging them is a contract question this card did not open. Successor: whoever next edits RecordDetailsProps.
  • packages/spec/src/ui/react-blocks.ts was in the dispatched file surface and needed no edit. All three blocks are withdrawn from the react tier because they take their record from context, so they carry REACT_RECORD_BLOCK_ALTERNATIVES rows rather than published props. Carrier: none — stated so the absence reads as measured rather than skipped.

⚠️ Not measured

  • The runtime behaviour of either declared key in a browser. No dogfood or integration run was made for this change, by anyone on this chain.
  • Whether any authored document anywhere writes these keys. "The schema refused it" is not "nobody writes it"; only the first is measured.
  • Whether the same gap exists on element faces beyond these three blocks. The census above covers all 48 ComponentPropsMap entries for the three key names; the renderer side was read only for @object-ui/plugin-detail.

Generated by Claude Code

`record:details`, `record:highlights` and `record:related_list` are
`strictObject`s that declared none of `enforceFieldSecurity` /
`redactFields`, while `@object-ui/plugin-detail` reads and honours both
on each of the three — an author who wrote either was refused at parse
while the renderer honoured the same document on the raw-node path.

Declare both on all three blocks, with each `describe()` held to what
the render path actually does: they are presentation filters over the
field list the block draws, applied in the browser after the record is
fetched, never a data-access control (Prime Directive #10).

The third key the same renderers read, `requiredPermissions`, is NOT
declared here — its evaluator's vocabulary is the closed
`PermissionActionSchema` enum, not the ADR-0066 capability set the name
means everywhere else in this spec. That exit is the spec seat's to rule.

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

`gen:api-surface-declarations` and `gen:docs`, as `check:generated` proved
stale; `authorable-surface/ui.json` is the build's own regeneration. Six
additions in the authorable surface and nothing else — the two keys on the
three record blocks.

The changeset is `minor` and carries `Clause-②: yes (widening)`: the three
keys were refused at parse before this change, so declaring two of them
widens the accept set on a published authoring surface.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/protocol/objectql/security.mdx (via redactFields (symbol, a field of const object RecordDetailsProps, an authorable key of ui/RecordDetailsProps; a field of const object RecordHighlightsProps, an authorable key of ui/RecordHighlightsProps; a field of const object RecordRelatedListProps, an authorable key of ui/RecordRelatedListProps))
  • content/docs/protocol/objectui/layout-dsl.mdx (via RecordRelatedListProps (symbol, a top-level const object))

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

  • content/docs/releases/v12.mdx (via RecordRelatedListProps (symbol, a top-level const object))
  • content/docs/releases/v14.mdx (via RecordRelatedListProps (symbol, a top-level const object))

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
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/ui.txt, packages/spec/authorable-surface/ui.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 136 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 eeaa882459edffd077c1cda2972af06d1f10550b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json eeaa882459edffd077c1cda2972af06d1f10550b

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

os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 676d44d593a8e0d8546bac212e2cb335b7aa22ea

Isolated at-tier contract review of PR #19185 (Part of #18159), rendered at 2026-09-19T09:38Z. Inputs: card #18159 and its thread, the PR body / full diff / changeset / check runs, the tree at origin/main (eeaa882459) and at the head above, AGENTS.md, docs/NORTH-STAR.md, ADR-0049 / ADR-0066 / ADR-0087, and the sibling objectui checkout at the pinned .objectui-sha (53ded82bf7, 2026-09-05) and at its main (dda8f3815d, 2026-09-16). Seat comments and the os-dev-report JSON were treated as claims, not evidence; every reading below was taken first-hand.

① Derived judgments

The accept-set delta, measured. My own parse probe (self-tested first: strict refuses nonsense / strict accepts a declared key / loose refuses nothing, 3 of 3) run inside each tree, and the PR's own suite src/ui/component-record-block-field-security.test.ts run on the extracted head tree (18 passed, exit 0) and reverse-run unchanged against origin/main (9 failed / 9 passed, exit 1: exactly the ACCEPT pins and the three-row census fail, every ABSENCE pin and control passes, so the suite measures the change rather than the tree):

key origin/main head 676d44d593
enforceFieldSecurity 0 blocks 3: record:details, record:highlights, record:related_list
redactFields 0 blocks 3: the same three
requiredPermissions 1: record:quick_actions 1: unchanged; still REFUSED-BY-NAME on all three blocks
nonsense-key control refused by name on all three refused by name on all three (strictness kept)
aria positive control 21 blocks 21 blocks
ComponentPropsMap rows 48 48

Every schema is the exported one (ComponentPropsMap[type] === RecordDetailsProps etc.), and an absent key stays absent on all three (no default materialises).

Item by item, against the renderer (@object-ui/plugin-detail, read at the pin and at objectui main):

  1. enforceFieldSecurity: z.boolean().optional(), no default: shape CORRECT. All three renderers read (schema as any).enforceFieldSecurity === true (record-details.tsx, record-highlights.tsx, record-related-list.tsx, identical at pin and main), so "renderer default: off" is what the code does and an explicit false is the same as absent to the renderer, which the schema does not pretend otherwise.
  2. enforceFieldSecurity describe(): held to the render path, and it does not claim enforcement. The fold is useFieldPermissions(objectName).readableFields(names), i.e. checkField(object, field, 'read') per name. Under MePermissionsProvider that reads the server's /me/permissions projection (data.fields[obj.field].readable, fail-closed while loading); under PermissionProvider it reads the client role config (default allow); with no provider mounted checkField is () => true. In every case the fold can only REMOVE entries, so "never widens access" is literally true. "With it off a denied field still arrives masked or stripped" is the server's doing and is verified in this repo: plugin-security FieldMasker strips readable: false fields from results and masks maskingRule fields, with field-level requiredPermissions AND-gated into the mask at the ADR-0066 D3 sites in security-plugin.ts. maskingRule and requiredPermissions are live keys on the field schema, so the prescription the text gives ("gate the field itself") names real gates. On record:related_list the hook is bound to schema.objectName, the RELATED object, as that block's describe() says.
  3. redactFields: z.array(z.string()).optional(), no default: shape CORRECT. All three renderers read Array.isArray(schema.redactFields) and test redact.includes(name) on machine names; with the key absent the list is empty and everything authored renders, which is the "renderer default" each describe() states.
  4. redactFields describe(): held to the render path, and the disclaimer is the load-bearing sentence. The filter runs in the browser over the block's list after the record or rows are fetched; the payload carries the values regardless. The contrast the text draws is real: the object's publicSharing.redactFields is a different key on a different schema, stripped server-side by plugin-sharing (share-link-routes.ts step 3, share-link-service.ts resolving redactFields per token). Every one of the six describe() strings says "Presentation only … NOT a data-access control" in the text an author reads. That is the opposite of the failure the North Star names, 「声明即强制,绝不让 AI 声明一个运行时不兑现的能力」: no wording on either key could lead an author to believe it enforces access, and the docs page the drift check flagged (content/docs/protocol/objectql/security.mdx) mentions redactFields only inside publicSharing examples, so it cannot be misread onto this key.
  5. One clause is TRUE on objectui main and NOT YET TRUE at the pin, recorded rather than hidden. record:related_list.redactFields says "Applies to the authored columns AND to the columns the list derives for itself when none are authored." At objectui main the renderer hands the list down (record-related-list.tsx: redactFields={redact.length > 0 ? redact : undefined}) and RelatedList.tsx filters its auto-derived walk by it (objectui#9090, commit 7e50e847ed, 2026-09-11). At the pinned 53ded82bf7 (2026-09-05) neither exists: with no authored columns the derivation ignores the list, and the npm @object-ui/plugin-detail@17.6.0 this console line last published (2026-08-24) predates it as well; objectui's changeset for it is still unversioned. So the derived-column half of that sentence is not delivered by the console this repo pins today. It is not a FAIL: this file's standing convention is to describe objectui main (precedent on the same block: sections[].hideEmpty, declared here 2026-08-24 with "renderer default: on" while the pinned renderer had retired the read, restored on objectui main 2026-09-16 after the maintainer ruled the protocol correct), and the gap sits on the safe side of the access claim, because the same sentence tells the author the values are in the page either way. Carrier: the next .objectui-sha bump past 7e50e847ed discharges it; worth one line in the card's Release: on landing. The TSDoc's "fails closed on an entry it cannot NAME (objectui#9054 / Refuse the declared relative-date preset vocabulary as a bare temporal comparand at publish time — the ruled C half of #8690, carved out for the spec seat #8793)" is likewise main-only (pin: return n ? allowed.has(n) : true), but that is a code comment, not shipped describe() text, and the describe() strings make no such claim.
  6. Generated artifacts and docs follow the schema and nothing else moved. authorable-surface/ui.json +6 rows (exactly the six key/block pairs, authorable-surface.base.json untouched), api-surface-declarations/ui.txt +12 (the three schemas plus their ComponentPropsMap mirror), content/docs/references/ui/component.mdx +6 rows carrying the describe() text verbatim (AUTO-GEN regenerated, not hand-edited). react-blocks.ts needed no edit: all three blocks sit in REACT_RECORD_BLOCK_ALTERNATIVES, so no react-tier props surface exists for the keys to reach. The PR's file surface is the dispatched one plus the changeset and the three regenerated artifacts.

② Semver level

.changeset/18159-record-block-field-security-pair.md declares "@objectstack/spec": minor and its body carries Clause-②: yes (widening). Correct by this repo's rules: two new optional keys with no default on three published schemas widen the accept set and remove or rename nothing, so this is a feature widening (patch would understate it) and not a break (major would overstate it; AGENTS.md: yes takes at least minor, and only (narrowing) is the breaking arm). No ADR-0087 disposition marker is owed because the changeset is not breaking, and the gates agree when run against this head: check-adr-0087-registration.mjs prints "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)", exit 0; check-empty-changeset.mjs exit 0 (one declaring changeset added, none from the merge base modified); check-changeset-no-major.mjs exit 0. Both Check Changeset runs on the head are green. The body text that will ship verbatim into CHANGELOG.md is accurate to what I measured, including its statement that the third key stays refused.

③ Boundary flags

  • Clause-②: yes (widening) STANDS. The governing claim (comment 5740363318) declares yes; the PR body and the changeset declare yes (widening). check-widening-tells.mjs fed this diff with a hypothetical no fires six T1 tells (new keys at component.zod.ts:1115, :1118, :1228, :1231, :1327, :1330), so yes is the only truthful declaration, and the arm is widening because nothing is narrowed. PM_SWEEP_REPO=objectstack-ai/objectstack node scripts/pm/check-clause2-carriers.mjs --pair 19185 read exit 0 at 2026-09-19T09:26Z: declaration readable in the fixed spelling, both carriers labelled needs:contract-review, pair head 676d44d593.
  • (a) Leaving requiredPermissions refused in this PR is CORRECT. Verified at the pin: all three renderers evaluate it as required.every((p) => perms.can(objectName, p as any)); can's second parameter is PermissionAction, this package's closed enum (create, read, update, delete, execute, manage, configure, share, export, import, admin; PermissionActionSchema.safeParse('crm.manage').success === false), not the ADR-0066 capability set the same key names on action, app, field and bulkAction. MePermissionsProvider.check maps eight spellings and falls through map[action] ?? 'allowRead', so an unmapped capability name passes for any reader who can read the object; PermissionProvider routes to evaluatePermission, which allows by default when the object has no config (evaluator.ts:42). Declaring the key now would ship a parsed security property whose runtime honouring fails open under a shipped provider: the fourth state ADR-0049 prohibits, the "never advertise a capability the runtime doesn't deliver" of Prime Directive chore: version packages #10, and the exact shape this repo's ADR-0087 registry records as "FAIL-OPEN access gates" for app.areas[].requiredPermissions. The card's own text leaves the per-key exit to this repo's ruling and the triage grading left it open; the PR's ABSENCE pin is written as tree-scoped, not as a permanent refusal. Stopping was right. Which exit that key takes is deliberately not judged here.
  • (b) Part of #18159 rather than Fixes is CORRECT. references/review-checklist.md: 「Fixes #N 仅当合并应当关卡;只落地了可实施的一半 ⇒ 必须 Part of #N」 (the source spells the card number with angle brackets, which are kept out of this comment for the sanitizer's sake). One of the three routed keys is undelivered, so merging must not close the card; the body's first section names which half stays open and why. The head's Part-of PR must not also close its card check is green.

Checks on the judged head, read at 2026-09-19T09:38Z: 39 check runs, all completed, 34 success, 5 skipped, none failed and none pending. All seven required contexts are green by name: Lint & Repo Gates (which carries the spec generated-artifact gates, so the regenerated authorable-surface, api-surface-declarations and docs are the build's own output), TypeScript Type Check, Test Core (all six shards), Dogfood Regression Gate (all three), Build Core, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard. Full green is the landing seat's precondition to re-read at arming time; this verdict rests on ①②③ above.

Not measured by this review: the runtime behaviour of either key in a browser (no dogfood run was made), and whether any authored document anywhere writes these keys. The objectui renderer was read as source at two commits, not executed.

Implemented-by: claude/issue-18159-record-block-permission-keys (mode:subagent, the branch on the governing claim)
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3 (mode:subagent: isolated at-tier reviewer, adopted by that seat session)

VERDICT: PASS


Generated by Claude Code

os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Clause-② provenance — carriers cleared on an at-tier PASS; ⛔ auto-merge deliberately NOT armed yet

domain:spec seat 2 (session_01JbZnqu8bt6YqfJsr9vaFb3), 2026-09-19T09:41Z.

Record adopted: comment 5740810186, a ## Contract review naming head 676d44d593a8e0d8546bac212e2cb335b7aa22ea — the head this PR carries now. Verdict PASS. Adopted verbatim: ⛔ nothing in it was rewritten, trimmed or polished, and it is the record whichever way it had read.

Tier, measured rather than taken from the reviewer's word. Rendered by an isolated at-tier subagent fed only the card, its rulings and this PR — ⛔ never the dispatch order, ⛔ never this seat's conclusions, and told in terms that seat comments and the os-dev-report are not evidence. Evidence is the subagent's own transcript, parsed per request: 141 of 141 type:"assistant" lines stamped claude-fable-5-1, against CONTRACT_REVIEW_TIER imported live from scripts/pm/dispatch-gates.mjs; 0 at any other stamp. ⛔ get_session was not used.

Carriers cleared in one act, both read back at 2026-09-19T09:40Z: card #18159 → priority:p2, pm:dispatched, domain:spec; this PR → documentation, size/m, tests, tooling, protocol:ui.

Clause-②: yes (widening) STANDS — the review confirms it rather than overturning it: the two keys were refused at parse by strictObject and now are not, which is a widening of a published authoring surface.

⭐ The review's substantive finding, recorded because it is the one that matters for this repo's North Star: it read the three objectui renderers, both permission providers and the evaluator at the pinned sha and at objectui main, and confirms both declared keys are browser-side filters that can only remove entries, with all six describe() strings saying "Presentation only … NOT a data-access control" — and that the server-side controls the text leans on are real here (FieldMasker, field requiredPermissions/maskingRule, plugin-sharing stripping publicSharing.redactFields). ⇒ no wording here can lead an author to believe either key enforces access. It also independently verified the fail-open reading behind the third key, so leaving requiredPermissions refused is correct — and ⛔ it did not rule that key's exit, which is #19186's and the maintainer's.

One finding it recorded rather than failed, carried here so it is not lost: record:related_list.redactFields's clause 「AND to the columns the list derives for itself」 is true on objectui main (objectui#9090) but not at the pinned 53ded82bf7 nor npm @object-ui/plugin-detail@17.6.0. Consistent with this file's convention of describing objectui main (the sections[].hideEmpty precedent) and on the safe side of the access claim. It is discharged by the next .objectui-sha bump past 7e50e847ed, and it gets a line in this card's Release: when this PR lands.

⛔ Why auto-merge is NOT armed, and what will arm it

Every landing precondition is met — PASS of record on this head, both carriers cleared, check-clause2-carriers --pair 19185 exit 0, and all 39 check runs green at the review's reading. What is not healthy is the queue path itself.

Measured this hour on a sibling PR of this seat: #19183 entered the queue at 2026-09-19T09:10Z and was ejected at 2026-09-19T09:27Z with nothing red anywhere — because Lint & Repo Gates in the merge group takes 28m50s (it concluded success at 2026-09-19T09:39Z) and the queue removed the PR while it still had ~12 minutes to run. Carded as #19189.

⇒ Arming now would very likely spend a fleet merge group to reproduce that, and 「合并队列是共享串行资源」. So this seat holds the arm on one cheap and decisive test it is already watching: the group formed at 09:27:31Z for another PR is on the same path. If that group lands, the arm goes on immediately. If it is ejected the same way, #19189 is confirmed with n=2 and this PR waits for it. ⛔ Nothing else about this PR is outstanding.


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 19, 2026 09:41
@os-bill
os-bill added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit d402e32 Sep 19, 2026
44 checks passed
@os-bill
os-bill deleted the claude/issue-18159-record-block-permission-keys branch September 19, 2026 10:24
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s / related_list with one true describe shared with record:quick_actions (objectstack-ai#19913)

Fixes objectstack-ai#18159

Clause-②: yes

<sub>Rewritten short by the `domain:spec#5` seat (2026-09-23T19:59Z).
The dev reports are on objectstack-ai#18159 (`5798750532`, `5800008407`, `5800978880`,
`5801656924`); the earlier long body is in the edit history.</sub>

`requiredPermissions` is now declared on `record:details`,
`record:highlights` and `record:related_list`, with the shape
`record:quick_actions` already has (`z.array(z.string()).optional()`).
Ruling: batch objectstack-ai#197 item 2, letter A (`5749268463`), after objectui#10058
became installable at `.objectui-sha` `62597c588072`.

## One describe, four blocks (seat ruling `5798783314`)

The ruling asks for the same describe as `record:quick_actions`. That
published describe said "…every named permission **on this object**",
which is false for the renderer at the pin: the check is
`hasCapabilities`, and the renderer's own comment says the capability
"is not object-scoped". So all four blocks now share ONE describe, from
one constant (`RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION`). **The
published `record:quick_actions` describe changes; its shape does not.**
The describe states:
- the names are ADR-0066 capabilities, not object actions;
- the user must hold all of them;
- when one is missing, the block shows an insufficient-permissions
notice instead of its content;
- it is presentation only, and the data API still serves the data;
- a client that cannot resolve the user's capabilities renders the block
(fails open).

The renderer lines behind each clause are in the dev report
`5800008407`.

## Other changes

- The texts that said the key is "deliberately NOT declared" are
rewritten: the `RecordDetailsProps` family header, the related-list and
highlights pointers, and the test header.
- The absence pins are now accept pins: a parse probe and `.shape`
enumeration per block, with `aria` and `fields` as lit controls, plus a
pin that the four JSON Schemas of the key are identical. Removing the
three declarations turns 11 of 25 pins red.
- `authorable-surface/ui.json` and `component.mdx` are regenerated.

## `Check Changeset` is red on purpose

The pending note `.changeset/18159-record-block-field-security-pair.md`
(from PR objectstack-ai#19185) said the third key "is deliberately NOT declared". This
PR makes that false, so the PR corrects it. That is a DELIBERATE
CORRECTION under `check-empty-changeset.mjs`: the check stays red, and
the correction awaits the maintainer's written confirmation on this PR.
The line to confirm is quoted verbatim in `5801679846`. `Check
Changeset` is not a required context.

## Fix round 2026-09-25: `origin/main` merged, objectui pin re-measured

Fix round for `domain:spec#4` (session
`session_019c3Hi6ZMU1p6m6aA6Bz45d`), new head `b19ac16c77`. The merge
queue dequeued the PR. `Type Check · source gates` failed at
`check:objectui-pin-citations`: objectstack-ai#20036 (`0bf85eaae6`) moved
`.objectui-sha` from `62597c588072` to `f8a9d0fb0596`, and the shared
`requiredPermissions` docblock in `component.zod.ts` still asserted the
old pin. This round adds three commits and changes nothing else:

- `62decd77dd` merges `origin/main` at `66960564d9` with
`scripts/pm/os-regen-merge.sh` (merge commit; no rebase or force-push).
It had no text conflict.
- `a3075b6ace` regenerates `packages/spec/authorable-surface/ui.json` on
the merged tree. In that file the merge driver kept this branch's side
and dropped main's 13 `DocNavItem` keys. The regenerated file has main's
keys plus this PR's three `requiredPermissions` keys, and nothing else.
- `b19ac16c77` re-measures the docblock at the new pin. It updates the
sha and the anchors together (10 lines replaced, 0 added).

### What was measured

objectui at `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` was read from a
scratch fetch of both pins. The seven cited files match the codeload
tarball of the same commit byte for byte. All 21 anchors in the docblock
were re-read. **At the new pin, each cited range holds the same text it
held at `62597c588072`.** No read point changed meaning or disappeared.

- `record-details.tsx` shifted +1 (one new import line).
`record-related-list.tsx` shifted +34 (an import block and the
props-type docblock). Nine anchors get new numbers.
- `record-highlights.tsx`, `record-quick-actions.tsx`,
`MePermissionsProvider.tsx`, `PermissionProvider.tsx` and
`usePermissions.ts` did not change between the two pins, so their twelve
anchors keep their numbers.

| Anchor, old → new | First line of the range at `f8a9d0fb0596` |
|---|---|
| `record-details.tsx:234` → `:235` | `if (required.length > 0 &&
!perms.hasCapabilities(required)) {` |
| `record-details.tsx:223` → `:224` | `* capability is not
object-scoped, and the old && objectName conjunct was` |
| `record-details.tsx:234-242` → `:235-243` | `if (required.length > 0
&& !perms.hasCapabilities(required)) {` |
| `record-details.tsx:186` → `:187` | `if (!ctx) {` |
| `record-related-list.tsx:242` → `:276` | `if (required.length > 0 &&
!perms.hasCapabilities(required)) {` |
| `record-related-list.tsx:229` → `:263` | `* capability is not
object-scoped. This site never carried the` |
| `record-related-list.tsx:242-250` → `:276-284` | `if (required.length
> 0 && !perms.hasCapabilities(required)) {` |
| `record-related-list.tsx:184` → `:218` | `if (!objectName) {` |
| `record-related-list.tsx:202` → `:236` | `if (perms.isLoaded &&
!perms.can(objectName, 'read')) {` |
| `record-highlights.tsx:93` (unchanged) | `const highlightsAllowed =
required.length === 0 \|\| perms.hasCapabilities(required);` |
| `record-highlights.tsx:77` (unchanged) | `* capability is not
object-scoped, and the old && objectName conjunct was` |
| `record-highlights.tsx:151-164` (unchanged) | `if (!highlightsAllowed)
{` |
| `record-highlights.tsx:146-149` (unchanged) |
`useRegisterHighlightFields(` |
| `record-quick-actions.tsx:263` (unchanged) | `if (required.length > 0
&& !perms.hasCapabilities(required)) {` |
| `record-quick-actions.tsx:252` (unchanged) | `* capability is not
object-scoped, and the old && objectName guard was a` |
| `record-quick-actions.tsx:263-271` (unchanged) | `if (required.length
> 0 && !perms.hasCapabilities(required)) {` |
| `MePermissionsProvider.tsx:416`, and the later `:416` (unchanged) |
`return required.every((p) => held.has(p));` |
| `MePermissionsProvider.tsx:414` (unchanged) | `if
(!Array.isArray(perms)) return true;` |
| `PermissionProvider.tsx:77` (unchanged) | `const ALL_CAPABILITIES:
PermissionContextValue['hasCapabilities'] = () => true;` |
| `usePermissions.ts:45` (unchanged) | `hasCapabilities: () => true,` |

(The inner backticks around `&& objectName` in the three docblock lines
are left out of the table.)

One observation from the re-read, which changes no anchor. The
props-type docblock that `record-related-list.tsx` gained (`:89-92`)
says no block the contract maps onto this tag declares
`requiredPermissions`, and says not to reopen the type to admit it. This
PR makes that sentence stale. That is the objectui half ruled **A** on
objectstack-ai/objectui#10281, and it moves to the `domain:ui` seat. The
renderer's read of the key (`:240-242`, through a cast) and its gate
(`:276`) are unchanged.

### Gates, on head `b19ac16c77`

- `check:objectui-pin-citations` exits 0 on `--self-test`, on the
ordinary run and on `--verify-anchors`, with objectui at the new pin
supplied through `OBJECTUI_ROOT`. The ordinary run prints: "48 asserting
objectui pin citation(s) match .objectui-sha (f8a9d0fb0), 40 historical
citation(s) recorded and not checked, across 1555 spec source(s). 7
anchor content assertion(s) verified against objectui at f8a9d0fb0". CI
has no objectui checkout, so it verifies the sha label only.
- `pnpm --filter @objectstack/spec build`, then `check:generated`: all
15 generated artifacts are up to date. `typecheck` exits 0. The full
spec suite: 535 files, 15727 passed, 2 todo.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 107 commands
for this change set. All 107 ran, and `--ran` reconciles 107 of 107 with
recorded exit codes and 0 NOT MEASURED. Seven of them first exited 3 (no
build to read). They were re-run green after building the lint closure,
the client-react closure and then every package. 106 exit 0. The one
exit 1 is `check-empty-changeset.mjs`. That is the deliberate correction
described above, which the maintainer confirmed at head `aeb6a57456`
(`5823706830`).
- The changesets are byte-identical to that head: `git diff --exit-code
aeb6a57 b19ac16 --
.changeset/18159-record-block-field-security-pair.md
.changeset/18159-record-block-required-permissions.md` exits 0. The
blobs are `d502a9a04c6a` and `80546d851168` on both heads. The test file
and `component.mdx` are byte-identical to that head as well.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 protocol:ui size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants