From aacda8df071a8531e4098decd81f4bb653269212 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:07:07 +0000 Subject: [PATCH 01/12] fix(rest): consume the parsed `api` sub-config instead of discarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RestServer.normalizeConfig` ran `RestApiConfigSchema` over `config.api` and threw the parsed output away, rebuilding the block from a `??` chain over the raw cast. That chain restated the schema's eleven top-level `z.default(...)`s as eleven literals in `packages/rest`, with nothing pinning that the two stayed equal — a `packages/spec` default change would silently fail to propagate. #11637 made the parse validate-only for two measured reasons; both have since expired (#11983 gave `enableSearch` a declared seat, #12450 withdrew the `projectResolution` omit). Re-measured here: the 14 keys the method reads and the 14 the schema declares after `.omit({ requireAuth: true })` are the same 14 in both directions, so a consumed parse cannot strip anything the runtime honours. `requireAuth` stays omitted and stays warn-and-ignore in the plugin, which reads it off the RAW config. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/rest/src/rest-server.ts | 102 ++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3c66d49dbb..c435253d4b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -80,7 +80,7 @@ import { refuseRepeatedQueryParams, assertFilterParamSuppliedOnce } from './quer // ignored filter is the one wrong answer a caller cannot detect. import { refuseUnknownQueryParams } from './query-allowlist.js'; import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; -import { RestServerConfig, RestApiConfig } from '@objectstack/spec/api'; +import { RestServerConfig, RestApiConfigParsed } from '@objectstack/spec/api'; // [#11683] The catalog's own floor for "a required `code` and no more specific // one" — see its use in `registerSharingEndpoints`, where the nested ADR-0112 // envelope declares `code` REQUIRED while the flat classification it re-dresses @@ -88,7 +88,7 @@ import { RestServerConfig, RestApiConfig } from '@objectstack/spec/api'; import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; // [#11637] The DECLARED contract for `config.api`, imported as a VALUE rather // than a type. Both hops into this package were casts, so this schema had -// never run on any deployment path — see `assertDeclaredApiConfig` below. +// never run on any deployment path — see `parseDeclaredApiConfig` below. import { RestApiConfigSchema, CrudEndpointsConfigSchema, @@ -743,8 +743,13 @@ type NormalizedRestServerConfig = { enableSearch: boolean; enableProjectScoping: boolean; projectResolution: 'required' | 'optional' | 'auto'; - documentation: RestApiConfig['documentation']; - responseFormat: RestApiConfig['responseFormat']; + // [#14366] The PARSED shape, not the authored one: this block is + // built from `RestApiConfigSchema`'s output, so a `documentation` or + // `responseFormat` the caller wrote arrives with its OWN declared + // inner defaults applied (`documentation.enabled`, `.title`; + // `responseFormat.envelope`, `.includeMetadata`, `.includePagination`). + documentation: RestApiConfigParsed['documentation']; + responseFormat: RestApiConfigParsed['responseFormat']; }; crud: { operations: { @@ -799,7 +804,7 @@ type NormalizedRestServerConfig = { * * `api` is the one entry with a subtraction: its retired `requireAuth` * tombstone is `.omit()`ed because this seam does not own that key's posture - * (see {@link RestServer.assertDeclaredApiConfig}). The four siblings carry no + * (see {@link RestServer.parseDeclaredApiConfig}). The four siblings carry no * tombstone of their own and are taken whole. ⛔ `RestServerConfigSchema` — the * whole-config schema — is deliberately NOT in this table: its `openApi31` * tombstone (#4579) is a `retiredKey()` whose parse REFUSES the key, while @@ -824,6 +829,13 @@ function buildDeclaredSubConfigSchemas() { } type DeclaredSubConfigSchemas = ReturnType; type DeclaredSubConfigName = keyof DeclaredSubConfigSchemas; +/** + * [#14366] The parsed `api` sub-object, which `normalizeConfig` now BUILDS + * FROM. Taken off the table's own entry rather than off `RestApiConfigParsed`, + * so it is the post-`.omit()` shape: the retired `requireAuth` tombstone is + * absent here exactly as it is absent from the schema this seam runs. + */ +type DeclaredApiConfigParsed = z.output; let declaredSubConfigSchemasCache: DeclaredSubConfigSchemas | undefined; function declaredSubConfigSchemas(): DeclaredSubConfigSchemas { return (declaredSubConfigSchemasCache ??= buildDeclaredSubConfigSchemas()); @@ -3616,8 +3628,9 @@ export class RestServer { * walked straight past it and mounted the whole API at `/api//`, and * `'v1/beta'` spliced an extra path segment into every route. * - * VALIDATION ONLY — the parsed output is deliberately discarded and the - * normalization below keeps reading the raw input. Two measured reasons: + * [#14366] The parsed output is CONSUMED — `normalizeConfig` builds the + * `api` block from what this returns. It was VALIDATE-ONLY from #11637 + * until then, for two measured reasons that have both since expired: * * - `enableSearch` USED to be the silent-strip trap here: it was read * below through `as any` and declared nowhere in `packages/spec`, so @@ -3626,9 +3639,27 @@ export class RestServer { * it off (the ADR-0104 class `shared/retired-key.ts` exists to * prevent). #11983 gave it a declared seat * (`RestApiConfigSchema.enableSearch`, default `true`), so the parse - * now preserves it — but the discard stays, for the omitted keys: - * - * - the retired `api.requireAuth` key is `.omit()`ed rather than enforced. + * preserves it. + * + * - `api.projectResolution` was `.omit()`ed until #12450 withdrew it. + * + * ⇒ Re-measured at #14366 on the landed tree, because the discard is + * only safe to remove if the key diff is EMPTY: the 14 keys + * `normalizeConfig` reads and the 14 `RestApiConfigSchema` declares + * after the `.omit()` are the same 14, in both directions. So the + * non-strict parse cannot strip anything the runtime honours, and the + * schema's `.default()`s ARE the defaults — one source, not the two + * that a `??` chain here duplicated key for key. + * + * The one measured behaviour delta is bounded and named: a + * `documentation` or `responseFormat` object the caller WRITES now + * arrives carrying its own declared inner defaults, where the `??` + * chain copied the authored object through untouched. Both keys have + * zero read sites outside this block (the #14369 census), so nothing + * observes it today — but it is a real change to this structure's + * contents and belongs in the record rather than in a reader's surprise. + * + * - the retired `api.requireAuth` key is STILL `.omit()`ed rather than enforced. * #3963 retired it with a deliberate warn-and-ignore posture * (`rest-api-plugin.ts`: "is IGNORED"), chosen in a world where nothing * parsed this config; converting that into a boot failure is that @@ -3676,8 +3707,8 @@ export class RestServer { * schema's defaults key for key today, and folding it onto the parse is a * separate, separately-measured change — not a rider on the siblings. */ - private assertDeclaredApiConfig(api: unknown): void { - parseDeclaredSubConfig('api', declaredSubConfigSchemas().api, api, (issues) => ( + private parseDeclaredApiConfig(api: unknown): DeclaredApiConfigParsed { + return parseDeclaredSubConfig('api', declaredSubConfigSchemas().api, api, (issues) => ( // The `version` rationale is appended only when `version` is what // failed. Measured during #11637's own ablation: a // `projectResolution` refusal printed the whole "an empty version @@ -3697,12 +3728,20 @@ export class RestServer { * Normalize configuration with defaults */ private normalizeConfig(config: RestServerConfig): NormalizedRestServerConfig { - // [#11637] `api`: parse BEFORE the cast, not instead of it — the cast - // is what makes the `api` block below type-check, and it is only sound - // once the declared contract has actually been run. Validate-only; see - // `assertDeclaredApiConfig` for why its parsed output is discarded. - this.assertDeclaredApiConfig(config.api); - const api = (config.api ?? {}) as Partial; + // [#11637 / #14366] `api`: parsed AND consumed. #11637 ran the declared + // contract here but discarded its output, leaving the block below to be + // built from a cast over the raw input through a `??` chain that + // duplicated `RestApiConfigSchema`'s defaults key for key — ELEVEN + // literals in `packages/rest` restating the eleven top-level + // `z.default(...)`s in `packages/spec`, with nothing pinning that the + // two stayed equal. (Eleven, measured on both sides at #14366; the + // filing card said twelve, having counted the `config.api ?? {}` that + // guards the whole object rather than a per-key default.) + // #14366 folded the chain onto the parse after re-measuring the key + // diff empty in both directions (see `parseDeclaredApiConfig`), so the + // schema is now the single source of these defaults. The cast is gone + // with it: the parsed output is already typed. + const api = this.parseDeclaredApiConfig(config.api); // [#11984] The four siblings: parsed AND consumed. Each used to be // `(config. ?? {}) as Partial<...>`, so `batch.maxBatchSize: 0` // was the live batch cap (`0` is not nullish) and @@ -3719,19 +3758,24 @@ export class RestServer { const routes = parseDeclaredSubConfig('routes', schemas.routes, config.routes); return { + // Keys listed rather than spread: `NormalizedRestServerConfig` + // declares `documentation` / `responseFormat` as REQUIRED (possibly + // `undefined`) while the schema declares them `.optional()`, so a + // spread would not satisfy this type — and listing them is also + // what makes the empty key diff readable at the seam it protects. api: { - version: api.version ?? 'v1', - basePath: api.basePath ?? '/api', + version: api.version, + basePath: api.basePath, apiPath: api.apiPath, - enableCrud: api.enableCrud ?? true, - enableMetadata: api.enableMetadata ?? true, - enableUi: api.enableUi ?? true, - enableBatch: api.enableBatch ?? true, - enableDiscovery: api.enableDiscovery ?? true, - enableOpenApi: api.enableOpenApi ?? true, - enableSearch: api.enableSearch ?? true, - enableProjectScoping: api.enableProjectScoping ?? false, - projectResolution: api.projectResolution ?? 'auto', + enableCrud: api.enableCrud, + enableMetadata: api.enableMetadata, + enableUi: api.enableUi, + enableBatch: api.enableBatch, + enableDiscovery: api.enableDiscovery, + enableOpenApi: api.enableOpenApi, + enableSearch: api.enableSearch, + enableProjectScoping: api.enableProjectScoping, + projectResolution: api.projectResolution, documentation: api.documentation, responseFormat: api.responseFormat, }, From c03840904cd97d0ecae47c331837600a752ecd3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:18:13 +0000 Subject: [PATCH 02/12] test(rest): pin that the `api` defaults follow `RestApiConfigSchema` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves, deliberately split by whether the schema is mocked. `rest-api-config-defaults-follow-spec.pin.test.ts` is the DISCRIMINATING pin: it moves five `z.default(...)`s to values that differ from both the shipped schema's and the deleted `??` chain's literals, then drives a real RestServer construction. Asserting today's values would have been vacuous — the chain's literals and the schema's defaults agreed key for key, which is the defect. `rest-config-parse-not-cast.test.ts` §D is the unmocked half: the shipped defaults are the schema's own output (derived, never restated), the normalized key set equals the declared key set, `requireAuth: false` still constructs and still warns through the plugin, and the one bounded behaviour delta — an authored `documentation` / `responseFormat` now carrying its declared inner defaults — is pinned rather than left to be rediscovered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...pi-config-defaults-follow-spec.pin.test.ts | 153 ++++++++++++++++++ .../src/rest-config-parse-not-cast.test.ts | 115 +++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts diff --git a/packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts b/packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts new file mode 100644 index 0000000000..7c3a274e15 --- /dev/null +++ b/packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14366] `RestApiConfigSchema` is the SINGLE SOURCE of the `api` sub-object's + * defaults — `RestServer.normalizeConfig` follows a change to a + * `z.default(...)` in `packages/spec` rather than restating it. + * + * ⛔ ANTI-VACUITY, and this file exists because the ordinary spelling of this + * pin is vacuous. A test that asserts today's VALUES — `version === 'v1'`, + * `enableProjectScoping === false` — passes just as well with the deleted `??` + * chain still in place, because the chain's literals and the schema's defaults + * agreed key for key on the day the chain was written. That agreement is the + * whole defect: two sources that happen to match, with nothing measuring that + * they keep matching. Asserting the matched value measures neither source. + * + * So this file MOVES the schema and asks where the server lands. The mock + * below re-declares five `z.default(...)`s to values that differ from both the + * real schema's and the deleted chain's literals, then drives a REAL + * `RestServer` construction and reads the normalized config back: + * + * key real default deleted `??` literal mutated to + * version 'v1' 'v1' 'v9-mutated' + * basePath '/api' '/api' '/mutated' + * enableUi true true false + * enableProjectScoping false false true + * projectResolution 'auto' 'auto' 'required' + * + * Pre-change tree: all five answer the `??` literal, because the chain read the + * RAW input (`api.version ?? 'v1'`) and an absent key is nullish whatever the + * schema says — the parsed output was discarded. Post-change: all five answer + * the mutated default. That gap is what makes each case below a measurement of + * the propagation rather than of a coincidence. Measured, both directions, in + * this change's own reverse verification. + * + * ⚠️ This file mocks `@objectstack/spec/api` module-wide, so the schema it + * drives is NOT the shipped one. The complementary pins that need the REAL + * schema — that the shipped defaults are the schema's, that `requireAuth` + * keeps its warn-and-ignore posture, and that the parse's inner defaults now + * reach `documentation` / `responseFormat` — live in + * `rest-config-parse-not-cast.test.ts` §D, which is deliberately unmocked. + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@objectstack/spec/api', async (importOriginal) => { + const actual = await importOriginal(); + const { z } = await import('zod'); + return { + ...actual, + // `.extend()` on the real schema, not a hand-built stand-in: every + // other key — and the `requireAuth` tombstone the seam `.omit()`s — + // must survive, or this would measure a shape change rather than a + // default change. Only the five defaults move. + RestApiConfigSchema: (actual.RestApiConfigSchema as any).extend({ + version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v9-mutated'), + basePath: z.string().default('/mutated'), + enableUi: z.boolean().default(false), + enableProjectScoping: z.boolean().default(true), + projectResolution: z.enum(['required', 'optional', 'auto']).default('required'), + }), + }; +}); + +const { RestServer } = await import('./rest-server.js'); +// The MOCKED schema, imported through the same specifier the seam uses, so the +// control case below reads the very object the server was handed. +const { RestApiConfigSchema } = await import('@objectstack/spec/api'); + +function makeServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), + } as any; +} + +function makeProtocol() { + return { + getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })), + } as any; +} + +/** Construct the real server with `api` as given — the seam under test. */ +function construct(api: Record = {}) { + return new RestServer(makeServer(), makeProtocol(), { api } as any); +} + +/** The normalized `api` block, read off the constructed server. */ +function normalizedApi(rest: unknown): Record { + return (rest as { config: { api: Record } }).config.api; +} + +describe('[#14366] normalizeConfig follows the SCHEMA default, not a local literal', () => { + it('CONTROL: the mock really did move the schema', () => { + // Not decoration. Every assertion below is "the server answers X"; if + // the mock silently failed to apply, the real default would be `'v1'` + // and a `not.toBe('v1')` style pin could pass for the wrong reason. + // This proves the premise the rest of the file rests on. + const parsed = (RestApiConfigSchema as any).omit({ requireAuth: true }).parse({}); + expect(parsed.version, 'the mocked schema must carry the mutated default').toBe('v9-mutated'); + expect(parsed.basePath).toBe('/mutated'); + expect(parsed.enableUi).toBe(false); + expect(parsed.enableProjectScoping).toBe(true); + expect(parsed.projectResolution).toBe('required'); + }); + + it('a moved `version` default reaches the normalized config', () => { + // Pre-change: `'v1'` — `api.version ?? 'v1'` never consulted the schema. + expect(normalizedApi(construct()).version).toBe('v9-mutated'); + }); + + it('a moved `basePath` default reaches the normalized config', () => { + expect(normalizedApi(construct()).basePath).toBe('/mutated'); + }); + + it('a moved BOOLEAN default reaches it too — the `??` chain could not express this', () => { + // The sharpest of the five. `api.enableUi ?? true` yields `true` for an + // absent key no matter what the schema declares, so a spec change from + // `default(true)` to `default(false)` was UNREPRESENTABLE downstream: + // silently dropped, with every test still green. This is the drift the + // card was filed about, stated as an executable case. + expect(normalizedApi(construct()).enableUi).toBe(false); + expect(normalizedApi(construct()).enableProjectScoping).toBe(true); + }); + + it('a moved ENUM default reaches it', () => { + expect(normalizedApi(construct()).projectResolution).toBe('required'); + }); + + it('the moved defaults reach the MOUNT, not just the config object', () => { + // Read through the behaviour, not only the structure: a default that + // landed in the normalized config but was not threaded would still be + // a half-fix. `getApiBasePath()` composes `${basePath}/${version}`. + expect(construct().getApiBasePath()).toBe('/mutated/v9-mutated'); + }); + + it('an AUTHORED value still wins over the schema default — the change is defaults only', () => { + // The bound. Consuming the parse must not start overriding what the + // caller wrote; zod `.default()` applies to `undefined` alone. + const rest = construct({ version: 'v3', basePath: '/authored', enableUi: true }); + expect(normalizedApi(rest).version).toBe('v3'); + expect(normalizedApi(rest).basePath).toBe('/authored'); + expect(normalizedApi(rest).enableUi).toBe(true); + expect(rest.getApiBasePath()).toBe('/authored/v3'); + }); + + it('an authored FALSE still survives — `??` and the parse agree here, and must keep agreeing', () => { + // `false` is not nullish, so the deleted chain honoured it too. Kept as + // a regression guard: the failure this pin guards against is a future + // author "simplifying" the parse into a truthiness check. + const rest = construct({ enableProjectScoping: false }); + expect(normalizedApi(rest).enableProjectScoping).toBe(false); + }); +}); diff --git a/packages/rest/src/rest-config-parse-not-cast.test.ts b/packages/rest/src/rest-config-parse-not-cast.test.ts index 96f576c19f..de4c4b29e1 100644 --- a/packages/rest/src/rest-config-parse-not-cast.test.ts +++ b/packages/rest/src/rest-config-parse-not-cast.test.ts @@ -58,6 +58,7 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { RestApiConfigSchema } from '@objectstack/spec/api'; import { RestServer } from './rest-server.js'; import { createRestApiPlugin } from './rest-api-plugin.js'; @@ -272,3 +273,117 @@ describe('[#11637] §C regression guards — the narrowing is exactly the declar expect(() => construct({ requireAuth: false, version: 'v1' })).not.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// §D — [#14366] the parsed output is CONSUMED +// --------------------------------------------------------------------------- + +/** + * #11637 ran the parse and threw its result away; the block was rebuilt from a + * `??` chain over the raw cast. #14366 folded the chain onto the parse after + * re-measuring both of #11637's reasons expired and the key diff empty in both + * directions (14 read, 14 declared after the `.omit()`). + * + * ⛔ These cases assert against `RestApiConfigSchema`'s OWN output, computed at + * run time — never against a literal. A literal here would restate in the test + * exactly the duplication the change deleted from the source, and would go + * green on both trees. The pin that DISCRIMINATES the two trees has to move the + * schema, which needs a module mock, so it lives in its own file: + * `rest-api-config-defaults-follow-spec.pin.test.ts`. §D is the unmocked half — + * the SHIPPED schema, the SHIPPED posture, and the one bounded behaviour delta. + */ +describe('[#14366] §D the `api` sub-object consumes the parsed output', () => { + const declaredApi = () => RestApiConfigSchema.omit({ requireAuth: true }); + + /** The normalized `api` block, read off the constructed server. */ + const normalizedApi = (rest: unknown) => + (rest as { config: { api: Record } }).config.api; + + it('every default in the normalized block is the SCHEMA\'s, key for key', () => { + const fromSchema = declaredApi().parse({}) as Record; + const fromServer = normalizedApi(construct({})); + // Positive control: an empty `fromSchema` would make the loop vacuous. + expect(Object.keys(fromSchema).length, 'the schema must actually supply defaults').toBeGreaterThan(0); + for (const [key, value] of Object.entries(fromSchema)) { + expect(fromServer[key], `api.${key} must come from RestApiConfigSchema`).toEqual(value); + } + }); + + it('the normalized key set is exactly the declared key set — nothing added, nothing dropped', () => { + // The measurement the consumption decision rests on: a key the method + // read but the schema did not declare would be silently STRIPPED by a + // consumed parse, which is the failure #11637 avoided by discarding. + const declared = Object.keys(declaredApi().shape).sort(); + const normalized = Object.keys(normalizedApi(construct({}))).sort(); + expect(normalized).toEqual(declared); + expect(declared, 'the retired tombstone stays out of the parsed shape').not.toContain('requireAuth'); + }); + + it('an authored value still wins over the schema default', () => { + const rest = construct({ version: 'v7', basePath: '/svc', enableSearch: false }); + expect(normalizedApi(rest).version).toBe('v7'); + expect(normalizedApi(rest).basePath).toBe('/svc'); + expect(normalizedApi(rest).enableSearch).toBe(false); + }); + + it('THE BOUNDED DELTA: an authored `documentation` now carries its own declared inner defaults', () => { + // The single measured behaviour change of #14366, pinned rather than + // left to be rediscovered. The deleted `??` chain copied this object + // through untouched (`documentation: api.documentation`), so a partial + // one stayed partial; the parse fills the inner `.default()`s. + // `documentation` has ZERO read sites outside this block (the #14369 + // census), so nothing observes it today — which is exactly why it needs + // a pin: an unobserved change is the kind that gets reverted by + // accident. + const doc = normalizedApi(construct({ documentation: { description: 'd' } })) + .documentation as Record; + expect(doc).toEqual( + (declaredApi().parse({ documentation: { description: 'd' } }) as { documentation: unknown }).documentation, + ); + expect(doc.description, 'the authored key survives').toBe('d'); + expect(doc.enabled, 'and the declared inner default arrives with it').toBe(true); + }); + + it('THE BOUNDED DELTA: an authored `responseFormat` does the same', () => { + const rf = normalizedApi(construct({ responseFormat: { envelope: false } })) + .responseFormat as Record; + expect(rf.envelope, 'the authored key survives').toBe(false); + expect(rf.includeMetadata).toBe(true); + expect(rf.includePagination).toBe(true); + }); + + it('an ABSENT optional object stays absent — the parse does not materialize it', () => { + // The bound on the delta above: `.optional()` without `.default()` + // means "missing stays missing". A parse that invented an empty + // `documentation` block would change what nothing-authored means. + const api = normalizedApi(construct({})); + expect(api.documentation).toBeUndefined(); + expect(api.responseFormat).toBeUndefined(); + expect(api.apiPath).toBeUndefined(); + }); + + it('an undeclared key under `api` is not carried into the normalized config', () => { + // Unchanged by #14366 and pinned as the bound: the `??` chain copied a + // fixed list of 14 keys, and the non-strict parse strips anything not + // declared. Both drop it — so consuming cannot have widened the surface. + const api = normalizedApi(construct({ totallyUndeclared: 'x' } as never)); + expect(api).not.toHaveProperty('totallyUndeclared'); + }); + + it('KEEPS the #3963 warn-and-ignore posture: `requireAuth: false` constructs AND still warns', async () => { + // The pin the card names. The warning is emitted by `rest-api-plugin.ts` + // off the RAW config, so consuming the parse inside `RestServer` cannot + // reach it — measured here end to end rather than argued. + expect(() => construct({ requireAuth: false, version: 'v1' })).not.toThrow(); + + const ctx = bootCtx(); + await expect( + createRestApiPlugin({ api: { api: { requireAuth: false, version: 'v1' } } as any }).start!(ctx), + ).resolves.toBeUndefined(); + const warned = (ctx.logger.warn as { mock: { calls: unknown[][] } }).mock.calls + .map((args) => String(args[0])) + .join('\n'); + expect(warned, 'the retired key must still be reported to the operator').toContain('`api.requireAuth` was removed'); + expect(warned).toContain('IGNORED'); + }); +}); From 392665599c08bde99540508d43f2451ef8a44627 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:21:33 +0000 Subject: [PATCH 03/12] chore(changeset): rest api config defaults now come from the schema Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/rest-api-config-consumes-parse.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/rest-api-config-consumes-parse.md diff --git a/.changeset/rest-api-config-consumes-parse.md b/.changeset/rest-api-config-consumes-parse.md new file mode 100644 index 0000000000..6cda0b961e --- /dev/null +++ b/.changeset/rest-api-config-consumes-parse.md @@ -0,0 +1,11 @@ +--- +"@objectstack/rest": patch +--- + +The REST server's `api` configuration defaults now come from `RestApiConfigSchema` alone, instead of being restated in `packages/rest`. + +`RestServer.normalizeConfig` already parsed `config.api` against `RestApiConfigSchema` — and then discarded the result, rebuilding the block from a `??` chain over the raw input. That chain restated the schema's eleven top-level `z.default(...)`s as eleven literals in a second package. They agreed key for key, and nothing measured that they would keep agreeing: changing a default in `@objectstack/spec` silently failed to propagate, because `api.enableUi ?? true` answers `true` for an absent key whatever the schema declares. Consuming the parse deletes the duplicate and makes the schema authoritative. + +The parse itself is unchanged, so **nothing new is accepted or refused**: the same schema, with the same `.omit({ requireAuth: true })`, already ran at construction. `api.requireAuth` keeps its retired warn-and-ignore posture (`@objectstack/rest`'s plugin reads it off the raw config, so the warning is untouched), and every authored value still wins over the default. + +One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat`: those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination` — where they were previously copied through exactly as authored. An object left unwritten stays absent; nothing in the platform reads either key today. From 7222e7f340dfdfcf1bcc388e09cf55d5514a74d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:24:54 +0000 Subject: [PATCH 04/12] docs(permissions): re-anchor the system-context citations after the line shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/permissions/system-context.mdx` cites `packages/rest/src/rest-server.ts` by absolute line number, so this change's +44 net lines rotted ten anchors. Rewritten by the gate's own `node scripts/check-system-context-census.mjs --fix`, which rewrites a pure shift and refuses a population change. Verified structurally rather than by line content (several anchored lines are the same string): the census JSON re-derived at the merge base and at head is identical in population — 106 sites both sides, 0 arrived, 0 vanished, same 45 files and 20 packages, `classified` 462 = 462. The six `rest-server.ts` sites carry identical text at +12 / +44. 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 77d53a0121..326dfc460d 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:1553`, `:1582`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1565`, `:1594`), 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:246` | | 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:1585` | +| 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` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 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:5016`, `:6430`, `:6678`, `:7109`, `:7302` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5060`, `:6474`, `:6722`, `:7153`, `:7346` | | 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:422`, `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:1580` (#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:1553`, `:1582`; `domains/actions.ts:414` | +| "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` | --- From 920260e83ff3b0fcb2568fbb6a78bb139455b253 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:49:02 +0000 Subject: [PATCH 05/12] docs(rest): retire the two comments that still called `api` validate-only Both were true until this branch consumed the parse, and both now contradict the code beside them: the seam's own sibling paragraph in `rest-server.ts` and the `api` aside in the #11984 pin file's header. Rewritten to say what is now true, keeping the history in past tense. Repo-wide grep for the stale claim returns only that one past-tense mention. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/rest/src/rest-server.ts | 8 ++++---- packages/rest/src/rest-sub-config-parse-not-cast.test.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c435253d4b..8b27f61a54 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3702,10 +3702,10 @@ export class RestServer { * CONSUMED. The asymmetry with `api` is measured, not stylistic: for each * of the four, every key `normalizeConfig` reads is one its schema * declares (the key diff is empty), and none carries a tombstone, so a - * consumed parse cannot strip anything the runtime honours. `api` keeps - * #11637's validate-only shape here; its `??` chain below duplicates the - * schema's defaults key for key today, and folding it onto the parse is a - * separate, separately-measured change — not a rider on the siblings. + * consumed parse cannot strip anything the runtime honours. [#14366] `api` + * went through the same door last, separately measured rather than ridden + * on the siblings: the asymmetry is gone and all five now build from their + * parsed output. */ private parseDeclaredApiConfig(api: unknown): DeclaredApiConfigParsed { return parseDeclaredSubConfig('api', declaredSubConfigSchemas().api, api, (issues) => ( diff --git a/packages/rest/src/rest-sub-config-parse-not-cast.test.ts b/packages/rest/src/rest-sub-config-parse-not-cast.test.ts index 7d0507c687..9bdb960284 100644 --- a/packages/rest/src/rest-sub-config-parse-not-cast.test.ts +++ b/packages/rest/src/rest-sub-config-parse-not-cast.test.ts @@ -40,8 +40,9 @@ * `normalizeConfig` reads is declared by the sub-object's schema (measured * key by key; the diff is empty for all four), so the PARSED output is what * the normalized config is built from and the schema's own defaults are the - * defaults. `api` keeps #11637's validate-only posture — its `.omit()`ed - * tombstone is the reason — and is not this file's subject. + * defaults. `api` held #11637's validate-only posture until [#14366] measured + * its key diff empty too and folded its `??` chain onto the parse; it keeps + * the `.omit()`ed `requireAuth` tombstone, and is not this file's subject. * * [#14691] Ten of the keys these pins originally exercised were RETIRED under * ADR-0049 enforce-or-remove (the #14369 liveness census found them normalized From d51d45507982a782067af5933b84df824d171d23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 03:44:23 +0000 Subject: [PATCH 06/12] docs(changeset): state the subtractive half of the `api` parse delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset described the behaviour change on an authored `api.documentation` / `api.responseFormat` as additive only — the objects now carry their declared inner defaults. Measured against the built schema, the delta is also subtractive: `RestApiConfigSchema`'s nested objects are non-strict `z.object()`s, so inner keys they do not declare are stripped, at both depths (`documentation.logo`, `documentation.contact.phone`, `documentation.license.spdxId`, `responseFormat.extra`), where the deleted `??` chain passed the authored object through by reference and kept them. Documentation accuracy only — no code, test or pin is touched, and the `patch` level is unchanged: the normalized block is `private` to `RestServer` and neither key has a read site, so nothing public widens or narrows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/rest-api-config-consumes-parse.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/rest-api-config-consumes-parse.md b/.changeset/rest-api-config-consumes-parse.md index 6cda0b961e..6c8b40cf45 100644 --- a/.changeset/rest-api-config-consumes-parse.md +++ b/.changeset/rest-api-config-consumes-parse.md @@ -8,4 +8,4 @@ The REST server's `api` configuration defaults now come from `RestApiConfigSchem The parse itself is unchanged, so **nothing new is accepted or refused**: the same schema, with the same `.omit({ requireAuth: true })`, already ran at construction. `api.requireAuth` keeps its retired warn-and-ignore posture (`@objectstack/rest`'s plugin reads it off the raw config, so the warning is untouched), and every authored value still wins over the default. -One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat`: those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination` — where they were previously copied through exactly as authored. An object left unwritten stays absent; nothing in the platform reads either key today. +One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat` — and it runs in two directions, not one. **Filled in:** those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination`. **Stripped:** inner keys the schema does not declare no longer survive, at either depth — an authored `documentation.logo`, a `documentation.contact.phone` or a `documentation.license.spdxId` inside the nested objects, a `responseFormat.extra` — where the `??` chain passed the authored object through by reference and kept every key on it. Both halves are the same parse: `documentation` / `responseFormat` (and their `contact` / `license`) are non-strict `z.object()`s, which fill in their `.default()`s and drop what they do not name — dropped silently, so this is a strip and not a new refusal. An object left unwritten stays absent, and nothing in the platform reads either key today: the normalized block is `private` to `RestServer`, which reads only scalars off it (`apiPath` / `basePath` / `version` in `getApiBasePath`, the `enable*` flags, `projectResolution`), and the repo has no other read site for either key — so no consumer observes either half. From 569df2f4eac4d791a73f7ed119162aeaa28ed07e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:08:49 +0000 Subject: [PATCH 07/12] docs(permissions): re-anchor the system-context census after merging main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/permissions/system-context.mdx` cites `rest-server.ts` by absolute line number, and both sides of this merge moved lines in that file: main by +32 and this branch by +44, in disjoint regions. Row 50's five REST anchors were the one hunk a text merge could not resolve. Re-anchored with the gate's own `--fix`, which rewrites a pure shift and refuses a population change. Verified structurally rather than by comparing line content (several anchored lines are the same string): the census re-derived with one instrument over main, this branch's pre-merge tip and the merged tree agrees on every population number — 106 sites, 20 packages, 45 files, 871 identifier appearances, 462 classified, 0 stale ledger rows — and site identity keyed shift-invariantly shows 0 arrived and 0 vanished in both directions. Only `file:line` identity moves, on exactly the six rest-server sites `--fix` rewrote. 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 326dfc460d..0b715d0dd1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 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:5060`, `:6474`, `:6722`, `:7153`, `:7346` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5092`, `:6518`, `:6766`, `:7197`, `:7390` | | 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:422`, `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` | From 232da30e7d8301c62f6e8379b9b1fd85f16a6709 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:40:52 +0000 Subject: [PATCH 08/12] docs(permissions): re-anchor the system-context census after the second merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `origin/main` moved again while this branch was being verified, and its new tip moves `rest-server.ts` a second time — so row 50's five REST anchors conflicted for exactly the same reason as the first merge. Re-anchored with the gate's own `--fix`, a pure shift again. Structural verification repeated with one instrument over main, this branch's previous tip and the merged tree: 106 sites, 20 packages, 45 files, 871 identifier appearances, 462 classified, 0 stale ledger rows on all three, and shift-invariant site identity shows 0 arrived / 0 vanished in both directions. Only `file:line` identity moves. 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 0b715d0dd1..e806295d11 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 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:5092`, `:6518`, `:6766`, `:7197`, `:7390` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5128`, `:6554`, `:6802`, `:7233`, `:7426` | | 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:422`, `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` | From dd0dd4b22c438fcfa0515699e09af5f45ac464df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:23:39 +0000 Subject: [PATCH 09/12] docs(permissions): re-anchor the system-context census after the main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node scripts/check-system-context-census.mjs --fix` rewrote the ten `packages/rest/src/rest-server.ts` anchors displaced by this branch's +44-line change to that file. Pure line rot: the census population is unchanged against `origin/main` — 107 sites, 873 identifier appearances, 463 classified, 0 arrived, 0 vanished — and the gate now reports 141 anchors resolving with 27 declared non-read. 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 6d1f6564ca..04bb5a3778 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:1553`, `:1582`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1565`, `:1594`), 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:246` | | 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:1585` | +| 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` | ### 2. Write pipeline and data integrity @@ -159,7 +159,7 @@ The largest single consumer — **17 of the 107 sites**. |:--|:---|:---|:---|:---| | 49 | 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` | | 50 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 51 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5084`, `:6510`, `:6758`, `:7189`, `:7382` | +| 51 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5128`, `:6554`, `:6802`, `:7233`, `:7426` | | 52 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 51's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 53 | 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:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 54 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -200,7 +200,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:1580` (#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:1553`, `:1582`; `domains/actions.ts:414` | +| "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` | --- From 72da90c07f56f63f2b3b3bb124c88d6e49353c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:27:54 +0000 Subject: [PATCH 10/12] docs(permissions): re-anchor the system-context census after the second merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node scripts/check-system-context-census.mjs --fix` rewrote the same ten `packages/rest/src/rest-server.ts` anchors, displaced again by this branch's +44-line change to that file. The instrument itself moved on main in this window, so every figure was re-derived with the merged tree's census rather than carried over: pure line rot, `--fix` did not refuse, and the population is unchanged against `origin/main` — 106 sites, 885 identifier appearances, 462 classified, 0 arrived, 0 vanished. The gate now reports 140 anchors resolving with 27 declared non-read. 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 1c5ffe7baf..b2a31285b5 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:1553`, `:1582`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1565`, `:1594`), 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:1585` | +| 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` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 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:5084`, `:6510`, `:6758`, `:7189`, `:7382` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5128`, `:6554`, `:6802`, `:7233`, `:7426` | | 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:422`, `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:1553`, `:1582`; `domains/actions.ts:414` | +| "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` | --- From 005ff8904a0a074e6068ac3bb349640e315a1fb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:02:07 +0000 Subject: [PATCH 11/12] docs(permissions): re-anchor the system-context census after the third merge Main landed another `packages/rest/src/rest-server.ts` comment change while the previous sync was being verified, displacing the same ten anchors again. `node scripts/check-system-context-census.mjs --fix` rewrote them; it did not refuse, so this is a pure shift. Re-derived against `origin/main` abdceef8c68 with the merged tree's own census instrument: 106 sites, 885 identifier appearances, 462 classified, 19 packages, 44 files, 0 arrived, 0 vanished, and scannedFiles 293 as a non-zero control on both sides. Gate: 140 anchors resolve, 27 declared non-read. 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 b2a31285b5..10b9cc5fbb 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 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:5128`, `:6554`, `:6802`, `:7233`, `:7426` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5127`, `:6553`, `:6801`, `:7232`, `:7425` | | 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:422`, `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` | From 18dad691616831704abc2587f99244854ef7ce27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:25:06 +0000 Subject: [PATCH 12/12] docs(permissions): re-anchor the system-context census after the fourth merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--fix` rewrote 5 anchors on row 50 and REFUSED ZERO files — the signal that this is a pure line shift, not a population change. Verified structurally against `origin/main`'s own census rather than by reading the rewritten lines: same 105 elevation read sites, same 44 files, same 19 packages, same per-site identity text, `staleLedgerRows` empty on both sides, and the only delta a +12 / +44 shift inside `packages/rest/src/rest-server.ts` — this branch's two insertion points. Zero sites arrived, zero vanished. 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 1d5efc7eb1..a2b06832eb 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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:5101`, `:6527`, `:6775`, `:7206`, `:7399` | +| 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` | | 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:422`, `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` |