Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/rest-meta-save-cast-retired.md
Original file line number Diff line number Diff line change
@@ -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<SaveMetaItemRequest>` — 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.
19 changes: 19 additions & 0 deletions .changeset/spec-save-meta-item-request-members.md
Original file line number Diff line number Diff line change
@@ -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=<id>`); 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.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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` |

---

Expand Down
7 changes: 7 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` 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. |


---
Expand Down
21 changes: 19 additions & 2 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SaveMetaItemRequest> = {
type: req.params.type,
name: req.params.name,
item,
Expand All @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading