Skip to content

Commit b0376ed

Browse files
refactor(metadata-protocol): move the write package scope resolution out of protocol.ts into protocol-write-package-scope.ts (#25467 cut 19) (#26350)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X7JKgiM29GUcitRvoGGj5BU)_ Part of #25467 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 This is cut 19 of the `protocol.ts` split (#24586 ruling A, recorded in `6270097840`). Cuts 1–18 landed as #25494, #25518, #25538, #25589, #25601, #25623, #25642, #25719, #25731, #25740, #25797, #25939, #25972, #26014, #26104, #26168, #26264 and #26317. The claim is `6427907689`. The scope is the cut 19 proposal of the cut 18 report (`6426921154`), as the server seat approved it in its dispatch order. The member passed the AST screen and moved unedited. One `ObjectStackProtocolImplementation` member moves unedited into a new module, `packages/metadata-protocol/src/protocol-write-package-scope.ts` (#23050 shape B): - `resolveWritePackageScope` resolves a package write's validation scope. That is the written package plus the closure of the dependencies its manifest declares, read from the registry. It answers `undefined` wherever the registry cannot be read. `protocol.ts` loses 30 net lines (29,270 to 29,240). The cut is move-only, and the card stays open for later cuts. Branch `claude/project-thread-ybjm3h` is the harness-pinned branch for this thread. It was restarted from `main` `77dc7062e5` after cut 18 merged. It names no issue, so the claim comment is the identity record. The branch holds the move commit `c5f1351978` and the merge of `main` `a8e7393dda` (`f4eb4330f3`). That merge touches no file of `packages/metadata-protocol/`: the package's `src/` tree is `e8f66938` at both ends. **Serial with the file's in-flight PRs.** Measured just before this PR was opened: of the 29 open PRs, two touch `packages/metadata-protocol/src/`, and neither names `resolveWritePackageScope`. Each was checked with `git merge-tree` against this branch's head (`f4eb4330f3`). - **#26244** (draft, head `c33b95d89f`) edits `protocol.ts` at `:163` (imports) and `:23178`, far from `:7361`. Its merge-tree conflicts in `protocol.ts` and in `packages/spec/src/type-alias-convention.pin.test.ts`. Both conflicts are ones #26244 has with `main` alone: a merge-tree of #26244 with `origin/main` reports the same two files. The `protocol.ts` one is the import-line conflict with cut 18's `ALL_CONVERSIONS` drop, whose union resolution #26317 spelled out. This cut adds no conflict. - **#26138** edits seven `protocol.*.test.ts` files. Its two conflicts (`visibility-alias-keys-retirement.test.ts`, `scripts/sdui-manifest.sources/core.json`) are likewise with `main` alone. No open PR edits the new module. ## The order's screen (private statics, `new Error(`) The member passed: - **No private-static read.** The AST walk found no access on the `ObjectStackProtocolImplementation` identifier and none on `this.constructor`. - **No error constructed.** There is no `new Error(` and no other error constructor, so no `PRODUCERS` row is owed. There is no log line. - **One silent `catch`** around the registry reads. It answers `undefined`, meaning "an unreadable registry means an unnarrowed gate". It moves with the body. See the durability `--list` below. ## What moves | Member | Base line | Visibility | Body lines | AST tokens (params + return + body) | |---|---|---|---|---| | `resolveWritePackageScope` | `:7361` | private | 40 | 252 | The proposal also said 40 body lines. ## The shape (#23050 shape B, as cuts 1–18 apply it) - **The class keeps the member** with its name, docblock, signature and position. Its body now delegates, marked `[#25467 cut 19]`. - **Typing.** `WritePackageScopeHost` types its one member as the class member itself (`ObjectStackProtocolImplementation['engine']`). The function types its `this` as `Pick<WritePackageScopeHost, 'engine'>`, and the stub annotates its literal as `ThisParameterType<typeof resolveWritePackageScope>`. No member changes visibility. - **Members read.** The AST walk covers property and element accesses on `this`, with `as` casts, parentheses and `!` stripped, and bare `this` checked too. It finds `engine` only, read as `this.engine.registry`. The engine is handed over as a live getter, so the body reads the protocol's engine, and so its registry, at call time. - **Every other caller still calls the class method**, as the order asks: - The authoring gate's host literal at `:7107` (cut 8, `assertRuntimeAuthoringRules` → `protocol-authoring-gate.ts`) is unchanged. It still hands over `resolveWritePackageScope: (...a) => this.resolveWritePackageScope(...a)`, which reaches the class stub and then the moved body. - `tenantAuthoredWriteRefusal` (`:16132`) is the other caller, through `this.`. ## Module-level declarations - **None.** The body reads no module-level declaration of `protocol.ts` and no import binding, measured by an AST identifier walk against the file's top-level declarations and imports. Nothing gains `export`, and no import of `protocol.ts` changes. - The new module imports only the class type (`import type` from `./protocol.js`), so it adds no runtime edge to the `protocol.ts` cycle. The one new statement in `protocol.ts` is the import of the new module. ## The durability `--list` (the order's re-measure) The silent `catch` moved file, so the list was read at both ends. - `check-durability-degradation-log-level` exits 0 at both ends with identical output. - Its `--list` is **identical once `[@:][0-9]+` is stripped**: 159 lines, and no entry changes file. - Neither end has an entry naming `resolveWritePackageScope` or the new module. The `catch` guards registry reads (`registry.getPackage`), not an engine call the list enumerates. ## The published `.d.ts` The series bar is ruling `6348891160`, sorted-union identity, measured first. **How it was built.** Base `77dc7062e5` and head `c5f1351978` were built with `pnpm --filter @objectstack/metadata-protocol build` against one dependency tree. The closure was built at head by a full `pnpm build` (119/119). For the base build, the package's `src/` was swapped tree-only to base and restored from `HEAD` under a trap. Afterwards `git diff HEAD` was empty, `git status` was clean, and both touched blobs equalled `HEAD`'s. The `.d.ts` was measured twice: on the pre-merge tree, and after merging `main` (head `f4eb4330f3`, closure rebuilt by a full `pnpm build`, 119/119). The readings below hold for both. The base `.d.ts` differs from cut 18's readings because #26305 landed in between. - **Raw sha256**: `a8f6adb6f9c56b8f441b7224ca8c4d43b84891bce0373446e1577a7884079dd8` at base and at head, on both `dist/index.d.ts` and `dist/index.d.cts`, 776,660 bytes each. **Raw identity holds**, so sorted-union identity holds too. The member stays a `private NAME;` line. - **Normalised sha256**: `f7f9306ed46a5605c5e4c59bbc212a7d5288061760ec85d473d2f3047a836ba5` at both ends (80 string-literal union runs sorted; the cut 12 normaliser). - **The two builds differ where they should**: - `index.js` hashes `4f35935b2eaf7f2b…` at base and `9495894cdf5ce39b…` at head. - The `.call(host` site of `resolveWritePackageScope` occurs 0 times in the base bundles and once in the head bundles (ESM and CJS alike). - `function resolveWritePackageScope(` appears 0 times at base and once at head. - **Positive control** on the head tree: `private` was dropped from `resolveWritePackageScope` (on disk, restored from `HEAD` under a trap) and the package rebuilt. Both `.d.ts` files move to `cf7f37aa818a9795…` (normalised `a7662435fc695cad…`) and print `resolveWritePackageScope(packageId: string | null | undefined): { packageId: string; dependencies: string[]; } | undefined;`. So the bar can see a change on this member. - **Export tables.** The ESM and CJS bundles, loaded at runtime from the package, each export the same 78 names at base and head. The sorted list hashes to `9f18a0177ac736ed…` on all four. Neither the moved function nor `WritePackageScopeHost` appears. No changeset: `skip-changeset`, per ruling `6348891160`. ## Proof of move-only **Body, by AST token.** The compare uses the TypeScript compiler's AST leaf tokens, with JSDoc skipped. It covers the parameters (less the added `this`), the return type and the body. Base `protocol.ts` at `77dc7062e5` is compared against the head module: ``` resolveWritePackageScope: base tokens 252, head tokens 252, differ 0, text-equal(ws-collapsed, comments incl.) true TOTAL tokens=252 differ=0 members=1 CLASS members base=301 head=301 order-same=true byte-identical=300 stubs-with-identical-docblock+signature=1 other=[] TOP base=247 head=248 byte-identical=247 ``` **Negative control.** One token was changed in an in-memory copy of the head module (`if (dep === packageId ||` to `if (dep !== packageId ||`, one occurrence): ``` MUTATED packages/metadata-protocol/src/protocol-write-package-scope.ts "if (dep === packageId ||" -> "if (dep !== packageId ||" resolveWritePackageScope: base tokens 252, head tokens 252, differ 1, text-equal(ws-collapsed, comments incl.) false TOTAL tokens=252 differ=1 members=1 ``` - **Class side.** There are 301 members at base and at head, in the same order. The 300 that did not move are byte-identical to base, leading comments included. The stub sits at its base position and keeps its docblock and signature byte-identical (everything before the body's opening brace). - **Top level of `protocol.ts`.** All 247 base statements are byte-identical, leading comments included. Head adds one statement, the new module's import. - **Size.** +96 / −38 over 2 files, 134 changed lines (the proposal's estimate was 150–200). `protocol-write-package-scope.ts` has 88 lines. ## Readers of the moved lines I grepped every test and script that reads `protocol.ts` by path (70 files) for the member name and for its body's text (`getPackage`, `frontier`, the `catch` comment). I also grepped every test file in `packages/` and `ui/`, and `docs/qa/`. - **By path: none reads the moved body.** Thirteen of them match `getPackage`, but as a registry test double (`getPackage: () => undefined` and the like) or a direct registry call (`package-dependencies-patch.integration.test.ts`), not as the moved text. - **Comment-only mentions, no edit owed.** `protocol-publish-drafts-closure.test.ts` (line 228), `packages/core/src/artifact-packages.ts` (line 130) and `packages/lint/src/validate-list-view-field-refs.ts` (line 272) name the method in comments. It still exists, on the class. - **The `@objectstack/spec` liveness ledger.** `packages/spec/liveness/manifest.json` (`dependencies` row) carries the evidence pointer `packages/metadata-protocol/src/protocol.ts#resolveWritePackageScope (\`const declared = manifest?.dependencies\` inside the frontier loop …)`. - `check:liveness` checks that the `#symbol` is named in the file. The method is still declared in `protocol.ts`, so the pointer resolves and `pnpm --filter @objectstack/spec check:liveness` exits 0 at head. - The quoted line itself now lives in `protocol-write-package-scope.ts`. That is the same state cut 14 left `seed.json`'s `protocol.ts#applySeedBodies` pointer in. - Re-pointing the row is a `packages/spec/**` edit, which this card does not make. It is disclosed here and in the report as a follow-up. - No hit in `docs/qa/`. - **ADR anchors.** `check-adr-anchors` exits 0 with identical output at both ends. - **Engine-double contract** (`check-engine-double-contract`). It exits 0 at both ends. - The `updateData`/`deleteData` seams keep their entries at new line numbers (13956 → 13926, 13981 → 13951). - `productionFiles` is 3,743 at base and 3,745 at head. One of the +2 is the new file; the other is the gitignored `packages/spec/src/migrations/registry.ts` that the spec build writes in the head tree. - **Identical output at both ends:** `check-adr-symbol-anchors`, `check-system-context-census`, `check-tenant-audit-census`, `check-query-options-erasure-ratchet`, `check-filter-alias-parity` and `check-durability-degradation-log-level`. `check-nul-bytes` differs only in the scanned file count, +1. The base-end readings were taken in a clean detached worktree at `77dc7062e5`. - **Governed surface.** `check-governed-merges --branch claude/project-thread-ybjm3h` finds 0 of 2 paths in the register, so this is NOT governed. It has 134 changed lines, under the 3,000-line human-merge bar. ## Verification - `tsc --noEmit` in `packages/metadata-protocol` exits 0 before and after the merge. - `pnpm exec eslint --no-inline-config packages/metadata-protocol` exits 0 with no output. It covers the whole touched package, which has no `lint` script; these are the root `lint` script's flags. - **`packages/metadata-protocol` suite**, base and head, compared per test name and status. Both runs used vitest `--maxWorkers=2` with the JSON reporter under the shared verify lock, with `VERDICT command-exit 0` on both legs. They ran on the pre-merge tree; the merge changed no file of the package. - Base `77dc7062e5`: 21,568 tests (21,549 passed, 0 failed, 19 skipped), 270 files. - Head `c5f1351978`: 21,568 tests (21,549 passed, 0 failed, 19 skipped), 270 files. - None lost, none added, none changed status. The base leg is the package `src/` swapped tree-only from `77dc7062e5` (the new module removed) and restored from `HEAD` under a trap. - `check-platform-checklist --changed-from origin/main` (merged tree): OK, 1 anchored file in the change set, 29 anchors judged, 0 lost. - **Derived gates.** `dispatch-gates --commands --repo objectstack-ai/objectstack` derives 51 gates on 2 changed paths from the merged tree `f4eb4330f3`. All 51 exit 0 after the full `pnpm build` (119/119). `dispatch-gates --ran`: 51 derived, 51 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - The derivation flags the tree as behind `main` only for `scripts/sdui-manifest.sources/plugin-grid.json`, which feeds none of these 51 families. The list is the same 51 as cut 18's, so `main` was not merged again. - `pnpm --filter @objectstack/spec check:liveness` exits 0 at head (see the liveness ledger above). ## Carry-forward Unchanged from cut 18. These stay in `protocol.ts`: - Public members whose return type is inferred. - The bodies kept by a reader: `probeRecord`, `getMetaItemCached`, `findReferencesToMeta`, `packagelessAddress`, `refuseUnmintableMetaType`, `metaTypeNamespaceExists`, `viewContainerNameCollisionRefusal` with `overlaidShippedContainerViewNames`, `reportPageRequiresAbsentAtLoad`, the 7 `applyRegistryWriteThrough` callers, `readCodeLayerForCarryForward` and `ensureObjectStorage`. - The private-static readers: `refusePackagedBaseRemoval`, `packagedBaseRefusal`, `runPublishSideEffects`, `promoteDraftForPublish`, `publishPackageDrafts` and `discardPackageDrafts`. - The field writers: `warnClosureReadUnavailable`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66d5cd6 commit b0376ed

2 files changed

Lines changed: 96 additions & 38 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 19] A package write's validation scope, as
5+
* `ObjectStackProtocolImplementation` resolves it
6+
* (`resolveWritePackageScope`): the written package and the closure of the
7+
* dependencies its manifest declares, read from the registry, or `undefined`
8+
* wherever that cannot be read. Moved out of `protocol.ts` whole.
9+
*
10+
* A MOVE, not a redesign. The function below is the body of the
11+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
12+
* re-indented one level and otherwise unedited: the same guards, the same
13+
* dependency walk, and the same silent `catch` answering `undefined`.
14+
*
15+
* ## How the moved body reaches the class
16+
*
17+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
18+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
19+
* The class keeps the method — the published `dist/*.d.ts` lists its name,
20+
* docblock and declared signature — and its body is one literal of exactly
21+
* the {@link WritePackageScopeHost} members it reads, plus a `.call`. The
22+
* engine is handed over as a getter, so the body reads the protocol's engine
23+
* (and so its registry) at call time. Every other reader of the method keeps
24+
* calling the class method: `tenantAuthoredWriteRefusal`, and the authoring
25+
* gate's host (cut 8, `protocol-authoring-gate.ts`), whose closure calls it.
26+
*
27+
* The body reads no module-level declaration of `protocol.ts` and no import,
28+
* so this module imports only the class type. Nothing re-exports this module,
29+
* so the published surface does not move.
30+
*/
31+
32+
import type { ObjectStackProtocolImplementation } from './protocol.js';
33+
34+
/**
35+
* The `ObjectStackProtocolImplementation` members the function below reads —
36+
* every one of them, and no other; the function's `this` is the `Pick` of the
37+
* members it reads. Each is typed AS the class member (an indexed access on
38+
* the class), so a change to a member's signature on the class is a change
39+
* here too, and naming a member the class does not have is a compile error.
40+
*/
41+
export interface WritePackageScopeHost {
42+
readonly engine: ObjectStackProtocolImplementation['engine'];
43+
}
44+
45+
/** The body of `ObjectStackProtocolImplementation#resolveWritePackageScope`; the method's docblock there is its contract. */
46+
export function resolveWritePackageScope(
47+
this: Pick<WritePackageScopeHost, 'engine'>,
48+
packageId: string | null | undefined,
49+
): { packageId: string; dependencies: string[] } | undefined {
50+
if (typeof packageId !== 'string' || packageId === '' || packageId === 'sys_metadata') return undefined;
51+
try {
52+
const registry = this.engine.registry as
53+
| { getPackage?: (id: string) => { manifest?: unknown } | undefined }
54+
| undefined;
55+
if (typeof registry?.getPackage !== 'function') return undefined;
56+
// The written package itself must be readable: its manifest is the
57+
// ONLY declaration of what it may reference, and narrowing without
58+
// it would be narrowing on nothing.
59+
if (!registry.getPackage(packageId)) return undefined;
60+
61+
const dependencies = new Set<string>();
62+
const frontier = [packageId];
63+
while (frontier.length > 0) {
64+
const current = frontier.pop() as string;
65+
const manifest = registry.getPackage(current)?.manifest as
66+
| { dependencies?: Record<string, unknown> }
67+
| undefined;
68+
const declared = manifest?.dependencies;
69+
if (!declared || typeof declared !== 'object') continue;
70+
for (const dep of Object.keys(declared)) {
71+
// A dependency the registry cannot resolve is still IN the
72+
// closure: the package declared it, so an object stamped
73+
// with it is reachable by declaration. Only the transitive
74+
// walk stops there — `getPackage` returns undefined and the
75+
// loop above simply contributes nothing further.
76+
if (dep === packageId || dependencies.has(dep)) continue;
77+
dependencies.add(dep);
78+
frontier.push(dep);
79+
}
80+
}
81+
return { packageId, dependencies: [...dependencies] };
82+
} catch {
83+
// Context gathering must never fail a write (the same contract
84+
// `listCollection` above holds). An unreadable registry means an
85+
// unnarrowed gate, not a refused publish.
86+
return undefined;
87+
}
88+
}

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,8 @@ import { namesWithOwnStoredRow, readActiveOverlayRows, readItemActiveRows, store
402402
import { classifyObjectContribution, releaseAdoptedObjectOwner } from './protocol-object-owner.js';
403403
// [#25467 cut 18] The flow save's retired-spelling replay verdict (#23903 ruling B′): does a save newly introduce a retired spelling no stored row carries.
404404
import { flowSaveReplaysRetired } from './protocol-flow-save-replay.js';
405+
// [#25467 cut 19] A package write's validation scope: the written package and its declared dependency closure, from the registry.
406+
import { resolveWritePackageScope } from './protocol-write-package-scope.js';
405407

406408
/**
407409
* Canonical Zod schema per metadata type lives in
@@ -7361,44 +7363,12 @@ export class ObjectStackProtocolImplementation implements
73617363
private resolveWritePackageScope(
73627364
packageId: string | null | undefined,
73637365
): { packageId: string; dependencies: string[] } | undefined {
7364-
if (typeof packageId !== 'string' || packageId === '' || packageId === 'sys_metadata') return undefined;
7365-
try {
7366-
const registry = this.engine.registry as
7367-
| { getPackage?: (id: string) => { manifest?: unknown } | undefined }
7368-
| undefined;
7369-
if (typeof registry?.getPackage !== 'function') return undefined;
7370-
// The written package itself must be readable: its manifest is the
7371-
// ONLY declaration of what it may reference, and narrowing without
7372-
// it would be narrowing on nothing.
7373-
if (!registry.getPackage(packageId)) return undefined;
7374-
7375-
const dependencies = new Set<string>();
7376-
const frontier = [packageId];
7377-
while (frontier.length > 0) {
7378-
const current = frontier.pop() as string;
7379-
const manifest = registry.getPackage(current)?.manifest as
7380-
| { dependencies?: Record<string, unknown> }
7381-
| undefined;
7382-
const declared = manifest?.dependencies;
7383-
if (!declared || typeof declared !== 'object') continue;
7384-
for (const dep of Object.keys(declared)) {
7385-
// A dependency the registry cannot resolve is still IN the
7386-
// closure: the package declared it, so an object stamped
7387-
// with it is reachable by declaration. Only the transitive
7388-
// walk stops there — `getPackage` returns undefined and the
7389-
// loop above simply contributes nothing further.
7390-
if (dep === packageId || dependencies.has(dep)) continue;
7391-
dependencies.add(dep);
7392-
frontier.push(dep);
7393-
}
7394-
}
7395-
return { packageId, dependencies: [...dependencies] };
7396-
} catch {
7397-
// Context gathering must never fail a write (the same contract
7398-
// `listCollection` above holds). An unreadable registry means an
7399-
// unnarrowed gate, not a refused publish.
7400-
return undefined;
7401-
}
7366+
// [#25467 cut 19] The body moved to `protocol-write-package-scope.ts` (see `WritePackageScopeHost`).
7367+
const readEngine = () => this.engine;
7368+
const host: ThisParameterType<typeof resolveWritePackageScope> = {
7369+
get engine() { return readEngine(); },
7370+
};
7371+
return resolveWritePackageScope.call(host, packageId);
74027372
}
74037373

74047374
/**

0 commit comments

Comments
 (0)