From 60224dac3e7c7d30c413365a5bebf905968bcb2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:11:33 +0000 Subject: [PATCH 1/5] fix(rest): the stored-version doors serve an app whole to its authors and pruned to everyone else Ruling 5856774816 (letter B, confirmed 5856866273), ADR-0106 D4's shape: on /layers, ?layers=true and /diff a caller who may write the app reads the full stored version; every other caller who may open it reads exactly what the plain read gives them, pruned. - MetaReadGatePolicy.app: 'pending-decision' retires; 'author-exempt' honours MetaReadGateCaller.mayWriteItem, a caller property. - RestServer.metaSaveVerdict: the save door's admission spelled once; PUT /meta/:type/:name and the exemption both ask it. - The layered view and /diff now serve the documents the gate serves (they discarded them), so a non-author reads pruned layers and pruned diff values; /diff substitutes emitted values, never drops an entry. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 102 ++++++++----- packages/rest/src/rest-server.ts | 187 ++++++++++++++++++----- 2 files changed, 215 insertions(+), 74 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 4c809812a34..817024027bb 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -52,6 +52,17 @@ import { logWarn } from './log.js'; export interface MetaReadGateCaller { userId?: unknown; systemPermissions?: unknown; + /** + * [#20156] May this caller WRITE the item being read? The transport's own + * answer, asked exactly as its save door asks it (REST: the admission of + * `PUT /meta/:type/:name`, `metaWriteCapabilityVerdict` for `save`) — a + * CALLER property, in ADR-0106 D4's shape, never a route property. + * + * Read only where the door's policy honours the author exemption + * ({@link MetaReadGatePolicy.app} `author-exempt`); a transport supplies it + * only there. Absent reads as `false`: no exemption. + */ + mayWriteItem?: boolean; [key: string]: unknown; } @@ -155,27 +166,35 @@ export interface MetaReadGatePolicy { * The `app` arm (the unpublished gate, `requiredPermissions`, the * docs-audience entry arm). * - * `gate` — the plain read's app answer: its refusal, or the pruned app. + * `gate` — the plain read's app answer: its refusal, or the pruned app, + * for EVERY caller, authors included: read-to-display is per user. + * + * `author-exempt` — [#20156] ruling 5856774816 (letter B, confirmed + * 5856866273), ADR-0106 D4's shape carried from object schemas to apps: + * read-to-edit is whole for whoever may edit. A caller the door hands in + * with {@link MetaReadGateCaller.mayWriteItem} `true` — one the item's + * save door admits — is served the STORED app, unpruned; every other + * caller is served exactly `gate`'s answer, pruned. An app the plain read + * refuses WHOLE (an app-level `requiredPermissions` the caller lacks → + * `403`; an unpublished app to a non-builder → the absence answer, + * ADR-0045 §3) is refused either way, author or not. + * + * Why the save door's answer, and not "platform administrator" (ruling + * item 1): Studio's designer loads a stored version and saves back what + * it loaded, so whoever may save an app must see all of it — pruned, the + * save would delete the withheld entries silently. That makes "whoever + * may write it sees it whole" true by construction, the same invariant + * ADR-0106 D4 holds for object schemas. * - * `pending-decision` — an app the plain read refuses WHOLE (an app-level - * `requiredPermissions` the caller lacks → `403`; an unpublished app to a - * non-builder → the absence answer, ADR-0045 §3) is refused exactly as the - * plain read refuses it. An app it would serve only in PART (entries - * withheld) is served as STORED, unpruned, exactly as before this gate. - * ⚠️ That second half is a DECLARED EXEMPTION, not a verdict: what the - * layered view (`/layers`, `?layers=`) and `/diff` owe a caller who may see - * part of an app is a new permission boundary, and the maintainer's to - * decide (decision anchor #20156). Both ways of pulling those cells to the - * plain read's answer draw one: pruning a stored version the designer - * saves back deletes the withheld entries, and refusing it locks out an - * author — a platform admin included, whose capability list carries no - * wildcard and meets no admin exemption in - * {@link filterAppForUserWithReason} — who lacks one entry's permission. - * The census in `meta-alternate-door-read-gates.test.ts` pins exactly those - * partial cells to the pre-gate answer under that anchor, so they stay loud - * until ruled, and holds every whole-refusal cell to the plain read's. + * The exemption is a CALLER property: this member says only whether a + * door honours it, and ⛔ no route test stands here. The doors that + * serve STORED versions for authoring (the layered view, `/diff`) honour + * it; the doors that serve the document a client renders (the plain + * read, `/published`) and the event doors (`/history`, `/audit`) do not. + * The census in `meta-alternate-door-read-gates.test.ts` pins both halves + * on every door, and that the exemption reaches no other cell. */ - app: 'gate' | 'pending-decision'; + app: 'gate' | 'author-exempt'; } // ── The gates' types ────────────────────────────────────────────────────────── @@ -1143,9 +1162,7 @@ const DOCS_HOLDER_MESSAGE = 'This documentation is limited to holders of a permi * through `?layers=true`, and an app's `requiredPermissions` entries reached * every member. The by-name app route's own rule — it "must not serve a nav * entry the list route prunes, or reading the single-app JSON defeats the - * filter" — held for one door out of seven (the layered view and `/diff` - * still serve an app the caller may see only in PART as stored, pending a - * decision: `MetaReadGatePolicy.app`). Each door asks THIS function, so a + * filter" — held for one door out of seven. Each door asks THIS function, so a * gate added here reaches all of them, and the census in * `meta-alternate-door-read-gates.test.ts` — its door list read off the * route table — fails a door that does not ask. @@ -1164,10 +1181,10 @@ const DOCS_HOLDER_MESSAGE = 'This documentation is limited to holders of a permi * `service` → the absence answer (ADR-0045 §3: an unpublished app is * externally unobservable). [#7912] The servability gate and [#19790] the * docs-audience entry arm ride along. - * ⚠️ Under `app: 'pending-decision'` — the layered view and `/diff` — - * an app the plain read refuses WHOLE is refused the same way, and one - * it would serve only in PART is served as stored, unpruned: see - * `MetaReadGatePolicy.app` for the decision it waits on. + * [ruling 5856774816] Under `app: 'author-exempt'` — the layered view + * and `/diff` — a caller who may write the app is served it as stored, + * unpruned, and every other caller the pruned app; an app the plain read + * refuses WHOLE is refused either way. See `MetaReadGatePolicy.app`. * - `dashboard` — ADR-0057 D10 {@link filterDashboardForUser}. A * per-DEPLOYMENT gate (which optional services are registered), never * per-caller, so `arms: 'per-caller'` skips it. @@ -1219,6 +1236,8 @@ export function createMetaItemReadGate( serviceGate?: (n: string) => boolean; servabilityGate?: NavServabilityGate; docAudienceGate?: NavDocAudienceGate; + /** [ruling 5856774816] Serve the stored app unpruned — see `MetaReadGatePolicy.app`. */ + authorExempt: boolean; }; let inputs: Promise | undefined; const resolveInputs = (): Promise => (inputs ??= (async () => { @@ -1238,15 +1257,20 @@ export function createMetaItemReadGate( // #4722 lesson, one gate over). servabilityGate = await resolveNavServability(sources) ?? undefined; } + // [#20156] Ruling 5856774816: on a door that honours the author + // exemption, a caller who may write the app reads it whole. The + // door answers `mayWriteItem` from its own save door's admission, + // so this is the caller's property, never the route's. + const authorExempt = policy.app === 'author-exempt' && ctx.mayWriteItem === true; // [#19790] And the same docs-audience gate, for the same reason: // a `doc` entry the list route prunes must not come back here. - // Not resolved under `pending-decision`: it only prunes ENTRIES, - // and that arm serves the stored app unpruned (see below), so - // its reads would decide nothing. - const docAudienceGate = policy.app === 'gate' - ? await resolveNavDocAudience(sources, documents) - : undefined; - return { sysPerms, serviceGate, servabilityGate, docAudienceGate }; + // Not resolved for an exempt author: it only prunes ENTRIES, and + // an exempt author is served the stored app unpruned (see below), + // so its reads would decide nothing. + const docAudienceGate = authorExempt + ? undefined + : await resolveNavDocAudience(sources, documents); + return { sysPerms, serviceGate, servabilityGate, docAudienceGate, authorExempt }; })()); return async (document) => { if (document == null) return serve(document); @@ -1276,12 +1300,12 @@ export function createMetaItemReadGate( // construction (ADR-0045 §3). return refuse({ reason: 'absent' }); } - // ⚠️ A DECLARED EXEMPTION, pending the maintainer's decision - // (anchor #20156) — see `MetaReadGatePolicy.app`. An app the - // plain read refuses WHOLE was refused above, exactly as the - // plain read refuses it; one it would serve only in PART is - // served as STORED, unpruned, exactly as before this gate. - return serve(policy.app === 'pending-decision' ? document : gated.app); + // [#20156] Ruling 5856774816 — see `MetaReadGatePolicy.app`. An + // app the plain read refuses WHOLE was refused above, author or + // not. Otherwise an exempt author reads the STORED app (whoever + // may save it must see all of it, or the save deletes what was + // withheld), and every other caller the plain read's pruned app. + return serve(gateInputs.authorExempt ? document : gated.app); }; } diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3fcdadce314..5942f7eb57d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -67,6 +67,7 @@ import { // organization. One predicate for every `/meta` item write door on both // transports — never a REST-local restatement. metaWriteCapabilityVerdict, + type MetaWriteCapabilityVerdict, } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; // [#6877] Query-parameter multiplicity. `IHttpRequest.query` declares @@ -166,6 +167,7 @@ import type { MetaItemReadGateSources, MetaItemReadRefusal, MetaReadGateAudienceSources, + MetaReadGateCaller, MetaReadGateListSource, MetaReadGatePolicy, NavDocAudienceGate, @@ -3245,7 +3247,8 @@ export class RestServer { policy: MetaReadGatePolicy, ): (document: any) => Promise { const judge = metaReadGate.createMetaItemReadGate( - this.metaItemReadGateSources(environmentId, req, p), metaType, name, documents, policy, + this.metaItemReadGateSources(environmentId, req, p, policy.app === 'author-exempt'), + metaType, name, documents, policy, ); return async (document) => { const verdict = await judge(document); @@ -3286,26 +3289,52 @@ export class RestServer { * caller from {@link resolveExecCtx} (an authz-store outage re-raised, * never read as anonymous), the protocol's list read, the security service * provider, the service probe, and this instance's prune-log dedupe. + * + * `withItemWriteVerdict` — [#20156] the door's policy honours the author + * exemption, so the caller carries its `mayWriteItem` (see + * {@link metaReadAudienceSources}). */ private metaItemReadGateSources( environmentId: string | undefined, req: any, p: RestProtocol, + withItemWriteVerdict = false, ): MetaItemReadGateSources { return { - ...this.metaReadAudienceSources(environmentId, req), + ...this.metaReadAudienceSources(environmentId, req, withItemWriteVerdict), ...this.metaListSource(p, environmentId), serviceProbe: (caller) => this.serviceProbeFor((caller as any)?.__kernel), navPruneLogged: this.navPruneLogged, }; } - /** [#20193] The caller half of {@link metaItemReadGateSources}. */ - private metaReadAudienceSources(environmentId: string | undefined, req: any): MetaReadGateAudienceSources { + /** + * [#20193] The caller half of {@link metaItemReadGateSources}. + * + * [#20156] `withItemWriteVerdict`: the caller also carries + * `mayWriteItem`, THIS transport's save-door admission of `:type/:name` + * ({@link metaSaveVerdict}, the one spelling `PUT /meta/:type/:name` + * asks), for a door whose policy honours ruling 5856774816's author + * exemption. Only there: a pure verdict over the context this read has + * already resolved (memoised per request) and the static type registry, so + * it costs no read — but a door that does not honour it is not handed one. + * The context is COPIED, never written: the same memoised object serves + * every other consumer of this request. + */ + private metaReadAudienceSources( + environmentId: string | undefined, + req: any, + withItemWriteVerdict = false, + ): MetaReadGateAudienceSources { + let withVerdict: MetaReadGateCaller | undefined; return { resolveCaller: async () => { const caller = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - return caller; + if (!withItemWriteVerdict || !caller) return caller; + return (withVerdict ??= { + ...caller, + mayWriteItem: RestServer.metaSaveVerdict(caller, req.params.type).allowed, + }); }, resolveSecurityService: async () => ( this.securityServiceProvider ? this.securityServiceProvider(environmentId) : undefined @@ -3411,13 +3440,49 @@ export class RestServer { return { from, to }; } + /** + * [#20156] A `diffMetaItem` answer whose EMITTED values are read off the + * two sides as {@link metaItemReadGate} served them ({@link diffSides} + * rebuilt, then judged): `added` from `to`, `removed` from `from`, + * `changed` from both. For a side the gate serves as given this is the + * answer unchanged; for an app side it serves pruned (ruling 5856774816: + * a caller who may open the app but not write it), the entries the plain + * read withholds from that caller leave with neither value. + * + * The comparison stays the protocol's, over the stored bodies, and no + * entry is added or dropped — only values are substituted. That is the + * redaction precedent one frame down (`diffMetaItem`: diff raw, emit the + * projected values, ruling 5299845282 option B) and the ADR-0106 mask + * beside this call, so the three projections of one answer agree on its + * shape. + */ + private static diffEmittedFrom( + diff: any, + from: Record, + to: Record, + ): any { + if (!diff || typeof diff !== 'object') return diff; + const emit = (bucket: unknown, project: (e: any) => any): unknown => (Array.isArray(bucket) + ? bucket.map((e) => (typeof e?.path === 'string' ? project(e) : e)) + : bucket); + return { + ...diff, + ...('added' in diff ? { added: emit(diff.added, (e) => ({ ...e, value: to[e.path] })) } : {}), + ...('removed' in diff ? { removed: emit(diff.removed, (e) => ({ ...e, value: from[e.path] })) } : {}), + ...('changed' in diff + ? { changed: emit(diff.changed, (e) => ({ ...e, from: from[e.path], to: to[e.path] })) } + : {}), + }; + } + /** * [#20156] Does {@link metaItemReadGate} judge this type per caller? The * question a door serving no document of its own asks before it fetches the * current one — a type the answer is no for costs that door no extra read * and no new failure mode. `dashboard` is never judged per caller (its gate - * answers per deployment). `app` always is: even its `pending-decision` arm - * answers the plain read's refusal of an app refused WHOLE. + * answers per deployment). `app` always is: an app the plain read refuses + * WHOLE is refused on every door, to an author as to anyone (ruling + * 5856774816), and a non-author is served it pruned. */ private static gatesPerCaller(metaType: string): boolean { return metaType === 'book' || metaType === 'doc' || metaType === 'app'; @@ -3426,13 +3491,46 @@ export class RestServer { /** * [#20156] The policy of the doors that serve STORED versions for authoring * — the layered view (`/layers`, `?layers=`) and `/diff`. One constant, so - * the two cannot come to disagree about the pending `app` cells. + * the two cannot come to disagree about the `app` row. + * + * `app: 'author-exempt'` — ruling 5856774816 (letter B, confirmed + * 5856866273): a caller who may write the app ({@link metaSaveVerdict}, + * carried on the caller as `mayWriteItem`) reads the full stored version, + * and every other caller who may open the app reads exactly what the plain + * read gives them, pruned. See `MetaReadGatePolicy.app`. */ private static readonly STORED_VERSION_DOOR_POLICY: MetaReadGatePolicy = Object.freeze({ arms: 'per-caller', - app: 'pending-decision', + app: 'author-exempt', }); + /** + * [#12702 · #20156] May this caller SAVE `:type/:name`? The admission of + * `PUT /meta/:type/:name`, spelled ONCE: that door asks it, and so does the + * stored-version read doors' author exemption (ruling 5856774816, item 1: + * 「whoever can save it must see it whole, or a save drops entries + * silently」). A second spelling of the question at the read doors could + * drift from the door it stands for — an exempt reader the save refuses, or + * a saver the exemption prunes, which is the silent drop itself. + * + * The whole admission is this verdict: the save door refuses on nothing + * else about the CALLER before `saveMetaItem` (whose own refusals judge the + * item and the scope, the same for every admitted caller). `rawType` is + * the URL segment, folded here at the boundary ([#10340]) so the verdict + * and the door's scope decision read one spelling; the organization is the + * context's `tenantId`, the very value `organizationIdForMetaWrite` + * threads. + */ + private static metaSaveVerdict(ctx: any, rawType: string): MetaWriteCapabilityVerdict { + return metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + canonicalType: canonicalMetaUrlType(rawType), + activeOrganizationId: ctx?.tenantId, + operation: 'save', + }); + } + /** * Probe which `requiresService` capability gates referenced anywhere in * `items` are actually registered — `resolveRegisteredServices` in @@ -3828,24 +3926,33 @@ export class RestServer { // the plain read's own), then `code` and `overlay`: a layer the caller // may not read is not served beside one they may. `per-caller` because // these are STORED versions, loaded by Studio's designer and saved - // back; an app the caller may see only in PART is `pending-decision` - // — see `MetaReadGatePolicy.app`. + // back. + // + // [#20156] Each layer is SERVED as the gate serves it, never as + // stored: ruling 5856774816 — a caller who may write an app reads + // every layer whole, and any other caller who may open it reads each + // layer pruned, exactly as the plain read prunes it (see + // `MetaReadGatePolicy.app`). Every layer is judged before any is + // replaced, so a refusal sends nothing of the others. { const metaType = RestServer.metaTypeSingular(req.params.type); const present = (['effective', 'code', 'overlay'] as const) - .map((layer) => (layered as any)?.[layer]) - .filter((document) => document != null); + .filter((layer) => (layered as any)?.[layer] != null); const judge = this.metaItemReadGate( - environmentId, req, p, metaType, req.params.name, present, + environmentId, req, p, metaType, req.params.name, + present.map((layer) => (layered as any)[layer]), RestServer.STORED_VERSION_DOOR_POLICY, ); - for (const document of present) { - const verdict = await judge(document); + const served = new Map<(typeof present)[number], unknown>(); + for (const layer of present) { + const verdict = await judge((layered as any)[layer]); if (verdict.kind === 'refuse') { verdict.send(res); return; } + served.set(layer, verdict.document); } + for (const [layer, document] of served) (layered as any)[layer] = document; } // [ADR-0106 D5(4)] The layered view is a schema-bearing exit — // `code`, `overlay` and `effective` are each a full object schema. @@ -6947,10 +7054,12 @@ export class RestServer { // {@link metaItemReadGate}, with their history, so a // door cannot serve this document past a gate this // read applies. This read runs every arm and serves a - // partly-withheld app PRUNED — the answer the census - // in `meta-alternate-door-read-gates.test.ts` holds - // each door to, less the partly-withheld app cells it - // declares pending a decision (see + // partly-withheld app PRUNED, to every caller, its + // authors included (read-to-display is per user) — + // the answer the census in + // `meta-alternate-door-read-gates.test.ts` holds each + // door to, save the stored-version doors' author + // exemption (ruling 5856774816, see // `MetaReadGatePolicy.app`). // // [plural-spelling commit 83a3b1f2e] (the original @@ -7122,17 +7231,16 @@ export class RestServer { // very value `organizationIdForMetaWrite` threads below, so // an admitted write can only land org-scoped in the caller's // own partition: never env-wide, never another org's. + // + // [#20156] Asked through {@link metaSaveVerdict}, the ONE + // spelling the stored-version read doors' author exemption + // asks too (ruling 5856774816): whoever this door admits + // reads the stored version whole there, whatever the plain + // read withholds from them — so what they save back is + // everything that was stored. const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); { - const verdict = metaWriteCapabilityVerdict({ - isSystem: ctx?.isSystem === true, - systemPermissions: ctx?.systemPermissions, - // [#10340] Folded at the boundary — the verdict and - // the scope decision below read one spelling. - canonicalType: canonicalMetaUrlType(req.params.type), - activeOrganizationId: ctx?.tenantId, - operation: 'save', - }); + const verdict = RestServer.metaSaveVerdict(ctx, req.params.type); if (!verdict.allowed) { res.status(403).json({ error: { @@ -8209,19 +8317,29 @@ export class RestServer { // side the caller may not read is not served beside one // they may. `per-caller`, the stored-version doors' policy — // see `MetaReadGatePolicy`. + // + // [#20156] And each side is EMITTED as the gate serves it: + // ruling 5856774816 — a caller who may write an app reads + // both sides whole, and any other caller who may open it + // reads each side pruned, exactly as the plain read prunes + // it ({@link diffEmittedFrom}). + let served: any = result; if (diffGated) { const { from, to } = RestServer.diffSides(diffCurrent, result); const judge = this.metaItemReadGate( environmentId, req, p, diffMetaType, req.params.name, [diffCurrent, from, to], RestServer.STORED_VERSION_DOOR_POLICY, ); + const sides: any[] = []; for (const side of [diffCurrent, from, to]) { const verdict = await judge(side); if (verdict.kind === 'refuse') { verdict.send(res); return; } + sides.push(verdict.document); } + served = RestServer.diffEmittedFrom(result, sides[1], sides[2]); } // [ADR-0106 D5(4)] The object mask on the one key it // projects, `fields`, in every bucket and on both sides — @@ -8229,8 +8347,7 @@ export class RestServer { // are compared raw, the EMITTED values are the projected // ones), so a field the caller may not read is not served // as a diff value either. - let served: any = result; - if (diffMaskPosture.kind === 'project' && result && typeof result === 'object') { + if (diffMaskPosture.kind === 'project' && served && typeof served === 'object') { let faulted = false; const maskFields = (value: unknown): unknown => { if (faulted || !value || typeof value !== 'object' || Array.isArray(value)) return value; @@ -8245,10 +8362,10 @@ export class RestServer { const bucket = (list: unknown, keys: readonly string[]) => (Array.isArray(list) ? list.map((e) => onFields(e, keys)) : list); served = { - ...(result as Record), - added: bucket((result as any).added, ['value']), - removed: bucket((result as any).removed, ['value']), - changed: bucket((result as any).changed, ['from', 'to']), + ...(served as Record), + added: bucket((served as any).added, ['value']), + removed: bucket((served as any).removed, ['value']), + changed: bucket((served as any).changed, ['from', 'to']), }; // `maskObjectDocument` already answered the ADR-0106 D6 // 5xx: a side projected to NO fields is never served. From 1bc5bc470f498912b5eff04e415f31c65c4fde8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:15:04 +0000 Subject: [PATCH 2/5] test(rest): the census holds the ruled author exemption on the stored-version doors Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .../meta-alternate-door-read-gates.test.ts | 303 +++++++++++++----- 1 file changed, 215 insertions(+), 88 deletions(-) diff --git a/packages/rest/src/meta-alternate-door-read-gates.test.ts b/packages/rest/src/meta-alternate-door-read-gates.test.ts index b1aaa717e5c..b1e2b69f5df 100644 --- a/packages/rest/src/meta-alternate-door-read-gates.test.ts +++ b/packages/rest/src/meta-alternate-door-read-gates.test.ts @@ -27,18 +27,20 @@ * * For `app` they refuse, like every other door, an app the plain read * refuses WHOLE (an app-level `requiredPermissions` the caller lacks; an - * unpublished app to a non-builder, ADR-0045 §3). + * unpublished app to a non-builder, ADR-0045 §3) — to an author as to + * anyone. * - * ⚠️ **Their PARTIAL `app` cells are a DECLARED PENDING-DECISION - * EXEMPTION** (decision anchor #20156, `PENDING_DECISION` below): a caller - * the plain read would serve only PART of an app. Both ways of pulling those - * cells to the plain read's answer draw a new permission boundary, which is - * the maintainer's to rule: a PRUNED stored app is deleted entries once - * Studio's designer saves back what it loaded, and a REFUSED one locks out - * an author — a platform admin included — who lacks one entry's permission. - * So exactly those cells answer what they answered before this card (the - * stored app, unpruned), and this file pins that, under the anchor, so the - * exemption stays loud and cannot widen until it is ruled. + * **The author exemption** (ruling 5856774816, letter B, confirmed + * 5856866273 — `AUTHOR_EXEMPTION` below): on these three doors a caller + * who may WRITE the app — the one the app's save door admits — reads the + * full stored version, and every other caller who may open it reads + * exactly what the plain read gives them, pruned. ADR-0106 D4's shape + * carried from object schemas to apps: read-to-display is pruned per + * user, read-to-edit is whole for whoever may edit, because Studio's + * designer saves back what it loaded and a pruned load would delete the + * withheld entries. The exemption is a CALLER property, honoured by these + * three doors and by no other: the plain read and `/published` still + * prune for an author. * - **`/history` and `/audit` serve events, never a body**: they refuse where * the plain read refuses the item whole, and otherwise serve the events. * - **`/references`** is declared exempt: it serves the identities of OTHER @@ -149,22 +151,41 @@ const previousVersionOf = (type: string, doc: any): any => // ── Callers ─────────────────────────────────────────────────────────────────── interface Caller { - ctx?: { userId: string; systemPermissions: string[] }; + ctx?: { userId: string; systemPermissions: string[]; tenantId?: string }; holdings: string[]; readableFields: string[]; + /** + * Does `PUT /meta/app/:name` admit this caller? DECLARED here and checked + * against the save door itself below — the author exemption's predicate is + * that door's answer (ruling 5856774816, item 1), so the census cannot + * call a caller an author the save door refuses, or the reverse. + */ + savesApps: boolean; } -const CALLERS: Record<'reader' | 'non-reader' | 'anonymous', Caller> = { +const CALLERS: Record<'reader' | 'non-reader' | 'author' | 'anonymous', Caller> = { reader: { ctx: { userId: 'u_reader', systemPermissions: ['finance.access', 'payroll.access', 'studio.access'] }, holdings: ['crm_admin'], readableFields: ['amount', 'secret_margin'], + savesApps: false, }, 'non-reader': { ctx: { userId: 'u_member', systemPermissions: [] }, holdings: [], readableFields: ['amount'], + savesApps: false, }, - anonymous: { holdings: [], readableFields: [] }, + // An author the plain read serves only PART of `crm`: they may write + // metadata, but hold neither `finance.access` nor `crm_admin`. And no + // builder capability either, so the unpublished `launchpad` stays + // invisible to them (ADR-0045 §3 — the exemption lifts no whole refusal). + author: { + ctx: { userId: 'u_author', systemPermissions: ['manage_metadata'] }, + holdings: [], + readableFields: ['amount'], + savesApps: true, + }, + anonymous: { holdings: [], readableFields: [], savesApps: false }, }; type CallerName = keyof typeof CALLERS; @@ -205,8 +226,8 @@ function shallowDiff(from: Record, to: Record) { return { added, removed, changed }; } -function setup(callerName: CallerName) { - const caller = CALLERS[callerName]; +function setup(who: CallerName | Caller) { + const caller = typeof who === 'string' ? CALLERS[who] : who; const protocol: any = { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), getMetaTypes: vi.fn().mockResolvedValue([]), @@ -243,6 +264,8 @@ function setup(callerName: CallerName) { const from = found ? previousVersionOf(t, clone(found)) : {}; return { type: t, name, fromVersion: 1, toVersion: 2, ...shallowDiff(from, to) }; }), + // The save door's protocol call — reached only past its admission. + saveMetaItem: vi.fn(async ({ type, name }: any) => ({ success: true, type: singular(type), name })), findReferencesToMeta: vi.fn(async () => ({ references: [] })), findData: vi.fn().mockResolvedValue([]), }; @@ -275,6 +298,16 @@ async function drive(rest: any, suffix: string, type: string, name: string, quer return res; } +/** `PUT /meta/:type/:name` — the save door whose admission IS the author exemption's predicate. */ +async function save(rest: any, type: string, name: string, item: any) { + const path = `${META}/:type/:name`; + const route = rest.getRoutes().find((r: any) => r.method === 'PUT' && r.path === path); + if (!route) throw new Error(`PUT ${path} is not registered`); + const res = makeRes(); + await route.handler({ method: 'PUT', path: `${META}/${type}/${name}`, params: { type, name }, query: {}, body: item, headers: {} }, res); + return res; +} + /** The ADR-0112 minimum: the status and the machine code, never the prose. */ const envelope = (res: any) => ({ status: res.statusCode, code: res.body?.code ?? res.body?.error?.code }); const text = (res: any): string => JSON.stringify(res.body ?? null); @@ -299,34 +332,31 @@ const DOORS: Record = { }; /** - * ⚠️ THE DECLARED PENDING-DECISION EXEMPTION — decision anchor #20156. + * THE AUTHOR EXEMPTION — ruling 5856774816 (letter B, confirmed 5856866273). * - * The PARTIAL `app` cells of the stored-version doors: a caller the plain read - * would serve only part of an app (`plain` answer `pruned`). Pulling those - * cells to the plain read's answer draws a NEW permission boundary either way - * (a pruned stored app is deleted entries once the designer saves back; a - * refused one locks out an author, a platform admin included, who lacks one - * entry's permission), and a new boundary is the maintainer's to rule. Until - * it is, these cells answer exactly what they answered before the gate - * existed — the stored app, unpruned — and the census pins THAT, under this - * anchor: when the ruling lands, they go red and are rewritten deliberately, - * never drifted into. + * 「On the three stored-version doors (`/layers`, `?layers=true`, `/diff`) a + * caller who may write the app reads the full stored version; every other + * caller who may open the app reads exactly what the plain read gives them, + * pruned.」 ADR-0106 D4's shape, carried from object schemas to apps: the + * exemption is a CALLER property ("may write" = the save door admits them, + * `savesApps` above, checked against that door below), and these three doors + * are the only ones that honour it. * - * ⛔ An app the plain read refuses WHOLE is NOT in the exemption: that refusal - * is the plain read's own answer, and every door gives it. A test below holds - * the exemption to exactly the `pruned` answer, on exactly these three doors, - * and to the exact cells that yields — so widening it to a whole-refusal cell, - * or to a fourth door, fails that test rather than passing silently. + * So on an exempt cell the door owes the STORED app whole where the plain + * read would prune it — and nothing else changes: an app the plain read + * refuses WHOLE is refused there too, author or not. Tests below hold the + * exemption to exactly the author's partial `app` cells of exactly these + * three doors: widening it to a fourth door, to a rendered door, to a + * non-author, or to a whole refusal fails them rather than passing silently. */ -const PENDING_DECISION = Object.freeze({ - anchor: '#20156', +const AUTHOR_EXEMPTION = Object.freeze({ + ruling: '5856774816', type: 'app', doors: Object.freeze(['?layers=true', '/layers', '/diff']), - answer: 'pruned' as PlainAnswer['kind'], }); -const isPendingDecision = (type: string, doorName: string, want: { kind: string }): boolean => - type === PENDING_DECISION.type && PENDING_DECISION.doors.includes(doorName) - && want.kind === PENDING_DECISION.answer; +const authorExempt = (type: string, doorName: string, callerName: CallerName): boolean => + type === AUTHOR_EXEMPTION.type && AUTHOR_EXEMPTION.doors.includes(doorName) + && CALLERS[callerName].savesApps; // ── The subjects, and what the plain read answers each caller ──────────────── @@ -360,40 +390,52 @@ interface Subject { const SUBJECTS: Subject[] = [ { type: 'doc', name: 'crm_admin_runbook', secrets: [DOC_SECRET], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, anonymous: ANON }, + plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, author: { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, anonymous: ANON }, }, { type: 'doc', name: 'crm_intro', secrets: [], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'whole' }, anonymous: ANON }, + plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'whole' }, author: { kind: 'whole' }, anonymous: ANON }, }, { type: 'book', name: 'admin_guide', secrets: [BOOK_SECRET], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, anonymous: ANON }, + plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, author: { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, anonymous: ANON }, }, { type: 'app', name: 'crm', secrets: ['nav_finance_ledger', 'nav_admin_runbook'], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'pruned' }, anonymous: ANON }, + // The plain read prunes for the AUTHOR too: read-to-display is per user. + plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'pruned' }, author: { kind: 'pruned' }, anonymous: ANON }, }, { type: 'app', name: 'payroll', secrets: ['nav_payroll_runs'], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, anonymous: ANON }, + plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, author: { kind: 'refused', status: 403, code: 'PERMISSION_DENIED' }, anonymous: ANON }, }, { type: 'app', name: 'launchpad', secrets: ['nav_launchpad_home'], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'refused', status: 404, code: 'RESOURCE_NOT_FOUND' }, anonymous: ANON }, + plain: { + reader: { kind: 'whole' }, + 'non-reader': { kind: 'refused', status: 404, code: 'RESOURCE_NOT_FOUND' }, + author: { kind: 'refused', status: 404, code: 'RESOURCE_NOT_FOUND' }, + anonymous: ANON, + }, }, { type: 'dashboard', name: 'ops', secrets: [], - plain: { reader: { kind: 'deployment-pruned' }, 'non-reader': { kind: 'deployment-pruned' }, anonymous: ANON }, + plain: { + reader: { kind: 'deployment-pruned' }, + 'non-reader': { kind: 'deployment-pruned' }, + author: { kind: 'deployment-pruned' }, + anonymous: ANON, + }, }, { type: 'object', name: 'invoice', secrets: ['secret_margin'], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'masked' }, anonymous: ANON }, + // ADR-0106 D4: whoever may write a schema reads it whole. + plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'masked' }, author: { kind: 'whole' }, anonymous: ANON }, }, // Control: a type the plain read gates for nobody. { type: 'view', name: 'all_leads', secrets: [], - plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'whole' }, anonymous: ANON }, + plain: { reader: { kind: 'whole' }, 'non-reader': { kind: 'whole' }, author: { kind: 'whole' }, anonymous: ANON }, }, ]; @@ -447,51 +489,84 @@ describe('[#20156] the plain read answers what the census declares', () => { } }); -describe('[#20156] the pending-decision exemption is declared, anchored, and no wider than the partial app cells of three doors', () => { - it(`exactly the \`pruned\` app answer, on exactly /layers, ?layers=true and /diff, anchored to ${PENDING_DECISION.anchor}`, () => { - expect(PENDING_DECISION.anchor).toBe('#20156'); - expect(PENDING_DECISION.type).toBe('app'); - // The PARTIAL answer only — never `refused`, the plain read's own. - expect(PENDING_DECISION.answer).toBe('pruned'); - // Literal, not derived from `DOORS`: a new stored-version door is gated - // by default and must be exempted by a deliberate edit here, never by - // inheriting a kind. - expect([...PENDING_DECISION.doors].sort()).toEqual(['/diff', '/layers', '?layers=true']); - for (const doorName of PENDING_DECISION.doors) expect(DOORS[doorName]?.kind, doorName).toBe('stored'); +const LAYERS = ['code', 'overlay', 'effective'] as const; +const bucketPaths = (diff: any): string[][] => + (['added', 'removed', 'changed'] as const).map((b) => (diff?.[b] ?? []).map((e: any) => e.path)); + +describe(`[#20156] ruling ${AUTHOR_EXEMPTION.ruling} — the author exemption is declared, and no wider than the author's partial app cells of three doors`, () => { + it('it names exactly /layers, ?layers=true and /diff — every stored-version door, and no other', () => { + expect(AUTHOR_EXEMPTION.ruling).toBe('5856774816'); + expect(AUTHOR_EXEMPTION.type).toBe('app'); + // Literal, not derived from `DOORS`: a door is exempted by a deliberate + // edit here, never by inheriting a kind. + expect([...AUTHOR_EXEMPTION.doors].sort()).toEqual(['/diff', '/layers', '?layers=true']); + // ...and the literal is every stored-version door: none is left + // pruning an author, none that renders is exempted. + const storedDoors = Object.entries(DOORS).filter(([, d]) => d.kind === 'stored').map(([n]) => n); + expect(storedDoors.sort()).toEqual([...AUTHOR_EXEMPTION.doors].sort()); }); - it('the cells it yields are exactly the partial app cells — no whole-refusal cell, no reader, no anonymous caller', () => { - const cells: string[] = []; - const wholeRefusals: string[] = []; + it('the cells whose answer it changes are exactly the author\'s partial app cells — no whole refusal, no other caller, no rendered door', () => { + const changed: string[] = []; + const stillRefused: string[] = []; for (const doorName of Object.keys(DOORS)) { for (const subject of SUBJECTS) { for (const callerName of Object.keys(CALLERS) as CallerName[]) { + if (!authorExempt(subject.type, doorName, callerName)) continue; const want = subject.plain[callerName]; - if (!isPendingDecision(subject.type, doorName, want)) continue; - cells.push(`${doorName} ${subject.type}/${subject.name} × ${callerName}`); - if (want.kind === 'refused') wholeRefusals.push(`${doorName} ${subject.name} × ${callerName}`); + const cell = `${doorName} ${subject.type}/${subject.name} × ${callerName}`; + if (want.kind === 'refused') stillRefused.push(cell); + else if (want.kind !== 'whole') changed.push(cell); } } } - expect(wholeRefusals).toEqual([]); - expect(cells.sort()).toEqual([ - '/diff app/crm × non-reader', - '/layers app/crm × non-reader', - '?layers=true app/crm × non-reader', + expect(changed.sort()).toEqual([ + '/diff app/crm × author', + '/layers app/crm × author', + '?layers=true app/crm × author', + ]); + // The whole refusals it does NOT lift — each is held to the plain + // read's refusal by its census row below. + expect(stillRefused.sort()).toEqual([ + '/diff app/launchpad × author', + '/diff app/payroll × author', + '/layers app/launchpad × author', + '/layers app/payroll × author', + '?layers=true app/launchpad × author', + '?layers=true app/payroll × author', ]); }); + + it('its predicate is the save door\'s answer: `savesApps` is exactly who `PUT /meta/app/crm` admits', async () => { + for (const callerName of Object.keys(CALLERS) as CallerName[]) { + const { rest, protocol } = setup(callerName); + const res = await save(rest, 'app', 'crm', clone(CRM_APP)); + if (CALLERS[callerName].savesApps) { + expect(res.statusCode, callerName).toBe(200); + expect(protocol.saveMetaItem, callerName).toHaveBeenCalledTimes(1); + } else { + expect(envelope(res), callerName).toEqual( + CALLERS[callerName].ctx + ? { status: 403, code: 'FORBIDDEN' } + : { status: 401, code: 'UNAUTHENTICATED' }, + ); + expect(protocol.saveMetaItem, callerName).not.toHaveBeenCalled(); + } + } + }); }); -describe('[#20156] every alternate door answers what the plain read answers, or refuses — save the declared partial app cells', () => { +describe(`[#20156] every alternate door answers what the plain read answers, or refuses — save the author exemption of ruling ${AUTHOR_EXEMPTION.ruling}`, () => { for (const [doorName, door] of Object.entries(DOORS)) { if (door.kind === 'exempt') continue; describe(doorName, () => { for (const subject of SUBJECTS) { for (const callerName of Object.keys(CALLERS) as CallerName[]) { const want = subject.plain[callerName]; - const pending = isPendingDecision(subject.type, doorName, want); + // A whole refusal is refused to an author as to anyone. + const exempt = authorExempt(subject.type, doorName, callerName) && want.kind !== 'refused'; const title = `${subject.type}/${subject.name} × ${callerName}` - + (pending ? ` — PENDING-DECISION ${PENDING_DECISION.anchor}: the stored app, unpruned, as before the gate` : ''); + + (exempt ? ` — AUTHOR, ruling ${AUTHOR_EXEMPTION.ruling}: the full stored version` : ''); it(title, async () => { const { rest, protocol } = setup(callerName); const plain = await drive(rest, '', subject.type, subject.name); @@ -499,22 +574,16 @@ describe('[#20156] every alternate door answers what the plain read answers, or const res = await drive(rest, door.suffix, subject.type, subject.name, door.query); const stored = find(subject.type, subject.name); - if (pending) { - // The pre-gate ANSWER, pinned under the anchor — it - // passes on the pre-gate code too, by design. LOUD by - // construction: the entries the plain read withholds from - // this caller ARE in this body — the exposure the - // decision waits on. That the whole-app gate still runs - // on these doors is pinned by the whole-refusal cells - // (`payroll`, `launchpad`), not here. + if (exempt) { + // A caller who may write the app reads what is STORED — + // every entry the plain read withholds from them + // included, so a save of what they loaded keeps them. expect(res.statusCode).toBe(200); for (const s of subject.secrets) expect(text(res)).toContain(s); if (door.suffix === '/diff') { expect(res.body).toEqual(await protocol.diffMetaItem.mock.results.at(-1)?.value); } else { - for (const layer of ['code', 'overlay', 'effective']) { - expect(res.body?.[layer]).toEqual(stored); - } + for (const layer of LAYERS) expect(res.body?.[layer]).toEqual(stored); } return; } @@ -545,14 +614,29 @@ describe('[#20156] every alternate door answers what the plain read answers, or return; } - // door.kind === 'stored' — the `pruned` answer belongs to - // the partial app cells alone, which are pending above. - expect(want.kind).not.toBe('pruned'); + // door.kind === 'stored' expect(res.statusCode).toBe(200); + if (want.kind === 'pruned') { + // Every other caller who may open the app reads exactly + // what the plain read gives them, pruned — on every + // layer, and on both sides of a diff, whose entries + // are kept and whose VALUES are the pruned ones. + for (const s of subject.secrets) expect(text(res)).not.toContain(s); + const expected = navIds(plainItem(plain)); + if (door.suffix === '/diff') { + const raw = await protocol.diffMetaItem.mock.results.at(-1)?.value; + expect(bucketPaths(res.body)).toEqual(bucketPaths(raw)); + const navigation = res.body?.added?.find((e: any) => e.path === 'navigation')?.value; + expect(navIds({ navigation })).toEqual(expected); + } else { + for (const layer of LAYERS) expect(navIds(res.body?.[layer])).toEqual(expected); + } + return; + } if (want.kind === 'masked') { for (const s of subject.secrets) expect(text(res)).not.toContain(s); if (door.suffix !== '/diff') { - for (const layer of ['code', 'overlay', 'effective']) { + for (const layer of LAYERS) { expect(fieldNames(res.body?.[layer])).toEqual(fieldNames(plainItem(plain))); } } @@ -566,7 +650,7 @@ describe('[#20156] every alternate door answers what the plain read answers, or // whole for (const s of subject.secrets) expect(text(res)).toContain(s); if (door.suffix !== '/diff') { - for (const layer of ['code', 'overlay', 'effective']) { + for (const layer of LAYERS) { expect(res.body?.[layer]).toEqual(stored); } } @@ -622,6 +706,49 @@ describe('[#20156] edges', () => { expect(plainApp.statusCode).toBe(200); expect(navIds(published.body)).toEqual(navIds(plainItem(plainApp))); expect(navIds(published.body)).not.toContain('nav_admin_runbook'); + // [ruling 5856774816] And on a stored-version door, to a caller who + // may not write the app (this reader holds `crm_admin` but not + // `manage_metadata`): the same composite answer, never the stored app. + const layers = await drive(rest, '/layers', 'app', 'crm'); + expect(layers.statusCode).toBe(200); + for (const layer of LAYERS) expect(navIds(layers.body?.[layer]), layer).toEqual(navIds(plainItem(plainApp))); + }); + + it('[ruling 5856774816] "may write" is the save door\'s WHOLE question, not a capability name: an org-scoped presentation author writes views, not apps, so the app is pruned for them', async () => { + // `manage_org_presentation` admits a save only of an org-overridable + // type, scoped to the session's own organization — `app` is not one. + const presenter: Caller = { + ctx: { userId: 'u_presenter', systemPermissions: ['manage_org_presentation'], tenantId: 'org_1' }, + holdings: [], + readableFields: ['amount'], + savesApps: false, + }; + const { rest, protocol } = setup(presenter); + + // The control: the same caller IS a writer, of a type they may write. + const view = await save(rest, 'view', 'all_leads', clone(LEADS_VIEW)); + expect(view.statusCode).toBe(200); + expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); + // The app's save door refuses them... + const app = await save(rest, 'app', 'crm', clone(CRM_APP)); + expect(envelope(app)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); + // ...so every stored-version door serves them the plain read's pruned app. + const plain = await drive(rest, '', 'app', 'crm'); + const expected = navIds(plainItem(plain)); + expect(expected).not.toEqual(navIds(CRM_APP)); + for (const doorName of AUTHOR_EXEMPTION.doors) { + const door = DOORS[doorName]; + const res = await drive(rest, door.suffix, 'app', 'crm', door.query); + expect(res.statusCode, doorName).toBe(200); + for (const s of ['nav_finance_ledger', 'nav_admin_runbook']) expect(text(res), doorName).not.toContain(s); + if (door.suffix === '/diff') { + const navigation = res.body?.added?.find((e: any) => e.path === 'navigation')?.value; + expect(navIds({ navigation }), doorName).toEqual(expected); + } else { + for (const layer of LAYERS) expect(navIds(res.body?.[layer]), `${doorName} ${layer}`).toEqual(expected); + } + } }); it('a gated type with nothing behind the name: /diff answers the plain read\'s absence, /history its events', async () => { From eb162852031c45dc35acd09c546fe7c4d7478721 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:34:30 +0000 Subject: [PATCH 3/5] docs(rest): the ruled author exemption in apps.mdx and the pending release notes Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .changeset/20156-alternate-door-read-gates.md | 4 +-- .changeset/20156-app-author-exemption.md | 13 ++++++++++ content/docs/ui/apps.mdx | 26 ++++++++++--------- 3 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 .changeset/20156-app-author-exemption.md diff --git a/.changeset/20156-alternate-door-read-gates.md b/.changeset/20156-alternate-door-read-gates.md index 8c84cccab50..2b347d6861e 100644 --- a/.changeset/20156-alternate-door-read-gates.md +++ b/.changeset/20156-alternate-door-read-gates.md @@ -13,7 +13,7 @@ What each door answers now: - `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read. - `/layers`, `?layers=true` and `/diff` refuse a `doc`, `book` or `app` the plain read refuses whole, with the same status and code. For an app, that means one whose `requiredPermissions` the caller lacks (`403`) or an unpublished app to a non-builder (`404`). They mask object fields as the plain read does. `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits. -- **One case is unchanged in this release: an app the plain read would serve a caller only in part.** That is an app the caller may open, but with navigation entries withheld from them by `requiredPermissions` or the documentation audience. On `/layers`, `?layers=true` and `/diff`, such a caller still receives the stored app with those entries included, as before. No other caller does: an app the plain read refuses whole is refused on these doors too. What these doors should answer a partial caller is pending a decision, because both ways of matching the plain read restrict authors. Pruning the stored app would delete the withheld entries when Studio's designer saves back what it loaded. Refusing it would lock out any author who lacks one entry's permission, platform admins included. +- For an app the caller may open, `/layers`, `?layers=true` and `/diff` answer by who is asking. A caller who may save the app (the one `PUT /meta/app/:name` admits) receives the full stored version, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries. Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff. The plain read and `/published` prune for every caller, authors included. An app the plain read refuses whole is refused on these doors to an author too. - `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body. -Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except the `app` row of `/layers`, `?layers=true` and `/diff` for a caller the plain read serves only part of the app. To read a gated doc or book through any of these doors, hold the permission set its book names. +Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except that a caller who may save an app reads it whole on `/layers`, `?layers=true` and `/diff`. To read a gated doc or book through any of these doors, hold the permission set its book names. diff --git a/.changeset/20156-app-author-exemption.md b/.changeset/20156-app-author-exemption.md new file mode 100644 index 00000000000..af2107eebdc --- /dev/null +++ b/.changeset/20156-app-author-exemption.md @@ -0,0 +1,13 @@ +--- +"@objectstack/rest": patch +--- + +**The stored-version doors of an app answer by who is asking: whoever may save the app reads it whole, everyone else reads it pruned.** `GET /api/v1/meta/app/:name/layers`, the deprecated `?layers=true` and `…/diff` serve the versions Studio's designer loads and saves back. Until now they served an app the caller may open as stored to every such caller, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them, which exposed those entries' names and targets to members the plain read hides them from. + +- A caller who may save the app receives the full stored version on these three doors, so a designer that saves back what it loaded keeps every entry. "May save" is exactly what `PUT /meta/app/:name` admits that caller: a system context or `manage_metadata`. `manage_org_presentation` does not save apps, so it does not qualify. +- Every other caller who may open the app receives it without the entries `requiredPermissions` or the documentation audience withhold from them, left out as the plain read leaves them out: on each layer, and in the values on both sides of a diff. A diff keeps all of its entries; only their values are pruned. +- Unchanged: the plain read and `/published` still prune for every caller, authors included. An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on every door, to an author too. `/history`, `/audit`, and the doc, book and dashboard answers do not change. + +A client that reads these doors as a non-author now receives fewer navigation entries. To read an app's full stored version there, read it as a caller the app's save door admits. + +For code that runs the shared read gate: `MetaReadGatePolicy.app` is `'gate'` or `'author-exempt'`, and a door that passes `'author-exempt'` supplies the caller's save verdict as `MetaReadGateCaller.mayWriteItem`. diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 9ddfcee1e25..e170d96f9bc 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -357,18 +357,20 @@ collapses; an area *authored* empty is passed through untouched, because filtering reports what the caller may not see rather than tidying the metadata. The single-app reads — `GET /api/v1/meta/app/` and its `/published` -twin — serve that filtered body, and every door refuses an app the caller may -not open: one whose app-level `requiredPermissions` they lack (`403`), or an -unpublished app to a non-builder (`404`). The doors that serve an app's -*stored* versions for authoring — the layered view (`/layers`, and the -deprecated `?layers=true`) and `/diff` — do **not** yet prune the entries of an -app the caller may open: a caller from whom `requiredPermissions` or the docs -audience withholds some entries still receives the stored app there, those -entries included. What these doors should answer that caller is pending a -decision. Pruning a stored app the designer saves back would delete the -withheld entries, and refusing it would lock out any author who lacks one -entry's permission, platform admins included. Until that is decided, such an -entry is absent from the app's served body but not from these authoring views. +twin — serve that filtered body to every caller, the app's authors included, +and every door refuses an app the caller may not open: one whose app-level +`requiredPermissions` they lack (`403`), or an unpublished app to a +non-builder (`404`). The doors that serve an app's *stored* versions for +authoring — the layered view (`/layers`, and the deprecated `?layers=true`) and +`/diff` — answer by who is asking. A caller who may **save** the app (the one +`PUT /api/v1/meta/app/` admits: `manage_metadata`, or a system context) +reads the full stored version there, gated entries included, because Studio's +designer saves back what it loaded and a pruned load would delete the entries +withheld from its author. Every other caller reads it without the entries +`requiredPermissions` or the docs audience withhold from them, left out as the +single-app read leaves them out, on every layer and on both sides of a diff. +The exemption lifts no whole refusal: an app an author may not open is refused +on these doors too. `visible` did **not** move server-side with them, and that asymmetry is deliberate: CEL is evaluated in the browser because server-side evaluation needs From bad8baa632892b3f0c91c70241a43cdb06933bfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:29:17 +0000 Subject: [PATCH 4/5] docs(permissions): the save verdict's isSystem read moved into metaSaveVerdict, which the stored-version read doors ask too Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 656e0cc6320..61d0057f684 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -168,7 +168,7 @@ The largest single consumer — **17 of the 105 sites**. |:--|:---|:---|:---|:---| | 47 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `packages/runtime/src/action-execution.ts#callData` | | 48 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` | -| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` | +| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict` | | 50 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 49's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | | 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` | | 52 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | @@ -362,7 +362,7 @@ still holds equal to the census on every pull request: | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **19** | ✅ | | Files containing at least one elevation read | 44 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 87 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 88 | ✅ | | — of those files, the ones holding more than one read in one symbol | 8 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were From 9d955a369a86c13a7047d2852fba3a9bda85f728 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:05:24 +0000 Subject: [PATCH 5/5] docs(rest): cite the type fold by its landing commit, the card no longer resolves Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/rest/src/rest-server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5942f7eb57d..f01eb85b5fc 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3516,8 +3516,9 @@ export class RestServer { * The whole admission is this verdict: the save door refuses on nothing * else about the CALLER before `saveMetaItem` (whose own refusals judge the * item and the scope, the same for every admitted caller). `rawType` is - * the URL segment, folded here at the boundary ([#10340]) so the verdict - * and the door's scope decision read one spelling; the organization is the + * the URL segment, folded here at the boundary ([folded-type commit + * 26f3588fb] — see the PUT door's org-scope comment) so the verdict and + * the door's scope decision read one spelling; the organization is the * context's `tenantId`, the very value `organizationIdForMetaWrite` * threads. */