From 570a2ea5587e2f580ef419ad4b03a23a7fc1184b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 01:43:11 +0000 Subject: [PATCH 1/5] feat(spec): declare the save door's request members and de-cast the REST PUT literal (wip) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- packages/rest/src/rest-server.ts | 21 ++++- packages/spec/src/api/protocol.zod.ts | 108 +++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 70cd972cc0..0920a4d93a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -89,6 +89,7 @@ import type { PublishMetaItemRequest, AuditMetaItemRequest, HistoryMetaItemRequest, + SaveMetaItemRequest, DeleteMetaItemRequest, } from '@objectstack/spec/api'; // [#8073] The closed ADR-0112 error vocabulary, so the explain family's single @@ -5797,7 +5798,22 @@ export class RestServer { const organizationId = organizationIdForMetaWrite( canonicalMetaUrlType(req.params.type), ctx?.tenantId, ); - const result = await p.saveMetaItem({ + // [#12004] The `as any` cast this call carried came off + // when `SaveMetaItemRequestSchema` caught up with the + // members this door sends. `saveMetaItem` is a REQUIRED + // protocol member, so unlike the publish door's old cast + // (member existence, TS2339) this one was load-bearing on + // REQUEST SHAPE alone: the schema declared only + // `{ type, name, item }`, and removing the cast surfaced + // TS2353 on every other key. The literal is now compiled + // against the spec contract through the #9741 + // `TransportScopedMetaRequest` wrapper — `environmentId` + // is the transport-level routing key that wrapper layers + // on, ⛔ never a protocol key; every other key here is + // checked against the declared request, so an undeclared + // member is a compile error instead of a payload member no + // contract has ever seen. + const saveRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, item, @@ -5820,7 +5836,8 @@ export class RestServer { ...((typeof req.query?.mode === 'string' && req.query.mode.toLowerCase() === 'draft') ? { mode: 'draft' } : {}), - } as any); + }; + const result = await p.saveMetaItem(saveRequest); res.json(result); } catch (error: any) { handleRouteError(res, error); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index df69e0725e..a49e0ec997 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -566,7 +566,21 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ /** * Save Metadata Item Request - * Create or update a metadata item + * Create or update a metadata item — the request shape for + * `PUT /api/v1/meta/:type/:name` (the `saveMetaItem` protocol method). + * + * Declared member for member against the implementation's parameter type in + * `@objectstack/metadata-protocol` and the REST save door's actual sends — a + * declared-surface catch-up, not a new capability (#12004, the #11006 + * maintainer-ruled pattern, 2026-08-22 option B, carried one door over + * exactly as #11679/PR #12003 carried it to the reset twin). `saveMetaItem` + * is a REQUIRED protocol member, so this was the sharpest instance of the + * request-shape gap: the schema declared 3 of the ~11 members the door + * sends, and the call-site literal had to stay behind an `as any` cast + * (removing it surfaced `TS2353` on the undeclared keys — pure + * request-shape smuggling, never member-existence feature detection). + * Every member below already ships and is read and enforced by the + * implementation. * * `name` carries the enforced item-name grammar (#12194 — lowercase * snake_case segments, optionally dot-qualified; `shared/identifiers.zod.ts` @@ -574,6 +588,30 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ * the door with `400 INVALID_REQUEST`, so declared = enforced. The read and * delete request shapes deliberately stay `z.string()`: pre-grammar residue * rows must remain listable and clearable. + * + * Two members the implementation's parameter type family carries are + * deliberately NOT declared: + * + * - `environmentId` — the TRANSPORT-level multi-kernel routing key, OUT of + * protocol request shapes by the #9741 maintainer ruling (2026-08-18): + * `resolveProtocol(environmentId)` has already selected the target kernel + * before this method is entered, and `packages/rest` layers that one + * member on top of the declared shape via its `TransportScopedMetaRequest` + * wrapper, which is where a routing key belongs. + * - `source` — write-provenance for the history/audit rows + * (`'protocol.saveMetaItem'` by default). The implementation declares it, + * but NO door sends it: its only producer is the implementation's own + * internal `migrateStoredMetadata` call (`'migrate-stored'`), which does + * not travel through this contract. The publish-door precedent (#11426) + * leaves such a member undeclared until a producer on THIS contract pulls + * it — declaring it here would advertise a wire-authorable provenance + * channel the REST layer deliberately never reads. + * + * `writeFace` IS declared, and the distinction with `source` is the point: + * both are server-stated, but `writeFace` is sent by two doors and the + * duplicate-package internal call — three real producers on this parameter + * — and the implementation branches its refusal envelopes on it. See the + * member's own doc for why declaring it does not make it client-authorable. */ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), @@ -583,6 +621,74 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ + 'the publish door.', ), item: z.unknown().describe('Metadata item definition'), + organizationId: z.string().optional().describe( + 'Organization (tenant) scope for the write. Load-bearing, not advisory: ' + + 'it selects the overlay partition (ADR-0005) the row lands in — an ' + + 'org-scoped save writes that tenant\'s own overlay, while an org-less ' + + 'save writes the environment-wide row every tenant reads — and it is ' + + 'the scope stamped on the write\'s audit row. An org-scoped write of a ' + + 'type whose registry entry declares `allowOrgOverride: false` is ' + + 'refused (403). Absent = environment-wide.', + ), + parentVersion: z.string().nullable().optional().describe( + 'ADR-0008 optimistic-concurrency pin: the version token the caller ' + + 'believes is current (on the REST door, the `If-Match` request ' + + 'header). Present as a string, a concurrent edit is reported as a 409 ' + + 'conflict instead of silently overwritten. ⚠️ `null` is NOT the same ' + + 'as absent: a present `null` asserts "no current row of this ' + + 'lifecycle" — the first-write pin, refused 409 when a row already ' + + 'exists — while an ABSENT key is unpinned: the implementation adopts ' + + 'the current row\'s hash as the parent (last-write-wins). Nullable ' + + 'because that is the implementation\'s parameter type, and unlike the ' + + 'reset twin (which folds a present `null` back to the current hash) ' + + 'this verb passes `null` through to the repository\'s conflict check ' + + 'unchanged.', + ), + actor: z.string().optional().describe( + 'Identity recorded on the write\'s history event (`recorded_by`, a ' + + 'lookup into `sys_user`) and audit row. On the REST door this is the ' + + 'request\'s authenticated identity (one producer) — never a ' + + 'caller-supplied header. Absent, the event is recorded actor-less ' + + '(null), deliberately not attributed to "system".', + ), + force: z.boolean().optional().describe( + 'Destructive-change acknowledgement (`?force=true` on the REST door): ' + + 'skips the safety diff that refuses an `object` save whose body drops ' + + 'fields or narrows types the stored item still carries (409 with the ' + + 'findings otherwise). Only `object` saves reach that diff, so the ' + + 'flag is inert for every other type. Absent = the guard runs.', + ), + mode: z.enum(['draft', 'publish']).optional().describe( + 'Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a ' + + 'pending draft overlay (`?mode=draft` on the REST door; the publish ' + + 'door promotes it later); `publish` or ABSENT writes straight to the ' + + 'live `active` row — the legacy default, kept so callers that ' + + 'predate the draft/publish split keep working. Any value other than ' + + '`draft` is read as `publish`.', + ), + packageId: z.string().nullable().optional().describe( + 'ADR-0048 — the software package to bind the saved row to ' + + '(`sys_metadata.package_id`; `?package=` on the REST door, sent ' + + 'only when it names a real package). Set when authoring inside a ' + + 'Studio package workspace; a named read-only base package is refused. ' + + 'On create the row is stamped with this id; on update an existing ' + + 'binding is preserved, never silently re-bound. Absent = env-local ' + + 'overlay (no package stamp); it also scopes which row the unpinned ' + + 'parent-version resolution reads.', + ), + writeFace: z.enum(['package-duplicate', 'meta-envelope', 'meta-dispatch']).optional().describe( + 'Which write door a refusal is being rendered FOR — stated by the ' + + 'SERVER, never by a remote caller: every door builds this request ' + + 'field by field and never spreads a request body into it, so there is ' + + 'no path for a client to smuggle a face in, and a face arriving in a ' + + 'wire body is simply never read. Two refusals branch on it, for ' + + 'different questions: the 409 destructive-change remedy names the ' + + 'acknowledgement mechanism that actually exists on the refusing door ' + + '(`?force=true` on the REST doors; the dispatcher and the ' + + 'duplicate-package door have none), and the 422 invalid-metadata ' + + 'message adapts to whether a structured `issues[]` channel reaches ' + + 'the consumer beside it. Absent = the conservative default wording.', + ), })); /** From c8bb90f82d7018e085009cde3fa534f54d2a8166 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:02:42 +0000 Subject: [PATCH 2/5] test(spec): pin the save request mirror, vocabularies, and the two deliberate non-declarations Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- packages/spec/authorable-surface/api.json | 7 + packages/spec/src/api/protocol.test.ts | 185 ++++++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 43d3d2c2d8..6a10d20d70 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1553,9 +1553,16 @@ "api/RuntimeAuthoringIssue:rule", "api/RuntimeAuthoringIssue:severity", "api/RuntimeAuthoringIssue:where", + "api/SaveMetaItemRequest:actor", + "api/SaveMetaItemRequest:force", "api/SaveMetaItemRequest:item", + "api/SaveMetaItemRequest:mode", "api/SaveMetaItemRequest:name", + "api/SaveMetaItemRequest:organizationId", + "api/SaveMetaItemRequest:packageId", + "api/SaveMetaItemRequest:parentVersion", "api/SaveMetaItemRequest:type", + "api/SaveMetaItemRequest:writeFace", "api/SaveMetaItemResponse:advisories", "api/SaveMetaItemResponse:message", "api/SaveMetaItemResponse:projectionApplied", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index f2d143c300..0e9fccede8 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -2365,6 +2365,191 @@ describe('MetadataProtocol.deleteMetaItem types against the caught-up request sc }); }); +import { SaveMetaItemRequestSchema } from './protocol.zod'; +import type { SaveMetaItemRequest, SaveMetaItemResponse } from './protocol.zod'; + +describe('SaveMetaItemRequestSchema declares the contract members the save door sends (#12004)', () => { + // The biggest remaining member of the request-shape family the reset twin + // above belongs to: `saveMetaItem` is a REQUIRED protocol member (so a scan + // for undeclared members walked past it), while the request schema declared + // 3 of the ~11 members `PUT /meta/:type/:name` sends — which is why the + // call-site cast could not come off (TS2353 on every undeclared key, pure + // request-shape smuggling, never feature detection). The measure is the + // implementation's parameter type in `@objectstack/metadata-protocol` — + // `{ type, name, item?, organizationId?, parentVersion?, actor?, force?, + // mode?, packageId?, source?, writeFace? }` — and the REST door's actual + // sends. As in the sibling blocks above, accept-pins assert the parsed + // VALUE: this is a non-strict object, so `success` alone is exactly the + // silent-strip state this family of cards closes. + + // `item` rides every fixture: the untouched `z.unknown()` member is + // measured key-REQUIRED at parse (absent key refused; a present `null` + // parses and is then refused 400 by the implementation's own guard) — + // pre-existing contract behaviour this card deliberately does not move. + const base = { type: 'view', name: 'account_list', item: { label: 'Account list' } } as const; + + it('accepts the full request and PRESERVES every member through parse', () => { + const full = { + ...base, + organizationId: 'org_alpha', + parentVersion: 'sha256:abc123', + actor: 'admin@objectos.ai', + force: true, + mode: 'draft', + packageId: 'com.example.crm', + writeFace: 'meta-envelope', + }; + const result = SaveMetaItemRequestSchema.safeParse(full); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(full); + } + }); + + it('requires type AND name — the save addresses one item', () => { + expect(SaveMetaItemRequestSchema.safeParse(base).success).toBe(true); + expect(SaveMetaItemRequestSchema.safeParse({ type: 'view', item: {} }).success).toBe(false); + expect(SaveMetaItemRequestSchema.safeParse({ name: 'account_list', item: {} }).success).toBe(false); + }); + + it('keeps item key-required at parse — the measured pre-existing contract', () => { + // Absent key is refused at parse; an explicit `null` PARSES (the + // implementation's own opening guard then refuses it 400) — so the + // wire-reachable "explicitly null envelope" keeps reaching the guard + // that owns its error message rather than dying as an anonymous parse + // failure. + expect(SaveMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list' }).success).toBe(false); + expect(SaveMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', item: null }).success).toBe(true); + }); + + it('the two optional strings stay optional and reject non-strings — values, not bags', () => { + for (const key of ['organizationId', 'actor'] as const) { + const absent = SaveMetaItemRequestSchema.safeParse(base); + expect(absent.success).toBe(true); + if (absent.success) { + expect(key in (absent.data as object)).toBe(false); + } + expect(SaveMetaItemRequestSchema.safeParse({ ...base, [key]: 42 }).success).toBe(false); + expect(SaveMetaItemRequestSchema.safeParse({ ...base, [key]: { v: 'x' } }).success).toBe(false); + } + }); + + it('parentVersion is the nullable OCC pin — string pins, null is the first-write pin, absent is unpinned', () => { + // Unlike the reset twin above (plain optional string — its implementation + // folds a present `null` back to the current hash), the save verb passes + // `null` through to the repository conflict check unchanged, so the + // absent-vs-null distinction is a real, enforced contract member here and + // the schema mirrors the implementation's `string | null`. + expect(SaveMetaItemRequestSchema.safeParse({ ...base, parentVersion: 'sha256:abc' }).success).toBe(true); + const withNull = SaveMetaItemRequestSchema.safeParse({ ...base, parentVersion: null }); + expect(withNull.success).toBe(true); + if (withNull.success) { + // Preserved as null, never stripped to absent — the two spell + // different pins. + expect('parentVersion' in (withNull.data as object)).toBe(true); + expect((withNull.data as { parentVersion: unknown }).parentVersion).toBeNull(); + } + expect(SaveMetaItemRequestSchema.safeParse({ ...base, parentVersion: 42 }).success).toBe(false); + }); + + it('keeps force boolean and the mode / writeFace vocabularies closed', () => { + expect(SaveMetaItemRequestSchema.safeParse({ ...base, force: false }).success).toBe(true); + // The REST door only ever SENDS `force: true` (conditional spread), but + // the contract member is a boolean, mirroring the implementation. + expect(SaveMetaItemRequestSchema.safeParse({ ...base, force: 'true' }).success).toBe(false); + expect(SaveMetaItemRequestSchema.safeParse({ ...base, mode: 'draft' }).success).toBe(true); + expect(SaveMetaItemRequestSchema.safeParse({ ...base, mode: 'publish' }).success).toBe(true); + expect(SaveMetaItemRequestSchema.safeParse({ ...base, mode: 'stage' }).success).toBe(false); + for (const face of ['package-duplicate', 'meta-envelope', 'meta-dispatch'] as const) { + expect(SaveMetaItemRequestSchema.safeParse({ ...base, writeFace: face }).success).toBe(true); + } + expect(SaveMetaItemRequestSchema.safeParse({ ...base, writeFace: 'rest' }).success).toBe(false); + }); + + it('packageId mirrors the implementation nullable — and null is preserved, not stripped', () => { + expect(SaveMetaItemRequestSchema.safeParse({ ...base, packageId: 'com.example.crm' }).success).toBe(true); + const withNull = SaveMetaItemRequestSchema.safeParse({ ...base, packageId: null }); + expect(withNull.success).toBe(true); + if (withNull.success) { + expect('packageId' in (withNull.data as object)).toBe(true); + expect((withNull.data as { packageId: unknown }).packageId).toBeNull(); + } + expect(SaveMetaItemRequestSchema.safeParse({ ...base, packageId: 42 }).success).toBe(false); + }); + + it('does not declare environmentId — transport-level by the #9741 ruling, stripped and shape-absent', () => { + // Same regression guard as the meta-read, publish, audit, history and + // reset blocks above: the save door DOES spread `environmentId` into its + // outgoing payload, and that member rides `packages/rest`'s + // `TransportScopedMetaRequest` envelope — never this schema. If someone + // declares it, this test names the ruling they are overturning + // (2026-08-18 on #9741). + const result = SaveMetaItemRequestSchema.safeParse({ ...base, environmentId: 'env_alpha' }); + expect(result.success).toBe(true); + if (result.success) { + expect('environmentId' in (result.data as object)).toBe(false); + } + const shape = (SaveMetaItemRequestSchema as unknown as { shape: Record }).shape; + expect(Object.keys(shape)).not.toContain('environmentId'); + }); + + it('does not declare source — implementation-internal provenance with no producer on this contract', () => { + // The implementation declares `source?: string` (its own + // `migrateStoredMetadata` call sends 'migrate-stored'), but NO door sends + // it and the REST layer deliberately never reads a client-supplied + // provenance — the publish-door precedent (#11426) leaves such a member + // undeclared until a producer on THIS contract pulls it. Declaring it + // would advertise a wire-authorable provenance channel that does not + // exist. + const result = SaveMetaItemRequestSchema.safeParse({ ...base, source: 'studio' }); + expect(result.success).toBe(true); + if (result.success) { + expect('source' in (result.data as object)).toBe(false); + } + const shape = (SaveMetaItemRequestSchema as unknown as { shape: Record }).shape; + expect(Object.keys(shape)).not.toContain('source'); + }); +}); + +describe('MetadataProtocol.saveMetaItem types against the caught-up request schema (#12004)', () => { + // The member declaration itself long predates this card — `saveMetaItem` + // is REQUIRED on `MetadataProtocol` — so these pins are the request-shape + // half: what turns red if the schema drops back to `{ type, name, item }` + // (the member would still exist; the door literal would stop compiling) or + // if a key drifts off the implementation's vocabulary. + + it('declares the member REQUIRED, against the save request/response schemas', () => { + expectTypeOf().toEqualTypeOf< + (request: SaveMetaItemRequest) => Promise + >(); + }); + + it('refuses an undeclared key at the member call shape — the TS2353 half this card names', () => { + const good: SaveMetaItemRequest = { + type: 'view', + name: 'account_list', + item: { label: 'Account list' }, + organizationId: 'org_alpha', + parentVersion: null, + actor: 'admin', + force: true, + mode: 'draft', + packageId: null, + writeFace: 'meta-dispatch', + }; + expect(good.type).toBe('view'); + // @ts-expect-error `environmentId` is transport-level (#9741) — not a declared request member; the REST door layers it on via TransportScopedMetaRequest. + const withEnv: SaveMetaItemRequest = { type: 'view', name: 'account_list', environmentId: 'env_a' }; + expect(withEnv.name).toBe('account_list'); + // @ts-expect-error `source` is implementation-internal provenance — no producer on this contract sends it, and the REST layer never reads it off the wire. + const withSource: SaveMetaItemRequest = { type: 'view', name: 'account_list', source: 'studio' }; + expect(withSource.name).toBe('account_list'); + // @ts-expect-error an undeclared (here: misspelt) key is refused at the call shape. + const misspelt: SaveMetaItemRequest = { type: 'view', name: 'account_list', writeface: 'meta-envelope' }; + expect(misspelt.name).toBe('account_list'); + }); +}); + // ========================================== // Meta history / diagnostics response conformance (#12038) // ========================================== From 7eb98b1867c47df0887f9c7658e52edf9dd99a4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:30:19 +0000 Subject: [PATCH 3/5] chore: changeset pair for the save-door declaration + de-cast Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- .changeset/rest-meta-save-cast-retired.md | 27 +++++++++++++++++++ .../spec-save-meta-item-request-members.md | 19 +++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 .changeset/rest-meta-save-cast-retired.md create mode 100644 .changeset/spec-save-meta-item-request-members.md diff --git a/.changeset/rest-meta-save-cast-retired.md b/.changeset/rest-meta-save-cast-retired.md new file mode 100644 index 0000000000..d89d49b3fe --- /dev/null +++ b/.changeset/rest-meta-save-cast-retired.md @@ -0,0 +1,27 @@ +--- +"@objectstack/rest": patch +--- + +refactor(rest): the save door call site is compiled against the declared contract (#12004) + +The `PUT /meta/:type/:name` door in `packages/rest/src/rest-server.ts` handed +`p.saveMetaItem` a request literal cast `as any`, so the compiler checked +nothing about the ~11 members it built. Unlike the publish door's old cast +(member existence, TS2339) and exactly like the reset twin's (#11679), this +cast was load-bearing on **request shape** alone: `saveMetaItem` is a REQUIRED +protocol member, but the schema declared only `{ type, name, item }`, so +removing the cast surfaced TS2353 on every other key. + +With `SaveMetaItemRequestSchema` caught up (the spec half of this landing), +the request is now a named const typed as +`TransportScopedMetaRequest` — the reset-door spelling, +because this door still spreads the transport-level `environmentId` +(long-standing wire shape, deliberately unchanged; the #9741 ruling keeps it +layered on by the wrapper rather than becoming a protocol key). + +**No behaviour change of any kind, and nothing about the wire moves.** The +outgoing payload is byte-identical (same keys, same conditional spreads, same +`writeFace: 'meta-envelope'` server-stated face); the capability gate, the 501 +guard, the If-Match / `?force` / `?package` / `?mode=draft` derivations and the +error envelopes are all untouched. An undeclared key in the literal is now a +compile error instead of a payload member no contract has ever seen. diff --git a/.changeset/spec-save-meta-item-request-members.md b/.changeset/spec-save-meta-item-request-members.md new file mode 100644 index 0000000000..54951f99b8 --- /dev/null +++ b/.changeset/spec-save-meta-item-request-members.md @@ -0,0 +1,19 @@ +--- +"@objectstack/spec": minor +--- + +**`SaveMetaItemRequestSchema` declares the contract members the save door sends** (#12004 — the #11006 maintainer-ruled pattern, 2026-08-22 option B, carried one door over exactly as #11679/PR #12003 carried it to the reset twin and #12005/PR #13521 to the history door). + +`PUT /api/v1/meta/:type/:name` — the save door — was the biggest remaining request-shape gap in the meta write family: `saveMetaItem` is a REQUIRED protocol member (so a scan for undeclared members walked past it), while its request schema declared 3 of the ~11 members the REST door sends, and the call-site literal had to stay behind an `as any` cast (removing it surfaced `TS2353` on every undeclared key — pure request-shape smuggling, never member-existence feature detection). + +Additive, not breaking — every member below already ships, and each is read and enforced by the implementation in `@objectstack/metadata-protocol`, whose parameter type is mirrored member for member: + +- `organizationId?` — the write-side tenant partition (ADR-0005): selects WHICH overlay row the save writes, is stamped on the audit row, and an org-scoped write of a non-overridable type is refused 403. +- `parentVersion?: string | null` — the ADR-0008 optimistic-concurrency pin (`If-Match` on the REST door). Nullable, unlike the reset twin's plain optional string, because this verb passes a present `null` through to the repository conflict check unchanged: `null` is the first-write pin (409 when a row already exists), absent is unpinned (the implementation adopts the current hash — last-write-wins). +- `actor?` — identity recorded on the history event (`recorded_by`) and audit row; on the REST door the request's authenticated identity, never a caller-supplied header. +- `force?` — the destructive-change acknowledgement (`?force=true`): skips the safety diff that refuses an `object` save dropping fields or narrowing types the stored item still carries. +- `mode?: 'draft' | 'publish'` — the ADR-0005 per-item lifecycle; anything but `draft` (absent included) is the legacy straight-to-live default. +- `packageId?: string | null` — the ADR-0048 package binding (`?package=`); a named read-only base package is refused, and absent keeps the env-local overlay. +- `writeFace?: 'package-duplicate' | 'meta-envelope' | 'meta-dispatch'` — which write door a refusal is rendered FOR. Server-stated: every producer builds the request field by field and never spreads a wire body into it, so a client cannot smuggle a face in; declared because it is a real implementation parameter with three server-side producers and two refusal renderers branching on it (the 409 destructive-change remedy and the 422 findings clause). + +Two members stay deliberately OUT: `environmentId` (transport-level multi-kernel routing key, the #9741 ruling — it rides `packages/rest`'s `TransportScopedMetaRequest` wrapper, never a protocol schema) and `source` (implementation-internal write provenance whose only producer is the implementation's own migrate call; the #11426 publish precedent leaves it undeclared until a producer on this contract pulls it). Both non-declarations are pinned shape-absent. From 397d9eaf161ef8723a5ac5438fcbac65c344fd5d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:33:52 +0000 Subject: [PATCH 4/5] docs(spec): regenerate protocol.mdx for the save request member rows Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- content/docs/references/api/protocol.mdx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 357e83e356..39e6fe605c 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2523,6 +2523,13 @@ Installed package with runtime lifecycle state | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at the publish door. | | **item** | `any` | ✅ | Metadata item definition | +| **organizationId** | `string` | optional | Organization (tenant) scope for the write. Load-bearing, not advisory: it selects the overlay partition (ADR-0005) the row lands in — an org-scoped save writes that tenant's own overlay, while an org-less save writes the environment-wide row every tenant reads — and it is the scope stamped on the write's audit row. An org-scoped write of a type whose registry entry declares `allowOrgOverride: false` is refused (403). Absent = environment-wide. | +| **parentVersion** | `string \| null` | optional | ADR-0008 optimistic-concurrency pin: the version token the caller believes is current (on the REST door, the `If-Match` request header). Present as a string, a concurrent edit is reported as a 409 conflict instead of silently overwritten. ⚠️ `null` is NOT the same as absent: a present `null` asserts "no current row of this lifecycle" — the first-write pin, refused 409 when a row already exists — while an ABSENT key is unpinned: the implementation adopts the current row's hash as the parent (last-write-wins). Nullable because that is the implementation's parameter type, and unlike the reset twin (which folds a present `null` back to the current hash) this verb passes `null` through to the repository's conflict check unchanged. | +| **actor** | `string` | optional | Identity recorded on the write's history event (`recorded_by`, a lookup into `sys_user`) and audit row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | +| **force** | `boolean` | optional | Destructive-change acknowledgement (`?force=true` on the REST door): skips the safety diff that refuses an `object` save whose body drops fields or narrows types the stored item still carries (409 with the findings otherwise). Only `object` saves reach that diff, so the flag is inert for every other type. Absent = the guard runs. | +| **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. | +| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, sent only when it names a real package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent = env-local overlay (no package stamp); it also scopes which row the unpinned parent-version resolution reads. | +| **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher and the duplicate-package door have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. | --- From 722b069fa7f5c361b7d94861404e4bdd940e0264 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:48:19 +0000 Subject: [PATCH 5/5] docs(permissions): re-anchor the eight rest-server.ts isSystem citations the save-door diff shifted check-system-context-census caught pure line rot: the new import line shifted every site below it by +1 and the de-cast door block shifted the three sites below it by +17. The gate's --fix refused (same population-sanity reason as the history-door landing), so the eight anchors are rewritten by hand to the lines the census and ledger already resolve to. Census now: 109 sites all anchored, 145 anchors resolve, 27 declared non-read. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- 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 46808f80a4..837831500d 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:1239`, `:1268`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1240`, `:1269`), and neither can an action body (`packages/runtime/src/domains/actions.ts:404`). 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:1271` | +| 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:1272` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4283`, `:5646`, `:5878`, `:6241`, `:6434` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` | | 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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:1451` (#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:273` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1239`, `:1268`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` | ---