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
31 changes: 31 additions & 0 deletions .changeset/rest-meta-history-cast-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/rest": patch
---

refactor(rest): the history door call site is compiled against the declared contract (#12005)

The `GET /meta/:type/:name/history` door in `packages/rest/src/rest-server.ts`
reached its protocol method through `(p as any)` — once for the
feature-detection guard, once for the call — so the compiler checked nothing
about the request literal it built. The cast was load-bearing on **member
existence** (`historyMetaItem` was undeclared in `packages/spec` entirely —
removing the cast answered `TS2339`), the same half the audit twin's cast
carried before #11678.

With `MetadataProtocol.historyMetaItem` declared (the spec half of this
landing), the guard is now `if (!p.historyMetaItem)` and the request is a named
const typed as `TransportScopedMetaRequest<HistoryMetaItemRequest>` — the
reset-door spelling, not the audit door's plain request type, 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
`Number.isFinite` drops); the 501 refusal is untouched (its bare-string
envelope remains the #7035-family ratcheted debt it already was — converging it
is a behaviour change this declaration must not smuggle). The guard survives
with identical truthiness semantics: the member is declared **optional** (a
kernel may implement neither door), and the guard is also what narrows it to
callable at the call site. An undeclared key in the literal is now a compile
error instead of a payload member no contract has ever seen.
13 changes: 13 additions & 0 deletions .changeset/spec-history-meta-item-member.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": minor
---

**`MetadataProtocol` declares the optional `historyMetaItem` member, and the history door's request/response schemas join the spec** (#12005 — the #11006 maintainer-ruled pattern, 2026-08-22 option B, carried one door over exactly as #11678/PR #12003 carried it to the audit twin).

`GET /api/v1/meta/:type/:name/history` — the durable change-log behind Studio's History tab — was the last undeclared read door of the audit/history pair: `historyMetaItem` appeared nowhere in `packages/spec`, so the REST door reached the verb through `(p as any)` twice (feature-detection guard + call) and its request literal was compiled against nothing.

Additive, not breaking:

- `HistoryMetaItemRequestSchema` / `HistoryMetaItemRequest` — `{ type, name, organizationId?, sinceSeq?, limit? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `organizationId` is a plain optional `string` (not nullable like the audit twin's) because that is this implementation's declared type — and this door currently sends no organization at all (the #8747-family tenant-scoping question stays a separate measurement, deliberately unanswered here). `sinceSeq` is the exclusive lower bound on `seq` for pagination. `limit` declares no bounds because the implementation forwards it unclamped with no default (unlike the audit twin's [1, 500] clamp). `environmentId` stays out by the #9741 ruling (transport-level routing key); this door still spreads it on the wire, and that member rides the REST `TransportScopedMetaRequest` wrapper, never the protocol schema.
- `HistoryMetaItemResponseSchema` / `HistoryMetaItemResponse` — the `{ events: [...] }` body, oldest first, transcribing `MetadataEventSchema` from `@objectstack/metadata-core` (ADR-0008 §2.4) with the closed `op` vocabulary (create/update/delete/rename/publish/revert). Two deliberate widenings against the source schema so the contract cannot refuse bodies the shipped verb yields: `ref.type` is a plain string rather than the static registry enum (plugin runtime-create types flow through this door — the #12038 1C anti-freezing reasoning), and `ref.name` carries no spelling regex. `{ events: [] }` is the honest answer for a clean change log or a non-overlay type — never for a missing capability (501 before the call).
- `MetadataProtocol.historyMetaItem?(request: HistoryMetaItemRequest): Promise<HistoryMetaItemResponse>` — optional like its `auditMetaItem` / `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error.
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:1238`, `:1267`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1239`, `:1268`), 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:1270` |
| 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` |

### 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:4282`, `:5645`, `:5877`, `:6222`, `:6415` |
| 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` |
| 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:1238`, `:1267`; `domains/actions.ts:404` |
| "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` |

---

Expand Down
Loading
Loading