From ca181384fc18835a2ddc8693817e1fb268ac28f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:33:46 +0000 Subject: [PATCH 01/21] =?UTF-8?q?wip(metadata-protocol):=20S5=20reads=20en?= =?UTF-8?q?vironment=20=E2=86=92=20code=20(in=20progress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- packages/metadata-core/src/index.ts | 7 - .../src/meta-write-org-scope.test.ts | 85 -- .../metadata-core/src/meta-write-org-scope.ts | 135 -- packages/metadata-protocol/src/item-lock.ts | 43 +- .../src/meta-overlay-cache.ts | 14 +- packages/metadata-protocol/src/protocol.ts | 1312 ++++++----------- .../src/sys-metadata-repository.ts | 42 +- 7 files changed, 446 insertions(+), 1192 deletions(-) delete mode 100644 packages/metadata-core/src/meta-write-org-scope.test.ts delete mode 100644 packages/metadata-core/src/meta-write-org-scope.ts diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 8328313261b..4b2f072d15b 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -95,13 +95,6 @@ export * from './object-schema-fls.js'; // `ITEM_KEY_DISCRIMINATORS` from `registry.ts`, so its surface is unchanged. export * from './item-key-discriminators.js'; -// [#9454 · ADR-0131 D6] The registry-derived per-organization overlay -// predicate (`declaresOrgOverride`) and the organization a protocol READ -// carries (`organizationIdForMetaRead`). The write-side twin retired with the -// per-organization overlay axis: the `/meta` doors carry no organization into -// a metadata write. See the module header for what still reads through it. -export * from './meta-write-org-scope.js'; - // [#12702 · ADR-0131 D6] Which CALLERS a `/meta` item write door admits: // `manage_metadata` (or `isSystem`), on both transports. The org-scoped // `manage_org_presentation` arm retired with the per-organization overlay diff --git a/packages/metadata-core/src/meta-write-org-scope.test.ts b/packages/metadata-core/src/meta-write-org-scope.test.ts deleted file mode 100644 index f9f676e73c9..00000000000 --- a/packages/metadata-core/src/meta-write-org-scope.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Commit 26f3588fb — `declaresOrgOverride` and the boundary fold, pinned as ONE -// composed contract. -// -// The predicate tolerates the MANIFEST-collection spellings only; the URL -// spelling contract is larger (`META_URL_TO_SINGULAR` also carries every -// registry-derived spelling). The two disagreed for `translations` / -// `email_templates`, and the REST doors — which held raw URL segments — -// consulted the predicate directly, so those two spellings read and wrote -// env-wide while their singular twins were org-scoped. -// -// The correction is at the boundary: callers fold through -// `canonicalMetaUrlType` BEFORE asking. These cases pin BOTH halves: -// -// 1. the COMPOSED contract (fold → predicate) answers the registry flag -// for every spelling in the URL map — the property the doors rely on; -// 2. the predicate itself does NOT absorb the URL map — the repair -// `metadata-url-spelling.ts` forbids ("nothing here should ever be -// consulted by a predicate one layer down"). If a future change widens -// the predicate, this pin turns red so the widening is argued against -// commit 26f3588fb / #7894 rather than slipped in as a convenience. - -import { describe, it, expect } from 'vitest'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/meta-spelling'; -import * as metadataCore from './index.js'; -import { - declaresOrgOverride, - organizationIdForMetaRead, -} from './meta-write-org-scope.js'; - -const ORG = 'org_alpha'; - -const REGISTRY_FLAG = new Map( - DEFAULT_METADATA_TYPE_REGISTRY.map((e) => [e.type, e.allowOrgOverride === true]), -); - -describe('#10340 org scope composed with the boundary fold', () => { - it('fold → predicate answers the registry flag for EVERY spelling in the URL map', () => { - // The contract every REST door relies on after commit 26f3588fb: whatever the - // caller spelled, folding first yields the canonical type's own - // declaration. Quantified over the whole map so a new spelling limb - // arrives already covered. - for (const [spelling, folded] of Object.entries(META_URL_TO_SINGULAR)) { - const expected = REGISTRY_FLAG.get(folded) === true ? ORG : undefined; - expect( - organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG), - `composed read scope for '${spelling}' (folds to '${folded}')`, - ).toBe(expected); - } - }); - - it('the predicate alone still answers the canonical singular correctly', () => { - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - expect( - declaresOrgOverride(entry.type), - `declaresOrgOverride('${entry.type}')`, - ).toBe(entry.allowOrgOverride === true); - } - }); - - it('⛔ the predicate does NOT absorb the URL map — URL-only spellings answer false raw', () => { - // The documented limit, pinned so it cannot erode silently. These are - // registry-derived URL spellings with no manifest key; the BOUNDARY - // folds them, the predicate must not learn them (#7894's forbidden - // repair, one layer down). A deliberate future widening flips this - // pin consciously, against the recorded rationale — never as a - // side effect. - expect(declaresOrgOverride('translations')).toBe(false); - expect(declaresOrgOverride('email_templates')).toBe(false); - // The manifest tolerance it DOES carry, kept for dispatcher-era - // callers, stays: manifest spellings of overridable types hold. - expect(declaresOrgOverride('views')).toBe(true); - expect(declaresOrgOverride('emailTemplates')).toBe(true); - }); -}); - -describe('ADR-0131 D6 — the write-side twin retired', () => { - it('`organizationIdForMetaWrite` is no longer exported: no `/meta` door threads an organization into a write', () => { - expect('organizationIdForMetaWrite' in metadataCore).toBe(false); - // Control: the read gate the protocol still runs is exported from the same barrel. - expect('organizationIdForMetaRead' in metadataCore).toBe(true); - }); -}); diff --git a/packages/metadata-core/src/meta-write-org-scope.ts b/packages/metadata-core/src/meta-write-org-scope.ts deleted file mode 100644 index 17a23e3b87c..00000000000 --- a/packages/metadata-core/src/meta-write-org-scope.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The registry-derived "does this type declare a per-organization overlay?" - * predicate, and the organization a metadata READ inside the protocol carries. - * - * ── ADR-0131 D6: the doors carry no organization (C5 stage S3) ──────────── - * - * The per-organization overlay axis is retired. Neither transport's `/meta` - * doors (`@objectstack/rest`, and the runtime dispatcher's `/meta` branch) - * carry an organization into a metadata write or read any more: every - * Studio-authored write lands environment-wide (`organization_id` NULL), and - * every door read resolves environment → code. The write-side twin that used - * to decide which writes carried the session's organization, - * `organizationIdForMetaWrite`, therefore had no caller left and was deleted. - * - * What stays, and why. `@objectstack/metadata-protocol` still gates the reads - * an in-process caller hands an organization (`getMetaItem`, `getMetaItems`, - * the layered read, history and diff) through {@link organizationIdForMetaRead}. - * The one door that still names an organization is the anonymous form door, - * whose read of the Default Organization's withdrawals stays, fail-closed, - * until the promotion ceremony carries those rows to the environment layer - * (ADR-0131 C7). The protocol's own read narrowing (environment → code - * everywhere) is a later stage of the same retirement, and deletes this - * module with it. - * - * ⛔ Registry-derived, never a hand-written list (Prime Directive #8): the set - * below is computed from `DEFAULT_METADATA_TYPE_REGISTRY`, so a registry entry - * flipping `allowOrgOverride` moves this predicate with it. - */ - -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; - -/** - * Metadata types whose registry entry declares `allowOrgOverride: true`, - * augmented with each one's MANIFEST plural spelling (`SINGULAR_TO_PLURAL`). - * - * ⚠️ [commit 26f3588fb] That augmentation is NOT the protocol's URL fold, and the doc - * that used to stand here — "judged identically to the singular form — the - * same normalization the protocol's own allow-list does" — was measured - * false. The protocol folds through `META_URL_TO_SINGULAR`, the COMPLETE - * spelling map; `SINGULAR_TO_PLURAL` is the manifest-collection map, which is - * incomplete by design (`translation` and `email_template` have no manifest - * key, so `translations` / `email_templates` are not in this set). Handed a - * raw URL segment this predicate therefore answered env-wide for those two - * spellings while storage folded them into an org-scoped type — one item, - * two partitions, addressed by spelling. - * - * The correction landed at the boundary, not here: a caller folds the - * segment through `canonicalMetaUrlType` BEFORE the scope decision, - * exactly as `metadata-url-spelling.ts` mandates ("folding happens at the - * boundary and only there; the layers below keep reading the single - * canonical singular"). ⛔ Do not "complete" this set with the URL map — a - * predicate below the boundary consuming the URL spelling contract is the - * repair that module's header forbids, and #7894 already refused once. - */ -const ORG_OVERRIDABLE_TYPES: ReadonlySet = (() => { - const out = new Set(); - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - if (!entry.allowOrgOverride) continue; - out.add(entry.type); - const plural = SINGULAR_TO_PLURAL[entry.type]; - if (plural) out.add(plural); - } - return out; -})(); - -/** - * Does the registry declare this metadata type per-org overridable? - * - * Expects the CANONICAL singular type. It additionally tolerates the - * manifest-collection spellings (`views`, `emailTemplates`, …) — kept for the - * dispatcher-era callers — but ⚠️ [commit 26f3588fb] that tolerance is NOT the URL - * fold: URL-only spellings (`translations`, `email_templates`) answer - * `false` here. A caller holding a raw `/meta/:type` segment must fold it - * through `canonicalMetaUrlType` BEFORE asking; see - * `ORG_OVERRIDABLE_TYPES` above for the measurement and for why this - * predicate must not grow the URL map itself. - * - * A type with no registry entry at all — runtime-registered plugin types — - * answers `false`: boot hydration has no per-org channel for them either, so - * an org-scoped row would be the same phantom. - */ -export function declaresOrgOverride(type: string): boolean { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - return ORG_OVERRIDABLE_TYPES.has(singular) || ORG_OVERRIDABLE_TYPES.has(type); -} - -/** - * [#9454] The `organizationId` a metadata READ of `type` should carry, given - * the caller's organization. - * - * ── Why a read door has to ask this at all ──────────────────────────────── - * - * The (since retired) write-side twin stopped the runtime MINTING org-scoped - * rows for types that have no per-org read channel. It says nothing about serving - * the rows that types WITH such a channel legitimately produce — and the REST - * `/meta` read doors were never told. A `PUT` of an org-overridable type - * (`view`, `dashboard`, `report`, `translation`, `email_template`) landed an - * org-scoped row, answered `200 state:'active'`, and then every REST read door - * asked for the row WITHOUT naming an organization. `getMetaItem` resolves - * `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)`, so an - * org-less read resolves the env-wide row only: the author's work was - * persisted, receipted as live, and served by nothing. That is #9454. - * - * ── Why it is registry-derived and NOT a bare `ctx?.tenantId` ───────────── - * - * ⛔ The tempting shorter fix — pass the active org at every read site — is - * wrong in a way that only shows on databases with history. Deployments that - * ran before the #6190 ruling contain PHANTOM org-scoped rows for types the - * registry declares non-overridable (`object`, `flow`, … — the runtime used to - * stamp `organization_id` on every type; `reportUnhydratableOrgScopedRows` is - * the audit that warns about the survivors). Boot hydration walks past those - * rows deliberately, so they are dead. A read door that named the org for - * EVERY type would resolve them again — resurrecting, on the read side, exactly - * the phantom writes #6190 stopped minting, and serving a document that - * vanishes at the next restart. Gating the read on the same static registry - * flag keeps the two sides answering one question. - * - * Since ADR-0131 D6 the `/meta` doors hand the protocol no organization, so - * this gate answers `undefined` for every door read; see the module header for - * the in-process callers that still name one. - * - * Returns the active org for a type the registry declares per-org overridable, - * and `undefined` — env-wide, today's behaviour for every read — otherwise. - * An anonymous or org-less caller reads exactly what it reads today. - */ -export function organizationIdForMetaRead( - type: string, - activeOrganizationId: string | undefined, -): string | undefined { - if (activeOrganizationId === undefined) return undefined; - return declaresOrgOverride(type) ? activeOrganizationId : undefined; -} diff --git a/packages/metadata-protocol/src/item-lock.ts b/packages/metadata-protocol/src/item-lock.ts index 2e06934a9c1..1721444e6ba 100644 --- a/packages/metadata-protocol/src/item-lock.ts +++ b/packages/metadata-protocol/src/item-lock.ts @@ -58,10 +58,9 @@ * reported that package's row while the `_lock` gate bound whichever row * `findOne` returned first. * - * - **The rows in scope.** ADR-0005 precedence, as the reads resolve it: the - * organization's rows when it holds any row of the item, else the env-wide - * rows. Within that scope every row of the item, whichever package it is - * bound to: the address's own package row, the package-less row, and any + * - **The rows in scope.** The environment's rows of the item (ADR-0131 D6: + * a legacy organization-scoped row is reported at boot, never read), every + * one of them, whichever package it is bound to: the address's own package row, the package-less row, and any * other package's row. Those are every row the doors could bind before this * rule (they asked with no package, so `findOne` could return any of them). * A row stored under the type's other spelling is in scope only for a @@ -143,7 +142,7 @@ import { * §3.3, "an overlay cannot loosen a packaged lock"), one per installed * package that ships the name, as {@link resolveArtifactLockLayer} selects * them from the item's address; - * - `overlay`: the stored `sys_metadata` rows in the item's scope (ADR-0005), + * - `overlay`: the item's stored environment `sys_metadata` rows, * as {@link resolveOverlayLockLayer} selects them from the item's address. */ export const ITEM_LOCK_LAYERS = Object.freeze(['artifact', 'overlay'] as const); @@ -251,7 +250,7 @@ export async function resolveItemLockLazily(read: { * axis, so a field added here without one turns that pin's completeness check * red, naming the field. */ -export const ITEM_ADDRESS_FIELDS = Object.freeze(['type', 'name', 'organizationId', 'packageId'] as const); +export const ITEM_ADDRESS_FIELDS = Object.freeze(['type', 'name', 'packageId'] as const); /** One of {@link ITEM_ADDRESS_FIELDS}. */ export type ItemAddressField = (typeof ITEM_ADDRESS_FIELDS)[number]; @@ -261,9 +260,6 @@ export type ItemAddressField = (typeof ITEM_ADDRESS_FIELDS)[number]; * * - `type`: the canonical metadata type (#4432); * - `name`: the item's name; - * - `organizationId`: the organization the request is scoped to, already - * gated by `organizationIdForMetaRead` (ADR-0005), or `undefined` for the - * env-wide scope alone; * - `packageId`: the package the request names (ADR-0048 `?package=`), or * `undefined`. It decides which row's prose the lock carries when several * rows in scope declare the strictest lock, never which rows are in scope. @@ -271,7 +267,6 @@ export type ItemAddressField = (typeof ITEM_ADDRESS_FIELDS)[number]; export type ItemAddress = { readonly type: string; readonly name: string; - readonly organizationId: string | undefined; readonly packageId: string | undefined; } & { readonly [F in ItemAddressField]: unknown }; @@ -283,14 +278,13 @@ export interface StoredOverlayRow { } /** - * The stored rows of the addressed item in one scope (`organizationId`, or - * `null` for env-wide) under one spelling of its type: `'canonical'`, or - * `'other'` (the type's singular/plural twin, pre-#4432 residue). Every row of - * the item there, whichever package it is bound to. A failed read throws: each - * caller owns its #5532 / #5706 discrimination. + * The stored environment rows of the addressed item (ADR-0131 D6) under one + * spelling of its type: `'canonical'`, or `'other'` (the type's + * singular/plural twin, pre-#4432 residue). Every row of the item there, + * whichever package it is bound to. A failed read throws: each caller owns its + * #5532 / #5706 discrimination. */ export type StoredOverlayRowReader = ( - organizationId: string | null, spelling: 'canonical' | 'other', ) => readonly StoredOverlayRow[] | Promise; @@ -343,22 +337,16 @@ const LOCK_FAMILY_KEYS = Object.freeze(['_lock', '_lockReason', '_lockDocsUrl', * * Every caller that enforces or reports an item's lock passes its address * here: the `_lock` gate (`otherSpelling: false`, #4432), both item reads and - * the list (`otherSpelling: true`). Rows are read scope by scope and stop at - * the first scope that holds one, so the gate reads no more than it did when - * it read one row. + * the list (`otherSpelling: true`). */ export async function resolveOverlayLockLayer( address: ItemAddress, rowsIn: StoredOverlayRowReader, options: { readonly otherSpelling: boolean }, ): Promise { - const scopes: Array = address.organizationId ? [address.organizationId, null] : [null]; - for (const scope of scopes) { - let rows = await rowsIn(scope, 'canonical'); - if (rows.length === 0 && options.otherSpelling) rows = await rowsIn(scope, 'other'); - if (rows.length > 0) return strictestRowLockDocument(rows, address.packageId); - } - return undefined; + let rows = await rowsIn('canonical'); + if (rows.length === 0 && options.otherSpelling) rows = await rowsIn('other'); + return rows.length > 0 ? strictestRowLockDocument(rows, address.packageId) : undefined; } function strictestRowLockDocument(rows: readonly StoredOverlayRow[], packageId: string | undefined): unknown { @@ -426,8 +414,7 @@ export function resolveArtifactLockLayer(address: ItemAddress, artifactsOf: Ship * and no `_lock*` key it does not. So a served body states exactly the lock the * envelope reports, whichever document it was served from: a row the address * prefers for content while other rows in scope bind ([#21761]), the address's - * own package's artifact while another package's lock binds ([#21803]), or a - * row served by content precedence from a scope the lock is not read from (a + * own package's artifact while another package's lock binds ([#21803]) (a * family the answer does not carry is removed). Returns `document` itself when * nothing changes, and a copy otherwise. */ diff --git a/packages/metadata-protocol/src/meta-overlay-cache.ts b/packages/metadata-protocol/src/meta-overlay-cache.ts index 213f1acf2ed..2128e61ab35 100644 --- a/packages/metadata-protocol/src/meta-overlay-cache.ts +++ b/packages/metadata-protocol/src/meta-overlay-cache.ts @@ -12,7 +12,8 @@ * is the shape #11633 §1 calls out: *"most of leg D's win is negative caching"*. * * This module caches the OUTCOME OF THAT READ — the raw `sys_metadata` rows — - * per `(engine, type, packageId, organizationId)`, and retires the entry the + * per `(engine, type, packageId)` — the environment's rows; ADR-0131 D6 reads + * no organization's — and retires the entry the * moment the engine's write epoch moves. * * ## ⭐ Where this cache sits, and why that IS the resolution of the @@ -131,7 +132,6 @@ export interface MetaOverlayCacheKey { /** Canonical metadata type — already folded by `canonicalizeMetaRequestType`. */ type: string; packageId?: string; - organizationId?: string; } /** One cached row set, with everything needed to decide it is still the answer. */ @@ -272,11 +272,11 @@ export function bumpWriteEpoch(engine: unknown, reason: string): number | undefi /** * The cache key. `JSON.stringify` over the tuple rather than a delimiter join: - * a package id or organization id containing the delimiter would otherwise let - * two different reads collide on one key. + * a package id containing the delimiter would otherwise let two different + * reads collide on one key. */ function cacheKeyOf(key: MetaOverlayCacheKey): string { - return JSON.stringify([key.type, key.packageId ?? null, key.organizationId ?? null]); + return JSON.stringify([key.type, key.packageId ?? null]); } /** @@ -359,8 +359,8 @@ export function writeMetaOverlayCache( // ⭐ Every entry read at an older epoch is already dead by the read-side // rule, so keeping it costs memory and buys nothing. Without this a // long-lived process accumulates one never-evicted entry per distinct - // `(type, packageId, organizationId)` ever requested — bounded in principle - // by the tenant count, which is not a bound worth shipping. + // `(type, packageId)` ever requested — bounded in principle by the + // package count, which is not a bound worth shipping. bucket.entries.clear(); bucket.epoch = epoch; } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ac55aabbb8d..bef922a98f2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -85,11 +85,6 @@ import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator, type MetadataItem, - // [#13216] The search sweep's page read carries the SAME org scope the - // REST `/meta` read doors derive — registry-gated, never a bare tenant id - // (#9454's read-side rule). One predicate on both doors means the swept - // page set and the served page set cannot drift. - organizationIdForMetaRead, // [#16319] The field-`type` admission vocabulary. `loadMetaFromDb` asks the // registration door's OWN question — never a second opinion about which // declarations the registry will take, which is the defect class the @@ -1979,8 +1974,9 @@ function storedRowDiscriminator(type: string, record: unknown): string | undefin /** * One place a stored `sys_metadata` row of an item can sit, as - * {@link servedOverlayRowCandidates} orders them: a scope (the organization's, - * or env-wide with `organizationId: null`), a spelling of the type + * {@link servedOverlayRowCandidates} orders them: a scope (the environment's, + * `organizationId: null`, or — for the anonymous form doors' legacy layer + * alone — a legacy organization's), a spelling of the type * (`'canonical'`, or `'other'`, the type's singular/plural twin, pre-#4432 * residue), and the package the row is bound to: the address's package, `null` * for the package-less row, or `undefined` for any row (an address that names @@ -2004,12 +2000,19 @@ interface StoredRowPlace { } /** - * [#21804, ADR-0005, ADR-0048] THE served-row resolution: the order in which - * the stored rows of one item are tried for the CONTENT a read serves. The - * first candidate that holds a row is the served row. - * - * - Scope first (ADR-0005): the organization's rows, then the env-wide rows. - * Precedence, never a merge. + * [#21804, ADR-0131 D6, ADR-0048] THE served-row resolution: the order in + * which the stored rows of one item are tried for the CONTENT a read serves. + * The first candidate that holds a row is the served row. + * + * - Scope (ADR-0131 D6): the environment's rows only. A legacy + * organization-scoped row is reported at boot + * ({@link ObjectStackProtocolImplementation.reportUnhydratableOrgScopedRows}) + * and never served. The ONE exception is `legacyOrganizationId`, which only + * the anonymous form doors' read of the Default Organization's legacy + * layer passes (through the list read): that legacy organization's rows, + * then the environment's — kept fail-closed until the v18 migration + * ceremony carries those rows into the environment layer and deletes the + * read with them. * - Then the spelling: the canonical type, then the other spelling (the * at-rest tolerance the reads have always had, #4432). * - Then the package (ADR-0048 prefer-local): with a `packageId`, that @@ -2030,11 +2033,11 @@ interface StoredRowPlace { * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). */ function servedOverlayRowCandidates(address: { - readonly organizationId: string | undefined; + readonly legacyOrganizationId?: string; readonly packageId: string | undefined; }): ServedOverlayRowCandidate[] { - const scopes: Array> = address.organizationId - ? [{ scope: 'org', organizationId: address.organizationId }, { scope: 'env', organizationId: null }] + const scopes: Array> = address.legacyOrganizationId + ? [{ scope: 'org', organizationId: address.legacyOrganizationId }, { scope: 'env', organizationId: null }] : [{ scope: 'env', organizationId: null }]; const packages: Array = address.packageId ? [address.packageId, null] : [undefined]; return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => @@ -2048,7 +2051,7 @@ function servedOverlayRowCandidates(address: { * `undefined`, which matches a row of any package. */ function addressPackages(packageId: string | undefined): ReadonlyArray { - return [...new Set(servedOverlayRowCandidates({ organizationId: undefined, packageId }) + return [...new Set(servedOverlayRowCandidates({ packageId }) .map((candidate) => candidate.packageId))]; } @@ -2113,8 +2116,9 @@ function servedViewExpansion( * draft winning). * • [#21804] When `records` are stored rows the caller read (`rowsRead`, * each record's `stored` place), the winner among them is the one - * {@link servedOverlayRowCandidates} picks for `P`: the organization's row - * before the env-wide one, then `P`'s own before the package-less one. It + * {@link servedOverlayRowCandidates} picks for `P`: `P`'s own row before + * the package-less one (the form doors' legacy organization row before + * the environment's, where they read that layer). It * is the resolution `getMetaItem` takes, so it does not depend on the * order the store returned the rows in. Without `rowsRead` (the * MetadataService merge, whose `records` are already-resolved items), @@ -2162,7 +2166,7 @@ function mergePackageAwareOverlay( baseItems: unknown[], records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, - rowsRead?: { readonly organizationId: string | undefined }, + rowsRead?: { readonly legacyOrganizationId?: string }, unseated?: WeakSet, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. @@ -2186,7 +2190,7 @@ function mergePackageAwareOverlay( // `real` among a slot's contributions: the first candidate of // {@link servedOverlayRowCandidates} that one of the rows sits at. const servedStoredRow = (list: readonly Contribution[], real: string | undefined): any => { - for (const candidate of servedOverlayRowCandidates({ organizationId: rowsRead?.organizationId, packageId: real })) { + for (const candidate of servedOverlayRowCandidates({ legacyOrganizationId: rowsRead?.legacyOrganizationId, packageId: real })) { const hit = list.find((c) => c.stored !== undefined && c.stored.organizationId === candidate.organizationId && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling @@ -5284,7 +5288,6 @@ export interface MetadataAuthoringGateContext { name: string; /** Lifecycle the body is being saved into. */ state: 'draft' | 'active'; - organizationId?: string; /** The body being persisted. */ body: unknown; /** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */ @@ -6005,7 +6008,7 @@ export class ObjectStackProtocolImplementation implements * declaration body (the baseline an overlay customizes) for the gate. */ private async runAuthoringGate(evt: { - type: string; name: string; state: 'draft' | 'active'; organizationId?: string; body: unknown; + type: string; name: string; state: 'draft' | 'active'; body: unknown; }): Promise { const singular = PLURAL_TO_SINGULAR[evt.type] ?? evt.type; const gate = this.authoringGates.get(singular); @@ -6021,7 +6024,6 @@ export class ObjectStackProtocolImplementation implements type: singular, name: evt.name, state: evt.state, - ...(evt.organizationId ? { organizationId: evt.organizationId } : {}), body: evt.body, isArtifactBacked: artifactBacked, ...(declaredBody !== undefined ? { declaredBody } : {}), @@ -6070,12 +6072,6 @@ export class ObjectStackProtocolImplementation implements */ private async assertRuntimeAuthoringRules(evt: { type: string; name: string; state: 'draft' | 'active'; body: unknown; source?: string; - /** - * The organization partition of this write (`saveMetaItem`'s - * `organizationId`). Absent = a platform-level / environment write, - * which is one limb of the #6285 refusal combination. - */ - organizationId?: string | null; /** * [#9612] The package this write belongs to (`saveMetaItem`'s * `packageId`, or the promoted draft row's). It decides the CLOSURE the @@ -6217,7 +6213,6 @@ export class ObjectStackProtocolImplementation implements listRegisteredCollection(singularType, pluralType), singularType, pluralType, - evt.organizationId ?? null, ); // Resolved together rather than in sequence: each is one indexed // `sys_metadata` read and they do not depend on one another, so the @@ -6286,7 +6281,6 @@ export class ObjectStackProtocolImplementation implements // collections above. Absent on every non-batch door. ...(evt.pending !== undefined ? { pending: evt.pending } : {}), ...(packageScope !== undefined ? { packageScope } : {}), - ...(evt.organizationId !== undefined ? { organizationId: evt.organizationId } : {}), orgWallEnforced: this.orgWallEnforced(), // [#21476] The posture IN FORCE, for the public-form intake // advisory — a separate input from the requested one above, on @@ -6411,29 +6405,26 @@ export class ObjectStackProtocolImplementation implements registered: unknown[], singularType: string, pluralType: string, - organizationId: string | null, ): Promise { if (typeof this.engine?.find !== 'function') return registered; let rows: Record[]; try { - const scopes: (string | null)[] = organizationId ? [null, organizationId] : [null]; - const read = async (type: string, oid: string | null): Promise[]> => { + // [ADR-0131 D6] The environment's rows only: a legacy + // organization-scoped row is reported at boot, never read + // ({@link reportUnhydratableOrgScopedRows}). + const read = async (type: string): Promise[]> => { // [#21911] The explicit system opt-in — see findServedOverlayRow. const rs = await this.engine.find('sys_metadata', { - where: { type, state: 'active', organization_id: oid }, + where: { type, state: 'active', organization_id: null }, context: { isSystem: true }, }); return (rs ?? []) as Record[]; }; - rows = []; - for (const oid of scopes) { - // The same singular/plural retry the registry half and - // `getMetaItems` both perform: rows written through a plural - // URL spelling are stored under it. - let rs = await read(singularType, oid); - if (rs.length === 0) rs = await read(pluralType, oid); - rows.push(...rs); - } + // The same singular/plural retry the registry half and + // `getMetaItems` both perform: rows written through a plural + // URL spelling are stored under it. + rows = await read(singularType); + if (rows.length === 0) rows = await read(pluralType); } catch (error) { if (!isMissingTableError(error, 'sys_metadata')) { console.warn( @@ -6929,7 +6920,7 @@ export class ObjectStackProtocolImplementation implements // registry (#6602). So an event a peer stamped with an organization // converges on the environment row — the state the registry holds. const orgId = null; - const repo = this.getOverlayRepo(orgId); + const repo = this.getOverlayRepo(); const ref = { type, name: evt.name, @@ -7051,11 +7042,27 @@ export class ObjectStackProtocolImplementation implements } /** - * Lazily obtain a SysMetadataRepository for the given organization. - * Env-wide overlays (organizationId == null) share a singleton under - * the `__env__` key. + * [ADR-0131 D6] THE repository every metadata read and write goes + * through: the environment's (`organization_id IS NULL`). One lazily + * built singleton under the `__env__` key. + */ + private getOverlayRepo(): SysMetadataRepository { + return this.repositoryFor(null); + } + + /** + * [ADR-0131 D6/D12] The repository of ONE legacy organization's rows, + * for the one caller left that touches them: the uninstall's removal of + * a package's legacy organization-scoped rows + * ({@link removeLegacyOrganizationRowOnUninstall}). ⛔ No read goes + * through it: a legacy organization-scoped row is reported at boot + * ({@link reportUnhydratableOrgScopedRows}), never served. */ - private getOverlayRepo(organizationId: string | null): SysMetadataRepository { + private legacyOrganizationRepo(organizationId: string): SysMetadataRepository { + return this.repositoryFor(organizationId); + } + + private repositoryFor(organizationId: string | null): SysMetadataRepository { const key = organizationId ?? '__env__'; let repo = this.overlayRepos.get(key); if (!repo) { @@ -7833,7 +7840,6 @@ export class ObjectStackProtocolImplementation implements async getMetaDiagnostics(request: { type?: string; severity?: 'error' | 'warning'; - organizationId?: string; packageId?: string; } = {}): Promise<{ entries: Array<{ type: string; name: string; diagnostics: MetadataDiagnostics }>; @@ -7866,7 +7872,6 @@ export class ObjectStackProtocolImplementation implements try { listed = await this.getMetaItems({ type: t, - organizationId: request.organizationId, packageId: request.packageId, } as any); } catch (error) { @@ -7995,7 +8000,6 @@ export class ObjectStackProtocolImplementation implements artifact: this.artifactLockLayerAt({ type: t, name: itemName, - organizationId: request.organizationId, packageId: request.packageId ?? (item?._packageId as string | undefined), }, shipping), overlay: item, @@ -8532,17 +8536,16 @@ export class ObjectStackProtocolImplementation implements */ private async restoredCredentialPathsFor(args: { type: string; - organizationId: string | null; name: string; packageId: string | null; item: unknown; }): Promise { if (!hasMetadataRedactor(args.type)) return []; - const repo = this.getOverlayRepo(args.organizationId); + const repo = this.getOverlayRepo(); const ref = { type: args.type, name: args.name, - org: args.organizationId ?? 'env', + org: 'env', } as Parameters[0]; const body = await this.storedBodyForCarryForward({ type: args.type, @@ -8683,7 +8686,13 @@ export class ObjectStackProtocolImplementation implements ); } - async getMetaItems(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }) { + /** + * `legacyFormOrganizationId` is not a request key of the spec's + * `GetMetaItemsRequest`: only the anonymous form doors pass it, for their + * read of the Default Organization's legacy `view` layer (triage ruling + * Q3 A; see {@link readFlattenedMetaItems}). + */ + async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This one // is load-bearing twice over: the SchemaRegistry indexes code-authored // items under the SINGULAR type, and the overlay-hydration branch below @@ -8720,7 +8729,7 @@ export class ObjectStackProtocolImplementation implements * export, a log line. Every such exit serves {@link getMetaItems}, whose * redaction is the whole reason this method has to exist separately. */ - async getMetaItemsForExecution(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }) { + async getMetaItemsForExecution(request: { type: string; packageId?: string; previewDrafts?: boolean }) { // The same #4432 fold {@link getMetaItems} applies, for the same reason. return this.readFlattenedMetaItems(canonicalizeMetaRequestType(request), 'execution'); } @@ -8735,112 +8744,24 @@ export class ObjectStackProtocolImplementation implements * `request` arrives already folded by the calling face (#4432). */ private async readFlattenedMetaItems( - request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }, + request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }, audience: 'served' | 'execution', ) { const { packageId } = request; - // ── [commit 96326040f] The registry read gate, resolved ONCE, HERE ────────────────── - // - // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors applied from #9454 until the doors stopped carrying an - // organization at all (ADR-0131 D6). Until this line - // `getMetaItems` applied NO gate of its own: whatever organization - // arrived was used for whatever type arrived, so the scope of a - // metadata sweep was decided per type, BY THE CALLER — and a request - // carrying ONE `organizationId` can only be correct when it sweeps ONE - // type. Three live callers sweep more than one: `getMetaDiagnostics`' - // untyped arm walks the whole registry; `findReferencesToMeta` spends - // the request's organization on `matcher.fromType` — the SOURCES — - // while `request.type` is the TARGET, so the target's own flag says - // nothing about the types actually read; and the runtime's package - // export sweep (`domains/packages.ts`) walks every plural key with one - // raw active organization. - // - // ⭐ THE HARM IS RESURRECTION, NOT CONCEALMENT, and which one it is - // decides that the registry-gated predicate is the right instrument. - // `SysMetadataRepository.history()` filters `organization_id` by strict - // equality, so naming the tenant THERE hides an `allowOrgOverride: - // false` type's rows. Here the two `queryByOrg` reads are UNIONed, so - // naming it can only ADD — and what it adds are the pre-#6190 - // phantoms: org-scoped rows of types with no per-org read channel, - // which `loadMetaFromDb` walks past and - // {@link reportUnhydratableOrgScopedRows} exists to say out loud. Read - // back, they surface in the admin "Used by" panel and the Studio - // governance directory — inside a clearance shown before a - // destructive action, where a resurrected row is worse than an - // omission because it reads as evidence. - // - // ── Why this is a repair, not a tenancy read-scope change ─────────── - // - // Let `f(t, o) = organizationIdForMetaRead(t, o)`. `f` answers `o` when - // the registry declares `t` per-org overridable and `undefined` - // otherwise, so `f(t, f(t, o)) === f(t, o)` for EVERY `t` and `o`. A - // caller that already gates therefore sees no change, provided it gated - // on the same type this line gates on. Every such caller does: - // - // • `packages/rest`'s `GET /meta/:type` list door — one of TWO doors - // that both gate and reach this method — computes - // `organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), - // ctx?.tenantId)` and then passes `type: req.params.type`, the RAW - // segment. The first statement of this method folds that segment - // through {@link canonicalizeMetaRequestType}, which IS - // `canonicalMetaUrlType` — so `request.type` here is the identical - // STRING the door gated on, and the second application is the - // algebraic no-op above. - // • `GET /meta/diagnostics?type=` — the SECOND such door, and the one - // a caller-side grep cannot see. It gates the same way - // (`organizationIdForMetaRead(canonicalMetaUrlType(diagnosticsType), - // ctx?.tenantId)`) and then passes its own RAW segment on — but it - // reaches this method TRANSITIVELY, through - // {@link getMetaDiagnostics}, whose `?type=` arm sets `targetTypes = - // [request.type]` and loops `getMetaItems({ type: t, - // organizationId, … })` over it. So the door's segment still arrives - // here as `request.type` and is still folded by the same first - // statement: the identical STRING, the same algebraic no-op, one hop - // further out. ⚠️ That hop is UNDECLARED — `getMetaDiagnostics` is - // not a member of `MetadataProtocol`, neither required nor optional, - // so the door reaches it through a `(p as any)` cast behind a 501 - // feature-detect. Real at runtime, invisible to the type system, and - // therefore something a caller census must be TOLD rather than left - // to derive. - // • the search sweep's page read below gates on `'page'` and passes - // `'page'`; `page` is non-overridable, so both readings are - // `undefined` whatever the session holds. - // • the four remaining `organizationIdForMetaRead` call sites in - // `rest-server.ts` (`/layers`, the by-name read, `/history`, - // `/diff`) reach `getMetaItemLayered` / `getMetaItem` / - // `historyMetaItem` / `diffMetaItem` — never this method, at any - // depth — so this line cannot move them at all. Two doors named - // above plus these four IS that file's whole set of SIX; the - // enumeration that named one door and "four remaining" described - // five, and the door it dropped was the one that reaches here. - // - // ⭐ Read this list from the CALLEE side, which is how it is now built. - // A grep for doors that invoke `getMetaItems` answers only its own - // question: it cannot see a door that arrives through something else, - // and that is exactly how the diagnostics door went unlisted. The - // closed form is the other direction — `this.getMetaItems(` has THREE - // callers in this file: {@link getMetaDiagnostics}, - // {@link searchAll} and {@link findReferencesToMeta}. The third gates - // nothing, deliberately: its door spends the organization on the - // reference SOURCES while `req.params.type` is the TARGET, so it hands - // the tenant over RAW and is not a caller this paragraph is about. + // ── [ADR-0131 D6] The environment → code read ────────────────────── // - // ⛔ Gate AFTER the fold, never before it. `declaresOrgOverride` - // tolerates the MANIFEST plurals and not the URL-only ones - // (`translations` / `email_templates` have no manifest key), and - // commit 26f3588fb measured what that costs when the raw segment reaches the - // predicate: one item in two partitions, addressed by spelling. Folding - // happens at the boundary and only there; this line reads what the - // boundary produced. - // - // ⚠️ ONE resolution for BOTH arms, deliberately — the active-overlay - // read below and the `previewDrafts` read further down both spend it. - // A gate threaded into only one arm would leave the draft preview - // resurrecting exactly the phantoms the active list had just stopped - // serving, which is the half-fix shape #9454's own hoist comment - // refuses one package over. - const orgId = organizationIdForMetaRead(request.type, request.organizationId); + // The list serves the environment's stored rows over the code + // packages' definitions, and no organization's: a legacy + // organization-scoped row is reported at boot + // ({@link reportUnhydratableOrgScopedRows}) and never served. The one + // exception is `legacyFormOrganizationId`, which only the anonymous + // form doors pass (triage ruling Q3 A): their read of a `view` keeps + // the Default Organization's legacy layer — that organization's body + // preferred, and a withdrawal in either layer closing the form — + // fail-closed until the v18 migration ceremony (ADR-0131 C7) carries + // those rows into the environment layer and deletes this read. Any + // other type reads the environment alone, whoever passes the key. + const orgId = canonicalMetaType(request.type) === 'view' ? request.legacyFormOrganizationId : undefined; let items: unknown[] = []; // Unscoped kernels (control plane): read everything from SchemaRegistry. @@ -8884,10 +8805,9 @@ export class ObjectStackProtocolImplementation implements // was never surfaced whenever system-bridged items populated the // registry). Deduplicate against whatever the registry returned. // - // ADR-0005 (revised 2026-05): isolation is now per-organization, since - // each env has its own physical DB. We surface both org-scoped overlays - // (when an active org is provided) and env-wide (organization_id IS NULL) - // overlays; org-scoped rows win on name collision. + // [ADR-0131 D6] The environment's rows (organization_id IS NULL); the + // anonymous form doors' legacy layer adds one legacy organization's + // rows, which win their slot (see the `orgId` binding above). // [#21761] Every active row of the type in this caller's scopes, // whichever package each is bound to: what each listed item's lock is // selected from ({@link overlayLockLayerAt}'s selection, below). A list @@ -8970,7 +8890,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }, { organizationId: orgId }, unseated); + }, { legacyOrganizationId: orgId }, unseated); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -9149,10 +9069,15 @@ export class ObjectStackProtocolImplementation implements // process reads from — one listing call was enough, and it also // undid the write-side gate for anything already saved. The // per-row verdict now lives in the shared hydrator, which each - // row's own `organizationId` answers to; the merged LIST above - // is unchanged, so org readers still get their overlays. + // row's own `organizationId` answers to. + // + // [ADR-0131 D6] An overlay of a sealed managed item is not + // served ({@link isUnservedSealedOverlay}), so it is not + // registered either: the registry keeps the package's + // definition, the item the reads serve. if (this.environmentId === undefined) { - for (const { data, packageId: recPkg, organizationId: recOrg } of overlays) { + for (const { name, data, packageId: recPkg, organizationId: recOrg } of overlays) { + if (this.isUnservedSealedOverlay(request.type, name)) continue; this.hydrateOverlayIntoRegistry(request.type, data, { packageId: recPkg, organizationId: recOrg, @@ -9194,15 +9119,15 @@ export class ObjectStackProtocolImplementation implements } return rs ?? []; }; - const draftRecords = [...(await queryDrafts(null, packageId)), ...(orgId ? await queryDrafts(orgId, packageId) : [])]; + // [ADR-0131 D6] The environment's drafts only, whoever asks. + const draftRecords = await queryDrafts(null, packageId); // [#21817] A list scoped to a package previews the package-less // drafts as stand-ins, as its active arm does: the // package-agnostic draft read, filtered back to the // package-less rows. The by-name preview arm naming the // package serves that draft when the package has none. const standInDraftRecords = packageId - ? [...(await queryDrafts(null, undefined)), ...(orgId ? await queryDrafts(orgId, undefined) : [])] - .filter((record) => (record?.package_id ?? null) === null) + ? (await queryDrafts(null, undefined)).filter((record) => (record?.package_id ?? null) === null) : []; if (draftRecords.length > 0 || standInDraftRecords.length > 0) { // ADR-0048 (#1828) — package-aware draft overlay (parity with @@ -9237,7 +9162,7 @@ export class ObjectStackProtocolImplementation implements items = mergePackageAwareOverlay(request.type, items, drafts, (data) => { if (data && typeof data === 'object') (data as any)._draft = true; return data; - }, { organizationId: orgId }, unseated); + }, {}, unseated); } } catch (error) { // [#5532] Same rule as the active-overlay read above. Serving @@ -9456,15 +9381,14 @@ export class ObjectStackProtocolImplementation implements const address: ItemAddress = { type: request.type, name: itemName, - organizationId: orgId, packageId: itemPackageId, }; shipping ??= this.shippingPackagesOf(request.type); itemLock = resolveItemLock({ artifact: this.artifactLockLayerAt(address, shipping), - overlay: await resolveOverlayLockLayer(address, (organizationId, spelling) => + overlay: await resolveOverlayLockLayer(address, (spelling) => (lockRowsByName.get(itemName) ?? []).filter((row) => - (row.organization_id ?? null) === organizationId + (row.organization_id ?? null) === null && row.type === (spelling === 'canonical' ? request.type : otherType)), { otherSpelling: true }), }); } @@ -9483,22 +9407,23 @@ export class ObjectStackProtocolImplementation implements } /** - * [#21442] The active `sys_metadata` rows a read of `request.type` consults - * for one caller: the environment-wide rows, plus that organization's rows - * when `orgId` names one (an organization's row wins its slot), restricted - * to `request.packageId` when one is given. Moved here unchanged from + * [#21442, ADR-0131 D6] The active `sys_metadata` rows a read of + * `request.type` consults: the environment's rows, restricted to + * `request.packageId` when one is given — plus, for the anonymous form + * doors' read alone, the rows of the legacy organization + * `legacyOrganizationId` names (that organization's row wins its slot; + * read uncached, and retired with the v18 migration ceremony). Moved here unchanged from * {@link readFlattenedMetaItems}, the list read, so the by-name read * ({@link resolveRowlessExpandedView}) selects the view containers it * expands by this same rule — ⛔ never a second selection rule, which would * be a second expansion rule. * - * `orgId` arrives already gated ({@link organizationIdForMetaRead}). A read - * failure is thrown as the engine threw it; each caller applies the #5532 - * rule ({@link rethrowUnlessMetadataStoreUnprovisioned}). + * A read failure is thrown as the engine threw it; each caller applies the + * #5532 rule ({@link rethrowUnlessMetadataStoreUnprovisioned}). */ private async readActiveOverlayRows( request: { type: string; packageId?: string }, - orgId: string | undefined, + legacyOrganizationId?: string, ): Promise { const { packageId } = request; const queryByOrg = async (oid: string | null): Promise => { @@ -9524,7 +9449,7 @@ export class ObjectStackProtocolImplementation implements // // ⭐ The cache sits HERE, and its position IS the resolution of the // SchemaRegistry-hydration trap #11633 §4 names. What is cached is - // the ROW SET — the value the two `queryByOrg` calls produce — + // the ROW SET — the value the environment `queryByOrg` call produces — // never the merged answer below it. Everything downstream of this // point still runs on every call, hit or miss: the overlay parse, // the package-aware merge, `hydrateOverlayIntoRegistry`, the @@ -9550,7 +9475,6 @@ export class ObjectStackProtocolImplementation implements const overlayCacheKey: MetaOverlayCacheKey = { type: request.type, packageId, - organizationId: orgId, }; const overlayCacheEpoch = readWriteEpoch(this.engine); const overlayCacheTtlMs = metaOverlayCacheTtlMs(); @@ -9563,30 +9487,7 @@ export class ObjectStackProtocolImplementation implements if (cachedRecords !== undefined) { records = cachedRecords as any[]; } else { - const envWideRecords = await queryByOrg(null); - const orgRecords = orgId ? await queryByOrg(orgId) : []; - // org-specific rows override env-wide rows on name collision. - // ADR-0048 (#1828) — key by (package, name), not bare name, so a - // package A row and a package B row of the same name do not - // collapse; org-over-env precedence still holds within each slot. - // - // [#7774] …and for a bundled type the slot is `(package, name, - // locale)`. Within ONE org this changes nothing — the store's own - // unique index is `(type, name, organization_id, package_id)`, so - // an org cannot hold two rows that differ only by body locale. - // Across the two tiers it can: an env-wide row and this org's row - // may customize DIFFERENT members of one bundle, and keying them - // together made the org's zh-CN row silently displace the - // env-wide en-US one. Precedence is unchanged where it was ever - // meaningful — an org row still overrides the env-wide row of the - // same member — and an undiscriminated type keeps a - // byte-identical key. - const mergedMap = new Map(); - const rowKey = (r: any): string => - metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); - for (const r of envWideRecords) mergedMap.set(rowKey(r), r); - for (const r of orgRecords) mergedMap.set(rowKey(r), r); - records = Array.from(mergedMap.values()); + records = await queryByOrg(null); // ⭐ An EMPTY row set is cached too, and that is the main point // rather than an edge case: the empty result is what triggers the // alt-type retry above, so "no overlay rows for this type" is the @@ -9597,7 +9498,21 @@ export class ObjectStackProtocolImplementation implements this.engine, overlayCacheKey, overlayCacheEpoch, records, overlayCacheTtlMs, overlayCacheNow, ); } - return records; + if (!legacyOrganizationId) return records; + // The anonymous form doors' legacy layer (triage Q3 A, ADR-0131 C7): + // that organization's rows override the environment's on name + // collision. ADR-0048 (#1828) — key by (package, name), not bare name, + // so a package A row and a package B row of the same name do not + // collapse. [#7774] …and for a bundled type the slot is `(package, + // name, locale)`: an environment row and the organization's row may + // customize DIFFERENT members of one bundle. + const orgRecords = await queryByOrg(legacyOrganizationId); + const mergedMap = new Map(); + const rowKey = (r: any): string => + metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); + for (const r of records) mergedMap.set(rowKey(r), r); + for (const r of orgRecords) mergedMap.set(rowKey(r), r); + return Array.from(mergedMap.values()); } /** @@ -9628,9 +9543,9 @@ export class ObjectStackProtocolImplementation implements if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) { (data as any)._packageId = recPkg; } - // [#6602] The row's own scope travels with its body. The - // merged row set is env-wide rows PLUS this org's rows, and - // the two are only distinguishable here, at the row. + // [#6602] The row's own scope travels with its body: the form + // doors' legacy layer merges a legacy organization's rows into + // the environment's, and the two are only distinguishable here. const recOrg = (record as { organization_id?: string | null }).organization_id ?? null; return { name: String(record.name), data, packageId: recPkg, organizationId: recOrg, @@ -9715,7 +9630,7 @@ export class ObjectStackProtocolImplementation implements * instead of the one the list serves. * * The answer is the list read's own, by construction: the same rows - * ({@link readActiveOverlayRows}, same `packageId`, same gated `orgId`), + * ({@link readActiveOverlayRows}, same `packageId`, the environment's), * the same parse ({@link storedOverlayEntries}) and the same expansion * ({@link expandStoredViewContainers} over * {@link expandRuntimeViewContainer}), selected for the address by the @@ -9738,7 +9653,6 @@ export class ObjectStackProtocolImplementation implements */ private async resolveRowlessExpandedView( request: { type: string; name: string; packageId?: string }, - orgId: string | undefined, ): Promise { if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) !== 'view') return undefined; let records: any[] = []; @@ -9749,10 +9663,9 @@ export class ObjectStackProtocolImplementation implements try { records = await this.readActiveOverlayRows( { type: request.type, ...(request.packageId ? { packageId: request.packageId } : {}) }, - orgId, ); if (request.packageId) { - standInRows = (await this.readActiveOverlayRows({ type: request.type }, orgId)) + standInRows = (await this.readActiveOverlayRows({ type: request.type })) .filter((row) => (row?.package_id ?? null) === null); } } catch (error) { @@ -9772,8 +9685,7 @@ export class ObjectStackProtocolImplementation implements /** * [#21716, ADR-0005] The stored `sys_metadata` row a by-name read SERVES - * for `(type, name)` in `orgId`'s scope, and the scope it was read from — - * the ONE content resolution {@link getMetaItem} (both its draft-preview + * for `(type, name)` — the ONE content resolution {@link getMetaItem} (both its draft-preview * arm and its row read) and {@link getMetaItemLayered} call. * * [#21761] It decides the served CONTENT only, never the lock. The lock's @@ -9784,12 +9696,10 @@ export class ObjectStackProtocolImplementation implements * reported that package's row while the gate bound whichever row * `findOne` returned first. * - * Precedence is ADR-0005's, and it is precedence, never a merge (the - * ruling is recorded in {@link getMetaItem}): the org-scoped row wins, - * and the env-wide row (`organization_id` null) is the fallback. Within - * one scope, ADR-0048 prefer-local: with a `packageId`, that package's - * row first and then the package-less row, never another package's; with - * none, any row. + * The environment's rows only (`organization_id` null, ADR-0131 D6): a + * legacy organization-scoped row is reported at boot and never served. + * ADR-0048 prefer-local: with a `packageId`, that package's row first and + * then the package-less row, never another package's; with none, any row. * * [#21804] That order is {@link servedOverlayRowCandidates}, and this * method is its store reader: one `findOne` per candidate, first hit @@ -9797,32 +9707,26 @@ export class ObjectStackProtocolImplementation implements * reader, over the rows the list already read, so a package's list slot * serves the row this method serves for that package. * - * `orgId` arrives already gated ({@link organizationIdForMetaRead}): an - * organization only selects a row on a type the registry declares per-org - * overridable, so a pre-#6190 phantom org row is never the served one. - * * The reads keep the at-rest tolerance they have always had: a row stored - * under the type's other spelling (pre-#4432 residue) is the last resort - * in each scope. + * under the type's other spelling (pre-#4432 residue) is the last resort. * - * Returns `undefined` when neither scope holds a row. A failed read - * propagates: each caller owns its #5532 discrimination. + * Returns `undefined` when no row is found. A failed read propagates: each + * caller owns its #5532 discrimination. */ private async findServedOverlayRow(args: { type: string; name: string; - orgId: string | undefined; state: 'active' | 'draft'; packageId?: string; - }): Promise<{ row: any; scope: 'org' | 'env' } | undefined> { + }): Promise<{ row: any } | undefined> { // [#21804] The order is {@link servedOverlayRowCandidates}, the one - // the list's merge takes over the rows it read: per scope, the - // canonical spelling then the other, and within each, the package's + // the list's merge takes over the rows it read: the canonical + // spelling then the other, and within each, the package's // own row then the package-less row (never another package's, or a // collision would serve package B's customization for a package A // read); with no package context (legacy/runtime reader), any row. const other = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; - for (const candidate of servedOverlayRowCandidates({ organizationId: args.orgId, packageId: args.packageId })) { + for (const candidate of servedOverlayRowCandidates({ packageId: args.packageId })) { const type = candidate.spelling === 'canonical' ? args.type : other; if (type === undefined) continue; const where: Record = { @@ -9835,7 +9739,7 @@ export class ObjectStackProtocolImplementation implements // itself (`organization_id` above). It no longer reaches the data // engine as a principal-less context. const row = await this.engine.findOne('sys_metadata', { where, context: { isSystem: true } }); - if (row) return { row, scope: candidate.scope }; + if (row) return { row }; } return undefined; } @@ -9866,136 +9770,26 @@ export class ObjectStackProtocolImplementation implements */ private overlayLockLayerAt(address: ItemAddress, options: { readonly otherSpelling: boolean }): Promise { const other = PLURAL_TO_SINGULAR[address.type] ?? SINGULAR_TO_PLURAL[address.type]; - return resolveOverlayLockLayer(address, async (organizationId, spelling) => { + return resolveOverlayLockLayer(address, async (spelling) => { const type = spelling === 'canonical' ? address.type : other; if (type === undefined) return []; // [#21911] The explicit system opt-in — see findServedOverlayRow. const rows: StoredOverlayRow[] | null | undefined = await this.engine.find('sys_metadata', { - where: { type, name: address.name, state: 'active', organization_id: organizationId }, + where: { type, name: address.name, state: 'active', organization_id: null }, context: { isSystem: true }, }); return rows ?? []; }, options); } - async getMetaItem(request: { type: string, name: string, packageId?: string, organizationId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { + async getMetaItem(request: { type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); let item: unknown; - // ── [commit d5cbb44f3] The registry read gate, resolved ONCE, HERE ──────────────────── - // - // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors applied from #9454, and which commit 96326040f moved INSIDE the - // plural verb, `getMetaItems` above. Until this line the SINGULAR verb - // applied no gate of its own: whatever organization arrived was spent on - // whatever type arrived. - // - // ⭐ THE SHARPER HALF, and why the plural verb's fix did not cover it. - // `getMetaItems` UNIONs its two `queryByOrg` reads, so an ungated - // organization can only ADD rows — the resurrection commit 96326040f closed. - // The two scope reads below ({@link findServedOverlayRow}) combine as - // PRECEDENCE: an ungated organization can SUBSTITUTE. On a type the - // registry declares `allowOrgOverride: false`, a pre-#6190 phantom - // org-scoped row — the kind `loadMetaFromDb` walks past and - // {@link reportUnhydratableOrgScopedRows} exists to warn about — was - // served INSTEAD OF the live env-wide document, to a caller that asked - // for the live one. Not an extra row in a list: the served document. - // - // ── ⛔ The `??` is the RULING, not the defect ──────────────────────── - // - // Do not "repair" this by turning the precedence read into a layering or - // union of the org overlay OVER the env-wide row. - // - // ⚠️ Read the citations at their real scope. ADR-0005's decision block - // names THIS method — `RUNTIME READ getMetaItem(type, name)` → `1. - // sys_metadata … ← overlay (wins)`, `2. SchemaRegistry / - // MetadataService ← artifact default` — but the pair it RANKS is - // overlay-vs-artifact-default, not org-row-vs-env-wide-row. It settles - // that an overlay WINS rather than merges; it does not by itself settle - // this method's inner `??`. - // - // What settles the inner one: - // • ADR-0005 design principle 3 — "Customizations are full-JSON - // deltas, not field-level patches" — stores the ENTIRE item - // document per overlay row, so a layering would have nothing to - // layer. The field-level patch model that would have given - // "layering" a meaning was retired and deleted whole under ADR-0049 - // (commit 9e0ba21a1, maintainer ruling 2026-08-29), recorded as a - // correction inside principle 3 itself, with ADR-0126 §6 ruling out - // the phase it was held for. - // • {@link organizationIdForMetaRead}'s own docblock quotes THIS - // expression — `(orgId ? findOverlay(orgId) : undefined) ?? - // findOverlay(null)` — as the intended shape while defining #9454. - // Precedence is the behaviour that card was written against, not the - // thing it reported. - // • ADR-0029 D9 reaches the same shape one type over. ⚠️ Quoted, not - // paraphrased: `resolveObject` "selects its base layer as `overlay ?? - // owner` instead of `owner`, then folds `extend` contributions". Its - // status line reads "Design only — nothing is implemented yet", so - // it is corroboration, not an authority this method rests on. - // ⇒ The defect is which ROW `orgId` selects, never how the two rows - // combine. - // - // ── The idempotence proof this change was made conditional on ─────── - // - // Let `f(t, o) = organizationIdForMetaRead(t, o)`. - // - // • `f(t, undefined) === undefined` for every `t`, so every caller that - // names no organization — `import-mapping.ts`, `import-prepare.ts`, - // plugin-email's template read, service-analytics' draft probe, - // plugin-auth's `metaReader` — reads exactly what it reads today. - // • `f(t, f(t, o)) === f(t, o)`, so a caller that already gated on the - // same type sees no change. The REST by-name door computes - // `organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), - // ctx?.tenantId)` and then passes `type: req.params.type`, the RAW - // segment; the first statement of this method folds that segment - // through {@link canonicalizeMetaRequestType}, which IS - // `canonicalMetaUrlType` — so `request.type` here is the identical - // STRING the door gated on, and this is the algebraic no-op. - // • That door's CACHED arm reaches here through `getMetaItemCached`, - // which folds first and forwards the same hoisted `readOrganizationId` - // — the same no-op, one hop later. - // • [ADR-0131 D6] Every write lands environment-wide (an - // organization-scoped one is refused, {@link - // organizationScopedWriteRefusal}), and the write-side pre-reads - // (`saveMetaItem`'s destructive-change probe, `publishMetaItem`'s - // seed-loader adapter, `publishPackageDrafts`' build probes) carry - // no organization, so they read the partition their write LANDS in. - // - // ⇒ What is left to move is the runtime callers that hand this method a - // RAW active organization. THREE, all in one file — the population is - // stated with the method that establishes it, because the first - // enumeration of it named only the first file and was wrong: grep every - // `getMetaItem(` / `getMetaItemCached(` invocation in the repo, then - // trace each `organizationId` argument to its source. - // • `runtime/src/domains/meta.ts:703` and `:745` — hard-coded `type: - // 'object'`, which is `allowOrgOverride: false`: callers that cannot - // be right about scope, by construction. - // • `runtime/src/domains/meta.ts:768` — `singularType` off the URL, so - // it moves only for the non-overridable half of what it serves. - // A FOURTH used to sit here: `applyPublishedSeeds` in - // `runtime/src/domains/packages.ts`, `type: 'seed'`, equally - // non-overridable. It hand-rolled an org-then-env ladder that this gate - // had turned into a byte-identical repeat — both rungs asking the - // engine the same predicates and serving the same answer. Commit 8744de9e9 - // measured that (ablation: neutering the second rung reddened nothing - // on a pinned publish-then-read path) and collapsed it to a single read - // naming no organization at all, so it now belongs to the bucket below. - // Every other invocation either names no organization at all or is a - // REST door that already computed `organizationIdForMetaRead` — the - // idempotence legs above are what make those two cases no-ops. - // - // ⚠️ ONE resolution for BOTH arms, deliberately — the ADR-0033 - // `previewDrafts` read below and the active-overlay read under it both - // spend this binding. A gate threaded into only one would leave the - // draft preview serving exactly the phantom the active read had just - // stopped serving, the half-fix shape #9454's hoist comment refuses. - // - // ⛔ Gate AFTER the fold, never before it. `declaresOrgOverride` - // tolerates the MANIFEST plurals and not the URL-only ones - // (`translations` / `email_templates` have no manifest key); commit 26f3588fb - // measured what that costs when a raw segment reaches the predicate. - const orgId = organizationIdForMetaRead(request.type, request.organizationId); + // [ADR-0131 D6] Environment → code: the environment's stored row, + // else the code packages' definition. No organization's row is read — + // a legacy organization-scoped row is reported at boot + // ({@link reportUnhydratableOrgScopedRows}) and never served. // Studio's editor opens a draft buffer with `state: 'draft'`; // runtime loaders omit it and get the live published row. const readState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; @@ -10021,9 +9815,8 @@ export class ObjectStackProtocolImplementation implements // Scoped to the ACTIVE read. A draft is answered as a draft (the strict // `state: 'draft'` read and the `previewDrafts` arm), never under the // artifact's envelope, and the list's own preview arm is equally - // unfiltered. Organization-scoped flow rows never reach this read: - // `orgId` is `undefined` for `flow`, which declares no org override. - // What becomes of the stored rows themselves (keep, refuse, migrate) is + // unfiltered. Organization-scoped rows never reach this read + // (ADR-0131 D6). What becomes of the stored rows themselves (keep, refuse, migrate) is // not decided here. // // ── [#21922, ADR-0062 D4, ADR-0126 §3] A code-defined DATASOURCE name ── @@ -10056,7 +9849,6 @@ export class ObjectStackProtocolImplementation implements const draftRec = (await this.findServedOverlayRow({ type: request.type, name: request.name, - orgId, state: 'draft', ...(request.packageId ? { packageId: request.packageId } : {}), }))?.row; @@ -10086,11 +9878,10 @@ export class ObjectStackProtocolImplementation implements } } - // 1. Customization overlay lookup (sys_metadata). - // Per ADR-0005 (revised), org-scoped row wins; env-wide - // (organization_id IS NULL) row is the fallback before falling + // 1. Customization overlay lookup (sys_metadata): the environment's + // row (organization_id IS NULL, ADR-0131 D6) before falling // through to the in-memory registry / MetadataService. - // ADR-0048 prefer-local within each scope (a package id prefers that + // ADR-0048 prefer-local (a package id prefers that // package's row, then the package-less one, mirroring // `SchemaRegistry.getItem(type, name, pkg)`). [#21716] The ONE // served-row resolution — see {@link findServedOverlayRow}. It @@ -10103,7 +9894,6 @@ export class ObjectStackProtocolImplementation implements const record = (await this.findServedOverlayRow({ type: request.type, name: request.name, - orgId, state: readState, ...(request.packageId ? { packageId: request.packageId } : {}), }))?.row; @@ -10152,7 +9942,7 @@ export class ObjectStackProtocolImplementation implements item: decorateMetadataItem(request.type, this.governServedObject(request.type, item)), // [#22114] The draft row's version token — the one a draft save // (`?mode=draft`) at this address accepts as `If-Match`. - version: await this.readVersionToken(request, orgId, 'draft'), + version: await this.readVersionToken(request, 'draft'), }; } @@ -10169,7 +9959,6 @@ export class ObjectStackProtocolImplementation implements overlayLockLayer = await this.overlayLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }, { otherSpelling: true }); } catch (error) { @@ -10215,7 +10004,7 @@ export class ObjectStackProtocolImplementation implements // answered only for an environment-wide container. ⛔ No // kernel-specific branch: every kernel answers here. if (item === undefined) { - const expanded = await this.resolveRowlessExpandedView(request, orgId); + const expanded = await this.resolveRowlessExpandedView(request); if (expanded !== undefined) item = expanded.item; } @@ -10340,7 +10129,6 @@ export class ObjectStackProtocolImplementation implements artifact: this.artifactLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }), overlay: overlayLockLayer, @@ -10420,7 +10208,7 @@ export class ObjectStackProtocolImplementation implements // editor reads `state: 'draft'` or the plain read. ...(request.previewDrafts ? {} - : { version: storedRowServed ? await this.readVersionToken(request, orgId, 'active') : null }), + : { version: storedRowServed ? await this.readVersionToken(request, 'active') : null }), }; } @@ -10475,13 +10263,12 @@ export class ObjectStackProtocolImplementation implements type: string; name: string; packageId?: string; - organizationId?: string; }): Promise<{ type: string; name: string; code: unknown | null; overlay: unknown | null; - overlayScope: 'org' | 'env' | null; + overlayScope: 'env' | null; effective: unknown | null; /** * Load-time validation result for the effective payload — same @@ -10517,99 +10304,8 @@ export class ObjectStackProtocolImplementation implements // `overlay` can be read from two. request = canonicalizeMetaRequestType(request); - // ── [commit e1d4f9e3f] The registry read gate, resolved AFTER the fold ───────── - // - // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors applied from #9454, and the same gate - // `getMetaItems` (commit 96326040f) and `getMetaItem` (commit d5cbb44f3) now carry. Until - // this line `getMetaItemLayered` — the third `/meta` read verb — - // applied NO gate of its own: whatever organization arrived was spent - // on whatever type arrived. - // - // ⛔ THE BINDING MOVED, and that reorder IS the fix. It used to sit - // ABOVE the fold, so dropping the sibling verbs' one-liner in place - // would have gated on the RAW type. Commit 26f3588fb measured what that costs: - // `declaresOrgOverride` tolerates the MANIFEST plurals but not the - // URL-only ones (`translations` / `email_templates` have no manifest - // key), so a raw segment splits one item across two partitions. Both - // REST callers below hand this method an unfolded `/meta/:type` - // segment, so gating before the fold would have been wrong on exactly - // the types the gate exists to admit. - // - // ⭐ THE HARM IS A FALSE POSITIVE CUSTOMIZATION CLAIM — which is why - // this verb was graded on its own rather than inheriting either twin's. - // On the plural verb a phantom can only ADD a row; on the singular verb - // it REPLACES the served document. Here the affected value is the - // `overlay` layer of a three-layer diagnostic whose entire purpose is - // to answer "what did this tenant customize", so a pre-#6190 phantom - // org-scoped row of an `allowOrgOverride: false` type is rendered by - // the Studio "Code default vs Overlay vs Effective" diff tab as - // evidence of a customization that does not exist. It compounds at the - // two doors that return the `overlay` layer AS the response: there the - // phantom is not merely displayed, it is served. - // - // ── The idempotence proof, discharged over THIS door's callers ────── - // - // Let `f(t, o) = organizationIdForMetaRead(t, o)`. `f` answers `o` when - // the registry declares `t` per-org overridable and `undefined` - // otherwise, so `f(t, undefined) === undefined` and - // `f(t, f(t, o)) === f(t, o)` for every `t` and `o`. Commit 96326040f's and - // commit d5cbb44f3's proofs do NOT carry: the ruling behind commit 96326040f discharges this per - // door over that door's OWN caller population, and this verb's is a - // different set. Enumerated by grepping every `getMetaItemLayered(` - // invocation in the repo and tracing each `organizationId` argument to - // its source — no caller is denied a partition it can legitimately - // read: - // - // • `rest-server.ts`'s `/meta/:type/:name/layers` door (and the - // deprecated `?layers=` flag, which delegates to the same helper) - // computes `organizationIdForMetaRead(canonicalMetaUrlType( - // req.params.type), ctx?.tenantId)` and then passes `type: - // req.params.type`, the RAW segment. The fold above IS - // `canonicalMetaUrlType`, so `request.type` here is the identical - // STRING the door gated on — the algebraic no-op. - // • Four of the five `plugin-security` invocations name no - // organization at all (`packaged-permission-set-lock-gate.ts`'s - // authoring gate, and three of `permission-set-projection.ts`'s - // reads), so `f(t, undefined) === undefined` leaves them reading - // exactly what they read today. `permission-set-overlay-discard.ts` - // only feature-detects this method and delegates to - // `projectPermissionMutation`; it is not a call site. - // - // ⇒ THREE callers move, and each one is the defect. ⚠️ Only the first - // was named by the enumeration this repair was dispatched with, which - // asserted that the runtime dispatcher was the ONLY reachable site, - // that BOTH REST doors already gated, and that every `plugin-security` - // site passed no organization. The last two were measured false here — - // in the direction that adds callers to this gate, never one that - // removes them: - // - // • `runtime/src/domains/meta.ts` — `resolveActiveOrganizationId` - // straight into the request, on a `type` taken off the URL, so it is - // not confined to the overridable five. Its `if (layered?.overlay != - // null)` branch returns the overlay layer AS the response. - // • `rest-server.ts`'s `/meta/:type/:name/published` door — the REST - // twin of that dispatcher branch, and the door recorded as already - // gating. It passes `publishedCtx.tenantId` RAW and then - // `res.json(layered.overlay)`. Its own comment argues the raw tenant - // "is right for a READ" because the overlay lookup is - // org-first-then-env, so "nothing that resolves today stops - // resolving" — precisely the argument {@link - // organizationIdForMetaRead} was written to refute: failing open in - // that direction is what RESURRECTS the phantoms, which is why - // #6190 stopped minting them and why boot hydration walks past the - // survivors. - // • `permission-set-projection.ts`'s post-mutation read forwards - // `evt.organizationId` — a raw active organization on - // `type: 'permission'`, which is `allowOrgOverride: false`. Moving - // it makes the projection read the partition boot hydration - // actually serves: the same correction, one caller further out. - // - // ⚠️ ONE resolution for BOTH overlay arms, deliberately — the - // org-scoped read and the env-wide fallback below both spend this - // binding, and gating only the first would leave `overlayScope: 'org'` - // reachable for a type with no per-org read channel. - const orgId = organizationIdForMetaRead(request.type, request.organizationId); + // [ADR-0131 D6] Environment → code: the overlay layer is the + // environment's stored row; no organization's row is read. // ── code layer: MetadataService.get + registry, BYPASSING overlay ── let code: unknown | null = null; @@ -10704,29 +10400,26 @@ export class ObjectStackProtocolImplementation implements this.throwMetadataServiceUnavailable(codeDegraded.errors); } - // ── overlay layer: sys_metadata row (org-scoped wins, then env-wide) ── + // ── overlay layer: the environment's sys_metadata row (ADR-0131 D6) ── let overlay: unknown | null = null; - let overlayScope: 'org' | 'env' | null = null; + let overlayScope: 'env' | null = null; // [#21761] The `overlay` layer of the one item-lock resolution below, // selected from this read's address as in {@link getMetaItem}: the // strictest lock among the item's stored rows in scope, never only the // row this layer reports. let overlayLockLayer: unknown; try { - // ADR-0048 prefer-local within each scope. [#21716] The ONE - // served-row resolution {@link getMetaItem} calls too — see - // {@link findServedOverlayRow}. + // ADR-0048 prefer-local. [#21716] The ONE served-row resolution + // {@link getMetaItem} calls too — see {@link findServedOverlayRow}. const served = await this.findServedOverlayRow({ type: request.type, name: request.name, - orgId, state: 'active', ...(request.packageId ? { packageId: request.packageId } : {}), }); overlayLockLayer = await this.overlayLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }, { otherSpelling: true }); if (served) { @@ -10735,7 +10428,7 @@ export class ObjectStackProtocolImplementation implements String(rec.type ?? request.type), storedRowDocument(rec), ); - overlayScope = served.scope; + overlayScope = 'env'; } } catch (error) { // [#5707] The same rule as the four overlay reads in @@ -10744,8 +10437,8 @@ export class ObjectStackProtocolImplementation implements // // Swallowing here does not answer 404 — it answers something this // method states positively in THREE fields at once: `overlay: null` - // ("nothing was ever customised"), `overlayScope: null` ("no scope - // holds a row"), and `effective = code` ("what runs today is the + // ("nothing was ever customised"), `overlayScope: null` ("no row + // is stored"), and `effective = code` ("what runs today is the // packaged artifact, verbatim"). The whole point of the layered // read is to show an author what they changed; during an outage it // told them they had changed nothing, which is the #5532 error — @@ -10769,16 +10462,15 @@ export class ObjectStackProtocolImplementation implements // the layers say where it came from in the fields that already say it: // `overlay` is the container's own stored row, the one stored layer // behind this name (its `name` is the container's, its `_packageId` the - // package its row is bound to), and `overlayScope` the scope that row - // was read from. `code` keeps its own read: the item a package ships + // package its row is bound to), and `overlayScope` `'env'`. `code` keeps its own read: the item a package ships // under this name (a tenant overlay of that package's container), else // `null`. let expandedFrom: RowlessExpandedView | undefined; if (overlay === null) { - expandedFrom = await this.resolveRowlessExpandedView(request, orgId); + expandedFrom = await this.resolveRowlessExpandedView(request); if (expandedFrom !== undefined) { overlay = expandedFrom.container.data; - overlayScope = expandedFrom.container.organizationId === null ? 'env' : 'org'; + overlayScope = 'env'; } } @@ -10855,7 +10547,6 @@ export class ObjectStackProtocolImplementation implements artifact: this.artifactLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }), overlay: overlayLockLayer, @@ -10970,16 +10661,12 @@ export class ObjectStackProtocolImplementation implements * unprovisioned table. The `/audit` route's existing * `handleRouteError` turns it into an honest 5xx. * - * `organizationId` SCOPES the read and is enforced in the query below: - * rows for that organization plus env-wide (`organization_id IS NULL`) - * rows, and nothing else. Omitted (or `null`) reads the env-wide rows - * only. It is never a hint — a caller that does not supply it does not - * get another tenant's rows. + * [ADR-0131 D6] The environment's rows (`organization_id IS NULL`) only, + * enforced in the query below. */ async auditMetaItem(request: { type: string; name: string; - organizationId?: string | null; limit?: number; }): Promise<{ events: Array<{ @@ -11045,36 +10732,10 @@ export class ObjectStackProtocolImplementation implements Math.max(1, request.limit ?? 100), 500, ); - // [#8747] `request.organizationId` is READ here. It was declared and - // never used, while the comment below described the filter it would - // have built — a live cross-tenant disclosure, measured rather than - // argued: three saves of one view name under `org_alpha`, `org_beta` - // and env-wide, then one `auditMetaItem({ type, name })`, returned all - // three orgs' rows (`actor`, `note`, `lock_state` with them). - // - // ⚠️ Nothing below this method compensates, and all three candidates - // were eliminated by measurement, not by reading: - // - the driver's tenant wall never engages — `buildDriverOptions` - // sets `DriverOptions.tenantId` only from `execCtx.tenantId` - // (`objectql/engine.ts`), and this read's context carries no - // `tenantId` (only the system opt-in below); - // - plugin-security's Layer 0 never engages — the middleware - // short-circuits on that `isSystem` opt-in (it used to take its - // principal-less `return next()`, refused since ADR-0096 D5) - // thousands of lines before the `objectFields.has('organization_id')` - // gate that would have carried it; - // - no posture would save it anyway: `computeTenantLayer0Filter` - // yields `null` under `single` and the deny sentinel under - // `isolated` with no tenantId. - // So the scope has to be BUILT here. It is unconditional — it does not - // depend on a posture, a principal, or a layer below choosing to act. - // - // `?? null` is the same normalization the sibling `/published` door - // applies (`request.organizationId ?? null`, mirroring what an org-less - // `publishPackageDrafts` WRITES): a caller that resolves no - // organization reads exactly the env-wide rows an org-less write - // produces. Fail-closed, and symmetric with the write path. - const organizationId = request.organizationId ?? null; + // [ADR-0131 D6] The environment's audit trail only — the scope is + // built here, unconditionally (#8747: nothing below this method + // scopes the read). A legacy organization-scoped audit row is not + // read; the v18 migration ceremony carries its organization's rows. // [#9638] The FIRST of the two benign causes the catch below used to // name, asked as a PRECONDITION rather than as an error shape. // @@ -11105,29 +10766,12 @@ export class ObjectStackProtocolImplementation implements return { events: [] }; } try { - // Org-scoped lookup: include rows for the specific org AND - // env-wide (organization_id IS NULL) rows so the editor - // sees both tenant overlays and env-level package writes. - // - // The env-wide limb is LOAD-BEARING, not defensive garnish: the - // REST `PUT /meta/:type/:name` door passes no `organizationId` at - // all, so every row that door writes is stamped - // `organization_id: null` (`recordMetadataAudit` persists - // `entry.organizationId ?? null`). Drop the limb and this read - // returns nothing on a REST-authored deployment — "correctly - // scoped" and "hides everything" are different behaviours and the - // tests pin them apart. + // The environment's rows: every write stamps + // `organization_id: null` (ADR-0131 D6). const where: Record = { type: singular, name: request.name, - ...(organizationId === null - ? { organization_id: null } - : { - $or: [ - { organization_id: organizationId }, - { organization_id: null }, - ], - }), + organization_id: null, }; // `order`, NOT `direction`: the QueryAST sort shape is // `SortNodeSchema` = `{ field, order }`, and both drivers normalize @@ -14368,11 +14012,8 @@ export class ObjectStackProtocolImplementation implements // - PUBLISHED only: `getMetaItems` reads `state: 'active'` overlay // rows plus code-registered pages; drafts surface only under // `previewDrafts`, which this sweep never passes. - // - Org scope: threaded through `organizationIdForMetaRead('page', - // context.tenantId)` — the registry-gated predicate every REST meta - // read door uses (#9454), so the sweep and the read door move - // together if `page` ever declares org override (today it does - // not, and the predicate answers env-wide). + // - Scope: the environment's pages, as every `/meta` read door + // answers (ADR-0131 D6: no metadata read is organization-scoped). // - Disabled-package and conversion handling ride along for free — // whatever `getMetaItems` withholds, this sweep never saw. // @@ -14407,11 +14048,7 @@ export class ObjectStackProtocolImplementation implements // whole one's answer. const pageHits: Array<{ kind: 'page'; name: string; title: string; snippet?: string; pageType?: string }> = []; if (!objectsFilter) { - const pageOrgId = organizationIdForMetaRead('page', request.context?.tenantId); - const served = await this.getMetaItems({ - type: 'page', - ...(pageOrgId !== undefined ? { organizationId: pageOrgId } : {}), - }); + const served = await this.getMetaItems({ type: 'page' }); const pageItems: unknown[] = Array.isArray(served) ? served : served.items; const localeTexts = (v: unknown): string[] => { if (typeof v === 'string') return v ? [v] : []; @@ -14468,17 +14105,11 @@ export class ObjectStackProtocolImplementation implements // ========================================== /** - * [#9454] `organizationId` — the sole meta read verb that could not express - * an org, which is why the CACHED door (`view` and every org-overridable - * type that is not `dashboard`) served nothing back after a runtime `PUT`. - * Its siblings `getMetaItem` / `getMetaItems` / `getMetaItemLayered` have - * carried the member all along; this one hard-coded a two-key delegation - * and dropped whatever the caller knew. Threaded into `getMetaItem` below, - * so the ADR-0005 read order (`sys_metadata` org row → env-wide row → - * registry → MetadataService) is honoured at the same scope the caller - * named — the whole reason this method delegates rather than re-reading. + * The cached read delegates to {@link getMetaItem}, so it serves the same + * environment → code read order (ADR-0131 D6) — the whole reason this + * method delegates rather than re-reading. */ - async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string, organizationId?: string }): Promise { + async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The ETag // and the cache entry are keyed by type, so two spellings would cache // the same item twice and invalidate only one of them. @@ -14490,14 +14121,6 @@ export class ObjectStackProtocolImplementation implements const result = await this.getMetaItem({ type: request.type, name: request.name, - // [#9454] Spread, not an unconditional member: `getMetaItem` - // branches on `organizationId !== undefined`, so passing an - // explicit `undefined` is not the same statement as passing - // nothing. Mirrors the conditional-spread idiom the REST read - // doors use to reach here. - ...(request.organizationId !== undefined - ? { organizationId: request.organizationId } - : {}), }); const item = (result as any)?.item; @@ -14530,59 +14153,19 @@ export class ObjectStackProtocolImplementation implements // language until a hard refresh (issue #1319). Folding the resolved // locale into the hash gives each locale a distinct validator. // - // [#9454] The ETag MUST also state the ORGANIZATION scope, and the - // mechanism differs from `locale` above in a way worth stating - // rather than glossing. `locale` is INVISIBLE to the hash (the body - // is translated AFTER this runs), so folding it in was the only way - // it could vary the validator at all. `organizationId` is VISIBLE — - // the org-resolved document is the very thing hashed — so two orgs - // whose overlays differ already get different validators, and no - // leak is claimed here: `Cache-Control` is `private, no-cache` and - // there is no server-side cache ENTRY keyed by type+name. - // - // It is folded in anyway because that makes the scope a DECLARED - // property of the validator instead of an emergent property of the - // body. Two orgs whose documents are byte-identical today share a - // validator by coincidence, not by statement. Prepended, and ONLY - // when present, so an org-less caller's validator stays byte-for- - // byte the one it is issued today. - // - // [#16525] ⚠️ The paragraph above used to argue from "any FUTURE - // path that resolves an org row but falls back to the env-wide - // body". THAT PATH IS PRESENT, and reading it as future is how a - // later author concludes the risk has not arrived yet: - // `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) - // ?? findOverlay(null)`, so an organization with no row of its own - // is served the env-wide document under an org-named validator. - // - // ⭐ AND `request.organizationId` IS THE SUPPLIED MEMBER, not the - // effective scope: {@link organizationIdForMetaRead} reduces it to - // `undefined` for a type declaring `allowOrgOverride: false`, and - // that reduction happens BELOW this line, inside `getMetaItem`. So - // a caller that hands this verb a raw organization gets a validator - // naming a scope its body was never resolved under. - // - // ⛔ Neither is a correctness fault, and the reason is the ONE - // invariant this block depends on: `content` — the bytes actually - // being sent — is inside the hash below. A 304 is therefore - // answered only on an exact match over those bytes, so a caller is - // only ever pinned to the representation IT received; the cost is - // validator FRAGMENTATION (N orgs, one env-wide document, N - // validators), which is waste, not error. ⇒ Hashing anything - // cheaper than the document — a version marker, the scope alone — - // destroys that argument silently. `get-meta-item-cached-etag- - // scope.test.ts` §3 is the pin; measured, removing `content` here - // reddens exactly one assertion and leaves the rest green. + // [ADR-0131 D6] No organization is part of the validator: the + // read is the environment's for every caller, so the document + // hashed below is the whole scope. An org-less caller's validator + // is byte-for-byte the one it was issued before. // - // ⛔ Do NOT "repair" this by folding the effective value without - // reading #16525: it changes every published ETag that carries an - // organization, and buys nothing at the only production door — - // `@objectstack/rest` computes `organizationIdForMetaRead` BEFORE - // it calls (pinned by `rest-server-meta-cached-etag-door-scope. - // test.ts`), so supplied and effective already agree there. + // ⛔ The ONE invariant this block depends on: `content` — the bytes + // actually being sent — is inside the hash below. A 304 is answered + // only on an exact match over those bytes, so a caller is only ever + // pinned to the representation IT received. Hashing anything + // cheaper than the document — a version marker — destroys that + // silently. const content = JSON.stringify(item); const scope = [ - request.organizationId ? `org:${request.organizationId}` : undefined, request.locale || undefined, ].filter((part): part is string => part !== undefined); const hash = simpleHash( @@ -16537,7 +16120,7 @@ export class ObjectStackProtocolImplementation implements * path of its own: the row is skipped, and each read falls through to the * code layer it already reads next. * - * Exactly two answers, each its own type's, neither re-derived here: + * Exactly three answers, none re-derived here: * * - a FLOW name the loader's set holds ({@link isShippedFlowName}, * #20913 / #20946 / #21002) — Regime C, "never an overlay read path"; @@ -16551,6 +16134,14 @@ export class ObjectStackProtocolImplementation implements * a layer of it, only residue ({@link originGatedRemovalRefusal}): it * stays at rest, the boot restore names it in a warning, and the `/meta` * DELETE removes it as repair. + * - [ADR-0131 D6, triage ruling Q1 → C] a SEALED managed item + * ({@link isUnservedSealedOverlay}): an environment overlay row on an + * item a managed package ships, of a type whose registry entry opens no + * overlay channel (a managed flow, action, hook or object), is in no + * regime D6 recognises — the package definition wins. Such rows were + * written through the `OS_METADATA_WRITABLE` hatch before the seal; they + * stay at rest, untouched, and the boot report names them per type with + * the two remedies ({@link reportSealedOverlayRows}). * * ⛔ Never a row's, slot's or body's `origin` — the caller sets it. * @@ -16575,7 +16166,28 @@ export class ObjectStackProtocolImplementation implements */ declinesStoredRow(type: string, name: unknown): boolean { if (this.isShippedFlowName(type, name)) return true; - return typeof name === 'string' && name !== '' && this.isDeclaredCodeDatasource(type, name); + if (typeof name !== 'string' || name === '') return false; + return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name); + } + + /** + * [ADR-0131 D6, triage ruling Q1 → C] Is a stored environment row of + * `(type, name)` an overlay of SEALED managed content, which no read + * serves? {@link isSealedManagedItem}, the predicate the save and delete + * doors refuse by, so the write refusal and the read narrowing cannot + * disagree about which items are sealed. + * + * ⛔ Except `permission`. A permission set a code package ships is guarded + * by its own lock (ADR-0086 / ADR-0094, `packaged-permission-set-lock`), + * and the forks stored before that lock existed have a maintainer ruling + * of their own: a detection reading and an operator's Discard Overlay + * action, "no automatic reap/merge … not a silent migration". Not serving + * such a fork would change the grants the deployment enforces without an + * operator's act, so it keeps that ruling's disposition. + */ + private isUnservedSealedOverlay(type: string, name: string): boolean { + if (canonicalMetaType(type) === 'permission') return false; + return this.isSealedManagedItem(type, name); } /** @@ -17205,13 +16817,10 @@ export class ObjectStackProtocolImplementation implements * writes rejected" were admitted. The door was looser than the read on the * organization axis, as it had been on the topology axis (#21694). * - * So the limb now asks {@link findServedOverlayRow} — the resolution both - * reads call — with the organization gated by the same - * {@link organizationIdForMetaRead} the reads apply. When the env-wide row - * is the one served, its `_lock` binds the organization's writes; when the - * organization's own row is served, that row's `_lock` does, whatever the - * env-wide row declares (the read reports that same row). ⛔ No second - * predicate: a change to the read's precedence moves this limb with it. + * So the limb asked {@link findServedOverlayRow} — the resolution both + * reads call. [ADR-0131 D6] Both now read the environment's rows alone, + * and so does this limb. ⛔ No second predicate: a change to the read's + * precedence moves this limb with it. * * ## [#21761] The limb and the reads select the rows from one ADDRESS * @@ -17310,7 +16919,6 @@ export class ObjectStackProtocolImplementation implements private async getEffectiveLock( type: string, name: string, - organizationId: string | null | undefined, packageId?: string, ): Promise<{ lock: MetadataLock; @@ -17331,10 +16939,9 @@ export class ObjectStackProtocolImplementation implements artifact: () => this.artifactLockLayerAt({ type: canonicalType, name, - organizationId: organizationId ?? undefined, packageId, }), - overlay: () => this.readLockGateOverlayLayer(canonicalType, name, organizationId, packageId), + overlay: () => this.readLockGateOverlayLayer(canonicalType, name, packageId), }); return { lock: resolved.lock, lockReason: resolved.lockReason, lockSource: resolved.layer }; } @@ -17349,14 +16956,13 @@ export class ObjectStackProtocolImplementation implements private async readLockGateOverlayLayer( canonicalType: string, name: string, - organizationId: string | null | undefined, packageId: string | undefined, ): Promise { // 2. Overlay rows — addressed by the SAME canonical key the repository // stores them under (`SysMetadataRepository.whereFor`), which is // what makes this limb read the rows the artifact limb already - // folded to. [#21716] …behind the reads' own organization gate, and - // [#21761] through the reads' own selection, from the same address — + // folded to. [#21761] Through the reads' own selection, from the + // same address — // see {@link getEffectiveLock}'s header. Canonical spelling only // (#4432), the one declared difference — see // {@link overlayLockLayerAt}. @@ -17364,7 +16970,6 @@ export class ObjectStackProtocolImplementation implements return await this.overlayLockLayerAt({ type: canonicalType, name, - organizationId: organizationIdForMetaRead(canonicalType, organizationId ?? undefined), packageId, }, { otherSpelling: false }); } catch (error) { @@ -17552,7 +17157,7 @@ export class ObjectStackProtocolImplementation implements source?: string; requestId?: string; }): Promise<{ err: Error; audit: MetadataAuditEntry } | null> { - const state = await this.getEffectiveLock(args.type, args.name, null, args.packageId); + const state = await this.getEffectiveLock(args.type, args.name, args.packageId); const refusal = evaluateLockForWrite(state.lock); if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; @@ -17622,7 +17227,7 @@ export class ObjectStackProtocolImplementation implements source?: string; requestId?: string; }): Promise { - const state = await this.getEffectiveLock(args.type, args.name, null); + const state = await this.getEffectiveLock(args.type, args.name); const refusal = evaluateLockForDelete(state.lock); if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; @@ -17761,16 +17366,15 @@ export class ObjectStackProtocolImplementation implements /** * [#22114] The item read's `version`: the keyed form * ({@link receiptVersion}) of the stored head a save to this item compares - * against ({@link storedHeadAt}), at the read's own scope — its - * organization partition and `packageId` — and its lifecycle, or `null` + * against ({@link storedHeadAt}), at the read's own scope — the + * environment's partition and `packageId` — and its lifecycle, or `null` * when no stored row is there. The `/meta` save door builds its address * from the same facts (no organization on either since ADR-0131 D6, and * `?package=` names the binding on both), so a read followed by a save with the served token is accepted, and a * `null` says that save is a create: the state `If-None-Match: *` asserts. * * ⚠️ The address of the SAVE, not of the served content. A read falls back - * from the caller's organization to the environment-wide row (ADR-0005) - * and from a package's own row to the package-less one (ADR-0048); a save + * from a package's own row to the package-less one (ADR-0048); a save * does not, it writes its own partition. A token of a row the save would * not overwrite would be refused by that save every time, so such a read * serves `null` rather than the served row's token. [#22128] And the @@ -17781,15 +17385,13 @@ export class ObjectStackProtocolImplementation implements */ private async readVersionToken( request: { type: string; name: string; packageId?: string }, - orgId: string | undefined, state: 'active' | 'draft', ): Promise { - const organizationId = orgId ?? null; - const repo = this.getOverlayRepo(organizationId); + const repo = this.getOverlayRepo(); const ref = { type: PLURAL_TO_SINGULAR[request.type] ?? request.type, name: request.name, - org: organizationId ?? 'env', + org: 'env', } as Parameters[0]; const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null); return stored === null ? null : this.receiptVersion(stored); @@ -19483,8 +19085,8 @@ export class ObjectStackProtocolImplementation implements * never a copy of them: * - another stored container's expansion, in the caller's selection, * whatever that container's object: the rows - * {@link readActiveOverlayRows} selects through the readers' gate - * ({@link organizationIdForMetaRead}) with no package filter, parsed by + * {@link readActiveOverlayRows} selects (the environment's) with no + * package filter, parsed by * {@link storedOverlayEntries} and expanded by * {@link expandStoredViewContainers} with each row's own package * binding. The row stored under the save name is left out: it is the @@ -20459,10 +20061,6 @@ export class ObjectStackProtocolImplementation implements // Stored, and handed to the credential walk below, as written. body: flowGateVerdictBody ?? gatedItem, source: writeSource, - // [#6285] The write's organization partition. It was always here; - // it simply never travelled to the gate, which is the whole reason - // the "platform-level flow" limb could not be judged before. - organizationId: null, // [#9612] Which package this write belongs to — the unit the gate // now judges it against. Same story as the line above: the request // has carried it all along, it just never reached the gate. Null @@ -20479,7 +20077,6 @@ export class ObjectStackProtocolImplementation implements // is absent and not stored as missing. The body judged is unchanged. restoredCredentialPaths: () => this.restoredCredentialPathsFor({ type: singularType, - organizationId: null, name: request.name, packageId: request.packageId ?? null, item: gatedItem, @@ -20661,7 +20258,7 @@ export class ObjectStackProtocolImplementation implements } // [ADR-0131 D6] Environment-wide, always: an organization-scoped // request was refused at the top of this method. - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); const ref = { type: singularTypeForRepo, name: request.name, @@ -21439,7 +21036,6 @@ export class ObjectStackProtocolImplementation implements async historyMetaItem(request: { type: string; name: string; - organizationId?: string; sinceSeq?: number; limit?: number; }): Promise<{ events: import('@objectstack/metadata-core').MetadataEvent[] }> { @@ -21490,17 +21086,17 @@ export class ObjectStackProtocolImplementation implements // every event's `ref.name` names the container. const expandedFrom = await this.resolveRowlessExpandedView( { type: singularType, name: request.name }, - organizationIdForMetaRead(singularType, request.organizationId), ); if (expandedFrom !== undefined) { return this.historyMetaItem({ ...request, name: expandedFrom.container.name }); } - const orgId = request.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + // [ADR-0131 D6] The environment's change log; a legacy + // organization-scoped history row is not read. + const repo = this.getOverlayRepo(); const ref = { type: singularType, name: request.name, - org: orgId ?? 'env', + org: 'env', } as Parameters[0]; const events: import('@objectstack/metadata-core').MetadataEvent[] = []; @@ -21895,7 +21491,7 @@ export class ObjectStackProtocolImplementation implements await this.ensureOverlayIndex(); // [ADR-0131 D6] Environment-wide: every caller refused an // organization-scoped request before reaching here. - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); // #4463 D1 — the OTHER way a body reaches `active`. `saveMetaItem` // gates a direct active save and deliberately lets every draft through; @@ -21983,9 +21579,6 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: 'active', body: draftForGate.body, - // [#6285] Same partition the draft is being promoted in — the - // environment's, since ADR-0131 D6. - organizationId: null, // [#9612] The package binding the CALLER stated for this // promotion — the same value threaded into `repo.promoteDraft` // below, so the gate and the write resolve the draft under one @@ -22351,22 +21944,20 @@ export class ObjectStackProtocolImplementation implements async listDrafts(request?: { packageId?: string; type?: string; - organizationId?: string; }): Promise<{ drafts: Array<{ type: string; name: string; /** The draft body's own top-level `label`, as authored; `null` when it declares none. */ label: I18nLabel | null; - organizationId: string | null; packageId: string | null; updatedAt: string | null; updatedBy: string | null; }>; }> { await this.ensureOverlayIndex(); - const orgId = request?.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + // [ADR-0131 D6] The environment's pending drafts. + const repo = this.getOverlayRepo(); const drafts = await repo.listDrafts({ ...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}), ...(request?.packageId ? { packageId: request.packageId } : {}), @@ -22424,8 +22015,8 @@ export class ObjectStackProtocolImplementation implements * ## Why the batch's existing enumeration cannot supply the bodies * * `listDrafts` — the read that DEFINES this batch — is a declared header - * projection: it maps rows to `(type, name, label, organizationId, - * packageId, updatedAt, updatedBy)` and drops `metadata` on purpose — of the + * projection: it maps rows to `(type, name, label, packageId, updatedAt, + * updatedBy)` and drops `metadata` on purpose — of the * body only its own top-level `label` leaves ([#22200]) — because its other * caller is the console's "pending changes" list. Widening it would put * every draft BODY on that listing, and it would not even remove the guard @@ -22435,7 +22026,7 @@ export class ObjectStackProtocolImplementation implements * and the absence of the member it needs is stated rather than assumed. */ private async collectBatchPendingDeclarations( - drafts: ReadonlyArray<{ type: string; name: string; organizationId: string | null }>, + drafts: ReadonlyArray<{ type: string; name: string }>, ): Promise { // [#13216] Typed as a mapped type over `RuntimePendingDeclarations` // with `-?`, not as a hand-listed literal. The literal it replaces was @@ -22461,8 +22052,7 @@ export class ObjectStackProtocolImplementation implements const singular = canonicalMetaType(d.type); const key = (CLOSURE_CONTEXT_KEY_BY_TYPE as Record)[singular]; if (!key) continue; - const draftOrgId = d.organizationId ?? null; - const repo = this.getOverlayRepo(draftOrgId); + const repo = this.getOverlayRepo(); // ── The repository-shape guard, and why it degrades ALL-OR-NOTHING // // This method introduced the batch door's first dependency on @@ -22495,7 +22085,7 @@ export class ObjectStackProtocolImplementation implements return undefined; } const ref = { - type: singular, name: d.name, org: draftOrgId ?? 'env', + type: singular, name: d.name, org: 'env', } as unknown as Parameters[0]; const draft = await repo.get(ref, { state: 'draft' }); if (draft?.body === undefined || draft.body === null) continue; @@ -22642,7 +22232,7 @@ export class ObjectStackProtocolImplementation implements // `organization_id IS NULL` rows, so a legacy organization-scoped draft // is never promoted here (it waits for the promotion ceremony, // ADR-0131 C7). - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); const drafts = await repo.listDrafts({ packageId: request.packageId }); // Runtime enforcement of the package namespace-prefix rule (ADR-0028 @@ -22935,13 +22525,12 @@ export class ObjectStackProtocolImplementation implements const commitItems: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }> = []; for (const d of ordered) { try { - // Read the pre-publish active row in the draft's OWN scope - // (env-wide drafts have env-wide active rows). Using the - // request's active org here would miss an env-wide edit and - // mis-record it as a create in the revert plan (#3115). + // Read the pre-publish active row in the draft's scope, the + // environment's (ADR-0131 D6), so an env-wide edit is not + // mis-recorded as a create in the revert plan (#3115). // [#21911] The explicit system opt-in — see findServedOverlayRow. const activeRow = (await this.engine.findOne('sys_metadata', { - where: { organization_id: d.organizationId ?? null, type: d.type, name: d.name, state: 'active' }, + where: { organization_id: null, type: d.type, name: d.name, state: 'active' }, context: { isSystem: true }, })) as { version?: number } | null; commitItems.push({ @@ -23480,7 +23069,7 @@ export class ObjectStackProtocolImplementation implements `Package '${request.packageId}'`, request, ); await this.ensureOverlayIndex(); - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); const drafts = await repo.listDrafts({ packageId: request.packageId }); const discarded: Array<{ type: string; name: string }> = []; @@ -23597,10 +23186,10 @@ export class ObjectStackProtocolImplementation implements let drafts: Awaited>; let publishedRows: unknown[]; try { - drafts = await this.getOverlayRepo(null).listDrafts({ packageId: request.packageId }); + drafts = await this.getOverlayRepo().listDrafts({ packageId: request.packageId }); // [#21911] The explicit system opt-in — see findServedOverlayRow. publishedRows = (await this.engine.find('sys_metadata', { - where: packageScopedRowWhere(null, 'active', { packageId: request.packageId }), + where: packageScopedRowWhere('active', { packageId: request.packageId }), limit: 1, context: { isSystem: true }, })) as unknown[]; @@ -23911,7 +23500,7 @@ export class ObjectStackProtocolImplementation implements actor: string | undefined, ): Promise { const type = PLURAL_TO_SINGULAR[row.type] ?? row.type; - const repo = this.getOverlayRepo(row.organization_id); + const repo = this.legacyOrganizationRepo(row.organization_id); const ref = { type, name: row.name, org: row.organization_id } as Parameters[0]; const current = await repo.get(ref, { state }); if (!current) return; @@ -24578,7 +24167,6 @@ export class ObjectStackProtocolImplementation implements */ async listCommits(request: { packageId: string; - organizationId?: string; limit?: number; }): Promise> { try { - const where: Record = { package_id: request.packageId }; - // [#7779] Surface BOTH org-scoped and env-wide (`organization_id IS - // NULL`) commit rows to an org-scoped caller — the same defect and - // the same remedy as the sibling {@link deletePackage} read one - // function above (#7705), and as {@link - // SysMetadataRepository.listDrafts} (#3115) in this package. - // - // Env-wide commit rows are not hypothetical: {@link - // recordPackageCommit} stores `organization_id: request. - // organizationId ?? null`, and the ONLY door into a publish — the - // dispatcher's `POST /packages/:id/publish-drafts` — forwards an - // org only when `resolveActiveOrganizationId` yields one. That - // resolver answers `undefined` for a session with no active - // organization AND for every failure on the auth seam (it is - // `catch`-wrapped). So a publish made before an org was selected — - // or during a transient auth blip — lands its commit env-wide, - // permanently, and the strict equality then hid it from every - // org-scoped read of that package's timeline. - // - // This is NOT merely an observability miss. {@link - // rollbackToPackageCommit} derives the set of commits to undo from - // this list, so an invisible commit was silently never reverted: - // measured pre-fix, a rollback past an env-wide commit answered - // `{success: true, revertedCommits: []}` while that commit's - // changes stayed live. - // - // The no-org branch is deliberately NOT narrowed to - // `organization_id IS NULL`, exactly as #7705 left its own: a - // caller with no active org reads the package's whole timeline, - // and restricting it to env-wide rows would hide every org-scoped - // commit instead — the same bug pointed the other way. - if (request.organizationId) { - where.$or = [ - { organization_id: request.organizationId }, - { organization_id: null }, - ]; - } + // [ADR-0131 D6] The environment's timeline: `organization_id IS + // NULL`, whoever asks. A legacy organization-scoped commit row is + // reported at boot ({@link reportUnhydratableOrgScopedRows}) and + // never served — the same rule as the legacy `sys_metadata` rows + // it recorded, which no read serves either. So the timeline an + // operator reverts from ({@link rollbackToPackageCommit} filters + // this list) holds exactly the commits whose changes the reads + // serve, and no organization's history is shown to another + // organization's operator. + const where: Record = { package_id: request.packageId, organization_id: null }; // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a // platform store the door already authorized; the protocol scopes the // `where` by organization itself. Principal-less, the engine refuses it. @@ -24877,7 +24438,7 @@ export class ObjectStackProtocolImplementation implements // [ADR-0131 D6] Every item reverts environment-wide: the commit row // is environment-wide (a legacy organization's was refused above). const itemOrgId = null; - const repo = this.getOverlayRepo(itemOrgId); + const repo = this.getOverlayRepo(); const ref = { type: it.type, name: it.name, org: itemOrgId ?? 'env' } as unknown as Parameters[0]; try { const current = await repo.get(ref, { state: 'active' }); @@ -25384,7 +24945,7 @@ export class ObjectStackProtocolImplementation implements // [ADR-0131 D6] The environment's lineage: an organization-scoped // request was refused above. const orgId = null; - const repo = this.getOverlayRepo(orgId); + const repo = this.getOverlayRepo(); const artifactBacked = this.isArtifactBacked(singularType, request.name); const intent: 'override-artifact' | 'runtime-only' = artifactBacked ? 'override-artifact' : 'runtime-only'; @@ -25556,7 +25117,6 @@ export class ObjectStackProtocolImplementation implements name: string; fromVersion?: number; toVersion?: number; - organizationId?: string; }): Promise<{ type: string; name: string; @@ -25614,12 +25174,12 @@ export class ObjectStackProtocolImplementation implements // `type` follows above. const expandedFrom = await this.resolveRowlessExpandedView( { type: singularType, name: request.name }, - organizationIdForMetaRead(singularType, request.organizationId), ); if (expandedFrom !== undefined) { return this.diffMetaItem({ ...request, name: expandedFrom.container.name }); } - const orgId = request.organizationId ?? null; + // [ADR-0131 D6] The environment's versions only. + const orgId = null; // [#8798] Read the history rows DIRECTLY, once. `historyMetaItem` // cannot serve this function: its `MetadataEvent` shape doesn't carry // the per-(type,name) `version` a diff selects versions by, so its @@ -26021,7 +25581,7 @@ export class ObjectStackProtocolImplementation implements if (useRepoPath) { // [ADR-0131 D6] Environment-wide: refused above otherwise. const orgId = null; - const repo = this.getOverlayRepo(orgId); + const repo = this.getOverlayRepo(); const ref = { type: singularTypeForRepo, name: request.name, @@ -26385,11 +25945,16 @@ export class ObjectStackProtocolImplementation implements * * Per ADR-0005, environment-kernel mode ALSO hydrates from sys_metadata — * customization overlay rows must survive restart. The where-clause below - * is `{ state: 'active', organization_id: null }`: env-wide rows only, with - * per-org overlays left to `getMetaItem`'s on-demand read so one org's - * customization never lands in the process-wide SchemaRegistry. There is no + * is `{ state: 'active', organization_id: null }`: the environment's rows + * only (ADR-0131 D6 — a legacy organization-scoped row is served by no + * read, and {@link reportUnhydratableOrgScopedRows} names it). There is no * `environment_id` constraint — ADR-0005 (revised 2026-05) gave each - * environment its own database and `organization_id` is the isolation key. + * environment its own database. + * + * [ADR-0131 D6, triage ruling Q1 → C] An environment row that overlays + * SEALED managed content ({@link isUnservedSealedOverlay}) is not loaded + * either: the package's definition stays the registered one, and + * {@link reportSealedOverlayRows} names the row with its remedies. * * #3903 — two contract duties run per row, and their split is deliberate: * @@ -26452,10 +26017,8 @@ export class ObjectStackProtocolImplementation implements /** #5897 — see the TSDoc: set only on the non-benign outer-catch branch. */ let storeUnavailable = false; try { - // ADR-0005 (revised 2026-05): hydrate only env-wide rows - // (organization_id IS NULL). Per-org overlays are loaded on - // demand by getMetaItem to avoid cross-org leakage into the - // process-wide SchemaRegistry. + // [ADR-0131 D6] Hydrate only the environment's rows + // (organization_id IS NULL); no read serves any other. const where: Record = { state: 'active', organization_id: null, @@ -26473,16 +26036,22 @@ export class ObjectStackProtocolImplementation implements const sduiManifest = (records as Array<{ type?: unknown }>).some( (r) => (PLURAL_TO_SINGULAR[String(r.type)] ?? r.type) === 'page', ) ? this.resolveSduiManifest() : undefined; + /** [ADR-0131 D6] Overlays of sealed managed content, not loaded. */ + const sealedOverlays: Array<{ type: string; name: string }> = []; for (const record of records) { try { + // Normalize DB type to singular (DB may store legacy plural forms) + const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type; + if (this.isUnservedSealedOverlay(normalizedType, String(record.name))) { + sealedOverlays.push({ type: normalizedType, name: String(record.name) }); + continue; + } const data = this.convertStoredItem( String(record.type), typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata, ); - // Normalize DB type to singular (DB may store legacy plural forms) - const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type; const verdict = computeMetadataDiagnostics(normalizedType, data); if (verdict && !verdict.valid) { invalid++; @@ -26648,8 +26217,10 @@ export class ObjectStackProtocolImplementation implements } } } - // #6190 — say out loud which org-scoped rows this filter just - // walked past. See {@link reportUnhydratableOrgScopedRows}. + // [ADR-0131 D6] Say out loud which rows this load walked past: + // the sealed overlays above, and (#6190) every legacy + // organization-scoped row the filter skipped. + this.reportSealedOverlayRows(sealedOverlays); await this.reportUnhydratableOrgScopedRows(); } catch (e: unknown) { // #5841 — the ONE benign reason this whole read can fail is @@ -26735,80 +26306,36 @@ export class ObjectStackProtocolImplementation implements } /** - * [#6190] Cold boot walks past every `organization_id IS NOT NULL` row. - * For the types the registry declares per-org overridable that is the - * design (ADR-0005 revised 2026-05 — those overlays are loaded on demand - * by `getMetaItem`/`getMetaItems`, which is why the filter above exists). - * For every OTHER type it is a stored row the platform has no per-org - * channel for, and until this method the skip was **completely silent**. - * - * The measured specimen is `flow`. `flow` is `allowOrgOverride: false` - * (rolled back in #6283 / commit 474f131cf, matching ADR-0005:57) but - * `allowRuntimeCreate: true`, so a tenant authoring a BRAND-NEW flow in - * Studio still writes `sys_metadata.organization_id = ''` — the - * runtime `PUT /metadata/:type/:name` threads `resolveActiveOrganizationId` - * into `saveMetaItem`, and `SysMetadataRepository.put` stamps - * `organization_id: this.organizationId` whatever the type is. That flow - * binds its triggers for the rest of the process's life (the publish-time - * write-through puts it in the process-wide registry) and then, on the - * next restart, this filter drops it and the `kernel:ready` binder — - * `getMetaItems({ type: 'flow' })`, no `organizationId`, so - * `orgRecords = []` — never sees it. It stops firing, and nothing said so: - * the `kernel:bootstrapped` unbound audit cannot report a flow that was - * never registered. - * - * This method does not change what boot loads. It makes the absence - * LOUD — AGENTS.md's rule, and the half of #6190 that is implementable - * without a contract ruling. Whether such a row should exist at all - * (refuse the write / force it env-wide / teach the binder to read per-org) - * is the maintainer decision recorded on the issue; the operator-visible - * consequence is the same either way and it is what an operator needs - * TODAY to explain an automation that stopped after a restart. + * [#6190, ADR-0131 D6] Say out loud which stored rows no read serves + * because they sit in a legacy organization's layer. + * + * Boot hydrates `organization_id IS NULL` rows only, and since ADR-0131 D6 + * every read is environment → code: no read, of any type, serves a row + * stored organization-scoped, and no write can store a new one + * ({@link organizationScopedWriteRefusal}). The rows stored before the + * per-organization overlay axis retired are left at rest — ⛔ nothing here + * deletes or rewrites one. The v18 migration ceremony (ADR-0131 D10, `os + * migrate`: plan → backup → apply → post-check) carries them and names + * each row's fate: the Default Organization's rows of the five + * presentational types are promoted to the environment layer, and a name + * another organization also holds is reported for the operator to choose. + * Until then this line is the operator's account of what is not served. * * Shape decisions, all deliberate: * - * - **Which rows.** Registry-derived, never a hand-written list - * (Prime Directive #7): the complement of - * {@link OVERLAY_ALLOWED_TYPES}'s source flag. Derived from - * `DEFAULT_METADATA_TYPE_REGISTRY` and NOT from - * {@link isOverlayAllowed}, because the `OS_METADATA_WRITABLE` escape - * hatch only unlocks the WRITE — an env-unlocked type's org rows are - * hydrated no more than any other's, so silencing the line on that - * flag would hide exactly the deployment most likely to have these rows. - * - **[#6992] …plus every LIVE type the registry does not declare at - * all.** {@link listLiveMetadataTypes} — the same accessor - * {@link getMetaTypes} lists from. A plugin-registered type (`theme`, - * `connector`, `webhook`, `sharing_rule`, `analytics_cube`, …) has no - * registry entry, so the loop above could not reach it, yet - * `loadMetaFromDb`'s filter is type-BLIND and skips its org-scoped rows - * exactly like a `flow`'s. That family was the one getting neither the - * refusal nor the warning. It has no declaration to consult, and - * `getMetaTypes()` synthesises `allowOrgOverride: false` for it, so - * "not per-org overridable" is its correct reading here. - * - * ── THE REFUSAL HAS SINCE WIDENED TO MEET IT ── - * - * The #6190 refusal keyed off the STATIC registry and let exactly this - * family through, while this audit keyed off the LIVE set (ruled on - * #6992, scoped to the diagnostic). Since ADR-0131 D6 the refusal - * ({@link organizationScopedWriteRefusal}) admits no organization-scoped - * write of ANY type, plugin-registered ones included, hatch or no hatch, - * so the only rows this audit can find are residue. - * - * Measured, not assumed (#6992): at the instant this method runs — in - * `ObjectQLPlugin.start()` Phase 2, after every plugin's `init` — a - * real `app-showcase` boot has 7 live types with no registry entry - * (`analytics_cube`, `connector`, `data`, `package`, `sharing_rule`, - * `theme`, `webhook`), all of them from the SchemaRegistry. The - * widening is therefore live and not defeated by boot order. - * - **Two predicates, both narrowing.** `organization_id IS NOT NULL` - * plus the type list keeps the query empty-by-default: a healthy - * deployment reads nothing and prints nothing. A driver that drops - * either predicate degrades to reading more rows, never to a false - * line — the JS filter re-checks both. + * - **Every type.** The narrowing is type-blind, so the report is too: a + * view a tenant customised and a flow a tenant authored before #6190 + * are both unserved now. No registry is consulted, so a plugin type + * that is not live this boot is reported all the same. + * - **Active and draft rows** of `sys_metadata` — the listing of pending + * drafts no longer shows a legacy organization's drafts either — plus + * a count of the legacy rows of the three ledgers the timeline, + * history, diff and audit reads no longer show. + * - **One predicate, re-checked in JS.** A driver that cannot lower + * `$null: false` hands back a superset, and a superset must not become + * a false line. * - **One aggregated line.** Counts per type plus a capped sample of - * names, so a tenant with a thousand such rows costs one line rather - * than a thousand. + * names, so a tenant with a thousand such rows costs one line. * - **Best-effort, and non-fatal by construction.** A diagnostic must * never be the reason a boot fails, so its own catch swallows: the * caller's outer catch classifies REAL hydration outages @@ -26818,118 +26345,64 @@ export class ObjectStackProtocolImplementation implements /** Names printed per type before the line collapses to a count. */ const SAMPLE_PER_TYPE = 5; try { - const orgOverridable = new Set(); - /** Types `DEFAULT_METADATA_TYPE_REGISTRY` declares, whatever their flags. */ - const declaredTypes = new Set(); - /** [#6992] Live types with NO registry entry — reported, never refused. */ - const undeclaredTypes = new Set(); - const scannedTypes: string[] = []; - const scan = (singular: string): void => { - scannedTypes.push(singular); - // Both spellings: `sys_metadata.type` may hold the legacy - // plural, exactly as the hydration loop above assumes. - const plural = SINGULAR_TO_PLURAL[singular]; - if (plural) scannedTypes.push(plural); - }; - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - declaredTypes.add(entry.type); - if (entry.allowOrgOverride) { - orgOverridable.add(entry.type); - continue; + const legacyRows = async (object: string, where: Record): Promise => { + try { + const rows = await this.engine.find(object, { + where: { ...where, organization_id: { $null: false } }, + context: { isSystem: true }, + }); + return ((rows ?? []) as any[]).filter((row) => { + const org = (row as { organization_id?: string | null }).organization_id; + return org !== null && org !== undefined && org !== ''; + }); + } catch (error) { + // An unprovisioned ledger holds no legacy row; any other + // failure is this diagnostic's own, and stays here. + if (isMissingTableError(error, object)) return []; + throw error; } - scan(entry.type); - } - // [#6992] Widen to the live registry — see the TSDoc's "…plus every - // LIVE type the registry does not declare at all" bullet. Best - // effort like the rest of this method: a kernel whose accessors - // throw degrades to the declared scan it had before, never to a - // failed boot. - let liveTypes: string[] = []; - try { - liveTypes = await this.listLiveMetadataTypes(); - } catch { - liveTypes = []; - } - for (const liveType of liveTypes) { - const singular = PLURAL_TO_SINGULAR[liveType] ?? liveType; - if (declaredTypes.has(singular) || undeclaredTypes.has(singular)) continue; - undeclaredTypes.add(singular); - scan(singular); - } - if (scannedTypes.length === 0) return; - - const rows = await this.engine.find('sys_metadata', { - where: { - state: 'active', - organization_id: { $null: false }, - type: { $in: scannedTypes }, - }, - }); - if (!rows || rows.length === 0) return; + }; - // Re-check both predicates in JS: a driver that cannot lower one - // of them hands back a superset, and a superset must not become a - // false accusation. + const rows = await legacyRows('sys_metadata', { state: { $in: ['active', 'draft'] } }); const counts = new Map(); const samples = new Map(); - const scannedSingulars = new Set( - scannedTypes.map((t) => PLURAL_TO_SINGULAR[t] ?? t), - ); - let total = 0; for (const row of rows) { - const org = (row as { organization_id?: string | null }).organization_id; - if (org === null || org === undefined || org === '') continue; const singular = PLURAL_TO_SINGULAR[String(row.type)] ?? String(row.type); - if (orgOverridable.has(singular)) continue; - // [#6992] Re-check the TYPE predicate too, which is what the - // TSDoc above has always promised ("the JS filter re-checks - // both"). Before the live widening, `orgOverridable` was that - // re-check: within the declared registry, "not org-overridable" - // and "in the scanned list" were the same statement. They are - // not any more — a row of a type that is neither declared NOR - // live (a plugin uninstalled since the row was written) is - // absent from the list, so only this line keeps a driver that - // cannot lower `$in` from turning a superset into a line about - // a type this kernel never scanned. - if (!scannedSingulars.has(singular)) continue; - total++; counts.set(singular, (counts.get(singular) ?? 0) + 1); const names = samples.get(singular) ?? []; - if (names.length < SAMPLE_PER_TYPE) names.push(`${String(row.name)}@${String(org)}`); + const draft = row.state === 'draft' ? ' (draft)' : ''; + if (names.length < SAMPLE_PER_TYPE) names.push(`${String(row.name)}@${String(row.organization_id)}${draft}`); samples.set(singular, names); } - if (total === 0) return; + const ledgers: string[] = []; + for (const ledger of ['sys_metadata_commit', 'sys_metadata_history', 'sys_metadata_audit']) { + const n = (await legacyRows(ledger, {})).length; + if (n > 0) ledgers.push(`${ledger}×${n}`); + } + if (rows.length === 0 && ledgers.length === 0) return; - let reportedUndeclared = false; const detail = Array.from(counts.entries()) .map(([type, count]) => { const names = samples.get(type) ?? []; const more = count > names.length ? `, +${count - names.length} more` : ''; - // [#6992] Mark the plugin-registered family. Not decoration: - // the operator's next step differs between the two: a - // DECLARED type's org-scoped write is refused from now on - // (#6190), so the listed rows are historical residue and - // cannot grow; an UNDECLARED type's write is not refused, - // so the same names come back after every restart until the - // author stops writing them org-scoped. - const mark = undeclaredTypes.has(type) ? ' [plugin-registered]' : ''; - if (mark) reportedUndeclared = true; - return `${type}×${count}${mark} (${names.join(', ')}${more})`; + return `${type}×${count} (${names.join(', ')}${more})`; }) .join('; '); console.warn( - `[Protocol] [metadata_org_scoped_unhydrated] ${total} active sys_metadata row(s) are ` + - `org-scoped on types with NO per-org channel (the registry declares allowOrgOverride=false, ` + - `or does not declare the type at all), so boot hydration skipped them and they are absent ` + - `from the process-wide registry: ${detail}. ` + - `A 'flow' listed here will NOT bind its triggers in this process (the kernel:ready binder ` + - `reads flows env-wide) — it fired until the last restart and stops now. ` + - (reportedUndeclared - ? `Types marked [plugin-registered] have no metadata-type registry entry, so the declared-types-only ` - + `org-scope write refusal does NOT cover them: rows of those types can still be written ` - + `org-scoped, and will be listed here again after every restart until the author stops. ` + `[Protocol] [metadata_org_scoped_unserved] ` + + (rows.length > 0 + ? `${rows.length} sys_metadata row(s) are stored in a legacy organization's layer, which ` + + `ADR-0131 D6 retired: no read serves them, boot does not load them, and the environment's ` + + `row or the package's definition is served in their place: ${detail}. A 'flow' listed here ` + + `does not bind its triggers. ` + : '') + + (ledgers.length > 0 + ? `Legacy organization-scoped ledger rows, which the commit timeline, history, diff and audit ` + + `reads no longer show: ${ledgers.join(', ')}. ` : '') + - `Re-save the item env-wide (no active organization), or delete the row. See ADR-0005.`, + `Nothing is deleted or rewritten: the v18 migration ceremony (\`os migrate\`, ADR-0131 D10) ` + + `carries these rows and names each one's fate — promoted to the environment layer, or reported ` + + `when another organization holds the same name.`, ); } catch { // Diagnostics never break boot — see the TSDoc. Deliberately not @@ -26938,6 +26411,51 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [ADR-0131 D6, triage ruling Q1 → C] Say out loud which stored + * ENVIRONMENT rows boot did not load because they overlay sealed managed + * content ({@link isUnservedSealedOverlay}): an overlay of an item a + * managed package ships, on a type whose registry entry opens no overlay + * channel — a managed flow, action, hook or object, written through the + * `OS_METADATA_WRITABLE` hatch before D6 sealed it. No read serves such a + * row: the package's definition wins. + * + * Per type, a capped sample of names, and the two remedies the ruling + * names. ⛔ Nothing here deletes or rewrites the row — its fate is the v18 + * migration ceremony's to name, as a reported population. + * + * `warn`: a functional degradation an operator can see (the customised + * behaviour is gone, the package's is served), not a durability one. + * Judged against the packages registered when boot hydrates; a package + * registered later (a marketplace install) is not judged here, and its + * sealed overlays are still not served. + */ + private reportSealedOverlayRows(rows: ReadonlyArray<{ type: string; name: string }>): void { + if (rows.length === 0) return; + const SAMPLE_PER_TYPE = 5; + const byType = new Map(); + for (const { type, name } of rows) { + const names = byType.get(type) ?? []; + names.push(name); + byType.set(type, names); + } + const detail = Array.from(byType.entries()) + .map(([type, names]) => { + const shown = names.slice(0, SAMPLE_PER_TYPE); + const more = names.length > shown.length ? `, +${names.length - shown.length} more` : ''; + return `${type}×${names.length} (${shown.join(', ')}${more})`; + }) + .join('; '); + console.warn( + `[Protocol] [metadata_sealed_overlay_unserved] ${rows.length} active environment sys_metadata row(s) ` + + `overlay an item a managed package ships, on a type that is sealed against overlays (ADR-0131 D6): ` + + `${detail}. They are not loaded and no read serves them — the package's own definition is served. ` + + `Each row is kept at rest, untouched. To keep the change, re-express it as a new item under a new ` + + `name (a linkage-free clone of the managed item), then delete the stored row; otherwise delete the ` + + `stored row.`, + ); + } + // ========================================== // Metadata References (Phase 3a-references) // ========================================== @@ -26988,7 +26506,6 @@ export class ObjectStackProtocolImplementation implements async findReferencesToMeta(request: { type: string; name: string; - organizationId?: string; }): Promise<{ references: Array<{ type: string; @@ -27189,7 +26706,6 @@ export class ObjectStackProtocolImplementation implements // is what surfaces. const result = await this.getMetaItems({ type: matcher.fromType, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), }); const items = (result?.items ?? []) as unknown[]; for (const raw of items) { diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 0843e9a99a5..0d13161d099 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -436,44 +436,30 @@ export function resetEnvWritableMetadataTypes(): void { } /** - * [#22090] The `sys_metadata` predicate for "the rows of one state that a - * caller in `organizationId` sees, optionally narrowed to a `type` and a - * package" — the ONE statement of that rule, which + * [#22090] The `sys_metadata` predicate for "the environment's rows of one + * state, optionally narrowed to a `type` and a package" — the ONE statement + * of that rule, which * {@link SysMetadataRepository.listDrafts} asks for `'draft'` and the * protocol's package revert asks for `'active'`. * * A package's membership in storage is its rows' `package_id` column, the * same column the read path decorates as `_packageId`. The revert has to know * which of a package's rows are published as well as which are drafts, and a - * second spelling of the org half below is exactly the drift #3115 / #7705 / + * second spelling of the scope half below is exactly the drift #3115 / #7705 / * #7779 each had to repair on one copy at a time — so the revert asks this * function, never a `where` of its own. * + * [ADR-0131 D6] The environment's rows only (`organization_id IS NULL`): a + * legacy organization-scoped row is reported at boot, never read. + * * Deliberately NOT exported from the package entry: it is a shared predicate * between this module and `protocol.ts`, not a public read. */ export function packageScopedRowWhere( - organizationId: string | null, state: 'draft' | 'active', filter?: { type?: string; packageId?: string }, ): Record { - const where: Record = { state }; - // Surface BOTH org-scoped drafts and env-wide (`organization_id IS NULL`) - // drafts. Env-wide drafts are real pending changes — `getMetaItems`/preview - // overlays them and `publish-drafts` promotes them — but a strict - // `organization_id = this.organizationId` equality silently dropped them - // whenever the active org was non-null. AI-authored metadata is written - // env-wide, so its drafts vanished from the pending-changes list and the - // Publish CTA never appeared: the change showed in preview yet could not be - // published from the UI (the "orphaned draft" bug). - if (organizationId != null) { - where.$or = [ - { organization_id: organizationId }, - { organization_id: null }, - ]; - } else { - where.organization_id = null; - } + const where: Record = { state, organization_id: null }; if (filter?.type) where.type = filter.type; if (filter?.packageId) where.package_id = filter.packageId; return where; @@ -1373,28 +1359,20 @@ export class SysMetadataRepository implements MetadataRepository { * item this header is the only place a client can find a label at all. */ label: I18nLabel | null; - /** - * The scope the draft actually lives in — `null` for an env-wide draft, - * a string for a per-org overlay draft. The `$or` below surfaces BOTH to - * a non-null-org caller, so consumers that then act on a draft (promote / - * discard) MUST route the write to THIS scope, not the caller's active - * org, or they 404 on the env-wide row they can never match (#3115). - */ - organizationId: string | null; packageId: string | null; updatedAt: string | null; updatedBy: string | null; }> > { this.assertOpen(); - const where = packageScopedRowWhere(this.organizationId, 'draft', filter); + // [ADR-0131 D6] The environment's drafts, whichever repository asks. + const where = packageScopedRowWhere('draft', filter); const rows = await this.engine.find('sys_metadata', { where, context: { isSystem: true } }); return (rows as any[]).map((row) => ({ type: row.type, name: row.name, // [#22200] Off the row this read already holds — no second query. label: draftBodyLabel(row), - organizationId: row.organization_id ?? null, packageId: row.package_id ?? null, // [commit c383352cb] `updated_at` / `created_at` are the BUILTIN audit columns, // and on Postgres and MySQL they used to arrive here as a JS `Date`: From 4a12bab987f0ec50aca434f328a1712aad85355b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:45:52 +0000 Subject: [PATCH 02/21] =?UTF-8?q?wip(metadata-protocol):=20S5=20tests=20re?= =?UTF-8?q?-premised=20on=20environment=20=E2=86=92=20code=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- .../get-meta-item-cached-etag-scope.test.ts | 347 ------------ ...et-meta-item-layered-org-read-gate.test.ts | 511 ------------------ .../src/get-meta-item-org-read-gate.test.ts | 451 ---------------- .../src/get-meta-items-org-read-gate.test.ts | 431 --------------- .../src/meta-overlay-cache.test.ts | 35 +- .../src/protocol.audit-org-scope.test.ts | 124 +---- .../protocol.i18n-bundle-list-merge.test.ts | 47 +- .../protocol.list-slot-prefer-local.test.ts | 39 +- .../src/protocol.lock-one-resolution.test.ts | 45 +- .../src/protocol.lock-org-axis-agree.test.ts | 20 +- .../protocol.metadata-store-outage.test.ts | 27 - ...oped-cold-boot-audit-live-registry.test.ts | 351 ------------ ...rotocol.org-scoped-cold-boot-audit.test.ts | 178 ++---- .../protocol.org-scoped-write-refused.test.ts | 12 +- .../protocol.reads-environment-only.test.ts | 339 ++++++++++++ .../src/protocol.scoped-list-fallback.test.ts | 55 +- packages/metadata-protocol/src/protocol.ts | 29 +- ...itory-14938-list-drafts-updated-at.test.ts | 9 +- .../view-container-runtime-expansion.test.ts | 53 +- 19 files changed, 581 insertions(+), 2522 deletions(-) delete mode 100644 packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts delete mode 100644 packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts delete mode 100644 packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts delete mode 100644 packages/metadata-protocol/src/get-meta-items-org-read-gate.test.ts delete mode 100644 packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts create mode 100644 packages/metadata-protocol/src/protocol.reads-environment-only.test.ts diff --git a/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts b/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts deleted file mode 100644 index 57237a4f51a..00000000000 --- a/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#16525] WHICH organization value `getMetaItemCached` folds into the ETag — - * the SUPPLIED request member, or the EFFECTIVE scope the registry read gate - * returns — and why the answer is not the defect the card feared. - * - * ── The question, and the measurement ───────────────────────────────────── - * - * The card put two facts side by side. `organizationIdForMetaRead(type, org)` - * answers `undefined` for a supplied organization whenever the registry - * declares `allowOrgOverride: false`, and `getMetaItem` then resolves - * `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)` — so a - * gated-away organization is served the env-wide record. `getMetaItemCached` - * delegates to that. ⇒ If the ETag names the SUPPLIED organization while the - * body is the env-wide representation, the validator states a scope that was - * never served. - * - * MEASURED, and the answer is the SUPPLIED member: - * - * const scope = [ - * request.organizationId ? `org:${request.organizationId}` : undefined, - * request.locale || undefined, - * ].filter(...); - * - * §1 reproduces the divergence end to end — `object` is `allowOrgOverride: - * false`, a supplied organization is gated away, the env-wide record is - * served, and the validator still names the organization. - * - * ── ⭐ Why that is a COST and not a correctness fault ────────────────────── - * - * Because `content` — the serialized document actually being sent — is inside - * the same hash. The validator is therefore a function of the bytes, not only - * of the declared scope, so the harm the card names ("a validator claiming to - * describe an org-scoped representation that was never served") cannot reach - * a caller: a `304` is answered only on an exact match of a hash that covers - * those bytes, so a caller is only ever pinned to the representation IT - * previously received. §3 pins that, and it is the half a future change is - * most likely to break silently — hashing a cheap version marker instead of - * the document would leave §1 and §2 green and destroy the whole argument. - * - * What is left is validator FRAGMENTATION: N organizations reading one - * env-wide document through a non-overridable type hold N validators for - * byte-identical content. Wasteful, not wrong. - * - * ── ⚠️ §1 is a CHARACTERIZATION pin, not a prohibition ──────────────────── - * - * Folding the EFFECTIVE value instead would merge those validators and make - * the declared scope true. It is deliberately NOT done here: it changes every - * published ETag that carries an organization (one forced miss per caller per - * deploy) and buys nothing at the only production door, which already supplies - * the effective value (pinned in `@objectstack/rest`, - * `rest-server-meta-cached-etag-door-scope.test.ts`). ⇒ If §1 reddens, read - * #16525 before making it green: it means the fold moved, which is a decision, - * not a regression. - * - * ── Why the observation channel is a BODY-vs-VALIDATOR pair ─────────────── - * - * Asserting an ETag literal would pin the hash function, which is not what - * this card is about. Every assertion below compares two reads that differ in - * exactly one input, and asserts the served BODIES alongside the validators — - * so an ETag difference can never be attributed to a body difference nobody - * checked, and a body difference can never hide behind a validator nobody - * read. - */ - -import { describe, expect, it } from 'vitest'; -import { assertEngineFindOnePredicate, organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; -const OTHER_ORG = 'org_globex'; - -/** `allowOrgOverride: false` — a supplied organization is gated away. */ -const NON_OVERRIDABLE = 'object'; - -/** `allowOrgOverride: true` — a supplied organization survives the gate. */ -const OVERRIDABLE = 'view'; - -interface StoredRow { - id: string; - type: string; - name: string; - organization_id: string | null; - package_id: string | null; - state: string; - metadata: string; -} - -/** - * A row whose `label` names the partition it came from, so a served document - * says which row answered without the assertion having to guess. - */ -const storedRow = ( - type: string, - name: string, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `findOne` and `find` over a row table, plus the registry - * surface the single-item read path touches on its way past the overlay. - * - * ⛔ No `insert` / `update` / `delete`, deliberately — the read path under test - * issues two read verbs (`findOne` for the served row, and since #21761 `find` - * for the rows its lock is selected from), and a double declaring verbs no - * case exercises would owe `check:engine-double-contract` a dispatch contract - * that protects nothing. Same shape the sibling org-read-gate pin drives. - */ -function makeHarness(rows: StoredRow[]) { - const matching = (table: string, opts?: { where?: Record }): StoredRow[] => { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong - // question rather than failing. Refuse the shape this double does - // not implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - }; - const engine: any = { - async findOne(table: string, opts?: { where?: Record }) { - // `check:engine-double-contract` — refuses what the real engine refuses. - assertEngineFindOnePredicate(table, opts); - if (table !== 'sys_metadata') return undefined; - return matching(table, opts)[0]; - }, - async find(table: string, opts?: { where?: Record }) { - return matching(table, opts); - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - return new ObjectStackProtocolImplementation(engine, () => new Map()) as any; -} - -/** The label the served document carries — `'env …'` or `'org_acme …'`. */ -const servedLabel = (res: any): string => res?.data?.label; - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the registry facts every section below rests on, read not assumed -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the fixture types really are on opposite sides of the read gate', () => { - it(`${NON_OVERRIDABLE} is declared allowOrgOverride: false`, () => { - const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === NON_OVERRIDABLE); - expect(entry, `no registry entry for '${NON_OVERRIDABLE}'`).toBeDefined(); - expect(entry!.allowOrgOverride).toBe(false); - expect(organizationIdForMetaRead(NON_OVERRIDABLE, ORG)).toBeUndefined(); - }); - - it(`${OVERRIDABLE} is declared allowOrgOverride: true`, () => { - const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERRIDABLE); - expect(entry, `no registry entry for '${OVERRIDABLE}'`).toBeDefined(); - expect(entry!.allowOrgOverride).toBe(true); - expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — (a) WHICH VALUE. The ETag folds the SUPPLIED member. -// ⚠️ Characterization. If this reddens, read #16525 before greening it. -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 the ETag names the SUPPLIED organization, not the effective scope', () => { - it('a gated-away organization is absent from the BODY and present in the VALIDATOR', async () => { - // A pre-#6190 phantom org row sits beside the env-wide one. The gate is - // what keeps it out of the response; without it this case would prove - // nothing about the ETag, because the two reads would differ in body. - const protocol = makeHarness([ - storedRow(NON_OVERRIDABLE, 'customer'), - storedRow(NON_OVERRIDABLE, 'customer', { organization_id: ORG, id: 'phantom' }), - ]); - - const supplied = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const orgless = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', - }); - - // ⭐ The control that makes the validator assertion mean anything: the - // gate held, so BOTH reads were answered by the env-wide row and the - // phantom was not served. - expect(servedLabel(supplied), 'the phantom org row was served').toBe('env customer'); - expect(servedLabel(orgless)).toBe('env customer'); - expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); - - // ⇒ Byte-identical representations, different validators. The scope - // component is the request member, unreduced by the read gate. - expect( - supplied.etag.value, - 'the ETag no longer distinguishes a gated-away organization — see #16525', - ).not.toBe(orgless.etag.value); - }); - - it('two organizations gated away from the same document hold two validators', async () => { - const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); - - const a = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const b = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, - }); - - expect(JSON.stringify(a.data)).toBe(JSON.stringify(b.data)); - // The fragmentation the card names. Wasteful, and harmless for the - // reason §3 pins. - expect(a.etag.value).not.toBe(b.etag.value); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — (b) REACHABILITY of the fallback the fold was written against -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 the org-resolved-then-env-wide fallback is present, not future', () => { - /** - * The fold's own comment argued from "any FUTURE path that resolves an org - * row but falls back to the env-wide body". That path is the `??` in - * `getMetaItem` and it runs today on an OVERRIDABLE type whose - * organization simply has no row — reachable through the production - * cached door, which forwards the organization for exactly these types. - */ - it('an overridable type with no org row serves env-wide under an org-named validator', async () => { - const protocol = makeHarness([storedRow(OVERRIDABLE, 'account_list')]); - - const supplied = await protocol.getMetaItemCached({ - type: OVERRIDABLE, name: 'account_list', organizationId: ORG, - }); - const orgless = await protocol.getMetaItemCached({ - type: OVERRIDABLE, name: 'account_list', - }); - - // The organization survived the gate here — and still resolved nothing, - // so the env-wide row answered. - expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); - expect(servedLabel(supplied)).toBe('env account_list'); - expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); - expect(supplied.etag.value).not.toBe(orgless.etag.value); - }); - - it('the same type WITH an org row serves it, so the fallback above was a real fallback', async () => { - // ⭐ Without this control the case above is indistinguishable from a - // harness that can never resolve an org row at all. - const protocol = makeHarness([ - storedRow(OVERRIDABLE, 'account_list'), - storedRow(OVERRIDABLE, 'account_list', { organization_id: ORG, id: 'org_row' }), - ]); - - const supplied = await protocol.getMetaItemCached({ - type: OVERRIDABLE, name: 'account_list', organizationId: ORG, - }); - expect(servedLabel(supplied)).toBe(`${ORG} account_list`); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — ⭐ WHY §1 IS A COST AND NOT A FAULT: the served bytes are in the hash -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 the validator is a function of the served bytes, not of the scope alone', () => { - it('an unchanged document answers 304 to the validator it issued', async () => { - // ⭐ The control that must FIRE. Every assertion below reads "no 304"; - // if the 304 arm were unreachable in this harness they would all pass - // for the wrong reason. - const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; - const protocol = makeHarness(rows); - - const first = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const again = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, - }); - - expect(again.notModified, 'the 304 arm is unreachable in this harness').toBe(true); - }); - - it('a changed document never answers 304, though the scope is unchanged', async () => { - // This is the invariant the whole "harmless" argument rests on. A hash - // over the scope and a cheap version marker — a plausible optimization — - // leaves §1 and §2 green and turns this red. - const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; - const protocol = makeHarness(rows); - - const first = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - - rows[0].metadata = JSON.stringify({ name: 'customer', label: 'env customer REVISED' }); - - const after = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, - }); - - expect(after.notModified, 'a stale validator was honoured across a body change').toBe(false); - expect(servedLabel(after)).toBe('env customer REVISED'); - expect(after.etag.value).not.toBe(first.etag.value); - }); - - it("one organization's validator is never honoured for another's request", async () => { - const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); - - const mine = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const theirs = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, - cacheRequest: { ifNoneMatch: `"${mine.etag.value}"` }, - }); - - expect(theirs.notModified).toBe(false); - expect(servedLabel(theirs)).toBe('env customer'); - }); -}); diff --git a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts deleted file mode 100644 index 22caf11e94f..00000000000 --- a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit e1d4f9e3f] `getMetaItemLayered` — the THIRD `/meta` read verb — applies the - * registry read gate ITSELF, so a caller cannot spend a raw active - * organization on a type that has no per-org read channel. - * - * ── The defect, and why this verb was graded on its own harm ────────────── - * - * The series is #9454 → commit 96326040f (plural `getMetaItems`) → commit d5cbb44f3 (singular - * `getMetaItem`) → this one. On the plural verb an ungated organization can - * only ADD a row; on the singular verb it SUBSTITUTES the served document. - * Here the affected value is the `overlay` LAYER of a three-layer diagnostic - * whose entire purpose is to answer "what did this tenant customize" — so a - * pre-#6190 phantom org-scoped row of an `allowOrgOverride: false` type is - * reported as `overlay` + `overlayScope: 'org'` and rendered by the Studio - * "Code default vs Overlay vs Effective" diff tab as evidence of a - * customization that does not exist. §1 is that case. - * - * ⚠️ It is not merely displayed. TWO doors return that layer AS the response - * when it is non-null — `runtime/src/domains/meta.ts` and `rest-server.ts`'s - * `/meta/:type/:name/published` — so on those paths the phantom is SERVED. - * §6 pins the predicate those two branches read. - * - * ── ⭐ §3 is the section that does not exist on either twin ─────────────── - * - * In both twins the fix is "replace the `orgId` binding with the gated call", - * and it is correct there because the binding already sat AFTER - * `canonicalizeMetaRequestType`. Here the binding sat BEFORE the fold, so the - * one-liner does NOT port: dropping the same expression in place would gate on - * the RAW type. Commit 26f3588fb measured what that costs — `declaresOrgOverride` - * tolerates the MANIFEST plurals but not the URL-only ones (`translations` / - * `email_templates` have no manifest key), so a raw segment splits one item - * across two partitions. The fix is therefore a REORDER, and §3 is what fails - * if a later author moves the binding back above the fold: it asserts that a - * URL-only spelling of an OVERRIDABLE type still reaches its org partition. - * - * ── §4–§5 are the idempotence proof the ruling behind commit 96326040f made this conditional - * on, discharged over THIS door's caller population ────────────────────── - * - * That ruling makes a callee-side gate conditional on proving no already-gating - * caller is double-scoped or wrongly denied, discharged PER DOOR over that - * door's own callers. Commit d5cbb44f3's proof covers none of this verb's population, so - * it is re-discharged here: §4 covers `f(t, undefined) === undefined` (the four - * `plugin-security` invocations that name no organization) and §5 covers - * `f(t, f(t, o)) === f(t, o)` over the COMPLETE accepted-spelling population - * (the `/layers` door, which gates on the folded type and passes the raw - * segment), including the direction that idempotence must NOT be achieved by - * denying everyone. - * - * ── Why the observation channel is the WHERE multiset AND the layer ─────── - * - * `partitions()` reads the `organization_id` partitions the engine was asked - * for — the whole of what this change moves — so it is body-independent and - * covers every declared type. The layer assertions pay for that by naming the - * real `overlay` / `overlayScope` on both sides of the gate, so neither half - * rests on a query nobody proved returns a row. - */ - -import { describe, expect, it } from 'vitest'; -import { declaresOrgOverride, organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; - -interface StoredRow { - id: string; - type: string; - name: string; - organization_id: string | null; - package_id: string | null; - state: string; - metadata: string; -} - -const storedRow = ( - type: string, - name: string, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}_${extra.state ?? 'active'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - // `label` is the observation channel for WHICH row became the layer — the - // two rows of a pair differ only by scope, so a served label names its - // origin. - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `findOne` over a row table, plus the registry surface the - * layered read touches on its way past the overlay. - * - * `find` is the second verb the read path issues, and only for a `view` name - * with no stored row of its own: the read then selects the stored view - * containers that might expand that name, through the list read's own row - * selection (#21442). It records into `finds` — the same partitions question - * asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a - * double declaring verbs no case exercises would owe - * `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the two sibling read-gate pins drive. - */ -function makeHarness(rows: StoredRow[]) { - const findOnes: Array> = []; - const finds: Array> = []; - const matching = (where: Record) => { - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong question - // rather than failing. Refuse the shape this double does not - // implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - }; - const engine: any = { - async findOne(table: string, opts?: { where?: Record }) { - if (table !== 'sys_metadata') return undefined; - const where = opts?.where ?? {}; - findOnes.push({ ...where }); - return matching(where)[0]; - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - engine.find = async (table: string, opts?: { where?: Record }) => { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - return matching(where); - }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, findOnes, finds }; -} - -/** Every `organization_id` partition the engine was asked for, deduplicated. */ -const partitions = (finds: Array>): Array => - [...new Set(finds.map((f) => (f.organization_id ?? null) as string | null))].sort( - (a, b) => String(a).localeCompare(String(b)), - ); - -/** The same, folded per canonical type. */ -function partitionsByType(finds: Array>): Map> { - const out = new Map>(); - for (const f of finds) { - const t = canonicalMetaUrlType(String(f.type)); - if (!out.has(t)) out.set(t, new Set()); - out.get(t)!.add((f.organization_id ?? null) as string | null); - } - return out; -} - -/** The label the `overlay` LAYER carries — `'env …'` or `'org_acme …'`. */ -const overlayLabel = (res: any): string | undefined => res?.overlay?.label; - -/** - * The COMPLETE accepted-spelling population: every URL spelling the `/meta` - * doors fold, unioned with every registry singular. Derived, never listed — - * a newly declared type arrives in this sweep on its own. - */ -const ALL_SPELLINGS: string[] = [ - ...new Set([ - ...Object.keys(META_URL_TO_SINGULAR), - ...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), - ]), -].sort(); - -/** The org-overridable canonical types, derived. */ -const OVERRIDABLE: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => e.allowOrgOverride) - .map((e) => e.type); - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the population this rests on, pinned so a registry change is visible -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the org-overridable set', () => { - it('is exactly the ADR-0005 tier-A five', () => { - expect([...OVERRIDABLE].sort()).toEqual( - ['dashboard', 'email_template', 'report', 'translation', 'view'], - ); - }); - - it("`object` — the type the reachable ungated callers can carry — is NOT overridable", () => { - expect(OVERRIDABLE).not.toContain('object'); - expect(organizationIdForMetaRead('object', ORG)).toBeUndefined(); - }); - - it("`permission` — the type every `plugin-security` call site hard-codes — is NOT overridable", () => { - // The premise of the third moving caller named in the gate's comment: - // `permission-set-projection.ts` forwards `evt.organizationId` into - // this verb on `type: 'permission'`. If that flag ever flips, that - // caller stops moving and the comment must be re-derived. - expect(OVERRIDABLE).not.toContain('permission'); - expect(organizationIdForMetaRead('permission', ORG)).toBeUndefined(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — ⭐ THE CARD'S CASE: a phantom must not become the `overlay` layer -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 a pre-#6190 phantom is not reported as a customization', () => { - const rows = () => [ - // The live env-wide document. - storedRow('object', 'showcase_task'), - // The phantom: an org-scoped row of an `allowOrgOverride: false` type. - // `loadMetaFromDb` walks past it at boot, so it is dead — it exists - // only because the runtime used to stamp `organization_id` on every - // type before #6190. - storedRow('object', 'showcase_task', { organization_id: ORG }), - ]; - - it('reports the env-wide row to a caller that passes a RAW active organization', async () => { - const { protocol, findOnes } = makeHarness(rows()); - const res = await protocol.getMetaItemLayered({ - type: 'object', - name: 'showcase_task', - organizationId: ORG, - }); - expect(overlayLabel(res)).toBe('env showcase_task'); - // ⭐ The field the Studio diff tab renders as "this tenant customized - // it". A phantom must never reach it. - expect(res.overlayScope).toBe('env'); - // And the phantom's partition was never even read — the gate resolves - // to `undefined`, so the `if (orgId)` arm is skipped whole. - expect(partitions(findOnes)).toEqual([null]); - }); - - it('answers identically whether or not the caller names the organization', async () => { - const withOrg = makeHarness(rows()); - const withoutOrg = makeHarness(rows()); - const a = await withOrg.protocol.getMetaItemLayered({ - type: 'object', name: 'showcase_task', organizationId: ORG, - }); - const b = await withoutOrg.protocol.getMetaItemLayered({ - type: 'object', name: 'showcase_task', - }); - expect(overlayLabel(a)).toBe(overlayLabel(b)); - expect(a.overlayScope).toBe(b.overlayScope); - expect(partitions(withOrg.findOnes)).toEqual(partitions(withoutOrg.findOnes)); - }); - - it('holds for every non-overridable declared type, not just `object`', async () => { - const nonOverridable = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => !e.allowOrgOverride) - .map((e) => e.type); - expect(nonOverridable.length).toBeGreaterThan(5); - for (const type of nonOverridable) { - const { protocol, findOnes } = makeHarness([ - storedRow(type, 'probe'), - storedRow(type, 'probe', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type, name: 'probe', organizationId: ORG, - }); - expect(overlayLabel(res), `${type} reported the phantom`).toBe('env probe'); - expect(res.overlayScope, `${type} claimed an org customization`).toBe('env'); - expect(partitions(findOnes), `${type} read the org partition`).toEqual([null]); - } - }); - - it('reports NO overlay at all when the phantom is the only row', async () => { - // The sharpest shape: nothing env-wide exists, so before this gate the - // diagnostic answered `overlay: , overlayScope: 'org'` — a - // customization claim with no live document behind it at all. - const { protocol, findOnes } = makeHarness([ - storedRow('object', 'ghost', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type: 'object', name: 'ghost', organizationId: ORG, - }); - expect(res.overlay).toBeNull(); - expect(res.overlayScope).toBeNull(); - expect(partitions(findOnes)).toEqual([null]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — the gate must not be achieved by denying everyone -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 an overridable type still reports its org-scoped customization', () => { - it('prefers the org row and says `overlayScope: org`', async () => { - const { protocol } = makeHarness([ - storedRow('view', 'task_list'), - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(overlayLabel(res)).toBe(`${ORG} task_list`); - expect(res.overlayScope).toBe('org'); - }); - - it('falls back to the env-wide row when the org has no overlay of its own', async () => { - const { protocol, findOnes } = makeHarness([storedRow('view', 'task_list')]); - const res = await protocol.getMetaItemLayered({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(overlayLabel(res)).toBe('env task_list'); - expect(res.overlayScope).toBe('env'); - // BOTH partitions read — the org one first, the env-wide one as the - // fallback. (`partitions` returns a SORTED set, so this asserts - // membership, not read order.) - expect(partitions(findOnes)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — ⭐ THE REORDER: the gate reads the FOLDED type, never the raw segment -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 the gate resolves AFTER canonicalizeMetaRequestType', () => { - /** - * The URL-only spellings — accepted at `/meta/:type` and folded by this - * method's first statement, but absent from the manifest map - * `declaresOrgOverride` tolerates. Derived from the predicate itself, so - * this cannot go stale against a changed map: a spelling qualifies when it - * folds to an OVERRIDABLE canonical type and yet answers `false` raw. - */ - const URL_ONLY_OVERRIDABLE = ALL_SPELLINGS.filter( - (s) => declaresOrgOverride(canonicalMetaUrlType(s)) && !declaresOrgOverride(s), - ); - - it('the #10340 hazard is real on this population — it is not an empty sweep', () => { - // ⛔ Positive control. Without this the three assertions below could - // pass over zero spellings and read as proof of nothing. The card names - // `translations` and `email_templates`; the derivation must find them. - expect(URL_ONLY_OVERRIDABLE.length).toBeGreaterThan(0); - expect(URL_ONLY_OVERRIDABLE).toContain('translations'); - expect(URL_ONLY_OVERRIDABLE).toContain('email_templates'); - }); - - it('a URL-only spelling of an overridable type still reaches its org partition', async () => { - // ⭐ THE PIN THAT FAILS IF THE BINDING MOVES BACK ABOVE THE FOLD. - // Gated raw, `organizationIdForMetaRead('translations', ORG)` is - // `undefined` and this read never asks for the org partition — one item - // split across two partitions, addressed by spelling. Gated folded, it - // is `ORG` and the org row wins. - for (const spelling of URL_ONLY_OVERRIDABLE) { - const canonical = canonicalMetaUrlType(spelling); - const { protocol, findOnes } = makeHarness([ - storedRow(canonical, 'greeting'), - storedRow(canonical, 'greeting', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type: spelling, name: 'greeting', organizationId: ORG, - }); - expect(overlayLabel(res), spelling).toBe(`${ORG} greeting`); - expect(res.overlayScope, spelling).toBe('org'); - // ONLY the org partition: the org row wins, so the `overlay === - // null` env fallback never runs. Gated on the raw segment this - // list is `[null]` instead — the partition split commit 26f3588fb measured. - expect(partitions(findOnes), spelling).toEqual([ORG]); - } - }); - - it('answers the URL spelling and the canonical spelling identically', async () => { - // The statement of commit 26f3588fb restated as an equality: one item, ONE - // partition, whichever accepted spelling addresses it. - for (const spelling of URL_ONLY_OVERRIDABLE) { - const canonical = canonicalMetaUrlType(spelling); - const rows = () => [ - storedRow(canonical, 'greeting'), - storedRow(canonical, 'greeting', { organization_id: ORG }), - ]; - const viaUrl = makeHarness(rows()); - const viaCanonical = makeHarness(rows()); - const a = await viaUrl.protocol.getMetaItemLayered({ - type: spelling, name: 'greeting', organizationId: ORG, - }); - const b = await viaCanonical.protocol.getMetaItemLayered({ - type: canonical, name: 'greeting', organizationId: ORG, - }); - expect(overlayLabel(a), spelling).toBe(overlayLabel(b)); - expect(a.overlayScope, spelling).toBe(b.overlayScope); - expect(partitions(viaUrl.findOnes), spelling) - .toEqual(partitions(viaCanonical.findOnes)); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §4 — idempotence, leg 1: `f(t, undefined) === undefined` -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§4 a caller that names no organization is untouched', () => { - it('reads only the env-wide partition, for every accepted spelling', async () => { - // The population that covers the four `plugin-security` invocations - // naming no organization — `packaged-permission-set-lock-gate.ts`'s - // authoring gate and three of `permission-set-projection.ts`'s reads: - // the predicate short-circuits on `undefined` before it ever consults - // the registry flag. - for (const spelling of ALL_SPELLINGS) { - const { protocol, findOnes } = makeHarness([]); - await protocol.getMetaItemLayered({ type: spelling, name: 'probe' }) - .catch(() => undefined); - for (const [type, parts] of partitionsByType(findOnes)) { - expect([...parts], `${spelling} → ${type}`).toEqual([null]); - } - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §5 — idempotence, leg 2: `f(t, f(t, o)) === f(t, o)` over the whole -// accepted-spelling population -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§5 an already-gating caller receives the same scope it did before', () => { - it('the predicate is idempotent for every accepted spelling', () => { - for (const spelling of ALL_SPELLINGS) { - const once = organizationIdForMetaRead(spelling, ORG); - expect(organizationIdForMetaRead(spelling, once), spelling).toBe(once); - } - }); - - it('the REST `/layers` door gates on the string this method folds to', () => { - // The door computes `organizationIdForMetaRead(canonicalMetaUrlType( - // req.params.type), layeredCtx?.tenantId)` and then passes `type: - // req.params.type` — the RAW segment. This method's first statement - // folds that segment through `canonicalizeMetaRequestType`, which IS - // `canonicalMetaUrlType`. So the gate inside reads the identical STRING - // the door gated on, and the second application is the algebraic no-op. - for (const spelling of ALL_SPELLINGS) { - const doorGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG); - const innerGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), doorGate); - expect(innerGate, spelling).toBe(doorGate); - } - }); - - it('a gated door reading an overridable type still reaches the org partition', async () => { - // The other direction of the no-op: a door that already resolved `ORG` - // for `view` gets the org partition read, exactly as before. - const gated = organizationIdForMetaRead('view', ORG); - expect(gated).toBe(ORG); - const { protocol, findOnes, finds } = makeHarness([]); - await protocol.getMetaItemLayered({ - type: 'view', name: 'probe', organizationId: gated, - }); - expect(partitions(findOnes)).toEqual([null, ORG]); - // [#21442] `probe` has no row of its own, so the read also selects the - // stored containers that might expand it — from the same partitions. - expect(partitions(finds)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §6 — the two doors that return the `overlay` layer AS the response -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§6 the branch both serving doors read does not fire on a phantom', () => { - /** - * `runtime/src/domains/meta.ts` and `rest-server.ts`'s - * `/meta/:type/:name/published` both spell the same test: - * - * if (layered?.overlay !== undefined && layered?.overlay !== null) - * - * and then return `layered.overlay` as the response body. Both hand this - * method a RAW active organization on a `type` taken off the URL, so - * neither is confined to the overridable five. This asserts the predicate - * they read, on the input that used to make it true wrongly. - */ - const serveBranchFires = (layered: any): boolean => - layered?.overlay !== undefined && layered?.overlay !== null; - - it('does not fire for a non-overridable type whose ONLY row is an org phantom', async () => { - const { protocol } = makeHarness([ - storedRow('object', 'ghost', { organization_id: ORG }), - ]); - const layered = await protocol.getMetaItemLayered({ - type: 'object', name: 'ghost', organizationId: ORG, - }); - expect(serveBranchFires(layered)).toBe(false); - }); - - it('still fires for an overridable type with a real org-scoped publish', async () => { - // #8805's case, which the `/published` door exists to serve: a `view` - // published into the caller's organization must still be served. - const { protocol } = makeHarness([ - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const layered = await protocol.getMetaItemLayered({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(serveBranchFires(layered)).toBe(true); - expect(overlayLabel(layered)).toBe(`${ORG} task_list`); - }); -}); diff --git a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts deleted file mode 100644 index 36b0647d9c7..00000000000 --- a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts +++ /dev/null @@ -1,451 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit d5cbb44f3] `getMetaItem` — the SINGULAR verb — applies the registry read gate - * ITSELF, so a caller cannot spend a raw active organization on a type that has - * no per-org read channel. - * - * ── The defect, and why the singular verb is the sharper half ───────────── - * - * Commit 96326040f moved {@link organizationIdForMetaRead} INSIDE the PLURAL - * verb, `getMetaItems`, and deliberately did not carry to this one. There, the - * two `queryByOrg` reads are UNIONed, so an ungated organization can only ADD - * rows — the resurrection that card is about. Here the two `findOverlay` reads - * combine with `??`, which is PRECEDENCE, so an ungated organization can - * SUBSTITUTE: - * - * const record = (orgId ? await findOverlay(orgId) : undefined) - * ?? await findOverlay(null); - * - * On a type the registry declares `allowOrgOverride: false`, a pre-#6190 - * phantom org-scoped row — the kind `loadMetaFromDb` walks past and - * `reportUnhydratableOrgScopedRows` exists to warn about — was served INSTEAD - * OF the live env-wide document, to a caller that asked for the live one. Not - * an extra row in a list: the served document. §1 is that case, and it is the - * case this whole card exists for. - * - * ── ⛔ §2 is the half that must NOT move ────────────────────────────────── - * - * The card's title names the `??` as precedence-not-union, and that is the - * DEFECT STATEMENT, not a licence to convert the combinator. - * - * ⚠️ Each citation at its real scope. ADR-0005's decision block DOES name this - * method — `RUNTIME READ getMetaItem(type, name)` → `1. sys_metadata … ← - * overlay (wins)`, `2. SchemaRegistry / MetadataService ← artifact default` — - * but the pair it ranks is overlay-vs-artifact-default, not - * org-row-vs-env-wide-row. It settles that an overlay WINS rather than merges. - * The inner precedence rests on two other things: ADR-0005 design principle 3 - * stores the ENTIRE item document per overlay row (so a layering has nothing - * to layer), and the field-level patch model that would have given "layering" - * any meaning was retired and deleted whole under ADR-0049 (ADR-0005 principle 3's correction, commit 9e0ba21a1, - * maintainer ruling 2026-08-29), with ADR-0126 §6 ruling out the phase it was - * held for; and `organizationIdForMetaRead`'s own docblock quotes this very - * expression as the intended shape while defining #9454. ADR-0029 D9 reaches - * the same shape one type over — quoted, not paraphrased: `resolveObject` - * "selects its base layer as `overlay ?? owner` instead of `owner`" — though - * its status line reads "Design only — nothing is implemented yet", so it - * corroborates rather than rules. ⇒ §2 pins precedence for a type that - * legitimately HAS a per-org channel, so a future reading of the title as - * "make it a union" fails a test instead of landing. - * - * ── §3–§5 are the idempotence proof the ruling made this conditional on ─── - * - * Direction "callee-side" was ruled conditional on demonstrating that moving - * the predicate INSIDE does not change the scope any already-gating call site - * receives. §3 discharges `f(t, undefined) === undefined`; §4 discharges - * `f(t, f(t, o)) === f(t, o)` over the COMPLETE population of accepted URL - * spellings rather than a hand-listed sample, so a newly declared type or a - * changed fold cannot slip past it; §5 pins that ONE binding serves BOTH arms - * (the ADR-0033 `previewDrafts` read and the active-overlay read), which is - * the half-fix shape #9454's hoist comment refuses one door over. - * - * ── Why the observation channel is the WHERE multiset ───────────────────── - * - * §4 seeds NO rows and reads the `organization_id` partitions the engine was - * asked for. That is the whole of what this change moves — which partitions - * are read — and observing it directly keeps the assertion body-independent, - * so it covers every declared type instead of the handful with a - * hand-written schema-valid body. §1/§2 pay for that by asserting the real - * SERVED DOCUMENT on both sides of the gate, so neither half rests on a query - * nobody proved returns a row. - */ - -import { describe, expect, it } from 'vitest'; -import { organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; - -interface StoredRow { - id: string; - type: string; - name: string; - organization_id: string | null; - package_id: string | null; - state: string; - metadata: string; -} - -const storedRow = ( - type: string, - name: string, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}_${extra.state ?? 'active'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - // `label` is the observation channel for WHICH row was served — the two - // rows of a pair differ only by scope, so a served label names its origin. - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `findOne` over a row table, plus the registry surface the - * single-item read path touches on its way past the overlay. - * - * `find` is the second verb the read path issues, and only for a `view` name - * with no stored row of its own: the read then selects the stored view - * containers that might expand that name, through the list read's own row - * selection (#21442). It records into `finds` — the same partitions question - * asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a - * double declaring verbs no case exercises would owe - * `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the sibling plural-door pin drives. - */ -function makeHarness(rows: StoredRow[]) { - const findOnes: Array> = []; - const finds: Array> = []; - const matching = (where: Record) => { - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong question - // rather than failing. Refuse the shape this double does not - // implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - }; - const engine: any = { - async findOne(table: string, opts?: { where?: Record }) { - if (table !== 'sys_metadata') return undefined; - const where = opts?.where ?? {}; - findOnes.push({ ...where }); - return matching(where)[0]; - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - engine.find = async (table: string, opts?: { where?: Record }) => { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - return matching(where); - }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, findOnes, finds }; -} - -/** Every `organization_id` partition the engine was asked for, deduplicated. */ -const partitions = (finds: Array>): Array => - [...new Set(finds.map((f) => (f.organization_id ?? null) as string | null))].sort( - (a, b) => String(a).localeCompare(String(b)), - ); - -/** The same, folded per canonical type. */ -function partitionsByType(finds: Array>): Map> { - const out = new Map>(); - for (const f of finds) { - const t = canonicalMetaUrlType(String(f.type)); - if (!out.has(t)) out.set(t, new Set()); - out.get(t)!.add((f.organization_id ?? null) as string | null); - } - return out; -} - -/** The label the served document carries — `'env …'` or `'org_acme …'`. */ -const servedLabel = (res: any): string => res?.item?.label; - -/** - * The COMPLETE accepted-spelling population: every URL spelling the `/meta` - * doors fold, unioned with every registry singular. Derived, never listed — - * a newly declared type arrives in this sweep on its own. - */ -const ALL_SPELLINGS: string[] = [ - ...new Set([ - ...Object.keys(META_URL_TO_SINGULAR), - ...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), - ]), -].sort(); - -/** - * The org-overridable canonical types, derived. Widened to `string[]` - * deliberately: the registry's `type` is a literal union, and the uses below - * ask the question of a type read back OUT of a WHERE clause or a fold — a - * plain `string` by construction. - */ -const OVERRIDABLE: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => e.allowOrgOverride) - .map((e) => e.type); - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the population this rests on, pinned so a registry change is visible -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the org-overridable set', () => { - it('is exactly the ADR-0005 tier-A five', () => { - expect([...OVERRIDABLE].sort()).toEqual( - ['dashboard', 'email_template', 'report', 'translation', 'view'], - ); - }); - - it("`object` — the type the ungated runtime callers hard-code — is NOT overridable", () => { - // The premise of §1. `runtime/src/domains/meta.ts` reaches this verb - // three times, twice hard-coded to `'object'`; a FOURTH raw-org caller - // lives in `runtime/src/domains/packages.ts` (`applyPublishedSeeds`, - // `type: 'seed'`, equally non-overridable). If either type ever flips, - // §1 is measuring something else and must be re-derived. - expect(OVERRIDABLE).not.toContain('object'); - expect(organizationIdForMetaRead('object', ORG)).toBeUndefined(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — ⭐ THE CARD'S CASE: a phantom must not shadow the live env-wide row -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 a pre-#6190 phantom does not become the served document', () => { - const rows = () => [ - // The live env-wide document — what the caller asked for. - storedRow('object', 'showcase_task'), - // The phantom: an org-scoped row of an `allowOrgOverride: false` type. - // `loadMetaFromDb` walks past it at boot, so it is dead — it exists - // only because the runtime used to stamp `organization_id` on every - // type before #6190. - storedRow('object', 'showcase_task', { organization_id: ORG }), - ]; - - it('serves the env-wide row to a caller that passes a RAW active organization', async () => { - const { protocol, findOnes } = makeHarness(rows()); - const res = await protocol.getMetaItem({ - type: 'object', - name: 'showcase_task', - organizationId: ORG, - }); - expect(servedLabel(res)).toBe('env showcase_task'); - // And the phantom's partition was never even read — the gate resolves - // to `undefined`, so the `orgId ? … : undefined` arm is skipped whole. - expect(partitions(findOnes)).toEqual([null]); - }); - - it('answers identically whether or not the caller names the organization', async () => { - const withOrg = makeHarness(rows()); - const withoutOrg = makeHarness(rows()); - const a = await withOrg.protocol.getMetaItem({ - type: 'object', name: 'showcase_task', organizationId: ORG, - }); - const b = await withoutOrg.protocol.getMetaItem({ - type: 'object', name: 'showcase_task', - }); - expect(servedLabel(a)).toBe(servedLabel(b)); - expect(partitions(withOrg.findOnes)).toEqual(partitions(withoutOrg.findOnes)); - }); - - it('holds for every non-overridable declared type, not just `object`', async () => { - const nonOverridable = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => !e.allowOrgOverride) - .map((e) => e.type); - expect(nonOverridable.length).toBeGreaterThan(5); - for (const type of nonOverridable) { - const { protocol, findOnes } = makeHarness([ - storedRow(type, 'probe'), - storedRow(type, 'probe', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ type, name: 'probe', organizationId: ORG }); - expect(servedLabel(res), `${type} served the phantom`).toBe('env probe'); - expect(partitions(findOnes), `${type} read the org partition`).toEqual([null]); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — ⛔ precedence is the RULING: the `??` must survive this fix -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 org-scoped row still WINS where the registry declares a per-org channel', () => { - it('serves the org row, not the env-wide one, for an overridable type', async () => { - // ADR-0005: `1. sys_metadata … ← overlay (wins)`. This is the - // behaviour #9454 exists to make reachable — the author's PUT landed an - // org-scoped row, and a read that names the organization must see it. - const { protocol } = makeHarness([ - storedRow('view', 'task_list'), - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(servedLabel(res)).toBe(`${ORG} task_list`); - }); - - it('is REPLACEMENT, not a merge of the two rows', async () => { - // ⛔ The pin that forbids reading the card's title as a licence to turn - // `??` into a union. The env-wide row carries a key the org row does - // not; a layering would surface it, and ADR-0005 principle 3 (whole - // document per row, patch model retired under ADR-0049) says it must - // not. `getMetaItem` serves ONE document, whole. - const env = storedRow('view', 'task_list'); - env.metadata = JSON.stringify({ name: 'task_list', label: 'env task_list', envOnlyKey: 1 }); - const { protocol } = makeHarness([ - env, - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(servedLabel(res)).toBe(`${ORG} task_list`); - expect(res.item.envOnlyKey).toBeUndefined(); - }); - - it('falls back to the env-wide row when the org has no overlay of its own', async () => { - const { protocol, findOnes } = makeHarness([storedRow('view', 'task_list')]); - const res = await protocol.getMetaItem({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(servedLabel(res)).toBe('env task_list'); - // BOTH partitions read — the org one first, the env-wide one as the - // `??` fallback. That is the precedence chain, intact. (`partitions` - // returns a SORTED set, so this asserts membership, not read order.) - expect(partitions(findOnes)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — idempotence, leg 1: `f(t, undefined) === undefined` -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 a caller that names no organization is untouched', () => { - it('reads only the env-wide partition, for every accepted spelling', async () => { - // The population that covers `import-mapping.ts`, `import-prepare.ts`, - // plugin-email's template read, service-analytics' draft probe and - // plugin-auth's `metaReader` in one statement: none of them passes an - // `organizationId`, and the predicate short-circuits on `undefined` - // before it ever consults the registry flag. - for (const spelling of ALL_SPELLINGS) { - const { protocol, findOnes } = makeHarness([]); - await protocol.getMetaItem({ type: spelling, name: 'probe' }).catch(() => undefined); - for (const [type, parts] of partitionsByType(findOnes)) { - expect([...parts], `${spelling} → ${type}`).toEqual([null]); - } - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §4 — idempotence, leg 2: `f(t, f(t, o)) === f(t, o)`, over the whole -// accepted-spelling population -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§4 an already-gating caller receives the same scope it did before', () => { - it('the predicate is idempotent for every accepted spelling', () => { - // The algebra, stated directly. `f` answers `o` when the registry - // declares the type per-org overridable and `undefined` otherwise, so - // a second application cannot move the answer. - for (const spelling of ALL_SPELLINGS) { - const once = organizationIdForMetaRead(spelling, ORG); - expect(organizationIdForMetaRead(spelling, once), spelling).toBe(once); - } - }); - - it('the REST by-name door gates on the string this method folds to', () => { - // The door computes `organizationIdForMetaRead(canonicalMetaUrlType( - // req.params.type), ctx?.tenantId)` and then passes `type: - // req.params.type` — the RAW segment. The first statement of - // `getMetaItem` folds that segment through `canonicalizeMetaRequestType`, - // which IS `canonicalMetaUrlType`. So the gate inside reads the - // identical STRING the door gated on, and the second application is - // the algebraic no-op above. - // - // ⛔ This is why the gate sits AFTER the fold. `declaresOrgOverride` - // tolerates the MANIFEST plurals and not the URL-only ones - // (`translations` / `email_templates` have no manifest key) — commit 26f3588fb - // measured what that costs when a raw segment reaches the predicate. - for (const spelling of ALL_SPELLINGS) { - const doorGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG); - const innerGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), doorGate); - expect(innerGate, spelling).toBe(doorGate); - } - }); - - it('a gated door reading an overridable type still reaches the org partition', async () => { - // The other direction of the no-op: idempotence must not be achieved by - // denying everyone. A door that already resolved `ORG` for `view` gets - // the org partition read, exactly as before this change. - const gated = organizationIdForMetaRead('view', ORG); - expect(gated).toBe(ORG); - const { protocol, findOnes, finds } = makeHarness([]); - await protocol.getMetaItem({ type: 'view', name: 'probe', organizationId: gated }); - expect(partitions(findOnes)).toEqual([null, ORG]); - // [#21442] `probe` has no row of its own, so the read also selects the - // stored containers that might expand it — from the same partitions. - expect(partitions(finds)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §5 — ONE binding, BOTH arms -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§5 the draft-preview arm gets the same scope as the active read', () => { - it('does not resurrect a phantom DRAFT row for a non-overridable type', async () => { - // ADR-0033 `previewDrafts` runs its own `findDraft` pair off the SAME - // `orgId` binding. A gate threaded into only the active arm would leave - // this one serving exactly the phantom the active read had just stopped - // serving — the half-fix #9454's hoist comment refuses. - const { protocol, findOnes } = makeHarness([ - storedRow('object', 'showcase_task', { state: 'draft' }), - storedRow('object', 'showcase_task', { state: 'draft', organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'object', - name: 'showcase_task', - organizationId: ORG, - previewDrafts: true, - }); - expect(servedLabel(res)).toBe('env showcase_task'); - expect(partitions(findOnes)).toEqual([null]); - }); - - it('still prefers the org DRAFT row for an overridable type', async () => { - const { protocol } = makeHarness([ - storedRow('view', 'task_list', { state: 'draft' }), - storedRow('view', 'task_list', { state: 'draft', organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'view', - name: 'task_list', - organizationId: ORG, - previewDrafts: true, - }); - expect(servedLabel(res)).toBe(`${ORG} task_list`); - }); -}); diff --git a/packages/metadata-protocol/src/get-meta-items-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-items-org-read-gate.test.ts deleted file mode 100644 index c038a9dad90..00000000000 --- a/packages/metadata-protocol/src/get-meta-items-org-read-gate.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 96326040f] `getMetaItems` applies the registry read gate ITSELF, so a sweep - * that reads MORE THAN ONE type per request is scoped per type instead of per - * request. - * - * ── The defect ──────────────────────────────────────────────────────────── - * - * `getMetaItems` used to apply no gate at all: whatever `organizationId` - * arrived was spent on whatever `type` arrived. The scope of a metadata sweep - * was therefore decided per type BY THE CALLER — which a request carrying ONE - * organization can only get right when it sweeps ONE type. Three live callers - * sweep more: - * - * • `getMetaDiagnostics` with no `type` — `targetTypes` is the whole - * registry, five `allowOrgOverride: true` types and every other declared - * type together, under one request-level organization. - * • `findReferencesToMeta` — `request.type` is the TARGET; the organization - * is spent on `matcher.fromType`, the SOURCES, so the target's own flag - * says nothing about the types actually read. - * • the runtime's package export sweep (`runtime/src/domains/packages.ts`, - * `assemblePackageManifest`) — every plural key of `PLURAL_TO_SINGULAR`, - * one raw active organization. - * - * ── ⭐ The harm class is RESURRECTION, not concealment ──────────────────── - * - * Stated because the sibling card was filed against the opposite premise and a - * fix aimed at concealment would be aiming at the wrong failure. - * `SysMetadataRepository.history()` filters `organization_id` by strict - * equality, so naming the tenant THERE hides an `allowOrgOverride: false` - * type's rows. On THIS path the two `queryByOrg` reads are UNIONed, so naming - * it can only ADD rows — and the rows it adds are the pre-#6190 phantoms: - * org-scoped rows of types with no per-org read channel, which `loadMetaFromDb` - * walks past and `reportUnhydratableOrgScopedRows` exists to warn about. They - * come back inside the admin "Used by" panel and the Studio governance - * directory — a clearance rendered before a destructive action, where a - * resurrected row is worse than an omission because it reads as evidence. - * - * ── ⭐ §3 is the IDEMPOTENCE PROOF the ruling made this change conditional on - * - * Direction A was ruled conditional on demonstrating that moving the predicate - * INSIDE does not CHANGE the scope any already-gating call site receives. §3 - * discharges that mechanically rather than by argument, over the COMPLETE - * population of accepted URL spellings (`META_URL_TO_SINGULAR`, unioned with - * every registry singular) rather than over a hand-listed sample — a new type - * or a changed fold cannot slip past it. - * - * ── Why the observation channel is the WHERE multiset ───────────────────── - * - * §3–§5 seed NO rows and read the `organization_id` partitions the engine was - * asked for. That is the whole of what this change moves — which partitions - * are read — and observing it directly keeps the sweep body-independent, so - * the pin covers every declared type instead of the handful with a - * hand-written schema-valid body. §1/§2 pay for that by asserting real merged - * items on both sides of the gate, so neither half rests on a query nobody - * proved returns rows. - */ - -import { describe, expect, it } from 'vitest'; -import { organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; - -interface StoredRow { - id: string; - type: string; - name: string; - organization_id: string | null; - package_id: string | null; - state: string; - metadata: string; -} - -const storedRow = ( - type: string, - name: string, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}_${extra.state ?? 'active'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `find` over a row table, plus the registry surface the - * overlay path touches. - * - * ⛔ No `findOne` / `insert` / `update` / `delete`, deliberately — the read - * path under test issues exactly one verb, and a double declaring verbs no - * case exercises would owe `check:engine-double-contract` a dispatch contract - * that protects nothing. Same shape `meta-overlay-cache.test.ts` drives. - */ -function makeHarness(rows: StoredRow[]) { - const finds: Array> = []; - const engine: any = { - async find(table: string, opts?: { where?: Record; limit?: number }) { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong - // question rather than failing. Refuse the shape this double does - // not implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - const matched = rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - // `check:objectql-double-limit` (commit 4c9780c7a) — hold the caller's bound, - // applied AFTER the filter and BY PRESENCE. - return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, finds }; -} - -/** Every `organization_id` partition the engine was asked for, deduplicated. */ -const partitions = (finds: Array>): Array => - [...new Set(finds.map((f) => (f.organization_id ?? null) as string | null))].sort( - (a, b) => String(a).localeCompare(String(b)), - ); - -/** The same, folded per canonical type — for the multi-type sweeps. */ -function partitionsByType(finds: Array>): Map> { - const out = new Map>(); - for (const f of finds) { - const t = canonicalMetaUrlType(String(f.type)); - if (!out.has(t)) out.set(t, new Set()); - out.get(t)!.add((f.organization_id ?? null) as string | null); - } - return out; -} - -const names = (res: any): string[] => - (Array.isArray(res) ? res : res.items).map((i: any) => i.name).sort(); - -const labels = (res: any): Record => - Object.fromEntries( - (Array.isArray(res) ? res : res.items).map((i: any) => [i.name, i.label]), - ); - -/** - * The COMPLETE accepted-spelling population: every URL spelling the `/meta` - * doors fold, unioned with every registry singular. Derived, never listed — - * a newly declared type arrives in this sweep on its own. - */ -const ALL_SPELLINGS: string[] = [ - ...new Set([ - ...Object.keys(META_URL_TO_SINGULAR), - ...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), - ]), -].sort(); - -/** - * The org-overridable canonical types, derived. Widened to `string[]` - * deliberately: the registry's `type` is a literal union, and every use below - * asks the question of a type read back OUT of a WHERE clause or a fold — - * a plain `string` by construction. Narrowing the probe to the union would - * make `includes` a compile error against exactly the values this file exists - * to judge. - */ -const OVERRIDABLE: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => e.allowOrgOverride) - .map((e) => e.type); - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the population this rests on, pinned so a registry change is visible -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the org-overridable set', () => { - it('is exactly the ADR-0005 tier-A five', () => { - expect([...OVERRIDABLE].sort()).toEqual( - ['dashboard', 'email_template', 'report', 'translation', 'view'], - ); - }); - - it('covers every accepted spelling — the sweep below is not a sample', () => { - expect(ALL_SPELLINGS.length).toBeGreaterThan(DEFAULT_METADATA_TYPE_REGISTRY.length); - for (const s of ALL_SPELLINGS) expect(typeof canonicalMetaUrlType(s)).toBe('string'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — the gate, in BOTH directions, on real merged items -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 a raw active organization is gated per type', () => { - it('a NON-overridable type does not resurrect its org-scoped phantom rows', async () => { - const h = makeHarness([ - storedRow('object', 'task'), - storedRow('object', 'task', { organization_id: ORG }), - storedRow('object', 'phantom_only', { organization_id: ORG }), - ]); - - const res = await h.protocol.getMetaItems({ type: 'object', organizationId: ORG }); - - // The org partition is never asked for at all — not asked and - // discarded, which would still pay for the read and still put the row - // one refactor away from the answer. - expect(partitions(h.finds)).toEqual([null]); - expect(names(res)).toEqual(['task']); - expect(labels(res).task).toBe('env task'); - }); - - it('an OVERRIDABLE type still unions both partitions, org winning on collision', async () => { - const h = makeHarness([ - storedRow('view', 'tasks_list'), - storedRow('view', 'tasks_list', { organization_id: ORG }), - storedRow('view', 'org_only', { organization_id: ORG }), - ]); - - const res = await h.protocol.getMetaItems({ type: 'view', organizationId: ORG }); - - expect(partitions(h.finds)).toEqual([null, ORG]); - expect(names(res)).toEqual(['org_only', 'tasks_list']); - expect(labels(res).tasks_list).toBe(`${ORG} tasks_list`); - }); - - it('an org-less caller reads exactly what it read before, either way', async () => { - const h = makeHarness([ - storedRow('view', 'tasks_list'), - storedRow('view', 'org_only', { organization_id: ORG }), - ]); - - const res = await h.protocol.getMetaItems({ type: 'view' }); - - expect(partitions(h.finds)).toEqual([null]); - expect(names(res)).toEqual(['tasks_list']); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — the `previewDrafts` arm spends the SAME resolution -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 the draft-preview arm carries the same gate', () => { - it('does not preview an org-scoped DRAFT of a non-overridable type', async () => { - const h = makeHarness([ - storedRow('object', 'task'), - storedRow('object', 'task', { organization_id: ORG, state: 'draft' }), - storedRow('object', 'draft_phantom', { organization_id: ORG, state: 'draft' }), - ]); - - const res = await h.protocol.getMetaItems({ - type: 'object', - organizationId: ORG, - previewDrafts: true, - }); - - expect(partitions(h.finds)).toEqual([null]); - expect(names(res)).toEqual(['task']); - }); - - it('still previews an org-scoped DRAFT of an overridable type', async () => { - const h = makeHarness([ - storedRow('view', 'tasks_list'), - storedRow('view', 'org_draft', { organization_id: ORG, state: 'draft' }), - ]); - - const res = await h.protocol.getMetaItems({ - type: 'view', - organizationId: ORG, - previewDrafts: true, - }); - - expect(partitions(h.finds)).toEqual([null, ORG]); - expect(names(res)).toContain('org_draft'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — ⭐ THE IDEMPOTENCE PROOF, over the complete spelling population -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 an already-gating call site receives unchanged read scope', () => { - /** - * The algebra, stated first because §3's behavioural half only measures - * that the implementation obeys it: `organizationIdForMetaRead` answers - * either its argument or `undefined`, so a second application over the - * SAME type is a no-op. The load-bearing half is "the same type" — which - * §3b measures. - */ - it.each(ALL_SPELLINGS)('f(t, f(t, org)) === f(t, org) for spelling %s', (spelling) => { - const t = canonicalMetaUrlType(spelling); - const once = organizationIdForMetaRead(t, ORG); - expect(organizationIdForMetaRead(t, once)).toBe(once); - }); - - /** - * §3b — the type this method gates on is the type the door gated on. - * - * The REST `GET /meta/:type` list door computes - * `organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), …)` - * and then passes the RAW segment as `type`. Reproduced here for every - * accepted spelling: the partitions read must equal the partitions the - * pre-change implementation read for that same argument — which it spent - * verbatim, so `expected` below IS the old behaviour, written out. - */ - it.each(ALL_SPELLINGS)('gated caller: read scope unchanged for spelling %s', async (spelling) => { - const gated = organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG); - const h = makeHarness([]); - - await h.protocol.getMetaItems({ - type: spelling, - ...(gated ? { organizationId: gated } : {}), - }); - - // Pre-change semantics, verbatim: `queryByOrg(null)` always, plus - // `queryByOrg(orgId)` iff the caller named one. - const expected: Array = gated ? [null, gated] : [null]; - expect(partitions(h.finds)).toEqual(expected.sort((a, b) => String(a).localeCompare(String(b)))); - }); - - /** - * §3c — the complement: an UNGATED caller IS narrowed, which is the - * repair. Without this the suite would pass on an implementation that - * changed nothing at all. - */ - it.each(ALL_SPELLINGS)('ungated caller: raw org is gated for spelling %s', async (spelling) => { - const canonical = canonicalMetaUrlType(spelling); - const overridable = OVERRIDABLE.includes(canonical); - const h = makeHarness([]); - - await h.protocol.getMetaItems({ type: spelling, organizationId: ORG }); - - expect(partitions(h.finds)).toEqual(overridable ? [null, ORG] : [null]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §4 — `getMetaDiagnostics`, the whole-registry sweep (door 1 on the card) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§4 GET /meta/diagnostics with no ?type= is scoped per swept type', () => { - it('names the organization only for the types whose registry entry allows it', async () => { - const h = makeHarness([]); - - await h.protocol.getMetaDiagnostics({ organizationId: ORG }); - - const byType = partitionsByType(h.finds); - expect(byType.size).toBeGreaterThan(1); // it really did sweep more than one type - - for (const [type, parts] of byType) { - expect(parts.has(null)).toBe(true); - expect(parts.has(ORG)).toBe(OVERRIDABLE.includes(type)); - } - // …and the sweep genuinely reached at least one of each kind, so the - // loop above is not vacuously true on a single-kind sweep. - expect([...byType.keys()].some((t) => OVERRIDABLE.includes(t))).toBe(true); - expect([...byType.keys()].some((t) => !OVERRIDABLE.includes(t))).toBe(true); - }); - - it('the ?type= arm is unchanged — one type, one organization, still correct', async () => { - for (const type of ['view', 'object']) { - const h = makeHarness([]); - // What the REST door passes once it gates: the predicate's answer. - const gated = organizationIdForMetaRead(type, ORG); - await h.protocol.getMetaDiagnostics({ type, ...(gated ? { organizationId: gated } : {}) }); - expect(partitions(h.finds)).toEqual(gated ? [null, ORG] : [null]); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §5 — `findReferencesToMeta`, where the org is spent on the SOURCES -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§5 GET /meta/:type/:name/references is scoped per SOURCE type', () => { - it('gates each matcher.fromType on its own flag, not on the target’s', async () => { - const h = makeHarness([]); - - // Target `object` is NON-overridable; its sources include `view`, - // which IS. Gating on the target would have answered env-wide for the - // org-scoped views this panel exists to find. - await h.protocol.findReferencesToMeta({ type: 'object', name: 'task', organizationId: ORG }); - - const byType = partitionsByType(h.finds); - expect(byType.size).toBeGreaterThan(1); - for (const [type, parts] of byType) { - expect(parts.has(null)).toBe(true); - expect(parts.has(ORG)).toBe(OVERRIDABLE.includes(type)); - } - // The load-bearing source: an org-overridable type really is among the - // sources of `object`, so the `true` branch above is exercised. - expect([...byType.keys()].some((t) => OVERRIDABLE.includes(t))).toBe(true); - }); - - it('an org-scoped view referencing the target is FOUND — the false clearance closes', async () => { - const h = makeHarness([ - storedRow('view', 'org_tasks', { - organization_id: ORG, - metadata: JSON.stringify({ name: 'org_tasks', label: 'Org tasks', object: 'task' }), - }), - ]); - - const res = await h.protocol.findReferencesToMeta({ - type: 'object', - name: 'task', - organizationId: ORG, - }); - - expect(res.references.map((r: any) => `${r.type}/${r.name}`)).toContain('view/org_tasks'); - }); -}); diff --git a/packages/metadata-protocol/src/meta-overlay-cache.test.ts b/packages/metadata-protocol/src/meta-overlay-cache.test.ts index 8e9cb007754..b9da8787201 100644 --- a/packages/metadata-protocol/src/meta-overlay-cache.test.ts +++ b/packages/metadata-protocol/src/meta-overlay-cache.test.ts @@ -578,25 +578,13 @@ describe('[#11967] §7 distinct reads never share an entry', () => { expect((scoped.items as any[]).map((i) => i.name)).toEqual(['beta']); }); - // ⚠️ [commit 96326040f] `view`, NOT `object`, and the type is LOAD-BEARING here in a - // way it is not in this section's three siblings. `getMetaItems` now - // resolves its own read scope through `organizationIdForMetaRead`, so a - // type the registry declares NON-overridable has exactly one partition to - // read: an `organizationId` handed in for `object` is gated to `undefined` - // before it ever reaches the cache key, and the two reads below would - // legitimately share one entry — this case would then be asserting that a - // key separation exists where the platform deliberately has none. - // - // The INVARIANT is unchanged and is what this case still pins: two reads - // at genuinely different org scopes must not answer from each other. It - // just has to be exercised on a type that HAS two org scopes, which since - // ADR-0005 tier A means one of `view` / `dashboard` / `report` / - // `translation` / `email_template`. See - // `get-meta-items-org-read-gate.test.ts` for the gate itself and for the - // `object` side of this — that an org-scoped read of a non-overridable - // type reads the env partition alone is pinned there, as behaviour rather - // than as a cache-key fact. - it('an org-scoped read does not answer from the env-wide entry', async () => { + // [ADR-0131 D6] The cache holds the environment's rows only. The one read + // that still reaches a legacy organization's rows — the anonymous form + // doors' `view` read, `legacyFormOrganizationId` — reads them uncached, + // on top of the environment's cached set, so it never answers from the + // environment-wide entry alone; and any other read naming an organization + // is the environment read. + it('the form doors\' legacy organization read does not answer from the env-wide entry', async () => { const h = makeHarness([ storedRow('view', 'alpha'), storedRow('view', 'org_only', { organization_id: 'org_1', id: 'r_view_org' }), @@ -605,14 +593,17 @@ describe('[#11967] §7 distinct reads never share an entry', () => { const envWide = await h.protocol.getMetaItems({ type: 'view' }); const findsAfterEnvWide = h.finds.length; - const orgScoped = await h.protocol.getMetaItems({ type: 'view', organizationId: 'org_1' }); + const legacy = await h.protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_1' }); expect(h.finds.length).toBeGreaterThan(findsAfterEnvWide); expect((envWide.items as any[]).map((i) => i.name)).toEqual(['alpha']); - expect((orgScoped.items as any[]).map((i) => i.name).sort()) - .toEqual(['alpha', 'org_only']); + expect((legacy.items as any[]).map((i) => i.name).sort()).toEqual(['alpha', 'org_only']); + + const named = await h.protocol.getMetaItems({ type: 'view', organizationId: 'org_1' } as { type: string }); + expect((named.items as any[]).map((i) => i.name)).toEqual(['alpha']); }); + it('two engines never share a bucket', async () => { const a = makeHarness([storedRow('object', 'from_engine_a')]); const b = makeHarness([storedRow('object', 'from_engine_b')]); diff --git a/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts b/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts index 053a722e5ed..57230b6f3b7 100644 --- a/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts @@ -1,32 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #8747 — `auditMetaItem` declared `organizationId?: string | null`, never read -// it, and carried a comment describing the org filter it would have built: +// #8747 — `auditMetaItem` once declared an organization, never read it, and +// returned every organization's rows. The scope is BUILT in the method, +// unconditionally: nothing below it scopes the read. // -// "Org-scoped lookup: include rows for the specific org AND env-wide -// (organization_id IS NULL) rows so the editor sees both tenant overlays -// and env-level package writes." -// -// The `where` underneath was exactly `{ type, name }`. Measured consequence: one -// read returned three organizations' rows, disclosing another tenant's `actor`, -// `note` and `lock_state` through a GA endpoint that carries no capability gate. +// [ADR-0131 D6] The read is the environment's, for every caller: the +// `organizationId` request key retired with the per-organization overlay axis, +// so the scope is `organization_id IS NULL` and nothing else. A legacy +// organization-scoped audit row is reported at boot, never read. // // This file pins the QUERY SHAPE, next to the code that builds it. The -// behavioural half — that the shape actually SELECTS the right rows through a -// real SQL driver, in both directions — is pinned by -// `packages/runtime/src/audit-meta-item-org-scope.integration.test.ts`, which -// needs a real driver this package does not depend on. Neither half is -// sufficient alone: a shape assertion cannot tell a correct filter from one -// that hides everything, and a row assertion cannot tell which spelling -// produced it. -// -// The test names below restate the comment's two claims deliberately. That is -// the "comment now describes behaviour that exists" pin the ruling asks for: -// each claim is an assertion, so the comment cannot drift back into -// over-claiming without a red test. +// behavioural half — that the shape SELECTS the right rows through a real SQL +// driver — is pinned by +// `packages/runtime/src/audit-meta-item-org-scope.integration.test.ts`. import { describe, it, expect, vi } from 'vitest'; -import { readFileSync } from 'node:fs'; import { ObjectStackProtocolImplementation } from './protocol.js'; /** A `find` that records its options and returns nothing. */ @@ -39,97 +27,21 @@ function makeProtocol() { /** The options the protocol handed to `engine.find` on its first call. */ const whereFrom = (find: any) => find.mock.calls[0][1].where; -const ORG = 'org_alpha'; - -describe('#8747 auditMetaItem builds the org scope its comment describes', () => { - it('claim 1 + 2: rows for the specific org AND env-wide (organization_id IS NULL) rows', async () => { - const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: ORG }); - - const where = whereFrom(find); - // Both limbs, in one `$or`. The env-wide limb is not optional: the REST - // `PUT /meta` door writes rows with `organization_id: null`, so an - // equality-only filter would blank the audit tab on those deployments. - expect(where.$or).toEqual([ - { organization_id: ORG }, - { organization_id: null }, - ]); - }); - - it('does not ALSO constrain organization_id at the top level (which would AND away the env-wide limb)', async () => { - const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: ORG }); - - // A top-level `organization_id` alongside the `$or` would re-narrow the - // query to the equality and silently undo the env-wide half — the exact - // "looks scoped, hides everything" shape this card warns about. - expect(whereFrom(find)).not.toHaveProperty('organization_id'); - }); - - it('still keys on (type, name), with the plural folded to singular', async () => { +describe('#8747 · ADR-0131 D6 auditMetaItem reads the environment\'s audit trail only', () => { + it('keys on (type, name), the plural folded to singular, and scopes organization_id IS NULL', async () => { const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: ORG }); - - const where = whereFrom(find); - expect(where.type).toBe('view'); - expect(where.name).toBe('shared_grid'); - }); - - it('an OMITTED organizationId is fail-closed: env-wide rows only, never unscoped', async () => { - const { p, find } = makeProtocol(); - // This is the exact call the production route made before the fix. await p.auditMetaItem({ type: 'views', name: 'shared_grid' }); - const where = whereFrom(find); - expect(where.organization_id).toBe(null); - // The absence of `$or` here is the point: there is no organization to - // widen to, so the read must not widen at all. - expect(where).not.toHaveProperty('$or'); + expect(whereFrom(find)).toEqual({ type: 'view', name: 'shared_grid', organization_id: null }); }); - it('an explicit null organizationId reads env-wide, identically to omitting it', async () => { - const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: null }); - - const where = whereFrom(find); - expect(where.organization_id).toBe(null); - expect(where).not.toHaveProperty('$or'); - }); - - it('the parameter is READ — no call shape leaves the query without an organization term', async () => { - // The defect in one assertion: `organizationId` was inert, so every - // spelling produced the same unscoped `where`. Each spelling must now - // constrain `organization_id` one way or the other. - for (const request of [ - { type: 'views', name: 'shared_grid' }, - { type: 'views', name: 'shared_grid', organizationId: null }, - { type: 'views', name: 'shared_grid', organizationId: ORG }, - { type: 'view', name: 'shared_grid', organizationId: ORG, limit: 5 }, - ] as any[]) { + it('a caller still naming an organization changes nothing: the retired key is not read', async () => { + for (const organizationId of ['org_alpha', null]) { const { p, find } = makeProtocol(); - await p.auditMetaItem(request); + await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId } as any); const where = whereFrom(find); - const scoped = where.$or !== undefined || 'organization_id' in where; - expect(scoped, `unscoped where for ${JSON.stringify(request)}`).toBe(true); + expect(where.organization_id, String(organizationId)).toBe(null); + expect(where, String(organizationId)).not.toHaveProperty('$or'); } }); }); - -describe('#8747 the comment and the code cannot drift apart again', () => { - it('the method that claims an org-scoped lookup is the method that builds one', () => { - const source = readFileSync(new URL('./protocol.ts', import.meta.url), 'utf8'); - const start = source.indexOf('async auditMetaItem('); - expect(start, 'auditMetaItem not found').toBeGreaterThan(-1); - // Slice to the end of the method — the next sibling member declaration. - const rest = source.slice(start); - const end = rest.indexOf('\n async ', 1); - const body = end === -1 ? rest : rest.slice(0, end); - - // The comment makes two claims. Both must be backed by code IN THE SAME - // METHOD. This is what went wrong: the prose survived, the query did - // not, and nothing failed. - expect(body).toContain('organization_id IS NULL'); - expect(body).toContain('$or'); - expect(body).toContain('request.organizationId'); - }); -}); diff --git a/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts b/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts index 8ff5830e78b..13d89bbf2cd 100644 --- a/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts +++ b/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts @@ -265,12 +265,10 @@ describe('[#7774] the unscoped /meta list keeps every i18n bundle member', () => expect(byPkg['com.acme.hr'].locale).toBe('zh-CN'); }); - it('keys an env-wide row and an org row of DIFFERENT locales as different slots', async () => { - // The store's unique index is `(type, name, organization_id, - // package_id)`, so an org cannot hold two rows differing only by - // body locale — but the env-wide tier and the org tier can, and - // keying them together made the org's row displace the env-wide - // one. Precedence within a member is unchanged; see the next case. + it('[ADR-0131 D6] a legacy organization row of a bundle member is served by no list read, whatever organization the caller names', async () => { + // The env-wide row customizes en-US; a legacy organization row + // customizes zh-CN. The organization row is not read: zh-CN is the + // registry's member, en-US the env-wide row. const { engine } = makeEngine({ items: [ { __type: 'email_template', ...tpl('auth.welcome', 'en-US'), _packageId: PKG }, @@ -291,36 +289,13 @@ describe('[#7774] the unscoped /meta list keeps every i18n bundle member', () => }); const protocol = new ObjectStackProtocolImplementation(engine); - const res = await protocol.getMetaItems({ type: 'email_template', organizationId: 'org_1' }); - const byLocale = Object.fromEntries((res.items as any[]).map((i) => [i.locale, i])); - expect(Object.keys(byLocale).sort()).toEqual(['en-US', 'zh-CN']); - expect(byLocale['en-US'].subject).toBe('ENV en-US'); - expect(byLocale['zh-CN'].subject).toBe('ORG zh-CN'); - }); - - it('still lets an org row override the env-wide row of the SAME locale', async () => { - // ADR-0005 org-over-env precedence, unchanged where it was ever - // meaningful: two rows of one member still resolve to one row. - const { engine } = makeEngine({ - items: [{ __type: 'email_template', ...tpl('auth.welcome', 'en-US'), _packageId: PKG }], - rows: [ - row({ - type: 'email_template', name: 'auth.welcome', package_id: PKG, - organization_id: null, - metadata: tpl('auth.welcome', 'en-US', { subject: 'ENV en-US' }), - }), - row({ - type: 'email_template', name: 'auth.welcome', package_id: PKG, - organization_id: 'org_1', - metadata: tpl('auth.welcome', 'en-US', { subject: 'ORG en-US' }), - }), - ], - }); - const protocol = new ObjectStackProtocolImplementation(engine); - - const res = await protocol.getMetaItems({ type: 'email_template', organizationId: 'org_1' }); - expect((res.items as any[]).length).toBe(1); - expect((res.items as any[])[0].subject).toBe('ORG en-US'); + for (const request of [{ type: 'email_template' }, { type: 'email_template', organizationId: 'org_1' }]) { + const res = await protocol.getMetaItems(request as { type: string }); + const byLocale = Object.fromEntries((res.items as any[]).map((i) => [i.locale, i])); + expect(Object.keys(byLocale).sort()).toEqual(['en-US', 'zh-CN']); + expect(byLocale['en-US'].subject).toBe('ENV en-US'); + expect(byLocale['zh-CN'].subject).toBe('Subject zh-CN'); + } }); it('treats a member that declares no locale as the canonical member', async () => { diff --git a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts index ff1b3528659..6a31ca7ecbb 100644 --- a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts +++ b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts @@ -15,8 +15,9 @@ * the by-name read (organization first, ADR-0005) never does. * * Both now take one order, `servedOverlayRowCandidates` in `protocol.ts`: - * scope (the organization's rows, then env-wide), then spelling, then the - * package's own row before the package-less row. `findServedOverlayRow` reads + * scope (the environment's rows — since ADR-0131 D6 no read reaches a legacy + * organization row), then spelling, then the package's own row before the + * package-less row. `findServedOverlayRow` reads * it from the store; the list reads it over the rows it already holds. * * 1. The generated pin: every subset of five stored rows (env-wide and @@ -27,9 +28,9 @@ * that package serves the same row. * 2. Named: package A's row and the package-less row, both env-wide, both * orders, with and without A's packaged artifact. The slot is A's row. - * 3. Named, organization scope: the organization's package-less row beats an - * env-wide row of A; the organization's row of A beats the env-wide - * package-less row. Both orders. + * 3. Named, a request from an organization (ADR-0131 D6): the + * organization's legacy rows are served by no read; the environment's + * rows are. Both orders. * 4. Control: only the package-less row is stored, under A's artifact. The * slot still falls back to it (stamped as A's), as the by-name read does. * 5. The draft preview: A's draft and a package-less draft, both orders. The @@ -163,13 +164,13 @@ function subsets(xs: readonly T[]): T[][] { } /** - * The oracle, written from the rule: the scopes the request reaches - * (ADR-0005: the organization's, then env-wide), and within one scope the - * package's own row before the package-less row (ADR-0048), never another - * package's. The first row found is the served row. + * The oracle, written from the rule: the environment's rows (ADR-0131 D6 — + * the request's organization reaches no row), the package's own row before + * the package-less row (ADR-0048), never another package's. The first row + * found is the served row. */ -function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: string | undefined): string | undefined { - const scopes: Scope[] = organizationId ? ['org-scoped', 'env-wide'] : ['env-wide']; +function servedLabel(rows: readonly RowKey[], packageId: string, _organizationId: string | undefined): string | undefined { + const scopes: Scope[] = ['env-wide']; for (const scope of scopes) { for (const candidate of [packageId, null]) { const hit = rows.find((key) => ROWS[key].scope === scope && ROWS[key].packageId === candidate); @@ -227,7 +228,7 @@ describe('pin 1 (generated) — every package slot in the list serves the row ge for (const packageId of [PKG_A, PKG_B]) { const expected = servedLabel(order, packageId, organizationId); const inScope = order.some((key) => ROWS[key].packageId === packageId - && (ROWS[key].scope === 'env-wide' || organizationId !== undefined)); + && ROWS[key].scope === 'env-wide'); const slots = await listSlots(protocol, packageId, scope); if (!inScope) { // No row of the package in scope and no artifact: the @@ -265,21 +266,21 @@ describe('pin 2 — the package\'s row beside the package-less row (one scope, e } }); -describe('pin 3 — organization scope (ADR-0005): the organization\'s rows before the env-wide rows, then the package\'s own row', () => { +describe('pin 3 — [ADR-0131 D6] a request from an organization: its legacy rows are served by no read', () => { for (const order of orders(['orgPackageless', 'envA', 'envPackageless'])) { - it(`the organization's package-less row over an env-wide row of A · row order ${order.join(', ')}`, async () => { + it(`the organization's legacy package-less row is not served over an env-wide row of A · row order ${order.join(', ')}`, async () => { const protocol = harness(order.map((key) => stored(key))); const slots = await listSlots(protocol, PKG_A, { organizationId: ORG }); - expect(slots.map((s) => s.label)).toEqual([ROWS.orgPackageless.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgPackageless.label); + expect(slots.map((s) => s.label)).toEqual([ROWS.envA.label]); + expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.envA.label); }); } for (const order of orders(['orgA', 'envPackageless'])) { - it(`the organization's row of A over the env-wide package-less row · row order ${order.join(', ')}`, async () => { + it(`the organization's legacy row of A is not served; A has no slot, and by name A is served the env-wide package-less row · row order ${order.join(', ')}`, async () => { const protocol = harness(order.map((key) => stored(key))); const slots = await listSlots(protocol, PKG_A, { organizationId: ORG }); - expect(slots.map((s) => s.label)).toEqual([ROWS.orgA.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgA.label); + expect(slots).toEqual([]); + expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.envPackageless.label); }); } }); diff --git a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts index d8edadf3546..c0b6f85492d 100644 --- a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts @@ -240,7 +240,6 @@ const POSITIONS: ReadonlyArray<{ const ADDRESS_AXES: { readonly [F in ItemAddressField]: AxisName | 'one item' } = { type: 'requestSpelling', name: 'one item', - organizationId: 'requestScope', packageId: 'requestPackage', }; @@ -386,8 +385,8 @@ const declaredLockOf = (a: ArtifactAxis): Lock => (a.artifact === 'declared' ? a * - `artifact`: [#21803] one lock per installed package that ships the name * (the package's own, another package's), `'none'` for an artifact that * declares no lock; no entry for a package that ships nothing. - * - `overlay`: the strictest lock among the rows in scope (ADR-0005: the - * organization's rows when it holds any, else the env-wide rows; any + * - `overlay`: the strictest lock among the rows in scope (ADR-0131 D6: the + * environment's rows, whatever organization the request comes from; any * package). The door differs on exactly one input: it does not see a row * stored under the other spelling, which the reads see only when no * canonical row is in that scope. The request's package selects nothing. @@ -398,7 +397,7 @@ const DECLARED: { } = { artifact: (row) => [row.artifact, row.otherArtifact].filter(present).map(declaredLockOf), overlay: (row, side) => { - const scopes: Array = row.requestScope === ORG ? ['org-scoped', 'env-wide'] : ['env-wide']; + const scopes: Array = ['env-wide']; for (const scope of scopes) { const inScope = oracleRows(row).filter((r) => r.scope === scope); let visible = inScope.filter((r) => r.spelling === 'canonical'); @@ -625,9 +624,11 @@ async function door( /** Both reads' envelopes for one request; they must agree with each other first. */ async function envelope( - protocol: ObjectStackProtocolImplementation, type: string, organizationId?: string, name = NAME, packageId?: string, + // [ADR-0131 D6] The caller's organization reaches no read: the reads take + // none, so the request's organization changes nothing here. + protocol: ObjectStackProtocolImplementation, type: string, _callerOrganization?: string, name = NAME, packageId?: string, ) { - const scope = { ...(organizationId ? { organizationId } : {}), ...(packageId ? { packageId } : {}) }; + const scope = { ...(packageId ? { packageId } : {}) }; const byName: any = await protocol.getMetaItem({ type, name, ...scope }); const layered: any = await protocol.getMetaItemLayered({ type, name, ...scope }); const pick = (r: any) => ({ lock: r.lock, editable: r.editable, deletable: r.deletable }); @@ -841,12 +842,13 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t expect(locks.some((l) => l.row.artifact.artifact === 'no _lock' && l.row.otherArtifact.artifact === 'declared' && l.row.otherArtifact.lock === 'no-delete' && l.row.storedRow.row === 'stored' && l.row.storedRow.lock === 'no-overlay' && l.door === 'full')).toBe(true); - // [#21803] The folded position: the organization's only row is another - // package's, and the request names the package, whose env-wide row is - // what it is served, with a lock the organization's rows do not declare. + // [#21803, ADR-0131 D6] The folded position is closed: the + // organization's only row (another package's) is in no read's scope, + // so the env-wide package row's 'full' binds the request naming the + // package, as it binds one naming no organization. expect(locks.some((l) => l.row.otherPackageRow.row === 'stored' && l.row.otherPackageRow.lock === 'none' && l.row.packageRow.row === 'stored' && l.row.packageRow.lock === 'full' - && l.row.requestScope === ORG && l.row.requestPackage === PACKAGE_ID && l.reads === 'none')).toBe(true); + && l.row.requestScope === ORG && l.row.requestPackage === PACKAGE_ID && l.reads === 'full')).toBe(true); }); it('lit control: an org-scoped request is served the env-wide row and reads its lock; an org-scoped row is never served to a request naming none', async () => { @@ -1081,14 +1083,14 @@ describe('[#21761] pin 7 — a third package\'s row is in scope: no write the ga }); } - it('an organization holding only another package\'s row: its rows are the reads\' scope, and its writes are refused', async () => { + it('[ADR-0131 D6] an organization holding only another package\'s row: that legacy row is in no read\'s scope, and its writes are refused', async () => { const protocol = harness(ENV_ID, [ storedRow('view', ORG, 'full', 'org row of the other package', 'v_org', OTHER), storedRow('view', null, 'none', 'env-wide package row', 'v_org', PACKAGE_ID), ]); const read = await envelope(protocol, 'view', ORG, 'v_org', PACKAGE_ID); expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) - .toEqual({ lock: 'full', editable: false, deletable: false }); + .toEqual({ lock: 'none', editable: true, deletable: true }); // Content stays prefer-local: the organization holds no row of the // package or package-less, so the env-wide package row is served. expect(read.byName.item?.label).toBe('env-wide package row'); @@ -1211,27 +1213,28 @@ describe('[#21803] pin 10 — a disabled package is still installed: its package } }); -describe('[#21803] pin 11 — the folded position: a body served from outside the lock\'s scope states no lock the envelope does not report', () => { +describe('[#21803, ADR-0131 D6] pin 11 — the folded position is closed: a legacy organization row is in neither the content\'s scope nor the lock\'s', () => { // 5988387087: the organization holds only package B's row; an env-wide row - // of package A declares 'full'. A request naming A in the organization is - // served A's env-wide row (content: the organization holds no row of A and - // no package-less row), while the lock's scope is the organization's rows. + // of package A declares 'full'. Before ADR-0131 D6 a request naming A in + // the organization was served A's env-wide row while the lock's scope was + // the organization's rows. The reads take no organization now, so the + // content and the lock are both the environment's: 'full'. const rows = () => [ storedRow('view', ORG, 'none', 'org row of the other package', 'v_scope', OTHER_PACKAGE), storedRow('view', null, 'full', 'env-wide package row', 'v_scope', PACKAGE_ID), ]; for (const reversed of [false, true]) { - it(`${reversed ? 'the env-wide row returned first' : 'the org row returned first'}: the envelope says 'none', and the served body carries no _lock`, async () => { + it(`${reversed ? 'the env-wide row returned first' : 'the org row returned first'}: the envelope and the served body both say 'full'`, async () => { const protocol = harness(ENV_ID, reversed ? rows().reverse() : rows()); const read = await envelope(protocol, 'view', ORG, 'v_scope', PACKAGE_ID); expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) - .toEqual({ lock: 'none', editable: true, deletable: true }); + .toEqual({ lock: 'full', editable: false, deletable: false }); expect(read.byName.item?.label).toBe('env-wide package row'); - expect(Object.keys(read.byName.item ?? {}).filter((k) => k.startsWith('_lock'))).toEqual([]); - expect(Object.keys(read.layered.effective ?? {}).filter((k) => k.startsWith('_lock'))).toEqual([]); - // The layered read still reports the stored layer as stored. + expect(read.byName.item?._lock).toBe('full'); + expect(read.layered.effective?._lock).toBe('full'); expect(read.layered.overlay?._lock).toBe('full'); + expect(read.layered.overlayScope).toBe('env'); // [ADR-0131 D6] The organization's writes are refused before the gate. expect(await door(protocol, 'view', 'save', ORG, 'v_scope', PACKAGE_ID)).toEqual(NOT_OVERRIDABLE); expect(await door(protocol, 'view', 'delete', ORG, 'v_scope')).toEqual(NOT_OVERRIDABLE); diff --git a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts index 9787fbc9c44..40fcafc9896 100644 --- a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts @@ -25,8 +25,9 @@ * control moved with it. * 2. The measured defect, named: an env-wide `_lock: 'full'` row, an * org-scoped read, save, delete, publish and rollback. - * 3. Precedence: with both rows present the read serves the org-scoped row, - * and the door binds THAT row's `_lock`, whatever the env-wide row says. + * 3. Precedence: with both rows present, [ADR-0131 D6] every request is + * served the env-wide row — a legacy organization row is served by no + * read — and the door binds that row's `_lock`. * 4. The organization gate: on a type with no per-org channel the reads * never serve an org-scoped row, and neither does the door. * @@ -216,7 +217,7 @@ describe('[#21716] pin 2 — an env-wide _lock: full row binds an organization w } }); -describe('[#21716] pin 3 — both rows present: the door binds the lock of the row the read serves', () => { +describe('[#21716, ADR-0131 D6] pin 3 — both rows present: every request is served the env-wide row, and the door binds its lock', () => { const cases: Array<{ env: Lock; org: Lock }> = [ { env: 'full', org: 'none' }, { env: 'none', org: 'full' }, @@ -229,13 +230,12 @@ describe('[#21716] pin 3 — both rows present: the door binds the lock of the r const rows = [viewRow('v_both', null, c.env), viewRow('v_both', ORG, c.org)]; const { protocol } = harness(environmentId, rows); const read = await envelope(protocol, 'v_both', q.organizationId); - // The read serves the org-scoped row to its organization, the - // env-wide row otherwise (ADR-0005 precedence, never a merge)… - const servedLock = q.organizationId ? c.org : c.env; + // [ADR-0131 D6] The read serves the env-wide row to every + // request: a legacy organization row is served by no read… expect(read).toMatchObject({ - lock: servedLock, - served: q.organizationId ? 'org row' : 'env-wide row', - overlayScope: q.organizationId ? 'org' : 'env', + lock: c.env, + served: 'env-wide row', + overlayScope: 'env', }); // …and that row's lock is the one the doors enforce. [ADR-0131 D6] // An org-scoped write is refused before the `_lock` gate. @@ -252,7 +252,7 @@ describe('[#21716] pin 3 — both rows present: the door binds the lock of the r describe('[#21716] pin 4 — a type with no per-org channel: neither the read nor the door serves an org-scoped row', () => { // `page` declares `allowOrgOverride: false`, so an org-scoped row of it is // pre-#6190 residue boot hydration walks past. The reads gate the - // organization away (`organizationIdForMetaRead`) and serve the env-wide + // organization away (ADR-0131 D6: no read takes one) and serve the env-wide // row. [ADR-0131 D6] Every org-scoped write — the removal included — is // refused by the org-scope door (`NOT_OVERRIDABLE`) before the `_lock` gate. const cases: Array<{ env: Lock; org: Lock }> = [ diff --git a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts index a6102b9ef5d..f5fd1fc9648 100644 --- a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts @@ -355,33 +355,6 @@ describe('[#5707] the layered read stops painting an outage as "nothing was cust expect([caught.status, caught.code]).toEqual([503, 'SERVICE_UNAVAILABLE']); }); - it('a failed ORG-scope read does not silently demote to the env row it never got to', async () => { - // The org lookup runs first and the env lookup is inside the same - // `try`, so the swallow hid BOTH: an env-wide overlay row that was - // perfectly readable was reported as "no overlay" because the org read - // failed ahead of it. - // - // ⚠️ [commit e1d4f9e3f] `view`, not `object` — and the type is now load-bearing - // rather than incidental. This verb gates its organization through - // `organizationIdForMetaRead`, so an `allowOrgOverride: false` type - // (`object`, which the rest of this file uses as its generic subject) - // resolves `orgId` to `undefined` and never issues an org-scope read at - // all. There would be no failing read to report, and this case would - // pass vacuously against a method that had stopped doing the thing it - // is about. `view` is tier-A, so the org arm is really taken. - const err = connectionRefused(); - const engine = engineWithRows([]); - engine.findOne = vi.fn(async (_o: string, opts: any) => { - if (opts?.where?.organization_id === 'org_acme') throw err; - return { type: 'view', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) }; - }); - - const caught = await rejection( - () => p_layered(engine, { type: 'view', name: 'acct', organizationId: 'org_acme' }), - ); - expectStoreUnavailable(caught, err); - }); - it('an unprovisioned sys_metadata still renders the code layer (benign, unchanged)', async () => { // First boot: no overlay row EXISTS, so `overlay: null`, // `overlayScope: null` and `effective === code` are the truth — and the diff --git a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts deleted file mode 100644 index c74d7996d47..00000000000 --- a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #6992 — the cold-boot audit scans the LIVE registry, not the static one. - * - * --------------------------------------------------------------------------- - * The gap this file pins - * --------------------------------------------------------------------------- - * A metadata type with no entry in `DEFAULT_METADATA_TYPE_REGISTRY` is - * registered at runtime by a plugin (`theme`, `connector`, `webhook`, - * `sharing_rule`, `analytics_cube`, …). Before this change that family fell - * through BOTH halves of the org-scoped-row protection: - * - * - `orgScopedWriteRefusal` (#6190) requires a static registry entry, so an - * org-scoped write of such a type is accepted; - * - `reportUnhydratableOrgScopedRows` (PR #6600) built its scanned-type list - * by walking `DEFAULT_METADATA_TYPE_REGISTRY`, so such a type was absent - * from the scan. - * - * …while `loadMetaFromDb`'s filter is type-BLIND (`organization_id: null`) and - * skips its rows exactly like a `flow`'s. Neither the gate nor the warning. - * - * Triage on #6992 scoped the fix to the DIAGNOSTIC half only; the refusal - * stayed static-registry-keyed. [ADR-0131 D6] has since widened the refusal to - * every organization-scoped write of every type, so the two sets agree; the - * last case in this file records that. - * - * --------------------------------------------------------------------------- - * Boot order — measured, because it is how this widening could have been inert - * --------------------------------------------------------------------------- - * The audit fires inside `loadMetaFromDb`, i.e. in `ObjectQLPlugin.start()` - * Phase 2 — after EVERY plugin's `init` (the kernel runs all inits, then all - * starts). Plugin metadata reaches the SchemaRegistry during Phase 1, via the - * `manifest` service's `ql.registerApp`. Probed on a real `app-showcase` boot, - * at the instant the audit runs: - * - * engine.registry.getRegisteredTypes() = ["action","analytics_cube","api", - * "app","book","capability","connector","dashboard","data","dataset", - * "doc","email_template","flow","hook","job","mapping","object","package", - * "page","permission","report","sharing_rule","theme","view","webhook"] - * metadataService.getRegisteredTypes() = - * live \ DEFAULT_METADATA_TYPE_REGISTRY = ["analytics_cube","connector", - * "data","package","sharing_rule","theme","webhook"] - * - * So the SchemaRegistry is the source that actually carries the family, it is - * already populated when the audit runs, and the widening is live rather than - * inert. The metadata service contributes nothing beyond the declared set at - * boot (its `typeRegistry` is seeded with `DEFAULT_METADATA_TYPE_REGISTRY` in - * the manager's constructor) — it is read anyway, because it is the other half - * of what `getMetaTypes()` lists and the two must not diverge. - * - * --------------------------------------------------------------------------- - * Reverse verification, direction predicted BEFORE running - * --------------------------------------------------------------------------- - * Ordinary red for the coverage cases, deliberate green for the guards. - * Reverting `reportUnhydratableOrgScopedRows`' scan to the static registry - * alone (the `origin/main` body) must turn the four "reports" cases red — - * they assert a line that names a plugin-registered type, which the static - * scan cannot produce — and must leave the guards green, because those assert - * an ABSENCE (`view` silence, env-wide silence) or read the registry directly - * (the premise pin, the divergence pin). Measured below in the PR body. - */ -import { describe, expect, it, vi } from 'vitest'; -// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / -// #5480 update). From `@objectstack/metadata-core`, never `@objectstack/objectql` -// — objectql depends on THIS package, so that import would close a cycle. -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -interface Row { - id: string; - type: string; - name: string; - organization_id: string | null; - state: string; - metadata: string; -} - -/** Honours the two predicates the audit query relies on, like the #6190 file. */ -function matchesWhere(r: Row, where: Record): boolean { - for (const [k, v] of Object.entries(where)) { - if (v === undefined) continue; - const actual = (r as any)[k]; - if (v !== null && typeof v === 'object') { - const ops = v as Record; - if ('$null' in ops) { - const isNull = actual === null || actual === undefined; - if (isNull !== ops.$null) return false; - } - if ('$in' in ops) { - if (!(ops.$in as unknown[]).includes(actual)) return false; - } - continue; - } - if (actual !== v) return false; - } - return true; -} - -/** - * The engine stub differs from the #6190 file's in exactly one way that - * matters: its registry ANSWERS `getRegisteredTypes()`. That method is the - * accessor this change reads, and the #6190 stub omits it — which is why every - * case in that file keeps passing unchanged (the audit degrades to the declared - * scan when the accessor is absent, and those cases only use declared types). - */ -function makeEngine( - rows: Row[], - opts: { liveTypes?: string[]; serviceTypes?: string[]; registryThrows?: boolean } = {}, -) { - const registered: Array<{ type: string; name: string }> = []; - const engine: any = { - async find(_table: string, q: { where: Record }) { - return rows.filter((r) => matchesWhere(r, q.where)); - }, - async findOne(object: string, query?: EngineFindOneQueryInput) { - assertEngineFindOnePredicate(object, query); return null; }, - async insert() { return { id: 'x' }; }, - async update(_t: string, data: Record, o?: Record) { - assertEngineUpdateDispatch(data, o); - return { id: null }; - }, - async delete(_t: string, o?: Record) { - assertEngineDeleteDispatch(o); - return { deleted: 0 }; - }, - registry: { - getRegisteredTypes: () => { - if (opts.registryThrows) throw new Error('registry exploded'); - return opts.liveTypes ?? []; - }, - registerItem: (type: string, item: any) => { registered.push({ type, name: item?.name }); }, - registerObject: (item: any) => { registered.push({ type: 'object', name: item?.name }); }, - listItems: () => [], - getItem: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - }, - }; - const services = new Map(); - if (opts.serviceTypes) { - services.set('metadata', { getRegisteredTypes: async () => opts.serviceTypes }); - } - return { engine, registered, getServicesRegistry: () => services }; -} - -/** A body every type parses as "a named item" — these rows are never hydrated. */ -const body = (name: string) => JSON.stringify({ name, label: name }); - -const row = (over: Partial & Pick): Row => ({ - id: `r_${over.type}_${over.name}_${over.organization_id ?? 'env'}`, - organization_id: null, - state: 'active', - metadata: body(over.name), - ...over, -}); - -async function bootAndCapture( - engine: any, - getServicesRegistry?: () => Map, -): Promise<{ result: any; warns: string[] }> { - const warns: string[] = []; - const spy = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { - warns.push(a.map(String).join(' ')); - }); - try { - const protocol = new ObjectStackProtocolImplementation(engine, getServicesRegistry) as any; - const result = await protocol.loadMetaFromDb(); - return { result, warns }; - } finally { - spy.mockRestore(); - } -} - -const AUDIT = '[metadata_org_scoped_unhydrated]'; - -describe('#6992 — the cold-boot audit scans the live registry, not just the declared one', () => { - // ── the premise, read from the registry rather than restated ────────── - - it('the specimen types genuinely have NO entry in DEFAULT_METADATA_TYPE_REGISTRY', () => { - // If a later PR declares one of these, this case goes red and the - // corresponding case below should be re-pointed at a type that is - // still plugin-registered — not "repaired" by deleting the assertion. - for (const type of ['webhook', 'theme', 'sharing_rule', 'connector']) { - expect( - DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type), - `${type} gained a registry entry — re-read #6992 before touching this file`, - ).toBeUndefined(); - } - }); - - // ── the acceptance criterion: the new family is reported ────────────── - - it('reports an org-scoped row of a PLUGIN-REGISTERED type the engine registry knows', async () => { - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'webhook', name: 'org_hook', organization_id: 'org_a' }), - row({ type: 'webhook', name: 'platform_hook' }), - ], - { liveTypes: ['object', 'view', 'webhook'] }, - ); - - const { result, warns } = await bootAndCapture(engine, getServicesRegistry); - - // Hydration is UNCHANGED — #6992 widens the diagnostic, nothing else. - // The env-wide row loads; the org-scoped one still does not. - expect(result).toMatchObject({ loaded: 1, errors: 0, storeUnavailable: false }); - - const line = warns.find((w) => w.includes(AUDIT)); - expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); - expect(line).toContain('webhook×1'); - expect(line).toContain('org_hook@org_a'); - // The row that DID load is not accused. - expect(line).not.toContain('platform_hook'); - }); - - it('marks the plugin-registered family, because its remediation differs', async () => { - // A DECLARED type's org-scoped write is refused from now on (#6190), so - // its rows are historical residue that cannot grow. An UNDECLARED type's - // write is NOT refused, so the same names return after every restart - // until the author stops writing them org-scoped. The line has to say - // which of the two an operator is looking at. - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), - row({ type: 'theme', name: 'org_theme', organization_id: 'org_a' }), - ], - { liveTypes: ['flow', 'theme'] }, - ); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - const line = warns.find((w) => w.includes(AUDIT))!; - expect(line).toContain('theme×1 [plugin-registered]'); - // The declared specimen in the SAME line carries no marker. - expect(line).toContain('flow×1 ('); - expect(line).not.toContain('flow×1 [plugin-registered]'); - expect(line).toContain('does NOT cover them'); - }); - - it('reads the metadata SERVICE as well as the engine registry', async () => { - // `getMetaTypes()` unions both sources; the audit must scan the same - // union or it accuses a type the listing does not admit exists, or - // stays silent about one it does. Here the type is known ONLY to the - // service. - const { engine, getServicesRegistry } = makeEngine( - [row({ type: 'sharing_rule', name: 'org_rule', organization_id: 'org_a' })], - { liveTypes: [], serviceTypes: ['sharing_rule'] }, - ); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - const line = warns.find((w) => w.includes(AUDIT)); - expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); - expect(line).toContain('sharing_rule×1 [plugin-registered]'); - expect(line).toContain('org_rule@org_a'); - }); - - it('stays ONE aggregated line across declared and plugin-registered types alike', async () => { - // The shape #6600 chose is unchanged by the widening: a tenant with - // many such rows costs one line, with a capped name sample per type. - const rows: Row[] = []; - for (let i = 0; i < 7; i++) { - rows.push(row({ type: 'webhook', name: `hook_${i}`, organization_id: 'org_a' })); - } - rows.push(row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' })); - const { engine, getServicesRegistry } = makeEngine(rows, { liveTypes: ['flow', 'webhook'] }); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - const audit = warns.filter((w) => w.includes(AUDIT)); - expect(audit).toHaveLength(1); - expect(audit[0]).toContain('8 active sys_metadata row(s)'); - expect(audit[0]).toContain('webhook×7'); - expect(audit[0]).toContain('+2 more'); - expect(audit[0]).toContain('flow×1'); - }); - - // ── the silence that is the design, not a miss ──────────────────────── - - it('says NOTHING about an ENV-WIDE row of a plugin-registered type', async () => { - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'webhook', name: 'platform_hook' }), - row({ type: 'theme', name: 'platform_theme' }), - ], - { liveTypes: ['webhook', 'theme'] }, - ); - - const { result, warns } = await bootAndCapture(engine, getServicesRegistry); - - expect(result.loaded).toBe(2); - expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); - }); - - it('still says NOTHING about an org-scoped VIEW — widening must not touch that silence', async () => { - // `view` is `allowOrgOverride: true`. Its org row is a per-org overlay - // served on demand — the ADR-0005 design, and the case that would break - // first if the widening were written as "scan every live type". - const { engine, getServicesRegistry } = makeEngine( - [row({ type: 'view', name: 'org_grid', organization_id: 'org_a' })], - { liveTypes: ['view', 'webhook', 'theme'] }, - ); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); - }); - - // ── the diagnostic can never become the outage ──────────────────────── - - it('degrades to the declared scan when the live accessor throws', async () => { - // A best-effort probe must not be able to cost a boot its report, let - // alone its verdict: an engine whose registry throws still gets the - // declared-type half of the audit, and a healthy `storeUnavailable`. - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), - row({ type: 'webhook', name: 'org_hook', organization_id: 'org_a' }), - ], - { registryThrows: true }, - ); - - const { result, warns } = await bootAndCapture(engine, getServicesRegistry); - - expect(result).toMatchObject({ errors: 0, storeUnavailable: false }); - const line = warns.find((w) => w.includes(AUDIT)); - expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); - expect(line).toContain('flow×1'); - expect(line).not.toContain('webhook'); - }); - - // ── the divergence from the refusal is deliberate, and pinned ───────── - - it('the write refusal now covers the family it reports too — recorded, not deleted (ADR-0131 D6)', async () => { - // #6992's scope was the diagnostic half ONLY, and this case used to pin - // the divergence: `orgScopedWriteRefusal` refused statically-declared - // types only, so a plugin-registered type's org-scoped write was - // accepted. ADR-0131 D6 is the new ruling that widened the refusal — - // every organization-scoped write is refused, for every type — so the - // two sets now agree, and this case records it. - const refuse = (type: string) => (ObjectStackProtocolImplementation as any).organizationScopedWriteRefusal( - `Metadata item '${type}/x'`, 'org_a', - ); - expect(refuse('flow')).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - for (const type of ['webhook', 'theme', 'sharing_rule', 'connector']) { - expect(refuse(type), `${type} write refusal changed`).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - } - }); -}); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts index 141c72675a1..3d7495b9c68 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts @@ -1,67 +1,29 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #6190 — cold boot must SAY which org-scoped rows it walked past. + * [#6190, ADR-0131 D6] Cold boot SAYS which stored rows sit in a legacy + * organization's layer — rows no read serves any more. * - * --------------------------------------------------------------------------- - * What survived the upstream ruling, measured against `origin/main` - * --------------------------------------------------------------------------- - * #6155 Q1=B → #6283 → commit 474f131cf rolled `flow`'s `allowOrgOverride` back to - * `false` and proved declared=enforced on the write side: overlaying a - * PACKAGED flow per org is now a 403 `NOT_OVERRIDABLE` before persistence. + * #6190 made the skip of an organization-scoped `flow` row loud: boot hydrates + * `organization_id IS NULL` rows only, and an organization-authored flow + * stopped firing after a restart with nothing said. ADR-0131 D6 then retired + * the per-organization overlay axis: every read is environment → code, for + * every type, so a legacy organization row of ANY type — a tenant's `view` + * overlay as much as a pre-#6190 flow — is served by no read. The report + * widened with it: every type, active and draft rows, plus the legacy rows of + * the three ledgers the timeline, history, diff and audit reads no longer + * show, and the remedy is the v18 migration ceremony (`os migrate`, ADR-0131 + * D10), which names each row's fate. Nothing is deleted or rewritten here. * - * That closed one of the two write tiers. The other is still open BY DESIGN — - * `flow` keeps `allowRuntimeCreate: true`, which is ADR-0005's "a deployment, - * not an overlay" — and it is the tier the tenant scenario in #6190 actually - * uses: authoring a BRAND-NEW flow in Studio. Measured on current main, that - * write still lands `sys_metadata.organization_id = ''`, because - * `SysMetadataRepository.put` stamps `organization_id: this.organizationId` - * for every type and the runtime `PUT /metadata/:type/:name` threads - * `resolveActiveOrganizationId` into `saveMetaItem`: - * - * PROBE rows = [{"name":"org_sweep","org":"org_a"}, - * {"name":"platform_sweep","org":null}] - * PROBE loadMetaFromDb = {"loaded":1,...} // only platform_sweep - * PROBE logs during cold boot = [] // ← the defect - * PROBE getMetaItems({type:flow}) no org = ["platform_sweep"] - * - * So the symptom #6190 filed — an org-scoped flow that fires all day and never - * fires again after a restart — is still reachable, and the third line is why - * nobody can tell: the skip was completely silent. `kernel:bootstrapped`'s - * unbound audit cannot report it either, because the flow was never registered. - * - * This file pins the loud half. It does NOT change what boot loads — whether - * such a row should exist at all (refuse the write / force it env-wide / teach - * the binder to read per-org) is a contract ruling recorded on the issue. - * - * --------------------------------------------------------------------------- - * Reverse verification, direction predicted BEFORE running - * --------------------------------------------------------------------------- - * Ordinary red, with a deliberately green control. Deleting the - * `reportUnhydratableOrgScopedRows()` call from `loadMetaFromDb` turns the - * three "warns" cases red AND the probe-failure case with them — that one - * asserts the second `find` happened at all, so it goes red counting calls - * rather than reading a message. The two silence cases and the registry - * premise pin stay green: they assert an ABSENCE of output, which a deleted - * producer trivially satisfies. Predicted 4 red / 3 green; measured 4 red / - * 3 green, and the reds fail in the shape that names the defect: - * - * AssertionError: no [metadata_org_scoped_unhydrated] line in: [] - * AssertionError: expected 1 to be greater than 1 - * - * — the silent cold boot of the PROBE output above, reproduced on demand. - * - * The silence cases are not slack: a "fix" that warned about every skipped - * org-scoped row would pass the red half and fail there, and that shape is - * wrong — for `view` and friends (`allowOrgOverride: true`) the skip IS the - * ADR-0005 design, loaded on demand by `getMetaItem`/`getMetaItems`. + * Reverse verification: deleting the `reportUnhydratableOrgScopedRows()` call + * from `loadMetaFromDb` turns the "names" cases red and leaves the silence + * cases green (they assert an absence a deleted producer satisfies). */ import { describe, expect, it, vi } from 'vitest'; // [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / // #5480 update). From `@objectstack/metadata-core`, never `@objectstack/objectql` // — objectql depends on THIS package, so that import would close a cycle. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation } from './protocol.js'; interface Row { @@ -100,13 +62,20 @@ function matchesWhere(r: Row, where: Record): boolean { return true; } -function makeEngine(rows: Row[], opts: { dropPredicates?: boolean } = {}) { +function makeEngine( + rows: Row[], + opts: { dropPredicates?: boolean; ledgers?: Record> } = {}, +) { const registered: Array<{ type: string; name: string }> = []; const engine: any = { - async find(_table: string, q: { where: Record }) { + async find(table: string, q: { where: Record }) { + if (table !== 'sys_metadata') { + const ledger = (opts.ledgers?.[table] ?? []) as any[]; + return opts.dropPredicates ? ledger : ledger.filter((r) => matchesWhere(r, q.where)); + } // A driver that cannot lower `$null`/`$in` hands back a superset — // the exact degradation the JS re-check exists for. - if (opts.dropPredicates) return rows.filter((r) => r.state === q.where.state); + if (opts.dropPredicates) return rows; return rows.filter((r) => matchesWhere(r, q.where)); }, async findOne(object: string, query?: EngineFindOneQueryInput) { @@ -171,50 +140,46 @@ async function bootAndCapture(engine: any): Promise<{ result: any; warns: string } } -const AUDIT = '[metadata_org_scoped_unhydrated]'; +const AUDIT = '[metadata_org_scoped_unserved]'; -describe('#6190 — cold boot names the org-scoped rows it cannot hydrate', () => { - // ── the premise, read from the registry rather than restated ────────── - - it('flow is the specimen: not per-org overridable, still runtime-creatable', () => { - // Both halves matter. `allowOrgOverride: false` (#6283 / commit 474f131cf) is - // why an org-scoped flow row can never be read back as an overlay; - // `allowRuntimeCreate: true` is why one can still be WRITTEN. If a - // later ruling closes the second flag, this case goes red and the - // whole file should be re-read, not repaired. - expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'flow')).toMatchObject({ - allowOrgOverride: false, - allowRuntimeCreate: true, - }); - // The control specimen's flag, likewise read and not assumed. - expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'view')).toMatchObject({ - allowOrgOverride: true, - }); - }); - - // ── the acceptance criterion: the absence is loud ───────────────────── - - it('warns, naming type/name/org, when an org-scoped FLOW row is skipped', async () => { +describe('[#6190, ADR-0131 D6] cold boot names every legacy organization-scoped row, which no read serves', () => { + it('names a legacy organization FLOW row and a tenant VIEW overlay alike, per type, with the ceremony as the remedy', async () => { const { engine } = makeEngine([ row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), row({ type: 'flow', name: 'platform_sweep' }), + row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }), + row({ type: 'view', name: 'org_draft_grid', organization_id: 'org_b', state: 'draft' }), + row({ type: 'view', name: 'platform_grid' }), ]); const { result, warns } = await bootAndCapture(engine); - // Hydration itself is UNCHANGED — this issue's fix is the log, not a - // load. The org row stays out of the process-wide registry. - expect(result).toMatchObject({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false }); + // Hydration loads the environment's rows only. + expect(result).toMatchObject({ loaded: 2, errors: 0, invalid: 0, storeUnavailable: false }); const line = warns.find((w) => w.includes(AUDIT)); expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); - expect(line).toContain('flow×1'); - expect(line).toContain('org_sweep@org_a'); - // The consequence, not just the fact — an operator reading this must - // learn why an automation stopped firing after a restart. + expect(line).toContain('flow×1 (org_sweep@org_a)'); + expect(line).toContain('view×2 (org_grid@org_a, org_draft_grid@org_b (draft))'); expect(line).toContain('bind its triggers'); - // And it must not name the row that DID load. - expect(line).not.toContain('platform_sweep'); + expect(line).toContain('os migrate'); + expect(line).not.toContain('platform_'); + }); + + it('counts the legacy rows of the three ledgers the reads no longer show', async () => { + const { engine } = makeEngine([], { + ledgers: { + sys_metadata_commit: [{ organization_id: 'org_a' }, { organization_id: null }], + sys_metadata_history: [{ organization_id: 'org_a' }, { organization_id: 'org_b' }], + sys_metadata_audit: [{ organization_id: null }], + }, + }); + + const { warns } = await bootAndCapture(engine); + + const line = warns.find((w) => w.includes(AUDIT)); + expect(line).toContain('sys_metadata_commit×1, sys_metadata_history×2'); + expect(line).not.toContain('sys_metadata_audit×'); }); it('counts every row but samples the names, so a thousand rows cost one line', async () => { @@ -232,45 +197,20 @@ describe('#6190 — cold boot names the org-scoped rows it cannot hydrate', () = expect(audit[0]).toContain('+4 more'); }); - it('still warns when the driver drops the predicates and returns a superset', async () => { - // `driver-memory` historically dropped `is_null` outright (see its - // `memory-filter-ast-vocabulary.test.ts`), so the audit re-checks both - // predicates in JS. A superset must produce the SAME line — not a - // false accusation against the env-wide and view rows in it. + it('still reports truthfully when the driver drops the predicates and returns a superset', async () => { const { engine } = makeEngine([ row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), row({ type: 'flow', name: 'platform_sweep' }), - row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }), ], { dropPredicates: true }); const { warns } = await bootAndCapture(engine); const line = warns.find((w) => w.includes(AUDIT)); - expect(line).toBeDefined(); expect(line).toContain('org_sweep@org_a'); expect(line).not.toContain('platform_sweep'); - expect(line).not.toContain('org_grid'); }); - // ── the silence that is the design, not a miss ──────────────────────── - - it('says NOTHING about an org-scoped VIEW — that skip is ADR-0005 working', async () => { - // `view` is `allowOrgOverride: true`: the row is a per-org overlay, - // deliberately not hydrated process-wide and served on demand by - // `getMetaItem`/`getMetaItems({ organizationId })`. Warning here would - // print a line at every boot of every healthy tenant. - const { engine } = makeEngine([ - row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }), - row({ type: 'view', name: 'platform_grid' }), - ]); - - const { result, warns } = await bootAndCapture(engine); - - expect(result.loaded).toBe(1); - expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); - }); - - it('says nothing at all on a store with no org-scoped rows', async () => { + it('says nothing at all on a store with no legacy organization row', async () => { const { engine } = makeEngine([ row({ type: 'flow', name: 'platform_sweep' }), row({ type: 'view', name: 'platform_grid' }), @@ -282,13 +222,9 @@ describe('#6190 — cold boot names the org-scoped rows it cannot hydrate', () = expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); }); - // ── the diagnostic can never become the outage ──────────────────────── - it('a failing audit probe does not change the boot verdict', async () => { - // #5897 draws a hard line between "the store had no rows" and "the - // store could not be read". A best-effort extra probe must not be able - // to cross it: the first `find` succeeds, so this boot is HEALTHY, and - // `storeUnavailable` must stay false even though the probe threw. + // #5897: a best-effort extra probe must not turn a healthy boot into + // `storeUnavailable`. let call = 0; const { engine } = makeEngine([row({ type: 'flow', name: 'platform_sweep' })]); const inner = engine.find; diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index a670d7a317f..695b88b89d8 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -198,7 +198,7 @@ async function seedLegacyOrgDraft( args: { type: string; name: string; body: unknown; organizationId: string; packageId?: string | null }, ): Promise { await protocol.ensureOverlayIndex(); - const repo = protocol.getOverlayRepo(args.organizationId); + const repo = protocol.legacyOrganizationRepo(args.organizationId); await repo.put( { type: args.type, name: args.name, org: args.organizationId }, args.body, @@ -437,7 +437,7 @@ describe('a package-less organization overlay, two packages shipping its view na /** The organization read's copies of the view, where the doors find the form. */ async function servedCopies(protocol: any): Promise>> { - const org: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_a' }); + const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); return (org.items as any[]).filter((v) => v?.name === NAME); } @@ -459,7 +459,7 @@ describe('a package-less organization overlay, two packages shipping its view na */ async function storeOpenOverlay(protocol: any, rows: Map) { await protocol.ensureOverlayIndex(); - await protocol.getOverlayRepo('org_a').put( + await protocol.legacyOrganizationRepo('org_a').put( { type: 'view', name: NAME, org: 'org_a' }, formView(true, 'Intake (org)'), { parentVersion: null, actor: null, source: 'test.legacy-residue', intent: 'runtime-only', state: 'active', packageId: null }, @@ -734,7 +734,7 @@ describe('each package\'s copy of a view container expands into its own package\ /** An organization overlay of the form, open, as a rollback restores it: the save check never judged it. */ async function restoreOpenOverlay(protocol: any) { await protocol.ensureOverlayIndex(); - await protocol.getOverlayRepo('org_a').put( + await protocol.legacyOrganizationRepo('org_a').put( { type: 'view', name: NAME, org: 'org_a' }, formView(true, 'Intake (org)'), { parentVersion: null, actor: null, source: 'test.restored', intent: 'runtime-only', state: 'active', packageId: null }, @@ -762,7 +762,7 @@ describe('each package\'s copy of a view container expands into its own package\ * judged over the env-wide view list beneath it. */ async function doorsServe(protocol: any): Promise { - const org: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_a' }); + const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); const envWide: any = await protocol.getMetaItems({ type: 'view' }); return (org.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) .some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(envWide.items, view, c))); @@ -1012,7 +1012,7 @@ describe('each package\'s copy of a view container expands into its own package\ /** An organization overlay of the form under `name`, open, as a rollback restores it. */ async function restoreOpenOverlayAt(protocol: any, name: string) { await protocol.ensureOverlayIndex(); - await protocol.getOverlayRepo('org_a').put( + await protocol.legacyOrganizationRepo('org_a').put( { type: 'view', name, org: 'org_a' }, { name, label: 'Intake (org)', object: 'task', viewKind: 'form', config: form(true, 'Intake (org)', SLUG) }, { parentVersion: null, actor: null, source: 'test.restored', intent: 'runtime-only', state: 'active', packageId: null }, diff --git a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts new file mode 100644 index 00000000000..cfc247035b9 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts @@ -0,0 +1,339 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] Every metadata read is environment → code. + * + * 1. A legacy organization-scoped row — one per formerly org-overridable + * (tier-A) type, stored before the per-organization overlay axis retired — + * is invisible to the item, list, layered, history, diff, audit, drafts, + * commits, lock and ETag reads, whatever organization a caller still + * names: the environment's row, or the code definition, is served. + * `overlayScope` is `'env'` or `null`, never `'org'`. + * 2. [Triage ruling Q1 → C] An environment overlay row of SEALED managed + * content — a managed action or hook, written through the + * `OS_METADATA_WRITABLE` hatch before the seal — is not served: the + * package's definition wins. `permission` keeps its own fork ruling, so a + * fork of a packaged permission set is still served (the control). + * 3. The boot report names both populations, per type, with their remedies + * — the v18 migration ceremony for the legacy organization rows, and the + * two remedies for the sealed overlays. Nothing is deleted or rewritten. + * 4. [Triage ruling Q3 A] The one read left that reaches a legacy + * organization's rows: the anonymous form doors' `view` list read + * (`legacyFormOrganizationId`), kept fail-closed until the ceremony. Any + * other type reads the environment alone, whoever passes the key. + * + * Reverse verification (one-shot, recorded in the PR): restoring an + * organization row to the served-row lookup (`findServedOverlayRow` with its + * `organization_id` predicate removed) turns §1's item-read pin red; removing + * the sealed arm from `declinesStoredRow` turns §2 red. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ORG = 'org_a'; +const PKG = 'app.pkg'; +const TIER_A = ['view', 'dashboard', 'report', 'translation', 'email_template'] as const; + +type Row = Record; + +/** Equality (NULL included), `$or`, `$null` and `$in` — the subset these reads emit. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + if (!(v as Row[]).some((clause) => matches(row, clause))) return false; + continue; + } + if (v === undefined) continue; + const actual = row[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = v as Record; + if ('$null' in ops && (actual === null) !== ops.$null) return false; + if ('$in' in ops && !(ops.$in as unknown[]).includes(actual)) return false; + continue; + } + if (actual !== v) return false; + } + return true; +} + +interface Artifact { type: string; name: string; body: Row } + +function harness(seed: Record, artifacts: Artifact[] = []) { + const tables = new Map(Object.entries(seed).map(([t, rows]) => [t, rows.map((r) => ({ ...r }))])); + const table = (t: string) => { + if (!tables.has(t)) tables.set(t, []); + return tables.get(t)!; + }; + let nextId = 0; + const artifactOf = (type: string, name: string) => + artifacts.find((a) => a.type === type && a.name === name)?.body; + const engine: any = { + async find(t: string, q?: { where?: Row }) { + return table(t).filter((r) => matches(r, q?.where)); + }, + async findOne(t: string, q: { where: Row }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + nextId += 1; + const row = { ...data, id: data.id ?? `i_${nextId}` }; + table(t).push(row); + return { id: row.id }; + }, + async update(_t: string, data: Row, o: { where: Row }) { + assertEngineUpdateDispatch(data, o); + return { id: null }; + }, + async delete(_t: string, o: { where: Row }) { + assertEngineDeleteDispatch(o); + return { deleted: 0 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + listItems: (type: string) => artifacts.filter((a) => a.type === type).map((a) => ({ ...a.body })), + getItem: (type: string, name: string) => artifactOf(type, name), + getArtifactItem: (type: string, name: string) => artifactOf(type, name), + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_prod') as any; + return { protocol, tables }; +} + +const metaRow = (type: string, name: string, org: string | null, body: Row, over: Row = {}): Row => ({ + id: `r_${type}_${name}_${org ?? 'env'}_${String(over.state ?? 'active')}`, + type, + name, + organization_id: org, + package_id: null, + state: 'active', + version: 1, + metadata: JSON.stringify({ name, ...body }), + ...over, +}); + +/** The two spellings a caller may still send: none, and a retired organization key. */ +const CALLERS = [{}, { organizationId: ORG }] as const; +const asCaller = (request: R, caller: object) => ({ ...request, ...caller }) as R; + +afterEach(() => vi.restoreAllMocks()); + +describe('§1 [ADR-0131 D6] a legacy organization row is invisible to every read, whatever organization the caller names', () => { + for (const type of TIER_A) { + const seed = () => ({ + sys_metadata: [ + metaRow(type, 'shared', null, { label: 'env' }), + metaRow(type, 'shared', ORG, { label: 'org', _lock: 'full' }), + metaRow(type, 'org_only', ORG, { label: 'org only' }), + metaRow(type, 'org_draft', ORG, { label: 'org draft' }, { state: 'draft' }), + ], + sys_metadata_history: [ + { + id: 'h_org', type, name: 'org_only', organization_id: ORG, version: 1, event_seq: 1, + operation_type: 'put', metadata: JSON.stringify({ name: 'org_only' }), checksum: 'x', + recorded_at: '2026-01-01T00:00:00.000Z', + }, + ], + sys_metadata_audit: [ + { id: 'a_env', type, name: 'shared', organization_id: null, operation: 'save', outcome: 'allowed', code: 'ok' }, + { id: 'a_org', type, name: 'shared', organization_id: ORG, operation: 'save', outcome: 'allowed', code: 'ok' }, + ], + sys_metadata_commit: [ + { id: 'c_env', package_id: PKG, organization_id: null, operation: 'apply', items: '[]', item_count: 0 }, + { id: 'c_org', package_id: PKG, organization_id: ORG, operation: 'apply', items: '[]', item_count: 0 }, + ], + }); + + for (const caller of CALLERS) { + const who = 'organizationId' in caller ? `a caller naming ${ORG}` : 'a caller naming none'; + it(`${type} · ${who}: item, list, layered, history, diff, audit, drafts, commits, lock and ETag`, async () => { + const { protocol } = harness(seed()); + + // item: the environment's row, its lock; the org-only name answers nothing + const shared = await protocol.getMetaItem(asCaller({ type, name: 'shared' }, caller)); + expect(shared.item?.label).toBe('env'); + expect(shared.lock).toBe('none'); + expect((await protocol.getMetaItem(asCaller({ type, name: 'org_only' }, caller))).item).toBeUndefined(); + + // list + const list = await protocol.getMetaItems(asCaller({ type }, caller)); + const listed = (list.items as Row[]).map((i) => `${String(i.name)}=${String(i.label)}`); + expect(listed).toEqual(['shared=env']); + + // layered: overlayScope is 'env' or null, never 'org' + const layeredShared = await protocol.getMetaItemLayered(asCaller({ type, name: 'shared' }, caller)); + expect({ label: layeredShared.overlay?.label, scope: layeredShared.overlayScope, lock: layeredShared.lock }) + .toEqual({ label: 'env', scope: 'env', lock: 'none' }); + const layeredOrgOnly = await protocol.getMetaItemLayered(asCaller({ type, name: 'org_only' }, caller)); + expect([layeredOrgOnly.overlay, layeredOrgOnly.overlayScope]).toEqual([null, null]); + + // history and diff: the legacy organization's change log is not read + expect((await protocol.historyMetaItem(asCaller({ type, name: 'org_only' }, caller))).events).toEqual([]); + const diff = await settle(protocol.diffMetaItem(asCaller({ type, name: 'org_only' }, caller))); + expect(JSON.stringify(diff)).not.toContain('org_only"}'); + + // audit + const audit = await protocol.auditMetaItem(asCaller({ type, name: 'shared' }, caller)); + expect(audit.events.map((e: Row) => e.id)).toEqual(['a_env']); + + // drafts + const drafts = await protocol.listDrafts(asCaller({ type }, caller)); + expect(drafts.drafts).toEqual([]); + + // commits + const commits = await protocol.listCommits(asCaller({ packageId: PKG }, caller)); + expect(commits.map((c: Row) => c.id)).toEqual(['c_env']); + + // ETag: one validator, whoever asks + const cached = await protocol.getMetaItemCached(asCaller({ type, name: 'shared' }, caller)); + const unscoped = await protocol.getMetaItemCached({ type, name: 'shared' }); + expect(cached.etag.value).toBe(unscoped.etag.value); + expect(cached.data?.label).toBe('env'); + }); + } + } +}); + +/** A promise's value, or the error it rejected with. */ +async function settle(p: Promise): Promise { + try { + return await p; + } catch (e) { + return e as Error; + } +} + +describe('§2 [triage ruling Q1 → C] an environment overlay of sealed managed content is not served: the package definition wins', () => { + const packaged = (type: string, name: string): Artifact => ({ + type, name, body: { name, label: 'packaged', _packageId: PKG }, + }); + + for (const type of ['action', 'hook'] as const) { + it(`a managed ${type}: the item, list and layered reads serve the package's definition, not the hatch-written row`, async () => { + const { protocol } = harness( + { sys_metadata: [metaRow(type, 'managed', null, { label: 'hatch-written' })] }, + [packaged(type, 'managed')], + ); + expect(protocol.declinesStoredRow(type, 'managed')).toBe(true); + + const item = await protocol.getMetaItem({ type, name: 'managed' }); + expect(item.item?.label).toBe('packaged'); + + const list = await protocol.getMetaItems({ type }); + expect((list.items as Row[]).filter((i) => i.name === 'managed').map((i) => i.label)).toEqual(['packaged']); + + const layered = await protocol.getMetaItemLayered({ type, name: 'managed' }); + expect(layered.effective?.label).toBe('packaged'); + }); + + it(`control: an environment ${type} no managed package ships is served from its row`, async () => { + const { protocol } = harness({ sys_metadata: [metaRow(type, 'authored', null, { label: 'authored' })] }); + expect(protocol.declinesStoredRow(type, 'authored')).toBe(false); + expect((await protocol.getMetaItem({ type, name: 'authored' })).item?.label).toBe('authored'); + }); + } + + it('control: a regime-O type keeps its overlay of a packaged item (a view)', async () => { + const { protocol } = harness( + { sys_metadata: [metaRow('view', 'managed', null, { label: 'overlay' })] }, + [packaged('view', 'managed')], + ); + expect(protocol.declinesStoredRow('view', 'managed')).toBe(false); + expect((await protocol.getMetaItem({ type: 'view', name: 'managed' })).item?.label).toBe('overlay'); + }); + + it('control: a fork of a packaged permission set keeps its own ruling and is still served', async () => { + const { protocol } = harness( + { sys_metadata: [metaRow('permission', 'managed', null, { label: 'fork', objects: {} })] }, + [packaged('permission', 'managed')], + ); + expect(protocol.declinesStoredRow('permission', 'managed')).toBe(false); + }); +}); + +describe('§3 the boot report names both populations, per type, with their remedies', () => { + async function boot(seed: Record, artifacts: Artifact[] = []) { + const warns: string[] = []; + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { warns.push(a.map(String).join(' ')); }); + const { protocol } = harness(seed, artifacts); + const result = await protocol.loadMetaFromDb(); + return { result, warns }; + } + + it('one line per population: the legacy organization rows (the ceremony), the sealed overlays (the two remedies)', async () => { + const { warns } = await boot( + { + sys_metadata: [ + metaRow('view', 'org_grid', ORG, { label: 'org' }), + metaRow('dashboard', 'org_board', ORG, { label: 'org' }), + metaRow('action', 'managed', null, { label: 'hatch-written' }), + metaRow('hook', 'managed', null, { label: 'hatch-written' }), + metaRow('view', 'env_grid', null, { label: 'env' }), + ], + }, + [ + { type: 'action', name: 'managed', body: { name: 'managed', _packageId: PKG } }, + { type: 'hook', name: 'managed', body: { name: 'managed', _packageId: PKG } }, + ], + ); + + const legacy = warns.filter((w) => w.includes('[metadata_org_scoped_unserved]')); + expect(legacy).toHaveLength(1); + expect(legacy[0]).toContain('view×1 (org_grid@org_a)'); + expect(legacy[0]).toContain('dashboard×1 (org_board@org_a)'); + expect(legacy[0]).toContain('os migrate'); + expect(legacy[0]).toContain('Nothing is deleted or rewritten'); + + const sealed = warns.filter((w) => w.includes('[metadata_sealed_overlay_unserved]')); + expect(sealed).toHaveLength(1); + expect(sealed[0]).toContain('action×1 (managed)'); + expect(sealed[0]).toContain('hook×1 (managed)'); + expect(sealed[0]).toContain('linkage-free clone'); + expect(sealed[0]).toContain('delete the stored row'); + expect(sealed[0]).not.toContain('env_grid'); + }); + + it('silence on a store with neither population', async () => { + const { warns } = await boot({ sys_metadata: [metaRow('view', 'env_grid', null, { label: 'env' })] }); + expect(warns.filter((w) => w.includes('_unserved]'))).toEqual([]); + }); +}); + +describe('§4 [triage ruling Q3 A] the anonymous form doors\' legacy layer is the one read left that reaches a legacy organization', () => { + const seed = () => ({ + sys_metadata: [ + metaRow('view', 'intake', null, { label: 'env intake' }), + metaRow('view', 'intake', ORG, { label: 'org intake' }), + metaRow('dashboard', 'board', ORG, { label: 'org board' }), + ], + }); + + it('a view list read with legacyFormOrganizationId prefers that organization\'s body', async () => { + const { protocol } = harness(seed()); + const list = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG }); + expect((list.items as Row[]).map((i) => i.label)).toEqual(['org intake']); + // …and nothing else reads it: the same list without the key, and with the retired key. + for (const caller of CALLERS) { + const plain = await protocol.getMetaItems(asCaller({ type: 'view' }, caller)); + expect((plain.items as Row[]).map((i) => i.label)).toEqual(['env intake']); + } + }); + + it('the key reaches no other type', async () => { + const { protocol } = harness(seed()); + const list = await protocol.getMetaItems({ type: 'dashboard', legacyFormOrganizationId: ORG }); + expect(list.items).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts index 6c3ce3fbf7a..16b480c1178 100644 --- a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts +++ b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts @@ -4,8 +4,9 @@ * [#21817, ADR-0048, ADR-0005] A list scoped to one package * (`getMetaItems({ type, packageId })`, `GET /api/v1/meta/:type?package=`) * serves, in each slot the package ships, the row `getMetaItem` naming that - * package serves: the package's own row, else the package-less row, the - * organization's rows before the env-wide ones. + * package serves: the package's own row, else the package-less row — the + * environment's rows only (ADR-0131 D6: a legacy organization-scoped row is + * served by no read, whatever organization the request comes from). * * The scoped list read its stored rows with the package only, so a * package-less row never reached its merge. With the package's artifact and a @@ -184,13 +185,13 @@ function subsets(xs: readonly T[]): T[][] { } /** - * The oracle, written from the rule: the scopes the request reaches - * (ADR-0005: the organization's, then env-wide), and within one scope the - * package's own row before the package-less row (ADR-0048), never another - * package's. With no row, the package's artifact. + * The oracle, written from the rule: the environment's rows (ADR-0131 D6 — + * the request's organization reaches no row), the package's own row before + * the package-less row (ADR-0048), never another package's. With no row, the + * package's artifact. */ -function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: string | undefined, artifact: boolean): string | undefined { - const scopes: Scope[] = organizationId ? ['org-scoped', 'env-wide'] : ['env-wide']; +function servedLabel(rows: readonly RowKey[], packageId: string, _organizationId: string | undefined, artifact: boolean): string | undefined { + const scopes: Scope[] = ['env-wide']; for (const scope of scopes) { for (const candidate of [packageId, null]) { const hit = rows.find((key) => ROWS[key].scope === scope && ROWS[key].packageId === candidate); @@ -201,10 +202,9 @@ function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: } /** Whether the package ships the name: its artifact, or a row of its own in scope. */ -function ships(rows: readonly RowKey[], packageId: string, organizationId: string | undefined, artifact: boolean): boolean { +function ships(rows: readonly RowKey[], packageId: string, _organizationId: string | undefined, artifact: boolean): boolean { if (artifact && packageId === PKG_A) return true; - return rows.some((key) => ROWS[key].packageId === packageId - && (ROWS[key].scope === 'env-wide' || organizationId !== undefined)); + return rows.some((key) => ROWS[key].packageId === packageId && ROWS[key].scope === 'env-wide'); } /** The items of the scoped list, by name. */ @@ -299,19 +299,19 @@ describe('pin 2 — named arrangements, both row orders: the scoped slot is getM } } for (const order of orders(['orgPackageless', 'envA'])) { - it(`organization ${ORG} · the organization's package-less row over an env-wide row of A · row order ${order.join(', ')}`, async () => { + it(`[ADR-0131 D6] organization ${ORG} · the organization's legacy package-less row is not served over an env-wide row of A · row order ${order.join(', ')}`, async () => { const protocol = harness(order.map((key) => stored(key))); const slots = await scopedSlots(protocol, PKG_A, { organizationId: ORG }); - expect(slots.map((s) => s.label)).toEqual([ROWS.orgPackageless.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.orgPackageless.label); + expect(slots.map((s) => s.label)).toEqual([ROWS.envA.label]); + expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.envA.label); }); } for (const order of orders(['orgA', 'envPackageless'])) { - it(`organization ${ORG} · the organization's row of A over the env-wide package-less row · row order ${order.join(', ')}`, async () => { + it(`[ADR-0131 D6] organization ${ORG} · the organization's legacy row of A is not served over the env-wide package-less row · row order ${order.join(', ')}`, async () => { const protocol = harness(order.map((key) => stored(key))); const slots = await scopedSlots(protocol, PKG_A, { organizationId: ORG }); - expect(slots.map((s) => s.label)).toEqual([ROWS.orgA.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.orgA.label); + expect(slots).toEqual([]); + expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.envPackageless.label); }); } }); @@ -333,10 +333,12 @@ describe('pin 3 — membership: a package-less row of a name the package does no const listedWithout = names(await scopedList(without, PKG_A, scope)); expect(listedWithout).toEqual([`d_rowed@${PKG_A}`, `${NAME}@${PKG_A}`]); expect(names(await scopedList(withRow, PKG_A, scope))).toEqual(listedWithout); - // The row is still served where it belongs: the unscoped list. + // The env-wide row is still served where it belongs: the + // unscoped list. [ADR-0131 D6] A legacy organization row is + // served by no list. const unscoped: any = await withRow.getMetaItems({ type: TYPE, ...scope }); expect((unscoped.items as any[]).filter((i) => i.name === OTHER).map((i) => i.label)) - .toEqual([ROWS[scopeKey].label]); + .toEqual(scopeKey === 'orgPackageless' ? [] : [ROWS[scopeKey].label]); }); } } @@ -347,13 +349,16 @@ describe('pin 3 — membership: a package-less row of a name the package does no describe('pin 4 — the MetadataService layer: a package-less row stands in for the package\'s runtime item', () => { const RUNTIME_A = { name: NAME, label: 'runtime item of A', _packageId: PKG_A }; for (const scopeKey of ['envPackageless', 'orgPackageless'] as const) { - it(`a ${ROWS[scopeKey].label} of the name: the scoped slot serves it, as getMetaItem naming A does`, async () => { + // [ADR-0131 D6] A legacy organization row stands in for nothing: the + // runtime item is served. + const served = scopeKey === 'orgPackageless' ? RUNTIME_A.label : ROWS[scopeKey].label; + it(`a ${ROWS[scopeKey].label} of the name: the scoped slot serves ${served}, as getMetaItem naming A does`, async () => { const protocol = harness([stored(scopeKey)], { runtime: [RUNTIME_A] }); const scope = { organizationId: ORG }; const slots = await scopedSlots(protocol, PKG_A, scope); expect(slots.map((s) => ({ label: s.label, packageId: s._packageId }))) - .toEqual([{ label: ROWS[scopeKey].label, packageId: PKG_A }]); - expect((await byName(protocol, PKG_A, scope)).item?.label).toBe(ROWS[scopeKey].label); + .toEqual([{ label: served, packageId: PKG_A }]); + expect((await byName(protocol, PKG_A, scope)).item?.label).toBe(served); }); } it('control: no runtime item of A, the package-less row adds no slot; lit control: the runtime item alone is served', async () => { @@ -432,15 +437,15 @@ describe('pin 6 — a stored view container of A: a package-less row of a name i describe('pin 7 — the lock: the scoped slot\'s lock family equals getMetaItem\'s envelope where the served row moved', () => { for (const lock of ['no-overlay', 'no-delete', 'full'] as const) { for (const declaredOn of ['orgPackageless', 'envA'] as const) { - it(`organization ${ORG} · the organization's package-less row served over env A · ${ROWS[declaredOn].label} declares ${lock}`, async () => { + it(`[ADR-0131 D6] organization ${ORG} · env A served, the organization's legacy package-less row not · ${ROWS[declaredOn].label} declares ${lock}`, async () => { const rows = (['orgPackageless', 'envA'] as const).map((key) => stored(key, { body: key === declaredOn ? { _lock: lock, _lockReason: `declared on ${key}` } : {} })); const protocol = harness(rows, { items: [ARTIFACT_A] }); const scope = { organizationId: ORG }; const [slot] = await scopedSlots(protocol, PKG_A, scope); const read = await byName(protocol, PKG_A, scope); - expect(slot?.label).toBe(ROWS.orgPackageless.label); - expect(read.item?.label).toBe(ROWS.orgPackageless.label); + expect(slot?.label).toBe(ROWS.envA.label); + expect(read.item?.label).toBe(ROWS.envA.label); expect({ lock: slot?._lock ?? 'none', reason: slot?._lockReason }) .toEqual({ lock: read.lock, reason: read.item?._lockReason }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index bef922a98f2..129da7590c4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9071,13 +9071,17 @@ export class ObjectStackProtocolImplementation implements // per-row verdict now lives in the shared hydrator, which each // row's own `organizationId` answers to. // - // [ADR-0131 D6] An overlay of a sealed managed item is not - // served ({@link isUnservedSealedOverlay}), so it is not - // registered either: the registry keeps the package's - // definition, the item the reads serve. + // [ADR-0131 D6] An `object` overlay of a sealed managed item is + // not served ({@link isUnservedSealedOverlay}), so it is not + // registered either: an object row registers as a LAYER the + // engine resolves the object through, not as a bare copy the + // reads decline, so the registry keeps the package's + // definition. Every other type's row is hydrated as before + // (#20913: the bare copy is the tenant row, which the reads + // decline through {@link isStoredEntryOfDeclinedName}). if (this.environmentId === undefined) { for (const { name, data, packageId: recPkg, organizationId: recOrg } of overlays) { - if (this.isUnservedSealedOverlay(request.type, name)) continue; + if (request.type === 'object' && this.isUnservedSealedOverlay(request.type, name)) continue; this.hydrateOverlayIntoRegistry(request.type, data, { packageId: recPkg, organizationId: recOrg, @@ -25952,9 +25956,12 @@ export class ObjectStackProtocolImplementation implements * environment its own database. * * [ADR-0131 D6, triage ruling Q1 → C] An environment row that overlays - * SEALED managed content ({@link isUnservedSealedOverlay}) is not loaded - * either: the package's definition stays the registered one, and - * {@link reportSealedOverlayRows} names the row with its remedies. + * SEALED managed content ({@link isUnservedSealedOverlay}) is named by + * {@link reportSealedOverlayRows} with its remedies. An `object` such row + * is not loaded: it would register as a layer the engine resolves the + * object through, so the package's definition stays the registered one. + * Every other type's row still registers its bare, tenant-marked copy, + * which the reads decline (#20913, {@link isStoredEntryOfDeclinedName}). * * #3903 — two contract duties run per row, and their split is deliberate: * @@ -26036,7 +26043,7 @@ export class ObjectStackProtocolImplementation implements const sduiManifest = (records as Array<{ type?: unknown }>).some( (r) => (PLURAL_TO_SINGULAR[String(r.type)] ?? r.type) === 'page', ) ? this.resolveSduiManifest() : undefined; - /** [ADR-0131 D6] Overlays of sealed managed content, not loaded. */ + /** [ADR-0131 D6] Overlays of sealed managed content, reported. */ const sealedOverlays: Array<{ type: string; name: string }> = []; for (const record of records) { try { @@ -26044,7 +26051,7 @@ export class ObjectStackProtocolImplementation implements const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type; if (this.isUnservedSealedOverlay(normalizedType, String(record.name))) { sealedOverlays.push({ type: normalizedType, name: String(record.name) }); - continue; + if (normalizedType === 'object') continue; } const data = this.convertStoredItem( String(record.type), @@ -26449,7 +26456,7 @@ export class ObjectStackProtocolImplementation implements console.warn( `[Protocol] [metadata_sealed_overlay_unserved] ${rows.length} active environment sys_metadata row(s) ` + `overlay an item a managed package ships, on a type that is sealed against overlays (ADR-0131 D6): ` + - `${detail}. They are not loaded and no read serves them — the package's own definition is served. ` + + `${detail}. No read serves them — the package's own definition is served. ` + `Each row is kept at rest, untouched. To keep the change, re-express it as a new item under a new ` + `name (a linkage-free clone of the managed item), then delete the stored row; otherwise delete the ` + `stored row.`, diff --git a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts index 15531c6f8d3..093f134891d 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts @@ -112,7 +112,6 @@ const CONFORMS: { [K in keyof DraftHeader]: (value: DraftHeader[K]) => boolean } name: (v) => typeof v === 'string', // [#22200] The draft body's own label: an `I18nLabel` as authored, or `null`. label: (v) => v === null || I18nLabelSchema.safeParse(v).success, - organizationId: (v) => v === null || typeof v === 'string', packageId: (v) => v === null || typeof v === 'string', updatedAt: (v) => v === null || typeof v === 'string', updatedBy: (v) => v === null || typeof v === 'string', @@ -236,9 +235,7 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever expectConformsToDeclaration(drafts); }); - it('reaches the same canonicalisation through the org-scoped $or read', async () => { - // A non-null-org caller sees BOTH its own overlay drafts and the env-wide - // ones (#3115); the canonicalisation must not depend on which arm matched. + it('[ADR-0131 D6] lists the environment\'s drafts only, whichever repository asks: a legacy organization draft is not listed', async () => { const engine = makeFakeEngine([ draftRow({ organization_id: 'org_alpha', updated_at: PG_INSTANT }), draftRow({ name: 'lead_grid', updated_at: PG_INSTANT }), @@ -248,7 +245,7 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever expect(engine.rows[0]!.updated_at).toBeInstanceOf(Date); const drafts = await repo.listDrafts(); - expect(drafts).toHaveLength(2); + expect(drafts.map((d) => d.name)).toEqual(['lead_grid']); for (const draft of drafts) expect(draft.updatedAt).toBe(PG_INSTANT.toISOString()); expectConformsToDeclaration(drafts); }); @@ -367,7 +364,7 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever * table above now covers `label` on every case in the file. */ describe('#22200 — each listDrafts row carries the draft body\'s own label, or null', () => { - const HEADER_KEYS = ['label', 'name', 'organizationId', 'packageId', 'type', 'updatedAt', 'updatedBy']; + const HEADER_KEYS = ['label', 'name', 'packageId', 'type', 'updatedAt', 'updatedBy']; describe('§A drafts saved through the real write path', () => { it('a draft whose body declares a label returns it', async () => { diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index fb4a685694e..152cac16f49 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -293,7 +293,7 @@ describe('#7736 a runtime-authored view container is served', () => { * isolation because it operates only on rows already scoped to THIS * request's own org/environment (the pre-existing `queryByOrg` merge). */ -describe('#13407 org-scoped and environment-scoped runtime containers are served', () => { +describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] a legacy organization container only by the anonymous form doors\' legacy read', () => { it('derives the object binding from the containers own top-level `object` field, not just `list.data.object`', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); @@ -312,17 +312,30 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served expect(switcherMatches(list.items, 'crm_lead').map((v: any) => v.name)).toEqual(['crm_lead.default']); }); - it('the cards own repro: a runtime container authored by a signed-in user with an ACTIVE ORG is now served', async () => { + it('the cards own repro, at the one read left that reaches a legacy organization row: the anonymous form doors\' legacy layer expands it', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); + const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); expect(served).toEqual(['crm_lead.default', 'crm_lead.pipeline']); }); + it('[ADR-0131 D6] every other read serves no legacy organization container, whatever organization it names', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + + for (const request of [{ type: 'view' }, { type: 'view', organizationId: 'org_acme' }]) { + const list: any = await protocol.getMetaItems(request as any); + expect(switcherMatches(list.items, 'crm_lead')).toEqual([]); + } + expect((await protocol.getMetaItem({ type: 'view', name: 'crm_lead.default', organizationId: 'org_acme' } as any)).item) + .toBeUndefined(); + }); + it('POSITIVE CONTROL: a pre-existing independent ViewItem for the same object is still served alongside the newly-expanded container', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); @@ -332,7 +345,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, }); - const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); + const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); // A fix that merely "returned everything" could not distinguish these: // the pre-existing independent item and the two newly-expanded @@ -345,10 +358,10 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - const globex: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_globex' } as any); + const globex: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_globex' }); expect(switcherMatches(globex.items, 'crm_lead')).toEqual([]); - const acme: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); + const acme: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); expect(switcherMatches(acme.items, 'crm_lead')).toHaveLength(2); }); @@ -356,7 +369,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served const { engine, registered } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); // The RESPONSE carries the expansion (re-confirmed above); the SHARED // registry — read by every org/environment this kernel serves — must @@ -632,8 +645,9 @@ describe('#21334 a container on another package\'s object never takes that packa const CONTAINERS = [ { arm: 'package-scoped (saved into another writable package)', packageId: REPAIR, organizationId: undefined, ownPackage: REPAIR }, { arm: 'package-less, environment-wide', packageId: undefined, organizationId: undefined, ownPackage: undefined }, - { arm: 'package-less, organization-scoped', packageId: undefined, organizationId: ORG, ownPackage: undefined }, - ] as const; + // [ADR-0131 D6] No organization-scoped arm: a legacy organization + // container is served by no read (pinned in the ISOLATION case below). + ] as { arm: string; packageId: string | undefined; organizationId: string | undefined; ownPackage: string | undefined }[]; const save = (protocol: Protocol, name: string, item: unknown, c: (typeof CONTAINERS)[number]) => c.organizationId ? plantOrgRow((protocol as any).engine, c.organizationId, name, item) : protocol.saveMetaItem({ @@ -881,19 +895,19 @@ describe('#21334 a container on another package\'s object never takes that packa }); } - it('ISOLATION — an organization-scoped container\'s names answer nothing by name for another organization', async () => { + it('[ADR-0131 D6] ISOLATION — a legacy organization-scoped container\'s names answer nothing, for any caller', async () => { const { protocol } = showcaseHarness(environmentId); - const org = CONTAINERS.find((c) => c.organizationId !== undefined)!; - await save(protocol, OWN, { name: OWN, object: TASK, ...MEMBER_CASES['listViews.*'].member }, org); + await plantOrgRow((protocol as any).engine, ORG, OWN, { name: OWN, object: TASK, ...MEMBER_CASES['listViews.*'].member }); const expanded = MEMBER_CASES['listViews.*'].servedAs; - expect(await byNameDoor(protocol, expanded, ORG)).toBeTruthy(); + expect(await byNameDoor(protocol, expanded, ORG)).toBeUndefined(); + expect(ownNames(await objectDoor(protocol, ORG))).toEqual([]); expect(ownNames(await objectDoor(protocol, 'org_globex'))).toEqual([]); expect(await byNameDoor(protocol, expanded, 'org_globex')).toBeUndefined(); expect(await byNameDoor(protocol, expanded)).toBeUndefined(); }); - for (const organizationId of [undefined, ORG]) { + for (const organizationId of [undefined] as Array) { it(`a tenant overlay of the package's own container (${organizationId ? 'organization-scoped' : 'environment-wide'}): each name it expands answers the overlay's view by name, not the packaged one`, async () => { const { protocol } = showcaseHarness(environmentId); const overlay = { @@ -1006,7 +1020,7 @@ describe('#21334 a container on another package\'s object never takes that packa for (const [kernel, environmentId] of KERNELS) { describe(`on ${kernel}`, () => { - for (const organizationId of [undefined, ORG]) { + for (const organizationId of [undefined] as Array) { const scope = organizationId ? 'organization-scoped' : 'environment-wide'; for (const order of ['the container first', 'the row first'] as const) { it(`${scope}, ${order}: the stored row answers its name on both doors; the row-less expanded name answers the expansion`, async () => { @@ -1043,19 +1057,16 @@ describe('#21334 a container on another package\'s object never takes that packa } } - it('the predicate is read over the caller\'s own rows: an organization\'s row wins for that organization only', async () => { + it('[ADR-0131 D6] a legacy organization row of the name is no caller\'s row: every caller is served the container\'s expansion', async () => { const { protocol } = showcaseHarness(environmentId); await saveView(protocol, TASK, container); await saveView(protocol, DEFAULT, row, ORG); - // The organization that holds the row: the row, on both doors. - await expectBothDoors(protocol, DEFAULT, ORG, expectTheRow); - // A caller for whom the name has no row of its own: the - // container's expansion, on both doors. const expectTheDefaultExpansion = (v: any) => { expect(v?.label).toBe('FromContainer'); expect(v?.config?.columns).toEqual([{ field: 'title' }]); }; + await expectBothDoors(protocol, DEFAULT, ORG, expectTheDefaultExpansion); await expectBothDoors(protocol, DEFAULT, undefined, expectTheDefaultExpansion); await expectBothDoors(protocol, DEFAULT, 'org_globex', expectTheDefaultExpansion); }); @@ -1937,7 +1948,7 @@ describe('#21334 a container on another package\'s object never takes that packa for (const [kernel, environmentId] of KERNELS) { describe(`on ${kernel}`, () => { - for (const organizationId of [undefined, ORG]) { + for (const organizationId of [undefined] as Array) { const scope = organizationId ? 'organization-scoped' : 'environment-wide'; for (const c of AT_REST) { it(`${scope}, ${c.member} stored at ${c.rowName}: ${c.spared} still answers the packaged view on both doors, and the container's view is served under its own name, with no package and no default`, async () => { From f929450d2130bbe36deaa2e5d02837bfb946b157 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:50:43 +0000 Subject: [PATCH 03/21] wip(spec): read request organizationId retired; overlayScope env-only Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- ...rotocol.org-scoped-cold-boot-audit.test.ts | 2 +- packages/spec/src/api/protocol.zod.ts | 80 +++---------------- ...etadata-read-organization-scope-retired.ts | 52 ++++++++++++ 3 files changed, 62 insertions(+), 72 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts diff --git a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts index 3d7495b9c68..dd06dfff4dd 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts @@ -155,7 +155,7 @@ describe('[#6190, ADR-0131 D6] cold boot names every legacy organization-scoped const { result, warns } = await bootAndCapture(engine); // Hydration loads the environment's rows only. - expect(result).toMatchObject({ loaded: 2, errors: 0, invalid: 0, storeUnavailable: false }); + expect(result).toMatchObject({ loaded: 2, errors: 0, storeUnavailable: false }); const line = warns.find((w) => w.includes(AUDIT)); expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index ff35c73e3e5..37d5e921ccb 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -237,15 +237,6 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name (e.g., "object", "plugin")'), packageId: z.string().optional().describe('Optional package ID to filter items by'), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order — org overlay wins ' - + 'over env-wide overlay wins over packaged artifact — so it decides which ' - + 'tenant\'s customization rows are merged into the list. Supplying a value ' - + 'does not by itself guarantee an org partition is consulted; where none ' - + 'applies, and whenever it is absent, the read is environment-wide and ' - + 'only env-level overlays apply.', - ), previewDrafts: z.boolean().optional().describe( 'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, ' + 'pending `state=\'draft\'` rows are overlaid on the active list — draft ' @@ -274,15 +265,6 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name (snake_case identifier)'), packageId: z.string().optional().describe('Optional package ID to filter items by'), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order — org overlay wins ' - + 'over env-wide overlay wins over packaged artifact — so it decides which ' - + 'tenant\'s customization row is served as the item. Supplying a value ' - + 'does not by itself guarantee an org partition is consulted; where none ' - + 'applies, and whenever it is absent, the read is environment-wide and ' - + 'only env-level overlays apply.', - ), state: z.enum(['active', 'draft']).optional().describe( 'Draft-visibility switch — which lifecycle row to read (strict mode): ' + '`\'draft\'` opens the pending draft buffer (Studio\'s editor read) and ' @@ -333,7 +315,7 @@ const MetadataProtectionEnvelopeFields = { 'Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; ' + '`no-overlay` / `no-delete` / `full` mean the write doors refuse the ' + 'corresponding write with 403. Joins two refusals: the document\'s own ' - + '`_lock` (`ITEM_LOCKED`; the packaged artifact wins over any org overlay), ' + + '`_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), ' + 'and the locked packaged base — an item a code package ships, on a type with ' + 'no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the ' + 'write names the read-only package).', @@ -447,16 +429,16 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ ...MetadataProtectionEnvelopeFields, version: z.string().nullable().optional().describe( 'ADR-0008 version token of the STORED row a save to this item compares ' - + 'against, at this read\'s scope (the caller\'s organization partition and ' + + 'against, at this read\'s scope (the environment\'s partition and ' + '`?package=`) and lifecycle (`?state=draft` reads the draft row, the plain ' + 'read the active row). The same keyed token a save receipt\'s `version` ' + 'serves for that row: send it back as the `If-Match` request header on ' + '`PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a ' + 'concurrent edit is refused 409 `METADATA_CONFLICT` instead of ' + 'overwritten. `null` when no stored row is there — an item served from ' - + 'code or a package artifact, or from a row in another scope (the ' - + 'environment-wide row an organization falls back to, a package-less row a ' - + 'package read falls back to) — so the next save creates one: pin it with ' + + 'code or a package artifact, or from a row in another scope (a ' + + 'package-less row a package read falls back to) — so the next save ' + + 'creates one: pin it with ' + '`If-None-Match: *`. ABSENT on the cached published-value branch and on a ' + '`?preview=draft` read, which publish no version: absence means "not ' + 'published here", never "no row". Opaque — echo it verbatim, never parse ' @@ -480,15 +462,6 @@ export const GetMetaItemLayeredRequestSchema = lazySchema(() => z.object({ 'Optional package ID — scopes the `code` layer so a same-name collision ' + 'resolves to the requested package\'s artifact (ADR-0048).', ), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order, so it decides which ' - + 'tenant\'s customization row is reported as the `overlay` layer (and ' - + 'merged into `effective`). Supplying a value does not by itself guarantee ' - + 'an org partition is consulted; where none applies, and whenever it is ' - + 'absent, the read is environment-wide: `overlay` reports the env-level ' - + 'row only.', - ), })); /** @@ -533,11 +506,11 @@ export const GetMetaItemLayeredResponseSchema = lazySchema(() => z.object({ ), overlay: z.unknown().describe( 'LAYER 2 — the stored customization row ALONE, not merged with `code`. ' - + '`null` when this tenant has not customized the item.', + + '`null` when the environment has not customized the item.', ), - overlayScope: z.enum(['org', 'env']).nullable().describe( - 'Which scope the `overlay` row was read from — `org` for a tenant overlay, ' - + '`env` for an environment-level one. `null` exactly when `overlay` is null.', + overlayScope: z.enum(['env']).nullable().describe( + 'Which scope the `overlay` row was read from: `env`, the environment layer — ' + + 'the only layer a read serves (ADR-0131 D6). `null` exactly when `overlay` is null.', ), effective: z.unknown().describe( 'LAYER 3 — the merged result, i.e. the value an ordinary ' @@ -1490,15 +1463,6 @@ export const DeleteMetaItemResponseSchema = lazySchema(() => z.object({ export const AuditMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), - organizationId: z.string().nullable().optional().describe( - 'Organization (tenant) scope for the read. With an organization, ' - + 'the trail includes that org\'s rows AND the env-wide ' - + '(`organization_id IS NULL`) rows — the env-wide limb is load-bearing, ' - + 'because env-level writes are stamped org-less. `null` and absent are ' - + 'equivalent and both mean the env-wide rows only — the fail-closed ' - + 'direction: an unresolved organization reads env-wide rows, never every ' - + 'tenant\'s.', - ), limit: z.number().optional().describe( 'Maximum events to return, newest first. The implementation clamps to ' + '[1, 500] and defaults to 100 — out-of-range values are clamped, never ' @@ -1593,19 +1557,6 @@ export const AuditMetaItemResponseSchema = lazySchema(() => z.object({ export const HistoryMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), - organizationId: z.string().optional().describe( - 'Organization (tenant) partition the change log is read from. Absent ' - + 'means the env-wide partition (the implementation resolves the overlay ' - + 'repository as `organizationId ?? null`, keyed `org: \'env\'`). ' - + 'Declared because the implementation declares and reads it; plain ' - + '`string`, not nullable, because that is the implementation\'s ' - + 'parameter type — unlike the audit twin, whose door always sends ' - + '`ctx?.tenantId ?? null` and whose implementation declares ' - + '`string | null`. The REST history door currently sends no ' - + 'organization at all (whether it should is a tenant-scoping question ' - + 'measured separately for that door — declaring the member records ' - + 'the implementation contract, it does not answer that question).', - ), sinceSeq: z.number().optional().describe( 'Exclusive lower bound on `seq` for pagination: only events with ' + '`seq > sinceSeq` are returned. Absent means "from the beginning".', @@ -1780,9 +1731,6 @@ export const ListDraftsResponseSchema = lazySchema(() => z.object({ + 'locale map (`I18nLabel`), resolved by the reader. `null` when the body declares ' + 'none, or none this shape admits — never the item name standing in for it.', ), - organizationId: z.string().nullable().describe( - 'Owning organization of the draft row, `null` for an environment-wide draft.', - ), packageId: z.string().nullable().describe( 'Package the draft is bound to, `null` for a package-less draft.', ), @@ -1916,16 +1864,6 @@ export const GetMetaItemCachedRequestSchema = lazySchema(() => z.object({ + 'never returns a stale-locale 304 — metadata is translated *after* the ' + 'cache validator check (issue).', ), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order — org overlay wins ' - + 'over env-wide overlay wins over packaged artifact — exactly as on the ' - + 'uncached read. Also ' - + 'folded into the ETag, so a scope switch never returns a stale 304 from ' - + 'another scope\'s cached representation. Supplying a value does not by ' - + 'itself guarantee an org partition is consulted; where none applies, and ' - + 'whenever it is absent, the read is environment-wide.', - ), })); /** diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts new file mode 100644 index 00000000000..bf6dc03cc12 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D6 (C5, stage S5) — the read half of the retirement whose write half +// `metadata-write-organization-scope-refused` records: every metadata read is +// environment → code. Registered because a read that named an organization +// served that organization's rows and now serves the environment's, because +// the `organizationId` key leaves six declared request shapes and the drafts +// listing, and because `overlayScope` loses its `org` value. +export const entry: SemanticMigration = { + id: 'metadata-read-organization-scope-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId member of the GetMetaItems, GetMetaItem, GetMetaItemLayered, AuditMetaItem, ' + + 'HistoryMetaItem and GetMetaItemCached request schemas and of each ListDraftsResponse draft of ' + + '@objectstack/spec, and the org value of GetMetaItemLayeredResponse overlayScope; and every ' + + 'metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: ' + + 'the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search ' + + 'and references reads, and the boot hydration — plus an environment overlay row of an item a ' + + 'managed package ships on a type sealed against overlays (a managed flow, action, hook or ' + + 'object), which the reads served over the package definition', + replacement: + 'drop `organizationId` from the read request: every read serves the environment row, else the ' + + 'code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored ' + + 'organization-scoped is served by no read; boot names each one, per type, and the v18 migration ' + + 'ceremony (ADR-0131 D10) carries it — nothing is deleted or rewritten before it. An environment ' + + 'overlay of sealed managed content is not served either, and boot names it: keep the change by ' + + 're-expressing it as a new item under a new name (a linkage-free clone), or delete the stored ' + + 'row. The anonymous public-form doors alone keep reading the Default Organization\'s legacy ' + + 'view rows, fail-closed, until the ceremony carries them', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata belongs to the ' + + 'whole deployment. The doors stopped carrying an organization ' + + '(meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped ' + + 'write (metadata-write-organization-scope-refused); a read that still served a legacy ' + + 'organization row would keep a retired layer live, one caller at a time. D6 also seals managed ' + + 'content: an overlay of a managed flow, action, hook or object sits in no regime D6 recognises, ' + + 'so the package definition wins at read as the write doors already make it win at write. ' + + 'Permission sets keep their own ruling on stored forks.', + acceptanceCriteria: + 'With a legacy organization-scoped view row and an environment row of the same name stored, ' + + 'getMetaItem, getMetaItems, getMetaItemLayered and getMetaItemCached serve the environment row ' + + 'whether or not the request names the organization, and the organization-only name answers ' + + 'nothing; historyMetaItem, auditMetaItem, listDrafts and listCommits show no legacy ' + + 'organization row; the ETag is one validator for every caller; overlayScope is env or null. A ' + + 'stored environment row of an action a managed package ships is not served — the package ' + + 'definition is. Boot logs metadata_org_scoped_unserved and metadata_sealed_overlay_unserved, ' + + 'per type. Remove organizationId from any typed read request literal: the key no longer ' + + 'type-checks.', +}; From 19056851b39c8045979f7eb7e3251ead0d36b544 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:53:27 +0000 Subject: [PATCH 04/21] wip(spec): authorable surface + migration registry Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- packages/spec/authorable-surface/api.json | 6 --- packages/spec/src/migrations/registry.ts | 48 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 93ac9d2a8b0..31e609358d2 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -206,7 +206,6 @@ "api/AssembledInstalledPackage:upgradeHistory", "api/AuditMetaItemRequest:limit", "api/AuditMetaItemRequest:name", - "api/AuditMetaItemRequest:organizationId", "api/AuditMetaItemRequest:type", "api/AuditMetaItemResponse:events", "api/AuthEndpoint:forgetPassword", @@ -748,7 +747,6 @@ "api/GetMetaItemCachedRequest:cacheRequest", "api/GetMetaItemCachedRequest:locale", "api/GetMetaItemCachedRequest:name", - "api/GetMetaItemCachedRequest:organizationId", "api/GetMetaItemCachedRequest:type", "api/GetMetaItemCachedResponse:cacheControl", "api/GetMetaItemCachedResponse:data", @@ -757,7 +755,6 @@ "api/GetMetaItemCachedResponse:notModified", "api/GetMetaItemCachedResponse:version", "api/GetMetaItemLayeredRequest:name", - "api/GetMetaItemLayeredRequest:organizationId", "api/GetMetaItemLayeredRequest:packageId", "api/GetMetaItemLayeredRequest:type", "api/GetMetaItemLayeredResponse:_diagnostics", @@ -778,7 +775,6 @@ "api/GetMetaItemLayeredResponse:resettable", "api/GetMetaItemLayeredResponse:type", "api/GetMetaItemRequest:name", - "api/GetMetaItemRequest:organizationId", "api/GetMetaItemRequest:packageId", "api/GetMetaItemRequest:previewDrafts", "api/GetMetaItemRequest:state", @@ -798,7 +794,6 @@ "api/GetMetaItemResponse:sortability", "api/GetMetaItemResponse:type", "api/GetMetaItemResponse:version", - "api/GetMetaItemsRequest:organizationId", "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:previewDrafts", "api/GetMetaItemsRequest:type", @@ -849,7 +844,6 @@ "api/GetUiViewResponse:protection", "api/HistoryMetaItemRequest:limit", "api/HistoryMetaItemRequest:name", - "api/HistoryMetaItemRequest:organizationId", "api/HistoryMetaItemRequest:sinceSeq", "api/HistoryMetaItemRequest:type", "api/HistoryMetaItemResponse:events", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e9f30c7d6fa..cf8840d7930 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16333,6 +16333,54 @@ const step18: MigrationStep = { + 'set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, ' + 'before and after.', }, + // ADR-0131 D6 (C5, stage S5) — the read half of the retirement whose write half + // `metadata-write-organization-scope-refused` records: every metadata read is + // environment → code. Registered because a read that named an organization + // served that organization's rows and now serves the environment's, because + // the `organizationId` key leaves six declared request shapes and the drafts + // listing, and because `overlayScope` loses its `org` value. + { + id: 'metadata-read-organization-scope-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId member of the GetMetaItems, GetMetaItem, GetMetaItemLayered, AuditMetaItem, ' + + 'HistoryMetaItem and GetMetaItemCached request schemas and of each ListDraftsResponse draft of ' + + '@objectstack/spec, and the org value of GetMetaItemLayeredResponse overlayScope; and every ' + + 'metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: ' + + 'the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search ' + + 'and references reads, and the boot hydration — plus an environment overlay row of an item a ' + + 'managed package ships on a type sealed against overlays (a managed flow, action, hook or ' + + 'object), which the reads served over the package definition', + replacement: + 'drop `organizationId` from the read request: every read serves the environment row, else the ' + + 'code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored ' + + 'organization-scoped is served by no read; boot names each one, per type, and the v18 migration ' + + 'ceremony (ADR-0131 D10) carries it — nothing is deleted or rewritten before it. An environment ' + + 'overlay of sealed managed content is not served either, and boot names it: keep the change by ' + + 're-expressing it as a new item under a new name (a linkage-free clone), or delete the stored ' + + 'row. The anonymous public-form doors alone keep reading the Default Organization\'s legacy ' + + 'view rows, fail-closed, until the ceremony carries them', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata belongs to the ' + + 'whole deployment. The doors stopped carrying an organization ' + + '(meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped ' + + 'write (metadata-write-organization-scope-refused); a read that still served a legacy ' + + 'organization row would keep a retired layer live, one caller at a time. D6 also seals managed ' + + 'content: an overlay of a managed flow, action, hook or object sits in no regime D6 recognises, ' + + 'so the package definition wins at read as the write doors already make it win at write. ' + + 'Permission sets keep their own ruling on stored forks.', + acceptanceCriteria: + 'With a legacy organization-scoped view row and an environment row of the same name stored, ' + + 'getMetaItem, getMetaItems, getMetaItemLayered and getMetaItemCached serve the environment row ' + + 'whether or not the request names the organization, and the organization-only name answers ' + + 'nothing; historyMetaItem, auditMetaItem, listDrafts and listCommits show no legacy ' + + 'organization row; the ETag is one validator for every caller; overlayScope is env or null. A ' + + 'stored environment row of an action a managed package ships is not served — the package ' + + 'definition is. Boot logs metadata_org_scoped_unserved and metadata_sealed_overlay_unserved, ' + + 'per type. Remove organizationId from any typed read request literal: the key no longer ' + + 'type-checks.', + }, // ADR-0131 D6 (C5, stage S4) — the protocol half of the door narrowing // `meta-doors-organization-scope-retired` records: the metadata protocol itself // refuses every organization-scoped write, and the per-organization write path From 0a610f26364303d47cf85c180d58b3ffc2f71665 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:59:46 +0000 Subject: [PATCH 05/21] wip(spec): read-scope pins and regenerated reference Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 17 ++-- .../src/api/meta-item-response-shapes.test.ts | 12 +-- packages/spec/src/api/protocol.test.ts | 87 ++++++------------- 3 files changed, 39 insertions(+), 77 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index fedea66a1ba..be67a2683ea 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -291,7 +291,6 @@ const result = AiAgentCapabilitiesSchema.parse(data); | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | -| **organizationId** | `string \| null` | optional | Organization (tenant) scope for the read. With an organization, the trail includes that org's rows AND the env-wide (`organization_id IS NULL`) rows — the env-wide limb is load-bearing, because env-level writes are stamped org-less. `null` and absent are equivalent and both mean the env-wide rows only — the fail-closed direction: an unresolved organization reads env-wide rows, never every tenant's. | | **limit** | `number` | optional | Maximum events to return, newest first. The implementation clamps to [1, 500] and defaults to 100 — out-of-range values are clamped, never refused. | @@ -1223,7 +1222,6 @@ Enable package response | **name** | `string` | ✅ | Item name | | **cacheRequest** | `{ ifNoneMatch?: string; ifModifiedSince?: string; cacheControl?: object }` | optional | Cache validation parameters | | **locale** | `string` | optional | Resolved response locale. Folded into the ETag so a language switch never returns a stale-locale 304 — metadata is translated *after* the cache validator check (issue). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — exactly as on the uncached read. Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide. | ### Nested Shape: `GetMetaItemCachedRequest.cacheRequest` @@ -1277,7 +1275,6 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **packageId** | `string` | optional | Optional package ID — scopes the `code` layer so a same-name collision resolves to the requested package's artifact (ADR-0048). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order, so it decides which tenant's customization row is reported as the `overlay` layer (and merged into `effective`). Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide: `overlay` reports the env-level row only. | --- @@ -1291,8 +1288,8 @@ Enable package response | **type** | `string` | ✅ | Metadata type name (canonical singular) | | **name** | `string` | ✅ | Item name | | **code** | `any` | ✅ | LAYER 1 — the packaged artifact baseline exactly as shipped, before any tenant customization. `null` when no artifact ships this item (it exists only as an overlay). | -| **overlay** | `any` | ✅ | LAYER 2 — the stored customization row ALONE, not merged with `code`. `null` when this tenant has not customized the item. | -| **overlayScope** | `Enum<'org' \| 'env'> \| null` | ✅ | Which scope the `overlay` row was read from — `org` for a tenant overlay, `env` for an environment-level one. `null` exactly when `overlay` is null. | +| **overlay** | `any` | ✅ | LAYER 2 — the stored customization row ALONE, not merged with `code`. `null` when the environment has not customized the item. | +| **overlayScope** | `Enum<'env'> \| null` | ✅ | Which scope the `overlay` row was read from: `env`, the environment layer — the only layer a read serves (ADR-0131 D6). `null` exactly when `overlay` is null. | | **effective** | `any` | ✅ | LAYER 3 — the merged result, i.e. the value an ordinary `GET /meta/:type/:name` would return under `item`. `null` when the item resolves to nothing at all. | | **_diagnostics** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | optional | Load-time spec-validation verdict for `effective`, so the Studio edit page can raise invalid-metadata banners and inline field errors without a second round trip. ABSENT for metadata types that register no Zod schema (function / service / router) — absence means "no opinion", never "valid". | | **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | ✅ | Resolved lock verdict (ADR-0010 §3.3), artifact winning over overlay. Always present on this path. | @@ -1326,7 +1323,6 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name (snake_case identifier) | | **packageId** | `string` | optional | Optional package ID to filter items by | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization row is served as the item. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide and only env-level overlays apply. | | **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. | | **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): when true and `state` is not `'draft'`, a pending draft row is preferred if one exists, else the read falls back to the active row — the render path degrades to the published value instead of erroring. A served draft is tagged `_draft: true` so UIs can badge it. Declaration ≠ authorization: this member only switches which row is read, and ADR-0106 masking is unaffected — draft preview is admin-gated upstream, not by this schema. | @@ -1343,7 +1339,7 @@ Enable package response | **name** | `string` | ✅ | Item name | | **item** | `any` | ✅ | Metadata item definition | | **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | -| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any org overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | | **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | | **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | | **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | @@ -1353,7 +1349,7 @@ Enable package response | **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | | **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | -| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (the environment-wide row an organization falls back to, a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1372,7 +1368,6 @@ Enable package response | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") | | **packageId** | `string` | optional | Optional package ID to filter items by | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization rows are merged into the list. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide and only env-level overlays apply. | | **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview): when true, pending `state='draft'` rows are overlaid on the active list — draft wins on name collision, draft-only items appear, and each overlaid item is tagged `_draft: true` so UIs can badge the preview. Absent/false = published world only. Declaration ≠ authorization: this member only switches which rows are read, and ADR-0106 masking is unaffected — callers without draft-preview authorization are refused upstream (admin-gated), not by this schema. | @@ -1854,7 +1849,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | -| **organizationId** | `string` | optional | Organization (tenant) partition the change log is read from. Absent means the env-wide partition (the implementation resolves the overlay repository as `organizationId ?? null`, keyed `org: 'env'`). Declared because the implementation declares and reads it; plain `string`, not nullable, because that is the implementation's parameter type — unlike the audit twin, whose door always sends `ctx?.tenantId ?? null` and whose implementation declares `string \| null`. The REST history door currently sends no organization at all (whether it should is a tenant-scoping question measured separately for that door — declaring the member records the implementation contract, it does not answer that question). | | **sinceSeq** | `number` | optional | Exclusive lower bound on `seq` for pagination: only events with `seq > sinceSeq` are returned. Absent means "from the beginning". | | **limit** | `number` | optional | Maximum events to return, oldest first. Forwarded to the repository unclamped and with NO default — absent means the full remaining change log. (Deliberately no declared bounds: unlike the audit twin's [1, 500] clamp, nothing on this path clamps or refuses, so declaring `.min()`/`.max()` here would refuse values the shipped verb accepts.) | @@ -2086,7 +2080,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **drafts** | `{ type: string; name: string; label: string \| Record \| null; organizationId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | +| **drafts** | `{ type: string; name: string; label: string \| Record \| null; packageId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | ### Nested Shape: `ListDraftsResponse.drafts[number]` @@ -2095,7 +2089,6 @@ Install package response | **type** | `string` | ✅ | Metadata type name (canonical singular). | | **name** | `string` | ✅ | Item name. | | **label** | `string \| Record \| null` | ✅ | The draft body's own top-level `label`, as authored: a plain string or an inline locale map (`I18nLabel`), resolved by the reader. `null` when the body declares none, or none this shape admits — never the item name standing in for it. | -| **organizationId** | `string \| null` | ✅ | Owning organization of the draft row, `null` for an environment-wide draft. | | **packageId** | `string \| null` | ✅ | Package the draft is bound to, `null` for a package-less draft. | | **updatedAt** | `string \| null` | ✅ | Last-touch timestamp of the draft row (ISO-8601 string), `null` when the row recorded none. | | **updatedBy** | `string \| null` | ✅ | Who last touched the draft, `null` when the row recorded none. | diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index 683fc6f3775..3280179b1af 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -149,7 +149,7 @@ describe('GetMetaItemLayeredResponseSchema — the three-layer projection', () = name: 'customer', code: { name: 'customer', label: 'Customer' }, overlay: { label: 'Client' }, - overlayScope: 'org', + overlayScope: 'env', effective: { name: 'customer', label: 'Client' }, _diagnostics: { valid: true }, lock: 'none', @@ -166,7 +166,7 @@ describe('GetMetaItemLayeredResponseSchema — the three-layer projection', () = expect(parsed.code).toEqual({ name: 'customer', label: 'Customer' }); expect(parsed.overlay).toEqual({ label: 'Client' }); expect(parsed.effective).toEqual({ name: 'customer', label: 'Client' }); - expect(parsed.overlayScope).toBe('org'); + expect(parsed.overlayScope).toBe('env'); expect(parsed._diagnostics).toEqual({ valid: true }); }); @@ -196,10 +196,10 @@ describe('GetMetaItemLayeredResponseSchema — the three-layer projection', () = } }); - it('rejects an overlayScope outside `org` | `env`', () => { - expect(() => - GetMetaItemLayeredResponseSchema.parse({ ...LAYERED_BODY, overlayScope: 'package' }), - ).toThrow(); + it('[ADR-0131 D6] rejects an overlayScope other than `env` — `org` included: no read serves an organization\'s row', () => { + for (const overlayScope of ['package', 'org']) { + expect(() => GetMetaItemLayeredResponseSchema.parse({ ...LAYERED_BODY, overlayScope })).toThrow(); + } }); it('is a DIFFERENT shape from the ordinary read — which is why it got its own path', () => { diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 443ad278942..350d0b7aef3 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1597,16 +1597,12 @@ import { GetMetaItemLayeredRequestSchema, } from './protocol.zod'; -describe('meta-read request schemas declare organizationId (declared = enforced)', () => { - // The implementation (`@objectstack/metadata-protocol`) accepts and HONOURS - // `organizationId` on all four read verbs: it selects the org partition in - // the ADR-0005 overlay read order, i.e. it decides which tenant's row is - // served. These pins hold the DECLARED surface to that enforced shape. - // - // Why the accept-pin asserts the parsed VALUE, not just `success`: these are - // non-strict objects, so before #9726 a request carrying `organizationId` - // still parsed green — the member was silently STRIPPED. Presence in the - // parse OUTPUT is the observable that actually changed. +describe('[ADR-0131 D6] meta-read request schemas declare no organizationId (declared = enforced)', () => { + // Every metadata read is environment → code: the implementation + // (`@objectstack/metadata-protocol`) reads no organization on any read + // verb, so the declared shape carries none. These are non-strict objects, + // so a request still carrying the retired key parses green with the member + // STRIPPED — absence from the parse OUTPUT is the observable. const cases = [ ['GetMetaItemsRequestSchema', GetMetaItemsRequestSchema, { type: 'object' }], ['GetMetaItemRequestSchema', GetMetaItemRequestSchema, { type: 'view', name: 'account_list' }], @@ -1614,37 +1610,27 @@ describe('meta-read request schemas declare organizationId (declared = enforced) ['GetMetaItemLayeredRequestSchema', GetMetaItemLayeredRequestSchema, { type: 'view', name: 'account_list' }], ] as const; - it.each(cases)('%s accepts organizationId and PRESERVES it through parse', (_n, schema, base) => { + it.each(cases)('%s strips a retired organizationId at parse', (_n, schema, base) => { const result = schema.safeParse({ ...base, organizationId: 'org_alpha' }); expect(result.success).toBe(true); - if (result.success) { - expect((result.data as { organizationId?: string }).organizationId).toBe('org_alpha'); - } - }); - - it.each(cases)('%s keeps organizationId OPTIONAL — an env-wide (org-less) read stays valid', (_n, schema, base) => { - const result = schema.safeParse(base); - expect(result.success).toBe(true); if (result.success) { expect('organizationId' in (result.data as object)).toBe(false); } }); - it.each(cases)('%s rejects a non-string organizationId — the scope key is a value, not a bag', (_n, schema, base) => { - expect(schema.safeParse({ ...base, organizationId: 42 }).success).toBe(false); - expect(schema.safeParse({ ...base, organizationId: { id: 'org_alpha' } }).success).toBe(false); + it.each(cases)('%s declares no organizationId member', (_n, schema) => { + expect('organizationId' in (schema as unknown as { shape: object }).shape).toBe(false); }); it('GetMetaItemLayeredRequestSchema mirrors the implementation inline type member for member', () => { - // `{ type, name, packageId?, organizationId? }` — the exact parameter type - // of `getMetaItemLayered` in metadata-protocol. packageId scopes the code + // `{ type, name, packageId? }` — the exact parameter type of + // `getMetaItemLayered` in metadata-protocol. packageId scopes the code // layer (ADR-0048); nothing else is declared because nothing else is // enforced. const full = GetMetaItemLayeredRequestSchema.safeParse({ type: 'view', name: 'account_list', packageId: 'pkg_crm', - organizationId: 'org_alpha', }); expect(full.success).toBe(true); expect(GetMetaItemLayeredRequestSchema.safeParse({ type: 'view' }).success).toBe(false); @@ -1944,8 +1930,8 @@ describe('AuditMetaItemRequestSchema mirrors the implementation parameter type', // commit cccbe51bf): NEITHER side was declared, and the REST call site reached the // verb through `(p as any)` twice (guard + call). The measure is the // implementation's parameter type in `@objectstack/metadata-protocol` — - // `{ type, name, organizationId?: string | null, limit?: number }` — and the - // REST door's actual sends; nothing else is declared because nothing else is + // `{ type, name, limit?: number }` (ADR-0131 D6 retired `organizationId`) — + // and the REST door's actual sends; nothing else is declared because nothing else is // enforced. As in the #9726 / commit 2a29caa53 / commit cccbe51bf blocks above, // accept-pins assert the parsed VALUE: this is a non-strict object, so // `success` alone is exactly the silent-strip state this family of cards closes. @@ -1953,7 +1939,7 @@ describe('AuditMetaItemRequestSchema mirrors the implementation parameter type', const base = { type: 'view', name: 'account_list' } as const; it('accepts the full request and PRESERVES every member through parse', () => { - const full = { ...base, organizationId: 'org_alpha', limit: 50 }; + const full = { ...base, limit: 50 }; const result = AuditMetaItemRequestSchema.safeParse(full); expect(result.success).toBe(true); if (result.success) { @@ -1967,18 +1953,12 @@ describe('AuditMetaItemRequestSchema mirrors the implementation parameter type', expect(AuditMetaItemRequestSchema.safeParse({ name: 'account_list' }).success).toBe(false); }); - it('organizationId accepts null AND preserves it — the REST door always sends it, possibly null', () => { - // The door sends `ctx?.tenantId ?? null` unconditionally (#8747's - // fail-closed scoping), so `null` must be a declared spelling for the - // literal to compile at all. Semantically `null` and absent are the same - // env-wide read; the pin is that the parse neither refuses nor strips it. - const withNull = AuditMetaItemRequestSchema.safeParse({ ...base, organizationId: null }); - expect(withNull.success).toBe(true); - if (withNull.success) { - expect('organizationId' in (withNull.data as object)).toBe(true); - expect((withNull.data as { organizationId?: string | null }).organizationId).toBeNull(); + it('[ADR-0131 D6] a retired organizationId is STRIPPED at parse — the trail is the environment\'s', () => { + for (const organizationId of ['org_alpha', null]) { + const result = AuditMetaItemRequestSchema.safeParse({ ...base, organizationId }); + expect(result.success).toBe(true); + if (result.success) expect('organizationId' in (result.data as object)).toBe(false); } - expect(AuditMetaItemRequestSchema.safeParse({ ...base, organizationId: 42 }).success).toBe(false); }); it('limit is an optional number — values, not bags', () => { @@ -2076,8 +2056,11 @@ describe('MetadataProtocol declares auditMetaItem', () => { }); it('refuses an undeclared key at the member call shape', () => { - const good: AuditMetaItemRequest = { type: 'view', name: 'account_list', organizationId: null }; + const good: AuditMetaItemRequest = { type: 'view', name: 'account_list', limit: 5 }; expect(good.type).toBe('view'); + // @ts-expect-error `organizationId` is retired (ADR-0131 D6): no metadata read is organization-scoped. + const withOrg: AuditMetaItemRequest = { type: 'view', name: 'account_list', organizationId: null }; + expect(withOrg.name).toBe('account_list'); // @ts-expect-error `environmentId` is transport-level (commit 2a29caa53) — not a declared request member (and #8747 removed it from this door's wire payload entirely). const withEnv: AuditMetaItemRequest = { type: 'view', name: 'account_list', environmentId: 'env_a' }; expect(withEnv.name).toBe('account_list'); @@ -2097,8 +2080,8 @@ describe('HistoryMetaItemRequestSchema mirrors the implementation parameter type // declared, the REST call site reaching the verb through `(p as any)` twice // (guard + call). The measure is the implementation's parameter type in // `@objectstack/metadata-protocol` — - // `{ type, name, organizationId?: string, sinceSeq?: number, limit?: number }` - // — and the REST door's actual sends; nothing else is declared because + // `{ type, name, sinceSeq?: number, limit?: number }` (ADR-0131 D6 retired + // `organizationId`) — and the REST door's actual sends; nothing else is declared because // nothing else is enforced. As in the #11678 block above, accept-pins // assert the parsed VALUE: this is a non-strict object, so `success` alone // is exactly the silent-strip state this family of cards closes. @@ -2106,7 +2089,7 @@ describe('HistoryMetaItemRequestSchema mirrors the implementation parameter type const base = { type: 'view', name: 'account_list' } as const; it('accepts the full request and PRESERVES every member through parse', () => { - const full = { ...base, organizationId: 'org_alpha', sinceSeq: 3, limit: 50 }; + const full = { ...base, sinceSeq: 3, limit: 50 }; const result = HistoryMetaItemRequestSchema.safeParse(full); expect(result.success).toBe(true); if (result.success) { @@ -2120,21 +2103,10 @@ describe('HistoryMetaItemRequestSchema mirrors the implementation parameter type expect(HistoryMetaItemRequestSchema.safeParse({ name: 'account_list' }).success).toBe(false); }); - it('organizationId is an optional string — the implementation\'s declared type, not the audit twin\'s nullable', () => { - // The audit twin declares `string | null` because ITS implementation - // does and its door always sends `ctx?.tenantId ?? null`. This - // implementation declares plain `organizationId?: string`, and this door - // sends no organization at all — so the mirror is `.optional()` without - // `.nullable()`. (Whether the door SHOULD send one is the #8747-family - // measurement the card fences to a future issue, deliberately not - // answered by this declaration.) + it('[ADR-0131 D6] a retired organizationId is STRIPPED at parse — the change log is the environment\'s', () => { const withOrg = HistoryMetaItemRequestSchema.safeParse({ ...base, organizationId: 'org_alpha' }); expect(withOrg.success).toBe(true); - if (withOrg.success) { - expect((withOrg.data as { organizationId?: string }).organizationId).toBe('org_alpha'); - } - expect(HistoryMetaItemRequestSchema.safeParse(base).success).toBe(true); - expect(HistoryMetaItemRequestSchema.safeParse({ ...base, organizationId: 42 }).success).toBe(false); + if (withOrg.success) expect('organizationId' in (withOrg.data as object)).toBe(false); }); it('sinceSeq and limit are optional numbers — values, not bags', () => { @@ -2620,7 +2592,6 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { type: 'view', name: 'account_pipeline', label: 'Account Pipeline', - organizationId: 'org_01', packageId: 'com.example.crm', updatedAt: '2026-08-27T09:12:44.000Z', updatedBy: 'admin@objectos.ai', @@ -2629,7 +2600,6 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { type: 'app', name: 'field_service', label: { en: 'Field Service', 'zh-CN': '现场服务' }, - organizationId: null, packageId: 'com.example.fsm', updatedAt: '2026-08-27T09:13:02.000Z', updatedBy: 'admin@objectos.ai', @@ -2638,7 +2608,6 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { type: 'object', name: 'lead_source', label: null, - organizationId: null, packageId: null, updatedAt: null, updatedBy: null, From f4526ed80b064004542b2119503cb43a6cf0e106 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:24:09 +0000 Subject: [PATCH 06/21] =?UTF-8?q?wip(rest,plugin-security,objectql,runtime?= =?UTF-8?q?):=20S5=20callers=20read=20environment=20=E2=86=92=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- packages/objectql/src/plugin.ts | 33 +++++---- .../src/permission-set-overlay-discard.ts | 2 +- .../src/permission-set-projection.test.ts | 8 +-- .../src/permission-set-projection.ts | 16 ++--- .../rest/src/execctx-consumer-census.test.ts | 16 ++--- .../rest/src/public-form-withdrawal.test.ts | 30 ++++---- packages/rest/src/rest-server.ts | 70 ++++++++++--------- .../meta-read-org-scope-parity.test.ts | 24 ++++--- .../packages-seed-apply-org-scope.test.ts | 19 ++--- packages/runtime/src/domains/packages.ts | 24 +++---- .../runtime/src/meta-write-org-scope.test.ts | 26 ++----- 11 files changed, 127 insertions(+), 141 deletions(-) diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 9b18363e90e..fb431aab273 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -2379,11 +2379,14 @@ export class ObjectQLPlugin implements Plugin { * Read the ACTIVE runtime-authored hook rows from `sys_metadata`. * * Reads the table directly (like `protocol.getMetaItems` does) instead of - * going through the metadata service, because (a) env-scoped kernels have - * no DatabaseLoader so the service never surfaces these rows, and (b) rows - * published from a Studio session are org-scoped — engine hooks fire - * process-wide, so we take active rows across ALL organizations rather - * than one org's overlay view. + * going through the metadata service, because env-scoped kernels have no + * DatabaseLoader so the service never surfaces these rows. + * + * [ADR-0131 D6] The environment's rows only (`organization_id IS NULL`), + * the rule every metadata read follows: a legacy organization-scoped hook + * row is not bound — the protocol's boot report + * (`reportUnhydratableOrgScopedRows`) names it, and the v18 migration + * ceremony carries it. * * Returns `null` when the read failed (e.g. no sys_metadata table on this * kernel) — callers must treat that as "couldn't read", NOT "zero hooks", @@ -2395,20 +2398,20 @@ export class ObjectQLPlugin implements Plugin { // No environment filter: per ADR-0005 (revised 2026-05) each // environment has its own physical DB, so this kernel's sys_metadata // only ever holds its own rows (saveMetaItem no longer stamps - // environment_id). Rows across ALL organizations are taken — engine - // hooks fire process-wide, matching flow-trigger semantics. + // environment_id). [ADR-0131 D6] The environment's rows only — see + // the TSDoc. // // [#21911, ADR-0096] The explicit system opt-in: a boot / resync read // of the platform store with no caller behind it, never a principal-less // engine context. let rows: any[] = (await this.ql.find('sys_metadata', { - where: { type: 'hook', state: 'active' }, + where: { type: 'hook', state: 'active', organization_id: null }, context: { isSystem: true }, })) ?? []; if (rows.length === 0) { // Legacy plural rows — mirrors getMetaItems' singular/plural fallback. rows = (await this.ql.find('sys_metadata', { - where: { type: 'hooks', state: 'active' }, + where: { type: 'hooks', state: 'active', organization_id: null }, context: { isSystem: true }, })) ?? []; } @@ -2564,9 +2567,9 @@ export class ObjectQLPlugin implements Plugin { * handler still needs registering. * * Same read discipline as {@link readAuthoredHookRows}: direct table read - * (env-scoped kernels surface authored rows nowhere else), all - * organizations (engine actions are process-wide), `null` on a failed - * read so callers never tear down live registrations on an error. + * (env-scoped kernels surface authored rows nowhere else), the + * environment's rows only (ADR-0131 D6), `null` on a failed read so callers + * never tear down live registrations on an error. */ private async readAuthoredActionRows(ctx: PluginContext): Promise { if (!this.ql) return null; @@ -2589,12 +2592,12 @@ export class ObjectQLPlugin implements Plugin { // [#21911, ADR-0096] The explicit system opt-in on all three reads — see // readAuthoredHookRows. let rows: any[] = (await this.ql.find('sys_metadata', { - where: { type: 'action', state: 'active' }, + where: { type: 'action', state: 'active', organization_id: null }, context: { isSystem: true }, })) ?? []; if (rows.length === 0) { rows = (await this.ql.find('sys_metadata', { - where: { type: 'actions', state: 'active' }, + where: { type: 'actions', state: 'active', organization_id: null }, context: { isSystem: true }, })) ?? []; } @@ -2608,7 +2611,7 @@ export class ObjectQLPlugin implements Plugin { // Converting the OBJECT row canonicalizes the embedded actions too — // the chain's action walker covers `objects[].actions[]`. const objectRows: any[] = (await this.ql.find('sys_metadata', { - where: { type: 'object', state: 'active' }, + where: { type: 'object', state: 'active', organization_id: null }, context: { isSystem: true }, })) ?? []; for (const row of objectRows) { diff --git a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts b/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts index 10b783f7358..5612a17eefa 100644 --- a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts +++ b/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts @@ -278,7 +278,7 @@ export async function discardPermissionSetOverlay( let resyncRefused = false; if (protocol && typeof protocol.getMetaItemLayered === 'function') { await projectPermissionMutation(protocol, projectionDeps, { - type: 'permission', name: String(row.name), state: 'active', organizationId: organizationId ?? null, + type: 'permission', name: String(row.name), state: 'active', }); healedRow = (await tryFind(ql, 'sys_permission_set', { id }, 1, organizationId))[0] ?? row; } else { diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 55ef1347ada..278322d28ea 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -611,7 +611,7 @@ describe('package-owned set customization lifecycle (ADR-0094 D5-R)', () => { ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['legacy.overlay'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); expect(JSON.parse(ql.permRows[0].system_permissions), 'the legacy overlay still wins at read time').toEqual(['legacy.overlay']); await protocol.deleteMetaItem({ type: 'permission', name: 'organization_admin' }); @@ -808,7 +808,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => // name (ADR-0094 D5-R). The invariant under test is unchanged: the record // is projected from the overlay, and the data-door delete resets it. seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['overlaid'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['overlaid']); const mw = makeMiddleware(ql, protocol); const nextCalled = await run(mw, { @@ -936,7 +936,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => // The invariant under test is untouched: "delete" lifts the overlay and // the record resets to the shipped declaration. seedLegacyOverlay(ql, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'crm_rep', state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'crm_rep', state: 'active' }); expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); // "delete" = reset const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }); @@ -990,7 +990,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', }); seedLegacyOverlay(pkgQl, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(pkgProtocol, { ql: pkgQl }, { type: 'permission', name: 'crm_rep', state: 'active', organizationId: null }); + await projectPermissionMutation(pkgProtocol, { ql: pkgQl }, { type: 'permission', name: 'crm_rep', state: 'active' }); const pkgCtx: any = { object: 'sys_permission_set', operation: 'delete', diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 60d14e65e02..24fbadd7b5e 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -835,7 +835,7 @@ export interface ProjectionDeps { export async function projectPermissionMutation( protocol: any, deps: ProjectionDeps, - evt: { type?: string; name?: string; state?: string; organizationId?: string | null } | null | undefined, + evt: { type?: string; name?: string; state?: string } | null | undefined, /** * [#11097] Forwarded to {@link upsertEnvPermissionSet} so a boot pass over * many overlay names pays ONE existence read for the whole set. The live @@ -851,11 +851,9 @@ export async function projectPermissionMutation( // second source, exactly as the write doors hand it theirs. let layeredProbe: LayeredProbe | undefined; if (protocol && typeof protocol.getMetaItemLayered === 'function') { - const layered = await protocol.getMetaItemLayered({ - type: 'permission', - name: evt.name, - ...(evt.organizationId ? { organizationId: evt.organizationId } : {}), - }); + // [ADR-0131 D6] The environment → code read: no organization is part + // of a metadata read, so the projection reads what every reader reads. + const layered = await protocol.getMetaItemLayered({ type: 'permission', name: evt.name }); // `getMetaItemLayered` may return a layered envelope (`{ effective | code }`) // OR the effective body directly (top-level `name`) — accept both. The // envelope carries `name` too, so detect it by its layer keys: an envelope @@ -1136,7 +1134,7 @@ export function createPermissionSetWriteThrough( // The awaited projector inside saveMetaItem/deleteMetaItem normally did // this already — re-running is an idempotent upsert, and covers the // window where the projector isn't registered yet (pre-kernel:ready). - await projectPermissionMutation(protocol, deps, { type: 'permission', name, state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, deps, { type: 'permission', name, state: 'active' }); return (await tryFind(ql, 'sys_permission_set', { name }, 1))[0] ?? null; }; @@ -1475,7 +1473,7 @@ export function createPermissionSetWriteThrough( for (const row of targets) { await protocol.deleteMetaItem({ type: 'permission', name: row.name, ...actorArg }); const res = await projectPermissionMutation(protocol, deps, { - type: 'permission', name: row.name, state: 'deleted', organizationId: null, + type: 'permission', name: row.name, state: 'deleted', }); if (res && (res.seeded + res.updated) > 0) { logger?.info?.('[security] permission set reset to its declared baseline (artifact-backed; ADR-0094)', { name: row.name }); @@ -1607,7 +1605,7 @@ export async function reconcilePermissionSetProjection( ); for (const name of overlayNames) { const res = await projectPermissionMutation(protocol, deps, { - type: 'permission', name, state: 'active', organizationId: null, + type: 'permission', name, state: 'active', }, { existing: existingByName }); // ⚠️ `unchanged` is deliberately NOT summed in. This counter reports records // this pass CREATED OR CHANGED, and now that an already-matching record is diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index fe5e633cb2c..4def040e9f4 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -63,9 +63,8 @@ import { AuthzStoreUnavailableError, AUTHZ_STORE_UNAVAILABLE_STATUS, } from '@objectstack/core'; // [#13538] §9 drives an ORG-OVERRIDABLE type on purpose, and asserts that -// choice against the registry rather than trusting a literal — the same -// predicate the read door itself consults. -import { declaresOrgOverride } from '@objectstack/metadata-core'; +// choice against the registry rather than trusting a literal. +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { RestServer } from './rest-server.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -1062,11 +1061,12 @@ const ORG_SCOPED_DOC = { name: 'ops', type: 'dashboard', widgets: [] }; describe('[#13538] §9 a PARTIAL outage is not served as an org-unscoped 200', () => { it('CONTROL: this section drives an ORG-OVERRIDABLE type; the type §8 drives is env-wide BY DESIGN', () => { - // The second half is why §8's green is not coverage of this card. On - // `object`, `organizationIdForMetaRead` returns `undefined` for an - // entitled caller too — so no fault model whatsoever could make §8 - // observe an org-scope difference on the type it sweeps. - expect({ driven: declaresOrgOverride(ORG_SCOPED_TYPE), section8: declaresOrgOverride(OBJECT_DOC.type) }) + // The second half is why §8's green is not coverage of this card: on + // `object` no organization was ever read, so no fault model whatsoever + // could make §8 observe an org-scope difference on the type it sweeps. + const declares = (type: string) => + DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type)?.allowOrgOverride === true; + expect({ driven: declares(ORG_SCOPED_TYPE), section8: declares(OBJECT_DOC.type) }) .toEqual({ driven: true, section8: false }); }); diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 30078c18000..34e4d9bcadb 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -104,11 +104,15 @@ interface Setup { function build(setup: Setup) { const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} }); - const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string }) => { + // [ADR-0131 D6, triage ruling Q3 A] The doors' `view` read reaches the + // Default Organization's legacy layer through `legacyFormOrganizationId`, the + // one read key left that names an organization. + const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string; legacyFormOrganizationId?: string }) => { if (req.type === 'view') { - if (req.organizationId !== ORG && setup.envWideViews) return setup.envWideViews; - if (req.organizationId === ORG && setup.orgViews) return setup.orgViews; - const effective = req.organizationId === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; + const org = req.legacyFormOrganizationId; + if (org !== ORG && setup.envWideViews) return setup.envWideViews; + if (org === ORG && setup.orgViews) return setup.orgViews; + const effective = org === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; return [formView(effective, setup.sharing), ...(setup.extraViews ?? [])]; } if (req.type === 'object') return [inquiryObject]; @@ -179,14 +183,16 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { const post = await s.post(); expect(post.statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); - // Every read names the organization, except the env-wide view read the - // kill switch adds beneath the organization's view read. - const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.organizationId]); + // [ADR-0131 D6] No read names an organization by the retired key; the + // `view` read alone reaches the legacy layer, and the object read is the + // environment's. + const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.legacyFormOrganizationId]); expect(reads.length).toBeGreaterThan(0); - for (const [type, organizationId] of reads) { - if (type !== 'view') expect(organizationId).toBe(ORG); + for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); + for (const [type, legacy] of reads) { + if (type !== 'view') expect(legacy).toBeUndefined(); } - // One resolution per door: the organization's view read, then the env-wide one. + // One resolution per door: the legacy organization's view read, then the env-wide one. expect(reads.filter(([type]) => type === 'view').map(([, o]) => o)).toEqual([ORG, undefined, ORG, undefined]); }); @@ -195,14 +201,14 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { expect((await s.get()).body.code).toBe('FORM_NOT_FOUND'); expect((await s.post()).body.code).toBe('FORM_NOT_FOUND'); expect(s.createData).not.toHaveBeenCalled(); - for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); + for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); it('tenancy never registered: the env-wide read, unchanged', async () => { const s = build({ envWide: true, inOrg: false, tenancy: 'not-registered' }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); - for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); + for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); it('tenancy registered but unreachable: both doors refuse instead of reading env-wide (fail closed)', async () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e1a572a1926..69fa203b47b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -291,6 +291,18 @@ export type RestProtocol = DataProtocol & MetadataProtocol; */ type TransportScopedMetaRequest = R & { environmentId?: string }; +/** + * [ADR-0131 D6, triage ruling Q3 A] The anonymous form doors' `view` list + * read: the one metadata read left that reaches a legacy organization's rows. + * `legacyFormOrganizationId` is not a key of the spec's `GetMetaItemsRequest` + * — every other read is environment → code — and the protocol honours it for + * `view` alone: the Default Organization's legacy body is preferred, and a + * withdrawal in either layer closes the form, fail-closed until the v18 + * migration ceremony (ADR-0131 C7) carries those rows into the environment + * layer and deletes this read with them. + */ +type LegacyFormViewsRequest = TransportScopedMetaRequest & { legacyFormOrganizationId?: string }; + /** * [#15866] The DATA doors' sibling of {@link TransportScopedMetaRequest}, and * the reason it is a SECOND alias rather than a widening of the first: the data @@ -5949,7 +5961,7 @@ export class RestServer { // [commit 2a29caa53] Typed against the spec request shape plus the // transport-level `environmentId` — the `as any` this // literal used to carry is retired now that the spec - // declares `previewDrafts` (and `organizationId`, #9726). + // declares `previewDrafts`. const listRequest: TransportScopedMetaRequest = { type: req.params.type, packageId, @@ -6642,7 +6654,7 @@ export class RestServer { // [commit 2a29caa53] Typed against the spec request shape — // the `as any` this literal used to carry is // retired now that the spec declares `state` and - // `previewDrafts` (and `organizationId`, #9726). + // `previewDrafts`. // No transport envelope: this door does not thread // `environmentId` (the kernel was already resolved // above), so the plain declared shape suffices. @@ -7239,10 +7251,10 @@ export class RestServer { // [#13406 · ADR-0131 D6] The ENV partition, stated by // omission: `sys_metadata_history` is filtered by strict // equality on `organization_id`, and since the - // per-organization overlay axis retired every `/meta` write - // logs there (`organizationId ?? null` in - // `historyMetaItem`). A legacy organization-scoped log is - // not served; its promotion is ADR-0131 C7's. + // per-organization overlay axis retired every write logs + // there and `historyMetaItem` reads no other partition. A + // legacy organization-scoped log is not served; its + // promotion is ADR-0131 C7's. // // Typed through `TransportScopedMetaRequest` like the // reset door above, NOT as a plain `HistoryMetaItemRequest` @@ -7418,7 +7430,6 @@ export class RestServer { const auditRequest: AuditMetaItemRequest = { type: req.params.type, name: req.params.name, - organizationId: null, // Already finite or absent — the declared parse above // refuses anything else. ...(limit !== undefined ? { limit } : {}), @@ -7803,8 +7814,7 @@ export class RestServer { } // [#13406 · ADR-0131 D6] The ENV partition, stated by // omission, as on the history twin: `diffMetaItem` reads - // `sys_metadata_history` by strict equality on - // `organization_id` (`request.organizationId ?? null`), and + // the environment's `sys_metadata_history` rows, and // `version` is a per-(org, type, name) lineage counter, so // the environment lineage is the one every `/meta` write // now extends. @@ -10183,19 +10193,17 @@ export class RestServer { } /** - * The object schemas an anonymous form request reads, in the organization - * the form itself was resolved in (#21331). They carry the columns the - * registry injects, `organization_id` among them. + * The object schemas an anonymous form request reads (#21331): the + * environment's, as every object read is (ADR-0131 D6). They carry the + * columns the registry injects, `organization_id` among them. */ private async readFormObjectDefinitions( p: RestProtocol, environmentId: string | undefined, - organizationId: string | undefined, ): Promise { const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), - ...(organizationId ? { organizationId } : {}), }; const r: any = await p.getMetaItems(objectsRequest); return Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; @@ -10221,8 +10229,7 @@ export class RestServer { const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); const posture = anonymousFormIntakePosture(tenancy); let objects: Promise | undefined; - const readObjects = (): Promise => (objects ??= anonymousFormOrganization(tenancy) - .then((organizationId) => this.readFormObjectDefinitions(p, environmentId, organizationId))); + const readObjects = (): Promise => (objects ??= this.readFormObjectDefinitions(p, environmentId)); const warnings: Array<{ path: string; message: string }> = []; for (const candidate of candidates) { const object = anonymousFormObjectName(view, candidate.form); @@ -10310,13 +10317,14 @@ export class RestServer { }; // [#21331] WHICH organization's metadata an anonymous form request - // reads. A public-form request carries no session, so it carries no - // active organization, and `getMetaItems` without one merges only the - // env-wide overlays. An administrator's edit of a packaged form is - // saved as an overlay of THEIR organization, so that read missed every - // such edit, including the one that withdraws the form from anonymous - // intake. The editor showed the form closed while both doors kept - // serving and accepting it. + // reads. Before ADR-0131 D6 an administrator's edit of a packaged + // form was saved as an overlay of THEIR organization, and an org-less + // read missed every such edit, including the one that withdraws the + // form from anonymous intake. Since D6 no write lands + // organization-scoped and no other read serves such a row; this + // `view` read alone keeps the Default Organization's legacy layer + // (`legacyFormOrganizationId`, triage ruling Q3 A), fail-closed, until + // the v18 migration ceremony (ADR-0131 C7) carries those rows. // // The answer is the tenancy service's `defaultOrgId()`: the // organization a single-posture deployment binds every principal to. @@ -10344,15 +10352,15 @@ export class RestServer { environmentId: string | undefined, req: any, slug: string, - ): Promise<{ view: any; form: any; object: string; organizationId: string | undefined } | null> => { + ): Promise<{ view: any; form: any; object: string } | null> => { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems !== 'function') return null; const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); const organizationId = await anonymousFormOrganization(tenancy); - const viewsRequest: TransportScopedMetaRequest = { + const viewsRequest: LegacyFormViewsRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), - ...(organizationId ? { organizationId } : {}), + ...(organizationId ? { legacyFormOrganizationId: organizationId } : {}), }; const listOf = (result: any): any[] => (Array.isArray(result?.items) ? result.items @@ -10383,10 +10391,10 @@ export class RestServer { const unavailable = await anonymousFormIntakeUnavailability( match.object, anonymousFormIntakePosture(tenancy), - async () => (await this.readFormObjectDefinitions(p, environmentId, organizationId)) + async () => (await this.readFormObjectDefinitions(p, environmentId)) .find((o: any) => o?.name === match.object), ); - return unavailable ? null : { ...match, organizationId }; + return unavailable ? null : match; }; // GET /forms/:slug — resolve and return the public form spec @@ -10441,13 +10449,11 @@ export class RestServer { try { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems === 'function') { - // [#21331] The same organization the form itself - // was resolved in, so the published field schema - // matches the form the caller was served. + // [#21331] The environment's object schema, as + // every object read is (ADR-0131 D6). const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), - ...(match.organizationId ? { organizationId: match.organizationId } : {}), }; const r: any = await p.getMetaItems(objectsRequest); const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index 34468d618b9..00afab1670a 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -36,17 +36,16 @@ * * ## The protocol double * - * It applies the read gate the real `metadata-protocol` applies to - * `getMetaItem`, `getMetaItems` and `getMetaItemLayered` - * (`organizationIdForMetaRead` over the folded type): an organization reaches a - * read only for an org-overridable type. So a transport that hands down a raw - * organization for `object` is answered what one that pre-gates it is — the - * `object` control below — and the difference measured is the organization - * VALUE, not where the gate runs. + * It models the read gate `metadata-protocol` applied before ADR-0131 D6 + * (an organization reached a read only for a type the registry declared + * `allowOrgOverride`), so a transport that still handed down an organization + * would be answered that organization's overlay — the double is the detector. + * The real protocol reads no organization at all since D6; what this file + * measures is that neither transport sends one. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { organizationIdForMetaRead } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { canonicalMetaUrlType } from '@objectstack/spec/shared'; import { RestServer } from '@objectstack/rest'; import { HttpDispatcher } from '../http-dispatcher.js'; @@ -76,9 +75,14 @@ const ENV_DRAFTS: any[] = [{ type: 'view', name: 'env_board', label: 'Env board const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +/** The types the registry declares `allowOrgOverride` — the pre-D6 gate the double models. */ +const ORG_OVERRIDABLE = new Set(DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)); + function protocolDouble() { - const gate = (type: string, organizationId: unknown) => - organizationIdForMetaRead(canonicalMetaUrlType(type), typeof organizationId === 'string' ? organizationId : undefined); + const gate = (type: string, organizationId: unknown) => { + const t = canonicalMetaUrlType(type); + return typeof organizationId === 'string' && ORG_OVERRIDABLE.has(t) ? organizationId : undefined; + }; const resolve = (type: string, name: string, organizationId: unknown) => { const t = canonicalMetaUrlType(type); const org = gate(type, organizationId); diff --git a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts index f68e616d48d..12d4cd3786f 100644 --- a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts @@ -23,13 +23,11 @@ * * ## The mechanism, in one line * - * `getMetaItem` opens with `organizationIdForMetaRead(request.type, - * request.organizationId)` (commit d5cbb44f3, the singular twin of commit 96326040f's plural - * gate) and spends that binding — never `request.organizationId` — on every - * read below it. `seed` declares `allowOrgOverride: false` - * (`metadata-plugin.zod.ts`), so the predicate answers `undefined` whatever - * organization arrives. ⇒ `{ type:'seed', name, organizationId }` and - * `{ type:'seed', name }` differ in a field the callee provably drops. + * `getMetaItem` reads no organization at all (ADR-0131 D6: every metadata + * read is environment → code; before it, commit d5cbb44f3's gate already + * dropped an organization for `seed`, which declares `allowOrgOverride: + * false`). ⇒ `{ type:'seed', name, organizationId }` and `{ type:'seed', name }` + * differ in a field the callee provably drops. * * ⛔ The repair is NOT to restore org-awareness to this read. An org-scoped * `seed` row is the unhydratable phantom `reportUnhydratableOrgScopedRows` @@ -80,7 +78,6 @@ import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, - organizationIdForMetaRead, } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; @@ -403,15 +400,11 @@ describe('#15068 · 0 · the publish-then-read path really runs', () => { // ═══════════════════════════════════════════════════════════════════════════ describe('#15068 · 1 · the two rungs resolve to one read', () => { - it('`seed` is non-overridable, so the read gate drops the organization', () => { + it('`seed` is non-overridable — and since ADR-0131 D6 no read takes an organization', () => { // The registry fact the card rests on, read from the registry rather // than restated. expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'seed')?.allowOrgOverride) .toBe(false); - expect(organizationIdForMetaRead('seed', ORG)).toBeUndefined(); - // ⇒ and the control that makes that reading mean something: the same - // predicate DOES carry an organization for an overridable type. - expect(organizationIdForMetaRead('view', ORG)).toBe(ORG); }); it('the two rungs issue the same predicates and serve the same answer', async () => { diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 7706d7a055c..c854d1ee036 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1547,13 +1547,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // true` on the next restart, because the env-wide row it left // untouched is the only one cold boot loads. The // `getMetaItems` read below is env-wide for the same - // reason, and since commit 96326040f it is so by construction: that - // method applies `organizationIdForMetaRead` to - // `request.type` itself, and the predicate answers - // `undefined` for every type the registry declares - // non-overridable — `app` among them, rolled back to - // `allowOrgOverride: false` in commit ee58392e1. [ADR-0131 D6] - // This route hands it no organization at all any more. + // reason, and by construction: [ADR-0131 D6] every + // metadata read is environment → code, and this route + // hands it no organization at all. // // ⛔ Dropping it is the REPAIR, not an oversight to undo. // An org-scoped `app` row is an unhydratable phantom — @@ -2362,15 +2358,11 @@ _context: HttpProtocolContext, const datasets: any[] = []; const readErrors: string[] = []; for (const name of names) { - // Read the just-published seed body. THE REGISTRY DECIDES THE SCOPE, - // not this call site: `seed` declares `allowOrgOverride: false`, and - // since commit d5cbb44f3 `getMetaItem` opens by resolving - // `organizationIdForMetaRead(request.type, request.organizationId)` - // and spends THAT binding — never the raw argument — on every read - // beneath it. The predicate answers `undefined` for every type the - // registry declares non-overridable, so this read is env-wide by - // construction: `organization_id IS NULL`, the partition a workspace - // seed is stored in and the only one cold boot hydrates. + // Read the just-published seed body. The protocol decides the scope, + // not this call site: [ADR-0131 D6] every metadata read is + // environment → code, so this read is env-wide by construction: + // `organization_id IS NULL`, the partition a workspace seed is stored + // in and the only one cold boot hydrates. // // [commit 8744de9e9] This used to be a two-attempt org-then-env ladder, written // when resolving the wrong scope here is what silently produced "0 diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index d4a6832ec95..1b52976651f 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -72,15 +72,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, - // [#8805] The predicate moved here from `../meta-write-org-scope.js` so the - // REST `/meta` write doors share it. This suite still drives the DISPATCHER - // through the real stack — that is why it stays in this package. - declaresOrgOverride, assertEngineFindOnePredicate, } from '@objectstack/metadata-core'; import * as metadataCore from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; // [commit 67ceb9aef] The URL spelling contract itself — the map storage folds through, // and the fold the transport was missing. Imported so the sweep below is // quantified over the CONTRACT rather than over a hand-copied specimen list @@ -359,23 +354,12 @@ describe('#7018 — the registry decides whether a metadata write carries the se // ── the predicate itself ────────────────────────────────────────────── - it('is derived from the registry, not a parallel allowlist (PD #8)', () => { - // Deliberately recomputed from `DEFAULT_METADATA_TYPE_REGISTRY` rather - // than spelled out: a hand-written list here would agree with a - // hand-written list there and pin nothing. Flipping any registry entry - // moves both sides of this assertion together. - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - expect(declaresOrgOverride(entry.type)).toBe(entry.allowOrgOverride); + it('[ADR-0131 D6] the organization-scope predicates are gone: no door and no read asks them', () => { + // The write-side predicate retired with the doors' organization (S3); + // the read-side pair retired with the protocol's read narrowing (S5). + for (const retired of ['organizationIdForMetaWrite', 'organizationIdForMetaRead', 'declaresOrgOverride']) { + expect(retired in metadataCore, retired).toBe(false); } - // Plural URL spellings are judged identically (`/meta/views/...`). - expect(declaresOrgOverride('views')).toBe(declaresOrgOverride('view')); - expect(declaresOrgOverride('flows')).toBe(declaresOrgOverride('flow')); - // A runtime-registered type with no registry entry has no per-org read - // channel either, so it is env-wide too. (`webhook` took this slot - // from `theme` at commit 35ad101bc — the retired kind left the contract.) - expect(declaresOrgOverride('webhook')).toBe(false); - // [ADR-0131 D6] The write-side predicate is gone: no door asks it. - expect('organizationIdForMetaWrite' in metadataCore).toBe(false); }); // ── PUT /meta/:type/:name — the dispatcher's metadata write ─────────── From 4ea63a1603945cbb80e1179fe1faf88ca592d76f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:36:28 +0000 Subject: [PATCH 07/21] wip(objectql): S5 pins re-premised; authored hook/action reads skip legacy organization rows Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 3 +- .../federated-injected-column-readers.test.ts | 4 + packages/objectql/src/plugin.ts | 25 ++- .../objectql/src/protocol-layered-get.test.ts | 10 +- packages/objectql/src/protocol-meta.test.ts | 168 +++++------------- .../src/protocol-object-overlay-layer.test.ts | 121 ++++++++----- ...protocol-org-overlay-registry-gate.test.ts | 43 ++--- .../protocol-packaged-dashboard-base.test.ts | 14 +- .../src/protocol-packaged-view-base.test.ts | 22 +-- .../src/protocol-publish-rollback.test.ts | 12 +- .../src/protocol-revert-org-scope.test.ts | 2 +- ...ys-metadata-repository-list-drafts.test.ts | 21 +-- 12 files changed, 207 insertions(+), 238 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 129da7590c4..5c3dc429d9d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -26457,7 +26457,8 @@ export class ObjectStackProtocolImplementation implements `[Protocol] [metadata_sealed_overlay_unserved] ${rows.length} active environment sys_metadata row(s) ` + `overlay an item a managed package ships, on a type that is sealed against overlays (ADR-0131 D6): ` + `${detail}. No read serves them — the package's own definition is served. ` + - `Each row is kept at rest, untouched. To keep the change, re-express it as a new item under a new ` + + `Each row is kept at rest, untouched (an object's table and its columns stay as they are). To keep ` + + `the change, re-express it as a new item under a new ` + `name (a linkage-free clone of the managed item), then delete the stored row; otherwise delete the ` + `stored row.`, ); diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 22258f4bb04..5d297ef1d02 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -225,6 +225,10 @@ const READERS: Record = { disposition: 'row-value', why: 'reads the tenant column off the written row; a federated row has none, so the event omits it', }, + 'plugin.ts#isLegacyOrganizationRow :: organization_id': { + disposition: 'row-value', + why: 'reads the column off a sys_metadata row in hand to skip a legacy organization row (ADR-0131 D6); sys_metadata is a platform table, never federated', + }, 'relation-filter-lowering.ts#admitRelationCondition :: referenceTargetOf()': { disposition: 'caller-predicate', why: "lowers a relation condition the caller wrote; it names an injected column only when the caller did", diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index fb431aab273..62d6aff0bfd 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -77,6 +77,17 @@ function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore { ); } +/** + * [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer: + * no metadata read serves one, so the authored hook and action binders skip it + * too. Judged off the row in hand — `sys_metadata` is a platform table, never a + * federated object. + */ +function isLegacyOrganizationRow(row: { organization_id?: unknown } | null | undefined): boolean { + const organizationId = row?.organization_id; + return organizationId !== null && organizationId !== undefined && organizationId !== ''; +} + /** * Options for ObjectQLPlugin. * @@ -2405,18 +2416,20 @@ export class ObjectQLPlugin implements Plugin { // of the platform store with no caller behind it, never a principal-less // engine context. let rows: any[] = (await this.ql.find('sys_metadata', { - where: { type: 'hook', state: 'active', organization_id: null }, + where: { type: 'hook', state: 'active' }, context: { isSystem: true }, })) ?? []; if (rows.length === 0) { // Legacy plural rows — mirrors getMetaItems' singular/plural fallback. rows = (await this.ql.find('sys_metadata', { - where: { type: 'hooks', state: 'active', organization_id: null }, + where: { type: 'hooks', state: 'active' }, context: { isSystem: true }, })) ?? []; } const hooks: any[] = []; for (const row of rows) { + // [ADR-0131 D6] The environment's rows only — see the TSDoc. + if (isLegacyOrganizationRow(row)) continue; try { const data = this.convertStoredRow( ctx, @@ -2592,17 +2605,18 @@ export class ObjectQLPlugin implements Plugin { // [#21911, ADR-0096] The explicit system opt-in on all three reads — see // readAuthoredHookRows. let rows: any[] = (await this.ql.find('sys_metadata', { - where: { type: 'action', state: 'active', organization_id: null }, + where: { type: 'action', state: 'active' }, context: { isSystem: true }, })) ?? []; if (rows.length === 0) { rows = (await this.ql.find('sys_metadata', { - where: { type: 'actions', state: 'active', organization_id: null }, + where: { type: 'actions', state: 'active' }, context: { isSystem: true }, })) ?? []; } const actions: any[] = []; for (const row of rows) { + if (isLegacyOrganizationRow(row)) continue; // [ADR-0131 D6] const data = parseRow(row, 'action'); if (data && typeof data.name === 'string') actions.push(data); } @@ -2611,10 +2625,11 @@ export class ObjectQLPlugin implements Plugin { // Converting the OBJECT row canonicalizes the embedded actions too — // the chain's action walker covers `objects[].actions[]`. const objectRows: any[] = (await this.ql.find('sys_metadata', { - where: { type: 'object', state: 'active', organization_id: null }, + where: { type: 'object', state: 'active' }, context: { isSystem: true }, })) ?? []; for (const row of objectRows) { + if (isLegacyOrganizationRow(row)) continue; // [ADR-0131 D6] const obj = parseRow(row, 'object'); if (!obj || typeof obj.name !== 'string' || !Array.isArray(obj.actions)) continue; for (const action of obj.actions) { diff --git a/packages/objectql/src/protocol-layered-get.test.ts b/packages/objectql/src/protocol-layered-get.test.ts index b4ad600d7e5..bdf55384186 100644 --- a/packages/objectql/src/protocol-layered-get.test.ts +++ b/packages/objectql/src/protocol-layered-get.test.ts @@ -59,7 +59,7 @@ describe('ObjectStackProtocolImplementation - getMetaItemLayered', () => { expect(result.effective).toMatchObject({ label: 'Customised Label' }); }); - it('prefers org-scoped overlay over env-wide when both exist', async () => { + it('[ADR-0131 D6] reports the environment overlay even when a legacy organization row exists', async () => { const orgOverlay = { ...overlayBody, label: 'Org-Specific Label' }; mockEngine.findOne.mockImplementation((_table: string, opts: any) => { if (opts.where?.organization_id === 'org_alpha') { @@ -75,10 +75,10 @@ describe('ObjectStackProtocolImplementation - getMetaItemLayered', () => { type: 'view', name: 'my_view', organizationId: 'org_alpha', - }); - expect(result.overlayScope).toBe('org'); - expect(result.overlay).toMatchObject({ label: 'Org-Specific Label' }); - expect(result.effective).toMatchObject({ label: 'Org-Specific Label' }); + } as { type: string; name: string }); + expect(result.overlayScope).toBe('env'); + expect(result.overlay).toMatchObject({ label: overlayBody.label }); + expect(result.effective).toMatchObject({ label: overlayBody.label }); }); it('returns null code when artifact baseline missing but overlay exists', async () => { diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index c7f2007f93d..7d49c13cf87 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -61,128 +61,46 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // ADR-0005 (revised 2026-05): per-organization overlay isolation // ═══════════════════════════════════════════════════════════════ - describe('per-organization overlay isolation', () => { - // [commit d5cbb44f3] Re-spelled from `app` to `view`, and its sibling below with - // it. The CLAIM is unchanged — an org row and an env-wide row of the - // same `(type, name)` both exist, and the org row is the one SERVED, - // whole, by precedence rather than a merge. It just has to be measured - // on a type that HAS an org partition to have precedence over. - // - // `getMetaItem` now resolves its own read scope through - // `organizationIdForMetaRead` — the read-side twin of the predicate - // `saveMetaItem` already gates on — so on `app`, which rolled back to - // `allowOrgOverride: false` in commit ee58392e1, `organizationId` is gated to - // `undefined` and the org partition is never queried. On `app` this - // case was pinning the phantom read commit d5cbb44f3 removed: a pre-#6190 - // org-scoped row served INSTEAD OF the live env-wide document. `view` - // is the whitelisted specimen — the same re-spelling #6190 made one - // case up and commit 96326040f made one case down. - it('getMetaItem returns org-specific overlay when both org and env-wide rows exist', async () => { - // findOverlay calls: first attempts org=org_alpha (returns row), - // env-wide fallback should be skipped. - mockEngine.findOne.mockImplementation((_table: string, opts: any) => { - if (opts?.where?.organization_id === 'org_alpha') { - return Promise.resolve({ - type: 'view', name: 'test_grid', state: 'active', - metadata: JSON.stringify({ ...sampleView, label: 'Org Alpha' }), - }); - } - if (opts?.where?.organization_id === null) { - return Promise.resolve({ - type: 'view', name: 'test_grid', state: 'active', - metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }), - }); - } - return Promise.resolve(null); - }); - - const result = await protocol.getMetaItem({ - type: 'view', name: 'test_grid', organizationId: 'org_alpha', - }); - expect((result.item as any).label).toBe('Org Alpha'); - // ADR-0005 resolution order: overlay WINS, it does not merge. The - // env-wide partition is never reached once the org row answers — - // the assertion the prose above used to make in a comment only. - expect(mockEngine.findOne).not.toHaveBeenCalledWith('sys_metadata', { - where: { type: 'view', name: 'test_grid', state: 'active', organization_id: null }, - }); - }); + describe('[ADR-0131 D6] no read serves a legacy organization row', () => { + // The per-organization overlay axis is retired: every metadata read is + // environment → code. A legacy organization-scoped row of the same + // `(type, name)` as an environment row is not read at all — not even + // when a caller still names its organization. + const orgOrEnv = (orgBody: unknown, envBody: unknown) => (_table: string, opts: any) => { + if (opts?.where?.organization_id === 'org_alpha') return Promise.resolve(orgBody); + if (opts?.where?.organization_id === null) return Promise.resolve(envBody); + return Promise.resolve(Array.isArray(envBody) ? [] : null); + }; - it('getMetaItem falls through to env-wide overlay when no org-specific row exists', async () => { - mockEngine.findOne.mockImplementation((_table: string, opts: any) => { - if (opts?.where?.organization_id === null) { - return Promise.resolve({ - type: 'view', name: 'test_grid', state: 'active', - metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }), - }); - } - return Promise.resolve(null); - }); + it('getMetaItem serves the environment row, and never asks for the organization\'s', async () => { + mockEngine.findOne.mockImplementation(orgOrEnv( + { type: 'view', name: 'test_grid', state: 'active', metadata: JSON.stringify({ ...sampleView, label: 'Org Alpha' }) }, + { type: 'view', name: 'test_grid', state: 'active', metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }) }, + )); const result = await protocol.getMetaItem({ type: 'view', name: 'test_grid', organizationId: 'org_alpha', - }); + } as { type: string; name: string }); expect((result.item as any).label).toBe('Env Default'); - // ⚠️ NON-VACUITY, and the reason this case had to move too. A - // fall-through only states something if the org partition was - // actually read FIRST. Left on `app` this case kept passing after - // commit d5cbb44f3 while measuring nothing at all: the gate resolves `app` to - // `undefined`, so the only read ever issued was the env-wide one and - // the assertion could no longer fail. - expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'view', name: 'test_grid', state: 'active', organization_id: 'org_alpha' }, - // [#21911] The platform store read carries the explicit system opt-in. - context: { isSystem: true }, - }); + for (const [table, query] of mockEngine.findOne.mock.calls) { + if (table === 'sys_metadata') expect((query as any)?.where?.organization_id ?? null).toBeNull(); + } }); - // [commit 96326040f] Re-spelled from `app` to `view`, the READ-side twin of the - // `[#6190]` re-spelling three cases up — same reason, one verb over. - // `getMetaItems` now resolves its own read scope through - // `organizationIdForMetaRead`, so an `organizationId` handed in for a - // type the registry declares NON-overridable is gated to `undefined` - // and the org partition is never queried. `app` rolled back to - // `allowOrgOverride: false` in commit ee58392e1, so on `app` this case was - // asserting a union the platform must NOT perform: the org rows it - // seeded are the pre-#6190 phantoms `loadMetaFromDb` walks past, and - // reading them back is the resurrection commit 96326040f closed. - // - // The CLAIM is unchanged and is what this case still pins — env-wide - // and org rows union, org winning on collision. It just has to be - // measured on a type that has an org partition to union. - // - // ⚠️ SUPERSEDED, 2026-09-03 (commit d5cbb44f3). This paragraph used to read "Its - // two `getMetaItem` (SINGULAR) siblings above keep `app` deliberately: - // that verb is untouched here." That was true when commit 96326040f landed and is - // the sentence commit d5cbb44f3 falsified: the singular verb now gates too, so - // both siblings moved to `view` in the same edit. The reasoning it gave - // — a singular caller CAN be right about its scope, and its REST door - // already gates — held for the DOOR and not for the VERB: commit d5cbb44f3 - // measured four runtime callers that reach the verb with a raw active - // organization, and on a `??` precedence read an ungated organization - // does not merely ADD a row, it SUBSTITUTES the served document. - it('getMetaItems unions env-wide and org-specific rows (org wins on collision)', async () => { - mockEngine.find.mockImplementation((_table: string, opts: any) => { - if (opts?.where?.organization_id === 'org_alpha') { - return Promise.resolve([ - { type: 'view', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Org Alpha' }) }, - { type: 'view', name: 'alpha_only', state: 'active', metadata: JSON.stringify({ name: 'alpha_only', label: 'Alpha Only' }) }, - ]); - } - if (opts?.where?.organization_id === null) { - return Promise.resolve([ - { type: 'view', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Env Default' }) }, - { type: 'view', name: 'env_only', state: 'active', metadata: JSON.stringify({ name: 'env_only', label: 'Env Only' }) }, - ]); - } - return Promise.resolve([]); - }); - const result = await protocol.getMetaItems({ - type: 'view', organizationId: 'org_alpha', - }); + it('getMetaItems lists the environment rows only, whatever organization the caller names', async () => { + mockEngine.find.mockImplementation(orgOrEnv( + [ + { type: 'view', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Org Alpha' }) }, + { type: 'view', name: 'alpha_only', state: 'active', metadata: JSON.stringify({ name: 'alpha_only', label: 'Alpha Only' }) }, + ], + [ + { type: 'view', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Env Default' }) }, + { type: 'view', name: 'env_only', state: 'active', metadata: JSON.stringify({ name: 'env_only', label: 'Env Only' }) }, + ], + )); + const result = await protocol.getMetaItems({ type: 'view', organizationId: 'org_alpha' } as { type: string }); const names = (result.items as any[]).map((i) => i.name).sort(); - expect(names).toEqual(['alpha_only', 'env_only', 'shared']); - const shared = (result.items as any[]).find((i) => i.name === 'shared'); - expect(shared.label).toBe('Org Alpha'); + expect(names).toEqual(['env_only', 'shared']); + expect((result.items as any[]).find((i) => i.name === 'shared').label).toBe('Env Default'); }); }); @@ -1051,18 +969,20 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); it('keeps both colliding rows when EACH package has its own sys_metadata overlay (ADR-0048 #1828)', async () => { + // [ADR-0131 D6] `dashboard`, a regime-O type: an environment overlay of a + // packaged `page` is sealed content now, which no read serves. // The #1828 gap: two installed packages ship `page/home` AND each has // a package-scoped sys_metadata overlay. The DB-overlay merge deduped // by bare `name`, collapsing the two rows to one (last-write-wins). // Each overlay must merge onto its OWN package's entry → two rows. - registry.registerItem('page', { name: 'home', label: 'Acme (artifact)' }, 'name', 'com.acme.crm'); - registry.registerItem('page', { name: 'home', label: 'Globex (artifact)' }, 'name', 'com.globex.crm'); + registry.registerItem('dashboard', { name: 'home', label: 'Acme (artifact)' }, 'name', 'com.acme.crm'); + registry.registerItem('dashboard', { name: 'home', label: 'Globex (artifact)' }, 'name', 'com.globex.crm'); mockEngine.find.mockResolvedValue([ - { type: 'page', name: 'home', state: 'active', package_id: 'com.acme.crm', metadata: JSON.stringify({ name: 'home', label: 'Acme (overlay)' }) }, - { type: 'page', name: 'home', state: 'active', package_id: 'com.globex.crm', metadata: JSON.stringify({ name: 'home', label: 'Globex (overlay)' }) }, + { type: 'dashboard', name: 'home', state: 'active', package_id: 'com.acme.crm', metadata: JSON.stringify({ name: 'home', label: 'Acme (overlay)' }) }, + { type: 'dashboard', name: 'home', state: 'active', package_id: 'com.globex.crm', metadata: JSON.stringify({ name: 'home', label: 'Globex (overlay)' }) }, ]); - const result = await protocol.getMetaItems({ type: 'page' }); + const result = await protocol.getMetaItems({ type: 'dashboard' }); const homes = (result.items as any[]).filter((i) => i.name === 'home'); expect(homes).toHaveLength(2); @@ -1097,15 +1017,17 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); it('single-package env-wide (package-less) overlay still overlays the artifact (ADR-0005 unchanged, #1828)', async () => { + // [ADR-0131 D6] `dashboard`, a regime-O type: an environment overlay of a + // packaged `page` is sealed content now, which no read serves. // A package-less overlay (package_id IS NULL) must still WIN over the // one artifact it customizes and stay a single row — the package-aware // merge must not split a global overlay off from its lone artifact. - registry.registerItem('page', { name: 'home', label: 'Artifact' }, 'name', 'com.acme.crm'); + registry.registerItem('dashboard', { name: 'home', label: 'Artifact' }, 'name', 'com.acme.crm'); mockEngine.find.mockResolvedValue([ - { type: 'page', name: 'home', state: 'active', package_id: null, metadata: JSON.stringify({ name: 'home', label: 'Customized' }) }, + { type: 'dashboard', name: 'home', state: 'active', package_id: null, metadata: JSON.stringify({ name: 'home', label: 'Customized' }) }, ]); - const result = await protocol.getMetaItems({ type: 'page' }); + const result = await protocol.getMetaItems({ type: 'dashboard' }); const homes = (result.items as any[]).filter((i) => i.name === 'home'); expect(homes).toHaveLength(1); diff --git a/packages/objectql/src/protocol-object-overlay-layer.test.ts b/packages/objectql/src/protocol-object-overlay-layer.test.ts index f54c5a8f7ae..1ae32557060 100644 --- a/packages/objectql/src/protocol-object-overlay-layer.test.ts +++ b/packages/objectql/src/protocol-object-overlay-layer.test.ts @@ -58,6 +58,16 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; * cases below that drove the delete under the hatch are pinned REFUSED, the * layer and the data plane intact; the subtraction itself stays reachable where * the row leaves by another route (a replica converging on a removal). + * + * ## [ADR-0131 D6, triage ruling Q1 → C] …and the boot no longer LAYERS it + * + * An environment overlay of an object a managed package ships sits in no regime + * D6 recognises: the package definition wins. So boot hydration no longer + * registers the stored row as an `overlay` layer — the packaged definition is + * the object, the row stays at rest untouched, and the boot names it + * (`[metadata_sealed_overlay_unserved]`) with its remedies. The D9 layer + * mechanics (owner first, the row's package as provenance) are what the cases + * below still pin for the packaged owner; the layer itself is never added. */ const APP_PKG = 'app.myapp'; @@ -261,41 +271,51 @@ async function bootWithPackage(seed: any[], opts: { controlPlane?: boolean; inst const kinds = (registry: SchemaRegistry, name: string) => registry.getObjectContributors(name).map((c) => c.ownership); -describe('ADR-0029 D9 — the boot replay LAYERS the tenant row instead of destroying the package', () => { +describe('ADR-0029 D9 · ADR-0131 D6 — the boot replay neither destroys the package nor layers the sealed row over it', () => { /** * The direct inversion of this file's old premise test, which asserted * `getArtifactItem(...)` was `undefined` and `packaged_only` was gone. Both - * flip here, and that flip is the whole card. + * stay flipped; [ADR-0131 D6] and the tenant row is not layered either. */ - it('after hydration the packaged definition is still there, underneath', async () => { + it('after hydration the packaged definition is the object, and the sealed row is reported, not layered', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { registry, res } = await bootWithPackage(seed); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let booted: Awaited>; + let warned: string[]; + try { + booted = await bootWithPackage(seed); + } finally { + warned = warn.mock.calls.map((c) => String(c[0])); + warn.mockRestore(); + } + const { registry, res, rows } = booted; - expect(res).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + expect(res).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); - // Two layers, not one — and the owner is the package, not the tenant. - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + // One layer: the package's own definition, and it is what resolves. + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - const owner = registry.getObjectOwner('myapp_invoice')!; - expect(Object.keys((owner.definition as any).fields)).toContain('packaged_only'); - expect((owner.definition as any)._provenance).toBe('package'); - - // The RESOLVED object does not move: still the tenant's body, still - // stamped `org`. Bit-for-bit what the splice used to produce. - expect(fieldNames(registry, 'myapp_invoice')).toContain('overlay_only'); - expect(fieldNames(registry, 'myapp_invoice')).not.toContain('packaged_only'); - expect((registry.getObject('myapp_invoice') as any)._provenance).toBe('org'); - - // …and the predicate both gates read is honest again. + expect(fieldNames(registry, 'myapp_invoice')).toContain('packaged_only'); + expect(fieldNames(registry, 'myapp_invoice')).not.toContain('overlay_only'); expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); + + // The row is kept at rest, untouched, and named at boot. + expect(storedRows(rows, 'myapp_invoice')).toHaveLength(1); + const line = warned.find((m) => m.includes('[metadata_sealed_overlay_unserved]')); + expect(line).toContain('object×1 (myapp_invoice)'); }); - it('the hydration is idempotent across boots — one layer, not a stack', async () => { + it('the hydration is idempotent across boots — never a layer', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = await bootWithPackage(seed); - await protocol.loadMetaFromDb(); - await protocol.loadMetaFromDb(); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { protocol, registry } = await bootWithPackage(seed); + await protocol.loadMetaFromDb(); + await protocol.loadMetaFromDb(); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); + } finally { + warn.mockRestore(); + } }); }); @@ -322,7 +342,7 @@ describe('ADR-0029 D9.6 — the declared contract, enforced consistently', () => // The refusal costs the packaged definition nothing. expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); }); /** @@ -390,7 +410,7 @@ describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is r } expect(storedRows(rows, 'myapp_invoice')).toHaveLength(1); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); expect((await protocol.createData({ object: 'myapp_invoice', data: { name: 'INV-2' } })).id).toBeTruthy(); expect(dataRows).toHaveLength(2); @@ -422,7 +442,7 @@ describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is r expect(refusedOpen).toBeInstanceOf(Error); expect(refusedOpen.code).toBe('NOT_OVERRIDABLE'); expect(refusedOpen.status).toBe(403); - expect(kinds(b.registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(kinds(b.registry, 'myapp_invoice')).toEqual(['own']); }); /** @@ -489,8 +509,11 @@ describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is r }); describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never an ownership claim', () => { - /** `P == O`: the normal case. One overlay layer over the owner's object. */ - it('same package — a normal layer, no error, no warning', async () => { + /** + * `P == O`: the normal case. [ADR-0131 D6] No error and no hydration + * warning — the one line naming it is the sealed-overlay report. + */ + it('same package — no error, and only the sealed-overlay report names it', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); let warned: string[] = []; @@ -501,8 +524,9 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a warned = warn.mock.calls.map((c) => String(c[0])); warn.mockRestore(); } - expect(res).toMatchObject({ loaded: 1, errors: 0 }); - expect(warned.filter((m) => m.includes('myapp_invoice'))).toEqual([]); + expect(res).toMatchObject({ loaded: 0, errors: 0 }); + expect(warned.filter((m) => m.includes('myapp_invoice')).map((m) => m.slice(0, 50))) + .toEqual(['[Protocol] [metadata_sealed_overlay_unserved] 1 ac']); }); /** @@ -513,17 +537,22 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a * ADR-0005's platform-global shape: the row addresses the object by name * and the registry knows who owns it. */ - it('package-LESS row over a packaged object — accepted as the layer, not refused', async () => { + it('package-LESS row over a packaged object — not refused at boot; [ADR-0131 D6] not layered either', async () => { const seed = await persistOverlayRow('myapp_invoice'); - const { registry, res } = await bootWithPackage(seed); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let booted: Awaited>; + try { + booted = await bootWithPackage(seed); + } finally { + warn.mockRestore(); + } + const { registry, res } = booted; - expect(res).toMatchObject({ loaded: 1, errors: 0 }); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); - // The layer carries the sentinel as its own provenance; ownership is - // untouched, and still the package's. - expect(registry.getObjectContributors('myapp_invoice')[1].packageId).toBe(SENTINEL); + expect(res).toMatchObject({ loaded: 0, errors: 0 }); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - expect(fieldNames(registry, 'myapp_invoice')).toContain('overlay_only'); + expect(fieldNames(registry, 'myapp_invoice')).not.toContain('overlay_only'); + expect(SENTINEL).toBe('sys_metadata'); }); /** @@ -554,8 +583,8 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a // No success receipt, and no row for the mis-bound package. expect(storedRows(rows, 'myapp_invoice').filter((r) => r.package_id === OTHER_PKG)).toHaveLength(0); - // The registry is untouched: one owner, one layer, still the right ones. - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + // The registry is untouched: the packaged owner alone (ADR-0131 D6). + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); }); @@ -564,7 +593,7 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a * counted in `loadMetaFromDb`'s per-record `errors` with its reason, which * is why #6995 is a write-path divergence and not a boot-path one. */ - it('different package — the BOOT counts it in `errors` and leaves the package alone', async () => { + it('different package — [ADR-0131 D6] the BOOT reports it as a sealed overlay and leaves the package alone', async () => { const seed = await persistOverlayRow('myapp_invoice', OTHER_PKG); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); let warned: string[] = []; @@ -576,10 +605,12 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a warn.mockRestore(); } - expect(booted.res).toMatchObject({ loaded: 0, errors: 1 }); - // A log line has no envelope, so the boot warning names the declared - // code itself rather than inheriting it from the refusal prose. - expect(warned.join('\n')).toContain('code=OBJECT_OVERLAY_PACKAGE_MISMATCH'); + // The row overlays an object a managed package ships, so it is a + // sealed overlay before it is a mis-bound one: never layered, so the + // D9.9 mismatch refusal is not reached, and the boot names it. + expect(booted.res).toMatchObject({ loaded: 0, errors: 0 }); + expect(warned.join('\n')).toContain('[metadata_sealed_overlay_unserved]'); + expect(warned.join('\n')).not.toContain('code=OBJECT_OVERLAY_PACKAGE_MISMATCH'); // The packaged definition is served, untouched — no half-applied layer. expect(kinds(booted.registry, 'myapp_invoice')).toEqual(['own']); diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts index da624a0aeb3..aa4d793cc45 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -35,6 +35,11 @@ * * ── The fix, and where it lives ───────────────────────────────────────── * + * [ADR-0131 D6] Since the per-organization overlay axis retired, the one + * list read left that merges a legacy organization's rows is the anonymous + * form doors' `view` read (`legacyFormOrganizationId`); the listing cases + * below drive that read, because it is the one the invariant still guards. + * * `hydrateOverlayIntoRegistry` is the ONE shared choke point all three * hydration callers already route through (#4521 made it so: boot, the * read-side hydration and the write-through). The row-scope verdict now @@ -285,7 +290,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', it('an org-scoped listing SERVES the org row but does not graft it', async () => { engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - const listed = await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); // Org readers keep their overlay — closing the leak must not close // the feature. @@ -307,7 +312,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - const listed = await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); expect(namesOf(listed as any).sort()).toEqual(['env_grid', 'org_grid']); expect(registry.getItem('view', 'env_grid')).toBeDefined(); @@ -324,7 +329,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); // Org A's own listing still shows org A's body (org-over-env merge)… - const listedA = await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + const listedA = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); expect(itemNamed(listedA as any, 'shared_grid')?.label).toBe('Org A grid'); // …and the shared registry still holds the ENV-WIDE one. This is the @@ -345,7 +350,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); }); @@ -369,8 +374,8 @@ describe('#6602 — the disclosure shape, end to end', () => { // later reader started from it. engine.plant(metaRow('view', viewBody('org_a_only', 'Org A only'), ORG_A)); - await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); expect(namesOf(listedB as any)).not.toContain('org_a_only'); // The unscoped control-plane listing is a third reader of the same @@ -383,14 +388,14 @@ describe('#6602 — the disclosure shape, end to end', () => { engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); expect(itemNamed(listedB as any, 'shared_grid')?.label).toBe('Env grid'); }); }); -describe('#6602 — the on-demand per-org read still serves org readers', () => { +describe('[ADR-0131 D6] the by-name read serves no legacy organization row, and never hydrates one', () => { let registry: SchemaRegistry; let engine: any; let protocol: ObjectStackProtocolImplementation; @@ -402,26 +407,22 @@ describe('#6602 — the on-demand per-org read still serves org readers', () => protocol = new ObjectStackProtocolImplementation(engine); }); - it('getMetaItem serves the org body to its own org and never hydrates it', async () => { + it('getMetaItem answers nothing for an organization-only row, whoever asks, and grafts nothing', async () => { engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - const got: any = await protocol.getMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A }); - expect(got.item.label).toBe('Org A grid'); - - // ADR-0005's "loaded on demand … to avoid cross-org leakage": serving - // it must not be the thing that grafts it. + const got: any = await protocol.getMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A } as { type: string; name: string }); + expect(got.item).toBeUndefined(); expect(registry.getItem('view', 'org_grid')).toBeUndefined(); }); - it('getMetaItem prefers the ORG body over the env-wide one for its own org', async () => { + it('getMetaItem serves the env-wide body to every caller for a colliding name', async () => { engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - const forOrgA: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); - expect(forOrgA.item.label).toBe('Org A grid'); - - const forOrgB: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_B }); - expect(forOrgB.item.label).toBe('Env grid'); + for (const organizationId of [ORG_A, ORG_B]) { + const got: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId } as { type: string; name: string }); + expect(got.item.label, organizationId).toBe('Env grid'); + } }); }); diff --git a/packages/objectql/src/protocol-packaged-dashboard-base.test.ts b/packages/objectql/src/protocol-packaged-dashboard-base.test.ts index ea180b78e36..ac005743f26 100644 --- a/packages/objectql/src/protocol-packaged-dashboard-base.test.ts +++ b/packages/objectql/src/protocol-packaged-dashboard-base.test.ts @@ -40,7 +40,6 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; const PKG = 'com.objectstack.plugin-auth'; const DASH = 'system_overview'; -const ORG = 'org_acme'; const SHIPPED_TITLE = 'Total Users'; const EDITED_TITLE = 'Total Users (edited)'; @@ -138,7 +137,8 @@ function seedOrgOverlay(rows: Record[]) { type: 'dashboard', name: DASH, package_id: null, - organization_id: ORG, + // [ADR-0131 D6] An environment overlay: no read serves an organization's. + organization_id: null, state: 'active', metadata: JSON.stringify(overlayBody()), }); @@ -189,12 +189,12 @@ describe('#20680 getPackagedDashboardBase — the packaged declaration, never an }); }); -describe('#20680 the published org overlay, through the protocol read and the translation', () => { +describe('#20680 the published overlay, through the protocol read and the translation', () => { it('the item read serves the overlay — one identity for the write and the read', async () => { const s = makeSession(); registerPackaged(s.registry); seedOrgOverlay(s.rows); - const served = await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG }); + const served = await s.protocol.getMetaItem({ type: 'dashboard', name: DASH }); expect(titleOf(served.item)).toBe(EDITED_TITLE); // The code artifact's envelope rides on the overlay body; it names the // registering package, not a second identity the overlay missed. @@ -205,7 +205,7 @@ describe('#20680 the published org overlay, through the protocol read and the tr const s = makeSession(); registerPackaged(s.registry); seedOrgOverlay(s.rows); - const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH })).item; const packagedBase = s.protocol.getPackagedDashboardBase(DASH); for (const locale of ['en', 'zh-CN']) { @@ -221,7 +221,7 @@ describe('#20680 the published org overlay, through the protocol read and the tr const s = makeSession(); registerPackaged(s.registry); seedOrgOverlay(s.rows); - const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH })).item; expect(titleOf(translateDashboard(served, BUNDLE, { locale: 'en' }))).toBe(SHIPPED_TITLE); expect(titleOf(translateDashboard(served, BUNDLE, { locale: 'zh-CN' }))).toBe('用户总数'); }); @@ -229,7 +229,7 @@ describe('#20680 the published org overlay, through the protocol read and the tr it('after reset (no overlay row) the shipped body is served and still translated', async () => { const s = makeSession(); registerPackaged(s.registry); - const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH })).item; const packagedBase = s.protocol.getPackagedDashboardBase(DASH); expect(titleOf(served)).toBe(SHIPPED_TITLE); expect(titleOf(translateDashboard(served, BUNDLE, { locale: 'zh-CN', packagedBase }))).toBe('用户总数'); diff --git a/packages/objectql/src/protocol-packaged-view-base.test.ts b/packages/objectql/src/protocol-packaged-view-base.test.ts index 8b37b58bcd4..219220d4c9d 100644 --- a/packages/objectql/src/protocol-packaged-view-base.test.ts +++ b/packages/objectql/src/protocol-packaged-view-base.test.ts @@ -36,7 +36,6 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; const PKG = 'com.example.showcase'; const OBJ = 'showcase_task'; const VIEW = 'showcase_task.in_progress'; -const ORG = 'org_acme'; const SHIPPED_LABEL = 'In Progress'; const EDITED_LABEL = 'In Progress (edited)'; @@ -126,7 +125,8 @@ function seedOrgOverlay(rows: Record[]) { type: 'view', name: VIEW, package_id: null, - organization_id: ORG, + // [ADR-0131 D6] An environment overlay: no read serves an organization's. + organization_id: null, state: 'active', metadata: JSON.stringify(overlayBody()), }); @@ -190,18 +190,18 @@ describe('#20731 getPackagedViewBase — the packaged view, never an overlay', ( }); }); -describe('#20731 the published org overlay, through the protocol reads and the translation', () => { +describe('#20731 the published overlay, through the protocol reads and the translation', () => { it('the item and list reads serve the overlay — one identity for the write and both reads', async () => { const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const served = await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG }); + const served = await s.protocol.getMetaItem({ type: 'view', name: VIEW }); expect(served.item.label).toBe(EDITED_LABEL); // The code artifact's envelope rides on the overlay body; it names the // registering package, not a second identity the overlay missed. expect(served.item._packageId).toBe(PKG); - const list = await s.protocol.getMetaItems({ type: 'view', organizationId: ORG }); + const list = await s.protocol.getMetaItems({ type: 'view' }); const listed = (list.items as any[]).find((v) => v?.name === VIEW); expect(listed?.label).toBe(EDITED_LABEL); }); @@ -210,8 +210,8 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const item = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; - const listed = ((await s.protocol.getMetaItems({ type: 'view', organizationId: ORG })).items as any[]) + const item = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; + const listed = ((await s.protocol.getMetaItems({ type: 'view' })).items as any[]) .find((v) => v?.name === VIEW); for (const served of [item, listed]) { @@ -226,7 +226,7 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const urgent = (await s.protocol.getMetaItem({ type: 'view', name: 'showcase_task.urgent', organizationId: ORG })).item; + const urgent = (await s.protocol.getMetaItem({ type: 'view', name: 'showcase_task.urgent' })).item; const packagedBase = s.protocol.getPackagedViewBase('showcase_task.urgent'); expect(packagedBase?.label).toBe('Urgent'); expect(translateView(urgent, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('紧急'); @@ -236,7 +236,7 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; expect(translateView(served, BUNDLE, { locale: 'zh-CN' }).label).toBe('进行中'); expect(translateView(served, BUNDLE, { locale: 'en' }).label).toBe(SHIPPED_LABEL); }); @@ -245,12 +245,12 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const edited = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; + const edited = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; const packagedBase = s.protocol.getPackagedViewBase(VIEW); expect(translateView(edited, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe(EDITED_LABEL); s.rows.length = 0; - const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; expect(served.label).toBe(SHIPPED_LABEL); expect(translateView(served, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('进行中'); }); diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index 0a8eb9e4b68..f7e4cccd676 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -188,11 +188,11 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const states = Array.from(rows.values()).map((r) => r.state).sort(); expect(states).toEqual(['active', 'draft']); const active = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', }); expect((active as any).item.label).toBe('Published'); const draft = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', state: 'draft', + type: 'view', name: 'case_grid', state: 'draft', }); expect((draft as any).item.label).toBe('Pending'); }); @@ -249,7 +249,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { }); await expect( protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', state: 'draft', }), ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); @@ -257,7 +257,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { // Also covers the "never saved" path — no overlay row at all. await expect( protocol.getMetaItem({ - type: 'view', name: 'never_existed', organizationId: 'org_alpha', + type: 'view', name: 'never_existed', state: 'draft', }), ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); @@ -277,7 +277,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { mode: 'draft', } as any); const got = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', state: 'draft', }); expect(got).toMatchObject({ @@ -305,7 +305,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { expect((result as any).success).toBe(true); expect((result as any).restoredFromVersion).toBe(1); const live = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', }); expect((live as any).item.label).toBe('v1'); // History records a revert. diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index 1479593819e..e506b1aab57 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -277,7 +277,7 @@ describe('#7559 — the revert reads the history row under the key the writer st ]); // READER: the commit's revert plan asks for exactly that version. - const commits = await protocol.listCommits({ packageId: PKG, organizationId: ORG }); + const commits = await protocol.listCommits({ packageId: PKG }); const target = commits.find((c) => c.id === p2.commitId)!; expect(target.items).toEqual([ { type: 'view', name: 'cases', existedBefore: true, prevVersion: 2 }, diff --git a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts index bfce4199fe6..febc9b4ff60 100644 --- a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts +++ b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts @@ -68,21 +68,17 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { expect(names).not.toContain('live'); }); - it('returns both org-scoped and env-wide drafts for a non-null org, excluding other orgs', async () => { + it('[ADR-0131 D6] lists the environment\'s drafts only — no organization\'s, whichever repository asks', async () => { const rows = [ - { type: 'object', name: 'env_wide', state: 'draft', package_id: 'p', organization_id: null, updated_at: 't1' }, - { type: 'object', name: 'org_scoped', state: 'draft', package_id: 'p', organization_id: 'org_acme', updated_at: 't2' }, - { type: 'object', name: 'other_org', state: 'draft', package_id: 'p', organization_id: 'org_other', updated_at: 't3' }, + { type: 'object', name: 'env_wide', state: 'draft', package_id: 'p', organization_id: null, updated_at: 't1', updated_by: 'u' }, + { type: 'object', name: 'org_scoped', state: 'draft', package_id: 'p', organization_id: 'org_acme', updated_at: 't2', updated_by: 'u' }, + { type: 'object', name: 'other_org', state: 'draft', package_id: 'p', organization_id: 'org_other', updated_at: 't3', updated_by: 'u' }, ]; const { repo } = makeRepo(rows, 'org_acme'); const out = await repo.listDrafts(); - expect(out.map((d) => d.name).sort()).toEqual(['env_wide', 'org_scoped']); - // Each draft is projected with the scope it actually lives in, so the - // publish/discard paths can promote/delete it in THAT scope rather than the - // caller's active org — otherwise the env-wide row 404s as `no_draft` - // (#3115). This projection is the contract those callers depend on. - expect(out.find((d) => d.name === 'env_wide')?.organizationId).toBeNull(); - expect(out.find((d) => d.name === 'org_scoped')?.organizationId).toBe('org_acme'); + expect(out.map((d) => d.name)).toEqual(['env_wide']); + // The header carries no organization: every draft listed is the environment's. + expect(Object.keys(out[0]!)).not.toContain('organizationId'); }); it('filters by type', async () => { @@ -166,11 +162,10 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { const { repo } = makeRepo(rows as any); const out = await repo.listDrafts({ type: 'object' }); - // Exactly the seven header keys — no `item`, no `body`, no `fields`. + // Exactly the six header keys — no `item`, no `body`, no `fields`. expect(Object.keys(out[0]).sort()).toEqual([ 'label', 'name', - 'organizationId', 'packageId', 'type', 'updatedAt', From 62a20a9c505ee67ceb9e8f18021ec97722ad6fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:40:04 +0000 Subject: [PATCH 08/21] wip: typing pins drop the retired read key Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- .../src/protocol-org-overlay-registry-gate.test.ts | 8 +++++--- .../objectql/src/protocol-revert-org-scope.test.ts | 1 - .../src/domains/domain-protocol-handle-typing.test.ts | 10 +++++----- .../src/domains/meta-read-org-scope-parity.test.ts | 2 +- .../domains/packages-protocol-handle-typing.test.ts | 7 +++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts index aa4d793cc45..505a9a77985 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -633,8 +633,10 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid' }); expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); - // Org A's own overlay row is untouched by an env-wide delete. - const forOrgA: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); - expect(forOrgA.item.label).toBe('Org A grid'); + // Org A's legacy row is untouched by an env-wide delete: the one read + // left that reaches it (the anonymous form doors' legacy layer) still + // finds it. + const legacy: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + expect(itemNamed(legacy, 'shared_grid')?.label).toBe('Org A grid'); }); }); diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index e506b1aab57..a65d717da7c 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -190,7 +190,6 @@ function makeStubDriver() { } const PKG = 'app.revertscope'; -const ORG = 'org_x'; const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair describe('#7559 — the revert reads the history row under the key the writer stored it with', () => { diff --git a/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts b/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts index 60aed30139d..b43d61c2a30 100644 --- a/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts +++ b/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts @@ -84,10 +84,10 @@ function declaredKeysCompile( ui.getUiView?.({ object: 'account', type: 'form' }), // `/meta` reads. meta.getMetaTypes?.({}), - meta.getMetaItems?.({ type: 'app', packageId: 'crm', organizationId: 'org_1', previewDrafts: true }), - meta.getMetaItem?.({ type: 'object', name: 'account', organizationId: 'org_1' }), - meta.getMetaItem?.({ type: 'app', name: 'crm', packageId: 'crm', organizationId: undefined, previewDrafts: false }), - meta.getMetaItemLayered?.({ type: 'view', name: 'account_list', organizationId: 'org_1' }), + meta.getMetaItems?.({ type: 'app', packageId: 'crm', previewDrafts: true }), + meta.getMetaItem?.({ type: 'object', name: 'account' }), + meta.getMetaItem?.({ type: 'app', name: 'crm', packageId: 'crm', previewDrafts: false }), + meta.getMetaItemLayered?.({ type: 'view', name: 'account_list' }), // `/meta` write — `writeFace` is a DECLARED closed set and // `'meta-dispatch'` is this door's member of it. meta.saveMetaItem?.({ @@ -99,7 +99,7 @@ function declaredKeysCompile( }), // The two undeclared-request verbs: the NAME is bought, the request // shape is honestly still `any` (nothing declares one). - meta.listDrafts?.({ packageId: 'crm', type: 'view', organizationId: 'org_1' }), + meta.listDrafts?.({ packageId: 'crm', type: 'view' }), meta.migrateStoredMetadata?.({ apply: false, types: ['view'], actor: 'u_1 (test)' }), meta.getProjectId?.(), // The `/mcp` merged skill read. diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index 00afab1670a..eb55bc153b0 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -76,7 +76,7 @@ const ENV_DRAFTS: any[] = [{ type: 'view', name: 'env_board', label: 'Env board const clone = (v: T): T => JSON.parse(JSON.stringify(v)); /** The types the registry declares `allowOrgOverride` — the pre-D6 gate the double models. */ -const ORG_OVERRIDABLE = new Set(DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)); +const ORG_OVERRIDABLE = new Set(DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)); function protocolDouble() { const gate = (type: string, organizationId: unknown) => { diff --git a/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts b/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts index 8fb0bca9a14..5234e7951c5 100644 --- a/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts +++ b/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts @@ -73,7 +73,7 @@ import type { PackagesDomainProtocol } from './packages.js'; function declaredKeysCompile(protocol: PackagesDomainProtocol) { return [ // ADR-0045 visibility flip — `GET` half. - protocol.getMetaItems?.({ type: 'app', packageId: 'crm', organizationId: 'org_1' }), + protocol.getMetaItems?.({ type: 'app', packageId: 'crm' }), // ADR-0045 visibility flip — `SAVE` half. `packageId` is declared // `nullable().optional()`, `actor` optional; both are load-bearing here. protocol.saveMetaItem?.({ @@ -83,11 +83,10 @@ function declaredKeysCompile(protocol: PackagesDomainProtocol) { packageId: 'crm', actor: 'u_publisher', }), - // `applyPublishedSeeds`' seed body read-back, both attempts. - protocol.getMetaItem?.({ type: 'seed', name: 'crm_seed', organizationId: 'org_1' }), + // `applyPublishedSeeds`' seed body read-back. protocol.getMetaItem?.({ type: 'seed', name: 'crm_seed' }), // The manifest-export read. - protocol.getMetaItems?.({ type: 'view', packageId: 'crm', organizationId: undefined }), + protocol.getMetaItems?.({ type: 'view', packageId: 'crm' }), ]; } From dffce02c738a03b1b757123688620f1defb8eb2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:46:02 +0000 Subject: [PATCH 09/21] wip(objectql): sealed app overlay pin Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- .../objectql/src/federated-injected-column-readers.test.ts | 4 ---- packages/objectql/src/protocol-registry-shadow.test.ts | 7 +++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 5d297ef1d02..22258f4bb04 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -225,10 +225,6 @@ const READERS: Record = { disposition: 'row-value', why: 'reads the tenant column off the written row; a federated row has none, so the event omits it', }, - 'plugin.ts#isLegacyOrganizationRow :: organization_id': { - disposition: 'row-value', - why: 'reads the column off a sys_metadata row in hand to skip a legacy organization row (ADR-0131 D6); sys_metadata is a platform table, never federated', - }, 'relation-filter-lowering.ts#admitRelationCondition :: referenceTargetOf()': { disposition: 'caller-predicate', why: "lowers a relation condition the caller wrote; it names an injected column only when the caller did", diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index f546dbc7fc0..b1b48c199e2 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -247,7 +247,7 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art engine.registry.registerItem('app', artifactApp(lock), 'name', PKG); } - it('GET list while the overlay row exists: overlay content wins, artifact envelope wins', async () => { + it('GET list while the overlay row exists: [ADR-0131 D6] the package content wins, and its envelope', async () => { await overlayRowThenLockedArtifact('full'); // A further PUT is refused. [ADR-0131 D6] By the package door now, ahead // of the item lock: the hatch opens no write onto an app a managed @@ -258,7 +258,10 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art const res = await protocol.getMetaItems({ type: 'app' }); const setup = findByName((res as any).items, 'setup'); expect(setup).toBeDefined(); - expect(setup.label).toBe('Setup HACKED'); // overlay content + // [ADR-0131 D6, triage ruling Q1 → C] An `app` a managed package ships + // is sealed: the stored row (written before the package arrived) is not + // served — the package's definition is. + expect(setup.label).toBe('Setup'); expect(setup._lock).toBe('full'); // artifact envelope (ADR-0010 §3.3) expect(setup._packageId).toBe(PKG); expect(setup._provenance).toBe('package'); From 32fd3099a6ce2fae16f38fc2dfded33a38a23df6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:50:53 +0000 Subject: [PATCH 10/21] =?UTF-8?q?wip(rest):=20pins=20re-premised=20on=20en?= =?UTF-8?q?vironment=20=E2=86=92=20code=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- .../rest/src/meta-item-layered-route.test.ts | 4 +- ...-object-extension-property-classes.test.ts | 7 +-- ...meta-object-overlay-extension-fold.test.ts | 40 +++++++++------ .../src/rest-server-audit-org-scope.test.ts | 51 +++++-------------- ...server-meta-cached-etag-door-scope.test.ts | 23 ++++----- 5 files changed, 53 insertions(+), 72 deletions(-) diff --git a/packages/rest/src/meta-item-layered-route.test.ts b/packages/rest/src/meta-item-layered-route.test.ts index bc8b3fc3d8a..c95dbb1cfb2 100644 --- a/packages/rest/src/meta-item-layered-route.test.ts +++ b/packages/rest/src/meta-item-layered-route.test.ts @@ -45,7 +45,7 @@ const LAYERED = { name: 'customer', code: { name: 'customer', label: 'Customer' }, overlay: { label: 'Client' }, - overlayScope: 'org', + overlayScope: 'env', effective: { name: 'customer', label: 'Client' }, _diagnostics: { valid: true }, lock: 'none', @@ -159,7 +159,7 @@ describe('#5882 GET /meta/:type/:name/layers — the declared layered resource', expect(body.code).toEqual({ name: 'customer', label: 'Customer' }); expect(body.overlay).toEqual({ label: 'Client' }); expect(body.effective).toEqual({ name: 'customer', label: 'Client' }); - expect(body.overlayScope).toBe('org'); + expect(body.overlayScope).toBe('env'); expect(body.code).not.toEqual(body.effective); // And it is NOT the ordinary envelope. expect(body.item).toBeUndefined(); diff --git a/packages/rest/src/meta-object-extension-property-classes.test.ts b/packages/rest/src/meta-object-extension-property-classes.test.ts index c2422193f0c..4e1b68ff913 100644 --- a/packages/rest/src/meta-object-extension-property-classes.test.ts +++ b/packages/rest/src/meta-object-extension-property-classes.test.ts @@ -327,9 +327,10 @@ async function measure(opts: { layerCode: layeredBody?.code, layerOverlay: layeredBody?.overlay, layerEffective: layeredBody?.effective, - registryResolved: registry.foldObjectExtendersOnto( - objectName, clone(storedBody ?? declaration), - ), + // [ADR-0131 D6, triage ruling Q1 → C] The host object is one a package + // ships, so a stored row of it is an overlay of sealed managed content + // that no read serves: the base is the owner's declaration. + registryResolved: registry.foldObjectExtendersOnto(objectName, clone(declaration)), storedRow: storedBody, }; } diff --git a/packages/rest/src/meta-object-overlay-extension-fold.test.ts b/packages/rest/src/meta-object-overlay-extension-fold.test.ts index 84f12205c47..292f246d3a6 100644 --- a/packages/rest/src/meta-object-overlay-extension-fold.test.ts +++ b/packages/rest/src/meta-object-overlay-extension-fold.test.ts @@ -37,6 +37,14 @@ // `bridged` host below, live on `main`; and a stored row saved from a folded // read), so `foldObjectExtendersOnto` was made idempotent instead. The // `prefolded` and `bridged` hosts here are what hold that. +// +// [ADR-0131 D6, triage ruling Q1 → C] The host object here is one a package +// ships, so a stored row of it is an overlay of SEALED managed content: no +// read serves it any more — the package's definition, with its extenders +// folded on, is the object. The layered read still REPORTS the row as its +// `overlay` layer. What the cases below pin is therefore the fold on the +// package's definition whatever row is stored, and that the row is reported, +// not served. import { describe, it, expect, vi } from 'vitest'; import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; @@ -306,12 +314,11 @@ async function measure(opts: { layerCode: layeredBody?.code, layerOverlay: layeredBody?.overlay, layerEffective: layeredBody?.effective, - // D9.2 applied to THIS host's base: the overlay row when one exists, - // else the owner's declaration. This is the definition the served body - // is checked against — never the other route. - registryResolved: registry.foldObjectExtendersOnto( - objectName, clone(storedBody ?? declaration), - ), + // D9.2 applied to THIS host's base: [ADR-0131 D6] the owner's + // declaration — a stored row of a packaged object is a sealed overlay + // no read serves. This is the definition the served body is checked + // against — never the other route. + registryResolved: registry.foldObjectExtendersOnto(objectName, clone(declaration)), storedRow: storedBody, }; } @@ -333,11 +340,10 @@ describe('[#8027] an object overlay row is a base layer, not the resolved schema expect(declaredFieldsOf(host.byName)).toEqual(declaredFieldsOf(host.registryResolved)); expect(declaredFieldsOf(host.listed)).toEqual(declaredFieldsOf(host.registryResolved)); - // The customisation itself still wins where it actually speaks. A fold - // that resolved the object by discarding the overlay would satisfy every - // field assertion above and be a worse bug than the one being fixed. - expect((host.byName as { label?: string }).label).toBe('Customer'); - expect((host.listed as { label?: string }).label).toBe('Customer'); + // [ADR-0131 D6] The sealed row's customisation is not served: the + // package's own label is. + expect((host.byName as { label?: string }).label).toBe('Account'); + expect((host.listed as { label?: string }).label).toBe('Account'); }); it('resolves `layers.effective`, and leaves `layers.overlay` the tenant\'s own row', async () => { @@ -442,8 +448,8 @@ describe('[#8027] an object overlay row is a base layer, not the resolved schema expect(entryNames(host.byName, 'validations')).toEqual(['task_rule']); expect(entryNames(host.byName, 'indexes')).toEqual(['task_idx']); } - // The row still customises what it customises. - expect((withRow.byName as { label?: string }).label).toBe('Customer'); + // [ADR-0131 D6] The sealed row is not served, row or no row. + expect((withRow.byName as { label?: string }).label).toBe('Task'); expect((withoutRow.byName as { label?: string }).label).toBe('Task'); }); @@ -487,9 +493,11 @@ describe('[#8027] an object overlay row is a base layer, not the resolved schema expect(fieldNamesOf(none.byName)).not.toEqual(fieldNamesOf(unextended.byName)); expect(fieldNamesOf(unextended.byName)).not.toContain('loyalty_tier'); - // 5. The customised row really does change something, so "the overlay - // still wins" is not vacuous either. - expect((artifact.byName as { label?: string }).label).toBe('Customer'); + // 5. The customised row really does change something — the layered + // read reports it — so "the package's label is served" is not + // vacuous either. + expect((artifact.layerOverlay as { label?: string }).label).toBe('Customer'); + expect((artifact.byName as { label?: string }).label).toBe('Account'); expect((none.byName as { label?: string }).label).toBe('Account'); }); }); diff --git a/packages/rest/src/rest-server-audit-org-scope.test.ts b/packages/rest/src/rest-server-audit-org-scope.test.ts index bcb9cb9dbaa..42bf01c1030 100644 --- a/packages/rest/src/rest-server-audit-org-scope.test.ts +++ b/packages/rest/src/rest-server-audit-org-scope.test.ts @@ -103,28 +103,19 @@ const BUILDER = { systemPermissions: ['manage_metadata'] }; const requestFrom = (fn: any) => fn.mock.calls[0][0]; describe('#8747 GET /meta/:type/:name/audit scopes the read to the environment-wide rows', () => { - it('[ADR-0131 D6] reads the environment-wide rows (`organizationId: null`) even for a caller with an active organization', async () => { - // Every `/meta` write now audits environment-wide, so that is the - // partition this door reads — never every tenant's rows (an absent key). - const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }); - await drive(); - - expect(auditMetaItem).toHaveBeenCalledTimes(1); - const request = requestFrom(auditMetaItem); - expect(request.organizationId).toBe(null); - expect(request).toHaveProperty('organizationId'); - }); - - it('is fail-closed when the caller resolves no organization', async () => { - // A principal with no active organization must read env-wide rows, not - // become a skeleton key. `null` is the env-wide read downstream; an - // ABSENT key would be the pre-fix unscoped call. - const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1' }); - await drive(); - - const request = requestFrom(auditMetaItem); - expect(request.organizationId).toBe(null); - expect(request).toHaveProperty('organizationId'); + it('[ADR-0131 D6] hands the read no organization, whatever the caller\'s: the protocol scopes it to the environment', async () => { + // The organization key retired from the read (the protocol builds the + // environment scope itself, unconditionally — #8747's fail-closed + // scoping now lives there, pinned by `protocol.audit-org-scope.test.ts`). + for (const ctx of [ + { ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }, + { ...BUILDER, userId: 'u1' }, + ]) { + const { auditMetaItem, drive } = boot(ctx); + await drive(); + expect(auditMetaItem).toHaveBeenCalledTimes(1); + expect(requestFrom(auditMetaItem)).not.toHaveProperty('organizationId'); + } }); it('an unresolvable execution context never reaches the read — the anonymous floor refuses first', async () => { @@ -147,22 +138,6 @@ describe('#8747 GET /meta/:type/:name/audit scopes the read to the environment-w expect(auditMetaItem).not.toHaveBeenCalled(); }); - it('never omits the organization — the call shape that leaked is unreachable', async () => { - for (const ctx of [ - { ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }, - { ...BUILDER, userId: 'u1', tenantId: undefined }, - { ...BUILDER, userId: 'u1' }, - ]) { - const { auditMetaItem, drive } = boot(ctx); - await drive(); - const request = requestFrom(auditMetaItem); - expect( - 'organizationId' in request, - `route omitted organizationId for ctx ${JSON.stringify(ctx)}`, - ).toBe(true); - } - }); - it('does not pass the dead `environmentId` the request type never declared', async () => { // Swept with the fix: `auditMetaItem` neither declares nor reads it. // Environment scoping is unaffected — it comes from WHICH protocol diff --git a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts index 92c87912f77..c897b28f0a8 100644 --- a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts +++ b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts @@ -7,8 +7,8 @@ * and the representation it validates agree at the only door that reaches this * verb in production. The history below is how the door used to reduce the * organization by the registry read gate; §1 and §2 now pin that nothing - * reaches the read, and §3 that a supplied organization WOULD move the - * validator (so §1's equality is not vacuous). + * reaches the read, and §3 that since S5 the protocol itself folds no + * organization into the validator either (ADR-0131 D6). * * ── Why this file exists ────────────────────────────────────────────────── * @@ -329,25 +329,22 @@ describe('§2 [ADR-0131 D6] a legacy organization-scoped row is served to nobody // §3 — ⭐ the counterfactual: what the pre-gate is actually worth // ═══════════════════════════════════════════════════════════════════════════ -describe('§3 handing the protocol the RAW tenant issues a different validator', () => { - it(`${NON_OVERRIDABLE}: the raw-tenant validator differs from the one the door issued`, async () => { +describe('§3 [ADR-0131 D6] handing the protocol the RAW tenant issues the SAME validator', () => { + it(`${NON_OVERRIDABLE}: the protocol folds no organization into the validator, so the raw tenant changes nothing`, async () => { const rows = [row(NON_OVERRIDABLE)]; const b = boot(rows); b.as(ORG_A); const throughDoor = await b.get(NON_OVERRIDABLE); - // The same read the door performs, minus the door's `organizationIdFor - // MetaRead` reduction. The protocol re-folds for the BODY — the label - // below proves it — and does NOT re-fold for the validator. + // The same read the door performs, handed a retired organization key: + // no read takes one any more, so neither the body nor the validator + // moves. const raw = await b.protocol.getMetaItemCached({ type: NON_OVERRIDABLE, name: NAME, organizationId: ORG_A, - }); + } as { type: string; name: string }); - expect(raw.data?.label, 'the callee stopped re-folding for the body').toBe(`env ${NON_OVERRIDABLE}`); - expect( - throughDoor.etag, - 'the door\'s pre-gate no longer changes the validator — re-read #16525 §3', - ).not.toBe(`"${raw.etag.value}"`); + expect(raw.data?.label).toBe(`env ${NON_OVERRIDABLE}`); + expect(throughDoor.etag).toBe(`"${raw.etag.value}"`); }); }); From da857b15b4079a0c4c060b8a52ab3f0d03c850cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:02:18 +0000 Subject: [PATCH 11/21] =?UTF-8?q?wip(runtime):=20integration=20pins=20re-p?= =?UTF-8?q?remised=20on=20environment=20=E2=86=92=20code=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR Co-authored-by: Claude --- ...it-meta-item-org-scope.integration.test.ts | 42 ++++++----------- .../packages-seed-apply-org-scope.test.ts | 14 +++--- ...list-commits-org-scope.integration.test.ts | 46 ++++++++++--------- ...-revert-stored-members.integration.test.ts | 13 ++++-- 4 files changed, 55 insertions(+), 60 deletions(-) diff --git a/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts b/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts index 3735fbd8da4..7b654cb4c19 100644 --- a/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts +++ b/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts @@ -149,36 +149,20 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' ]); }); - it('BOTH DIRECTIONS: an org-scoped read sees its own rows AND env-wide rows, and NOT a third org', async () => { + it('[ADR-0131 D6] a read naming an organization sees the env-wide rows only — not its own legacy rows, not another organization\'s', async () => { const { engine, protocol } = await boot(); await seedThreeOrgs(protocol, engine); - const result = await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: ORG_A, - }); - const actors = actorsOf(result); - - // (1) own-org rows visible — without this the filter is "hides everything". - expect(actors).toContain(ACTOR_A); - // (2) env-wide rows visible — THE discriminating control. Package-level - // and REST-authored writes are env-wide and must stay in the tab. - expect(actors).toContain(ACTOR_ENV); - // (3) the third org is gone — the disclosure this card exists to close. - expect(actors).not.toContain(ACTOR_B); - - expect(actors).toEqual([ACTOR_A, ACTOR_ENV].sort()); - }); - - it('is symmetric — org_beta sees its own rows plus env-wide, never org_alpha', async () => { - const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); - - const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: ORG_B, - })); - - expect(actors).toEqual([ACTOR_B, ACTOR_ENV].sort()); - expect(actors).not.toContain(ACTOR_A); + for (const organizationId of [ORG_A, ORG_B]) { + const actors = actorsOf(await (protocol as any).auditMetaItem({ + type: 'view', name: NAME, organizationId, + })); + // The env-wide rows stay — THE discriminating control: every write is + // env-wide and must stay in the tab. No organization's legacy row is + // read: the trail is the environment's (#8747's disclosure stays closed + // in both directions). + expect(actors, organizationId).toEqual([ACTOR_ENV]); + } }); it('an organization with no rows of its own still sees the env-wide rows, and only those', async () => { @@ -228,10 +212,10 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' await plantLegacyOrgSave(engine, ORG_A, 'other_grid', 'carol@alpha.example'); const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: ORG_A, + type: 'view', name: NAME, })); expect(actors).not.toContain('carol@alpha.example'); - expect(actors).toEqual([ACTOR_A, ACTOR_ENV].sort()); + expect(actors).toEqual([ACTOR_ENV]); }); }); diff --git a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts index 12d4cd3786f..139bc7827b4 100644 --- a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts @@ -427,10 +427,11 @@ describe('#15068 · 1 · the two rungs resolve to one read', () => { } }); - it('[CONTROL] the same comparison DOES separate the two rungs for an overridable type', async () => { - // Anti-vacuity, and the reason the assertion above is a reading rather - // than a tautology: on `view` — `allowOrgOverride: true` — the org-first - // rung reads a partition the env-wide rung never touches. + it('[ADR-0131 D6] no type separates the two rungs any more — the formerly overridable `view` included', async () => { + // Before S5 this was the anti-vacuity control: on `view` the org-first + // rung read a partition the env-wide rung never touched. Every read is + // environment → code now, so the comparison is equal for every type — + // and the reads it compares were really issued (non-empty). const engine = seededStore(); const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; @@ -439,8 +440,9 @@ describe('#15068 · 1 · the two rungs resolve to one read', () => { await protocol.getMetaItem({ type: 'view', name: 'anything' }); const envWideReads = engine.metaReads.splice(0); - expect(JSON.stringify(orgFirstReads)).not.toBe(JSON.stringify(envWideReads)); - expect(orgFirstReads.map((w: any) => w.organization_id)).toContain(ORG); + expect(orgFirstReads.length).toBeGreaterThan(0); + expect(JSON.stringify(orgFirstReads)).toBe(JSON.stringify(envWideReads)); + expect(orgFirstReads.map((w: any) => w.organization_id ?? null)).not.toContain(ORG); }); it('every partition the publish path touches for a seed is env-wide', async () => { diff --git a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts index af0b1abafd4..12923e586d0 100644 --- a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts +++ b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts @@ -266,7 +266,7 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', ]); }); - it('returns the env-wide commits to an org-scoped caller (was: returned none of them)', async () => { + it('[ADR-0131 D6] returns the env-wide commits to a caller naming an organization — and not its legacy organization commit', async () => { const { protocol } = await boot(); const { envWide, own } = await seed(protocol as any); @@ -275,15 +275,12 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', organizationId: ACTIVE_ORG, }); - // The CONSEQUENCE: the caller's own commit AND the env-wide one. Before - // the fix this was `[own]` alone — `envWide` was missing outright. - expect(idsOf(commits)).toEqual([envWide, own].sort()); - - // Newest-first is the contract the timeline UI and - // `rollbackToPackageCommit` both rely on, and widening the predicate must - // not disturb it: `own` was published after `envWide`. - expect(commits[0].id).toBe(own); - expect(commits.map((c: any) => c.message)).toEqual(['own-org publish', 'env-wide publish']); + // The env-wide commit is the timeline (#7779 closed its absence). The + // caller's legacy organization commit is reported at boot, never served: + // the timeline holds exactly the commits whose changes the reads serve. + expect(idsOf(commits)).toEqual([envWide]); + expect(idsOf(commits)).not.toContain(own); + expect(commits.map((c: any) => c.message)).toEqual(['env-wide publish']); }); it('does NOT surface another organization’s commits', async () => { @@ -313,28 +310,32 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', expect(idsOf(commits)).not.toContain(otherPkg); }); - it('a caller with NO org still sees the package’s whole timeline (the other door)', async () => { + it('[ADR-0131 D6] a caller with NO org sees the environment\'s timeline — no organization\'s legacy commits', async () => { const { protocol } = await boot(); const { envWide, own, foreign } = await seed(protocol as any); - // The no-org branch is deliberately left package-wide. Narrowing it to - // `organization_id IS NULL` — mirroring only half the #3115 shape — would - // hide every org-scoped commit from this door instead, re-creating the - // bug pointed the other way. #7705 left its own no-org branch alone for - // the same reason and this pins the equivalent here. + // The no-org branch used to be left package-wide, so an org-active + // operator on the packages door saw every organization's legacy commits. + // The ledger is deployment-level (ADR-0131 D7) and the legacy rows are + // reported at boot, not served — the same rule as the metadata rows. const commits = await (protocol as any).listCommits({ packageId: PKG }); - expect(idsOf(commits)).toEqual([envWide, own, foreign].sort()); + expect(idsOf(commits)).toEqual([envWide]); + expect(idsOf(commits)).not.toContain(own); + expect(idsOf(commits)).not.toContain(foreign); }); it('the timeline the rollback planner reads now contains the env-wide commit', async () => { const { protocol } = await boot(); const p = protocol as any; - // C1 under an active org, then C2 env-wide and strictly newer — the shape - // a session hits when its org resolution lapses between two publishes. - const c1 = await publishOne(p, { - view: 'roll_a', packageId: PKG, organizationId: ACTIVE_ORG, message: 'c1', + // [ADR-0131 D6] C1 and C2, both env-wide (every publish is), C2 strictly + // newer; plus a legacy organization commit between them, which the + // timeline does not show and the planner therefore never reverts. + const c1 = await publishOne(p, { view: 'roll_a', packageId: PKG, message: 'c1' }); + await tick(); + const legacy = await publishOne(p, { + view: 'roll_legacy', packageId: PKG, organizationId: ACTIVE_ORG, message: 'legacy', }); await tick(); const c2 = await publishOne(p, { view: 'roll_b', packageId: PKG, message: 'c2' }); @@ -344,8 +345,9 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', // list was `[c1]` and an org-scoped rollback to C1 answered // `{success: true, revertedCommits: []}` — reporting a rollback it had not // performed, with C2's changes still live. - const commits = await p.listCommits({ packageId: PKG, organizationId: ACTIVE_ORG }); + const commits = await p.listCommits({ packageId: PKG }); expect(idsOf(commits)).toEqual([c1, c2].sort()); + expect(idsOf(commits)).not.toContain(legacy); // [#7819 tier 1] ⭐ THE GAP THIS SUITE HANDED ON IS NOW CLOSED — these // lines are the handoff, and they changed exactly as it predicted. diff --git a/packages/runtime/src/package-revert-stored-members.integration.test.ts b/packages/runtime/src/package-revert-stored-members.integration.test.ts index 90ef048dead..4fa6cd71adf 100644 --- a/packages/runtime/src/package-revert-stored-members.integration.test.ts +++ b/packages/runtime/src/package-revert-stored-members.integration.test.ts @@ -183,8 +183,8 @@ async function publish(protocol: any, packageId: string): Promise { expect(res.success).toBe(true); } -async function draftNames(protocol: any, packageId: string, organizationId?: string): Promise { - const { drafts } = await protocol.listDrafts({ packageId, ...(organizationId ? { organizationId } : {}) }); +async function draftNames(protocol: any, packageId: string): Promise { + const { drafts } = await protocol.listDrafts({ packageId }); return drafts.map((d: any) => `${d.type}/${d.name}`).sort(); } @@ -273,7 +273,14 @@ describe('#22090 POST /packages/:id/revert on a Studio-authored package', () => const answer = await revert(STUDIO_PKG, ACTIVE_ORG); expect(answer.status).toBe(200); - expect(await draftNames(protocol, STUDIO_PKG, OTHER_ORG)).toEqual(['view/repairs_board']); + // [ADR-0131 D6] The draft listing is the environment's, so the legacy row + // is read at rest: still there, untouched. + const legacy = (await engine.find('sys_metadata', { + where: { organization_id: OTHER_ORG, package_id: STUDIO_PKG, state: 'draft' }, + context: { isSystem: true }, + })) as any[]; + expect(legacy.map((r) => `${r.type}/${r.name}`)).toEqual(['view/repairs_board']); + expect(await draftNames(protocol, STUDIO_PKG)).toEqual([]); }); }); From 4560ed20b909929c64b0a4f24a74bc917b0b8687 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:06:22 +0000 Subject: [PATCH 12/21] =?UTF-8?q?docs(metadata):=20reads=20are=20environme?= =?UTF-8?q?nt=20=E2=86=92=20code;=20changeset=20for=20S5=20(#15206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- .changeset/15206-reads-environment-only.md | 45 +++++++++++++++++++ content/docs/concepts/metadata-lifecycle.mdx | 16 ++++--- .../kernel/contracts/metadata-service.mdx | 11 ++--- packages/metadata-protocol/src/seed-loader.ts | 6 ++- 4 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 .changeset/15206-reads-environment-only.md diff --git a/.changeset/15206-reads-environment-only.md b/.changeset/15206-reads-environment-only.md new file mode 100644 index 00000000000..a974c95fa36 --- /dev/null +++ b/.changeset/15206-reads-environment-only.md @@ -0,0 +1,45 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/metadata-core': minor +'@objectstack/objectql': minor +'@objectstack/rest': minor +'@objectstack/plugin-security': minor +'@objectstack/spec': minor +--- + +feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + +Clause-②: no (narrowing) + + + +**BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + +ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + +**What changes.** + +- The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. +- `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. +- `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. +- `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. +- [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (a managed flow, action, hook or object — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling (detection reading and Discard Overlay) and is still served. +- `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. +- Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. +- The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. +- The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + +**What moves for consumers.** + +| From | To | +|:--|:--| +| `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | +| `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | +| `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | +| `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | +| `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | +| `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | +| a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | +| an environment overlay of a managed flow / action / hook / object | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + +**What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index cdac13d1c9b..b3dedf66022 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -45,8 +45,8 @@ Reads walk top-to-bottom: the first non-null layer wins. Writes always route to Today this precedence is implemented **inside the protocol layer**, not by `LayeredRepository`. `ObjectStackProtocolImplementation` lazily builds one -`SysMetadataRepository` per org (`getOverlayRepo()`) and merges it against the -in-memory code registry — `getMetaItemLayered()` returns `code` / `overlay` / +`SysMetadataRepository` for the environment (`getOverlayRepo()`) and merges it +against the in-memory code registry — `getMetaItemLayered()` returns `code` / `overlay` / `effective` separately. `LayeredRepository` ships in `@objectstack/metadata-core` and implements exactly the semantics above, but it is **not yet composed** into any production path: every `new LayeredRepository(...)` call site is a test @@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m In shared-database multi-tenancy, **most metadata types must not be per-org customizable** — overriding them would break the physical schema. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowOrgOverride` in `packages/spec/src/kernel/metadata-plugin.zod.ts`. -> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is not served by any `/meta` read until the promotion ceremony (ADR-0131 C7) carries it to the environment layer; re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. The table still records which types take an overlay of a shipped item. +> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read: boot names each such row, per type (`[metadata_org_scoped_unserved]`), and the v18 migration ceremony (ADR-0131 C7, `os migrate`) carries it to the environment layer; nothing deletes or rewrites it before then. Re-save the item in Studio to make the edit live now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. The table still records which types take an overlay of a shipped item. | Type | `allowOrgOverride` | Rationale | | :--- | :---: | :--- | @@ -142,6 +142,8 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged. +An environment row stored over a managed flow, action, hook or object before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had. + --- ## Conflict handling @@ -176,13 +178,13 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi | :--- | :--- | | `InMemoryRepository`, `LayeredRepository` | ✅ Shipped (`@objectstack/metadata-core`) | | `FileSystemRepository` | ✅ Shipped (`@objectstack/metadata-fs`) | -| Change log + `seq` (per-org, monotonic) | ✅ Shipped | +| Change log + `seq` (per environment, monotonic) | ✅ Shipped | | SSE bridge (`/api/v1/dev/metadata-events`, `event: metadata-change`) | ✅ Shipped | | Granular client-side HMR hook + seq status badge | ❌ Not implemented — no such hook or badge exists in the console today | | Console dev-mode HMR reloader (`MetadataHmrReloader`) | ✅ Shipped (console app, mounted app-wide, dev-only) | | `SysMetadataRepository` (overlay over `sys_metadata`) | ✅ Shipped (`@objectstack/metadata-protocol`) | | `LayeredRepository(SysMeta + artifact)` composition | ⏳ Not wired — the class ships, but nothing composes it outside tests | -| `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (via the per-org `getOverlayRepo()` cache) | +| `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (via the environment's `getOverlayRepo()` repository) | | `sys_metadata_history` table (durable, org-keyed change log) | ✅ Shipped (object definition in `@objectstack/metadata-core`; written by `SysMetadataRepository`) | | Cache + Registry refactor against `MetadataRepository` | ⏳ Post-M0 | @@ -236,9 +238,9 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi ### Why artifact never enters the database 1. **Single source of truth.** Code → artifact → memory. If artifact also wrote to `sys_metadata`, every deploy would need a reconciliation step ("the row says v3.0.0 but the code says v3.0.1") that is impossible to get right under concurrent edits. -2. **Overlay stays small and auditable.** Per-org overlay = "what this organization deliberately changed at runtime". A clean table that's safe to dump, diff, or reset to factory. +2. **Overlay stays small and auditable.** The overlay = "what this environment deliberately changed at runtime" (ADR-0131 D6: one environment layer, no per-organization one). A clean table that's safe to dump, diff, or reset to factory. 3. **Immutable infrastructure.** Rolling back a deploy = switching image tags. No DB migration, no reverse `put`, no history replay required. -4. **Per-org multi-tenant economics.** If artifacts were materialised into `sys_metadata`, every org would carry tens of thousands of baseline rows. Overlay-only keeps each org's row count proportional to its actual customisation. +4. **Storage economics.** If artifacts were materialised into `sys_metadata`, every environment would carry tens of thousands of baseline rows. Overlay-only keeps its row count proportional to its actual customisation. ### What if I want auditable history of deployed artifact changes? diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 200875c5892..9a7785a903b 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -160,7 +160,7 @@ silently. Reach for the keyed read wherever the set you are assembling is addressed by key. -Not to be confused with the **org customization overlay**, which is ADR-0005's +Not to be confused with the **customization overlay**, which is ADR-0005's `sys_metadata` mechanism described under "Overlay Management" below — a different thing entirely. @@ -409,10 +409,11 @@ View customization rides ADR-0005's metadata overlay (opt-in per type via `code` / `overlay` / `effective` for it. Since ADR-0131 D6 the per-organization overlay axis is retired: the doors carry no organization, so the overlay is **environment-wide** (`organization_id` NULL) and served to every organization -of the deployment, whatever the author's active organization. An overlay row an -earlier release stored under an organization is not served by any `/meta` read -until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. -Public forms are the exception: until that ceremony the anonymous form doors +of the deployment, whatever the author's active organization. Every protocol +read is environment → code, whatever organization a caller names. An overlay +row an earlier release stored under an organization is served by no read: +boot names it, and the v18 migration ceremony (ADR-0131 C7) carries it to the +environment layer. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 097d4adee50..c559be1b27d 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1321,8 +1321,10 @@ export class SeedLoaderService implements ISeedLoaderService { `column, this seed load names no organization (config.organizationId) and none can be derived — ` + `the install holds ${count === 0 ? 'no organization at all' : 'several organizations'}. Every seed ` + `row of an organization-owned object is stamped with its organization or refused; writing it would ` + - `store a row no organization owns. Fix it by passing the organization the seed populates as ` + - `config.organizationId, or by setting organization_id on the record. Under the 'single' tenancy ` + + `store a row no organization owns. Fix it by setting organization_id on the record — a seed ` + + `published through a package carries no caller organization (ADR-0131 D12), so the record is ` + + `where its owner is named; a direct SeedLoader caller may instead pass config.organizationId. ` + + `Under the 'single' tenancy ` + `posture the Default Organization is created at boot, before seeds load, so a single-tenant ` + `deployment that reaches this has lost that organization.`, }; From 2ccf4570163e9c28fdcd734b8b2cafb97ce5a248 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:53:51 +0000 Subject: [PATCH 13/21] test(metadata-protocol): S5 pin double honours limit and refuses unknown combinators; ledgers follow the deleted pins (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- .../protocol.reads-environment-only.test.ts | 10 ++++-- scripts/engine-double-contract.pinned.json | 35 ++++++++----------- scripts/objectql-double-limit.baseline.json | 6 ---- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts index cfc247035b9..d7db7ee9056 100644 --- a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts +++ b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts @@ -48,10 +48,15 @@ function matches(row: Row, where: Record = {}): boolean { if (!(v as Row[]).some((clause) => matches(row, clause))) return false; continue; } + // Any other combinator is refused, never read as a field name. + if (k.startsWith('$')) throw new Error(`matches: unsupported combinator ${k}`); if (v === undefined) continue; const actual = row[k] ?? null; if (v !== null && typeof v === 'object' && !Array.isArray(v)) { const ops = v as Record; + for (const op of Object.keys(ops)) { + if (op !== '$null' && op !== '$in') throw new Error(`matches: unsupported operator ${op}`); + } if ('$null' in ops && (actual === null) !== ops.$null) return false; if ('$in' in ops && !(ops.$in as unknown[]).includes(actual)) return false; continue; @@ -73,8 +78,9 @@ function harness(seed: Record, artifacts: Artifact[] = []) { const artifactOf = (type: string, name: string) => artifacts.find((a) => a.type === type && a.name === name)?.body; const engine: any = { - async find(t: string, q?: { where?: Row }) { - return table(t).filter((r) => matches(r, q?.where)); + async find(t: string, q?: { where?: Row; limit?: number }) { + const rows = table(t).filter((r) => matches(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, async findOne(t: string, q: { where: Row }) { assertEngineFindOnePredicate(t, q); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 55e84d66a21..9164e60220e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -111,11 +111,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts", - "verb": "findOne", - "pinned": 1 - }, { "file": "packages/metadata-protocol/src/migrations/recorded-by-sentinel.test.ts", "verb": "delete", @@ -1036,21 +1031,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts", "verb": "delete", @@ -1256,6 +1236,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.reads-environment-only.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.reads-environment-only.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.reads-environment-only.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.record-not-found.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 58a61e505bb..4349aff2929 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -156,12 +156,6 @@ "packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts": { "blind": 1 }, - "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts": { - "blind": 1 - }, - "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts": { - "unjudged": 1 - }, "packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts": { "blind": 1 }, From ea26adb03150e966b85cbee3ee2b963d1106225f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:14:01 +0000 Subject: [PATCH 14/21] test(dogfood): a pre-seal rename of a packaged object is kept at rest, not served (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- ...-extension-scalar-divergence.dogfood.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts index f610f274cf6..d4c6de2b16e 100644 --- a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts @@ -300,19 +300,24 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc expect(await listedLabel()).toBe(EXTENSION_LABEL); }); - it('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => { + it('[triage ruling Q1 → C] a pre-seal rename of the PACKAGED object is kept at rest, not served: the package definition wins', async () => { // [ADR-0131 D6] The rename is the row a pre-seal Studio save stored — - // see `withPreSealRename` — read by a cold boot. + // see `withPreSealRename` — read by a cold boot. `object` is sealed + // against overlays of what a managed package ships, so that row is an + // overlay no regime recognises: boot names it + // (`[metadata_sealed_overlay_unserved]`) and does not load it, and the + // reads every writable form derives from serve the package's + // definition. Nothing is deleted. await withPreSealRename(); const layered: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true')).json(); - // The row stored the rename — the customisation is real and readable… + // The row is still at rest — the diagnostic read shows it… expect(labelOf(layered?.overlay)).toBe(RENAMED_LABEL); - // …and neither read that a writable form derives from ever shows it. + // …and the two translated reads serve the package's (folded) label. const after: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); - expect(labelOf(after?.item)).toBe(RENAMED_LABEL); - expect(await listedLabel()).toBe(RENAMED_LABEL); + expect(labelOf(after?.item)).toBe(EXTENSION_LABEL); + expect(await listedLabel()).toBe(EXTENSION_LABEL); }, 180_000); it('[#8284] after the rename the three reads still AGREE — on the extension, not the catalog', async () => { From 5616042ea9107f7cf1b95e9e6b4b9d5eaf365816 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:01:31 +0000 Subject: [PATCH 15/21] docs(metadata): the seal covers every type whose registry entry admits no overlay, not four named ones (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- .changeset/15206-reads-environment-only.md | 4 ++-- content/docs/concepts/metadata-lifecycle.mdx | 2 +- .../18.metadata-read-organization-scope-retired.ts | 7 ++++--- packages/spec/src/migrations/registry.ts | 7 ++++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.changeset/15206-reads-environment-only.md b/.changeset/15206-reads-environment-only.md index a974c95fa36..7e409114fa6 100644 --- a/.changeset/15206-reads-environment-only.md +++ b/.changeset/15206-reads-environment-only.md @@ -23,7 +23,7 @@ ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organi - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. -- [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (a managed flow, action, hook or object — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling (detection reading and Discard Overlay) and is still served. +- [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling (detection reading and Discard Overlay) and is still served. - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. @@ -40,6 +40,6 @@ ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organi | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | -| an environment overlay of a managed flow / action / hook / object | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | +| an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index b3dedf66022..cb4d1bca4ab 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -142,7 +142,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged. -An environment row stored over a managed flow, action, hook or object before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had. +An environment row stored over a managed item of a type sealed against overlays (a flow, action, hook, object or datasource, for example — any type whose registry entry admits no overlay) before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had. --- diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts index bf6dc03cc12..c01e0115a8e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts @@ -19,8 +19,9 @@ export const entry: SemanticMigration = { + 'metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: ' + 'the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search ' + 'and references reads, and the boot hydration — plus an environment overlay row of an item a ' - + 'managed package ships on a type sealed against overlays (a managed flow, action, hook or ' - + 'object), which the reads served over the package definition', + + 'managed package ships on a type sealed against overlays (any type whose registry entry admits ' + + 'no overlay: a managed flow, action, hook, object or datasource, for example), which the reads ' + + 'served over the package definition', replacement: 'drop `organizationId` from the read request: every read serves the environment row, else the ' + 'code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored ' @@ -36,7 +37,7 @@ export const entry: SemanticMigration = { + '(meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped ' + 'write (metadata-write-organization-scope-refused); a read that still served a legacy ' + 'organization row would keep a retired layer live, one caller at a time. D6 also seals managed ' - + 'content: an overlay of a managed flow, action, hook or object sits in no regime D6 recognises, ' + + 'content: an overlay of a managed item of a sealed type sits in no regime D6 recognises, ' + 'so the package definition wins at read as the write doors already make it win at write. ' + 'Permission sets keep their own ruling on stored forks.', acceptanceCriteria: diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index cf8840d7930..f4400dc11af 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16350,8 +16350,9 @@ const step18: MigrationStep = { + 'metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: ' + 'the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search ' + 'and references reads, and the boot hydration — plus an environment overlay row of an item a ' - + 'managed package ships on a type sealed against overlays (a managed flow, action, hook or ' - + 'object), which the reads served over the package definition', + + 'managed package ships on a type sealed against overlays (any type whose registry entry admits ' + + 'no overlay: a managed flow, action, hook, object or datasource, for example), which the reads ' + + 'served over the package definition', replacement: 'drop `organizationId` from the read request: every read serves the environment row, else the ' + 'code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored ' @@ -16367,7 +16368,7 @@ const step18: MigrationStep = { + '(meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped ' + 'write (metadata-write-organization-scope-refused); a read that still served a legacy ' + 'organization row would keep a retired layer live, one caller at a time. D6 also seals managed ' - + 'content: an overlay of a managed flow, action, hook or object sits in no regime D6 recognises, ' + + 'content: an overlay of a managed item of a sealed type sits in no regime D6 recognises, ' + 'so the package definition wins at read as the write doors already make it win at write. ' + 'Permission sets keep their own ruling on stored forks.', acceptanceCriteria: From bb6012af863b02f48509305cebdb23b451a68b94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:36:52 +0000 Subject: [PATCH 16/21] docs(deployment): a hatch-written overlay of a managed item is kept at rest, not served (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- content/docs/deployment/environment-variables.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 6f4c2102a6a..4d44ced98c5 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from | `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. | | `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). | | `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. | -| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. Overlay rows this hatch wrote before are still served, and a row of a type that merges overlays at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed to restore the package's definition. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | +| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. An overlay row this hatch wrote before over a managed item is kept at rest but **not served**: every read takes the package's definition, and boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies — re-express the change as a new item under a new name, or delete the row. A stored fork of a packaged permission set is the one exception: it is still served, with its detection reading and Discard Overlay. A row of a type that merges overlays at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed to restore the package's definition. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | --- From b8ad3c549ab93efc8eecfcd62507fa422dcee0d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:46:53 +0000 Subject: [PATCH 17/21] chore(spec): regenerate the protocol reference after merging main (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- content/docs/references/api/protocol.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index be67a2683ea..fef5485c7c0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1618,7 +1618,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | -| **flows** | `Record; refusals?: Record }>` | optional | Screen-flow translations keyed by flow name | +| **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -1683,7 +1683,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1768,7 +1768,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | From 6c7c6c28dde8af710fa46311db5490a28fadc29f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:50:26 +0000 Subject: [PATCH 18/21] =?UTF-8?q?chore(spec):=20regenerate=20spec-changes.?= =?UTF-8?q?json=20=E2=80=94=20metadata-read-organization-scope-retired=20(?= =?UTF-8?q?#15206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- packages/spec/spec-changes.json | 90 ++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 0b5b326ce29..8158184aee4 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2208,14 +2208,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the CEL value envelope of their string form (isoDate(today()), isoDatetime(now())), the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -2228,11 +2228,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. and the date macros NOW() and TODAY() with an optional day offset included", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A date macro is its CEL string form on the UTC calendar: TODAY() is isoDate(today()), TODAY() + N and TODAY() - N are isoDate(daysFromNow(N)) and isoDate(daysAgo(N)), NOW() is isoDatetime(now()), and NOW() plus or minus N is isoDatetime(addDays(now(), N)) with N signed, which keeps the time of day where daysFromNow lands on midnight; a variable offset is isoDate(addDays(today(), days)). Where an envelope is literal data — a string inside an object or list value, or either legacy assignment shape — an envelope written in its place is stored as the object it spells, so the whole value is one envelope building a CEL map or list literal, and a legacy assignment moves into the assignments map. A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. The date macros wrote the ISO text of the UTC day or instant, which isoDate and isoDatetime write byte for byte for a whole number of days, but the template read an offset it could not use as 0 — a variable it did not find, a value that is not a number, text that is neither — where CEL fails the run, and it truncated a fractional offset after adding it to the day of the month, where addDays truncates the offset itself and daysFromNow and daysAgo refuse a fraction at build. A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -2365,7 +2365,7 @@ "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8281 tracked files (0 across the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces every earlier count)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8351 tracked files (0 across the 8281 at 47b1f0bb7, the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78, to 17980 and 7523 at 47b1f0bb7 and to 18047 and 7545 at this pin (git grep -o -F, the method that reproduces every earlier count)." }, { "surface": "the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)", @@ -2393,7 +2393,7 @@ "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", "migrationId": "kernel-runtime-config-timeout-unit-in-key", "toMajor": 18, - "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." + "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells resourceLimits.timeout 0 times across 8351 tracked files, against lit controls timeout 1694, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8281, and 1674 / 337 / 2, at 47b1f0bb7; 0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." }, { "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", @@ -2402,6 +2402,13 @@ "toMajor": 18, "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one boundary: a host passes StartupOptions in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back from the same call. The file already contained its own counter-example — IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter timeoutMs while the options object beside it said timeout, so one contract carried both conventions and the suffixed one was already the honest half. totalDuration is the sum of the per-plugin durations, so the two had to move together or the aggregate would have been spelled unlike its parts. All three are retiredKey() tombstones; none of these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: StartupOptions is a boot-time call argument and the two result shapes are emitted measurements, so none is ever a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one — the same disposition HealthStatus.timestamp took on this very file (epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ADR-0087." }, + { + "surface": "ui.UserActionsConfig.editInline — an OMITTED editInline inside a list view's userActions block, or a page interfaceConfig.userActions block", + "replacement": "nothing, to take the platform default: the list is editable in place by a user who may update the object, under the permission gate that already exists. To keep a list read-only in place — a log, an audit trail, a history, a report roll-up — write it: `userActions: { editInline: false }`", + "migrationId": "list-view-edit-inline-default-on", + "toMajor": 18, + "rationale": "A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: the maintainer's ruling of 2026-10-10 made a list view editable in place by default on the v18 line, and the declared default of `UserActionsConfigSchema.editInline` moved from `false` to `true`. A `userActions` block that omits `editInline` now parses to `true` — the renderer offers the inline-edit toggle, and a user who may `update` the object edits a cell in place with the field's type-aware widget; a user without `update` sees no toggle, because the permission gate is untouched. A view with no `userActions` block at all parses with none on either side; the renderer reads an absent key as the spec default. The one-vocabulary rule objectui ruled for the fold stands: a boolean view-level `inlineEdit` folds into `editInline` (`true` reads on and opens the grid in edit mode), and an explicit `editInline` wins. Not losslessly convertible because the question is intent, not text: only the deployment knows which silent lists were relying on read-only cells. The accept set is unchanged — a boolean — and every authored `editInline` parses exactly as before. A document that serialised an earlier parse (an `os compile` artifact) carries a written `false` and stays read-only in place; recompile it, or delete the key." + }, { "surface": "view.list.navigation.view", "replacement": "page assignment — assign a `record` page to the object and let `isDefault` pick the one that opens. That is the machinery that resolves a detail layout by name; a list view's `navigation` block decides only HOW the detail is surfaced (`mode`, `size`, `preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged", @@ -2435,7 +2442,7 @@ "replacement": "`batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged", "migrationId": "logging-durations-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8351 tracked files, against lit controls `useState` 2630 and `timeout` 1694 on the same corpus (all four 0 across 8281, against 2630 and 1674, at 47b1f0bb7, 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." }, { "surface": "the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors", @@ -2542,6 +2549,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." }, + { + "surface": "the organizationId member of the GetMetaItems, GetMetaItem, GetMetaItemLayered, AuditMetaItem, HistoryMetaItem and GetMetaItemCached request schemas and of each ListDraftsResponse draft of @objectstack/spec, and the org value of GetMetaItemLayeredResponse overlayScope; and every metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search and references reads, and the boot hydration — plus an environment overlay row of an item a managed package ships on a type sealed against overlays (any type whose registry entry admits no overlay: a managed flow, action, hook, object or datasource, for example), which the reads served over the package definition", + "replacement": "drop `organizationId` from the read request: every read serves the environment row, else the code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored organization-scoped is served by no read; boot names each one, per type, and the v18 migration ceremony (ADR-0131 D10) carries it — nothing is deleted or rewritten before it. An environment overlay of sealed managed content is not served either, and boot names it: keep the change by re-expressing it as a new item under a new name (a linkage-free clone), or delete the stored row. The anonymous public-form doors alone keep reading the Default Organization's legacy view rows, fail-closed, until the ceremony carries them", + "migrationId": "metadata-read-organization-scope-retired", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis: environment metadata belongs to the whole deployment. The doors stopped carrying an organization (meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped write (metadata-write-organization-scope-refused); a read that still served a legacy organization row would keep a retired layer live, one caller at a time. D6 also seals managed content: an overlay of a managed item of a sealed type sits in no regime D6 recognises, so the package definition wins at read as the write doors already make it win at write. Permission sets keep their own ruling on stored forks." + }, { "surface": "the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of @objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — including the five types that declared allowOrgOverride (view, dashboard, report, translation, email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller publishing a package, which a published seed draft whose records named no organization was loaded into under the group posture", "replacement": "drop `organizationId` from the request: every metadata write lands environment-wide (`organization_id` NULL), where every organization reads it. The key is stripped at a spec parse and refused at the protocol: a request that still names an organization is refused with 403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); under group a seed record that names none is refused at load", @@ -2703,6 +2717,13 @@ "toMajor": 18, "rationale": "`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server that has the deployment's SDUI component manifest compiles the source, stores the plugin namespaces it uses as `requires`, and refuses a written list that disagrees. A react source is executed at render and never compiled at save, and full and slotted pages have no source, so on those kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and every other door that parses a page name the key, the page's kind and the compiled kinds. An empty list is refused like a full one, because the key is what is refused. No page body authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from 17` lists the edit for authored sources, which the parse refuses until it is made. The delete loses nothing a page did. What it cannot decide is whether the page should have been an html page: an author who wrote the list to have plugin presence checked gets that check only on an html page, where the platform derives the list from the source and judges it at save and load." }, + { + "surface": "page.slots.details authored beside page.slots.tabs on one page — either slot a single component or an array, an empty details array included, whatever the page kind", + "replacement": "One `tabs` slot whose items carry the details body: the `record:details` component (its `sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by convention — e.g. `{ label: 'Details', children: [{ type: 'record:details', properties: { … } }] }` — and no `details` slot. To keep the synthesized tab strip with the authored details body in its Details tab instead, delete `slots.tabs`.", + "migrationId": "page-slots-details-beside-tabs-refused", + "toMajor": 18, + "rationale": "The slot map declared `details` and `tabs` as two independent optional slots, and they are not: on a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save door while the authored details body — its sections, its hidden fields — silently never applied. The platform's own `sys_user_detail` page authored both, so its Identity and Audit sections never showed and the ban columns it hides were never hidden by it; it now carries its `record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `objectstack validate` and the metadata save door (`422 INVALID_METADATA`). Measured reach before the narrowing: in this repository only `sys_user_detail` authored the pair (no example app page does), and hotcrm's one slotted page authors `header` and `discussion` only. ⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the author's decision, and moving it makes visible a body that never rendered — a change to the page, not a respelling. ⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion touches it), so it renders as before — the authored tabs, without the details body — while its read diagnostics name the pair and saving it again is refused until the details body moves. ADR-0087." + }, { "surface": "permission.objects..allowRestore / permission.objects..allowPurge — the object-permission bits for undelete and hard delete", "replacement": "(removed — the `restore` and `purge` operations they claimed to gate do not exist.) A dispatched `restore` or `purge` is denied fail-closed by the permission evaluator's destructive-operation backstop for every principal. The bits return together with the operations they gate. `allowTransfer`, the third lifecycle bit, is enforced and stays.", @@ -2988,7 +3009,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", @@ -3107,7 +3128,7 @@ "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", "migrationId": "system-metrics-jsdoc-durations-unit-in-key", "toMajor": 18, - "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." + "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8351 tracked files at that sha, against lit controls window 4470, timeout 1694, period 249, interval 213 and metrics 456 on that same corpus and sha (0 across 8281, against 4449 / 1674 / 249 / 213 / 455, at 47b1f0bb7, 0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." }, { "surface": "the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)", @@ -3135,7 +3156,7 @@ "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8351 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 18047 hits for the bare token objectstack, and 7545 for the package specifier @objectstack/spec (at 47b1f0bb7: 0 across 8281, Span 517, 17980 and 7523; at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." }, { "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", @@ -3156,7 +3177,7 @@ "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", "migrationId": "tenant-schema-cache-ttl-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells it 0 times across 8351 tracked files, against lit controls `TTL` 184 and `tenant` 1340 on the same corpus (0 across 8281, against 184 and 1338, at 47b1f0bb7; 0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." }, { "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", @@ -5846,14 +5867,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the CEL value envelope of their string form (isoDate(today()), isoDatetime(now())), the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -5866,11 +5887,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. and the date macros NOW() and TODAY() with an optional day offset included", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A date macro is its CEL string form on the UTC calendar: TODAY() is isoDate(today()), TODAY() + N and TODAY() - N are isoDate(daysFromNow(N)) and isoDate(daysAgo(N)), NOW() is isoDatetime(now()), and NOW() plus or minus N is isoDatetime(addDays(now(), N)) with N signed, which keeps the time of day where daysFromNow lands on midnight; a variable offset is isoDate(addDays(today(), days)). Where an envelope is literal data — a string inside an object or list value, or either legacy assignment shape — an envelope written in its place is stored as the object it spells, so the whole value is one envelope building a CEL map or list literal, and a legacy assignment moves into the assignments map. A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. The date macros wrote the ISO text of the UTC day or instant, which isoDate and isoDatetime write byte for byte for a whole number of days, but the template read an offset it could not use as 0 — a variable it did not find, a value that is not a number, text that is neither — where CEL fails the run, and it truncated a fractional offset after adding it to the day of the month, where addDays truncates the offset itself and daysFromNow and daysAgo refuse a fraction at build. A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -6003,7 +6024,7 @@ "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8281 tracked files (0 across the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces every earlier count)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8351 tracked files (0 across the 8281 at 47b1f0bb7, the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78, to 17980 and 7523 at 47b1f0bb7 and to 18047 and 7545 at this pin (git grep -o -F, the method that reproduces every earlier count)." }, { "surface": "the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)", @@ -6031,7 +6052,7 @@ "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", "migrationId": "kernel-runtime-config-timeout-unit-in-key", "toMajor": 18, - "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." + "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells resourceLimits.timeout 0 times across 8351 tracked files, against lit controls timeout 1694, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8281, and 1674 / 337 / 2, at 47b1f0bb7; 0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." }, { "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", @@ -6040,6 +6061,13 @@ "toMajor": 18, "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one boundary: a host passes StartupOptions in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back from the same call. The file already contained its own counter-example — IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter timeoutMs while the options object beside it said timeout, so one contract carried both conventions and the suffixed one was already the honest half. totalDuration is the sum of the per-plugin durations, so the two had to move together or the aggregate would have been spelled unlike its parts. All three are retiredKey() tombstones; none of these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: StartupOptions is a boot-time call argument and the two result shapes are emitted measurements, so none is ever a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one — the same disposition HealthStatus.timestamp took on this very file (epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ADR-0087." }, + { + "surface": "ui.UserActionsConfig.editInline — an OMITTED editInline inside a list view's userActions block, or a page interfaceConfig.userActions block", + "replacement": "nothing, to take the platform default: the list is editable in place by a user who may update the object, under the permission gate that already exists. To keep a list read-only in place — a log, an audit trail, a history, a report roll-up — write it: `userActions: { editInline: false }`", + "migrationId": "list-view-edit-inline-default-on", + "toMajor": 18, + "rationale": "A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: the maintainer's ruling of 2026-10-10 made a list view editable in place by default on the v18 line, and the declared default of `UserActionsConfigSchema.editInline` moved from `false` to `true`. A `userActions` block that omits `editInline` now parses to `true` — the renderer offers the inline-edit toggle, and a user who may `update` the object edits a cell in place with the field's type-aware widget; a user without `update` sees no toggle, because the permission gate is untouched. A view with no `userActions` block at all parses with none on either side; the renderer reads an absent key as the spec default. The one-vocabulary rule objectui ruled for the fold stands: a boolean view-level `inlineEdit` folds into `editInline` (`true` reads on and opens the grid in edit mode), and an explicit `editInline` wins. Not losslessly convertible because the question is intent, not text: only the deployment knows which silent lists were relying on read-only cells. The accept set is unchanged — a boolean — and every authored `editInline` parses exactly as before. A document that serialised an earlier parse (an `os compile` artifact) carries a written `false` and stays read-only in place; recompile it, or delete the key." + }, { "surface": "view.list.navigation.view", "replacement": "page assignment — assign a `record` page to the object and let `isDefault` pick the one that opens. That is the machinery that resolves a detail layout by name; a list view's `navigation` block decides only HOW the detail is surfaced (`mode`, `size`, `preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged", @@ -6073,7 +6101,7 @@ "replacement": "`batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged", "migrationId": "logging-durations-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8351 tracked files, against lit controls `useState` 2630 and `timeout` 1694 on the same corpus (all four 0 across 8281, against 2630 and 1674, at 47b1f0bb7, 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." }, { "surface": "the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors", @@ -6180,6 +6208,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." }, + { + "surface": "the organizationId member of the GetMetaItems, GetMetaItem, GetMetaItemLayered, AuditMetaItem, HistoryMetaItem and GetMetaItemCached request schemas and of each ListDraftsResponse draft of @objectstack/spec, and the org value of GetMetaItemLayeredResponse overlayScope; and every metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search and references reads, and the boot hydration — plus an environment overlay row of an item a managed package ships on a type sealed against overlays (any type whose registry entry admits no overlay: a managed flow, action, hook, object or datasource, for example), which the reads served over the package definition", + "replacement": "drop `organizationId` from the read request: every read serves the environment row, else the code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored organization-scoped is served by no read; boot names each one, per type, and the v18 migration ceremony (ADR-0131 D10) carries it — nothing is deleted or rewritten before it. An environment overlay of sealed managed content is not served either, and boot names it: keep the change by re-expressing it as a new item under a new name (a linkage-free clone), or delete the stored row. The anonymous public-form doors alone keep reading the Default Organization's legacy view rows, fail-closed, until the ceremony carries them", + "migrationId": "metadata-read-organization-scope-retired", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis: environment metadata belongs to the whole deployment. The doors stopped carrying an organization (meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped write (metadata-write-organization-scope-refused); a read that still served a legacy organization row would keep a retired layer live, one caller at a time. D6 also seals managed content: an overlay of a managed item of a sealed type sits in no regime D6 recognises, so the package definition wins at read as the write doors already make it win at write. Permission sets keep their own ruling on stored forks." + }, { "surface": "the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of @objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — including the five types that declared allowOrgOverride (view, dashboard, report, translation, email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller publishing a package, which a published seed draft whose records named no organization was loaded into under the group posture", "replacement": "drop `organizationId` from the request: every metadata write lands environment-wide (`organization_id` NULL), where every organization reads it. The key is stripped at a spec parse and refused at the protocol: a request that still names an organization is refused with 403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); under group a seed record that names none is refused at load", @@ -6341,6 +6376,13 @@ "toMajor": 18, "rationale": "`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server that has the deployment's SDUI component manifest compiles the source, stores the plugin namespaces it uses as `requires`, and refuses a written list that disagrees. A react source is executed at render and never compiled at save, and full and slotted pages have no source, so on those kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and every other door that parses a page name the key, the page's kind and the compiled kinds. An empty list is refused like a full one, because the key is what is refused. No page body authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from 17` lists the edit for authored sources, which the parse refuses until it is made. The delete loses nothing a page did. What it cannot decide is whether the page should have been an html page: an author who wrote the list to have plugin presence checked gets that check only on an html page, where the platform derives the list from the source and judges it at save and load." }, + { + "surface": "page.slots.details authored beside page.slots.tabs on one page — either slot a single component or an array, an empty details array included, whatever the page kind", + "replacement": "One `tabs` slot whose items carry the details body: the `record:details` component (its `sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by convention — e.g. `{ label: 'Details', children: [{ type: 'record:details', properties: { … } }] }` — and no `details` slot. To keep the synthesized tab strip with the authored details body in its Details tab instead, delete `slots.tabs`.", + "migrationId": "page-slots-details-beside-tabs-refused", + "toMajor": 18, + "rationale": "The slot map declared `details` and `tabs` as two independent optional slots, and they are not: on a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save door while the authored details body — its sections, its hidden fields — silently never applied. The platform's own `sys_user_detail` page authored both, so its Identity and Audit sections never showed and the ban columns it hides were never hidden by it; it now carries its `record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `objectstack validate` and the metadata save door (`422 INVALID_METADATA`). Measured reach before the narrowing: in this repository only `sys_user_detail` authored the pair (no example app page does), and hotcrm's one slotted page authors `header` and `discussion` only. ⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the author's decision, and moving it makes visible a body that never rendered — a change to the page, not a respelling. ⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion touches it), so it renders as before — the authored tabs, without the details body — while its read diagnostics name the pair and saving it again is refused until the details body moves. ADR-0087." + }, { "surface": "permission.objects..allowRestore / permission.objects..allowPurge — the object-permission bits for undelete and hard delete", "replacement": "(removed — the `restore` and `purge` operations they claimed to gate do not exist.) A dispatched `restore` or `purge` is denied fail-closed by the permission evaluator's destructive-operation backstop for every principal. The bits return together with the operations they gate. `allowTransfer`, the third lifecycle bit, is enforced and stays.", @@ -6626,7 +6668,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", @@ -6745,7 +6787,7 @@ "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", "migrationId": "system-metrics-jsdoc-durations-unit-in-key", "toMajor": 18, - "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." + "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8351 tracked files at that sha, against lit controls window 4470, timeout 1694, period 249, interval 213 and metrics 456 on that same corpus and sha (0 across 8281, against 4449 / 1674 / 249 / 213 / 455, at 47b1f0bb7, 0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." }, { "surface": "the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)", @@ -6773,7 +6815,7 @@ "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8351 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 18047 hits for the bare token objectstack, and 7545 for the package specifier @objectstack/spec (at 47b1f0bb7: 0 across 8281, Span 517, 17980 and 7523; at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." }, { "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", @@ -6794,7 +6836,7 @@ "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", "migrationId": "tenant-schema-cache-ttl-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells it 0 times across 8351 tracked files, against lit controls `TTL` 184 and `tenant` 1340 on the same corpus (0 across 8281, against 184 and 1338, at 47b1f0bb7; 0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." }, { "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", From 82e2de41b2c989665518858659ca49b5ae2c7a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:55:37 +0000 Subject: [PATCH 19/21] perf(metadata-protocol): the legacy-row boot report counts the three ledgers and projects sys_metadata to the printed columns (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- ...rotocol.org-scoped-cold-boot-audit.test.ts | 10 ++++ .../protocol.reads-environment-only.test.ts | 51 ++++++++++++++++--- packages/metadata-protocol/src/protocol.ts | 40 ++++++++++----- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts index dd06dfff4dd..54a5249e3cf 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts @@ -44,6 +44,11 @@ interface Row { */ function matchesWhere(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { + if (k === '$and') { + if (!(v as Array>).every((clause) => matchesWhere(r, clause))) return false; + continue; + } + if (k.startsWith('$')) throw new Error(`matchesWhere: unsupported combinator ${k}`); if (v === undefined) continue; const actual = (r as any)[k]; if (v !== null && typeof v === 'object') { @@ -55,6 +60,7 @@ function matchesWhere(r: Row, where: Record): boolean { if ('$in' in ops) { if (!(ops.$in as unknown[]).includes(actual)) return false; } + if ('$ne' in ops && actual === ops.$ne) return false; continue; } if (actual !== v) return false; @@ -78,6 +84,10 @@ function makeEngine( if (opts.dropPredicates) return rows; return rows.filter((r) => matchesWhere(r, q.where)); }, + async count(table: string, q: { where: Record }) { + const ledger = (opts.ledgers?.[table] ?? []) as any[]; + return ledger.filter((r) => matchesWhere(r, q.where)).length; + }, async findOne(object: string, query?: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, query); return null; }, async insert() { return { id: 'x' }; }, diff --git a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts index d7db7ee9056..cbd537c2d78 100644 --- a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts +++ b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts @@ -41,13 +41,17 @@ const TIER_A = ['view', 'dashboard', 'report', 'translation', 'email_template'] type Row = Record; -/** Equality (NULL included), `$or`, `$null` and `$in` — the subset these reads emit. */ +/** Equality (NULL included), `$or`, `$and`, `$null`, `$in` and `$ne` — the subset these reads emit. */ function matches(row: Row, where: Record = {}): boolean { for (const [k, v] of Object.entries(where)) { if (k === '$or') { if (!(v as Row[]).some((clause) => matches(row, clause))) return false; continue; } + if (k === '$and') { + if (!(v as Row[]).every((clause) => matches(row, clause))) return false; + continue; + } // Any other combinator is refused, never read as a field name. if (k.startsWith('$')) throw new Error(`matches: unsupported combinator ${k}`); if (v === undefined) continue; @@ -55,10 +59,11 @@ function matches(row: Row, where: Record = {}): boolean { if (v !== null && typeof v === 'object' && !Array.isArray(v)) { const ops = v as Record; for (const op of Object.keys(ops)) { - if (op !== '$null' && op !== '$in') throw new Error(`matches: unsupported operator ${op}`); + if (op !== '$null' && op !== '$in' && op !== '$ne') throw new Error(`matches: unsupported operator ${op}`); } if ('$null' in ops && (actual === null) !== ops.$null) return false; if ('$in' in ops && !(ops.$in as unknown[]).includes(actual)) return false; + if ('$ne' in ops && actual === ops.$ne) return false; continue; } if (actual !== v) return false; @@ -77,10 +82,19 @@ function harness(seed: Record, artifacts: Artifact[] = []) { let nextId = 0; const artifactOf = (type: string, name: string) => artifacts.find((a) => a.type === type && a.name === name)?.body; + /** Every `find` the protocol made: the object, its predicate, and the columns it asked for. */ + const finds: Array<{ object: string; where?: Row; fields?: string[] }> = []; const engine: any = { - async find(t: string, q?: { where?: Row; limit?: number }) { + async find(t: string, q?: { where?: Row; limit?: number; fields?: string[] }) { + finds.push({ object: t, where: q?.where, fields: q?.fields }); const rows = table(t).filter((r) => matches(r, q?.where)); - return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + const page = typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + // A projection hands back only the columns asked for, as a driver does. + const fields = q?.fields; + return fields ? page.map((r) => Object.fromEntries(fields.map((f) => [f, r[f]]))) : page; + }, + async count(t: string, q?: { where?: Row }) { + return table(t).filter((r) => matches(r, q?.where)).length; }, async findOne(t: string, q: { where: Row }) { assertEngineFindOnePredicate(t, q); @@ -115,7 +129,7 @@ function harness(seed: Record, artifacts: Artifact[] = []) { }, }; const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_prod') as any; - return { protocol, tables }; + return { protocol, tables, finds }; } const metaRow = (type: string, name: string, org: string | null, body: Row, over: Row = {}): Row => ({ @@ -273,9 +287,9 @@ describe('§3 the boot report names both populations, per type, with their remed async function boot(seed: Record, artifacts: Artifact[] = []) { const warns: string[] = []; vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { warns.push(a.map(String).join(' ')); }); - const { protocol } = harness(seed, artifacts); + const { protocol, finds } = harness(seed, artifacts); const result = await protocol.loadMetaFromDb(); - return { result, warns }; + return { result, warns, finds }; } it('one line per population: the legacy organization rows (the ceremony), the sealed overlays (the two remedies)', async () => { @@ -311,6 +325,29 @@ describe('§3 the boot report names both populations, per type, with their remed expect(sealed[0]).not.toContain('env_grid'); }); + it('it reads no ledger row and no row body: the three ledgers are counted, sys_metadata yields the four columns the line prints', async () => { + const LEDGERS = ['sys_metadata_commit', 'sys_metadata_history', 'sys_metadata_audit']; + const { warns, finds } = await boot({ + sys_metadata: [metaRow('view', 'org_grid', ORG, { label: 'org' })], + sys_metadata_commit: [{ id: 'c1', organization_id: ORG, payload: 'body' }, { id: 'c2', organization_id: null }], + sys_metadata_history: [{ id: 'h1', organization_id: ORG, metadata: 'body' }], + sys_metadata_audit: [{ id: 'a1', organization_id: '' }], + }); + + const legacy = warns.filter((w) => w.includes('[metadata_org_scoped_unserved]')); + expect(legacy).toHaveLength(1); + expect(legacy[0]).toContain('view×1 (org_grid@org_a)'); + expect(legacy[0]).toContain('sys_metadata_commit×1, sys_metadata_history×1'); + expect(legacy[0]).not.toContain('sys_metadata_audit×'); + + // No ledger row is read at all… + expect(finds.filter((f) => LEDGERS.includes(f.object))).toEqual([]); + // …and the legacy sys_metadata read projects the printed columns, never `metadata`. + const legacyReads = finds.filter((f) => f.object === 'sys_metadata' && JSON.stringify(f.where ?? {}).includes('$ne')); + expect(legacyReads).toHaveLength(1); + expect(legacyReads[0].fields).toEqual(['type', 'name', 'organization_id', 'state']); + }); + it('silence on a store with neither population', async () => { const { warns } = await boot({ sys_metadata: [metaRow('view', 'env_grid', null, { label: 'env' })] }); expect(warns.filter((w) => w.includes('_unserved]'))).toEqual([]); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 4ecad1546b4..d3ba654c25d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -26387,25 +26387,34 @@ export class ObjectStackProtocolImplementation implements /** Names printed per type before the line collapses to a count. */ const SAMPLE_PER_TYPE = 5; try { - const legacyRows = async (object: string, where: Record): Promise => { + // A legacy row is one whose `organization_id` names an + // organization: neither NULL nor the empty string. + const LEGACY = { $and: [{ organization_id: { $null: false } }, { organization_id: { $ne: '' } }] }; + // An unprovisioned ledger holds no legacy row; any other failure is + // this diagnostic's own, and stays here. + const orNoTable = async (object: string, read: () => Promise, empty: T): Promise => { try { - const rows = await this.engine.find(object, { - where: { ...where, organization_id: { $null: false } }, - context: { isSystem: true }, - }); - return ((rows ?? []) as any[]).filter((row) => { - const org = (row as { organization_id?: string | null }).organization_id; - return org !== null && org !== undefined && org !== ''; - }); + return await read(); } catch (error) { - // An unprovisioned ledger holds no legacy row; any other - // failure is this diagnostic's own, and stays here. - if (isMissingTableError(error, object)) return []; + if (isMissingTableError(error, object)) return empty; throw error; } }; - const rows = await legacyRows('sys_metadata', { state: { $in: ['active', 'draft'] } }); + // Only the columns the line prints — never a row's body. The + // three ledgers below are counted, never read: this runs on every + // boot, and the populations it names can be large. + const rows = (await orNoTable('sys_metadata', async () => ((await this.engine.find('sys_metadata', { + where: { state: { $in: ['active', 'draft'] }, ...LEGACY }, + fields: ['type', 'name', 'organization_id', 'state'], + context: { isSystem: true }, + })) ?? []) as any[], [] as any[])) + // A driver that cannot lower the predicate hands back a + // superset; the names this line prints are re-checked here. + .filter((row) => { + const org = (row as { organization_id?: string | null }).organization_id; + return org !== null && org !== undefined && org !== ''; + }); const counts = new Map(); const samples = new Map(); for (const row of rows) { @@ -26418,7 +26427,10 @@ export class ObjectStackProtocolImplementation implements } const ledgers: string[] = []; for (const ledger of ['sys_metadata_commit', 'sys_metadata_history', 'sys_metadata_audit']) { - const n = (await legacyRows(ledger, {})).length; + const n = await orNoTable(ledger, () => this.engine.count(ledger, { + where: LEGACY, + context: { isSystem: true }, + } as any), 0); if (n > 0) ledgers.push(`${ledger}×${n}`); } if (rows.length === 0 && ledgers.length === 0) return; From 62f64d223118aac70255b7c48413702d384b271c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:55:37 +0000 Subject: [PATCH 20/21] docs(deployment): removing a merge-at-read row restores the package only for a permission set (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- content/docs/deployment/environment-variables.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 4d44ced98c5..bd22e11b997 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from | `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. | | `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). | | `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. | -| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. An overlay row this hatch wrote before over a managed item is kept at rest but **not served**: every read takes the package's definition, and boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies — re-express the change as a new item under a new name, or delete the row. A stored fork of a packaged permission set is the one exception: it is still served, with its detection reading and Discard Overlay. A row of a type that merges overlays at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed to restore the package's definition. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | +| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. An overlay row this hatch wrote before over a managed item is kept at rest but **not served**: every read takes the package's definition, and boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies — re-express the change as a new item under a new name, or delete the row. A stored fork of a packaged permission set is the one exception: it is still served, with its detection reading and Discard Overlay. Removing that permission-set row restores the package's definition. A stored row over a managed item of the other types that merge overlays at read (`position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed, but removing it changes nothing that is served: no read serves it, so the package's definition is already served. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | --- From b45b42a6c2c703e9156c0282b2754e68df3a90c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:43:29 +0000 Subject: [PATCH 21/21] fix(metadata-protocol): the ledger count carries the declared options type, not an erasure (#15206) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Lgdatg1AaT6mAeCQaQJZgR --- packages/metadata-protocol/src/protocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d3ba654c25d..65aa95bfb0f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -26430,7 +26430,7 @@ export class ObjectStackProtocolImplementation implements const n = await orNoTable(ledger, () => this.engine.count(ledger, { where: LEGACY, context: { isSystem: true }, - } as any), 0); + }), 0); if (n > 0) ledgers.push(`${ledger}×${n}`); } if (rows.length === 0 && ledgers.length === 0) return;