fix(spec): refuse a depth axis declared beside the super-user bit that short-circuits it (#16870) - #17889
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
C5 disposition — the widening tell is OVERTURNED for this PR, by direct readingPosted by the
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 The T1 detector reads added
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 — ⇒ 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 Generated by Claude Code |
|
os-contract-review REVISEOne required gate is RED on the head commit The blocker, first —
|
| 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—:264if (opClass === 'read' && (op.viewAllRecords || op.modifyAllRecords)) return 'org';·:265if (opClass === 'write' && op.modifyAllRecords) return 'org';·:266const 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.
packages/spec resolves zod 4.4.3 (packages/spec/node_modules/zod → node_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 7c073285 → 988671d1):
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 test — writeScope 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:
- There is no path from the snapshot back into stored permissions.
buildAccessMatrixis called atcompile.ts:678onresult.data— already-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. - The sole consumer of a committed
access-matrix.jsonis 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. - 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-428asserts 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': minorwith**BREAKING**in prose. The cited precedent checks out:.changeset/15110-retired-element-node-refusal.mdis also'@objectstack/spec': minorcarrying 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
writeScopebesideviewAllRecords: trueis 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) embedspermissions: 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 forGetEffectivePermissionsResponseSchema. 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/clientre-exports the type, and a refinement does not movez.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:1220and:1226readshapeKeysOf(ObjectPermissionSchema)— a cross-package consumer of exactly the.shaperead-through Q2 turns on, in a package whose tests are not in@objectstack/spec'slocalorrepoprojects. I ran it: 122 passed.⚠️ Its first run wasPREREQUISITE NOT MET(the specdistwas 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 afterpnpm --filter @objectstack/spec build. - ⓘ Docs. No documentation example is falsified: a nesting-tolerant scan of
content/docsfor areadScopewithin 400 characters of aviewAllRecords: truereturns 0 candidates, with the control lit (7viewAllRecords: trueliterals present, so the scan can see the token). Butcontent/docs/permissions/permission-sets.mdx:109— the "Access depth" section — documentsreadScope/writeScopeand even notes thatorgis "≈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.
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-majorand the dist-reading set. I ran onlycheck:authorable-surfaceandcheck:adr-0087-registration. Repo-widepnpm lintis 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,localproject) andscripts/def-key-collisions.test.ts(1,repoproject) fail for me because they spawn subprocesses that resolvetsxthrough 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 withpermission.zod.tsreverted to the merge-base blobc61f48c4bd— so they are a location artifact of running under/tmp, not a reading about this PR. Net of them:local13437 passed / 13445 total across 472 files,repo519 / 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/docssubset independently and took the rest on the round's controls. - Real end-to-end behaviour of a deployed older server against the narrowed
GetObjectPermissionsresponse — reasoned from parse sites, not exercised. - The sibling repositories. I did not look at
objectuiorcloudfor consumers of this shape. - Nothing in
packages/plugins/plugin-securitywas 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
Fixes #16870
Clause-②: no — SET BY THE SEAT AFTER MEASUREMENT, and the earlier⚠️ The seat's original
yesis withdrawn with its reason. The rule isreferences/lanes/spec.md— "放宽接受集或扩大公开面的卡,不论多小,即条款②;收窄仍是语义面,⛔ 不触条款②" — andreferences/contract-review.mdline 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 dummyexport constin the same file DOES moveapi-surface/security.jsonandexport-origins/security.json; one extra optional key on the same shape DOES turncheck:authorable-surfacered naming it). The seat verified the export leg itself: the only added line in the whole diff containing the tokenexportis prose inside a comment.yesrested 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
readScopebesideviewAllRecords: true.PermissionEvaluator.getEffectiveScopeanswersorgon the super-user bit before it consults the depth key (permission-evaluator.ts:264ahead of the read at:266), andgetDeclaredScope— the ADR-0090 D10 delegated-path input — carries the identical short-circuit at:305ahead 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, on272c04b460; nothing inplugin-securityis 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: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 onecdfc9411), 0, with a lit control on every probe:jqwalk, every tracked.json(536 files)viewAllRecords, 53 of themtrue; 18 declarereadScopeexamples/app-showcase/access-matrix.jsonviewAllRecords: trueexamples/app-crm/access-matrix.jsonpackages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.jsonviewAllRecords: truepackages/plugins/plugin-security/src/objects/default-permission-sets.tsviewAllRecordstokens; the singlereadScopetoken is prose inside a commentexamples/app-showcase/src/security/permission-sets.tsviewAllRecords: truegrants and 5readScopegrants, disjoint.ts/.jsonviewAllRecords: trueliterals matched — the regex is not dead*.yml/*.yamlpermission, so the sweep can see yamlThe 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:p2stands. No STOP.The site, picked by measurement
Two candidates were open to this lane: a refinement on
ObjectPermissionSchema, an author-time rule besideSECURITY_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:374parses throughObjectStackDefinitionSchemaand exits at:376on failure. The author-time rule registry does not run until:415, andbuildAccessMatrix— the census substrate — not until:678. Both readresult.data, i.e. post-parse.validateSecurityPostureis registered inpackages/lint/src/authoring-rules.tswithinput: 'parsed'. There is no call site that feeds it pre-parse data.saveMetaItemresolvesPermissionSetSchemafrom thepermissionrow ofpackages/spec/src/kernel/metadata-type-schemas.tsand 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 ZodsafeParseand stopped".OS_ALLOW_UNLINTED_METADATA_WRITES=1degrades 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: truegrants. 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:
readScope+viewAllRecords: trueopClass === 'read' && (viewAllRecords || modifyAllRecords)readScope+modifyAllRecords: truewriteScope+modifyAllRecords: trueopClass === 'write' && modifyAllRecordswriteScope+viewAllRecords: trueviewAllRecordsThe 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:
resolveObjectPermissionreturns a single entry (explicit, else the'*'wildcard) and never merges two. A super-user bit in one permission set widening past another set'sreadScopeis 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
ObjectPermissionBaseSchemainside theacceptRetiredDefaultResiduestage. Both halves were measured on zod 4.4.3, not assumed:.superRefine()on aZodObjectreturns aZodObjectthat keeps.shape; the same call on the residue pipe returns a schema with no.shape— and that read-throughshapeis what shape-reading consumers and the schema walkers duck-test.EffectiveObjectPermissionSchemaextends the unrefined base, so the effective-permissions response surface stays tolerant (未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001's authorable/wire split). A server still running an older toolchain can return a stored pair there without crashing a client. Pinned.GetEffectivePermissionsResponseSchemaonly. One further response payload embeds the authoring schema directly:GetObjectPermissionsResponseSchemaatpackages/spec/src/api/protocol.zod.ts:2516-2519declarespermissions: 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/clientre-exports the TYPE, and a refinement does not movez.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
securitybarrel re-exports this module withexport *, and an exported refinement helper would land on the published API surface for no caller.check:api-surfaceis green with no artifact regeneration.A
.shapeconsumer outside both spec vitest projects, added to the placement evidence.packages/lint/src/validate-security-posture.test.ts:1220and:1226readshapeKeysOf(ObjectPermissionSchema)— a CROSS-PACKAGE consumer of exactly the read-through the placement preserves, in a package neither--project localnor--project repocovers. It resolves@objectstack/specthrough the packageexports, 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 deletedThe 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).AccessMatrixEntryis a derived SNAPSHOT shape, not an accept set, and it should keep tolerating the pair for two measured reasons:buildAccessMatrixconstructs entries from already-parsed metadata, andos buildreads a committedaccess-matrix.jsonback with a bareJSON.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.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:PermissionSetSchemaappears 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.tsgoes 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 atobjects.crm_opportunity.readScope.Cost direction, because a pin set proving only the new refusal measures nothing about its price:
viewAllRecords: falsebeside areadScope(the ordinary, honoured shape), a barereadScope,viewAllRecords: truealone,writeScopebesideviewAllRecords: true, the wire surface staying tolerant, and theshaperead-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 aregit checkout HEAD -- path(never bare), the script carriestrap … EXIT INT TERMwith 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 blob7c073285.7c073285→9ae136b37c073285(match),git diff HEADempty → GREEN 103 passedwriteScopebesideviewAllRecords247ae59ewriteScope beside viewAllRecords: true stays ACCEPTED. Restored hash7c073285(match) → GREEN 103 passedLeg 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 commit3740c1d9a0fmoves 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).pnpm --filter @objectstack/spec test(--project local)pnpm --filter @objectstack/spec test:repo(--project repo)pnpm --filter @objectstack/spec typecheckBoth projects are reported separately on purpose:
pnpm --filter @objectstack/spec testis--project localONLY, and CI runs both.Cross-package⚠️ Its first run in a fresh worktree was
.shapeconsumer, run on the revise head —packages/lint/src/validate-security-posture.test.ts, which readsshapeKeysOf(ObjectPermissionSchema)at:1220and:1226and is in neither spec vitest project: EXIT 0, 1 file / 122 tests passed.PREREQUISITE NOT MET—Failed to resolve entry for package "@objectstack/spec", because it resolves spec through the packageexportsi.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 AFTERpnpm --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-test0 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-postureandcheck:doc-formula-expressionsfirst answered exit 3, which that gate's own text defines as NOTHING MEASURED (itspackages/lint/distwas unbuilt) — not read as a result untilpnpm --filter @objectstack/lint buildmade them measurable, after which both exit 0.Gates run locally beyond the two test projects: the derived family list from
scripts/pm/dispatch-gates.mjsfor this diff, includingcheck: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 lintand the rest) are CI's.Documentation
content/docs/permissions/permission-sets.mdx— the "Access depth" section documentedreadScope/writeScopeand even noted thatorgis approximatelyviewAllRecords/modifyAllRecords, without saying that declaring the two together is now refused. It now says so, and states the asymmetry explicitly:viewAllRecordsbypasses READ only, sowriteScopebeside it stays accepted and honoured, whilemodifyAllRecordsbypasses both. ⓘ No documentation example was falsified — a nesting-tolerant scan ofcontent/docsfor areadScopewithin 400 characters of aviewAllRecords: truereturns 0 candidates with the control lit (7viewAllRecords: trueliterals 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. Noskip-changeset:packages/specpublishes this surface, the parse behaviour of a shipped schema moves, andpackages/specadditionally shipssrc/**/*.zod.tsas 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.check:adr-0087-registrationthat are both FALSE, and it reported the gate green when it was RED. It said the gate "keys onmajor" and "reports no declared-breaking changeset". Measured: the gate keys on the**BREAKING**marker in the changeset body (scripts/check-adr-0087-registration.mjs,breakingDeclaration—if (/\*\*BREAKING/i.test(parsed.body) …) signals.push('BREAKING')), so it does select this changeset; and it REFUSES an unknownnot-requiredcategory 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 ondd71bf31bc.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 (
??ingit 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/mainnow exits 0 on the committed blob and names the disposition back; its--self-testexits 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
exportis prose inside a comment. The refinement helper is deliberately module-private — thesecuritybarrel re-exports this module withexport *, so exporting it would have put a refinement helper on the published API surface for no caller.gen:api-surface,gen:export-originsandgen:declaration-mapfrom this diff's dist moves no artifact —git statusoverpackages/spec/api-surface,packages/spec/export-originsandpackages/spec/declaration-mapis empty afterwards.export constto the very same file and regenerating movesapi-surface/security.jsonANDexport-origins/security.json, and the dummy symbol appears in the regeneratedapi-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.
check:authorable-surfaceexits 0 andpackages/spec/authorable-surface.base.jsonis untouched by the diff.ObjectPermissionshape turns that gate RED and names it —+ security/ObjectPermission:os16870ControlKeyand+ 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:specseat lands it after an at-tier verdict.needs:contract-reviewis 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.PATCHmakes the platform append a second bare footer: authored by Claude Code in sessionsession_01MkQhmuuJAVDjmeWNixwDDH— https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDHGenerated by Claude Code