fix(metadata-core): derive the ADR-0106 D4 read exemption from the #6603 write gate (#7020) - #7361
Conversation
write gate (#7020) The maintainer's 2026-08-10 ruling on #7020: the write-capability gate is the authoritative set and the D4 read-exemption list becomes a derivation of it, so "whoever can write a schema can see all of it" is enforced by construction rather than by two hand-kept lists staying coincidentally equal. The measured diff (posted on #7020 before implementing) found the two sets met only on `admin_full_access`, which carries `manage_metadata` AND `studio.access` AND `setup.access`. A `manage_metadata`-only caller — a shape every write gate admits and pins as a 200 — read a PROJECTED schema, so its GET, edit and PUT round trip deleted the fields it was never shown. `OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES` is now the union of two named halves: `OBJECT_SCHEMA_WRITE_CAPABILITIES` (the #6603 key, spelled once) and `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` (`studio.access`/`setup.access`). The derivation is one-directional — nobody loses read access, and the three named read-only exemptions the measurement surfaced go back for the follow-up ruling the card stays open for. The two `/packages` call sites name the read-only half explicitly: that cohort was ruled separately (#7033 / #7023) and pins write-only callers out, so its value is unchanged and #7020 does not re-rule it by side effect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
`docs/adr/**` merges are reserved to the maintainer in person — 「adr 只能由维护者自己确认,人工合并,ai 不得擅自合并。」 (#6741) — and `check-adr-merge-approval` enforces it. The D4 amendment text is not required by the fix, and #7020 stays open for the follow-up ruling on the named read-only exemptions, which is the natural moment to settle D4's final wording. The proposed text is quoted in the PR body for the maintainer to apply. The governance record the next code author actually reads — `scripts/adr-anchors/packages__metadata-core__src__object-schema-fls.ts.json` — still carries the amendment, so the file cannot be edited without being told that the exemption is now derived rather than hand-kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
Part of #7020
Part of, notFixes, and the reason is the ruling's own precondition: phase 1's category (b) came back non-empty. Three principals are D4-exempt today while holding no write capability, and the maintainer ruled those named cases go back for a follow-up ruling instead of being silently unified over. That follow-up has not happened, so merging this must not close the card.The ruling
Maintainer, 2026-08-10, issue comment
5236144046— quoted verbatim:Phase 1 — the measured diff (posted before implementing)
Full table with file:line evidence: issue comment 5236962689. Summary, measured on merge base
06be54ec3across every shipped and example permission set:(a) satisfies the write gate but NOT D4-exempt — empty among seeded permission sets, non-empty in the principal space the platform admits and already tests.
meta-item-save-capability-gate.test.ts:261andmeta-compound-save-and-reset-capability-gate.test.ts:239both pin amanage_metadata-only caller as a 200 through the write gate, andruntime/src/sandbox/script-user-type-assertions.ts:72constructs exactly that principal. Such a caller passed every write gate and still read a projected schema — the GET, edit and PUT round trip that deletes the fields it could not see.(b) D4-exempt but NOT satisfying the write gate — non-empty (3):
organization_admin,organization_admin_no_bypass,showcase_ops. These are intentional read-only exemptions and the intent is stated in the source, not inferred:default-permission-sets.ts:139-142withholdsstudio.accessandmanage_metadatafrom the org admin in as many words while grantingsetup.access. They go back for the follow-up ruling; this PR preserves their behaviour exactly.Phase 2 — the derivation
packages/metadata-core/src/object-schema-fls.tsnow names the two halves and builds the exemption from them:The derivation is one-directional by construction: can-write implies can-see-all, and no principal loses read access. Narrowing the read-only half before the follow-up ruling would be the reversal the ruling forbids, so it is preserved verbatim and pinned as such.
One structural finding shaped the spelling.
OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIESwas not only the D4 mask exemption —rest/src/package-routes.tsandruntime/src/domains/packages.tsimport the same constant as the/packagesread cohort, which was ruled separately (#7033 / #7023) and deliberately pins write-only callers OUT (package-envelope.conformance.test.ts:518). Widening the constant in place would have re-ruled that gate as a side effect. Both call sites now nameOBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, whose value is identical to what they read before — behaviour preserved, no re-ruling by side effect.scripts/adr-anchors/packages__metadata-core__src__object-schema-fls.ts.jsonis amended too: its invariant told the next author that widening the exemption reverses a recorded decision, which is now only half true. It records the ruling, the derivation, and the two NEW reversals (re-spelling either half as a second literal; narrowing the read-only half before the follow-up ruling).The ADR text amendment is deliberately NOT in this PR
The first push carried an amendment to ADR-0106's D4 section.
check-adr-merge-approvalrefused it, correctly:Drafting is allowed and only the merge is reserved, but the amendment is not required by the fix, and #7020 stays open for the follow-up ruling that will settle D4's final wording anyway — so it was removed in the second commit rather than parking this PR behind a personal merge. The governance record the next code author actually reads (the anchor invariant above) carries the amendment. Suggested D4 wording for whoever lands the ADR change: the exemption set is derived as
OBJECT_SCHEMA_WRITE_CAPABILITIESUNIONOBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES; the derivation is one-directional;organization_admin/organization_admin_no_bypass/showcase_opsare the named read-only exemptions pending the follow-up ruling; the/packagesread cohort (#7033 / #7023) is unchanged.Tests
New coverage, all three cases the card asked for:
manage_metadataholder with neither builder capability reads the FULL schema. Added to the shared ADR-0106 contract table (object-schema-fls-contract.ts, casewrite-capable-caller/exempt), so it drives all thirteen schema-serving exits inrestandruntimerather than one unit.admin_full_accessshape, theorganization_adminshape and theshowcase_opsshape all still exempt.exemptwithout consulting the security service, and the union is asserted to BE the concatenation of its two halves — a third hand-kept list cannot be reintroduced quietly.Reverse verification (predicted direction: red — the derivation is the whole behaviour). Restoring the union to the read-only half alone:
Thirteen exits red by name, and cases (ii)/(iii) stayed green throughout — which is what shows the preserve-behaviour half is not carried by the same line as the fix. The fix was taken out with an edit and restored, then
git diffcompared byte-for-byte against the saved patch (identical). Green after restore:TEST_DEBT['@objectstack/rest']did not move: no test file was added torestorruntime— the new case lives in the shared contract table inmetadata-core, which type-checks its tests.Changeset
minorfor@objectstack/metadata-core(two new exported constants),patchfor the two consumers (call-site rename, no behaviour change of their own). The behaviour change is a deliberate widening of read access formanage_metadataholders and is the ruled intent, so it is stated in the changeset in those terms rather than buried as a refactor.Generated by Claude Code