Skip to content

Commit 7fefeb7

Browse files
committed
refactor(metadata-protocol): move tenantAuthoredWriteRefusal's body through the host, and read moved bodies through it
The pilot member's body moves to protocol-tenant-authored-write.ts; the method keeps its docblock and signature and becomes a stub. The class's static block builds the host. The read-verb fold pin, the lookupArtifactItem call-site pin and the read-seam census follow host.x(...) calls and fn(protocolHost(this), ...) stubs into every split module. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude <noreply@anthropic.com>
1 parent bec8b77 commit 7fefeb7

5 files changed

Lines changed: 489 additions & 79 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 E1] The pilot of the host split: `tenantAuthoredWriteRefusal`'s body,
5+
* moved out of `protocol.ts` by `scripts/host-split.mjs move` to prove the
6+
* machinery E2's section cuts use, on one member.
7+
*
8+
* A MOVE, not a redesign. The function below is the body of the
9+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
10+
* re-indented one level, with `this.<member>` read as `host.<member>` and
11+
* nothing else changed (`node scripts/host-split.mjs check <base> …` proves
12+
* it). The method stays on the class with its docblock and signature, which the
13+
* published `.d.ts` prints, and hands its body the host (`protocol-host.ts`).
14+
*
15+
* What the body reads from elsewhere, it imports from where `protocol.ts`
16+
* imports it. Nothing re-exports this module, so the published surface does
17+
* not move.
18+
*/
19+
20+
import {
21+
// [#20761, ADR-0126 §2] The ADR-0029 D9.6 reading of a body's own
22+
// provenance stamps — asked by {@link
23+
// ObjectStackProtocolImplementation.tenantAuthoredWriteRefusal} to tell a
24+
// body that CLAIMS to be code-shipped from one that does not.
25+
isCodeArtifactBody,
26+
} from '@objectstack/metadata-core';
27+
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
28+
import type { ProtocolHost } from './protocol-host.js';
29+
import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js';
30+
31+
/** The body of `ObjectStackProtocolImplementation#tenantAuthoredWriteRefusal`; the method's docblock there is its contract. */
32+
export async function tenantAuthoredWriteRefusal(host: Pick<ProtocolHost, 'overrideWriteVerdict' | 'packagedArtifactOwner' | 'packagedBaseRefusal' | 'storedFlowBindingAgrees' | 'uninstalledNamedBaseRefusal'>, request: { type: string; name: string; item: unknown; packageId?: string | null }): Promise<Error | null> {
33+
const folded = canonicalizeMetaRequestType(request);
34+
const singular = PLURAL_TO_SINGULAR[folded.type] ?? folded.type;
35+
if (singular !== 'flow') return null;
36+
const shipped = host.packagedArtifactOwner(folded) !== undefined;
37+
// [#28412, ADR-0146 D2 / D9] Rule 1's lock does not apply to the
38+
// shipping package's declared override: the one override write gate
39+
// ({@link overrideWriteVerdict}) admits it as the pair's own row, which
40+
// edits nothing the package ships. Any other write of a shipped name
41+
// keeps the lock.
42+
if (shipped && host.overrideWriteVerdict({
43+
type: singular,
44+
name: folded.name,
45+
item: folded.item,
46+
...(folded.packageId ? { packageId: folded.packageId } : {}),
47+
}).verdict !== 'admit') {
48+
const locked = host.packagedBaseRefusal({
49+
type: folded.type,
50+
name: folded.name,
51+
operation: 'save',
52+
...(folded.packageId ? { packageId: folded.packageId } : {}),
53+
});
54+
if (locked) return locked;
55+
}
56+
// [#20863] The named-base rule (see the docblock): a base the write
57+
// names must be a package the registry holds as installed. [#26708]
58+
// The rule is {@link uninstalledNamedBaseRefusal}'s, which
59+
// `saveMetaItem` asks for every other type too.
60+
const unbound = host.uninstalledNamedBaseRefusal({ type: singular, name: folded.name, packageId: folded.packageId });
61+
if (unbound) return unbound;
62+
if (shipped) return null;
63+
if (!isCodeArtifactBody(folded.item)) return null;
64+
const stamped = String((folded.item as { _packageId?: unknown })._packageId);
65+
if (folded.packageId === stamped) return null;
66+
if (await host.storedFlowBindingAgrees(folded.name, stamped)) return null;
67+
const err = new Error(
68+
`Flow '${folded.name}' was sent with a code-package provenance, but no managed package ships a flow of `
69+
+ `that name. A flow written through an authoring door is authored in this deployment: which flows a `
70+
+ `package ships is established by the package loader, never by the definition a caller sends. `
71+
+ `Remove the provenance keys (_packageId, _packageVersion, _provenance) from the definition and send it `
72+
+ `again. Nothing was written. See docs/adr/0126-packaged-metadata-customization-model.md.`,
73+
);
74+
(err as any).code = 'INVALID_METADATA';
75+
(err as any).status = 422;
76+
return err;
77+
}

‎packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,32 @@
3434
* function the class member delegates to and that member's two calls; the
3535
* class member keeps its signature in `protocol.ts`, and `shippedArtifactsOf`'s
3636
* stub there hands the moved body the lookup as a closure, one more site.
37+
*
38+
* [#25467 E1] …and every other `protocol-*.ts` module of the split, once: a
39+
* member's body that moves out of `protocol.ts` (ruling E's host split, or the
40+
* per-member series before it) takes its calls with it, so the scan reads every
41+
* module the split can move a body to, by the same rule. A site in a moved body
42+
* is keyed by its module and the function it sits in. Comments are blanked
43+
* before the scan in every file, so a docblock that names the callee (two
44+
* modules' do) is not a site.
3745
*/
38-
import { readFileSync } from 'node:fs';
46+
import { readdirSync, readFileSync } from 'node:fs';
3947
import { fileURLToPath } from 'node:url';
48+
import ts from 'typescript';
4049
import { describe, expect, it } from 'vitest';
4150

4251
const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8');
4352
/** [#25467 cut 15] The seam module the lookup's body moved to; scanned with its own header shape. */
4453
const SEAM_FILE = 'protocol-artifact-lookup.ts';
45-
const SEAM = readFileSync(fileURLToPath(new URL(`./${SEAM_FILE}`, import.meta.url)), 'utf8');
54+
/**
55+
* [#25467 E1] Every module of the split: `protocol-*.ts` beside `protocol.ts`,
56+
* tests excluded — the census's own module pattern (`HOST_SPLIT.module` in
57+
* `scripts/check-durability-degradation-log-level.mjs`).
58+
*/
59+
const SPLIT_MODULES = readdirSync(fileURLToPath(new URL('.', import.meta.url)))
60+
.filter((file) => /^protocol-[a-z0-9-]+\.ts$/.test(file))
61+
.sort();
62+
const MODULE_SOURCES = new Map(SPLIT_MODULES.map((file) => [file, readFileSync(fileURLToPath(new URL(`./${file}`, import.meta.url)), 'utf8')]));
4663
const CALLEE = 'lookupArtifactItem(';
4764
/**
4865
* [#25467 E0] The module the protocol.ts preamble's package-binding helpers moved
@@ -53,16 +70,37 @@ const BINDINGS = readFileSync(fileURLToPath(new URL(`./${BINDINGS_FILE}`, import
5370

5471
/** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */
5572
const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
56-
/** A module function's header in the seam module: `export`, optional `async`, `function`, its name, its parameter list. */
57-
const FUNCTION_HEADER = /^export (?:async )?function ([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
73+
/** A module function's header in a split module: optional `export` and `async`, `function`, its name, its parameter list. */
74+
const FUNCTION_HEADER = /^(?:export )?(?:async )?function ([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
75+
76+
/**
77+
* [#25467 E1] The source with every comment blanked to spaces (newlines kept), so
78+
* offsets and line numbers are the file's own and a docblock that names the
79+
* callee is not read as a call. Comment ranges come from the TypeScript parser,
80+
* so a `//` inside a string or a template is not a comment.
81+
*/
82+
function withoutComments(source: string): string {
83+
const sf = ts.createSourceFile('x.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
84+
const chars = source.split('');
85+
const blank = (ranges: ts.CommentRange[] | undefined) => {
86+
for (const r of ranges ?? []) for (let i = r.pos; i < r.end; i++) if (chars[i] !== '\n') chars[i] = ' ';
87+
};
88+
const visit = (node: ts.Node) => {
89+
blank(ts.getLeadingCommentRanges(source, node.getFullStart()));
90+
blank(ts.getTrailingCommentRanges(source, node.getEnd()));
91+
for (const child of node.getChildren(sf)) visit(child);
92+
};
93+
visit(sf);
94+
return chars.join('');
95+
}
5896

5997
interface Site { readonly file?: string; readonly member: string; readonly args: string }
6098

61-
/** Every `lookupArtifactItem(` in `protocol.ts` and the seam module: the file, the member it sits in and its argument text, whitespace-normalised. */
99+
/** Every `lookupArtifactItem(` in `protocol.ts` and every split module: the file, the member it sits in and its argument text, whitespace-normalised. */
62100
function sitesIn(): Site[] {
63101
return [
64-
...sitesOf(SOURCE, MEMBER_HEADER),
65-
...sitesOf(SEAM, FUNCTION_HEADER).map((site) => ({ ...site, file: SEAM_FILE })),
102+
...sitesOf(withoutComments(SOURCE), MEMBER_HEADER),
103+
...SPLIT_MODULES.flatMap((file) => sitesOf(withoutComments(MODULE_SOURCES.get(file)!), FUNCTION_HEADER).map((site) => ({ ...site, file }))),
66104
];
67105
}
68106

@@ -173,6 +211,19 @@ describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded dispo
173211
expect(found.filter((site) => site.file === SEAM_FILE)).toHaveLength(3);
174212
});
175213

214+
it('[#25467 E1] every module of the split is scanned, the host split\'s own included', () => {
215+
// Non-vacuity for the population of files: the E0 preamble modules, the
216+
// per-member series' modules and the host split's are all read.
217+
expect(SPLIT_MODULES.length).toBeGreaterThan(40);
218+
expect(SPLIT_MODULES).toContain(SEAM_FILE);
219+
expect(SPLIT_MODULES).toContain('protocol-host.ts');
220+
expect(SPLIT_MODULES).toContain('protocol-tenant-authored-write.ts');
221+
expect(SPLIT_MODULES.some((file) => file.endsWith('.test.ts'))).toBe(false);
222+
// A docblock naming the callee is not a site: two modules carry one.
223+
expect(MODULE_SOURCES.get('protocol-credential-carry-forward.ts')).toContain('`' + CALLEE);
224+
expect(found.filter((site) => site.file === 'protocol-credential-carry-forward.ts')).toEqual([]);
225+
});
226+
176227
it('a site passing fewer than three arguments is recorded as passing no package', () => {
177228
const topLevelArgs = (args: string) => {
178229
let depth = 0;

‎packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,42 @@ describe('#9157 — the population, re-derived from the code rather than from th
266266
}
267267
return undefined;
268268
};
269+
// [#25467 E1] A member whose body moved out of protocol.ts (ruling E's
270+
// host split, or the per-member series before it) keeps its signature
271+
// here and its body in a `protocol-*.ts` module: the stub's last line is
272+
// `return fn(protocolHost(this), …)` or `return fn.call(host, …)`, `fn` a
273+
// named import from that module. The verb is judged by the moved body,
274+
// read from the module protocol.ts imports `fn` from: every module, by
275+
// this one rule.
276+
const moduleImports = new Map<string, string>();
277+
for (const m of src.matchAll(/\nimport \{([^}]*)\} from '\.\/(protocol-[a-z0-9-]+)\.js';/g)) {
278+
for (const spec of m[1]!.split(',')) {
279+
const local = /^\s*(?:\/\/[^\n]*\n\s*)*(?:[A-Za-z_$][\w$]*\s+as\s+)?([A-Za-z_$][\w$]*)\s*$/.exec(spec)?.[1];
280+
if (local !== undefined) moduleImports.set(local, `${m[2]}.ts`);
281+
}
282+
}
283+
// The stub's last statement, right before the member's own closing brace
284+
// (a member slice runs on to the next scanned member's header).
285+
const STUB = /\n {8}return (?:await )?([A-Za-z_$][\w$]*)(?:\(protocolHost\(this\)|\.call\([A-Za-z_$][\w$]*)[,)][^\n]*\n {4}\}\n/;
286+
const throughSplit: string[] = [];
287+
const movedBody = (body: string): string => {
288+
const own = body.slice(0, body.indexOf('\n }\n') + 7);
289+
const fn = STUB.exec(own)?.[1];
290+
if (fn === undefined || !moduleImports.has(fn)) return body;
291+
const file = moduleImports.get(fn)!;
292+
throughSplit.push(fn);
293+
const text = readFileSync(fileURLToPath(new URL(`./${file}`, import.meta.url)), 'utf8');
294+
const at = text.search(new RegExp(`\\nexport (?:async )?function ${fn}\\b`));
295+
expect(at, `the stub hands its body to '${fn}', which ${file} does not export`).toBeGreaterThanOrEqual(0);
296+
const end = text.indexOf('\n}\n', at + 1);
297+
return text.slice(at, end < 0 ? text.length : end + 2);
298+
};
269299
const delegateBody = (body: string): string => {
270300
const to = /this\.([A-Za-z_][A-Za-z0-9_]*Unaudited)\(request\)/.exec(body)?.[1];
271-
if (to === undefined) return body;
301+
if (to === undefined) return movedBody(body);
272302
const line = lines.findIndex((l) => l.startsWith(` private async ${to}(`));
273303
expect(line, `the wrapper delegates to '${to}', which the scan cannot find`).toBeGreaterThanOrEqual(0);
274-
return memberSlice(line);
304+
return movedBody(memberSlice(line));
275305
};
276306

277307
const unfolded: string[] = [];
@@ -299,6 +329,10 @@ describe('#9157 — the population, re-derived from the code rather than from th
299329
(/canonicalizeMetaRequestType\(request\)/.test(body) ? folded : unfolded).push(starts[k]!.name);
300330
}
301331

332+
// [#25467 E1] Non-vacuity of the split resolution: the host split's pilot
333+
// is judged by its moved body, so a stub regex that stopped matching
334+
// would fail here instead of silently judging a one-line stub.
335+
expect(throughSplit).toContain('tenantAuthoredWriteRefusal');
302336
expect(
303337
unfolded,
304338
'A `/meta` verb takes a required `type` without folding it at the request boundary. '

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

Lines changed: 20 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,6 @@ import {
6969
// it prints it at.
7070
findUndeclarableFieldType,
7171
isObjectFieldTypeRefused,
72-
// [#20761, ADR-0126 §2] The ADR-0029 D9.6 reading of a body's own
73-
// provenance stamps — asked by {@link
74-
// ObjectStackProtocolImplementation.tenantAuthoredWriteRefusal} to tell a
75-
// body that CLAIMS to be code-shipped from one that does not.
76-
isCodeArtifactBody,
7772
// [#21059] The tenant marker the hydrator writes on every stored row it
7873
// registers (ADR-0010 `_provenance: 'org'`) — read by
7974
// {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer
@@ -720,6 +715,11 @@ import type {
720715
RollbackMetaItemRequest,
721716
SaveMetaItemRequest,
722717
} from './protocol-door-requests.js';
718+
// [#25467 E1] The host a moved member body reaches this class through: the
719+
// class builds it in its static block below, and a stub hands it to the body.
720+
import { installProtocolHost, protocolHost } from './protocol-host.js';
721+
// [#25467 E1] The host split's pilot: one member's whole body, moved through the host.
722+
import { tenantAuthoredWriteRefusal } from './protocol-tenant-authored-write.js';
723723

724724
/**
725725
* [#4435/#5138] The 404 a single-record operation answers when the id names no
@@ -1264,6 +1264,19 @@ export class ObjectStackProtocolImplementation implements
12641264
this.authoringChannel = authoringChannel;
12651265
}
12661266

1267+
// [#25467 E1] The one place that can read this class's private members builds
1268+
// the host its moved bodies reach it through (`protocol-host.ts`). A static
1269+
// block adds no member to the class: the published `.d.ts` does not move.
1270+
static {
1271+
installProtocolHost((protocol) => ({
1272+
overrideWriteVerdict: (...a) => protocol.overrideWriteVerdict(...a),
1273+
packagedArtifactOwner: (...a) => protocol.packagedArtifactOwner(...a),
1274+
packagedBaseRefusal: (...a) => protocol.packagedBaseRefusal(...a),
1275+
storedFlowBindingAgrees: (...a) => protocol.storedFlowBindingAgrees(...a),
1276+
uninstalledNamedBaseRefusal: (...a) => protocol.uninstalledNamedBaseRefusal(...a),
1277+
}));
1278+
}
1279+
12671280
/**
12681281
* Register a publish-time materializer for a metadata type (ADR-0086 P2).
12691282
* Called by domain plugins at init (e.g. service-automation registers the
@@ -11118,50 +11131,8 @@ export class ObjectStackProtocolImplementation implements
1111811131
* proceed on these grounds.
1111911132
*/
1112011133
async tenantAuthoredWriteRefusal(request: { type: string; name: string; item: unknown; packageId?: string | null }): Promise<Error | null> {
11121-
const folded = canonicalizeMetaRequestType(request);
11122-
const singular = PLURAL_TO_SINGULAR[folded.type] ?? folded.type;
11123-
if (singular !== 'flow') return null;
11124-
const shipped = this.packagedArtifactOwner(folded) !== undefined;
11125-
// [#28412, ADR-0146 D2 / D9] Rule 1's lock does not apply to the
11126-
// shipping package's declared override: the one override write gate
11127-
// ({@link overrideWriteVerdict}) admits it as the pair's own row, which
11128-
// edits nothing the package ships. Any other write of a shipped name
11129-
// keeps the lock.
11130-
if (shipped && this.overrideWriteVerdict({
11131-
type: singular,
11132-
name: folded.name,
11133-
item: folded.item,
11134-
...(folded.packageId ? { packageId: folded.packageId } : {}),
11135-
}).verdict !== 'admit') {
11136-
const locked = this.packagedBaseRefusal({
11137-
type: folded.type,
11138-
name: folded.name,
11139-
operation: 'save',
11140-
...(folded.packageId ? { packageId: folded.packageId } : {}),
11141-
});
11142-
if (locked) return locked;
11143-
}
11144-
// [#20863] The named-base rule (see the docblock): a base the write
11145-
// names must be a package the registry holds as installed. [#26708]
11146-
// The rule is {@link uninstalledNamedBaseRefusal}'s, which
11147-
// `saveMetaItem` asks for every other type too.
11148-
const unbound = this.uninstalledNamedBaseRefusal({ type: singular, name: folded.name, packageId: folded.packageId });
11149-
if (unbound) return unbound;
11150-
if (shipped) return null;
11151-
if (!isCodeArtifactBody(folded.item)) return null;
11152-
const stamped = String((folded.item as { _packageId?: unknown })._packageId);
11153-
if (folded.packageId === stamped) return null;
11154-
if (await this.storedFlowBindingAgrees(folded.name, stamped)) return null;
11155-
const err = new Error(
11156-
`Flow '${folded.name}' was sent with a code-package provenance, but no managed package ships a flow of `
11157-
+ `that name. A flow written through an authoring door is authored in this deployment: which flows a `
11158-
+ `package ships is established by the package loader, never by the definition a caller sends. `
11159-
+ `Remove the provenance keys (_packageId, _packageVersion, _provenance) from the definition and send it `
11160-
+ `again. Nothing was written. See docs/adr/0126-packaged-metadata-customization-model.md.`,
11161-
);
11162-
(err as any).code = 'INVALID_METADATA';
11163-
(err as any).status = 422;
11164-
return err;
11134+
// [#25467 E1] The body moved to `protocol-tenant-authored-write.ts`; it reaches this class through `protocol-host.ts`.
11135+
return tenantAuthoredWriteRefusal(protocolHost(this), request);
1116511136
}
1116611137

1116711138
/**

0 commit comments

Comments
 (0)