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
59 changes: 59 additions & 0 deletions .changeset/authoring-channel-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
"@objectstack/metadata-protocol": minor
"@objectstack/objectql": minor
---

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)

The 26 shared author-time rules (`AUTHORING_RULES` — the same table `os validate`
/ `os build` / `os lint` run) were gated behind
`if (this.environmentId === undefined) return;`. That short-circuit was meant to
be ADR-0005's "the package author's own bootstrap channel" carve-out, and the
carve-out itself is legitimate. The key was not: `environmentId` is a ROW-SCOPING
key, and two very different topologies leave it undefined.

**The defect.** The CLI's lightweight host-config assembler — `serve.ts`'s
`config.objects && !hasObjectQL` auto-register branch, which constructs
`new ObjectQLPlugin()` with no options — also boots with no `environmentId`.
That is the shape any `objectstack.config.ts` with instantiated plugins gets
(`isHostConfig` → `shouldBootWithLibrary === false`), including the flagship
showcase app. Its `PUT /api/v1/meta/*` is an **end-user** surface, so a
self-hosted app server ran **zero** of the 26 rules on every publish. For a
Studio tenant or an MCP/AI author this gate is not the weakest of four doors —
it is the only one, because a `sys_metadata` overlay row is never in the CLI's
config file and there is no `os lint` for it. Measured at boot level: the kernel
reports `environmentId === undefined` and #4463's own broken-CEL approval flow
(`record.owner ==`) runs straight past the gate into persistence.

**The fix — the channel is declared, not inferred.** A new plugin option states
what a kernel *is*, and gate activation reads that instead of row scope:

```ts
new ObjectQLPlugin({ authoringChannel: 'package-author' })
createMetadataProtocolPlugin({ authoringChannel: 'package-author' })
```

`'environment'` (the default, and what you get by omitting the option) runs the
rules. `'package-author'` is the ADR-0005 carve-out and belongs only on the
genuine control-plane assembly — the kernel installing packages on the
platform's own behalf. The option is threaded through `assembleMetadataProtocol`,
the one seam both mounts share, so the built-in and delegated (ADR-0076 Step 2)
mounts cannot disagree.

**Omitting it means more enforcement, never less.** That direction is the point:
the failure mode being designed out is a future assembly variant nobody thought
about silently reopening this hole, which is exactly how the host-config
topology got here. It is also why the option is a channel NAME and not a
boolean — `skipAuthoringRules: true` would be the same bytes with the opposite
meaning, a switch for making a red publish go away. #5086 had already retired
the same proxy key for the code-only refusal, for the same reason.

**What changes for you.** A kernel that serves metadata writes to end users
should change nothing — it now enforces the rules it always should have. A
kernel that genuinely is a control plane must add `authoringChannel:
'package-author'`; until it does it runs gated in the safe direction, and the
existing per-write `OS_ALLOW_UNLINTED_METADATA_WRITES=1` hatch (#4463 D4)
degrades a refusal to a loud log. `environmentId` keeps every one of its other
jobs unchanged — the `environment_id` stamp and filter, the ADR-0005 overlay
whitelist, the #3050 authoring gate's scope, and local metadata-storage
provisioning. Only this one activation moved.
6 changes: 5 additions & 1 deletion packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeView
// instead of minting a second not-found shape. See `recordNotFoundError`.
export { recordNotFoundError } from './protocol.js';
export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
export type { MetadataProtocolPluginOptions } from './plugin.js';
export type { MetadataProtocolPluginOptions, AssembleMetadataProtocolOptions } from './plugin.js';
// [#6710] The declared authoring channel — the explicit expression of ADR-0005's
// "package author's own bootstrap channel", replacing the `environmentId ===
// undefined` proxy the #4463 gate used to key its activation off.
export type { MetadataAuthoringChannel } from './protocol.js';

// [#5839] `sys_view_definition`'s active-row uniqueness, delivered as a runtime
// partial-UNIQUE migration (the `ensureOverlayIndex` paradigm, for the one other
Expand Down
45 changes: 43 additions & 2 deletions packages/metadata-protocol/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
resolveIndexExec,
} from './migrations/view-definition-active-index.js';
import { ObjectStackProtocolImplementation } from './protocol.js';
import type { MetadataAuthoringChannel } from './protocol.js';

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

export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOptions = {}): Plugin {
const { environmentId } = options;
const { environmentId, authoringChannel } = options;
return {
name: 'com.objectstack.metadata.protocol',
version: '1.0.0',
Expand All @@ -71,11 +92,20 @@ export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOpti
);
}

assembleMetadataProtocol(ctx, ql, environmentId);
assembleMetadataProtocol(ctx, ql, environmentId, { authoringChannel });
},
};
}

/** Extra assembly inputs that are not row scope. Bag-shaped so the next one is additive. */
export interface AssembleMetadataProtocolOptions {
/**
* [#6710] See {@link MetadataProtocolPluginOptions.authoringChannel}.
* Omitted ⇒ `'environment'` ⇒ the #4463 runtime authoring gate is active.
*/
authoringChannel?: MetadataAuthoringChannel;
}

/**
* The ONE protocol assembly (ADR-0076 Step 2 PR-C): metadata-storage platform
* objects + `ObjectStackProtocolImplementation` as the `protocol` service.
Expand All @@ -98,6 +128,7 @@ export function assembleMetadataProtocol(
ctx: PluginContext,
ql: any,
environmentId?: string,
options: AssembleMetadataProtocolOptions = {},
): ObjectStackProtocolImplementation {
// Metadata-storage platform objects (sys_metadata + history/audit
// siblings + sys_view_definition). Same `environmentId === undefined`
Expand All @@ -123,10 +154,20 @@ export function assembleMetadataProtocol(
});
}

// [#6710] The authoring channel is threaded here and NOWHERE else:
// this function is the one seam BOTH mounts share (the delegated
// MetadataProtocolPlugin and ObjectQLPlugin's built-in
// `registerProtocol !== false` convenience mode), so a declaration
// that lands here cannot be half-applied depending on how the host
// chose to mount the protocol. `?? 'environment'` is the fail-safe
// default restated at the seam — a caller reaching
// `assembleMetadataProtocol` directly with no options bag gets the
// gated channel, exactly like one that omits the plugin option.
const protocolShim = new ObjectStackProtocolImplementation(
ql,
() => (ctx.getServices ? ctx.getServices() : new Map()),
environmentId,
options.authoringChannel ?? 'environment',
);
ctx.registerService('protocol', protocolShim);
ctx.logger.info('Protocol service registered (MetadataProtocolPlugin)');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,16 +526,20 @@ describe('#6285 refusal through saveMetaItem / publishMetaItem', () => {
expect(shouted[0]).toContain(PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING);
});

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

it('[#6710] DOES gate an unscoped kernel that never declared the channel', async () => {
// The other half of the re-spelling, and the reason it is not merely
// cosmetic: this guardrail is one of the 26 shared rules, so #6710
// widened ITS reach too. An unscoped kernel that has not claimed the
// package-author channel is a host-config app server — an end-user
// surface — and #6285's refusal now applies there. Without this case
// the file would assert only the side that stayed the same.
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
const err = await protocol
.saveMetaItem({ type: 'flow', name: 'nightly_sweep', item: scheduledSweep() })
.catch((e: any) => e);
expect(err?.code).toBe('INVALID_METADATA');
expect(err?.status).toBe(422);
expect(err.issues.map((i: any) => i.rule)).toContain(PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING);
expect(flowRows(rows)).toEqual([]);
});

it('does not gate `os migrate meta --stored`, which rewrites rows that already exist', async () => {
const { protocol, rows } = makeProtocol();
const result = await save(protocol, scheduledSweep(), { source: 'migrate-stored' });
Expand Down
Loading
Loading