From de394e98e0fb47ad2a71df3a95c686d6e4b392e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:57:30 +0000 Subject: [PATCH 1/2] fix(metadata-core): derive the ADR-0106 D4 read exemption from the #6603 write gate (#7020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu --- ...-read-exemption-derived-from-write-gate.md | 13 +++ ...etadata-plane-fls-object-schema-masking.md | 29 +++++++ .../src/object-schema-fls-contract.ts | 7 ++ .../src/object-schema-fls.test.ts | 69 +++++++++++++++- .../metadata-core/src/object-schema-fls.ts | 79 ++++++++++++++++--- packages/rest/src/package-routes.ts | 9 ++- packages/runtime/src/domains/packages.ts | 23 ++++-- ...adata-core__src__object-schema-fls.ts.json | 2 +- 8 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 .changeset/d4-read-exemption-derived-from-write-gate.md diff --git a/.changeset/d4-read-exemption-derived-from-write-gate.md b/.changeset/d4-read-exemption-derived-from-write-gate.md new file mode 100644 index 0000000000..3ed9a237eb --- /dev/null +++ b/.changeset/d4-read-exemption-derived-from-write-gate.md @@ -0,0 +1,13 @@ +--- +"@objectstack/metadata-core": minor +"@objectstack/rest": patch +"@objectstack/runtime": patch +--- + +Metadata-plane FLS: the ADR-0106 D4 read exemption is now **derived** from the #6603 write-capability gate, so "whoever can write a schema can see all of it" is enforced by construction (#7020). + +The two sets used to be maintained separately and were in fact different: the write gate demands `manage_metadata`, while the D4 exemption listed `studio.access` / `setup.access`. They met only on the shipped `admin_full_access` set, which carries all three — so the invariant #6603's ruling stated held by coincidence, not by construction. A caller holding `manage_metadata` alone passed every metadata write gate and still read a **masked** object schema, and its GET, edit and PUT round trip then 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 write gate's key, spelled once) and `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` (`studio.access` / `setup.access`) — both newly exported from `@objectstack/metadata-core`. + +**Behaviour change:** a caller holding `manage_metadata` now reads object schemas unmasked on every schema-serving exit. This widens read access for that cohort and is the ruled intent (maintainer, 2026-08-10). The derivation is one-directional: no principal loses read access, and the `/packages` read cohort (#7033 / #7023) keeps its own separately-ruled set. diff --git a/docs/adr/0106-metadata-plane-fls-object-schema-masking.md b/docs/adr/0106-metadata-plane-fls-object-schema-masking.md index b8375ca722..5cc0d4fc0f 100644 --- a/docs/adr/0106-metadata-plane-fls-object-schema-masking.md +++ b/docs/adr/0106-metadata-plane-fls-object-schema-masking.md @@ -218,6 +218,35 @@ metadata read, so it is a stepping stone, not the end state. ### D4 — Exemptions: `isSystem` and platform admins +> **Amendment (2026-08-10, #7020 — maintainer ruling, issue comment +> `5236144046`).** The exemption set is no longer a hand-kept list. #6603's +> ruling justified its write gate with an invariant — *"whoever can write a +> schema is whoever can see the full schema"* — and #7020 measured that the two +> sets were in fact different: the write gate demands `manage_metadata`, this +> exemption listed `studio.access` / `setup.access`, and they met only on +> `admin_full_access` carrying all three. A `manage_metadata`-only caller +> therefore passed every write gate and still read a **projected** schema, so +> its GET, edit and PUT round trip deleted the fields it could not see — the +> exact hazard #6603 exists to close, left open for precisely the callers the +> gate lets through. +> +> The write gate is now the **authoritative** set, and this exemption is +> **derived** from it: `OBJECT_SCHEMA_WRITE_CAPABILITIES` (the #6603 key) +> UNION `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` (the two below). The +> invariant holds by construction rather than by two lists staying +> coincidentally equal. +> +> The derivation is **one-directional**: can-write implies can-see-all; it does +> not imply the converse. The measurement found real principals that are exempt +> and hold no write capability — `organization_admin` / +> `organization_admin_no_bypass` (granted `setup.access` while `manage_metadata` +> is withheld in as many words) and the showcase `showcase_ops` persona. Whether +> those stay exempt is a follow-up ruling #7020 leaves open; until it lands, +> nobody's current read access is narrowed. +> +> Unchanged by this amendment: the `/packages` read cohort (#7033 / #7023), +> which names the read-only half specifically and was ruled on its own terms. + `getReadableFields` already bypasses for `isSystem`. Platform-admin callers (the same `systemPermissions`-based judgment the `app` filter uses) are likewise exempt: Studio/Setup authoring requires the full schema, and diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts index bd4b7b4e26..809598f1ef 100644 --- a/packages/metadata-core/src/object-schema-fls-contract.ts +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -148,6 +148,13 @@ export const OBJECT_SCHEMA_MASK_CASES: readonly ObjectSchemaMaskCase[] = [ readable: ['id'], expect: { kind: 'unmasked' }, }, + { + id: 'write-capable-caller/exempt', + why: '[#7020] D4 is DERIVED from the #6603 write gate — whoever may write a schema sees all of it, by construction. A `manage_metadata`-only caller passes every write gate, so a projected GET here is the round trip that PUTs the invisible fields away. Holds NEITHER builder capability on purpose: that is the shape the two hand-kept sets used to separate.', + context: { userId: 'u_author', systemPermissions: ['manage_metadata'] }, + readable: ['id'], + expect: { kind: 'unmasked' }, + }, { id: 'guest-fallback/D7', why: 'D7 — a caller resolving to zero permission sets goes through the fallback set rather than the everything-default; the exit sees whatever that resolution answers and projects it like any other. (The resolution itself is pinned in plugin-security; a truly ANONYMOUS caller never reaches an exit on a requireAuth deployment, which D7 says in as many words.)', diff --git a/packages/metadata-core/src/object-schema-fls.test.ts b/packages/metadata-core/src/object-schema-fls.test.ts index 5f0a1a7c6c..4401ab8446 100644 --- a/packages/metadata-core/src/object-schema-fls.test.ts +++ b/packages/metadata-core/src/object-schema-fls.test.ts @@ -16,7 +16,10 @@ import { objectFieldVisibilityFingerprint, resolveObjectSchemaMaskPosture, OBJECT_SCHEMA_MASK_DISABLE_ENV, + OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES, OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, + OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, + OBJECT_SCHEMA_WRITE_CAPABILITIES, type ObjectSchemaMaskPosture, } from './object-schema-fls.js'; @@ -105,7 +108,7 @@ describe('[ADR-0106 D3] visibility fingerprint', () => { }); describe('[ADR-0106 D4] exemptions are caller properties', () => { - it('exempts `isSystem` and the two builder capabilities, nothing else', () => { + it('exempts `isSystem` and the builder capabilities, nothing else', () => { expect(isObjectSchemaMaskExempt({ isSystem: true })).toBe(true); expect(isObjectSchemaMaskExempt({ systemPermissions: ['studio.access'] })).toBe(true); expect(isObjectSchemaMaskExempt({ systemPermissions: ['setup.access'] })).toBe(true); @@ -115,6 +118,70 @@ describe('[ADR-0106 D4] exemptions are caller properties', () => { }); }); +/** + * [#7020] The maintainer's 2026-08-10 ruling: the #6603 write gate is the + * authoritative set and the D4 read exemption is a DERIVATION of it, so + * "whoever can write a schema can see all of it" holds by construction. + * + * Before this change the two sets were disjoint apart from `admin_full_access` + * carrying all three capabilities, so a `manage_metadata`-only caller — a shape + * the write gate admits and pins as a 200 — read a PROJECTED schema and its + * GET → edit → PUT round trip deleted the fields it could not see. + */ +describe('[#7020] the D4 read exemption is derived from the write gate', () => { + it('exempts a caller holding `manage_metadata` and NEITHER builder capability', () => { + // The broken case: passes every #6603 write gate, was masked on read. + expect(isObjectSchemaMaskExempt({ userId: 'u_author', systemPermissions: ['manage_metadata'] })).toBe(true); + }); + + it('leaves the existing exempt principals exactly as they were', () => { + // `admin_full_access`'s shipped shape, and the two named read-only + // exemptions that hold no write capability (`organization_admin` / + // `showcase_ops`) — none of them lose access to anything. + expect(isObjectSchemaMaskExempt({ + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + })).toBe(true); + expect(isObjectSchemaMaskExempt({ + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + })).toBe(true); + expect(isObjectSchemaMaskExempt({ systemPermissions: ['setup.access', 'showcase.export_data'] })).toBe(true); + }); + + it('still masks a caller holding neither half', () => { + expect(isObjectSchemaMaskExempt({ userId: 'u_portal', systemPermissions: [] })).toBe(false); + expect(isObjectSchemaMaskExempt({ userId: 'u_member', systemPermissions: ['manage_org_users'] })).toBe(false); + // Adjacent-but-different capability names do not leak in. + expect(isObjectSchemaMaskExempt({ systemPermissions: ['manage_metadata_drafts'] })).toBe(false); + }); + + it('resolves the posture to `exempt` WITHOUT consulting the security service', async () => { + // D4 is decided before the service call, so the write cohort's exemption + // costs nothing and a sick service cannot turn it into a fault. + let asked = 0; + const posture = await resolveObjectSchemaMaskPosture({ + objectName: 'account', + context: { userId: 'u_author', systemPermissions: ['manage_metadata'] }, + security: { getMetadataReadableFields: () => { asked++; throw new Error('must not be consulted'); } }, + enabled: true, + }); + expect(posture).toEqual({ kind: 'passthrough', reason: 'exempt' }); + expect(asked).toBe(0); + }); + + it('builds the exempt set BY CONSTRUCTION — not a third hand-kept list', () => { + // The point of the ruling: the union cannot drift from the write gate, + // because it is not written down twice. + expect(OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES) + .toEqual([...OBJECT_SCHEMA_WRITE_CAPABILITIES, ...OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES]); + // The write half is the #6603 gate's key, spelled once. + expect(OBJECT_SCHEMA_WRITE_CAPABILITIES).toEqual(['manage_metadata']); + // The read-only half is preserved verbatim pending #7020's follow-up + // ruling on `organization_admin` / `showcase_ops` — nobody's current + // read access is narrowed by this change. + expect(OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES).toEqual(['studio.access', 'setup.access']); + }); +}); + describe('[ADR-0106 D8] escape hatch', () => { it('defaults ON', () => { expect(isObjectSchemaMaskingEnabled(undefined, {})).toBe(true); diff --git a/packages/metadata-core/src/object-schema-fls.ts b/packages/metadata-core/src/object-schema-fls.ts index 9efa18ff60..d840fe79b3 100644 --- a/packages/metadata-core/src/object-schema-fls.ts +++ b/packages/metadata-core/src/object-schema-fls.ts @@ -59,17 +59,70 @@ */ /** - * The `systemPermissions` capabilities that exempt a caller from the mask - * (ADR-0106 D4). + * [#6603 / ADR-0066 D1] The capabilities that let a caller **write** an object + * schema — the authoritative set, from which the D4 read exemption below is + * derived. + * + * The gate itself is spelled at eight sites (`packages/rest/src/rest-server.ts` + * ×4, `packages/runtime/src/domains/meta.ts` ×2, and the two `/packages` write + * transports); this constant is the same key named ONCE so the read side can + * reference it instead of re-spelling it. Changing the write gate's key without + * changing this constant is the drift #7020 measured — see below. + */ +export const OBJECT_SCHEMA_WRITE_CAPABILITIES: readonly string[] = ['manage_metadata']; + +/** + * [ADR-0106 D4] Capabilities that exempt a caller from the mask **without** + * granting them schema writes — the named read-only exemptions. * * These are exactly the two the `app` filter treats as "builder" — Studio and * Setup authoring cannot work against a projected schema, and draft/preview - * reads are admin-gated upstream already. The exemption is a **caller** - * property, not a route property: an exempt caller hitting the public route - * gets the full schema, and a non-exempt caller gets the projection on every - * route. + * reads are admin-gated upstream already. + * + * Kept as an explicit list rather than derived, because the #7020 measurement + * found real principals here that hold no write capability and are meant not to: + * `organization_admin` / `organization_admin_no_bypass` (`setup.access`, with + * `manage_metadata` withheld in as many words at + * `plugin-security/src/objects/default-permission-sets.ts:139-142`) and the + * showcase `showcase_ops` operations persona. Whether those stay exempt is the + * follow-up ruling #7020 leaves open; until it lands, nobody's current read + * access is narrowed. + * + * This is ALSO the `/packages` read cohort (#7033 / #7023) — `package-routes.ts` + * and `domains/packages.ts` import it by this name. That gate was ruled + * separately and is deliberately NOT the union below. + */ +export const OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES: readonly string[] = ['studio.access', 'setup.access']; + +/** + * The `systemPermissions` capabilities that exempt a caller from the mask + * (ADR-0106 D4) — **derived**, never hand-kept. + * + * #7020 measured the two sets this platform actually had: the #6603 write gate + * (`manage_metadata`) and this exemption list (`studio.access` / `setup.access`) + * were disjoint apart from `admin_full_access` carrying all three, so + * #6603's stated rationale — *"whoever can write a schema is whoever can see + * the full schema"* — held only by that coincidence. A `manage_metadata`-only + * caller passed every write gate and still read a PROJECTED schema, which is + * precisely the GET → edit → PUT round trip that deletes the fields the caller + * could not see. + * + * The maintainer's 2026-08-10 ruling makes the write gate authoritative and this + * list a derivation of it, so the invariant holds **by construction**: the union + * cannot drift from the write gate, because it is not written down twice. + * + * The derivation is one-directional on purpose — can-write implies can-see-all; + * it does not imply can-see-all requires can-write. The read-only exemptions + * above are preserved verbatim pending their follow-up ruling. + * + * The exemption is a **caller** property, not a route property: an exempt caller + * hitting the public route gets the full schema, and a non-exempt caller gets + * the projection on every route. */ -export const OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES: readonly string[] = ['studio.access', 'setup.access']; +export const OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES: readonly string[] = [ + ...OBJECT_SCHEMA_WRITE_CAPABILITIES, + ...OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, +]; /** * Environment escape hatch for ADR-0106 D8 — a deployment that explicitly wants @@ -158,8 +211,16 @@ export function isObjectSchemaMaskingEnabled( /** * Is this caller exempt from the mask (ADR-0106 D4)? * - * `isSystem` (which `getReadableFields` already bypasses) plus a platform-admin - * caller, judged by the same `systemPermissions` reading the `app` filter uses. + * `isSystem` (which `getReadableFields` already bypasses) plus any caller in + * {@link OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES} — i.e. anyone the #6603 write + * gate admits ({@link OBJECT_SCHEMA_WRITE_CAPABILITIES}) OR one of the named + * read-only exemptions ({@link OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES}), + * judged by the same `systemPermissions` reading the `app` filter uses. + * + * The write half is what makes "whoever can write a schema can see all of it" + * true by construction rather than by two lists staying coincidentally equal + * (#7020's ruling); it is also what keeps a masked read from being PUT back + * verbatim and silently deleting the invisible fields. */ export function isObjectSchemaMaskExempt(context: unknown): boolean { if (!context || typeof context !== 'object') return false; diff --git a/packages/rest/src/package-routes.ts b/packages/rest/src/package-routes.ts index 324ed66006..452031fe97 100644 --- a/packages/rest/src/package-routes.ts +++ b/packages/rest/src/package-routes.ts @@ -1,7 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { IHttpServer, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE } from '@objectstack/core'; -import { OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; +// [#7020] The read cohort names the READ-ONLY half of the ADR-0106 D4 exemption +// on purpose: `OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES` became the derived union +// (write gate ∪ read-only exemptions) under the 2026-08-10 ruling, while this +// gate's cohort was ruled separately (#7033 / #7023) and pins write-only callers +// OUT. Same value it read before — no re-ruling by side effect. +import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; import type { PackageService } from '@objectstack/service-package'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; @@ -60,7 +65,7 @@ async function refusePackageRequest( const held = new Set(Array.isArray(ctx?.systemPermissions) ? ctx.systemPermissions : []); const allowed = ctx?.isSystem || (kind === 'write' ? held.has('manage_metadata') - : OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES.some((c) => held.has(c))); + : OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES.some((c) => held.has(c))); if (!allowed) { // Same wrapped envelope, one FORBIDDEN code, message per cohort — the sibling // `/meta` REST capability gate's shape, built through the shared `sendError`. diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index b0e8482ee3..624b191c8d 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -17,7 +17,14 @@ import { // [#7033 / #7023] The read gate reuses the SAME "builder" capability set the // object-schema mask exempts (ADR-0106 D4) — REFERENCED, never re-spelled, so // the package-read cohort cannot drift from the metadata mask's exemption. -import { OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; +// [#7020] That constant became the DERIVED union (write gate ∪ read-only +// exemptions) when the maintainer ruled the two sets must not be hand-kept +// separately. This gate wants the read-only half specifically: its cohort was +// ruled on its own terms (#7033 / #7023) and pinned WRITE-only callers OUT +// (`packages/rest/src/package-envelope.conformance.test.ts`), so it names +// `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` — same value it read before, +// no re-ruling of the package cohort as a side effect of #7020. +import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; import { organizationIdForMetaWrite } from '../meta-write-org-scope.js'; import { setPackageDisabled } from '../package-state-store.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -74,10 +81,14 @@ function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolCon * `GET /packages/:id` detail, the `GET /packages/:id/commits` history and the * `GET /packages/:id/export` whole-package export (27 metadata types) — so each * requires one of the two "builder" capabilities the object-schema mask exempts - * ({@link OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES} = `studio.access` / - * `setup.access`), or `isSystem`. The read cohort is deliberately BROADER than - * the write cohort: an `organization_admin` holding `setup.access` (but not - * `manage_metadata`) may inspect a package yet not publish or delete it. + * read-only ({@link OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES} = + * `studio.access` / `setup.access`), or `isSystem`. The read cohort is not the + * write cohort: an `organization_admin` holding `setup.access` (but not + * `manage_metadata`) may inspect a package yet not publish or delete it, and a + * write-only caller holding `manage_metadata` alone is refused these reads — + * pinned in `packages/rest/src/package-envelope.conformance.test.ts`. (#7020 + * unified the object-schema MASK exemption with the write gate; it did not + * re-rule this cohort, which is why this site names the read-only half.) * * Returns a 403 result to short-circuit on, or `null` to proceed. Callers MUST * run this BEFORE reading, so the answer never leaks the package inventory to a @@ -86,7 +97,7 @@ function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolCon function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolContext): HttpDispatcherResult | null { const ec: any = context?.executionContext; const held = new Set(ec?.systemPermissions ?? []); - if (!ec?.isSystem && !OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES.some((c) => held.has(c))) { + if (!ec?.isSystem && !OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES.some((c) => held.has(c))) { return { handled: true, response: deps.error('Reading packages requires the `studio.access` or `setup.access` capability.', 403), diff --git a/scripts/adr-anchors/packages__metadata-core__src__object-schema-fls.ts.json b/scripts/adr-anchors/packages__metadata-core__src__object-schema-fls.ts.json index 6d893067b0..41e62956ee 100644 --- a/scripts/adr-anchors/packages__metadata-core__src__object-schema-fls.ts.json +++ b/scripts/adr-anchors/packages__metadata-core__src__object-schema-fls.ts.json @@ -3,5 +3,5 @@ "adrs": [ "ADR-0106" ], - "invariant": "ADR-0106 is the WHOLE decision this module realizes: object schemas are projected per caller (D1, unreadable fields removed whole), the projection runs in the dispatch layer after the protocol fetch (D2), the shared cache keeps one full copy and the caller's DENIED-set fingerprint is folded into the ETag (D3), isSystem and platform admins are exempt as a CALLER property (D4), and the failure posture is three tiers — no security service or exempt caller serve unmasked, an unresolvable field universe serves unmasked with telemetry and private/no-store, and a THROW refuses the request (D6). The tiers look asymmetric on purpose: failing closed on the middle tier converts a hydration window into a rendering outage and risks a bootstrap deadlock, because permission sets are themselves metadata. Anyone tempted to \"simplify\" a tier, to widen the exemption set, or to make the fingerprint hash the READABLE set (which would move every unrestricted caller's ETag and forfeit D3's zero-regression promise) is reversing a recorded decision." + "invariant": "ADR-0106 is the WHOLE decision this module realizes: object schemas are projected per caller (D1, unreadable fields removed whole), the projection runs in the dispatch layer after the protocol fetch (D2), the shared cache keeps one full copy and the caller's DENIED-set fingerprint is folded into the ETag (D3), isSystem and platform admins are exempt as a CALLER property (D4), and the failure posture is three tiers — no security service or exempt caller serve unmasked, an unresolvable field universe serves unmasked with telemetry and private/no-store, and a THROW refuses the request (D6). The tiers look asymmetric on purpose: failing closed on the middle tier converts a hydration window into a rendering outage and risks a bootstrap deadlock, because permission sets are themselves metadata. Anyone tempted to \"simplify\" a tier, to widen the exemption set, or to make the fingerprint hash the READABLE set (which would move every unrestricted caller's ETag and forfeit D3's zero-regression promise) is reversing a recorded decision. AMENDMENT (maintainer ruling 2026-08-10, #7020, issue comment 5236144046): D4's exemption set is no longer a hand-kept list — the #6603 WRITE gate (`manage_metadata`) is the authoritative set and the exemption is DERIVED as write-gate capabilities UNION the named read-only exemptions (`studio.access` / `setup.access`), so \"whoever can write a schema can see all of it\" holds by construction rather than by two lists staying coincidentally equal. Widening the exemption is still a reversal; re-spelling either half as a second literal, or narrowing the read-only half before #7020's follow-up ruling on `organization_admin` / `showcase_ops` lands, are the two NEW reversals this amendment adds." } From 68dee48897833ec8d05683b3146d7270c07b148a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:01:06 +0000 Subject: [PATCH 2/2] chore: drop the ADR-0106 doc amendment from this PR (#7020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu --- ...etadata-plane-fls-object-schema-masking.md | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/docs/adr/0106-metadata-plane-fls-object-schema-masking.md b/docs/adr/0106-metadata-plane-fls-object-schema-masking.md index 5cc0d4fc0f..b8375ca722 100644 --- a/docs/adr/0106-metadata-plane-fls-object-schema-masking.md +++ b/docs/adr/0106-metadata-plane-fls-object-schema-masking.md @@ -218,35 +218,6 @@ metadata read, so it is a stepping stone, not the end state. ### D4 — Exemptions: `isSystem` and platform admins -> **Amendment (2026-08-10, #7020 — maintainer ruling, issue comment -> `5236144046`).** The exemption set is no longer a hand-kept list. #6603's -> ruling justified its write gate with an invariant — *"whoever can write a -> schema is whoever can see the full schema"* — and #7020 measured that the two -> sets were in fact different: the write gate demands `manage_metadata`, this -> exemption listed `studio.access` / `setup.access`, and they met only on -> `admin_full_access` carrying all three. A `manage_metadata`-only caller -> therefore passed every write gate and still read a **projected** schema, so -> its GET, edit and PUT round trip deleted the fields it could not see — the -> exact hazard #6603 exists to close, left open for precisely the callers the -> gate lets through. -> -> The write gate is now the **authoritative** set, and this exemption is -> **derived** from it: `OBJECT_SCHEMA_WRITE_CAPABILITIES` (the #6603 key) -> UNION `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` (the two below). The -> invariant holds by construction rather than by two lists staying -> coincidentally equal. -> -> The derivation is **one-directional**: can-write implies can-see-all; it does -> not imply the converse. The measurement found real principals that are exempt -> and hold no write capability — `organization_admin` / -> `organization_admin_no_bypass` (granted `setup.access` while `manage_metadata` -> is withheld in as many words) and the showcase `showcase_ops` persona. Whether -> those stay exempt is a follow-up ruling #7020 leaves open; until it lands, -> nobody's current read access is narrowed. -> -> Unchanged by this amendment: the `/packages` read cohort (#7033 / #7023), -> which names the read-only half specifically and was ruled on its own terms. - `getReadableFields` already bypasses for `isSystem`. Platform-admin callers (the same `systemPermissions`-based judgment the `app` filter uses) are likewise exempt: Studio/Setup authoring requires the full schema, and