Skip to content

Commit 3028326

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol,objectql): 运行时发布门改由「显式声明的作者通道」激活,不再借用 environmentId (#6710) (#6971)
#4463 的运行时发布门(26 条共享 AUTHORING_RULES)此前挂在 `if (this.environmentId === undefined) return;` 后面。这条短路本意是 ADR-0005 「包作者自有 bootstrap 通道」的 carve-out——carve-out 本身正确并保留——但 `environmentId` 是一个行作用域键,而两种意图相反的拓扑都让它为 undefined: 真正的控制面,以及 CLI 的轻量 host-config 装配(`serve.ts` 的 `config.objects && !hasObjectQL` 分支,`new ObjectQLPlugin()` 无参)。后者是任何 带实例化插件的 `objectstack.config.ts` 的形状(`isHostConfig` → `shouldBootWithLibrary === false`),旗舰 showcase 即在其上,而它的 `PUT /api/v1/meta/*` 是终端用户面——于是自托管应用服务器上 26 条规则一条都不跑。 本轮在基线 68feaad 上做了 boot 级复测:environmentId 为 undefined,#4463 自己 那条 broken-CEL 审批流直接越过门进入持久化。 改法:通道由装配显式声明,门按声明激活。新增公开插件选项 `authoringChannel: 'environment' | 'package-author'`(ObjectQLPlugin 与 createMetadataProtocolPlugin 各一份),经 `assembleMetadataProtocol` 这一两种挂载 共享的唯一接缝下沉到协议实例。缺省(不写)即 `'environment'`,即开门设防: 忘记声明得到的是更多而非更少的强制——这正是本单要设计掉的失败模式。选项刻意是 通道名而非布尔:`skipAuthoringRules: true` 是同样的字节、相反的语义。 `environmentId` 保留其余全部职责(行标记与过滤、ADR-0005 overlay 白名单、#3050 authoring gate 的作用域、本地元数据表 provisioning),本单只搬这一处激活判断。 cloud 侧后续(不在本仓):`control-plane-preset.ts` 需声明 `authoringChannel: 'package-author'`;在此之前控制面按安全方向受门约束,由既有 `OS_ALLOW_UNLINTED_METADATA_WRITES` 逃生舱缓冲。 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ec55ef commit 3028326

8 files changed

Lines changed: 724 additions & 37 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(metadata-protocol,objectql): the #4463 runtime authoring gate now runs on every kernel that has not declared itself the package author's channel (#6710)
7+
8+
The 26 shared author-time rules (`AUTHORING_RULES` — the same table `os validate`
9+
/ `os build` / `os lint` run) were gated behind
10+
`if (this.environmentId === undefined) return;`. That short-circuit was meant to
11+
be ADR-0005's "the package author's own bootstrap channel" carve-out, and the
12+
carve-out itself is legitimate. The key was not: `environmentId` is a ROW-SCOPING
13+
key, and two very different topologies leave it undefined.
14+
15+
**The defect.** The CLI's lightweight host-config assembler — `serve.ts`'s
16+
`config.objects && !hasObjectQL` auto-register branch, which constructs
17+
`new ObjectQLPlugin()` with no options — also boots with no `environmentId`.
18+
That is the shape any `objectstack.config.ts` with instantiated plugins gets
19+
(`isHostConfig``shouldBootWithLibrary === false`), including the flagship
20+
showcase app. Its `PUT /api/v1/meta/*` is an **end-user** surface, so a
21+
self-hosted app server ran **zero** of the 26 rules on every publish. For a
22+
Studio tenant or an MCP/AI author this gate is not the weakest of four doors —
23+
it is the only one, because a `sys_metadata` overlay row is never in the CLI's
24+
config file and there is no `os lint` for it. Measured at boot level: the kernel
25+
reports `environmentId === undefined` and #4463's own broken-CEL approval flow
26+
(`record.owner ==`) runs straight past the gate into persistence.
27+
28+
**The fix — the channel is declared, not inferred.** A new plugin option states
29+
what a kernel *is*, and gate activation reads that instead of row scope:
30+
31+
```ts
32+
new ObjectQLPlugin({ authoringChannel: 'package-author' })
33+
createMetadataProtocolPlugin({ authoringChannel: 'package-author' })
34+
```
35+
36+
`'environment'` (the default, and what you get by omitting the option) runs the
37+
rules. `'package-author'` is the ADR-0005 carve-out and belongs only on the
38+
genuine control-plane assembly — the kernel installing packages on the
39+
platform's own behalf. The option is threaded through `assembleMetadataProtocol`,
40+
the one seam both mounts share, so the built-in and delegated (ADR-0076 Step 2)
41+
mounts cannot disagree.
42+
43+
**Omitting it means more enforcement, never less.** That direction is the point:
44+
the failure mode being designed out is a future assembly variant nobody thought
45+
about silently reopening this hole, which is exactly how the host-config
46+
topology got here. It is also why the option is a channel NAME and not a
47+
boolean — `skipAuthoringRules: true` would be the same bytes with the opposite
48+
meaning, a switch for making a red publish go away. #5086 had already retired
49+
the same proxy key for the code-only refusal, for the same reason.
50+
51+
**What changes for you.** A kernel that serves metadata writes to end users
52+
should change nothing — it now enforces the rules it always should have. A
53+
kernel that genuinely is a control plane must add `authoringChannel:
54+
'package-author'`; until it does it runs gated in the safe direction, and the
55+
existing per-write `OS_ALLOW_UNLINTED_METADATA_WRITES=1` hatch (#4463 D4)
56+
degrades a refusal to a loud log. `environmentId` keeps every one of its other
57+
jobs unchanged — the `environment_id` stamp and filter, the ADR-0005 overlay
58+
whitelist, the #3050 authoring gate's scope, and local metadata-storage
59+
provisioning. Only this one activation moved.

packages/metadata-protocol/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeView
66
// instead of minting a second not-found shape. See `recordNotFoundError`.
77
export { recordNotFoundError } from './protocol.js';
88
export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
9-
export type { MetadataProtocolPluginOptions } from './plugin.js';
9+
export type { MetadataProtocolPluginOptions, AssembleMetadataProtocolOptions } from './plugin.js';
10+
// [#6710] The declared authoring channel — the explicit expression of ADR-0005's
11+
// "package author's own bootstrap channel", replacing the `environmentId ===
12+
// undefined` proxy the #4463 gate used to key its activation off.
13+
export type { MetadataAuthoringChannel } from './protocol.js';
1014

1115
// [#5839] `sys_view_definition`'s active-row uniqueness, delivered as a runtime
1216
// partial-UNIQUE migration (the `ensureOverlayIndex` paradigm, for the one other

packages/metadata-protocol/src/plugin.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
resolveIndexExec,
3737
} from './migrations/view-definition-active-index.js';
3838
import { ObjectStackProtocolImplementation } from './protocol.js';
39+
import type { MetadataAuthoringChannel } from './protocol.js';
3940

4041
export interface MetadataProtocolPluginOptions {
4142
/**
@@ -46,10 +47,30 @@ export interface MetadataProtocolPluginOptions {
4647
* Mirrors `ObjectQLPluginOptions.environmentId` — pass the same value.
4748
*/
4849
environmentId?: string;
50+
/**
51+
* [#6710] Which authoring channel this kernel's metadata writes arrive on.
52+
*
53+
* Leave unset on ANY kernel that serves `PUT /api/v1/meta/*` to end users
54+
* (Studio tenants, MCP/AI authors, self-hosted app servers): the default
55+
* `'environment'` runs the #4463 runtime authoring rules, which for those
56+
* authors is the only author-time gate that exists.
57+
*
58+
* Set `'package-author'` ONLY on the genuine control-plane assembly — the
59+
* kernel that installs packages on the platform's own behalf and is not an
60+
* author publishing into a live tenant. Stating it is a claim about what
61+
* this kernel IS; it is not a switch for making a red publish go away.
62+
*
63+
* Deliberately no env-var fallback (unlike `skipSchemaSync`): a deployment
64+
* must not be able to turn an end-user guardrail off from the outside. The
65+
* per-write escape hatch that DOES exist is
66+
* `OS_ALLOW_UNLINTED_METADATA_WRITES` (#4463 D4), which degrades the
67+
* refusal to a loud log instead of silencing it.
68+
*/
69+
authoringChannel?: MetadataAuthoringChannel;
4970
}
5071

5172
export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOptions = {}): Plugin {
52-
const { environmentId } = options;
73+
const { environmentId, authoringChannel } = options;
5374
return {
5475
name: 'com.objectstack.metadata.protocol',
5576
version: '1.0.0',
@@ -71,11 +92,20 @@ export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOpti
7192
);
7293
}
7394

74-
assembleMetadataProtocol(ctx, ql, environmentId);
95+
assembleMetadataProtocol(ctx, ql, environmentId, { authoringChannel });
7596
},
7697
};
7798
}
7899

100+
/** Extra assembly inputs that are not row scope. Bag-shaped so the next one is additive. */
101+
export interface AssembleMetadataProtocolOptions {
102+
/**
103+
* [#6710] See {@link MetadataProtocolPluginOptions.authoringChannel}.
104+
* Omitted ⇒ `'environment'` ⇒ the #4463 runtime authoring gate is active.
105+
*/
106+
authoringChannel?: MetadataAuthoringChannel;
107+
}
108+
79109
/**
80110
* The ONE protocol assembly (ADR-0076 Step 2 PR-C): metadata-storage platform
81111
* objects + `ObjectStackProtocolImplementation` as the `protocol` service.
@@ -98,6 +128,7 @@ export function assembleMetadataProtocol(
98128
ctx: PluginContext,
99129
ql: any,
100130
environmentId?: string,
131+
options: AssembleMetadataProtocolOptions = {},
101132
): ObjectStackProtocolImplementation {
102133
// Metadata-storage platform objects (sys_metadata + history/audit
103134
// siblings + sys_view_definition). Same `environmentId === undefined`
@@ -123,10 +154,20 @@ export function assembleMetadataProtocol(
123154
});
124155
}
125156

157+
// [#6710] The authoring channel is threaded here and NOWHERE else:
158+
// this function is the one seam BOTH mounts share (the delegated
159+
// MetadataProtocolPlugin and ObjectQLPlugin's built-in
160+
// `registerProtocol !== false` convenience mode), so a declaration
161+
// that lands here cannot be half-applied depending on how the host
162+
// chose to mount the protocol. `?? 'environment'` is the fail-safe
163+
// default restated at the seam — a caller reaching
164+
// `assembleMetadataProtocol` directly with no options bag gets the
165+
// gated channel, exactly like one that omits the plugin option.
126166
const protocolShim = new ObjectStackProtocolImplementation(
127167
ql,
128168
() => (ctx.getServices ? ctx.getServices() : new Map()),
129169
environmentId,
170+
options.authoringChannel ?? 'environment',
130171
);
131172
ctx.registerService('protocol', protocolShim);
132173
ctx.logger.info('Protocol service registered (MetadataProtocolPlugin)');

packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -526,16 +526,20 @@ describe('#6285 refusal through saveMetaItem / publishMetaItem', () => {
526526
expect(shouted[0]).toContain(PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING);
527527
});
528528

529-
it('does not gate control-plane (package-author) writes — the pre-existing carve-out', async () => {
530-
// The dispatch's STOP item. `environmentId === undefined` is the
531-
// control-plane / package-author channel, and the short-circuit is
532-
// EXISTING DESIGN (`protocol.runtime-authoring-gate.test.ts` pins it for
533-
// the other 26 rules). It does not put this guardrail out of reach:
534-
// every serving path binds an environment id (`env_local` /
535-
// `proj_local` / a cloud project), which is the case the test above
536-
// drives.
529+
it('does not gate a DECLARED package-author (control-plane) channel — the pre-existing carve-out', async () => {
530+
// The dispatch's STOP item, re-spelled by #6710. The carve-out is
531+
// unchanged and still EXISTING DESIGN
532+
// (`protocol.runtime-authoring-gate.test.ts` pins it for all 26 rules);
533+
// what changed is how a kernel claims it. This case used to construct
534+
// the protocol with no `environmentId` and rely on that meaning
535+
// "control plane" — a row-scoping key standing in for a topology, which
536+
// #6710 measured to be false for the CLI's host-config assembler. The
537+
// channel is declared now, so the carve-out this case is about is
538+
// stated rather than inferred.
537539
const { engine, rows } = makeStubEngine();
538-
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
540+
const protocol = new ObjectStackProtocolImplementation(
541+
engine, () => new Map(), undefined, 'package-author',
542+
) as any;
539543
const result = await protocol.saveMetaItem({
540544
type: 'flow',
541545
name: 'nightly_sweep',
@@ -545,6 +549,24 @@ describe('#6285 refusal through saveMetaItem / publishMetaItem', () => {
545549
expect(flowRows(rows).length).toBe(1);
546550
});
547551

552+
it('[#6710] DOES gate an unscoped kernel that never declared the channel', async () => {
553+
// The other half of the re-spelling, and the reason it is not merely
554+
// cosmetic: this guardrail is one of the 26 shared rules, so #6710
555+
// widened ITS reach too. An unscoped kernel that has not claimed the
556+
// package-author channel is a host-config app server — an end-user
557+
// surface — and #6285's refusal now applies there. Without this case
558+
// the file would assert only the side that stayed the same.
559+
const { engine, rows } = makeStubEngine();
560+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
561+
const err = await protocol
562+
.saveMetaItem({ type: 'flow', name: 'nightly_sweep', item: scheduledSweep() })
563+
.catch((e: any) => e);
564+
expect(err?.code).toBe('INVALID_METADATA');
565+
expect(err?.status).toBe(422);
566+
expect(err.issues.map((i: any) => i.rule)).toContain(PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING);
567+
expect(flowRows(rows)).toEqual([]);
568+
});
569+
548570
it('does not gate `os migrate meta --stored`, which rewrites rows that already exist', async () => {
549571
const { protocol, rows } = makeProtocol();
550572
const result = await save(protocol, scheduledSweep(), { source: 'migrate-stored' });

0 commit comments

Comments
 (0)