From 986123da29ee144a33b1a3e3438357bcc3e0201c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 06:32:21 +0000 Subject: [PATCH] =?UTF-8?q?fix(metadata-protocol):=20=E5=86=B7=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=B7=B3=E8=BF=87=E7=9A=84=20org=20=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E5=9F=9F=E8=A1=8C=E4=B8=8D=E5=86=8D=E6=97=A0=E5=A3=B0=E6=B6=88?= =?UTF-8?q?=E5=A4=B1=20(#6190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadMetaFromDb` 只水合 `organization_id IS NULL` 的行(ADR-0005 2026-05 修订): 对 `allowOrgOverride: true` 的类型这是设计本身,per-org overlay 由 `getMetaItem` / `getMetaItems` 按需加载;对其余类型,一条 org 作用域的行是平台根本没有 per-org 通道 的行,而此前这个跳过是完全静默的。 实测标本是 `flow`:#6283 / PR #6478 把 `allowOrgOverride` 回滚为 `false` 并在写入侧 证明了 declared=enforced(覆盖代码包交付的 flow → 403 NOT_OVERRIDABLE),但另一层 `allowRuntimeCreate: true` 按设计仍开着,而那正是 #6190 场景真正走的那条路:租户在 Studio 里新建一条 flow,`SysMetadataRepository.put` 对任何类型都写 `organization_id: this.organizationId`,于是该行仍然是 org 作用域的。它在本进程内一直 触发,重启后被这条过滤器丢掉,`kernel:ready` 绑定器读的是不带 org 的 `getMetaItems({ type: 'flow' })`,于是它再也不触发且没有任何日志—— `kernel:bootstrapped` 的 unbound 审计也看不见它(它压根没注册)。 新增 `reportUnhydratableOrgScopedRows()`:冷启动打一条聚合 warn,按类型给出计数、 抽样的 `name@org`、后果与处置建议。类型清单由 `DEFAULT_METADATA_TYPE_REGISTRY` 派生 (不是手写白名单),两个收窄谓词让健康部署读不到行也不打印;驱动无法下推谓词时退化为 多读几行,JS 侧复核两个谓词,不会产生误报。诊断自带 catch,永远不会变成 boot 失败, 也不会污染 #5897 的 storeUnavailable 判定。 加载行为未改变——这次只把缺席变响亮。这类行是否该存在(写入侧拒绝 / 强制 env-wide / 让绑定器按 org 读)是 #6190 上待裁决的契约问题。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA --- .changeset/org-scoped-cold-boot-audit.md | 29 ++ ...rotocol.org-scoped-cold-boot-audit.test.ts | 307 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 124 +++++++ 3 files changed, 460 insertions(+) create mode 100644 .changeset/org-scoped-cold-boot-audit.md create mode 100644 packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts diff --git a/.changeset/org-scoped-cold-boot-audit.md b/.changeset/org-scoped-cold-boot-audit.md new file mode 100644 index 0000000000..337e957132 --- /dev/null +++ b/.changeset/org-scoped-cold-boot-audit.md @@ -0,0 +1,29 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +冷启动跳过的 org 作用域元数据行不再无声消失 + +`loadMetaFromDb` 按 ADR-0005(2026-05 修订)只水合 `organization_id IS NULL` 的行, +per-org overlay 由 `getMetaItem`/`getMetaItems` 按需加载——对注册表里 +`allowOrgOverride: true` 的类型(`view`/`dashboard`/`report` 等)这是设计本身。但对 +**其余类型**,一条 org 作用域的行是平台根本没有 per-org 通道的行,而在此之前这个跳过 +是**完全静默**的。 + +实测标本是 `flow`:它是 `allowOrgOverride: false`(#6283 / PR #6478 按 ADR-0005:57 +回滚),同时 `allowRuntimeCreate: true`,所以租户在 Studio 里新建一条 flow 仍会写出 +`sys_metadata.organization_id = ''`——运行时 `PUT /metadata/:type/:name` 把 +`resolveActiveOrganizationId` 透传给 `saveMetaItem`,而 `SysMetadataRepository.put` +对任何类型都按 `organization_id: this.organizationId` 落库。该 flow 在本进程内一直正常 +触发(发布时写穿进了进程级 registry),下一次重启后被这条过滤器丢掉,`kernel:ready` 的 +绑定器读的是 `getMetaItems({ type: 'flow' })`(不带 org),于是它**再也不触发,且没有任何 +日志说它消失了**——`kernel:bootstrapped` 的 unbound 审计也看不见它(它压根没注册)。 + +现在冷启动会打一条聚合的 `warn`,按类型给出计数、抽样的 `name@org`,以及后果本身 +(「A 'flow' listed here will NOT bind its triggers in this process」)和处置建议。 +查询默认为空:两个收窄谓词(`organization_id IS NOT NULL` + 类型清单,清单由 +`DEFAULT_METADATA_TYPE_REGISTRY` 派生而非手写)让健康部署读不到行、也不打印任何东西; +驱动若无法下推其中一个谓词,退化为多读几行而不是打出误报(JS 侧会复核两个谓词)。 + +加载行为**未改变**:这次只是把缺席变响亮。这类行到底该不该存在(写入侧拒绝 / 强制写成 +env-wide / 让绑定器按 org 读)是 #6190 上待裁决的契约问题。 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 new file mode 100644 index 0000000000..418667eb56 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6190 — cold boot must SAY which org-scoped rows it walked past. + * + * --------------------------------------------------------------------------- + * What survived the upstream ruling, measured against `origin/main` + * --------------------------------------------------------------------------- + * #6155 Q1=B → #6283 → PR #6478 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. + * + * 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`. + */ +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 } 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; +} + +/** + * A stub that honours the two predicates the audit query relies on + * (`organization_id: { $null: false }` and `type: { $in: [...] }`) plus the + * plain equality the boot query uses. `driver-memory`, `driver-sql` and + * `driver-mongodb` all lower `$null`; this mirrors that, and the + * dropped-predicate case gets its own stub below. + */ +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; +} + +function makeEngine(rows: Row[], opts: { dropPredicates?: boolean } = {}) { + const registered: Array<{ type: string; name: string }> = []; + const engine: any = { + async find(_table: string, q: { where: Record }) { + // 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); + return rows.filter((r) => matchesWhere(r, q.where)); + }, + async findOne() { 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: { + 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, + }, + }; + return { engine, registered }; +} + +const flowBody = (name: string) => JSON.stringify({ + name, + label: 'Escalate overdue tasks', + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'task', triggerType: 'record-after-update' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +const viewBody = (name: string) => JSON.stringify({ + name, label: 'Overdue', object: 'task', columns: [{ field: '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: over.type === 'view' ? viewBody(over.name) : flowBody(over.name), + ...over, +}); + +/** One `console.warn` capture, returned as the lines the boot printed. */ +async function bootAndCapture(engine: any): 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) as any; + const result = await protocol.loadMetaFromDb(); + return { result, warns }; + } finally { + spy.mockRestore(); + } +} + +const AUDIT = '[metadata_org_scoped_unhydrated]'; + +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 / PR #6478) 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 () => { + const { engine } = makeEngine([ + row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), + row({ type: 'flow', name: 'platform_sweep' }), + ]); + + 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 }); + + 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('bind its triggers'); + // And it must not name the row that DID load. + expect(line).not.toContain('platform_sweep'); + }); + + it('counts every row but samples the names, so a thousand rows cost one line', async () => { + const rows: Row[] = []; + for (let i = 0; i < 9; i++) { + rows.push(row({ type: 'flow', name: `sweep_${i}`, organization_id: `org_${i}` })); + } + const { engine } = makeEngine(rows); + + const { warns } = await bootAndCapture(engine); + + const audit = warns.filter((w) => w.includes(AUDIT)); + expect(audit).toHaveLength(1); + expect(audit[0]).toContain('flow×9'); + 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. + 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 () => { + const { engine } = makeEngine([ + row({ type: 'flow', name: 'platform_sweep' }), + row({ type: 'view', name: 'platform_grid' }), + ]); + + const { result, warns } = await bootAndCapture(engine); + + expect(result.loaded).toBe(2); + 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. + let call = 0; + const { engine } = makeEngine([row({ type: 'flow', name: 'platform_sweep' })]); + const inner = engine.find; + engine.find = async (t: string, q: any) => { + call++; + if (call > 1) throw new Error('probe exploded'); + return inner(t, q); + }; + + const { result, warns } = await bootAndCapture(engine); + + expect(call).toBeGreaterThan(1); + expect(result).toMatchObject({ loaded: 1, errors: 0, storeUnavailable: false }); + expect(warns.filter((w) => w.includes('DB hydration skipped'))).toEqual([]); + expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6a46349b5b..ad818cbe5f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10955,6 +10955,9 @@ export class ObjectStackProtocolImplementation implements console.warn(`[Protocol] Failed to hydrate ${record.type}/${record.name}: ${e instanceof Error ? e.message : String(e)}`); } } + // #6190 — say out loud which org-scoped rows this filter just + // walked past. See {@link reportUnhydratableOrgScopedRows}. + await this.reportUnhydratableOrgScopedRows(); } catch (e: unknown) { // #5841 — the ONE benign reason this whole read can fail is // `sys_metadata` not being provisioned yet: on a first boot, before @@ -11000,6 +11003,127 @@ export class ObjectStackProtocolImplementation implements return { loaded, errors, invalid, storeUnavailable }; } + /** + * [#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 / PR #6478, 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. + * + * 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. + * - **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. + * - **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. + * - **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 + * (`storeUnavailable`, #5897) and this must not be able to reach it. + */ + private async reportUnhydratableOrgScopedRows(): Promise { + /** Names printed per type before the line collapses to a count. */ + const SAMPLE_PER_TYPE = 5; + try { + const orgOverridable = new Set(); + const scannedTypes: string[] = []; + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + if (entry.allowOrgOverride) { + orgOverridable.add(entry.type); + continue; + } + scannedTypes.push(entry.type); + const plural = SINGULAR_TO_PLURAL[entry.type]; + if (plural) scannedTypes.push(plural); + } + 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 counts = new Map(); + const samples = new Map(); + 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; + 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)}`); + samples.set(singular, names); + } + if (total === 0) return; + + const detail = Array.from(counts.entries()) + .map(([type, count]) => { + const names = samples.get(type) ?? []; + const more = count > names.length ? `, +${count - names.length} 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 the registry declares NOT per-org overridable, 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. ` + + `Re-save the item env-wide (no active organization), or delete the row. See #6190 / ADR-0005.`, + ); + } catch { + // Diagnostics never break boot — see the TSDoc. Deliberately not + // routed to the caller's outer catch: that one classifies real + // hydration outages, and a failed extra probe is not one. + } + } + // ========================================== // Metadata References (Phase 3a-references) // ==========================================