From 03ec7b65810dad64cf89f7fb17632f8b7710e98b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:00:22 +0000 Subject: [PATCH 1/8] feat(metadata-core): the D4 exemption covers the definition, not the runtime projections An exempt caller's posture now carries a lazy `runtime` question (that caller's own field permission), and `resolveObjectSchemaRuntimeView` derives the view the runtime projections beside the schema read from. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../metadata-core/src/object-schema-fls.ts | 192 +++++++++++++++++- 1 file changed, 186 insertions(+), 6 deletions(-) diff --git a/packages/metadata-core/src/object-schema-fls.ts b/packages/metadata-core/src/object-schema-fls.ts index e06a1f06692..6b362bc03ce 100644 --- a/packages/metadata-core/src/object-schema-fls.ts +++ b/packages/metadata-core/src/object-schema-fls.ts @@ -51,6 +51,15 @@ * | `getReadableFields` throws | throws {@link ObjectSchemaMaskEvaluationError} | the exit answers 5xx — never the unmasked body, never an empty-fields 200 | * | otherwise | `project` | fields ∉ readable are deleted whole, references included | * + * ## D4 exempts the definition, not the runtime projections (#22250) + * + * An exempt caller is served the schema whole because authoring (Studio, + * Setup) needs the full definition. Their field permission still binds the + * data route, so the projections a runtime client builds affordances from — + * the `sortability` key served beside the schema — are derived from + * {@link resolveObjectSchemaRuntimeView}: the served document for every other + * caller, and for an exempt one the fields their own permission reads. + * * ## D3 — mask AFTER the cache, fingerprint the ETag * * The shared metadata cache keeps storing ONE full schema per @@ -148,17 +157,52 @@ export type ObjectSchemaMaskPassthroughReason = | 'disabled' /** ADR-0106 D6 tier 1 — no `security` service at all; the deployment has no FLS posture. */ | 'no-service' - /** ADR-0106 D4 — `isSystem` or a platform-admin caller. */ + /** + * ADR-0106 D4 — `isSystem` or a platform-admin caller: the DEFINITION is + * served whole, for authoring. [#22250] The runtime projections beside it + * are not exempt — see {@link resolveObjectSchemaRuntimeView}. + */ | 'exempt' /** Not an object schema (no `fields` map to project). */ | 'not-applicable'; +/** + * [#22250] What a D4-exempt caller's OWN field permission says about one + * object — the answer the RUNTIME projections served beside the schema read + * (the `sortability` envelope key), never the schema itself. + * + * - `project` — the caller's readable fields: a runtime projection offers + * none outside them, because the data route refuses each one to this caller + * (a filter or sort on it answers `403`); + * - `undetermined` — the security service could not answer (`undefined`) or + * threw. The runtime projections are derived from the served document as it + * is, which D4 already serves whole to this caller; said once, at `warn`. + */ +export type ObjectSchemaRuntimeFieldPosture = + | { kind: 'project'; readable: ReadonlySet } + | { kind: 'undetermined' }; + /** * The decision {@link resolveObjectSchemaMaskPosture} reaches for one caller × * one object, BEFORE the document is fetched. */ export type ObjectSchemaMaskPosture = - | { kind: 'passthrough'; reason: ObjectSchemaMaskPassthroughReason } + | { + kind: 'passthrough'; + reason: ObjectSchemaMaskPassthroughReason; + /** + * [#22250] Set on an `exempt` posture only, for a caller exempt by + * CAPABILITY ({@link OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES}; never + * `isSystem`, whom field-level security does not restrict at all) in a + * deployment with a `security` service: asks that caller's own field + * permission, for the runtime projections beside the schema. LAZY and + * asked at most once, so an exit that serves no such projection (the + * list read, the layered view) never pays for it, and it NEVER + * rejects: D4's "a sick security service cannot fault an exempt caller" + * holds. Read through {@link resolveObjectSchemaRuntimeView}. + */ + runtime?: () => Promise; + } /** ADR-0106 D6 tier 2 — `getReadableFields` could not answer. */ | { kind: 'undetermined' } | { @@ -317,14 +361,24 @@ export async function resolveObjectSchemaMaskPosture(input: { }): Promise { const { objectName, context, security, enabled, telemetry } = input; if (!enabled) return { kind: 'passthrough', reason: 'disabled' }; - // D4 — a caller property. Checked before the service call so an exempt - // caller costs nothing and cannot be turned into an error by a sick - // security service. - if (isObjectSchemaMaskExempt(context)) return { kind: 'passthrough', reason: 'exempt' }; const ask = typeof security?.getMetadataReadableFields === 'function' ? security.getMetadataReadableFields.bind(security) : (typeof security?.getReadableFields === 'function' ? security.getReadableFields.bind(security) : undefined); + + // D4 — a caller property. Checked before the service call so an exempt + // caller costs nothing and cannot be turned into an error by a sick + // security service. + if (isObjectSchemaMaskExempt(context)) { + // [#22250] The exemption is the DEFINITION's (Studio/Setup authoring + // needs the whole schema, and a projected one PUT back deletes what it + // withheld). It is not the runtime projections': they keep this + // caller's own field permission, asked lazily — see `runtime`. + const isSystem = (context as { isSystem?: unknown }).isSystem === true; + return ask && !isSystem + ? { kind: 'passthrough', reason: 'exempt', runtime: runtimeFieldPostureAsker(objectName, context, ask, telemetry) } + : { kind: 'passthrough', reason: 'exempt' }; + } // D6 tier 1 — no FLS posture in this deployment at all. The data plane does // not mask either, so tightening the metadata plane alone would be theater. if (!ask) return { kind: 'passthrough', reason: 'no-service' }; @@ -418,6 +472,132 @@ export async function relateObjectSchemaMaskPosture( return { ...posture, related }; } +/** + * [#22250] The lazy, memoised question an `exempt` posture's `runtime` asks: + * the same `ask` the mask asks a non-exempt caller (so one field permission + * yields one runtime answer whatever the caller's class), settled into + * {@link ObjectSchemaRuntimeFieldPosture}. It never rejects — an `undefined` + * answer and a throw both settle `undetermined`, warned once — because the + * exempt caller's DEFINITION read must not become a fault (D4). + */ +function runtimeFieldPostureAsker( + objectName: string, + context: unknown, + ask: (object: string, context?: unknown) => Promise | string[] | undefined, + telemetry: ObjectSchemaMaskTelemetry | undefined, +): () => Promise { + let settled: Promise | undefined; + const settle = async (): Promise => { + let readable: string[] | undefined; + try { + readable = await ask(objectName, context); + } catch (error) { + telemetry?.warn?.( + '[ADR-0106] field visibility for a D4-exempt caller could not be evaluated — the schema is ' + + 'served whole as D4 rules, and the runtime projections beside it are derived from it unprojected', + { object: objectName, decision: 'runtime-projections-unprojected', error: String(error) }, + ); + return { kind: 'undetermined' }; + } + if (!Array.isArray(readable)) { + telemetry?.warn?.( + '[ADR-0106] field visibility for a D4-exempt caller undetermined — the schema is served whole ' + + 'as D4 rules, and the runtime projections beside it are derived from it unprojected', + { object: objectName, decision: 'runtime-projections-unprojected' }, + ); + telemetry?.counter?.(OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, { object: objectName }); + return { kind: 'undetermined' }; + } + return { kind: 'project', readable: new Set(readable) }; + }; + return () => (settled ??= settle()); +} + +/** + * Qualifies a field withheld from the runtime view ONLY in + * {@link ObjectSchemaRuntimeView.fingerprint}. `@` occurs in no field or object + * machine name, so the entry can never hash equal to a field the DEFINITION + * mask withheld (a bare name) or an `objectOverride` read (`object.field`): + * an exempt caller and a projected caller denied the same field are served + * different bodies, and so must never share a validator. + */ +const RUNTIME_VIEW_FINGERPRINT_QUALIFIER = '@runtime:'; + +/** The view {@link resolveObjectSchemaRuntimeView} answers. */ +export interface ObjectSchemaRuntimeView { + /** + * The document the runtime projections derive from — the served one itself + * (same reference) unless this caller is D4-exempt and their own field + * permission withholds some of its fields, then a copy without those + * `fields` entries. ⛔ Never a served body: only `fields` is narrowed. + */ + document: T; + /** The fields withheld from the runtime view only, sorted. Empty for every caller the definition mask projects. */ + withheld: readonly string[]; + /** + * {@link objectFieldVisibilityFingerprint} over {@link withheld}, each entry + * qualified so it cannot collide with the definition mask's own; `''` when + * nothing is withheld. An exit that validates the served body folds it into + * the ETag beside the definition's ({@link foldVisibilityFingerprintIntoEtag}), + * because the runtime projections are part of that body. + */ + fingerprint: string; +} + +/** + * [#22250] The view of a served object schema that the RUNTIME projections + * beside it — today the `sortability` envelope key — are derived from. + * + * For every caller the definition mask projects, and for every posture that + * serves the schema unmasked for a DEPLOYMENT reason (D6 tiers 1–2, D8), that + * view is the served document itself: what the caller is served IS what their + * field permission admits, or the deployment has no answer to give. + * + * A D4-exempt caller is served the definition whole, for authoring — and that + * is all D4 exempts. Their field permission still binds the data route (a + * filter or sort on an unreadable field answers `403`), so a projection a + * runtime client builds affordances from must not offer what that route + * refuses them. For such a caller this asks the posture's `runtime` and keeps + * only the `fields` their permission reads, exactly the field set a non-exempt + * caller holding the same permission is served — one field permission, one + * runtime projection, whatever the caller's class. + * + * Asks the security service only for an exempt posture carrying `runtime` and a + * document that has a `fields` record; never rejects. + */ +export async function resolveObjectSchemaRuntimeView( + document: T, + posture: ObjectSchemaMaskPosture, +): Promise> { + const unchanged: ObjectSchemaRuntimeView = { document, withheld: [], fingerprint: '' }; + if (posture.kind !== 'passthrough' || !posture.runtime) return unchanged; + if (!document || typeof document !== 'object' || Array.isArray(document)) return unchanged; + const rec = document as unknown as Record; + const fields = rec.fields; + // The one `fields` shape `packages/spec` declares — the same reading as + // {@link applyObjectSchemaMask}, and nothing to ask about without it. + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return unchanged; + + const runtime = await posture.runtime(); + if (runtime.kind !== 'project') return unchanged; + + const kept: Record = {}; + const withheld: string[] = []; + for (const [name, def] of Object.entries(fields as Record)) { + if (runtime.readable.has(name)) kept[name] = def; + else withheld.push(name); + } + if (withheld.length === 0) return unchanged; + withheld.sort(); + return { + document: { ...rec, fields: kept } as unknown as T, + withheld, + fingerprint: objectFieldVisibilityFingerprint( + withheld.map((name) => `${RUNTIME_VIEW_FINGERPRINT_QUALIFIER}${name}`), + ), + }; +} + /** The result of projecting one document. */ export interface ObjectSchemaMaskResult { /** The document to serve. Same reference when nothing was removed. */ From 8b412a83e2f985968a64470f90c715ba3c0c28ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:02:18 +0000 Subject: [PATCH 2/8] feat(rest,runtime): derive sortability from the caller's runtime view on every item exit The cached arm folds the runtime view's fingerprint into the ETag and judges the conditional request itself for a posture carrying runtime. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 40 +++++++++++++++++------- packages/rest/src/rest-server.ts | 40 +++++++++++++++++++----- packages/runtime/src/domains/meta.ts | 5 +-- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index b9e94d4bd88..97c7b96cb09 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -67,6 +67,7 @@ import { applyObjectSchemaMask, organizationIdForMetaRead, relateObjectSchemaMaskPosture, + resolveObjectSchemaRuntimeView, type ObjectSchemaMaskPosture, } from '@objectstack/metadata-core'; import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; @@ -2284,9 +2285,14 @@ export async function translateMetaDocument( * translated ({@link translateMetaDocument}). * * Beside an OBJECT schema it serves the per-column `sortability` projection - * (#10235), computed from the FINAL document — post ADR-0106 mask — so its - * domain is exactly the field set this caller is served, and never inside - * `item` (`FieldSchema` is strict; the key must stay un-authorable). + * (#10235), computed from the caller's RUNTIME view of the final document — + * post ADR-0106 mask — and never inside `item` (`FieldSchema` is strict; the + * key must stay un-authorable). [#22250] `runtimeDocument` is that view + * (`resolveObjectSchemaRuntimeView`): the served document itself for every + * caller the mask projects, so the domain is exactly the field set they are + * served; for a D4-exempt caller, served the definition whole for authoring, + * the fields their own field permission reads — a sort the data route refuses + * them is never offered. Defaults to `document`. * * Moved here, unchanged, from `RestServer.translateMetaEnvelope` (which now * delegates), so the runtime dispatcher's item read answers the same body — @@ -2297,10 +2303,11 @@ export async function translateMetaEnvelope( metaType: string, envelope: Readonly> | undefined, document: unknown, + runtimeDocument: unknown = document, ): Promise> { const sortability = metaType === 'object' - && document && typeof document === 'object' && !Array.isArray(document) - ? { sortability: resolveObjectSortability(document) } + && runtimeDocument && typeof runtimeDocument === 'object' && !Array.isArray(runtimeDocument) + ? { sortability: resolveObjectSortability(runtimeDocument) } : {}; return { ...envelope, @@ -2626,11 +2633,17 @@ export interface MetaItemAnswerSources extends MetaItemReadGateSources { requestLocale(i18n?: unknown): string | undefined; /** * The body step: {@link translateMetaEnvelope} over this transport's I/O, - * handed the store's envelope and the served document. A port rather than a - * call so each transport keeps ONE entry into it + * handed the store's envelope, the served document and [#22250] the + * caller's runtime view of it (`resolveObjectSchemaRuntimeView`), which the + * port hands on as `translateMetaEnvelope`'s `runtimeDocument`. A port + * rather than a call so each transport keeps ONE entry into it * (`RestServer.translateMetaEnvelope`). */ - translateEnvelope(envelope: Readonly>, document: unknown): Promise; + translateEnvelope( + envelope: Readonly>, + document: unknown, + runtimeDocument?: unknown, + ): Promise; } /** The request facts the item chain reads — no transport shape. */ @@ -2700,7 +2713,10 @@ export type MetaItemAnswer = * 4. `object` — the ADR-0106 mask ({@link projectMetaObjectSchema}) under the * posture resolved before the fetch. * 5. The body ({@link translateMetaEnvelope}, through the transport's entry): - * the translation, and `sortability` beside an object schema. + * the translation, and `sortability` beside an object schema — derived + * from the caller's runtime view ([#22250] `resolveObjectSchemaRuntimeView`: + * a D4-exempt caller is served the definition whole, never a sort their + * own field permission refuses). * * The envelope is never mutated. A gate input that cannot be read REJECTS: * the transport answers that fault, ⛔ never the document. @@ -2733,8 +2749,10 @@ export function createMetaItemAnswer( if (!masked.ok) return { kind: 'mask-fault', object: name }; visible = masked.document; - // 5. The body — translation, and `sortability` beside an object schema. - const served = await sources.translateEnvelope(envelope ?? {}, visible); + // 5. The body — translation, and `sortability` beside an object schema, + // over [#22250] the caller's runtime view of what step 4 serves. + const runtime = await resolveObjectSchemaRuntimeView(visible, maskPosture); + const served = await sources.translateEnvelope(envelope ?? {}, visible, runtime.document); return masked.cacheControl ? { kind: 'serve', envelope: served, cacheControl: masked.cacheControl } : { kind: 'serve', envelope: served }; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 530b674d57e..a5d198e3639 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -59,6 +59,7 @@ import { normalizeIfNoneMatch, relateObjectSchemaMaskPosture, resolveObjectSchemaMaskPosture, + resolveObjectSchemaRuntimeView, OBJECT_SCHEMA_MASK_NOT_APPLICABLE, type ObjectSchemaMaskPosture, // [#8805] The organization a metadata WRITE carries, given the caller's @@ -3812,12 +3813,14 @@ export class RestServer { environmentId: string | undefined, envelope: Record, document: any, - i18nService?: any, + i18nService: any, + runtimeDocument: unknown, ): Promise { // [#10235] The per-column sortability projection, served beside the // document whenever the document IS an object schema, computed from the - // FINAL document (post ADR-0106 masking) and never inside `item`. - // [#20408] Both halves are `translateMetaEnvelope` in + // caller's runtime view of the FINAL document (post ADR-0106 masking; + // [#22250] `runtimeDocument`, `resolveObjectSchemaRuntimeView`) and never + // inside `item`. [#20408] Both halves are `translateMetaEnvelope` in // `./meta-item-read-gate.ts` — the runtime dispatcher's item read answers // the same body through it. return metaReadGate.translateMetaEnvelope( @@ -3825,6 +3828,7 @@ export class RestServer { RestServer.metaTypeSingular(type), envelope, document, + runtimeDocument, ); } @@ -3866,7 +3870,7 @@ export class RestServer { requestLocale: (i18n) => this.extractLocale(req, i18n), // [#21476] The uncached arm's share of the public-form intake // reason the cached arm states (`GET /meta/:type/:name`). - translateEnvelope: async (envelope, document) => + translateEnvelope: async (envelope, document, runtimeDocument) => this.translateMetaEnvelope( req, req.params.type, environmentId, envelope as Record, RestServer.metaTypeSingular(req.params.type) === 'view' @@ -3874,6 +3878,8 @@ export class RestServer { document, await this.anonymousFormIntakeWarnings(environmentId, req, p, document), ) : document, + undefined, + runtimeDocument, ), }; } @@ -6673,7 +6679,12 @@ export class RestServer { // for the dashboard gate. The comparison moves below, // against the fingerprinted ETag, which is the one // that identifies what we are actually sending. - const maskApplies = maskPosture.kind !== 'passthrough'; + // [#22250] A D4-exempt caller's posture carrying + // `runtime` is one: the definition is served whole, + // but the `sortability` beside it follows that + // caller's own field permission, which the + // protocol's validator does not hash either. + const maskApplies = maskPosture.kind !== 'passthrough' || maskPosture.runtime !== undefined; // [#21476] Same move for a `view`: its body can carry // the public-form intake reason, which derives from // the posture and the bound object, and the @@ -6750,6 +6761,12 @@ export class RestServer { cachedDocument = stampAnonymousFormIntakeWarnings(cachedDocument, warnings); intakeFingerprint = anonymousFormIntakeFingerprint(warnings); } + // [#22250] The caller's runtime view of what is + // served — the document itself unless the caller is + // D4-exempt and their own field permission withholds + // some of its fields — which `sortability` derives + // from, and whose fingerprint the validator folds. + const runtimeView = await resolveObjectSchemaRuntimeView(cachedDocument, maskPosture); // [ADR-0106 D6 tier 2] Visibility undetermined → // the body is unmasked, so it must not be stored or @@ -6762,7 +6779,7 @@ export class RestServer { res.json(await this.translateMetaEnvelope( req, req.params.type, environmentId, { type: metaType, name: req.params.name }, - cachedDocument, cacheI18n, + cachedDocument, cacheI18n, runtimeView.document, )); return; } @@ -6775,9 +6792,15 @@ export class RestServer { // fingerprint is empty → the ETag is byte-identical // to the pre-ADR one. A cohort shares 304s; a // permission change moves the fingerprint and - // self-invalidates the stale 304. + // self-invalidates the stale 304. [#22250] So does + // the runtime view's: a D4-exempt caller whose + // permission withholds a field is served a + // different `sortability`, never a 304 for another's. const value = foldVisibilityFingerprintIntoEtag( - foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint), + foldVisibilityFingerprintIntoEtag( + foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint), + runtimeView.fingerprint, + ), intakeFingerprint, ); const etagValue = result.etag.weak @@ -6837,6 +6860,7 @@ export class RestServer { }; res.json(await this.translateMetaEnvelope( req, req.params.type, environmentId, cachedEnvelope, cachedDocument, cacheI18n, + runtimeView.document, )); } else { // Non-cached version diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index e90328952e7..12ab13d7cb4 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -643,8 +643,9 @@ async function answerMetaItem( const sources: MetaItemAnswerSources = { ...metaItemReadGateSources(deps, context, protocol, mayWriteItem), requestLocale, - translateEnvelope: (itemEnvelope, document) => - translateMetaEnvelope(translation, request.metaType, itemEnvelope, document), + // [#22250] `runtimeDocument` — the caller's runtime view, which `sortability` derives from. + translateEnvelope: (itemEnvelope, document, runtimeDocument) => + translateMetaEnvelope(translation, request.metaType, itemEnvelope, document, runtimeDocument), }; let answer: MetaItemAnswer; try { From ba03fefc8499da33f13afd820d1d5b15617008f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:08:36 +0000 Subject: [PATCH 3/8] test: pin sortability per caller class on both transports and the shared runtime view Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/object-schema-fls.test.ts | 139 +++++++++++- ...meta-object-sortability-caller-fls.test.ts | 211 ++++++++++++++++++ ...meta-object-sortability-caller-fls.test.ts | 99 ++++++++ 3 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 packages/rest/src/meta-object-sortability-caller-fls.test.ts create mode 100644 packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts diff --git a/packages/metadata-core/src/object-schema-fls.test.ts b/packages/metadata-core/src/object-schema-fls.test.ts index c24332b018e..97f3c1b227a 100644 --- a/packages/metadata-core/src/object-schema-fls.test.ts +++ b/packages/metadata-core/src/object-schema-fls.test.ts @@ -15,6 +15,7 @@ import { normalizeIfNoneMatch, objectFieldVisibilityFingerprint, resolveObjectSchemaMaskPosture, + resolveObjectSchemaRuntimeView, OBJECT_SCHEMA_MASK_DISABLE_ENV, OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES, OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, @@ -164,7 +165,10 @@ describe('[#7020] the D4 read exemption is derived from the write gate', () => { security: { getMetadataReadableFields: () => { asked++; throw new Error('must not be consulted'); } }, enabled: true, }); - expect(posture).toEqual({ kind: 'passthrough', reason: 'exempt' }); + // [#22250] The posture carries the LAZY runtime question — resolving the + // posture still asks nothing, and the definition still passes through. + expect(posture).toMatchObject({ kind: 'passthrough', reason: 'exempt' }); + expect(posture.kind === 'passthrough' && typeof posture.runtime).toBe('function'); expect(asked).toBe(0); }); @@ -291,3 +295,136 @@ describe('[ADR-0106 D7] the metadata-plane query is preferred when the service o expect(posture).toMatchObject({ kind: 'project', readable: new Set(['id']) }); }); }); + +/** + * [#22250] D4 exempts the DEFINITION (Studio/Setup authoring needs the whole + * schema), not the runtime projections served beside it: those follow the + * caller's own field permission, which the data route enforces for every + * class. Pinned per caller class, with the member as the control. + */ +describe('[#22250] the D4 exemption covers the definition, not the runtime view', () => { + const CLASSES = { + 'platform admin': { userId: 'u_platform_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + 'organization admin': { userId: 'u_org_admin', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'] }, + member: { userId: 'u_member', systemPermissions: [] as string[] }, + } as const; + + const resolveFor = (context: unknown, readable: () => string[] | undefined, telemetry?: Parameters[0]['telemetry']) => { + let asked = 0; + const security = { getMetadataReadableFields: () => { asked++; return readable(); } }; + return { + posture: resolveObjectSchemaMaskPosture({ objectName: 'account', context, security, enabled: true, telemetry }), + asked: () => asked, + }; + }; + + for (const [label, context] of Object.entries(CLASSES)) { + it(`${label}: the runtime view holds only the readable fields, whatever the definition serves`, async () => { + const { posture: pending } = resolveFor(context, () => ['id']); + const posture = await pending; + const served = applyObjectSchemaMask(OBJECT, posture).document; + const view = await resolveObjectSchemaRuntimeView(served, posture); + + expect(Object.keys(view.document.fields)).toEqual(['id']); + if (label === 'member') { + // Control: the definition mask already projects a member, so the + // runtime view IS the served document — same reference, nothing + // withheld on top, no second fingerprint. + expect(posture.kind).toBe('project'); + expect(Object.keys(served.fields)).toEqual(['id']); + expect(view.document).toBe(served); + expect(view.withheld).toEqual([]); + expect(view.fingerprint).toBe(''); + } else { + // The exempt definition stays whole, for authoring... + expect(posture).toMatchObject({ kind: 'passthrough', reason: 'exempt' }); + expect(served).toBe(OBJECT); + expect(Object.keys(served.fields)).toEqual(['id', 'salary_grade']); + // ...and only the runtime view drops what the caller cannot read. + expect(view.withheld).toEqual(['salary_grade']); + expect(view.fingerprint).not.toBe(''); + } + }); + } + + it('one field permission, one runtime view, whatever the class', async () => { + const views = await Promise.all(Object.values(CLASSES).map(async (context) => { + const posture = await resolveFor(context, () => ['id']).posture; + return (await resolveObjectSchemaRuntimeView(applyObjectSchemaMask(OBJECT, posture).document, posture)).document; + })); + for (const view of views) expect(view.fields).toEqual(views[2].fields); + }); + + it('never shares a validator with the definition mask over the same denied set', async () => { + const admin = await resolveFor(CLASSES['platform admin'], () => ['id']).posture; + const member = await resolveFor(CLASSES.member, () => ['id']).posture; + const adminView = await resolveObjectSchemaRuntimeView(OBJECT, admin); + const memberMask = applyObjectSchemaMask(OBJECT, member); + // Same denied field, different served bodies (the admin's carries the + // field in `item`): the two fingerprints must differ or a 304 crosses over. + expect(memberMask.fingerprint).toBe(objectFieldVisibilityFingerprint(['salary_grade'])); + expect(adminView.fingerprint).not.toBe(memberMask.fingerprint); + }); + + it('an unrestricted exempt caller: same reference, empty fingerprint (D3 byte-identity)', async () => { + const posture = await resolveFor(CLASSES['platform admin'], () => ['id', 'salary_grade']).posture; + const view = await resolveObjectSchemaRuntimeView(OBJECT, posture); + expect(view.document).toBe(OBJECT); + expect(view.fingerprint).toBe(''); + }); + + it('asks the service lazily and at most once per posture, and never for a document without `fields`', async () => { + const { posture: pending, asked } = resolveFor(CLASSES['platform admin'], () => ['id']); + const posture = await pending; + expect(asked()).toBe(0); + await resolveObjectSchemaRuntimeView({ name: 'not_an_object_schema' }, posture); + expect(asked()).toBe(0); + await resolveObjectSchemaRuntimeView(OBJECT, posture); + await resolveObjectSchemaRuntimeView(OBJECT, posture); + expect(asked()).toBe(1); + // The input is never mutated. + expect(Object.keys(OBJECT.fields)).toEqual(['id', 'salary_grade']); + }); + + it('`undefined` settles undetermined: the view is the served document, warned and counted once', async () => { + const warns: unknown[] = []; + const counters: unknown[] = []; + const posture = await resolveFor(CLASSES['platform admin'], () => undefined, { + warn: (m, meta) => warns.push([m, meta]), + counter: (n, labels) => counters.push([n, labels]), + }).posture; + const view = await resolveObjectSchemaRuntimeView(OBJECT, posture); + expect(view.document).toBe(OBJECT); + expect(view.fingerprint).toBe(''); + expect(warns).toHaveLength(1); + expect(counters).toEqual([[OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, { object: 'account' }]]); + }); + + it('a throwing service never faults the exempt caller (D4) — the view settles undetermined, warned', async () => { + const warns: unknown[] = []; + const posture = await resolveObjectSchemaMaskPosture({ + objectName: 'account', + context: CLASSES['platform admin'], + security: { getMetadataReadableFields: () => { throw new Error('security down'); } }, + enabled: true, + telemetry: { warn: (m, meta) => warns.push([m, meta]) }, + }); + const view = await resolveObjectSchemaRuntimeView(OBJECT, posture); + expect(view.document).toBe(OBJECT); + expect(view.fingerprint).toBe(''); + expect(warns).toHaveLength(1); + }); + + it('`isSystem` and a deployment with no security service carry no runtime question', async () => { + const system = await resolveFor({ isSystem: true }, () => ['id']); + const systemPosture = await system.posture; + expect(systemPosture).toEqual({ kind: 'passthrough', reason: 'exempt' }); + expect((await resolveObjectSchemaRuntimeView(OBJECT, systemPosture)).document).toBe(OBJECT); + expect(system.asked()).toBe(0); + + const noService = await resolveObjectSchemaMaskPosture({ + objectName: 'account', context: CLASSES['platform admin'], security: undefined, enabled: true, + }); + expect(noService).toEqual({ kind: 'passthrough', reason: 'exempt' }); + }); +}); diff --git a/packages/rest/src/meta-object-sortability-caller-fls.test.ts b/packages/rest/src/meta-object-sortability-caller-fls.test.ts new file mode 100644 index 00000000000..6f6d0029a96 --- /dev/null +++ b/packages/rest/src/meta-object-sortability-caller-fls.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22250] `GET /meta/object/:name` — the `sortability` projection served + * beside the schema follows the CALLER's field permission, per caller class. + * + * ADR-0106 D4 exempts a platform-admin caller (and any caller holding + * `manage_metadata`, `studio.access` or `setup.access`, an organization admin + * included) from the object-schema mask: Studio/Setup authoring needs the whole + * DEFINITION, and a projected one PUT back deletes what it withheld. That is + * all D4 exempts. The same caller's field permission still binds the data + * route — a filter or sort on an unreadable field answers `403` — so the + * projection a grid builds sort clicks from must not offer that field. Before + * this, it did: the exempt caller's `sortability` was derived from the + * unmasked document and marked the field `sortable: true`. + * + * Pinned per class on both branches (the cached one is THE DEFAULT): + * - the definition (`item.fields`) is unchanged — whole for the two admin + * classes, masked for the member (the control); + * - `sortability` never names the unreadable field, and is the same for all + * three classes holding the same field permission; + * - the cached arm's ETag folds the difference, so an exempt caller is never + * answered `304` against a body that offered a field it may no longer read, + * and never shares a validator with a member denied the same field; + * - an unrestricted exempt caller's answer — ETag included — is byte-identical + * to the pre-change one, and a sick security service never faults the + * exempt caller's definition read. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { resolveObjectSortability } from '@objectstack/spec/api'; +import { objectFieldVisibilityFingerprint } from '@objectstack/metadata-core'; +import { RestServer } from './rest-server.js'; + +const OPPORTUNITY = { + name: 'crm_opportunity', + label: 'Opportunity', + fields: { + name: { type: 'text' }, + amount: { type: 'currency' }, + secret_margin: { type: 'currency' }, + expected_revenue: { type: 'formula', expression: 'amount * probability / 100' }, + }, +}; +const ALL_FIELDS = Object.keys(OPPORTUNITY.fields); +/** Every class below holds the same field permission: `secret_margin` is unreadable. */ +const READABLE = ['name', 'amount', 'expected_revenue']; + +const CLASSES = { + 'platform admin': { userId: 'u_platform_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + 'organization admin': { userId: 'u_org_admin', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'] }, + member: { userId: 'u_member', systemPermissions: [] as string[] }, +} as const; +type CallerClass = keyof typeof CLASSES; + +/** The `sortability` the member is served — the control every class must equal. */ +const EXPECTED_SORTABILITY = resolveObjectSortability({ + ...OPPORTUNITY, + fields: Object.fromEntries(Object.entries(OPPORTUNITY.fields).filter(([name]) => READABLE.includes(name))), +}); + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const headers: Record = {}; + return { + headers, + statusCode: 200, + json: vi.fn(), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn((name: string, value: string) => { headers[name] = value; }), + }; +} + +interface ReadOptions { + caller: Record; + /** What the security service answers; `'throw'` for an unhealthy one. */ + readable: readonly string[] | 'throw'; + cached: boolean; + ifNoneMatch?: string; +} + +async function read(opts: ReadOptions) { + const clone = () => JSON.parse(JSON.stringify(OPPORTUNITY)); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn(async ({ name }: any) => ({ type: 'object', name, item: clone(), lock: 'none' })), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + }; + if (opts.cached) { + // The protocol judges a conditional request only when it is handed one; + // this double answers `notModified` exactly as it would. + protocol.getMetaItemCached = vi.fn(async ({ cacheRequest }: any) => ({ + data: clone(), + etag: { value: 'v1', weak: false }, + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: cacheRequest?.ifNoneMatch === '"v1"', + })); + } + const answer = () => { + if (opts.readable === 'throw') throw new Error('security service unhealthy (test)'); + return [...opts.readable]; + }; + const security = { getReadableFields: async () => answer(), getMetadataReadableFields: async () => answer() }; + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + async () => security as any, + ); + (rest as any).resolveExecCtx = async () => ({ ...opts.caller }); + rest.registerRoutes(); + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + const res = mockRes(); + await route.handler({ + params: { type: 'object', name: 'crm_opportunity' }, + query: {}, + headers: opts.ifNoneMatch ? { 'if-none-match': opts.ifNoneMatch } : {}, + }, res); + const calls = res.json.mock.calls; + return { res, protocol, body: calls.length > 0 ? calls[calls.length - 1][0] : undefined }; +} + +const isExempt = (cls: CallerClass) => cls !== 'member'; + +for (const cached of [true, false]) { + describe(`[#22250] ${cached ? 'cached (default)' : 'uncached'} branch — sortability follows the caller's field permission`, () => { + for (const cls of Object.keys(CLASSES) as CallerClass[]) { + it(`${cls}: the definition is ${isExempt(cls) ? 'served whole' : 'masked'}; sortability never offers the unreadable field`, async () => { + const { res, body } = await read({ caller: CLASSES[cls], readable: READABLE, cached }); + + expect(res.statusCode).toBe(200); + // The definition read — unchanged by this card for every class. + expect(Object.keys(body.item.fields)).toEqual(isExempt(cls) ? ALL_FIELDS : READABLE); + // The runtime projection — the same for every class. + expect(body.sortability.fields.secret_margin).toBeUndefined(); + expect(body.sortability).toEqual(EXPECTED_SORTABILITY); + // Anti-vacuity: readable columns keep their verdicts. + expect(body.sortability.fields.amount).toEqual({ sortable: true }); + expect(body.sortability.fields.expected_revenue).toEqual({ sortable: false, reason: 'virtual-type' }); + if (!isExempt(cls)) { + // Control: a member's projection is still derived from exactly what it is served (#10235). + expect(body.sortability).toEqual(resolveObjectSortability(body.item)); + } + }); + } + + it('an unrestricted exempt caller is served the pre-change answer, every field sortable', async () => { + const { body } = await read({ caller: CLASSES['platform admin'], readable: ALL_FIELDS, cached }); + expect(Object.keys(body.item.fields)).toEqual(ALL_FIELDS); + expect(body.sortability).toEqual(resolveObjectSortability(OPPORTUNITY)); + }); + + it('a sick security service never faults the exempt caller: the definition is served, sortability unprojected', async () => { + const { res, body } = await read({ caller: CLASSES['platform admin'], readable: 'throw', cached }); + expect(res.statusCode).toBe(200); + expect(Object.keys(body.item.fields)).toEqual(ALL_FIELDS); + expect(body.sortability).toEqual(resolveObjectSortability(OPPORTUNITY)); + }); + }); +} + +describe('[#22250] the cached arm\'s validator folds the exempt caller\'s runtime view', () => { + it('platform admin: a fingerprinted ETag the protocol is not allowed to judge, distinct from the member\'s', async () => { + const admin = await read({ caller: CLASSES['platform admin'], readable: READABLE, cached: true, ifNoneMatch: '"v1"' }); + const member = await read({ caller: CLASSES.member, readable: READABLE, cached: true }); + + // The protocol hashes the unfiltered document, so it must not answer a + // conditional request whose served body varies with the caller. + expect(admin.protocol.getMetaItemCached.mock.calls[0][0].cacheRequest.ifNoneMatch).toBeUndefined(); + // The pre-change validator no longer matches: 200 with the narrowed projection, not a stale 304. + expect(admin.res.statusCode).toBe(200); + expect(admin.body.sortability.fields.secret_margin).toBeUndefined(); + + expect(member.res.headers.ETag).toBe(`"v1~${objectFieldVisibilityFingerprint(['secret_margin'])}"`); + expect(admin.res.headers.ETag).toMatch(/^"v1~[0-9a-f]{8}"$/); + // Same denied field, different served body: never one validator. + expect(admin.res.headers.ETag).not.toBe(member.res.headers.ETag); + }); + + it('platform admin: its own ETag revalidates to 304; the member\'s does not', async () => { + const first = await read({ caller: CLASSES['platform admin'], readable: READABLE, cached: true }); + const own = await read({ caller: CLASSES['platform admin'], readable: READABLE, cached: true, ifNoneMatch: first.res.headers.ETag }); + expect(own.res.statusCode).toBe(304); + + const member = await read({ caller: CLASSES.member, readable: READABLE, cached: true }); + const crossed = await read({ caller: CLASSES['platform admin'], readable: READABLE, cached: true, ifNoneMatch: member.res.headers.ETag }); + expect(crossed.res.statusCode).toBe(200); + }); + + it('an unrestricted exempt caller keeps the byte-identical ETag and its 304 (ADR-0106 D3)', async () => { + const first = await read({ caller: CLASSES['platform admin'], readable: ALL_FIELDS, cached: true }); + expect(first.res.headers.ETag).toBe('"v1"'); + const again = await read({ caller: CLASSES['platform admin'], readable: ALL_FIELDS, cached: true, ifNoneMatch: '"v1"' }); + expect(again.res.statusCode).toBe(304); + }); +}); diff --git a/packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts b/packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts new file mode 100644 index 00000000000..cf98e13d065 --- /dev/null +++ b/packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22250] The dispatcher's `/metadata/object/:name` item read — the only + * answer on a host that mounts just the `${prefix}/*` catch-all — serves the + * `sortability` projection beside the schema through the SAME item chain as + * `RestServer` (`createMetaItemAnswer`), so it follows the caller's field + * permission per caller class exactly as the REST suite pins it + * (`meta-object-sortability-caller-fls.test.ts` in `@objectstack/rest`). + * + * D4 still serves the two admin classes the DEFINITION whole (authoring); what + * changed is that their `sortability` no longer offers a field their own field + * permission withholds, which the data route refuses them with `403`. The + * member is the control: masked definition, same projection as before. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { resolveObjectSortability } from '@objectstack/spec/api'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const OPPORTUNITY = { + name: 'crm_opportunity', + label: 'Opportunity', + fields: { + name: { type: 'text' }, + amount: { type: 'currency' }, + secret_margin: { type: 'currency' }, + expected_revenue: { type: 'formula', expression: 'amount * probability / 100' }, + }, +}; +const ALL_FIELDS = Object.keys(OPPORTUNITY.fields); +/** Every class below holds the same field permission: `secret_margin` is unreadable. */ +const READABLE = ['name', 'amount', 'expected_revenue']; +const EXPECTED_SORTABILITY = resolveObjectSortability({ + ...OPPORTUNITY, + fields: Object.fromEntries(Object.entries(OPPORTUNITY.fields).filter(([name]) => READABLE.includes(name))), +}); + +const CLASSES = { + 'platform admin': { userId: 'u_platform_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + 'organization admin': { userId: 'u_org_admin', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'] }, + member: { userId: 'u_member', systemPermissions: [] as string[] }, +} as const; +type CallerClass = keyof typeof CLASSES; + +const clone = () => JSON.parse(JSON.stringify(OPPORTUNITY)); + +/** The two lookups the item read serves an object from: the scoped protocol, and the registry fallback. */ +const EXITS: Record Record> = { + 'protocol-backed (scoped kernel)': () => ({ + protocol: { + getProjectId: () => 'env_1', + getMetaItem: vi.fn(async ({ name }: any) => ({ type: 'object', name, item: clone(), lock: 'none' })), + }, + }), + 'registry-backed fallback': () => ({ objectql: { registry: { getObject: vi.fn(() => clone()) } } }), +}; + +async function read(exit: string, caller: Record, readable: readonly string[]) { + const services: Record = { + ...EXITS[exit](), + security: { + getReadableFields: async () => [...readable], + getMetadataReadableFields: async () => [...readable], + }, + }; + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + const res = await new HttpDispatcher(kernel).handleMetadata( + '/object/crm_opportunity', + { request: {}, environmentId: 'platform', executionContext: { ...caller } } as any, + 'GET', + ); + return { status: res.response.status, data: res.response.body?.data }; +} + +for (const exit of Object.keys(EXITS)) { + describe(`[#22250] /metadata/object/:name — ${exit}: sortability follows the caller's field permission`, () => { + for (const cls of Object.keys(CLASSES) as CallerClass[]) { + const exempt = cls !== 'member'; + it(`${cls}: the definition is ${exempt ? 'served whole' : 'masked'}; sortability never offers the unreadable field`, async () => { + const { status, data } = await read(exit, CLASSES[cls], READABLE); + expect(status).toBe(200); + expect(Object.keys(data.item.fields)).toEqual(exempt ? ALL_FIELDS : READABLE); + expect(data.sortability.fields.secret_margin).toBeUndefined(); + expect(data.sortability).toEqual(EXPECTED_SORTABILITY); + expect(data.sortability.fields.amount).toEqual({ sortable: true }); + }); + } + + it('an unrestricted exempt caller is served every field sortable, as before', async () => { + const { data } = await read(exit, CLASSES['platform admin'], ALL_FIELDS); + expect(data.sortability).toEqual(resolveObjectSortability(OPPORTUNITY)); + }); + }); +} From d9e2f385f170b28e372c487155fb8351d884bb44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:33:57 +0000 Subject: [PATCH 4/8] chore(changeset): the object read's sortability follows the caller's field permission Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22250-meta-fls-runtime-sortability.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/22250-meta-fls-runtime-sortability.md diff --git a/.changeset/22250-meta-fls-runtime-sortability.md b/.changeset/22250-meta-fls-runtime-sortability.md new file mode 100644 index 00000000000..6ee4eae8a1e --- /dev/null +++ b/.changeset/22250-meta-fls-runtime-sortability.md @@ -0,0 +1,16 @@ +--- +"@objectstack/metadata-core": minor +"@objectstack/rest": minor +"@objectstack/runtime": patch +--- + +fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class + +Clause-②: yes (widening) + +- **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. +- **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. +- **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. +- **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. +- **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). +- **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. From 805f1ba09ab024edc7cc4a816d287ee0f5751b71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:20:08 +0000 Subject: [PATCH 5/8] refactor(metadata-core): ask the exemption's capability half instead of a second isSystem read Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../metadata-core/src/object-schema-fls.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/metadata-core/src/object-schema-fls.ts b/packages/metadata-core/src/object-schema-fls.ts index 6b362bc03ce..ed53d7eb4c6 100644 --- a/packages/metadata-core/src/object-schema-fls.ts +++ b/packages/metadata-core/src/object-schema-fls.ts @@ -300,8 +300,19 @@ export function isObjectSchemaMaskExempt(context: unknown): boolean { if (!context || typeof context !== 'object') return false; const ctx = context as { isSystem?: unknown; systemPermissions?: unknown }; if (ctx.isSystem === true) return true; - if (!Array.isArray(ctx.systemPermissions)) return false; - return ctx.systemPermissions.some( + return holdsObjectSchemaMaskExemptCapability(ctx); +} + +/** + * The capability half of {@link isObjectSchemaMaskExempt} — the one reading of + * `systemPermissions` against {@link OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES}. + * [#22250] {@link resolveObjectSchemaMaskPosture} asks it alone to decide + * whether an exempt caller's field permission is worth asking: a caller exempt + * only as `isSystem` is one field-level security never restricts. + */ +function holdsObjectSchemaMaskExemptCapability(context: { systemPermissions?: unknown }): boolean { + if (!Array.isArray(context.systemPermissions)) return false; + return context.systemPermissions.some( (p) => typeof p === 'string' && OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES.includes(p), ); } @@ -374,8 +385,7 @@ export async function resolveObjectSchemaMaskPosture(input: { // needs the whole schema, and a projected one PUT back deletes what it // withheld). It is not the runtime projections': they keep this // caller's own field permission, asked lazily — see `runtime`. - const isSystem = (context as { isSystem?: unknown }).isSystem === true; - return ask && !isSystem + return ask && holdsObjectSchemaMaskExemptCapability(context as { systemPermissions?: unknown }) ? { kind: 'passthrough', reason: 'exempt', runtime: runtimeFieldPostureAsker(objectName, context, ask, telemetry) } : { kind: 'passthrough', reason: 'exempt' }; } From 3768ffad20f44319000147027c9a7d8be9cd323e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:52:33 +0000 Subject: [PATCH 6/8] test(runtime): narrow the dispatcher response before reading it, so the test layer typechecks Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/domains/meta-object-sortability-caller-fls.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts b/packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts index cf98e13d065..3cd3d1b8af8 100644 --- a/packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts +++ b/packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts @@ -74,7 +74,9 @@ async function read(exit: string, caller: Record, readable: rea { request: {}, environmentId: 'platform', executionContext: { ...caller } } as any, 'GET', ); - return { status: res.response.status, data: res.response.body?.data }; + const response = res.response; + if (!response) throw new Error('the dispatcher answered /object/crm_opportunity with no response'); + return { status: response.status, data: response.body?.data }; } for (const exit of Object.keys(EXITS)) { From d0528df721d0d7c081c96b299f504258344ff85c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:29:20 +0000 Subject: [PATCH 7/8] docs(spec): the sortability projection names the caller's field permission as an absence case Text only: the module docblock's category list gains case 5, and the ObjectSortabilitySchema.fields description no longer claims the domain is the served field map for a caller exempt from the object-schema mask. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/spec/src/api/sortability.zod.ts | 33 +++++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/api/sortability.zod.ts b/packages/spec/src/api/sortability.zod.ts index ff787af1143..dabe432c819 100644 --- a/packages/spec/src/api/sortability.zod.ts +++ b/packages/spec/src/api/sortability.zod.ts @@ -31,12 +31,15 @@ * `assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and * `assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually * do with an `orderBy` over the name. Three verdicts are REFUSALS, and one - * measured degradation is not refused; the projection covers all four: + * measured degradation is not refused; the projection covers all four, and + * one more absence that is a fact about the CALLER, not the column (5.): * * 1. **Unknown name** — not a field of the object. Refused (`400 * INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly - * the served field map plus the always-provisioned `id`, so a name with no - * entry has no platform sort behind it and gets no affordance. + * the served field map plus the always-provisioned `id`, less any field + * the caller's own field permission withholds (5. — which removes nothing + * for every caller but a D4-exempt one), so a name with no entry has no + * platform sort behind it and gets no affordance. * 2. **Dotted path** (`account.name`) — crosses into a related record no * driver joins for. Refused. Encoded as absence too: entries are keyed by * whole-column field names, and a dotted name can never appear as one. @@ -57,6 +60,15 @@ * so the entry stays `sortable: true` — the enforcement fact — and carries * `caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative * affordance without re-deriving federation from the document. + * 5. **Withheld by the caller's field permission** (ADR-0106 D1/D4, #22250) — + * a field the caller's own field-level security marks unreadable. Refused + * for that caller by the data route's field guard (`403`: filtering or + * sorting on a hidden field would leak its values). Encoded as absence: no + * entry, whatever the caller's class — including a caller exempt from the + * object-schema mask (ADR-0106 D4: `manage_metadata`, `studio.access`, + * `setup.access`), who IS served the field in `item.fields` because + * authoring needs the whole definition. For every other caller the mask + * has already removed the field from `item`, so this is case 1's absence. * * ## Considered and deliberately NOT members * @@ -153,9 +165,13 @@ export type FieldSortability = z.input; export const ObjectSortabilitySchema = lazySchema(() => z.object({ fields: z.record(z.string(), FieldSortabilitySchema).describe( 'Verdict per sortable-addressable column, keyed by field name. The domain ' - + 'is the served field map plus the always-provisioned `id`; a name absent ' - + 'from this map (an unknown field, a dotted path, an unprovisioned audit ' - + 'column) has no platform sort behind it and must get no sort affordance.', + + 'is the served field map plus the always-provisioned `id`, less any field ' + + 'the caller\'s own field permission withholds: a caller exempt from the ' + + 'object-schema mask (ADR-0106 D4) is served such fields in `item` and gets ' + + 'no entry for them; for every other caller the served map already excludes ' + + 'them. A name absent from this map (an unknown field, a dotted path, an ' + + 'unprovisioned audit column, a field the caller may not read) has no ' + + 'platform sort behind it and must get no sort affordance.', ), })); @@ -191,7 +207,10 @@ function fieldEntriesOf(doc: unknown): Array<[string, SearchFieldMeta | undefine * every derivation in `data/injected-system-column-provenance.ts` carries), so * the REST serving layer can run it on the exact document it is about to serve * — post-masking, post-injection — and the projection's domain equals what the - * caller can see. Judged by the spec's own predicates and nothing else: + * caller can see. For a caller exempt from the mask (ADR-0106 D4) it runs on + * that document narrowed to the fields the caller's own field permission reads + * (case 5. above), so the domain is what the caller may sort on. Judged by the + * spec's own predicates and nothing else: * * - virtuality by {@link isVirtualSearchField} (`SEARCH_VIRTUAL_TYPES`) — the * same storage fact the runtime doors (#6994/#7095) and the authoring linter From a337d0cc537ea09b8a2bee82f9b80270df735b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:51:57 +0000 Subject: [PATCH 8/8] docs(references): regenerate the sortability reference from the amended spec text Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/api/sortability.mdx | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3e5eab4532f..c492e3eb828 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1360,7 +1360,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **fields** | `Record` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`; a name absent from this map (an unknown field, a dotted path, an unprovisioned audit column) has no platform sort behind it and must get no sort affordance. | +| **fields** | `Record` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. | --- diff --git a/content/docs/references/api/sortability.mdx b/content/docs/references/api/sortability.mdx index b805bd800e0..4ebc2b31d6d 100644 --- a/content/docs/references/api/sortability.mdx +++ b/content/docs/references/api/sortability.mdx @@ -36,12 +36,15 @@ records as deliberately rejected). `assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and `assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually do with an `orderBy` over the name. Three verdicts are REFUSALS, and one -measured degradation is not refused; the projection covers all four: +measured degradation is not refused; the projection covers all four, and +one more absence that is a fact about the CALLER, not the column (5.): 1. **Unknown name** — not a field of the object. Refused (`400 INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly - the served field map plus the always-provisioned `id`, so a name with no - entry has no platform sort behind it and gets no affordance. + the served field map plus the always-provisioned `id`, less any field + the caller's own field permission withholds (5. — which removes nothing + for every caller but a D4-exempt one), so a name with no entry has no + platform sort behind it and gets no affordance. 2. **Dotted path** (`account.name`) — crosses into a related record no driver joins for. Refused. Encoded as absence too: entries are keyed by whole-column field names, and a dotted name can never appear as one. @@ -62,6 +65,15 @@ measured degradation is not refused; the projection covers all four: so the entry stays `sortable: true` — the enforcement fact — and carries `caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative affordance without re-deriving federation from the document. +5. **Withheld by the caller's field permission** (ADR-0106 D1/D4, #22250) — + a field the caller's own field-level security marks unreadable. Refused + for that caller by the data route's field guard (`403`: filtering or + sorting on a hidden field would leak its values). Encoded as absence: no + entry, whatever the caller's class — including a caller exempt from the + object-schema mask (ADR-0106 D4: `manage_metadata`, `studio.access`, + `setup.access`), who IS served the field in `item.fields` because + authoring needs the whole definition. For every other caller the mask + has already removed the field from `item`, so this is case 1's absence. ## Considered and deliberately NOT members @@ -132,7 +144,7 @@ const result = FieldSortabilitySchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **fields** | `Record` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`; a name absent from this map (an unknown field, a dotted path, an unprovisioned audit column) has no platform sort behind it and must get no sort affordance. | +| **fields** | `Record` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. | ### Nested Shape: `ObjectSortability.fields[string]`