Skip to content

fix(spec): refuse a depth axis declared beside the super-user bit that short-circuits it (#16870) - #17889

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-16870-viewallrecords-readscope-refusal
Sep 12, 2026
Merged

fix(spec): refuse a depth axis declared beside the super-user bit that short-circuits it (#16870)#17889
os-bill merged 2 commits into
mainfrom
claude/issue-16870-viewallrecords-readscope-refusal

Conversation

@os-bill

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

Copy link
Copy Markdown
Collaborator

Fixes #16870

Clause-②: no — SET BY THE SEAT AFTER MEASUREMENT, and the earlier yes is withdrawn with its reason. The rule is references/lanes/spec.md — "放宽接受集或扩大公开面的卡,不论多小,即条款②;收窄仍是语义面,⛔ 不触条款②" — and references/contract-review.md line 13 — "新导出符号或已发布载荷上的新键恒 yes". This diff is a pure accept-set NARROWING with zero new exported symbols and zero new keys, both legs measured by the round with lit controls (a dummy export const in the same file DOES move api-surface/security.json and export-origins/security.json; one extra optional key on the same shape DOES turn check:authorable-surface red naming it). The seat verified the export leg itself: the only added line in the whole diff containing the token export is prose inside a comment. ⚠️ The seat's original yes rested on "narrowing is also moving a declared surface", which is NOT a rule in this repo. ⭐ An at-tier contract review is still being run on this PR — as a SEAT ELECTION for a BREAKING refusal on a permission surface, ⛔ not because clause ② requires one.

The defect, reproduced on this branch point

An object permission could carry readScope beside viewAllRecords: true. PermissionEvaluator.getEffectiveScope answers org on the super-user bit before it consults the depth key (permission-evaluator.ts:264 ahead of the read at :266), and getDeclaredScope — the ADR-0090 D10 delegated-path input — carries the identical short-circuit at :305 ahead of the identical read at :307. So the declared narrowing is unread on the direct read path and dropped from the delegation fold. The reading preserved by triage is confirmed, unchanged, on 272c04b460; nothing in plugin-security is touched by this PR.

Yet the declaration was accepted with zero diagnostics, materialised into sys_permission_set.object_permissions, and counted by a capability census reading the deployed shape as coverage. Measured on the branch point before any edit:

ObjectPermissionSchema.parse({ allowRead: true, viewAllRecords: true, readScope: 'own_and_reports' })
  -> ACCEPTED  { …, viewAllRecords: true, readScope: 'own_and_reports' }
PermissionSetSchema.parse({ name: 'sales_manager', objects: { crm_opportunity: { …the pair… } } })
  -> ACCEPTED

Escalation condition — re-measured on today's tree, still No

Triage's rule: if any shipped or seeded permission set carries the pair today, this is p1 on the maintainer floor. Re-taken on 272c04b460 (the earlier reading was on ecdfc9411), 0, with a lit control on every probe:

probe result control that could have come back the other way
structural jq walk, every tracked .json (536 files) 0 pair nodes 130 nodes declare viewAllRecords, 53 of them true; 18 declare readScope
examples/app-showcase/access-matrix.json 0 47 / 5 nodes, 4 with viewAllRecords: true
examples/app-crm/access-matrix.json 0 7 / 0 nodes — ⚠️ the left leg is empty here, so this file's zero is weak and is carried by the others
packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json 0 75 / 12 nodes, 49 with viewAllRecords: true
shipped defaults, packages/plugins/plugin-security/src/objects/default-permission-sets.ts 0 8 viewAllRecords tokens; the single readScope token is prose inside a comment
seeded example, examples/app-showcase/src/security/permission-sets.ts 0 4 viewAllRecords: true grants and 5 readScope grants, disjoint
same-literal brace-local sweep, every tracked .ts / .json 2 hits, one file 133 brace-local viewAllRecords: true literals matched — the regex is not dead
nesting-tolerant pass (both tokens within 400 chars) 6 candidate files each read by hand: in all six the two tokens are in separate permission sets or separate objects, never one object permission
FORMAT control — *.yml / *.yaml 0 files carry either token 38 yaml files carry permission, so the sweep can see yaml

The only same-literal pair in the tree is packages/spec/src/security/explain.test.ts, which is the guard below, not an instance of the defect.

⇒ nobody is running on a grant they believe is scoped. priority:p2 stands. No STOP.

The site, picked by measurement

Two candidates were open to this lane: a refinement on ObjectPermissionSchema, an author-time rule beside SECURITY_PRIVATE_NO_READSCOPE, or both. Chosen: the schema refinement, alone. The measurement that decided it is ORDER — which site runs before the declaration is stored:

  • packages/cli/src/commands/compile.ts:374 parses through ObjectStackDefinitionSchema and exits at :376 on failure. The author-time rule registry does not run until :415, and buildAccessMatrix — the census substrate — not until :678. Both read result.data, i.e. post-parse.
  • validateSecurityPosture is registered in packages/lint/src/authoring-rules.ts with input: 'parsed'. There is no call site that feeds it pre-parse data.
  • The metadata write path is the same order: saveMetaItem resolves PermissionSetSchema from the permission row of packages/spec/src/kernel/metadata-type-schemas.ts and runs that Zod parse first; packages/metadata-protocol/src/runtime-authoring-gate.ts's own header states the gate was added because that path "ran a per-type Zod safeParse and stopped".
  • And the runtime rule gate has an escape hatch the schema does not: OS_ALLOW_UNLINTED_METADATA_WRITES=1 degrades a rule refusal to a log. A lint-only fix would leave the declaration materialising through it, which is the headline harm.

⇒ the schema is the only site that stops the declaration from being STORED. A lint rule for the same pair, added beside it, could then never fire — a check no code path can reach, which is the very shape this card is about. "Both" is measured NOT owed, and the cost it would have carried (a finding with a hint) is paid instead by the refusal message, which names both remedies at the located path.

What this does not do: ⛔ it does not change what viewAllRecords: true grants. Triage closed "honour the scope" as a permission-semantics change and the maintainer's; only which declarations are accepted moves.

What is refused, and the one shape that deliberately is not

The refusal is the two short-circuits transcribed, not a guess — which makes it asymmetric:

declaration resolver line verdict
readScope + viewAllRecords: true opClass === 'read' && (viewAllRecords || modifyAllRecords) refused
readScope + modifyAllRecords: true same disjunct refused
writeScope + modifyAllRecords: true opClass === 'write' && modifyAllRecords refused
writeScope + viewAllRecords: true the write short-circuit does not name viewAllRecords accepted — honoured; refusing it would delete a real grant

The two sibling refusals are the same defect class read off the same two lines; refusing only the headline third of it would have left two silent variants in the same accept set.

Scope is ONE object-permission entry, which is exactly the resolver's input: resolveObjectPermission returns a single entry (explicit, else the '*' wildcard) and never merges two. A super-user bit in one permission set widening past another set's readScope is ADR-0090's documented additive "widest wins" semantics, not a contradictory declaration, and is not judged.

Placement, and why the wire surface does not move

The refinement rides on ObjectPermissionBaseSchema inside the acceptRetiredDefaultResidue stage. Both halves were measured on zod 4.4.3, not assumed:

⚠️ Correction, from at-tier review — the earlier flat claim "the WIRE surface stays tolerant" was broader than what was measured. It covered GetEffectivePermissionsResponseSchema only. One further response payload embeds the authoring schema directly: GetObjectPermissionsResponseSchema at packages/spec/src/api/protocol.zod.ts:2516-2519 declares permissions: ObjectPermissionSchema, so that response now carries the refinement too.

Disposition: stated, not changed, and here is why. Review measured no in-tree break — the only parse site is one spec test (packages/spec/src/api/protocol.test.ts:216), there is no production or client runtime parse (packages/client re-exports the TYPE, and a refinement does not move z.input), and this is the pre-existing posture rather than something this PR introduces: that response was already tied to the strict authoring shape, refinement or no. Splitting it onto a tolerant clone would be a response-contract change with no measured consumer asking for it, and it belongs to whoever next revisits that endpoint, not to a refusal PR. ⇒ recorded as a residual a reviewer should see, with the scope of the tolerance claim narrowed to match the measurement.

The check function is deliberately module-private: the security barrel re-exports this module with export *, and an exported refinement helper would land on the published API surface for no caller. check:api-surface is green with no artifact regeneration.

A .shape consumer outside both spec vitest projects, added to the placement evidence. packages/lint/src/validate-security-posture.test.ts:1220 and :1226 read shapeKeysOf(ObjectPermissionSchema) — a CROSS-PACKAGE consumer of exactly the read-through the placement preserves, in a package neither --project local nor --project repo covers. It resolves @objectstack/spec through the package exports, i.e. dist, so it is only a reading once spec is built. Both legs are reported below.

The nail — explain.test.ts, rewritten with its reason, not deleted

The assertion triage flagged is AccessMatrixEntrySchema.parse({ … viewAllRecords: true, … readScope: 'unit_and_below' … }) (verified by symbol on this tree: the parse-side pair sits at lines 374–375 and the assert-side at 380–381, one line off the cited :375 / :381).

⚠️ One dispatch premise is falsified here, and reported rather than carried: it is not a guard this refusal flips. AccessMatrixEntry is a derived SNAPSHOT shape, not an accept set, and it should keep tolerating the pair for two measured reasons:

  1. buildAccessMatrix constructs entries from already-parsed metadata, and os build reads a committed access-matrix.json back with a bare JSON.parse (compile.ts:692) to diff it. A snapshot written by an older toolchain may carry the pair and the drift diff must keep describing it.
  2. Nothing in the tree parses through that schema outside that one test file. Whole-tree grep for AccessMatrixEntrySchema / AccessMatrixSchema: 8 files — two docs pages, three generated spec surface manifests, explain.zod.ts, explain.test.ts, and a type-alias pin. Zero production parse sites. Control: PermissionSetSchema appears in 120 files. ⇒ a refusal added there would be a check no code path can run — the declared-but-unenforced shape of this very card, reproduced one level up.

So the verdict stays and the reason is rewritten, which is what the guard was actually missing: the test now says in its own text that the pair is refused by the AUTHORING accept set and tolerated here on purpose, and a new sibling test asserts that boundary rather than describing it — if the authoring schema ever starts accepting the pair again, explain.test.ts goes red too. ⛔ Nothing was deleted.

Pins, both directions

packages/spec/src/security/permission.test.ts — refusal direction: each of the three refused shapes, the located path (readScope / writeScope), the message naming both remedies, and the refusal reaching through a whole permission set at objects.crm_opportunity.readScope.

Cost direction, because a pin set proving only the new refusal measures nothing about its price: viewAllRecords: false beside a readScope (the ordinary, honoured shape), a bare readScope, viewAllRecords: true alone, writeScope beside viewAllRecords: true, the wire surface staying tolerant, and the shape read-through surviving.

Ablation — two legs, one of them the cost direction

Each leg: mutate on disk, prove it landed by occurrence count AND git hash-object, run, restore, prove the restore by hash against the HEAD blob. Restores are git checkout HEAD -- path (never bare), the script carries trap … EXIT INT TERM with an absolute repo root, and an empty hash is treated as FAILURE. The fix was committed first, so the restore leg has a real target. HEAD blob 7c073285.

leg mutation on-disk proof result
A — refusal disable the read-axis refusal anchor lines 1→0, injected 0→1, hash 7c0732859ae136b3 RED: 4 failed / 99 passed. Restored hash 7c073285 (match), git diff HEAD empty → GREEN 103 passed
B — cost over-refuse: also reject writeScope beside viewAllRecords anchor 1→0, injected 0→1, hash → 247ae59e RED on exactly one test, writeScope beside viewAllRecords: true stays ACCEPTED. Restored hash 7c073285 (match) → GREEN 103 passed

Leg B is what makes the cost pins a measurement: widening the refusal one shape too far is caught, and caught by the pin that exists to catch it.

Tests

Spec readings were taken at dd71bf31bc; the revise commit 3740c1d9a0f moves only the changeset's category token and one documentation section, neither of which is spec source, and the gate families those two paths schedule were re-derived and re-run on the new head (below).

command exit result
pnpm --filter @objectstack/spec test (--project local) 0 472 files, 13445 tests passed
pnpm --filter @objectstack/spec test:repo (--project repo) 0 30 files, 520 tests passed
pnpm --filter @objectstack/spec typecheck 0 clean

Both projects are reported separately on purpose: pnpm --filter @objectstack/spec test is --project local ONLY, and CI runs both.

Cross-package .shape consumer, run on the revise headpackages/lint/src/validate-security-posture.test.ts, which reads shapeKeysOf(ObjectPermissionSchema) at :1220 and :1226 and is in neither spec vitest project: EXIT 0, 1 file / 122 tests passed. ⚠️ Its first run in a fresh worktree was PREREQUISITE NOT METFailed to resolve entry for package "@objectstack/spec", because it resolves spec through the package exports i.e. dist, which did not exist yet. That is NOT MEASURED and is not read as a result in either direction; the 122 above is from the run AFTER pnpm --filter '@objectstack/lint^...' build.

Gate families re-derived on the revise head (scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, change set now 5 paths — the documentation edit schedules a docs family the first round did not owe). Newly scheduled and green: check-doc-frontmatter, check-docs-section-name, check-section-landing-index, check-doc-route-spelling --advisory, docs-audit/check-affected-docs, docs-audit/check-drift-comment, @objectstack/lint check:doc-security-posture, @objectstack/lint check:doc-formula-expressions, check-reference-carrier-shape. Re-run and green: check-adr-0087-registration (EXIT 0, and --self-test 0 over 353 assertions), check-empty-changeset, check-changeset-no-major, check-changeset-fixed, check:doc-authoring, check:nul-bytes, check:llms-txt, check:docs. ⚠️ check:doc-security-posture and check:doc-formula-expressions first answered exit 3, which that gate's own text defines as NOTHING MEASURED (its packages/lint/dist was unbuilt) — not read as a result until pnpm --filter @objectstack/lint build made them measurable, after which both exit 0.

Gates run locally beyond the two test projects: the derived family list from scripts/pm/dispatch-gates.mjs for this diff, including check:authorable-surface, check:strictness-ledger, check:api-surface, check:dual-source-exports, check:export-origins, check:exported-any, check:entry-nameability, check:browser-reachable-entries, check:dts-closure, check:published-files, check:nul-bytes, check:doc-authoring, check:test-source-alias, check:type-check-coverage, check:adr-0087-registration, check:empty-changeset, check:changeset-no-major. The five dist-reading gates were re-run after a rebuild — their first run reported PREREQUISITE NOT MET, which is NOT MEASURED and was not read as a result in either direction. Repo-wide scans (pnpm lint and the rest) are CI's.

Documentation

content/docs/permissions/permission-sets.mdx — the "Access depth" section documented readScope / writeScope and even noted that org is approximately viewAllRecords / modifyAllRecords, without saying that declaring the two together is now refused. It now says so, and states the asymmetry explicitly: viewAllRecords bypasses READ only, so writeScope beside it stays accepted and honoured, while modifyAllRecords bypasses both. ⓘ No documentation example was falsified — a nesting-tolerant scan of content/docs for a readScope within 400 characters of a viewAllRecords: true returns 0 candidates with the control lit (7 viewAllRecords: true literals present, so the scan can see the token). This sentence is owed by judgment, not by any gate.

Changeset

One changeset, @objectstack/spec: minor, marked BREAKING in prose — matching the precedent this repo set for an accept-set narrowing. No skip-changeset: packages/spec publishes this surface, the parse behaviour of a shipped schema moves, and packages/spec additionally ships src/**/*.zod.ts as source with comments intact, so even the reasoning is published text. The changeset carries an ADR-0087 disposition marker, not-required (no-migration-prescription) — no authorable key is added, renamed or retired, and a COMBINATION narrowing has no mechanical conversion because the two remedies express opposite author intents and only the author can choose.

⚠️ Correction — an earlier revision of this body asserted two things about check:adr-0087-registration that are both FALSE, and it reported the gate green when it was RED. It said the gate "keys on major" and "reports no declared-breaking changeset". Measured: the gate keys on the **BREAKING** marker in the changeset body (scripts/check-adr-0087-registration.mjs, breakingDeclarationif (/\*\*BREAKING/i.test(parsed.body) …) signals.push('BREAKING')), so it does select this changeset; and it REFUSES an unknown not-required category rather than ignoring it. The vocabulary is closed: unpublished · already-registered · no-migration-prescription · runtime-interface-only · type-surface-only. The category first used here, no-key-moves, is not in it, and the gate exited 1 on dd71bf31bc.

Why the earlier reading was a dead probe, stated rather than glossed: this gate scans the committed base..head diff, not the working tree. When it was first run the changeset file was still untracked (?? in git status), so the scan saw zero changesets and printed "0 non-breaking changeset(s) seen" — an empty population reported as a clean one. That is a probe with no lit control, and it is exactly the failure class this PR is about: a green that measured nothing. It is corrected here rather than quietly dropped.

Fixed: only the category token moves, to no-migration-prescription; the reason prose is unchanged byte for byte, because it already argued precisely that case. node scripts/check-adr-0087-registration.mjs --base origin/main now exits 0 on the committed blob and names the disposition back; its --self-test exits 0 over 353 assertions.

Publish-surface reading — new exported symbols, new payload keys

Answered from the diff and from the REGENERATED surface artifacts, each leg with a control that could have come back the other way.

(a) New exported symbols: ZERO. The only added line in the whole diff containing the token export is prose inside a comment. The refinement helper is deliberately module-private — the security barrel re-exports this module with export *, so exporting it would have put a refinement helper on the published API surface for no caller.

  • Negative leg: regenerating gen:api-surface, gen:export-origins and gen:declaration-map from this diff's dist moves no artifactgit status over packages/spec/api-surface, packages/spec/export-origins and packages/spec/declaration-map is empty afterwards.
  • Lit control: appending ONE dummy export const to the very same file and regenerating moves api-surface/security.json AND export-origins/security.json, and the dummy symbol appears in the regenerated api-surface/security.json. ⇒ the generators do see a new export in this file; the zero above is a reading, not a dead probe. Reverted, source hash back to the HEAD blob, tree clean.

(b) New keys on a published payload: ZERO. No key is added, renamed or retired on any schema; what narrows is a key COMBINATION.

  • Negative leg: check:authorable-surface exits 0 and packages/spec/authorable-surface.base.json is untouched by the diff.
  • Lit control: adding ONE optional key to the same ObjectPermission shape turns that gate RED and names it — + security/ObjectPermission:os16870ControlKey and + security/EffectiveObjectPermission:os16870ControlKey. Reverted, hash match, tree clean.

zero new exports, zero new keys. The only published-surface movement is the accept set itself. Per the seat's correction, the final value of the clause declaration is the seat's to set before landing; this round leaves the line and both carriers exactly as dispatched.

Landing

⛔ Left as a draft. Not flipped ready, not enqueued, no auto-merge — the domain:spec seat lands it after an at-tier verdict. needs:contract-review is attached from the moment this opens.

Session attribution for this work, in prose so it survives a body PATCH: generated by Claude Code, session session_01MkQhmuuJAVDjmeWNixwDDH.

⚠️ Attribution, in prose because a body PATCH makes the platform append a second bare footer: authored by Claude Code in session session_01MkQhmuuJAVDjmeWNixwDDHhttps://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH


Generated by Claude Code

…t short-circuits it

An object permission could carry `readScope` beside `viewAllRecords: true`. The
read-scope resolver answers `org` on the super-user bit before it consults the
depth key — and `getDeclaredScope`, the ADR-0090 D10 delegated-path input,
carries the identical short-circuit ahead of the identical read — so the
declared narrowing was never read on any runtime path. It was nonetheless
accepted with zero diagnostics, materialised into
`sys_permission_set.object_permissions`, and counted by a capability census
reading the deployed shape as coverage.

`ObjectPermissionSchema` now refuses the three declarations those two
short-circuits make unreadable, located at the offending key:

  - `readScope` beside `viewAllRecords: true`
  - `readScope` beside `modifyAllRecords: true`
  - `writeScope` beside `modifyAllRecords: true`

`writeScope` beside `viewAllRecords: true` stays accepted — the write
short-circuit does not name `viewAllRecords`, so that depth IS honoured and
refusing it would delete a real grant. What `viewAllRecords: true` grants is
untouched; only which declarations are accepted moves.

The accept set is the only door that stops the declaration from being STORED:
`saveMetaItem` runs this parse before the runtime authoring gate, and `os build`
runs it before both the author-time rule registry and `buildAccessMatrix`. A
lint rule beside `SECURITY_PRIVATE_NO_READSCOPE` is registered `input: 'parsed'`
and would run strictly downstream of it — unreachable once the parse refuses,
and bypassable via `OS_ALLOW_UNLINTED_METADATA_WRITES=1` before that.

The refinement rides on the authoring wrapper only, so
`EffectiveObjectPermissionSchema` (the wire surface) stays tolerant of a pair
stored before this landed. `AccessMatrixEntry` keeps describing the pair too —
it is a derived snapshot shape with no production parse site — and its guard in
`explain.test.ts` is rewritten to state that reason instead of reading as
evidence that the platform accepts the declaration.

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

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 6 documentable anchor(s).

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

  • content/docs/deployment/tenancy-modes.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/access-matrix.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/access-recipes.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), readScope (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), writeScope (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/authorization.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/index.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), readScope (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), writeScope (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/permission-metadata.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/permission-sets.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), readScope (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), writeScope (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/permissions-matrix.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), readScope (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), writeScope (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/positions.mdx (via readScope (literal, a string literal in checkScopeAgainstSuperUserBits), writeScope (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/rls.mdx (via viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/permissions/sharing-rules.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/protocol/objectql/schema.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/protocol/objectql/security.mdx (via ObjectPermissionSchema (symbol, a top-level const), modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))

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

  • content/docs/releases/v12.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/releases/v15.mdx (via modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))
  • content/docs/releases/v17/17-0.mdx (via ObjectPermissionSchema (symbol, a top-level const), modifyAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits), viewAllRecords (literal, a string literal in checkScopeAgainstSuperUserBits))

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

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 a61ae59f93302c1256330be8a6282f7be7f4a120packageMentionDocs.

Which tree this was computed on

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

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

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

@os-bill os-bill self-assigned this Sep 12, 2026

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

C5 disposition — the widening tell is OVERTURNED for this PR, by direct reading

Posted by the domain:spec execution seat (session session_01MkQhmuuJAVDjmeWNixwDDH), 2026-09-12T19:05Z. check-clause2-carriers --pair 17889 exits 4 on C5, report-only:

card #16870 … declares Clause-②: no while its diff carries 1 widening tell(s) … packages/spec/src/security/permission.zod.ts:142 (T1).

The standing discipline for a report-only row is that it stands until a direct reading of the thing it claims overturns it, per PR, recorded. This is that reading.

What is actually at that line. It is inside the TypeScript parameter type annotation of a module-private function, not a z.object shape:

function checkScopeAgainstSuperUserBits(
  value: {
    viewAllRecords?: boolean;
    modifyAllRecords?: boolean;
    readScope?: string;
    writeScope?: string;
  },
  ctx: z.RefinementCtx,
): void {

The T1 detector reads added optional-looking key lines as a widening. Here they are a structural type annotation on a local helper's parameter, and the four names it annotates are pre-existing schema keys, measured on origin/main:

name occurrences as a schema key on origin/main permission.zod.ts
viewAllRecords 1
modifyAllRecords 1
readScope 1
writeScope 1
dark control os16870ControlKey 0

The dark control is what makes the four 1s readings rather than a regex that matches anything.

Corroboration from the other side, measured by the round with its own lit control: no key is added, renamed or retired on any schema — check:authorable-surface exits 0 and packages/spec/authorable-surface.base.json is untouched, while adding one optional key to this same shape turns that gate red and names it (+ security/ObjectPermission:os16870ControlKey). And the only added line in the entire diff containing the token export is prose inside a comment, verified by the seat.

Nothing widens. What narrows is a key combination. The tell fired on a shape that looks like a schema and is not one. ⛔ Recorded here rather than silenced: the row keeps standing for every other pair, and this disposition is scoped to PR #17889 only.

⭐ Separately: an at-tier contract review is being run on this PR as a seat election — it is a BREAKING refusal on a permission surface — ⛔ not because clause ② requires one. The declaration stands at no on the rule and the measurement.


Generated by Claude Code

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

os-contract-review

REVISE

One required gate is RED on the head commit dd71bf31bc, and the PR body asserts the opposite about that same gate. The remedy is one token in the changeset, measured green with a lit control. Everything else in this PR I was able to reach verified — the transcription, the placement, both ablation legs, and the falsified-premise disposition all hold up under re-measurement.


The blocker, first — check:adr-0087-registration exits 1

Run in my own worktree at dd71bf31bc:

::error file=.changeset/16870-scope-beside-superuser-bit-refused.md::
  declares a breaking change with no valid ADR-0087 disposition
  • unknown `not-required` category "no-key-moves".
    The vocabulary is closed on purpose: unpublished, already-registered,
    no-migration-prescription, runtime-interface-only, type-surface-only.
EXIT=1

⚠️ The PR body says: «check:adr-0087-registration reports no declared-breaking changeset (it keys on major); the disposition marker is carried anyway». Both halves are falsified. The gate keys on the **BREAKING** body marker, not on major (scripts/check-adr-0087-registration.mjs:574), so it does select this changeset; and the marker it carries is refused, not merely surplus. The gate reads the committed blob from the base..head diff, not the working tree — so a working-tree probe of this is a dead probe, and my first one was. I say so rather than dressing it up.

Lit control, by local throwaway commits in my own detached worktree (reset after, HEAD back at dd71bf31bc, changeset blob back to 208bb275):

committed marker category gate reason
no-key-moves (as committed) EXIT 1 unknown category
no-migration-prescription, same reason prose EXIT 0 — GREEN
type-surface-only packages/spec/src/security/permission.zod.ts#ObjectPermissionSchema EXIT 1 correctly refused: "diff moves 1 ADR-0087 shape surface(s)"
valid category + a 10-char reason EXIT 1 correctly refused: 40-character minimum

The control could have come back the other way in three directions, and did in two — so the green is a reading, not a probe that always passes. It is aimed at: does the committed marker satisfy the gate's closed vocabulary and its reason-length floor.

Exactly what to change: in .changeset/16870-scope-beside-superuser-bit-refused.md, replace the category token no-key-moves with no-migration-prescription. ⭐ Keep the prose — it already argues precisely that case ("has no mechanical conversion: the two remedies … express opposite author intents and only the author can choose"), which is what no-migration-prescription names. Nothing else in the marker moves. Then correct the PR body's claim about this gate, since the record is otherwise carrying a falsified measurement.


Q1 — Faithful transcription. No over-refusal, no under-refusal on the depth axis.

Read by ref on origin/main, not taken from the dispatch. The file is packages/plugins/plugin-security/src/permission-evaluator.ts (note: packages/plugins/, not packages/plugin-security/ as the body's prose shorthand has it):

  • getEffectiveScope:264 if (opClass === 'read' && (op.viewAllRecords || op.modifyAllRecords)) return 'org'; · :265 if (opClass === 'write' && op.modifyAllRecords) return 'org'; · :266 const s = opClass === 'read' ? op.readScope : op.writeScope;
  • getDeclaredScope (the ADR-0090 D10 delegated input) — the identical pair at :305 / :306, identical read at :307.

Both short-circuits sit ahead of the depth read in both functions, and both return out of the widest-wins loop entirely. So each of the three refused shapes is genuinely unreachable-as-declared on both the direct and the delegated path.

The accepted shape genuinely IS honoured. :265 and :306 name modifyAllRecords only. With viewAllRecords: true and opClass === 'write', neither line fires and control reaches :266 / :307, which reads op.writeScope. Refusing that pair would delete a live grant. The asymmetry is correct.

Completeness, measured rather than assumed — I enumerated the parsed shape rather than trusting the diff:

ObjectPermission shape keys: allowCreate, allowRead, allowEdit, allowDelete, allowExport,
  allowTransfer, allowRestore, allowPurge, viewAllRecords, modifyAllRecords, readScope, writeScope
scope-looking keys: readScope, writeScope
RowLevelSecurityPolicy keys: name, label, description, object, operation, using, check,
  positions, enabled, priority, tags

⇒ exactly two depth keys exist, both are judged, and RLS carries no depth key of its own — so there is no third axis left silently unrefused.

One nearest sibling the PR does not take, reported rather than carried as a defect. The same super-user bits also gate a wildcard-RLS bypass (superuserBypassSets at permission-evaluator.ts:353, and the read-bypass helper just below it at :363-364). An RLS policy authored beside viewAllRecords: true can therefore be unread too. I do not think it belongs in this PR: that bypass is posture-conditional (it honours resolveObjectPermission's private-object handling), not the unconditional two-line short-circuit this refusal transcribes, so the "unreachable as declared" claim would not be true of it without separate measurement. Naming it so the next reader does not mistake its absence for an oversight.

Q2 — Placement is sound, and it is load-bearing. Re-measured on the installed zod.

⚠️ Version first, because the round's claim is version-scoped: packages/spec resolves zod 4.4.3 (packages/spec/node_modules/zodnode_modules/.pnpm/zod@4.4.3/…), matching the round. My worktree root happens to resolve 4.6.2; I ran the probe under both and got byte-identical answers, so the conclusion is not knife-edge on the patch version.

All three of the round's claims reproduce:

claim measured control that could have come back the other way
.superRefine() on a ZodObject returns a ZodObject keeping .shape ctor ZodObject, instanceof z.ZodObject true, .shape = the keys the same call on the pipe returns ZodPreprocess with .shape undefined
the same call on the residue pipe drops .shape .shape undefined the pipe itself reports .shape truthy — so the probe can see a shape when one is there
.extend() in zod 4 carries checks extend(REFINED) rejects the pair extend(UNREFINED) accepts it

The mechanism behind claim 2 is visible in source, and it is why the placement is not a style choice: acceptRetiredDefaultResidue attaches shape with Object.defineProperty(pipe, 'shape', …) on that one instance (packages/spec/src/shared/retired-key.ts, the read-through getter at the end of the helper). A .superRefine() clone of the pipe is a different instance and does not carry the own-property. And .superRefine() does not mutate in place (measured: base === refined is false, and the base still accepts the pair), which is what keeps EffectiveObjectPermissionSchema — which extends the unrefined ObjectPermissionBaseSchema at packages/spec/src/security/permission.zod.ts:447-460 — tolerant. Given that .extend() carries checks, refining the base in place would have leaked the refusal straight onto the wire clone. That reasoning is correct.

The decisive reading, which the round did not take: I ablated the placement itself. Moved the refinement from inside the residue stage onto the pipe (acceptRetiredDefaultResidue(Base, RESIDUE).superRefine(check)), proved it landed (anchor 1→0, injected 0→1, hash 7c073285988671d1):

FAIL  src/security/permission.test.ts [ collection failed ]
TypeError: Cannot read properties of undefined (reading 'modifyAllRecords')
 ❯ src/security/permission.test.ts:1060:52
     const description = ObjectPermissionSchema.shape.modifyAllRecords.de…

Restored, hash back to 7c073285, GREEN. So the placement is not merely defensible — the wrong placement takes the whole suite down at collection, and it is caught by a pre-existing #6698 pin, not only by the new one this PR adds. That is stronger evidence for the placement than the PR body claims for itself.

I also ran check:authorable-surface, which executes with OS_EAGER_SCHEMAS=1 and therefore exercises the eager (non-Proxy) construction path the lazy tests never touch: EXIT 0, 1534 schemas generated, and no tracked-file drift afterwards.

Q3 — The cost-direction pins are real. Reproduced leg B independently.

Mutated on disk, proved it landed by occurrence count and git hash-object, ran, restored, proved the restore by hash — with a trap … EXIT INT TERM restore and a git checkout HEAD -- packages/spec/src/security/permission.zod.ts (never bare):

reading
HEAD blob 7c073285347c72cc65d48cfca191a0177ca02de6
mutation over-refuse: writeScope also rejected beside viewAllRecords
landed anchor 1→0, injected 0→1, on-disk hash → 247ae59e71abd87b64e96aa688c478881b5eff85
result RED on exactly one testwriteScope beside viewAllRecords: true stays ACCEPTED
restored hash 7c073285… (match), git status clean → GREEN, 103 passed

The mutated hash 247ae59e matches the round's reported leg B exactly. Widening the refusal one shape too far is caught, and caught by the pin that exists to catch it — the cost pins are a measurement, not decoration.

Q4 — The disposition is right, and the snapshot tolerance is not a hole.

The falsified premise is correctly reported. Re-measured, whole tree, excluding node_modules and dist: AccessMatrixEntrySchema / AccessMatrixSchema appear in exactly 8 files — two docs pages, three generated spec surface manifests, packages/spec/src/security/explain.zod.ts, packages/spec/src/security/explain.test.ts, and packages/spec/src/type-alias-convention.pin.test.ts. Zero production parse sites. Control: PermissionSetSchema appears in 120 files, so the grep is not dead. And the read-back is a bare JSON.parse at packages/cli/src/commands/compile.ts:692, confirmed by eye.

Adjudication: the snapshot tolerance is defensible, and refusing there would have been the worse choice. Three readings carry it:

  1. There is no path from the snapshot back into stored permissions. buildAccessMatrix is called at compile.ts:678 on result.dataalready-parsed metadata — so once the authoring accept set refuses the pair, a fresh build can no longer emit an entry carrying it. The tolerance only ever describes artifacts written by an older toolchain.
  2. The sole consumer of a committed access-matrix.json is the drift diff, and it does not parse through the schema at all. A refusal added there would be unreachable — the declared-but-unenforced shape this very card is about, one level up. The round's argument is correct on its own measurements, which I reproduced.
  3. Rewriting the reason and adding the sibling assertion is strictly better than deleting the test: the new test at packages/spec/src/security/explain.test.ts:413-428 asserts the boundary (authoring refuses, snapshot tolerates), so the tolerance stops being readable as evidence that the platform accepts the declaration, and it goes red if the door upstream ever reopens. That is the guard the original was actually missing.

Q5 — What is owed: the ADR-0087 marker (above). Three further observations.

  • Changeset grade — correct. '@objectstack/spec': minor with **BREAKING** in prose. The cited precedent checks out: .changeset/15110-retired-element-node-refusal.md is also '@objectstack/spec': minor carrying a **BREAKING** marker for an accept-set narrowing.
  • Refusal message — correct. Both remedies at the located path, verified from the real ZodError my ablation surfaced: it names the offending bit, explains the short-circuit, and closes "Delete writeScope if the org-wide write is intended, or set modifyAllRecords: false if the narrowing is", plus an explicit note that writeScope beside viewAllRecords: true is a different shape and stays accepted.
  • A wire surface the tolerance argument does not cover, and which the body does not mention. GetObjectPermissionsResponseSchema (packages/spec/src/api/protocol.zod.ts:2516-2519) embeds permissions: ObjectPermissionSchema — the authoring schema, now refined — on a response payload. The PR's wire-tolerance reasoning ("a server still running an older toolchain may emit a stored pair") applies verbatim to this response, and it is covered only for GetEffectivePermissionsResponseSchema. I do not call this a break: in-tree parse sites are one spec test (packages/spec/src/api/protocol.test.ts:216) and no production or client runtime parse — packages/client re-exports the type, and a refinement does not move z.input. It is also consistent with the pre-existing posture, since that response was already tied to the strict authoring shape. But it is an unstated residual, and the body's flat claim that "the WIRE surface stays tolerant" is broader than what was measured.
  • A shape-reading consumer outside the test projects the round ran. packages/lint/src/validate-security-posture.test.ts:1220 and :1226 read shapeKeysOf(ObjectPermissionSchema) — a cross-package consumer of exactly the .shape read-through Q2 turns on, in a package whose tests are not in @objectstack/spec's local or repo projects. I ran it: 122 passed. ⚠️ Its first run was PREREQUISITE NOT MET (the spec dist was absent — "Failed to resolve entry for package"), which is NOT MEASURED and I did not read it as a result in either direction; it went green only after pnpm --filter @objectstack/spec build.
  • Docs. No documentation example is falsified: a nesting-tolerant scan of content/docs for a readScope within 400 characters of a viewAllRecords: true returns 0 candidates, with the control lit (7 viewAllRecords: true literals present, so the scan can see the token). But content/docs/permissions/permission-sets.mdx:109 — the "Access depth" section — documents readScope / writeScope and even notes that org is "≈ viewAllRecords / modifyAllRecords", without stating that declaring the two together is now refused. The drift bot listed that page, and its own caveat about pages that state a rule by its inputs applies here. One sentence there is owed by judgment, not by any gate; I am listing it as a condition, not as part of the REVISE.

Q6 — Not the maintainer. This stays on the seat.

⚠️ I am deliberately not citing SKILL.md:391/397. Those lines sit inside the 代裁 block, which governs when the PM may self-adjudicate a decision-box card; #16870 was graded pm:queue with the direction ruled at triage and was never in the decision box. Citing them here would be a category error, and the same objection applies to :389-390 — same block. Answering on substance instead:

What the standing floor's security/permission-boundary class is aimed at, read in its own place: the nearest in-place statement of the negative boundary is SKILL.md:514 — 运行时权限/安全行为变更 belongs to the manual floor's security/permission-boundary class. That is the test, and this diff fails it in the exempting direction: nothing runtime moves. The diff is four files — one changeset and three under packages/spec — and packages/plugins/plugin-security is untouched, so getEffectiveScope, getDeclaredScope, the VAMA bypass and every grant viewAllRecords: true confers are byte-identical before and after. What moves is which declarations are storable, and every shape it stops storing is one the resolver provably never reads (Q1).

The mechanical boundary test at SKILL.md:395 points the same way: widening the accept set or the public face ⇒ manual; pulling back to the declared contract ⇒ not. This is a pure narrowing — zero new exported symbols, zero new keys, both legs re-confirmed here (check:authorable-surface EXIT 0 with no tracked drift; the C5 widening tell already overturned by direct reading in comment 5648024150, whose no-key-moves reasoning I agree with as analysis even though that same token is what fails the ADR-0087 gate above).

The counterweights hold on re-measurement, and the escalation condition triage set — does any shipped or seeded permission set carry the pair today — is the one I can still see holding from my side: no documentation example carries it either (0, control 7). priority:p2 stands.

The one argument I weighed for escalating, and rejected. This refusal hard-codes the current resolver's short-circuit into the accept set: if a maintainer later rules that a depth declared beside a super-user bit should be honoured, this refusal has to be reverted first, and authors who wrote the pair to express "org-wide view, narrower read" lose the ability to say it. That is a real coupling. It does not reach the floor, for three reasons: ADR-0049 enforce-or-remove is a standing rule and triage already ruled which of its two routes this card takes, explicitly closing "honour the scope" as the maintainer's and out of remit; the failure direction is loud (a parse refusal naming both remedies at the located path) and one revert restores it; and the fleet impact is zero. ⇒ Not the maintainer. No 维护者速读 is owed, which is the correct outcome here rather than a missing one.


What I did NOT measure

  • pnpm --filter @objectstack/spec typecheck. Not run.
  • The rest of the derived gate family — check:api-surface, check:export-origins, check:dual-source-exports, check:declaration-map, check:exported-any, check:entry-nameability, check:browser-reachable-entries, check:dts-closure, check:published-files, check:doc-authoring, check:empty-changeset, check:changeset-no-major and the dist-reading set. I ran only check:authorable-surface and check:adr-0087-registration. Repo-wide pnpm lint is CI's and I did not run it.
  • 9 tests are NOT MEASURED in my environment, in neither direction. scripts/openapi-self-consistency.test.ts (8, local project) and scripts/def-key-collisions.test.ts (1, repo project) fail for me because they spawn subprocesses that resolve tsx through a path my worktree's location does not provide (Cannot find module '/tmp/node_modules/.pnpm/tsx@4.23.12/…'). Control, and it is the one that settles it: both fail identically with permission.zod.ts reverted to the merge-base blob c61f48c4bd — so they are a location artifact of running under /tmp, not a reading about this PR. Net of them: local 13437 passed / 13445 total across 472 files, repo 519 / 520 across 30 files — the same totals the round reports, which is itself corroboration that we ran the same file set. The runner is demonstrably live, since it turned red on precisely the right tests in both of my ablations.
  • The round's fleet-scan census numbers (130 / 53 / 18 nodes, the 133 brace-local literals, the six nesting-tolerant candidates read by hand). I re-derived only the content/docs subset independently and took the rest on the round's controls.
  • Real end-to-end behaviour of a deployed older server against the narrowed GetObjectPermissions response — reasoned from parse sites, not exercised.
  • The sibling repositories. I did not look at objectui or cloud for consumers of this shape.
  • Nothing in packages/plugins/plugin-security was re-run; I read it by ref only, which is sufficient for Q1 since the PR does not touch it.

Tier

This is an in-seat at-tier review. The adjudicating subagent inherits the dispatching seat's session id, so it is ⛔ NOT an independent second seat. It was dispatched with an explicit model parameter. And this review is a seat election rather than a clause-② gate — the Clause-②: no declaration was set by the seat after measurement and I found nothing that moves it.


Generated by Claude Code

…ent the refused pairing

Two follow-ups from at-tier contract review on #17889.

`check:adr-0087-registration` exits 1 on the previous commit: the disposition
marker used the category `no-key-moves`, which is not in the gate's closed
vocabulary (`scripts/check-adr-0087-registration.mjs`). The gate keys on the
`**BREAKING**` marker in the changeset body, not on a `major` grade, so it does
select this changeset, and it REFUSES an unknown category rather than ignoring
it. The reason prose already argued exactly the case `no-migration-prescription`
names — a key COMBINATION narrowing has no mechanical conversion, because the
two remedies express opposite author intents — so only the category token moves
and the prose is unchanged byte for byte.

The "Access depth" section of the permission-sets doc documented `readScope` /
`writeScope` and even noted that `org` is approximately `viewAllRecords` /
`modifyAllRecords`, without saying that declaring the two together is now
refused. It now says so, and states the asymmetry the resolver actually has:
`viewAllRecords` bypasses READ only, so `writeScope` beside it stays accepted
and honoured, while `modifyAllRecords` bypasses both.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@os-bill
os-bill marked this pull request as ready for review September 12, 2026 19:55
@os-bill
os-bill added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 88a072e Sep 12, 2026
44 checks passed
@os-bill
os-bill deleted the claude/issue-16870-viewallrecords-readscope-refusal branch September 12, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants