Skip to content

Commit ea025d3

Browse files
refactor(metadata-protocol): move the protocol.ts preamble into 22 themed modules, verbatim (#25467 E0) (#29336)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9UmbUGPtw5rdRcd3UpYXs5N)_ Part of #25467 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:把 `protocol.ts` 类定义之上的整段前言(183 条顶层声明,约 6,300 行)原样搬进 22 个按主题命名的 `protocol-*.ts` 模块;`protocol.ts` 照旧导出原来导出的每个名字。没有任何逻辑改动。 - **为什么改**:`protocol.ts` 是 server 车道的热文件,每个改它的 PR 都要排队;这是 #25467 裁决 E 的第一步 E0(E1、E2 之后另派),按字母 A 的 `.d.ts` 新判据落地。 - **风险与回滚**:低。工具逐条证明 190/190 条语句逐字搬移、无改动无多余;对外 `index.d.ts` 声明集合、顺序、ESM/CJS 导出名单都不变,原始字节只差空行、一条 import 内的名字顺序、一个字面量联合的成员顺序。另把 QA 清单和 spec liveness 账本里 3 处指向被搬符号的引用改指新模块。回滚即整体 revert 本 PR。 - **先看**:`packages/metadata-protocol/src/protocol.ts`(文件头的 import/export 名单);`packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts`(迁移后的按路径 pin);`packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.read-verb-canonical-fold.test.ts src/protocol.lookup-artifact-item-call-sites.test.ts`,预期 2 个文件全绿(main 上的搬移前形态同样绿;搬移后不迁移则红 2 条)。 - **需要你做的**:审批合并(改动约 13,200 行,超过 3,000 行人工合并线);另请确认 `packages/spec/liveness/query.json` 两处证据引用改指新模块可以留在本 PR(不留则 `check:liveness` 红)。 ## What changes Step **E0** of ruling E (6537939895, folded onto #25467 in 6537947529), landed under letter A (6545252863). Move-only. - Every top-level statement above `export class ObjectStackProtocolImplementation` in `packages/metadata-protocol/src/protocol.ts` that is not an import or an export list (183 declarations) moves **verbatim, with its leading comments**, into one of 22 new themed modules: `protocol-meta-type-schema`, `protocol-authoring-refusals`, `protocol-metadata-issues`, `protocol-publish-backfill-request`, `protocol-data-request-shapes`, `protocol-view-normalization`, `protocol-package-bindings`, `protocol-bundle-members`, `protocol-overlay-merge`, `protocol-version-tokens`, `protocol-write-constants`, `protocol-client-failures`, `protocol-list-query-params`, `protocol-discovery-services`, `protocol-reference-hits`, `protocol-write-face`, `protocol-package-lifecycle-types`, `protocol-mutation-events`, `protocol-authoring-channel`, `protocol-audit-entry`, `protocol-stored-overlay-types`, `protocol-door-requests`. - The theming is the predecessor trial's (`claude/project-thread-6aibsn@4a35157`, seat 4's report 6542958324), redone by tool on fresh `main`. The three statements added since that trial follow the statement before them: `isActionWithheldByDisabledPackage` → `protocol-authoring-refusals`, `perPackageLayerEnvelope` → `protocol-package-bindings`, `storedEntryRow` → `protocol-stored-overlay-types`. - The only new text is import lists, export lists (`export { … }` at a module's foot for a name that was module-private and is now shared; `export { … } from` in `protocol.ts` for every name it exported before) and a module header comment per module. The three preamble export lists that re-export imports (`FormFieldInput`, `recordNotFoundError`, `stripReadDecorations`) stay in `protocol.ts` with their comments. `protocol.ts` imports the modules in the order that keeps the rolled-up `.d.ts` declaration order equal to the base's. - `protocol.ts`: 30,912 → 24,892 lines (on `main` `f125ce40da`). No class-body member is touched (that is E1/E2). - Three citations that named a moved symbol at `protocol.ts` and no longer resolve there are re-pointed at the module it moved to: `docs/qa/platform-checklist/areas/studio-authoring.json` (`diffShallow` → `protocol-write-face.ts`, twice; `protectionBlockRefusal` → `protocol-authoring-refusals.ts`) and `packages/spec/liveness/query.json` (`QUERY_AST_KEYS`, `WIRE_DOLLAR_ALIASES` → `protocol-list-query-params.ts`; red in `check:platform-checklist --changed-from` and `check:liveness` before). Citations that still resolve through `protocol.ts`'s re-exports (`ConcurrentUpdateError`, `servedViewExpansion`, `specValidationFindings`, and the ADR texts) are left as they are; `check:adr-symbol-anchors` is green. - Changeset `.changeset/25467-e0-protocol-preamble-modules.md`: `patch` on `@objectstack/metadata-protocol`, and `patch` on `@objectstack/spec` because `liveness/` ships in its tarball; both declaration lines. ## Tests Forced edits, all because a reader names a moved symbol by its file path: two tests and two data files that are not tests. Two by-path pins read preamble symbols out of `protocol.ts` and are migrated in this PR (forced test edits, both in `packages/metadata-protocol/src/`): - **`protocol.lookup-artifact-item-call-sites.test.ts`**: `envelopePackageId`'s one definition is read from `protocol-package-bindings.ts`; two assertions are added so the pin keeps its reach: `protocol.ts` defines no copy of its own, and it imports `envelopePackageId` from that module. - **`protocol.read-verb-canonical-fold.test.ts`**: the four door request aliases (`SaveMetaItemRequest` … `DeleteMetaItemRequest`) are read from `protocol-door-requests.ts`, only when `protocol.ts` imports them from that module, so the signature judged is still the one the class names. Two by-path citations in data files are re-pointed (forced, not tests): `docs/qa/platform-checklist/areas/studio-authoring.json` and `packages/spec/liveness/query.json`, as listed under What changes. Before the migration the package suite was 2 failed / 298 passed files, the failures exactly these two pins (13 of 17 verbs found; `function envelopePackageId` not in `protocol.ts`). <details><summary>Ablations (one-off, each from committed HEAD through scripts/ablation-replace.mjs, restored to the HEAD blob with git diff HEAD empty)</summary> - **read-verb fold:** `saveMetaItemUnaudited`'s `request = canonicalizeMetaRequestType(request);` replaced by `request = { ...request };` (anchor 1 → 0, blob `a0a204da0761` → `6d869851bc31`). The pin goes red: `Tests 1 failed | 17 passed (18)`, received `[ "saveMetaItem" ]` against `[]`, the verb whose request type now comes from the moved alias. - **lookup, definition home:** the bindings module's `envelopePackageId` signature changed (`served: unknown` → `served?: unknown`; blob `e062a10a8f3e` → `47d2d431def2`). Red: `Tests 1 failed | 4 passed (5)`, `expected '// Copyright …' to match /\nfunction envelopePackageId\(request…/`. - **lookup, no second copy:** a local `function envelopePackageId(…)` put back into `protocol.ts` (blob `a0a204da0761` → `cb2f4764a1cf`). Red: `Tests 1 failed | 4 passed (5)`, `expected '// Copyright …' not to match /\nfunction envelopePackageId\(/`. </details> ## Acceptance notes - **Move-only, by tool** (a verifier separate from the move tool): every non-import top-level statement of base `protocol.ts` (190, the class and the three trailer statements included), leading comments included and blank lines before it excluded, appears verbatim exactly once across head `protocol.ts` and the 22 modules: verbatim 190, missing 0, changed 0, surplus 0, duplicated 0. Head adds import statements (109 → 183), 47 export lists and 165 module-header comment lines. **Negative control:** one operator changed in `protocol-version-tokens.ts` (`hash = hash & hash` → `hash = hash | hash`) → verbatim 189, changed 1, surplus 1 (`simpleHash`), exit 1; restored, blob equals HEAD. - **`.d.ts`, normaliser 2 (letter A).** Read-only, not a gate (the card adds no gate). Normalised: drop blank lines; sort the names inside each import brace list; sort each run of consecutive string/number literal members of a union. Everything else survives. | `dist/index.d.ts` (= `index.d.cts` byte for byte, both sides) | base `main` `f125ce40da` | head `20d8c2c813` | |---|---|---| | raw sha256 | `65a4356b2efed1dd5d25e5d80a55fccfef2adfd4a6c098c686d85f1a4749b0ab` | `d939f04a7ae66bbf19398a1f7ff4d905748fe9de667b8092105cffae3deec955` | | normaliser 2 sha256 | `7dea44d6d7660b9c7412af3cd0c6aa0382e3ab11dc52505431372f8185ecd97d` | `7dea44d6d7660b9c7412af3cd0c6aa0382e3ab11dc52505431372f8185ecd97d` | An earlier reading, base `74a19dd857` against head `c1d3b9dec3`, gave equal normaliser 2 hashes the same way (`ff357c33…`). Raw diff: 11 added blank lines, 1 line pair (the `@objectstack/spec/api` import brace list, `MetadataAuthoringSurface, MetadataAuthoringClient` first) and 2 line pairs (`columns?:` in `getMetaTypes()`'s inferred return type, members permuted). Declaration sequence (182 top-level statements) identical but for that import line; the final export-list line identical. **Positive controls** (each applied to head, each must differ from base and does): a class's `private engine;` made public; one name dropped from the export list; `MetadataWriteFace` moved to the end; one union literal dropped; one `readonly` removed; and a real one, an earlier layout of this head whose `.d.ts` carried a leaked JSDoc comment (below). **Negative controls** (each allowed kind alone, applied to base, must equal base and does): blank lines inserted; the import brace list reversed; the literal union permuted. The controls assert each edit landed (raw text changed). - **A declaration-emit trap met on the way, noted (not filed: it changes no published meaning and is TypeScript's behaviour, not this package's).** The first layout of this move published `description? /** [#27842] The list's disabledActions … */: string | undefined;` inside `getMetaTypes()`'s inferred return type: the emitter printed a comment that sits in `protocol.ts` at the byte offset of a node reused from another file. One line more in the `protocol.ts` header comment moved the offset and the comment left the `.d.ts`. It struck again after merging `main` with #29093 (class-body lines shifted); two more lines in the E0 note (now 5 lines, which also tells the next editor to read the raw `.d.ts` diff) cleared it, and the final reading above has no comment in the raw diff. Normaliser 2 does not tolerate it (positive control P6). Any edit of `protocol.ts` on `main` can trip it the same way while `getMetaTypes()` has an inferred return type; E1/E2 will meet it again and should read their raw diffs for it. - **In flight on the same files, re-read just before opening** (`origin/main` `5780fa49df`; 30 commits past this branch's last merge, none touching `protocol.ts` or any file of this PR, `git merge-tree` clean, so not merged again). #29093 (#28990) has merged and is in this branch. Open PRs that touch `protocol.ts`, each against this head by `git merge-tree`: - #28731 (draft, head `8372e0a1`): hunks at base line 3330 (`ConcurrentUpdateError`'s doc comment, which this PR moves to `protocol-version-tokens.ts`) and in the class body. **Conflicts**: whichever lands second carries that doc-comment edit to the other side. - #29270 (draft), #29307: add names to `protocol.ts`'s own import lists, which stay in `protocol.ts`, plus class-body hunks. Clean. - #29290, #29295: class-body hunks only. Clean. - #29317 touches `studio-authoring.json` in another item (revision 11 of the object designer item). Clean. Any of them landing shifts `protocol.ts` offsets, so the comment leak above can come back on its own; it is not this PR's to guard. - E0 is one step of a series; `Part of #25467` keeps the card open for E1 and E2. ## Verification Readings in this container under `scripts/pm/os-verify-lock.sh`, on head `20d8c2c813` unless named. - `@objectstack/metadata-protocol` build, `typecheck`, `test`: VERDICT command-exit 0 · Test Files 301 passed | 4 skipped (305) · Tests 22127 passed | 26 skipped (22153). - Direct dependents typecheck (`pnpm turbo run typecheck` on the 11 packages that depend on `@objectstack/metadata-protocol`: cli, client, cloud-connection, objectql, plugin-approvals, plugin-security, rest, runtime, service-automation, service-messaging, service-package), on `6b1dd49e2b`: VERDICT command-exit 0 · Tasks 72 successful, 72 total, every typecheck a cache miss. The two commits after it change the changeset and two comment lines in `protocol.ts`; the published `.d.ts` normaliser 2 hash is the same. - Gates that read `protocol.ts` or a moved symbol by path: `check:filter-alias-parity`, `check:system-context-census`, `check:durability-log-level`, `check:query-options-erasure`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:platform-checklist` (in the battery below), plus `check:adr-anchors`, `check:adr-symbol-anchors`, `check:scripts-symbol-anchors` and `pnpm --filter @objectstack/spec check:liveness`, each exit 0. `gen:liveness-counts` leaves the tree clean. - Derived gate battery (`dispatch-gates --commands`, 71 commands): 71 exit 0. `dispatch-gates --ran`: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN. - Lint: root `eslint packages/metadata-protocol --no-inline-config` (the package has no `lint` script) exit 0. - `test:repo` (Test Core) and the full farm are CI's. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (the one `packages/spec` edit is two evidence citations in `liveness/query.json`; no schema, type or logic) - [x] 2. Every name I added uses the casing for its kind. (no name added; module files are kebab-case like their siblings) - [x] 3. No formatter ran; `pnpm lint` passes. (scoped to the package) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (`liveness/query.json` only; `gen:liveness-counts` regenerates nothing from it, tree clean) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5e7ba5 commit ea025d3

28 files changed

Lines changed: 6917 additions & 6296 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
refactor(metadata-protocol): the module preamble of `protocol.ts` moves, verbatim, into 22 themed `protocol-*.ts` modules
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
`@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.

‎docs/qa/platform-checklist/areas/studio-authoring.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@
432432
"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)",
433433
"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)",
434434
"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)",
435-
"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",
435+
"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",
436436
"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",
437437
"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)",
438438
"attempt a rollback with a missing/invalid toVersion and capture the 400 INVALID_REQUEST guard",
@@ -490,7 +490,7 @@
490490
{
491491
"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",
492492
"oracle": "api",
493-
"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",
493+
"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",
494494
"evidence": "the diff response"
495495
},
496496
{
@@ -1996,7 +1996,7 @@
19961996
},
19971997
"source": [
19981998
"packages/metadata-protocol/src/runtime-authoring-gate.ts",
1999-
"packages/metadata-protocol/src/protocol.ts#protectionBlockRefusal",
1999+
"packages/metadata-protocol/src/protocol-authoring-refusals.ts#protectionBlockRefusal",
20002000
"packages/spec/src/ui/page.zod.ts#PAGE_SLOTS_TABS_RETIRED",
20012001
"packages/spec/src/ui/view.zod.ts#checkListViewChartBinding",
20022002
"packages/lint/src/validate-expressions.ts#validateStackExpressions",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the metadata
4+
// audit entry, the pending-audit carrier and the preflight violation codes.
5+
//
6+
// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood
7+
// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot
8+
// are new. `protocol.ts` re-exports what it exported.
9+
import {
10+
type MetadataLock,
11+
} from '@objectstack/spec/kernel';
12+
13+
/**
14+
* One `sys_metadata_audit` row as a VALUE — exactly the entry
15+
* `recordMetadataAudit` writes, named so a refusal can hand its row to whoever
16+
* is in a position to make it durable instead of writing it where it stands.
17+
*
18+
* [#8594] Why the entry became a value. `promoteDraftForPublish` used to audit
19+
* its own denials inline, and it has two callers standing on opposite sides of
20+
* a transaction boundary: `publishMetaItem` runs it in no transaction of its
21+
* own, `publishPackageDrafts` runs it inside ONE `engine.transaction()` for the
22+
* whole batch (ADR-0067 D2). A denial row written inline therefore landed on
23+
* the single-item route and ROLLED BACK on the batch route — destroyed by the
24+
* very rollback its own refusal had caused, which is the defect #7748 exists to
25+
* close. Making the row data lets the helper stay ignorant of whose transaction
26+
* it is inside (it cannot know, and guessing is what broke) and puts the
27+
* placement decision where the transaction actually is: in the caller.
28+
*/
29+
export interface MetadataAuditEntry {
30+
type: string;
31+
name: string;
32+
/**
33+
* [ADR-0131 D6/D7] Always environment-wide: the audit ledger is
34+
* deployment-level, and no metadata write is organization-scoped any more.
35+
* Typed `null` so an organization cannot be recorded by accident.
36+
*/
37+
organizationId?: null;
38+
operation: 'save' | 'publish' | 'rollback' | 'delete' | 'reset';
39+
outcome: 'allowed' | 'denied' | 'forced';
40+
code: string;
41+
lockState?: MetadataLock;
42+
lockOverridden?: boolean;
43+
actor?: string;
44+
source?: string;
45+
requestId?: string;
46+
note?: string;
47+
}
48+
49+
/** Attach the denial row `err` owes, and return `err` so call sites read as one `throw`. */
50+
function withPendingAudit<E extends Error>(err: E, audit: MetadataAuditEntry): E {
51+
(err as E & { __pendingAudit?: MetadataAuditEntry }).__pendingAudit = audit;
52+
return err;
53+
}
54+
55+
/**
56+
* [#8595] The PRE-FLIGHT gate's refusal vocabulary — the closed set of codes
57+
* `publishPackageDrafts` can refuse a batch with BEFORE it opens its
58+
* transaction, and the spelling each one takes in `sys_metadata_audit.code`.
59+
*
60+
* Why a declared map and not `code.toLowerCase()`. `batch_aborted` (#8400)
61+
* deliberately took ONE fixed value because its causal code is whatever the
62+
* error catalog threw, and lower-casing that would turn this column's
63+
* documented closed set into an open one that grows with the catalog. The
64+
* pre-flight codes are a different animal — they are minted in
65+
* `publishPackageDrafts`'s own pre-flight block, not caught from below — so the
66+
* violation's own code CAN be carried honestly, which is what a compliance
67+
* query filters on ("show me every publish refused by the namespace rule").
68+
*
69+
* The precedent's principle still binds, though: the column must not drift
70+
* silently. Hence `Record<PreflightViolationCode, …>` over an index signature —
71+
* a new pre-flight gate (ADR-0121's endpoint gates, should #2657 Part B bring
72+
* them back) cannot reach this column without failing to compile until its
73+
* audit spelling is declared here AND added to the column's documented set in
74+
* `sys-metadata-audit.object.ts`. A mechanical lower-case would have let it
75+
* land silently; a runtime fallback would have put an unreachable value in a
76+
* documented closed set. The compiler is the enforcement.
77+
*/
78+
type PreflightViolationCode = 'NAMESPACE_PREFIX' | 'STORED_TYPE_NOT_CANONICAL';
79+
80+
const PREFLIGHT_AUDIT_CODE: Record<PreflightViolationCode, string> = {
81+
// adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary
82+
NAMESPACE_PREFIX: 'namespace_prefix',
83+
// adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary
84+
STORED_TYPE_NOT_CANONICAL: 'stored_type_not_canonical',
85+
};
86+
87+
// Shared with `protocol.ts` and the sibling preamble modules; module-private before the move.
88+
export { PREFLIGHT_AUDIT_CODE, withPendingAudit };
89+
export type { PreflightViolationCode };
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [#25467 E0] Part of the module preamble of `protocol.ts`, moved here verbatim: the authoring
4+
// gate, the credential channel and the authoring channel contracts.
5+
//
6+
// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood
7+
// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot
8+
// are new. `protocol.ts` re-exports what it exported.
9+
10+
/**
11+
* Pre-persistence authoring gate (ADR-0094 addendum seam; #3050).
12+
*
13+
* Unlike the post-persist {@link MetadataMutationProjector} (best-effort,
14+
* never thrown), an authoring gate runs BEFORE persistence and REJECTS the
15+
* write by throwing — it is the seam for domain invariants that must hold on
16+
* every runtime-authored body regardless of which HTTP surface produced it
17+
* (e.g. plugin-security's OWD posture gate: an environment may only TIGHTEN
18+
* a packaged object's `sharingModel`).
19+
*
20+
* Invoked inside `saveMetaItem` for BOTH draft and publish-mode saves, after
21+
* the ADR-0005 overlay/runtime-create authorization and the per-type spec
22+
* validation — so `publishMetaItem` promotes an already-gated body and needs
23+
* no second gate. Environment writes only: control-plane bootstrap writes
24+
* (`environmentId === undefined`) are the package author's own channel and
25+
* bypass the gate, mirroring the ADR-0005 gate above.
26+
*/
27+
export interface MetadataAuthoringGateContext {
28+
/** Singular type name (e.g. `object`). */
29+
type: string;
30+
name: string;
31+
/** Lifecycle the body is being saved into. */
32+
state: 'draft' | 'active';
33+
/** The body being persisted. */
34+
body: unknown;
35+
/** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */
36+
isArtifactBacked: boolean;
37+
/** The packaged (code-layer) baseline body when {@link isArtifactBacked}; the declaration an overlay customizes. */
38+
declaredBody?: unknown;
39+
}
40+
41+
export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise<void>;
42+
43+
/**
44+
* [#20790] A metadata type's WRITE-ONLY credential channel — where the
45+
* credentials its bodies carry are stored instead of in the body, on the
46+
* platform's one secret seam (#7799: a `secret`-typed field the engine
47+
* encrypts, masks on every read and dereferences only through
48+
* `resolveSecretField`). Registered per type by the domain plugin that owns
49+
* the type's credential-location table (the automation plugin holds `flow`'s),
50+
* beside its authoring gate.
51+
*
52+
* Every write that lands a body at rest consults it:
53+
* - `saveMetaItem` calls {@link store} immediately before the put, after the
54+
* carry-forward, and persists what it returns;
55+
* - the runtime authoring gate reads {@link heldPaths} as restored positions,
56+
* on an active save and on the draft → active promotion;
57+
* - a restore (rollback, revert) stores {@link strip}'s body.
58+
*
59+
* ⛔ Not a second secret mechanism and not a per-door redaction: the read
60+
* projection stays the type's redactor; this only decides where a credential
61+
* is STORED.
62+
*/
63+
export interface MetadataCredentialChannel {
64+
/**
65+
* Move every explicit credential in `body` into the channel for
66+
* `(name, state)` and return the body without any — what is stored. An
67+
* absent credential means "unchanged". THROWS (with an ADR-0112
68+
* `code`/`status`) to refuse the save; nothing may have been put then.
69+
*/
70+
store(args: { name: string; state: 'draft' | 'active'; body: unknown }): Promise<unknown>;
71+
/**
72+
* The positions in `item` (dotted, item-relative — `redactedKeys`'
73+
* spelling) whose credential is withheld from the body and held by the
74+
* channel, so the gate reads them as present. `state: 'draft'` for a draft
75+
* being promoted.
76+
*/
77+
heldPaths(args: { name: string; state: 'draft' | 'active'; item: unknown }): Promise<readonly string[]>;
78+
/**
79+
* The body a restore stores: every credential removed, and nothing written
80+
* to the channel — it keeps its current credential.
81+
*/
82+
strip(body: unknown): unknown;
83+
}
84+
85+
/**
86+
* Which authoring channel a kernel's metadata writes arrive on (#6710).
87+
*
88+
* ADR-0005 carves out "the package author's own bootstrap channel" from the
89+
* runtime authoring rules: a control-plane kernel installing a package is not
90+
* an author publishing into a live tenant, and gating it would refuse the very
91+
* bodies the platform ships. The carve-out is legitimate and stays.
92+
*
93+
* What #6710 changed is **how a kernel says it is that channel**. Until this
94+
* type existed the answer was inferred from `environmentId === undefined` — a
95+
* ROW-SCOPING key pressed into service as a topology signal. That proxy lies:
96+
* the CLI's lightweight host-config assembler (`serve.ts`'s auto-register
97+
* branch, `new ObjectQLPlugin()` with no options) also leaves `environmentId`
98+
* undefined, and it serves an END-USER `PUT /api/v1/meta/*`. Both topologies
99+
* read identically, so the whole #4463 gate — all the shared `AUTHORING_RULES`
100+
* — was disengaged on a self-hosted app server. #5086 had already moved the
101+
* code-only refusal off the same proxy for the same reason ("keying
102+
* authorization off a row-scoping key is what made a type-level declaration
103+
* depend on deployment topology; the declaration decides it here instead").
104+
*
105+
* So the channel is now DECLARED, not inferred:
106+
*
107+
* - `'environment'` — metadata arrives from an author (Studio tenant, MCP/AI
108+
* agent, `PUT /api/v1/meta/*`). The gate runs. **This is the default**, and
109+
* the default is the whole point: an assembly that forgets to declare gets
110+
* MORE enforcement, never less, so the next assembly variant nobody thought
111+
* about cannot silently reopen this hole.
112+
* - `'package-author'` — this kernel IS the package author's own bootstrap
113+
* channel. Only the genuine control-plane assembly may state this.
114+
*
115+
* Deliberately a channel NAME and not a boolean: `skipAuthoringRules: true`
116+
* would be the same bytes with the opposite meaning — a kill switch any
117+
* assembly could reach for to make a red publish go away. A caller has to
118+
* claim to BE the package author to be treated as one.
119+
*/
120+
export type MetadataAuthoringChannel = 'environment' | 'package-author';

0 commit comments

Comments
 (0)