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
13 changes: 13 additions & 0 deletions .changeset/25467-e0-protocol-preamble-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/spec': patch
---

refactor(metadata-protocol): the module preamble of `protocol.ts` moves, verbatim, into 22 themed `protocol-*.ts` modules

Clause-②: no
Metadata-protocol: none

A move with no behaviour change. Every top-level declaration that stood above `ObjectStackProtocolImplementation` in `src/protocol.ts` (183 statements: helpers, error classes, the query-parameter and filter vocabulary, request and event types) now lives in a themed module beside it, byte for byte with its comments. `protocol.ts` re-exports every name it exported, so `@objectstack/metadata-protocol`'s entry is unchanged: the published `index.d.ts` keeps the same declarations in the same order and the same ESM and CJS export list, and differs only in blank lines, the order of names in one `@objectstack/spec/api` import list and the member order of one literal union. Nothing to migrate.

`@objectstack/spec` ships its `liveness/` ledger: two evidence citations in `liveness/query.json` (`QUERY_AST_KEYS`, `WIRE_DOLLAR_ALIASES`) now name `protocol-list-query-params.ts`, the module those lines moved to. No schema, type or export of `@objectstack/spec` changes.
6 changes: 3 additions & 3 deletions docs/qa/platform-checklist/areas/studio-authoring.json
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@
"package-wide door: stage two drafts in the writable package and POST /api/v1/packages/<pkg id>/publish-drafts — both flip in one atomic release (the audit's 'Published all drafts in this package (one atomic release)' toast); capture the STATUS and the BODY separately, because the abort path answers 200 with data.success:false (see the clause below)",
"author an INVALID draft (a widget carrying a stray legacy key) and attempt its publish — the author-time gate must reject the draft→active transition (#4463)",
"meta forensics (after the two publishes of qa_lifecycle_probe — revision 1, then the second revision): GET /api/v1/meta/dashboard/qa_lifecycle_probe/history — the durable sys_metadata_history events must list BOTH published revisions (a dashboard is an overlay type, so history is real; a non-overlay type answers { events: [] } by design). History counts draft saves AND publishes, so two save-draft-then-publish rounds read v1 (draft), v2 (its publish), v3 (draft), v4 (publish): read the two PUBLISH versions off this body for the diff step, steps[9] (QA run #23166 read v2 and v4)",
"GET /api/v1/meta/dashboard/qa_lifecycle_probe/diff?from=<first publish version>&to=<second publish version> — the two publish versions read off /history in steps[8] (v2 and v4 on a clean two-round run; QA run #26715 read v2 and v5, because one extra If-Match draft save sat between them); the diff is SHALLOW by design — it names the changed TOP-LEVEL key (`widgets`, with its whole before/after value) and only the keys that differ, never the whole body and never a path inside the array (packages/metadata-protocol/src/protocol.ts#diffShallow: deep structural diffs are out of scope, Studio renders the full bodies side by side). ⛔ Not ?from=1&to=2: v1 → v2 is a draft save and its own publish, which differ in nothing (changed []), and a default previous-vs-current read can land on the same draft/publish pair",
"GET /api/v1/meta/dashboard/qa_lifecycle_probe/diff?from=<first publish version>&to=<second publish version> — the two publish versions read off /history in steps[8] (v2 and v4 on a clean two-round run; QA run #26715 read v2 and v5, because one extra If-Match draft save sat between them); the diff is SHALLOW by design — it names the changed TOP-LEVEL key (`widgets`, with its whole before/after value) and only the keys that differ, never the whole body and never a path inside the array (packages/metadata-protocol/src/protocol-write-face.ts#diffShallow: deep structural diffs are out of scope, Studio renders the full bodies side by side). ⛔ Not ?from=1&to=2: v1 → v2 is a draft save and its own publish, which differ in nothing (changed []), and a default previous-vs-current read can land on the same draft/publish pair",
"POST /api/v1/meta/dashboard/qa_lifecycle_probe/rollback with { toVersion: 1 }, driven under an authenticated session so the actor is attributable — the authenticated identity is the ONLY source ([#7941]; resolveMetaWriteActor no longer consults `X-Actor` at all); the runtime GET must then serve revision 1's body again and the rendered dashboard must show revision 1's widget",
"repeat the rollback (or another /meta write on this item, e.g. a follow-up draft save) while ALSO sending header `X-Actor: someone_else_entirely` on the authenticated request — the header must NOT change the recorded actor: the audit/history rows for that action still name the authenticated runner's own identity, never `someone_else_entirely` ([#7941] — pinned at the unit layer in packages/rest/src/meta-write-actor-identity.test.ts)",
"attempt a rollback with a missing/invalid toVersion and capture the 400 INVALID_REQUEST guard",
Expand Down Expand Up @@ -488,7 +488,7 @@
{
"clause": "diff names the changed top-level key, not a whole-body dump — GET .../diff between the two PUBLISH versions read off /history (v2 → v4 on a clean run) reports `widgets` under changed[] with its before and after values, and no top-level key whose value did not change",
"oracle": "api",
"verify": "changed[] carries an entry with path 'widgets' (the top-level key the second revision edited) whose from/to carry the two arrays, and no entry for a top-level key whose value is the same in both revisions; the diff is shallow by contract (protocol.ts#diffShallow), so a path inside the array (widgets[0].title) is NOT expected and its absence is not a FAIL, while an unchanged key listed, or the whole document under one path, is. A diff of a draft save against its own publish (from=1&to=2) reports changed [] and scores nothing either way",
"verify": "changed[] carries an entry with path 'widgets' (the top-level key the second revision edited) whose from/to carry the two arrays, and no entry for a top-level key whose value is the same in both revisions; the diff is shallow by contract (protocol-write-face.ts#diffShallow), so a path inside the array (widgets[0].title) is NOT expected and its absence is not a FAIL, while an unchanged key listed, or the whole document under one path, is. A diff of a draft save against its own publish (from=1&to=2) reports changed [] and scores nothing either way",
"evidence": "the diff response"
},
{
Expand Down Expand Up @@ -1993,7 +1993,7 @@
},
"source": [
"packages/metadata-protocol/src/runtime-authoring-gate.ts",
"packages/metadata-protocol/src/protocol.ts#protectionBlockRefusal",
"packages/metadata-protocol/src/protocol-authoring-refusals.ts#protectionBlockRefusal",
"packages/spec/src/ui/page.zod.ts#PAGE_SLOTS_TABS_RETIRED",
"packages/spec/src/ui/view.zod.ts#checkListViewChartBinding",
"packages/lint/src/validate-expressions.ts#validateStackExpressions",
Expand Down
89 changes: 89 additions & 0 deletions packages/metadata-protocol/src/protocol-audit-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the metadata
// audit entry, the pending-audit carrier and the preflight violation codes.
//
// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood
// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot
// are new. `protocol.ts` re-exports what it exported.
import {
type MetadataLock,
} from '@objectstack/spec/kernel';

/**
* One `sys_metadata_audit` row as a VALUE — exactly the entry
* `recordMetadataAudit` writes, named so a refusal can hand its row to whoever
* is in a position to make it durable instead of writing it where it stands.
*
* [#8594] Why the entry became a value. `promoteDraftForPublish` used to audit
* its own denials inline, and it has two callers standing on opposite sides of
* a transaction boundary: `publishMetaItem` runs it in no transaction of its
* own, `publishPackageDrafts` runs it inside ONE `engine.transaction()` for the
* whole batch (ADR-0067 D2). A denial row written inline therefore landed on
* the single-item route and ROLLED BACK on the batch route — destroyed by the
* very rollback its own refusal had caused, which is the defect #7748 exists to
* close. Making the row data lets the helper stay ignorant of whose transaction
* it is inside (it cannot know, and guessing is what broke) and puts the
* placement decision where the transaction actually is: in the caller.
*/
export interface MetadataAuditEntry {
type: string;
name: string;
/**
* [ADR-0131 D6/D7] Always environment-wide: the audit ledger is
* deployment-level, and no metadata write is organization-scoped any more.
* Typed `null` so an organization cannot be recorded by accident.
*/
organizationId?: null;
operation: 'save' | 'publish' | 'rollback' | 'delete' | 'reset';
outcome: 'allowed' | 'denied' | 'forced';
code: string;
lockState?: MetadataLock;
lockOverridden?: boolean;
actor?: string;
source?: string;
requestId?: string;
note?: string;
}

/** Attach the denial row `err` owes, and return `err` so call sites read as one `throw`. */
function withPendingAudit<E extends Error>(err: E, audit: MetadataAuditEntry): E {
(err as E & { __pendingAudit?: MetadataAuditEntry }).__pendingAudit = audit;
return err;
}

/**
* [#8595] The PRE-FLIGHT gate's refusal vocabulary — the closed set of codes
* `publishPackageDrafts` can refuse a batch with BEFORE it opens its
* transaction, and the spelling each one takes in `sys_metadata_audit.code`.
*
* Why a declared map and not `code.toLowerCase()`. `batch_aborted` (#8400)
* deliberately took ONE fixed value because its causal code is whatever the
* error catalog threw, and lower-casing that would turn this column's
* documented closed set into an open one that grows with the catalog. The
* pre-flight codes are a different animal — they are minted in
* `publishPackageDrafts`'s own pre-flight block, not caught from below — so the
* violation's own code CAN be carried honestly, which is what a compliance
* query filters on ("show me every publish refused by the namespace rule").
*
* The precedent's principle still binds, though: the column must not drift
* silently. Hence `Record<PreflightViolationCode, …>` over an index signature —
* a new pre-flight gate (ADR-0121's endpoint gates, should #2657 Part B bring
* them back) cannot reach this column without failing to compile until its
* audit spelling is declared here AND added to the column's documented set in
* `sys-metadata-audit.object.ts`. A mechanical lower-case would have let it
* land silently; a runtime fallback would have put an unreachable value in a
* documented closed set. The compiler is the enforcement.
*/
type PreflightViolationCode = 'NAMESPACE_PREFIX' | 'STORED_TYPE_NOT_CANONICAL';

const PREFLIGHT_AUDIT_CODE: Record<PreflightViolationCode, string> = {
// adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary
NAMESPACE_PREFIX: 'namespace_prefix',
// adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary
STORED_TYPE_NOT_CANONICAL: 'stored_type_not_canonical',
};

// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move.
export { PREFLIGHT_AUDIT_CODE, withPendingAudit };
export type { PreflightViolationCode };
120 changes: 120 additions & 0 deletions packages/metadata-protocol/src/protocol-authoring-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the authoring
// gate, the credential channel and the authoring channel contracts.
//
// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood
// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot
// are new. `protocol.ts` re-exports what it exported.

/**
* Pre-persistence authoring gate (ADR-0094 addendum seam; #3050).
*
* Unlike the post-persist {@link MetadataMutationProjector} (best-effort,
* never thrown), an authoring gate runs BEFORE persistence and REJECTS the
* write by throwing — it is the seam for domain invariants that must hold on
* every runtime-authored body regardless of which HTTP surface produced it
* (e.g. plugin-security's OWD posture gate: an environment may only TIGHTEN
* a packaged object's `sharingModel`).
*
* Invoked inside `saveMetaItem` for BOTH draft and publish-mode saves, after
* the ADR-0005 overlay/runtime-create authorization and the per-type spec
* validation — so `publishMetaItem` promotes an already-gated body and needs
* no second gate. Environment writes only: control-plane bootstrap writes
* (`environmentId === undefined`) are the package author's own channel and
* bypass the gate, mirroring the ADR-0005 gate above.
*/
export interface MetadataAuthoringGateContext {
/** Singular type name (e.g. `object`). */
type: string;
name: string;
/** Lifecycle the body is being saved into. */
state: 'draft' | 'active';
/** The body being persisted. */
body: unknown;
/** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */
isArtifactBacked: boolean;
/** The packaged (code-layer) baseline body when {@link isArtifactBacked}; the declaration an overlay customizes. */
declaredBody?: unknown;
}

export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise<void>;

/**
* [#20790] A metadata type's WRITE-ONLY credential channel — where the
* credentials its bodies carry are stored instead of in the body, on the
* platform's one secret seam (#7799: a `secret`-typed field the engine
* encrypts, masks on every read and dereferences only through
* `resolveSecretField`). Registered per type by the domain plugin that owns
* the type's credential-location table (the automation plugin holds `flow`'s),
* beside its authoring gate.
*
* Every write that lands a body at rest consults it:
* - `saveMetaItem` calls {@link store} immediately before the put, after the
* carry-forward, and persists what it returns;
* - the runtime authoring gate reads {@link heldPaths} as restored positions,
* on an active save and on the draft → active promotion;
* - a restore (rollback, revert) stores {@link strip}'s body.
*
* ⛔ Not a second secret mechanism and not a per-door redaction: the read
* projection stays the type's redactor; this only decides where a credential
* is STORED.
*/
export interface MetadataCredentialChannel {
/**
* Move every explicit credential in `body` into the channel for
* `(name, state)` and return the body without any — what is stored. An
* absent credential means "unchanged". THROWS (with an ADR-0112
* `code`/`status`) to refuse the save; nothing may have been put then.
*/
store(args: { name: string; state: 'draft' | 'active'; body: unknown }): Promise<unknown>;
/**
* The positions in `item` (dotted, item-relative — `redactedKeys`'
* spelling) whose credential is withheld from the body and held by the
* channel, so the gate reads them as present. `state: 'draft'` for a draft
* being promoted.
*/
heldPaths(args: { name: string; state: 'draft' | 'active'; item: unknown }): Promise<readonly string[]>;
/**
* The body a restore stores: every credential removed, and nothing written
* to the channel — it keeps its current credential.
*/
strip(body: unknown): unknown;
}

/**
* Which authoring channel a kernel's metadata writes arrive on (#6710).
*
* ADR-0005 carves out "the package author's own bootstrap channel" from the
* runtime authoring rules: a control-plane kernel installing a package is not
* an author publishing into a live tenant, and gating it would refuse the very
* bodies the platform ships. The carve-out is legitimate and stays.
*
* What #6710 changed is **how a kernel says it is that channel**. Until this
* type existed the answer was inferred from `environmentId === undefined` — a
* ROW-SCOPING key pressed into service as a topology signal. That proxy lies:
* the CLI's lightweight host-config assembler (`serve.ts`'s auto-register
* branch, `new ObjectQLPlugin()` with no options) also leaves `environmentId`
* undefined, and it serves an END-USER `PUT /api/v1/meta/*`. Both topologies
* read identically, so the whole #4463 gate — all the shared `AUTHORING_RULES`
* — was disengaged on a self-hosted app server. #5086 had already moved the
* code-only refusal off the same proxy for the same reason ("keying
* authorization off a row-scoping key is what made a type-level declaration
* depend on deployment topology; the declaration decides it here instead").
*
* So the channel is now DECLARED, not inferred:
*
* - `'environment'` — metadata arrives from an author (Studio tenant, MCP/AI
* agent, `PUT /api/v1/meta/*`). The gate runs. **This is the default**, and
* the default is the whole point: an assembly that forgets to declare gets
* MORE enforcement, never less, so the next assembly variant nobody thought
* about cannot silently reopen this hole.
* - `'package-author'` — this kernel IS the package author's own bootstrap
* channel. Only the genuine control-plane assembly may state this.
*
* Deliberately a channel NAME and not a boolean: `skipAuthoringRules: true`
* would be the same bytes with the opposite meaning — a kill switch any
* assembly could reach for to make a red publish go away. A caller has to
* claim to BE the package author to be treated as one.
*/
export type MetadataAuthoringChannel = 'environment' | 'package-author';
Loading
Loading