From 135184bf0d75284d1f66402c5d61132e75c967ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 22:34:38 +0000 Subject: [PATCH 1/4] fix(rest): compile the data doors' request literals against the declared protocol contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DELETE and PATCH data handlers dispatched through `p.deleteData({…} as any)` / `p.updateData({…} as any)`, which erased TypeScript's check of the assembled request against `DeleteDataRequest` / `UpdateDataRequest`. A sweep found the same erasure in two forms across 22 protocol-dispatch sites in this file, not two. The casts were load-bearing, as filed: `environmentId` and `context` are passed at these call sites and are members of no data request schema. Neither belongs in one: `environmentId` is the transport routing key already ruled out of the protocol request shape (2026-08-18, #9741), and `context` is the SERVER-DERIVED execution context whose caller-supplied form is a privilege escalation the ingress deletes unconditionally. So both are declared on a typed envelope beside the ruled `TransportScopedMetaRequest`, and every other member of every literal is now compiled against the spec contract. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/rest/src/rest-server.ts | 205 ++++++++++++++++++++++++------- 1 file changed, 159 insertions(+), 46 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index bb9dc8168d..c9aa8227b8 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -111,6 +111,22 @@ import type { HistoryMetaItemRequest, SaveMetaItemRequest, DeleteMetaItemRequest, + GetUiViewRequest, +} from '@objectstack/spec/api'; +// [#15866] The same discipline for the DATA doors. Every literal these routes +// assemble is now compiled against the declared request type through +// `ServerScopedDataRequest` below, so a member the contract does not declare is +// a compile error at the call site instead of a payload no schema has seen. +import type { + FindDataRequest, + GetDataRequest, + CreateDataRequest, + UpdateDataRequest, + DeleteDataRequest, + BatchDataRequest, + CreateManyDataRequest, + UpdateManyDataRequest, + DeleteManyDataRequest, } from '@objectstack/spec/api'; // [#8073] The closed ADR-0112 error vocabulary, so the explain family's single // refusal emitter types its `code` parameter as the vocabulary rather than as @@ -207,6 +223,76 @@ export type RestProtocol = DataProtocol & MetadataProtocol; * literals is now a compile error instead of a cast-and-hope. */ type TransportScopedMetaRequest = R & { environmentId?: string }; + +/** + * [#15866] The DATA doors' sibling of {@link TransportScopedMetaRequest}, and + * the reason it is a SECOND alias rather than a widening of the first: the data + * routes layer on TWO server-side members, and only one of them is the meta + * doors' transport key. + * + * - `environmentId` — identical to the meta case and covered by the same + * ruling (2026-08-18, #9741): `resolveProtocol(environmentId)` picks the + * target kernel BEFORE the call, `@objectstack/metadata-protocol`'s data + * methods never read it off the request, and `protocol.zod.ts` records the + * exclusion schema-side. The doors still spread it (long-standing wire + * shape), so it is declared here rather than smuggled past the compiler. + * - `context` — the SERVER-DERIVED execution context from + * {@link RestServer.resolveExecCtx}. The implementation genuinely reads it + * (`findData`/`getData`/`createData`/`updateData`/`deleteData` all declare + * `context?: any` and forward it so the RBAC/RLS middleware can enforce), + * so unlike `environmentId` it IS consumed — but it still must not join the + * request schema, because that schema is the catalog's published + * `requestSchema` and a CALLER-supplied `context` is a privilege escalation: + * `metadata-protocol.findData` deletes any inbound `context` unconditionally + * for exactly that reason (`if (opCtx.context?.isSystem) return next()` + * skips the whole RLS/FLS/CRUD chain). Declared-here is what keeps it + * server-only AND compiled. + * + * ⛔ Never add a third member here to make a literal fit. A key a caller may + * send belongs in the spec schema; a key the door invents belongs in neither. + * What this buys is the guard #15866 was filed for: a field ADDED to + * `DeleteDataRequestSchema` / `UpdateDataRequestSchema` (and their siblings) + * as REQUIRED now reddens this file at build, naming the door that would + * otherwise have gone on not sending it. + * + * ⚠️ Scope of the restored check, stated so it is not overread: these handlers + * declare `req: any`, so every key sourced from `req` is `any` on the way in. + * What the compiler regains here is the KEY SET — an undeclared member (TS2353) + * and a missing required member (TS2739/TS2741) — not the value types of keys + * read off the request bag. + */ +type ServerScopedDataRequest = R & { environmentId?: string; context?: unknown }; + +/** + * [#15866] The ONE thing restoring the data doors' compile-time check could not + * type honestly, isolated behind a name so what stays erased is countable and + * greppable instead of diffuse — and so the next person meets the reason rather + * than a bare cast. + * + * `FindDataRequest.query` declares the AST (`QuerySchema`). But + * `@objectstack/metadata-protocol`'s `findData` ingress accepts TWO dialects + * through that one slot: the AST, and the WIRE dialect its normalizer folds — + * the bare transport spellings and the OData `$` forms (`$top`→`top`→`limit`, + * `$orderby`→`orderBy`, `filter`/`filters`/`$filter`→`where`, …). That second + * set is deliberately undeclared: the normalizer's own table calls them "the + * wire-only spellings no schema declares", and its sibling hint table is + * documented as never accepting input precisely so a second de-facto contract + * does not grow (Prime Directive #12). + * + * Three server-built literals in this file speak that wire dialect (the + * import-job listing, the export chunk loop, the public picker). ⛔ The two + * repairs this card forbids are exactly the two that would make them compile: + * widening `QuerySchema` to admit `$`-forms, and dropping back to a runtime + * `safeParse`. So the honest move is neither — it is to keep the erasure, make + * it one slot wide instead of one call wide, and hand the gap back: the + * declared-vs-shipped mismatch on this slot is a CONTRACT question, filed + * separately, not something this door may settle by itself. + * + * ⚠️ What is NOT erased at those three sites, and was before: the method name, + * the arity, and every other member of the request literal. + */ +const wireDialectQuery = (query: Record): FindDataRequest['query'] => + query as FindDataRequest['query']; import { buildFieldMetaMap, referenceFieldNames, @@ -7996,9 +8082,9 @@ export class RestServer { if (this.enforceEnvironmentOwnership(req, res, environmentId, context)) return; const p = await this.resolveProtocol(environmentId, req); if (p.getUiView) { - const view = await p.getUiView({ + const viewRequest: TransportScopedMetaRequest = { object: req.params.object, - type: req.params.type as any, + type: req.params.type, // [#13214] `routeEnvironmentId`, NOT the resolved id. // The gate above changed WHO may reach the producer; // it deliberately did not change WHAT the producer is @@ -8008,7 +8094,8 @@ export class RestServer { // the unscoped mount would be an unrelated behaviour // change riding on a security fix. ...(routeEnvironmentId ? { environmentId: routeEnvironmentId } : {}), - } as any); + }; + const view = await p.getUiView(viewRequest); res.json(view); } else { res.status(501).json({ error: 'UI View resolution not supported by protocol implementation', code: 'NOT_IMPLEMENTED' }); @@ -8079,12 +8166,13 @@ export class RestServer { // the envelope this route's other filter refusals already // use; see the helper for why. assertFilterParamSuppliedOnce(req.query); - const result = await p.findData({ + const listRequest: ServerScopedDataRequest = { object: req.params.object, query: req.query, ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.findData(listRequest); res.json(result); } catch (error: any) { const mapped = mapDataError(error, req.params?.object); @@ -8136,14 +8224,15 @@ export class RestServer { // — the silent-drop defect wearing a different status. if (refuseUnknownQueryParams(req, res, DATA_RECORD_READ_PARAMS)) return; const { select, expand } = req.query || {}; - const result = await p.getData({ + const getRequest: ServerScopedDataRequest = { object: req.params.object, id: req.params.id, ...(select != null ? { select } : {}), ...(expand != null ? { expand } : {}), ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.getData(getRequest); res.json(result); } catch (error: any) { const mapped = mapDataError(error, req.params?.object); @@ -8189,12 +8278,13 @@ export class RestServer { }); return; } - const result = await p.createData({ + const createRequest: ServerScopedDataRequest = { object: req.params.object, data: req.body ?? {}, ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.createData(createRequest); // [#3431] Advertise fields the engine's create-side static- // `readonly` strip dropped (`engine.insert`, relayed by // `createData` as `droppedFields`) via the response header @@ -8256,12 +8346,13 @@ export class RestServer { }); return; } - const result = await p.findData({ + const queryRequest: ServerScopedDataRequest = { object: req.params.object, query, ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.findData(queryRequest); res.json(result); } catch (error: any) { const mapped = mapDataError(error, req.params?.object); @@ -8328,14 +8419,15 @@ export class RestServer { }); return; } - const result = await p.updateData({ + const updateRequest: ServerScopedDataRequest = { object: req.params.object, id: req.params.id, data: data ?? {}, ...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}), ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.updateData(updateRequest); // [#3431] Advertise any LEGALLY-stripped write fields via // the response header before serialising (the body also // carries `droppedFields`). Status stays 200. @@ -8380,13 +8472,14 @@ export class RestServer { : undefined; const expectedVersion = queryVersion ?? ifMatchHeader; if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return; - const result = await p.deleteData({ + const deleteRequest: ServerScopedDataRequest = { object: req.params.object, id: req.params.id, ...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}), ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.deleteData(deleteRequest); res.json(result); } catch (error: any) { const mapped = mapDataError(error, req.params?.object); @@ -8636,7 +8729,8 @@ export class RestServer { // ['get','list']. Persist system-elevated so the engine-owned // write guard admits it; attribution is preserved because // `created_by` is stamped explicitly on the row above. - await (p as any).createData({ object: IMPORT_JOB_OBJECT, data: jobRow, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }); + const jobCreateRequest: ServerScopedDataRequest = { object: IMPORT_JOB_OBJECT, data: jobRow, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }; + await p.createData(jobCreateRequest); } catch (err: any) { logError('[REST] Failed to persist import job:', err); res.status(500).json({ code: 'IMPORT_JOB_CREATE_FAILED', error: 'Could not create import job' }); @@ -8650,7 +8744,9 @@ export class RestServer { // handling and persists terminal state to the job row. const patch = async (data: Record) => { try { - await (p as any).updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }); // [ADR-0103] engine-owned + // [ADR-0103] engine-owned + const jobPatchRequest: ServerScopedDataRequest = { object: IMPORT_JOB_OBJECT, id: jobId, data, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }; + await p.updateData(jobPatchRequest); } catch (err) { logError('[REST] import job progress write failed:', err); } @@ -8757,7 +8853,9 @@ export class RestServer { // Signal the in-process worker and mark the durable row. this.cancelledImportJobs.add(jobId); try { - await (p as any).updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data: { status: 'cancelled', completed_at: new Date().toISOString() }, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }); // [ADR-0103] engine-owned + // [ADR-0103] engine-owned + const jobCancelRequest: ServerScopedDataRequest = { object: IMPORT_JOB_OBJECT, id: jobId, data: { status: 'cancelled', completed_at: new Date().toISOString() }, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }; + await p.updateData(jobCancelRequest); } catch { /* worker will still stop via the in-memory flag */ } } res.json({ success: true }); @@ -8820,24 +8918,27 @@ export class RestServer { // Delete created records first (they didn't exist before). for (const id of log.created) { try { - await (p as any).deleteData({ object: objectName, id, context: writeCtx, ...(environmentId ? { environmentId } : {}) }); + const undoDeleteRequest: ServerScopedDataRequest = { object: objectName, id, context: writeCtx, ...(environmentId ? { environmentId } : {}) }; + await p.deleteData(undoDeleteRequest); deleted++; } catch { failed++; } } // Restore the touched fields on updated records. for (const u of log.updated) { try { - await (p as any).updateData({ object: objectName, id: u.id, data: u.before, context: writeCtx, ...(environmentId ? { environmentId } : {}) }); + const undoRestoreRequest: ServerScopedDataRequest = { object: objectName, id: u.id, data: u.before, context: writeCtx, ...(environmentId ? { environmentId } : {}) }; + await p.updateData(undoRestoreRequest); restored++; } catch { failed++; } } - await (p as any).updateData({ + const undoStampRequest: ServerScopedDataRequest = { object: IMPORT_JOB_OBJECT, id: jobId, data: { reverted_at: new Date().toISOString() }, context: { ...(context as any), isSystem: true }, // [ADR-0103] engine-owned ...(environmentId ? { environmentId } : {}), - }); + }; + await p.updateData(undoStampRequest); res.json({ success: true, jobId, object: objectName, deleted, restored, failed }); } catch (error: any) { handleRouteError(res, error, ''); @@ -8917,12 +9018,13 @@ export class RestServer { if (typeof q.status === 'string' && q.status) filter.status = q.status; const limit = Math.min(200, Math.max(1, Number(q.limit) || 50)); const offset = Math.max(0, Number(q.offset) || 0); - const r = await (p as any).findData({ + const jobsListRequest: ServerScopedDataRequest = { object: IMPORT_JOB_OBJECT, - query: { $filter: filter, $orderby: { created_at: 'desc' }, $top: limit, $skip: offset }, + query: wireDialectQuery({ $filter: filter, $orderby: { created_at: 'desc' }, $top: limit, $skip: offset }), ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - }); + }; + const r: any = await p.findData(jobsListRequest); const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows @@ -9220,9 +9322,9 @@ export class RestServer { while (exported < limit) { const take = Math.min(chunkSize, limit - exported); - const findArgs: any = { + const findArgs: ServerScopedDataRequest = { object: objectName, - query: { + query: wireDialectQuery({ ...(filter ? { $filter: filter } : {}), ...(search ? { $search: search } : {}), ...(search && searchFields ? { $searchFields: searchFields } : {}), @@ -9230,11 +9332,11 @@ export class RestServer { ...(expandFields.length > 0 ? { $expand: expandFields.join(',') } : {}), $top: take, $skip: skip, - }, + }), ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), }; - const result: any = await (p as any).findData(findArgs); + const result: any = await p.findData(findArgs); // `findData` returns `{ object, records, total, hasMore }`; // accept the legacy `data` / `rows` aliases and a bare array // so test doubles and alternate protocols keep working. @@ -9907,12 +10009,13 @@ export class RestServer { }; const p = await this.resolveProtocol(environmentId, req); - const result = await p.createData({ + const formCreateRequest: ServerScopedDataRequest = { object: match.object, data: filteredData, ...(environmentId ? { environmentId } : {}), context, - } as any); + }; + const result = await p.createData(formCreateRequest); res.status(201).json(result); } catch (error: any) { const mapped = mapDataError(error); @@ -10087,9 +10190,11 @@ export class RestServer { anonymous: true, }; - const result: any = await (p as any).findData({ + const pickerRequest: ServerScopedDataRequest = { object: referenceTo, - query: { + // [#15866] `filters` is a WIRE-only spelling the normalizer folds to + // `where`, and no schema declares it — see {@link wireDialectQuery}. + query: wireDialectQuery({ limit: maxResults, offset: 0, filters, @@ -10104,10 +10209,11 @@ export class RestServer { // UNAUTHENTICATED surface. A pre-schema stored row // still carrying `sort` is IGNORED, not an error. sort: [{ field: displayFields[0], order: 'asc' }], - }, + }), ...(environmentId ? { environmentId } : {}), context, - } as any); + }; + const result: any = await p.findData(pickerRequest); // Project the response server-side too — never trust // that the driver respected `select`. @@ -12592,7 +12698,10 @@ export class RestServer { // open transaction, so the engine's strip decides exactly // as it does on the single route and the insert still // joins this transaction. - const created: any = await p.createData({ object: op.object, data, context: trxCtx } as any); + const batchCreateRequest: ServerScopedDataRequest = { + object: op.object, data, context: trxCtx, + }; + const created: any = await p.createData(batchCreateRequest); for (const e of (created?.droppedFields ?? []) as DroppedFieldsEvent[]) { dropped.push({ ...e, index }); } @@ -12681,12 +12790,13 @@ export class RestServer { // [#3939] Cap AFTER the shape check, so a caller gets the // more specific answer first. if (this.enforceBatchSize(res, parsedBatch.data.records.length, maxBatch, req.params?.object)) return; - const result = await p.batchData!({ + const batchRequest: ServerScopedDataRequest = { object: req.params.object, request: req.body, ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.batchData!(batchRequest); res.json(result); } catch (error: any) { handleRouteError(res, error, req.params?.object); @@ -12733,12 +12843,13 @@ export class RestServer { } // [#3939] Cap AFTER the shape check. if (this.enforceBatchSize(res, parsedCreateMany.data.records.length, maxBatch, req.params?.object)) return; - const result = await p.createManyData!({ + const createManyRequest: ServerScopedDataRequest = { object: req.params.object, records: req.body || [], ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.createManyData!(createManyRequest); res.status(201).json(result); } catch (error: any) { handleRouteError(res, error, req.params?.object); @@ -12776,7 +12887,7 @@ export class RestServer { // resolves (e.g. an anonymous public-book read, #3963). const { UpdateManyDataRequestSchema } = await import('@objectstack/spec/api'); const updateManyInput = { ...(req.body ?? {}), object: req.params.object }; - const parsedUpdate = (UpdateManyDataRequestSchema as any).safeParse(updateManyInput); + const parsedUpdate = UpdateManyDataRequestSchema.safeParse(updateManyInput); if (!parsedUpdate.success) { res.status(400).json({ error: 'Invalid updateMany request', @@ -12789,11 +12900,12 @@ export class RestServer { // [#3939] Cap AFTER the shape check, so a caller gets the // more specific answer first. if (this.enforceBatchSize(res, parsedUpdate.data.records.length, maxBatch, req.params?.object)) return; - const result = await p.updateManyData!({ + const updateManyRequest: ServerScopedDataRequest = { ...parsedUpdate.data, ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.updateManyData!(updateManyRequest); res.json(result); } catch (error: any) { handleRouteError(res, error, req.params?.object); @@ -12837,7 +12949,7 @@ export class RestServer { // whose exposure policy was never checked. const { DeleteManyDataRequestSchema } = await import('@objectstack/spec/api'); const deleteManyInput = { ...(req.body ?? {}), object: req.params.object }; - const parsed = (DeleteManyDataRequestSchema as any).safeParse(deleteManyInput); + const parsed = DeleteManyDataRequestSchema.safeParse(deleteManyInput); if (!parsed.success) { res.status(400).json({ error: 'Invalid deleteMany request', @@ -12851,11 +12963,12 @@ export class RestServer { // route deletes per id, so the list length IS the engine // round-trip count. if (this.enforceBatchSize(res, parsed.data.ids.length, maxBatch, req.params?.object)) return; - const result = await p.deleteManyData!({ + const deleteManyRequest: ServerScopedDataRequest = { ...parsed.data, ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), - } as any); + }; + const result = await p.deleteManyData!(deleteManyRequest); res.json(result); } catch (error: any) { handleRouteError(res, error, req.params?.object); From c8102696c975c8437989218ae11e9b2bd001b903 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 22:46:29 +0000 Subject: [PATCH 2/4] test(rest): record the execctx census mention move, and add the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ServerScopedDataRequest` doc-comment names `resolveExecCtx` to say where its `context` member comes from, which moves the census's prose-mention control from 98 to 99. The invocation-site control is UNCHANGED at 77 — no consumer was added, moved or removed — which is the split that census exists to keep visible, and the entry records it in the block's house style. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...st-data-doors-compiled-against-protocol.md | 11 +++++++++++ .../rest/src/execctx-consumer-census.test.ts | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .changeset/rest-data-doors-compiled-against-protocol.md diff --git a/.changeset/rest-data-doors-compiled-against-protocol.md b/.changeset/rest-data-doors-compiled-against-protocol.md new file mode 100644 index 0000000000..880a0230f8 --- /dev/null +++ b/.changeset/rest-data-doors-compiled-against-protocol.md @@ -0,0 +1,11 @@ +--- +"@objectstack/rest": patch +--- + +The REST data doors' protocol requests are compiled against the declared contract again, so a field added to a data request schema reddens the build instead of going silently unsent. + +No runtime behaviour changes — every door assembles and forwards exactly the object it did before. What changes is what the compiler is allowed to see. `packages/rest/src/rest-server.ts` dispatched to the protocol through two erasing forms: `p.deleteData({ … } as any)` on the argument, and the stronger `(p as any).updateData({ … })` on the protocol object itself, which erases the check on *every* member — a misspelled method name would not have errored. Across the file that was 22 dispatch sites spanning `findData` / `getData` / `createData` / `updateData` / `deleteData`, their `*Many` and batch siblings, and `getUiView`. + +The casts were load-bearing rather than lazy: these call sites pass `environmentId` and `context`, and neither is a member of any data request schema. Neither should become one. `environmentId` is the transport routing key that selects the kernel *before* the protocol call and is already ruled out of the request shape; `context` is the server-derived execution context, and a caller-supplied `context` is a privilege escalation the ingress deletes unconditionally — putting it in the published request schema would re-open that door. Both are now declared on a typed envelope alongside the request type, so they stay server-side *and* compiled, and every other member of every literal is checked against the spec. + +One slot stays deliberately untyped and is now named rather than diffuse: `findData`'s `query` accepts both the declared AST and an undeclared wire dialect (`$top`, `$orderby`, `filters`, …) that the protocol normalizer folds. Three server-built literals speak that dialect; the erasure there is confined to the query slot alone, and the declared-versus-shipped mismatch is filed as its own question. diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 96e7c3e078..6f13ea3044 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -309,7 +309,22 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('77 invocation sites, 98 mentions — the thread\'s two control numbers hold', () => { + it('77 invocation sites, 99 mentions — the thread\'s two control numbers hold', () => { + // [#15866] 77 sites UNCHANGED / 98 → 99 mentions — the fourth pattern, + // and the first entry here that moves the mention count while adding no + // consumer at all. That card retired the `as any` casts on this file's + // protocol-dispatch sites, so each data door's request literal is now + // compiled against the declared spec contract through a typed envelope + // (`ServerScopedDataRequest`). The envelope's doc-comment has to say + // where its `context` member comes from — it is the SERVER-DERIVED + // execution context, which is the whole reason it may not join the + // published request schema — and naming {@link RestServer.resolveExecCtx} + // is how it says so. ⚠️ No call site was added, moved or removed: the + // repair is a type annotation, and `SITES.length` staying at 77 across + // it is the assertion that says so. A reader who sees only the mention + // count move should read it as documentation about the consumers, never + // as a consumer. + // // [#13753, the `/references` half] 76 → 77 sites / 97 → 98 mentions. // `GET /meta/:type/:name/references` resolved NO identity, so the // reference sweep behind the admin "Used by" panel read the env @@ -378,7 +393,7 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // that tracked the site count exactly would be measuring one thing // twice. expect(SITES.length).toBe(77); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(98); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(99); }); it('the split is 24 locally caught / 53 bare — NOT 16 / 53, which does not add to 77', () => { From c4d62d3d6ca6018c05034c431ad6f3446f35b17d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 22:58:40 +0000 Subject: [PATCH 3/4] docs(permissions): re-anchor the system-context census citations after the line shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure line rot, applied with the gate's own `--fix`: the typed-envelope declaration and its imports sit near the top of `rest-server.ts`, so every `isSystem` citation below them moved by a constant +86. No prose, no row and no verdict changed — only the `file:line` anchors. `check-system-context-census` was verified GREEN at the merge base first, so this is a shift this branch caused rather than one it inherited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 2e6ca492b0..05002e3da2 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1565`, `:1594`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1651`, `:1680`), and neither can an action body (`packages/runtime/src/domains/actions.ts:414`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1597` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1683` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5145`, `:6571`, `:6819`, `:7250`, `:7443` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5231`, `:6657`, `:6905`, `:7336`, `:7529` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1581` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1565`, `:1594`; `domains/actions.ts:414` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1651`, `:1680`; `domains/actions.ts:414` | --- From 29356431077e74df8b6638c01951b260bd47ebe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:30:27 +0000 Subject: [PATCH 4/4] docs(permissions): restore the sharing-rule-service anchors the merge reverted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ The merge with `origin/main` was textually CLEAN and semantically wrong, which on a line-anchor page is the failure mode to expect rather than a surprise: `main` had re-anchored row 39 to `sharing-rule-service.ts:278`/`:503`, the merge resolved that line to this branch's older `:202`/`:427`, and nothing about the merge said so. Only `check-system-context-census` did — it was verified GREEN at `origin/main` (31403453dda) first, so the four findings were the merge's and not inherited. Re-derived from the merged sources with the gate's own `--fix`; the two anchors now read what `main` set them to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 05002e3da2..455d1f5282 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -137,7 +137,7 @@ The largest single consumer — **17 of the 105 sites**. | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` | | 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | -| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:202`, `:427` | +| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:278`, `:503` | ### 4. Approvals, reports, attachments, comments, knowledge