diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 9bd00f5637..f87de35fb2 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -58,6 +58,22 @@ can proceed without provider promotion. Section 1.4 defines their boundary; the [TS execution cards](typescript-control-plane-migration-v0.md#execution-cards-after-the-current-stack) still own business-rule consolidation and legacy-caller deletion. +### Local provider opening boundary (2026-09-13) + +The local runtime now resolves File, SQLite, and the medium-term PostgreSQL +profile through one typed provider handle. No selector means the explicit File +default; a SQLite selector remains an opt-in local profile; a PostgreSQL +selector is accepted only with a service-owned factory that supplies the +provider-neutral `AuthorityStore`. The selector contains no credentials or +database client and binds the selected store identity before any command runs. + +This boundary removes per-command provider construction and corrects the +observable source label for injected PostgreSQL stores. A selected-provider +failure preserves its source and fails closed; it cannot fall back to File or +Markdown. The refactor prepares the File/SQLite default path and a switchable +PostgreSQL deployment without changing promotion, D2 soak/retention, or D3 +whole-Goal cutover holds. + ## Document map and maintenance contract This RFC separates durable decisions from delivery evidence: diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 7dfb761310..4a55cf8251 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -49,6 +49,20 @@ head 之外。 第 1.4 节明确边界;[TS 执行卡](typescript-control-plane-migration-v0.zh-CN.md) 继续负责业务规则收敛及旧 caller 删除。 +### Local provider opening 边界(2026-09-13) + +Local runtime 现在通过一个 typed provider handle 解析 File、SQLite 与中期 +PostgreSQL profile。没有 selector 时使用明确的 File 默认值;SQLite selector 仍是 +opt-in local profile;PostgreSQL selector 只有在 service-owned factory 提供 +provider-neutral `AuthorityStore` 时才接受。Selector 不包含凭据或 database client, +并在任何 command 执行前绑定所选 store identity。 + +这个边界删除了每个 command 各自构造 provider 的重复,并修正了注入 PostgreSQL +store 时对外 source label 的错误标记。已选择 provider 的失败保留 source 并 fail +closed,不能回退到 File 或 Markdown。本次重构为 File/SQLite 默认路径与可切换 +PostgreSQL deployment 做准备,不改变 promotion、D2 soak/retention 或 D3 整 Goal +cutover hold。 + ## 文档地图与维护约定 本文将稳定决策与交付证据分开维护: diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 795df45452..f23304c336 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -103,6 +103,23 @@ native creation, archival, receipt replay, and store reopen are tested without Markdown metadata. Python only adapts the typed read result to the compatibility summary. This is a contract checkpoint, not a completed CLI lifecycle cutover. +### Local provider opening boundary (2026-09-13) + +The provider-first runtime now has one typed local opening seam. An absent +selector resolves explicitly to the File profile (`source_authority=file_v0`); +the same handle reports SQLite when the qualified local selector is present and +can report PostgreSQL only through a service-owned factory. Runtime commands no +longer repeat provider construction or infer a PostgreSQL store as File from an +`AuthorityStore` implementation. + +The selector carries only provider, goal, tenant, and store-incarnation facts. +It never carries credentials or a database client. Selected-provider failures +retain their provider source and fail closed; they do not silently fall back to +File or Markdown. This is the default provider boundary and TypeScript +ownership refactor, not a SQLite promotion, a whole-Goal cutover, or a +PostgreSQL service claim. Existing promotion, soak, retention, and writer-fence +holds remain unchanged. + Provider-first `todo update --text/--note` preserves claim-neutral correction: a registered, non-excluded actor may edit an unclaimed active, non-completed agent Todo, subject to its agent binding. It must not introduce `claimed_by`. diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index cbe2a8d7e7..f1e9890db2 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -83,6 +83,20 @@ coordination 路径使用同一份语言中立的 `coordination_state_contract_v 仅将 typed read result 适配为兼容 summary。这是 contract 检查点,不是已经完成的 CLI lifecycle cutover。 +### Local provider opening 边界(2026-09-13) + +Provider-first runtime 现在只有一个 typed local opening seam。没有 selector 时 +明确解析为 File profile(`source_authority=file_v0`);存在经过资格验证的 local +selector 时,同一个 handle 报告 SQLite;只有通过 service-owned factory 才能报告 +PostgreSQL。runtime command 不再重复构造 provider,也不会因为某个 +`AuthorityStore` 实现而把 PostgreSQL 误报成 File。 + +Selector 只携带 provider、goal、tenant 和 store-incarnation facts,不携带凭据或 +database client。已选择 provider 的失败保留其 provider source 并 fail closed,绝不 +静默回退到 File 或 Markdown。这是默认 provider 边界与 TypeScript ownership 的重构, +不是 SQLite promotion、整 Goal cutover 或 PostgreSQL service 已交付的声明。现有 +promotion、soak、retention 与 writer-fence hold 均保持不变。 + Provider-first `todo update --text/--note` 保留不改变认领关系的文案修正: 已注册、未被排除且符合 agent binding 的 actor,可以编辑未认领、active 且未完成的 agent Todo,不得因此写入 `claimed_by`;其他 claim owner 的 Todo 仍拒绝修改。 diff --git a/docs/reference/local-authority-provider-selection.md b/docs/reference/local-authority-provider-selection.md new file mode 100644 index 0000000000..fcfd26f1f4 --- /dev/null +++ b/docs/reference/local-authority-provider-selection.md @@ -0,0 +1,58 @@ +# Local authority provider selection + +LoopX now has one typed local-provider boundary for every provider-first +coordination command. When a goal has no selector, the boundary resolves the +`file` profile (`source_authority=file_v0`). This makes File/SQLite provider +semantics the default local contract without silently promoting an existing +Markdown goal or changing its writer fence. + +## Selection contract + +`openLocalAuthorityStoreHandle(runtime_root, goal_id)` resolves a handle with: + +| Field | Meaning | +| --- | --- | +| `store` | The provider-neutral `AuthorityStore` implementation | +| `provider` | `file`, `sqlite`, or `postgresql` | +| `sourceAuthority` | The provider evidence label (`*_v0`) | + +An absent selector is the explicit default File profile. A SQLite selector uses +the existing `loopx_local_authority_provider_v0` marker and its database +incarnation. A PostgreSQL selector uses the same marker schema plus a +`tenant_id` and `postgresql:<32 lowercase hex>` store identity. + +The PostgreSQL marker contains no URL, credential, or database client. Opening +it requires a service-owned `openPostgresqlStore` factory. The factory receives +only the validated public binding facts and must return a PostgreSQL-labelled +`AuthorityStore` whose identity matches the selector. This is the runtime seam +for the medium-term switchable PostgreSQL profile; it does not ship an +authenticated service or grant an Agent database access. + +## Failure and compatibility rules + +- A selected provider never falls back to File when its selector, database, + factory, identity, or metadata is unavailable. +- `source_authority` identifies the selected provider even when opening it + fails; unresolved or malformed selection reports `null`. +- `decision_read_from_provider` is false for selection/open failures, and + `legacy_fallback_used` remains false. +- The legacy `openLocalAuthorityStore` function still returns only the store, + so existing callers remain source-compatible. Runtime entrypoints use one + shared opening seam and no longer duplicate provider construction. +- Provider identity is observability metadata. It does not decide Todo + eligibility, claims, leases, receipts, or promotion. + +The default profile is a routing decision, not a migration. Existing Markdown +state, writer fences, qualification gates, and explicit File/SQLite promotion +holds remain unchanged. SQLite stays an opt-in qualified candidate until the +shared-authority RFC's D2 evidence and owner approval are complete. PostgreSQL +remains an independent service-provider qualification path. + +## Validation + +The provider selection matrix is exercised with the production-scale synthetic +coordination fixture. Tests cover the default File handle, SQLite persistence, +selected-provider failure without fallback, PostgreSQL factory identity +fencing, and the factory's rejection of a different provider. File, SQLite, +and PostgreSQL continue to share the provider-neutral transaction conformance +contract; PostgreSQL's real-server qualification remains a separate gate. diff --git a/docs/reference/local-authority-provider-selection.zh-CN.md b/docs/reference/local-authority-provider-selection.zh-CN.md new file mode 100644 index 0000000000..0ae9bac67f --- /dev/null +++ b/docs/reference/local-authority-provider-selection.zh-CN.md @@ -0,0 +1,54 @@ +# Local Authority Provider 选择 + +LoopX 现在为所有 provider-first coordination command 提供一个 typed 的 +local-provider 边界。Goal 没有 selector 时,边界解析为 `file` profile +(`source_authority=file_v0`)。这使 File/SQLite provider 语义成为默认的 +local contract,同时不会静默晋升已有的 Markdown Goal,也不会改变其 writer +fence。 + +## 选择 contract + +`openLocalAuthorityStoreHandle(runtime_root, goal_id)` 返回一个 handle: + +| 字段 | 含义 | +| --- | --- | +| `store` | 与 provider 无关的 `AuthorityStore` 实现 | +| `provider` | `file`、`sqlite` 或 `postgresql` | +| `sourceAuthority` | provider 证据标签(`*_v0`) | + +没有 selector 时使用显式的默认 File profile。SQLite selector 继续使用已有 +的 `loopx_local_authority_provider_v0` marker 及其 database incarnation。 +PostgreSQL selector 使用同一 marker schema,并额外绑定 `tenant_id` 与 +`postgresql:<32 位小写十六进制>` store identity。 + +PostgreSQL marker 不包含 URL、凭据或 database client。打开它必须提供由 +service 持有的 `openPostgresqlStore` factory。factory 只接收经过校验的公开 +binding facts,并且必须返回带 PostgreSQL 标签、且 identity 与 selector 一致 +的 `AuthorityStore`。这是中期可切换 PostgreSQL profile 的 runtime seam;它 +不包含 authenticated service,也不会向 Agent 授予数据库访问权。 + +## 失败与兼容规则 + +- 已选择的 provider 在 selector、数据库、factory、identity 或 metadata 不可用 + 时,绝不回退到 File。 +- `source_authority` 即使在打开失败时也标识被选择的 provider;选择未解析或 + 格式错误时返回 `null`。 +- 选择/打开失败时 `decision_read_from_provider` 为 false, + `legacy_fallback_used` 始终为 false。 +- 旧的 `openLocalAuthorityStore` 函数仍只返回 store,已有 caller 保持源码兼容。 + runtime entrypoint 统一使用一个 opening seam,不再重复构造 provider。 +- provider identity 只是可观测 metadata,不负责决定 Todo eligibility、claim、 + lease、receipt 或 promotion。 + +默认 profile 是路由决策,不是迁移。已有 Markdown state、writer fence、 +qualification gate 以及 File/SQLite 显式 promotion hold 均保持不变。在 shared- +authority RFC 的 D2 证据和 owner approval 完成前,SQLite 仍是 opt-in 的 +qualified candidate;PostgreSQL 仍是独立的 service-provider qualification 路径。 + +## 验证 + +provider selection matrix 使用 production-scale synthetic coordination fixture +验证。测试覆盖默认 File handle、SQLite 持久化、selected provider 失败时不回退、 +PostgreSQL factory identity fencing,以及 factory 返回其他 provider 时的拒绝。 +File、SQLite 和 PostgreSQL 继续共享 provider-neutral transaction conformance +contract;PostgreSQL 的真实服务器 qualification 仍是独立 gate。 diff --git a/loopx/control_plane/coordination/authority_store.ts b/loopx/control_plane/coordination/authority_store.ts index c357495be3..46ca8cf2a6 100644 --- a/loopx/control_plane/coordination/authority_store.ts +++ b/loopx/control_plane/coordination/authority_store.ts @@ -19,6 +19,7 @@ export type AuthorityStoreRequiredGuarantee = (typeof AUTHORITY_STORE_REQUIRED_GUARANTEES)[number]; export type AuthorityStoreProviderKind = "file" | "nokv" | "postgresql" | "sqlite"; +export type AuthorityStoreSourceAuthority = `${AuthorityStoreProviderKind}_v0`; export type AuthorityStoreProviderStage = | "stage1_implemented" | "stage2a_candidate" @@ -166,9 +167,21 @@ export type AuthorityStoreScanResult = /** Storage-only seam. Legal transitions and receipt meaning stay in LoopX. */ export interface AuthorityStore { + /** + * Provider identity is observability metadata, not a semantic authority. + * Optional keeps third-party/test stores source-compatible while built-in + * providers expose an unambiguous runtime label. + */ + readonly providerKind?: AuthorityStoreProviderKind; storeIdentity(): Promise; loadAuthority(): Promise; commitAuthority(commit: AuthorityStoreCommit): Promise; readReceipt(operationId: string): Promise; scanCommitted(afterCursor: string | null, limit: number): Promise; } + +/** Map a storage implementation to the public source label used by adapters. */ +export function authorityStoreSourceAuthority(store: AuthorityStore): AuthorityStoreSourceAuthority { + const kind = store.providerKind ?? "file"; + return `${kind}_v0`; +} diff --git a/loopx/control_plane/coordination/file_authority_store.ts b/loopx/control_plane/coordination/file_authority_store.ts index 2ef5818dfb..3a19aa4e23 100644 --- a/loopx/control_plane/coordination/file_authority_store.ts +++ b/loopx/control_plane/coordination/file_authority_store.ts @@ -139,6 +139,7 @@ function readFailure(error: unknown): AuthorityStoreReadFailure { /** File-backed Stage 1 conformance provider; LoopX owns all domain decisions. */ export class FileAuthorityStore implements AuthorityStore { + readonly providerKind = "file" as const; readonly goalId: string; readonly directory: string; readonly path: string; diff --git a/loopx/control_plane/coordination/local_authority_provider.ts b/loopx/control_plane/coordination/local_authority_provider.ts index 982a97ad1c..e6eda7f733 100644 --- a/loopx/control_plane/coordination/local_authority_provider.ts +++ b/loopx/control_plane/coordination/local_authority_provider.ts @@ -5,23 +5,59 @@ import { isAbsolute, join } from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import { durableWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; -import type { AuthorityStore } from "./authority_store.ts"; -import { isAuthorityJsonObject, requireAuthorityStoreId } from "./authority_store_codec.ts"; +import { + authorityStoreSourceAuthority, + type AuthorityStore, + type AuthorityStoreProviderKind, + type AuthorityStoreSourceAuthority, +} from "./authority_store.ts"; +import { hasExactAuthorityKeys, isAuthorityJsonObject, requireAuthorityStoreId } from "./authority_store_codec.ts"; import { FileAuthorityStore } from "./file_authority_store.ts"; import { SqliteAuthorityStore, sqliteAuthorityPath } from "./sqlite_authority_store.ts"; import { loadLegacyCoordinationWriterFence } from "./legacy_writer_fence.ts"; import { shadowMaintenanceLockPath } from "./shadow_management.ts"; const SCHEMA = "loopx_local_authority_provider_v0"; -type LocalAuthoritySource = "file_v0" | "sqlite_v0"; +export const DEFAULT_LOCAL_AUTHORITY_PROVIDER = "file" as const satisfies AuthorityStoreProviderKind; +export type LocalAuthorityProviderKind = "file" | "sqlite" | "postgresql"; +export type LocalAuthoritySource = Extract; type ProviderOpenReason = | "local_authority_selector_unavailable" | "local_authority_selector_invalid" | "local_authority_selector_missing" + | "local_authority_provider_unavailable" | "local_authority_provider_missing" | "local_authority_provider_open_failed" | "local_authority_provider_identity_mismatch"; +/** + * The selector contains only public binding facts. Credentials and database + * clients stay in the service-owned factory supplied by the caller. + */ +export interface LocalPostgreSqlAuthoritySelection { + schema_version: typeof SCHEMA; + provider: "postgresql"; + goal_id: string; + tenant_id: string; + store_identity: string; +} + +export type LocalPostgreSqlAuthorityFactory = ( + selection: LocalPostgreSqlAuthoritySelection, +) => Promise | AuthorityStore; + +export interface LocalAuthorityProviderDependencies { + /** Service-owned hook for the medium-term PostgreSQL profile. */ + openPostgresqlStore?: LocalPostgreSqlAuthorityFactory; +} + +export interface LocalAuthorityStoreHandle { + store: AuthorityStore; + provider: LocalAuthorityProviderKind; + sourceAuthority: LocalAuthoritySource; +} + /** Null source means selection could not be validated, never a file fallback. */ export class LocalAuthorityProviderOpenError extends Error { readonly sourceAuthority: LocalAuthoritySource | null; @@ -51,30 +87,33 @@ function paths(root: string, goalId: string) { sqlite: join(root, "authority", "sqlite-v0"), file: join(root, "authority", "file-v0")}; } -export async function openLocalAuthorityStore(root: string, goalId: string): Promise { - const p = paths(root, goalId); - let raw: string; - try { raw = await readFile(p.marker, "utf8"); } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_unavailable", "Local authority provider selector could not be read"); - } - // Lost selector must never silently redirect an initialized SQLite goal. - try { await stat(sqliteAuthorityPath(p.sqlite, goalId)); } - catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return new FileAuthorityStore(p.file, goalId); - throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_unavailable", "Local authority selection could not be resolved"); - } - throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_missing", "SQLite authority exists but its provider selector is missing"); - } - let config: unknown; - try { config = JSON.parse(raw); } - catch { throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_invalid", "Invalid local authority provider selector JSON"); } - if (!isAuthorityJsonObject(config) || - config.schema_version !== SCHEMA || config.goal_id !== goalId || config.provider !== "sqlite" || - typeof config.store_identity !== "string" || !/^sqlite:[0-9a-f]{32}$/.test(config.store_identity)) { - throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_invalid", "Invalid local authority provider selector"); +function sourceFor(provider: LocalAuthorityProviderKind): LocalAuthoritySource { + return `${provider}_v0` as LocalAuthoritySource; +} + +function selectorError(reason: string): LocalAuthorityProviderOpenError { + return new LocalAuthorityProviderOpenError(null, "local_authority_selector_invalid", reason); +} + +function decodePostgreSqlSelection(value: Record, goalId: string): LocalPostgreSqlAuthoritySelection { + if (!hasExactAuthorityKeys(value, ["schema_version", "provider", "goal_id", "tenant_id", "store_identity"]) || + value.provider !== "postgresql" || typeof value.tenant_id !== "string" || + value.tenant_id.trim() !== value.tenant_id || value.tenant_id.length === 0 || + typeof value.store_identity !== "string" || + !/^postgresql:[0-9a-f]{32}$/.test(value.store_identity)) { + throw selectorError("Invalid PostgreSQL authority provider selector"); } + return { + schema_version: SCHEMA, + provider: "postgresql", + goal_id: goalId, + tenant_id: requireAuthorityStoreId(value.tenant_id, "PostgreSQL tenant id"), + store_identity: value.store_identity, + }; +} + +async function openSelectedSqlite(root: string, goalId: string, storeIdentity: string): Promise { + const p = paths(root, goalId); try { // Validate metadata before comparing selector lineage so drift has its own recovery signal. const store = new SqliteAuthorityStore(p.sqlite, goalId, {existingOnly: true}); @@ -90,17 +129,117 @@ export async function openLocalAuthorityStore(root: string, goalId: string): Pro if (identity.status !== "available") { throw new LocalAuthorityProviderOpenError("sqlite_v0", "local_authority_provider_open_failed", identity.reason, identity.reason_code); } - if (identity.store_identity !== config.store_identity) { + if (identity.store_identity !== storeIdentity) { throw new LocalAuthorityProviderOpenError("sqlite_v0", "local_authority_provider_identity_mismatch", "Selected SQLite authority identity changed"); } // Keep every subsequent operation fenced to the selected lineage. - return new SqliteAuthorityStore(p.sqlite, goalId, {existingOnly: true, expectedIdentity: config.store_identity}); + return new SqliteAuthorityStore(p.sqlite, goalId, {existingOnly: true, expectedIdentity: storeIdentity}); } catch (error) { if (error instanceof LocalAuthorityProviderOpenError) throw error; throw new LocalAuthorityProviderOpenError("sqlite_v0", "local_authority_provider_open_failed", "Selected SQLite authority could not be opened"); } } +async function openSelectedPostgreSql( + selection: LocalPostgreSqlAuthoritySelection, + dependencies: LocalAuthorityProviderDependencies, +): Promise { + if (dependencies.openPostgresqlStore === undefined) { + throw new LocalAuthorityProviderOpenError( + "postgresql_v0", + "local_authority_provider_unavailable", + "PostgreSQL authority requires a service-owned store factory", + ); + } + let store: AuthorityStore; + try { + store = await dependencies.openPostgresqlStore(selection); + } catch (error) { + throw new LocalAuthorityProviderOpenError( + "postgresql_v0", + "local_authority_provider_open_failed", + "Selected PostgreSQL authority could not be opened", + error instanceof LocalAuthorityProviderOpenError ? error.reasonCode : undefined, + ); + } + if (store === null || typeof store !== "object" || authorityStoreSourceAuthority(store) !== "postgresql_v0") { + throw new LocalAuthorityProviderOpenError( + "postgresql_v0", + "local_authority_provider_identity_mismatch", + "PostgreSQL provider factory returned a different provider", + ); + } + let identity; + try { + identity = await store.storeIdentity(); + } catch (error) { + throw new LocalAuthorityProviderOpenError( + "postgresql_v0", + "local_authority_provider_open_failed", + "Selected PostgreSQL authority identity could not be read", + error instanceof LocalAuthorityProviderOpenError ? error.reasonCode : undefined, + ); + } + if (identity.status !== "available") { + throw new LocalAuthorityProviderOpenError("postgresql_v0", "local_authority_provider_open_failed", identity.reason, identity.reason_code); + } + if (identity.store_identity !== selection.store_identity) { + throw new LocalAuthorityProviderOpenError("postgresql_v0", "local_authority_provider_identity_mismatch", "Selected PostgreSQL authority identity changed"); + } + return store; +} + +export async function openLocalAuthorityStoreHandle( + root: string, + goalId: string, + dependencies: LocalAuthorityProviderDependencies = {}, +): Promise { + const p = paths(root, goalId); + let raw: string; + try { raw = await readFile(p.marker, "utf8"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_unavailable", "Local authority provider selector could not be read"); + } + // Lost selector must never silently redirect an initialized SQLite goal. + try { await stat(sqliteAuthorityPath(p.sqlite, goalId)); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {store: new FileAuthorityStore(p.file, goalId), provider: DEFAULT_LOCAL_AUTHORITY_PROVIDER, + sourceAuthority: sourceFor(DEFAULT_LOCAL_AUTHORITY_PROVIDER)}; + } + throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_unavailable", "Local authority selection could not be resolved"); + } + throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_missing", "SQLite authority exists but its provider selector is missing"); + } + let config: unknown; + try { config = JSON.parse(raw); } + catch { throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_invalid", "Invalid local authority provider selector JSON"); } + if (!isAuthorityJsonObject(config) || config.schema_version !== SCHEMA || config.goal_id !== goalId || + (config.provider !== "sqlite" && config.provider !== "postgresql")) { + throw selectorError("Invalid local authority provider selector"); + } + if (config.provider === "sqlite") { + if (!hasExactAuthorityKeys(config, ["schema_version", "provider", "goal_id", "store_identity"]) || + typeof config.store_identity !== "string" || !/^sqlite:[0-9a-f]{32}$/.test(config.store_identity)) { + throw selectorError("Invalid SQLite authority provider selector"); + } + const store = await openSelectedSqlite(root, goalId, config.store_identity); + return {store, provider: "sqlite", sourceAuthority: sourceFor("sqlite")}; + } + const selection = decodePostgreSqlSelection(config, goalId); + const store = await openSelectedPostgreSql(selection, dependencies); + return {store, provider: "postgresql", sourceAuthority: sourceFor("postgresql")}; +} + +export async function openLocalAuthorityStore( + root: string, + goalId: string, + dependencies: LocalAuthorityProviderDependencies = {}, +): Promise { + return (await openLocalAuthorityStoreHandle(root, goalId, dependencies)).store; +} + /** Administrative opt-in for an empty, unpromoted goal; no implicit migration. */ export async function selectLocalSqliteAuthority(root: string, goalId: string, execute: boolean) { const p = paths(root, goalId); diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index bc16684945..83095531e5 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -27,7 +27,7 @@ import { validateCoordinationTodoReadModel, type CoordinationProjectionMutation, } from "./coordination_projection.ts"; -import type { AuthorityStore, AuthorityStoreReceiptResult } from "./authority_store.ts"; +import { authorityStoreSourceAuthority, type AuthorityStore, type AuthorityStoreReceiptResult } from "./authority_store.ts"; import { canonicalAuthorityBytes, canonicalAuthorityObject, @@ -35,8 +35,11 @@ import { requireAuthorityStoreId, } from "./authority_store_codec.ts"; import { FileAuthorityStore } from "./file_authority_store.ts"; -import { SqliteAuthorityStore } from "./sqlite_authority_store.ts"; -import { openLocalAuthorityStore, localAuthorityOpenFailure } from "./local_authority_provider.ts"; +import { + openLocalAuthorityStore, + localAuthorityOpenFailure, + type LocalAuthorityProviderDependencies, +} from "./local_authority_provider.ts"; import { decodeLegacyCoordinationWriterFence, LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, @@ -99,8 +102,8 @@ export { } from "./coordination_state_contract.generated.ts"; export { LEGACY_COORDINATION_WRITER_FENCE_SCHEMA } from "./legacy_writer_fence.ts"; -export function sourceAuthorityFor(store: AuthorityStore): "sqlite_v0" | "file_v0" { - return store instanceof SqliteAuthorityStore ? "sqlite_v0" : "file_v0"; +export function sourceAuthorityFor(store: AuthorityStore) { + return authorityStoreSourceAuthority(store); } export async function withCanonicalWriter(root: string, goalId: string, dryRun: boolean, write: () => Promise): Promise { @@ -123,8 +126,7 @@ export async function pollLocalCoordinationMonitor(value: unknown, if (!Array.isArray(input.registered_agents)) throw new TypeError("registered_agents must be an array"); const registered = input.registered_agents.map(agent => claimAgentValue(agent, "registered agent")); return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); evidence.source_authority = sourceAuthorityFor(store); return {...await executeCoordinationMonitorPoll(store, { goal_id: goalId, operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), @@ -142,12 +144,24 @@ export async function pollLocalCoordinationMonitor(value: unknown, } } -interface LocalAuthorityRuntimeDependencies { +interface LocalAuthorityRuntimeDependencies extends LocalAuthorityProviderDependencies { createStore?: (directory: string, goalId: string) => AuthorityStore; createShadowStore?: (directory: string, goalId: string) => AuthorityStore; createCanonicalStore?: (directory: string, goalId: string) => AuthorityStore; } +/** One runtime seam owns provider construction for every local command. */ +async function openRuntimeStore( + root: string, + goalId: string, + dependencies: LocalAuthorityRuntimeDependencies, +): Promise { + if (dependencies.createStore !== undefined) { + return dependencies.createStore(authorityDirectory(root), goalId); + } + return await openLocalAuthorityStore(root, goalId, dependencies); +} + export function runtimeRoot(value: unknown): string { if (typeof value !== "string" || value.trim() !== value || !isAbsolute(value)) { throw new Error("runtime_root must be an absolute path"); @@ -400,10 +414,7 @@ export async function promoteLocalCoordinationAuthority( const canonical = dependencies.createCanonicalStore?.( authorityDirectory(request.runtime_root), request.goal_id, - ) ?? dependencies.createStore?.( - authorityDirectory(request.runtime_root), - request.goal_id, - ) ?? await openLocalAuthorityStore(request.runtime_root, request.goal_id); + ) ?? await openRuntimeStore(request.runtime_root, request.goal_id, dependencies); const canonicalAuthority = sourceAuthorityFor(canonical); const persistedFence = await loadLegacyCoordinationWriterFence( request.runtime_root, @@ -617,8 +628,7 @@ export async function mutateLocalCoordinationAuthority( const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); return await withCanonicalWriter(root, goalId, false, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); const result = await commitCoordinationProjectionMutation(store, { goal_id: goalId, @@ -668,8 +678,7 @@ export async function claimLocalCoordinationTodo( const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); if (!Array.isArray(input.registered_agents)) { throw new Error("registered_agents must be a JSON array"); @@ -754,8 +763,7 @@ export async function createLocalCoordinationTodo( const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; if (!Array.isArray(input.registered_agents)) { @@ -817,8 +825,7 @@ export async function updateLocalCoordinationTodo( if (!Array.isArray(input.registered_agents) || !Array.isArray(input.clear_fields)) { throw new TypeError("registered_agents and clear_fields must be JSON arrays"); } - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; return {...await executeCoordinationTodoUpdate(store, { @@ -886,8 +893,7 @@ export async function terminalLifecycleLocalCoordinationTodo( const successorIntents = input.successor_intents.map((intent, index) => requireJsonObject(intent, `successor_intents[${index}]`)); return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; return {...await executeCoordinationTodoTerminalLifecycle(store, { @@ -982,8 +988,7 @@ export async function archiveLocalCoordinationTodos( if (typeof input.dry_run !== "boolean") throw new TypeError("dry_run must be a boolean"); const now = claimObservedAt(input.observed_at); return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; return {...await executeLocalArchiveAttempt(store, root, { @@ -1023,8 +1028,7 @@ export async function acknowledgeLocalCoordinationTodoArchive( const role = archiveRole(input.role); const operationId = requireAuthorityStoreId(input.operation_id, "operation id"); return await withCanonicalWriter(root, goalId, false, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); return acknowledgeLocalArchiveAttempt(store, root, goalId, role, operationId); }); } catch (error) { @@ -1051,8 +1055,7 @@ export async function editLocalCoordinationTodo( const root = runtimeRoot(runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); return {...await editCoordinationTodo(store, request), source_authority: sourceAuthority, decision_read_from_provider: true, legacy_fallback_used: false}; @@ -1080,8 +1083,7 @@ export async function readLocalCoordinationTodo( const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); const todoId = requireAuthorityStoreId(input.todo_id, "todo id"); - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); const head = await store.loadAuthority(); if (head.status !== "loaded") { @@ -1138,8 +1140,7 @@ export async function listLocalCoordinationTodos( } const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); const head = await store.loadAuthority(); if (head.status !== "loaded") { @@ -1186,14 +1187,17 @@ export async function listLocalCoordinationTodos( } /** The explicit local CLI continuation uses the existing promoted writer fence. */ -export async function continueLocalTodo(value: unknown): Promise { +export async function continueLocalTodo( + value: unknown, + dependencies: LocalAuthorityRuntimeDependencies = {}, +): Promise { const evidence = {source_authority: "file_v0", decision_read_from_provider: true, legacy_fallback_used: false}; try { const input = requireJsonObject(value, "Todo continuation request"); const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); return await withCanonicalWriter(root, goalId, false, async () => { - const store = await openLocalAuthorityStore(root, goalId); + const store = await openRuntimeStore(root, goalId, dependencies); evidence.source_authority = sourceAuthorityFor(store); const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status !== "loaded") return {ok: false, status: "rejected", diff --git a/loopx/control_plane/coordination/nokv_authority_store.ts b/loopx/control_plane/coordination/nokv_authority_store.ts index 365af80e20..f25084167a 100644 --- a/loopx/control_plane/coordination/nokv_authority_store.ts +++ b/loopx/control_plane/coordination/nokv_authority_store.ts @@ -193,6 +193,7 @@ function validStoreIdentity(value: string, workbench: string): boolean { /** Stage 2A candidate. No runtime constructs this provider by default. */ export class NoKVAuthorityStore implements AuthorityStore { + readonly providerKind = "nokv" as const; readonly transport: NoKVBlobTransport; readonly tenantId: string; readonly goalId: string; diff --git a/loopx/control_plane/coordination/postgresql_authority_store.ts b/loopx/control_plane/coordination/postgresql_authority_store.ts index fba9c1cfc9..a14722a258 100644 --- a/loopx/control_plane/coordination/postgresql_authority_store.ts +++ b/loopx/control_plane/coordination/postgresql_authority_store.ts @@ -412,6 +412,7 @@ export async function installPostgreSqlAuthorityStoreSchema( /** PostgreSQL Stage 2B store; domain decisions remain in LoopX authority. */ export class PostgreSqlAuthorityStore implements AuthorityStore { + readonly providerKind = "postgresql" as const; readonly database: PostgreSqlAuthorityDatabase; readonly tenantId: string; readonly goalId: string; diff --git a/loopx/control_plane/coordination/sqlite_authority_store.ts b/loopx/control_plane/coordination/sqlite_authority_store.ts index be3d7169bc..c018048165 100644 --- a/loopx/control_plane/coordination/sqlite_authority_store.ts +++ b/loopx/control_plane/coordination/sqlite_authority_store.ts @@ -54,6 +54,7 @@ function readFailure(error: unknown): AuthorityStoreReadFailure { /** One local database per goal. No network filesystem or cross-host authority. */ export class SqliteAuthorityStore implements AuthorityStore { + readonly providerKind = "sqlite" as const; readonly path: string; readonly goalId: string; readonly existingOnly: boolean; diff --git a/tests/control_plane_ts/local_authority_provider.test.ts b/tests/control_plane_ts/local_authority_provider.test.ts index 6d692bebcd..112f245f93 100644 --- a/tests/control_plane_ts/local_authority_provider.test.ts +++ b/tests/control_plane_ts/local_authority_provider.test.ts @@ -3,10 +3,15 @@ import { mkdir, mkdtemp, readdir, readFile, rm, rename, writeFile } from "node:f import { join } from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; -import { openLocalAuthorityStore, selectLocalSqliteAuthority } from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import { + openLocalAuthorityStore, + openLocalAuthorityStoreHandle, + selectLocalSqliteAuthority, +} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; import { SqliteAuthorityStore } from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; import { createRequire } from "node:module"; +import { createHash } from "node:crypto"; import { authorityStoreCommitFixture } from "./authority_store_conformance.ts"; import * as runtime from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; import { qualifiedShadow, promotionRequest, engageFence } from "./local_promotion_fixture.ts"; @@ -144,6 +149,94 @@ test("SQLite opt-in is persistent and default-off with a read-only preview", asy assert.ok(await openLocalAuthorityStore(directory, "another-goal") instanceof FileAuthorityStore); }); +test("the default local handle is explicit and carries provider metadata", async t => { + const directory = await root(t); + const handle = await openLocalAuthorityStoreHandle(directory, "goal"); + assert.equal(handle.provider, "file"); + assert.equal(handle.sourceAuthority, "file_v0"); + assert.ok(handle.store instanceof FileAuthorityStore); +}); + +test("a PostgreSQL selector is a service-owned, identity-fenced switch", async t => { + const directory = await root(t); + const storeIdentity = `postgresql:${"a".repeat(32)}`; + const marker = join(directory, "authority", `provider-${createHash("sha256").update("goal").digest("hex")}.json`); + await mkdir(join(directory, "authority"), {recursive: true}); + await writeFile(marker, JSON.stringify({ + schema_version: "loopx_local_authority_provider_v0", + provider: "postgresql", + goal_id: "goal", + tenant_id: "tenant-a", + store_identity: storeIdentity, + })); + const store = { + providerKind: "postgresql" as const, + storeIdentity: async () => ({status: "available" as const, store_identity: storeIdentity}), + loadAuthority: async () => ({status: "missing" as const}), + commitAuthority: async () => ({status: "failed" as const, reason_code: "unused", reason: "unused"}), + readReceipt: async () => ({status: "missing" as const}), + scanCommitted: async () => ({status: "page" as const, transactions: [], next_cursor: null, has_more: false}), + }; + const handle = await openLocalAuthorityStoreHandle(directory, "goal", { + openPostgresqlStore: selection => { + assert.equal(selection.tenant_id, "tenant-a"); + assert.equal(selection.store_identity, storeIdentity); + return store; + }, + }); + assert.equal(handle.provider, "postgresql"); + assert.equal(handle.sourceAuthority, "postgresql_v0"); + assert.equal(handle.store, store); + const listed = await runtime.listLocalCoordinationTodos({ + schema_version: runtime.LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, + runtime_root: directory, + goal_id: "goal", + }, {openPostgresqlStore: () => store}); + assert.equal(listed.status, "missing"); + assert.equal(listed.source_authority, "postgresql_v0"); + assert.equal(listed.decision_read_from_provider, true); + await assert.rejects( + openLocalAuthorityStore(directory, "goal"), + {reasonCode: "local_authority_provider_unavailable", sourceAuthority: "postgresql_v0"}, + ); +}); + +test("a PostgreSQL selector cannot accept a factory for another provider", async t => { + const directory = await root(t); + const marker = join(directory, "authority", `provider-${createHash("sha256").update("goal").digest("hex")}.json`); + await mkdir(join(directory, "authority"), {recursive: true}); + await writeFile(marker, JSON.stringify({ + schema_version: "loopx_local_authority_provider_v0", + provider: "postgresql", + goal_id: "goal", + tenant_id: "tenant-a", + store_identity: `postgresql:${"a".repeat(32)}`, + })); + const fileStore = new FileAuthorityStore(join(directory, "wrong"), "goal"); + await assert.rejects( + openLocalAuthorityStoreHandle(directory, "goal", {openPostgresqlStore: () => fileStore}), + {reasonCode: "local_authority_provider_identity_mismatch", sourceAuthority: "postgresql_v0"}, + ); +}); + +test("provider selectors reject undeclared credential-shaped fields", async t => { + const directory = await root(t); + const marker = join(directory, "authority", `provider-${createHash("sha256").update("goal").digest("hex")}.json`); + await mkdir(join(directory, "authority"), {recursive: true}); + await writeFile(marker, JSON.stringify({ + schema_version: "loopx_local_authority_provider_v0", + provider: "postgresql", + goal_id: "goal", + tenant_id: "tenant-a", + store_identity: `postgresql:${"a".repeat(32)}`, + connection_string: "must-not-be-persisted", + })); + await assert.rejects(openLocalAuthorityStoreHandle(directory, "goal"), { + reasonCode: "local_authority_selector_invalid", + sourceAuthority: null, + }); +}); + test("SQLite selection cannot replace existing canonical authority", async t => { const directory = await root(t); const store = await openLocalAuthorityStore(directory, "goal"); diff --git a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts index 7807db4296..becadb9fff 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import test from "node:test"; import { Pool, type PoolClient } from "pg"; @@ -11,6 +14,7 @@ import { type PostgreSqlAuthorityConnection, type PostgreSqlAuthorityDatabase, } from "../../loopx/control_plane/coordination/postgresql_authority_store.ts"; +import { openLocalAuthorityStoreHandle } from "../../loopx/control_plane/coordination/local_authority_provider.ts"; import { authorityStoreCommitFixture as commit, registerAuthorityStoreConformance, @@ -214,6 +218,38 @@ if (database && installed) { assert.equal((await second.readReceipt("shared-operation")).status, "found"); }); + test("local provider selector switches to a real PostgreSQL tenant", async (t) => { + await installed; + const root = await mkdtemp(join(tmpdir(), "loopx-local-provider-pg-")); + const tenantId = `tenant-${randomUUID()}`; + const goalId = `goal-${randomUUID()}`; + t.after(async () => { + await cleanScope(tenantId, goalId); + await rm(root, {recursive: true, force: true}); + }); + await mkdir(join(root, "authority"), {recursive: true}); + const marker = join(root, "authority", `provider-${createHash("sha256").update(goalId).digest("hex")}.json`); + await writeFile(marker, JSON.stringify({ + schema_version: "loopx_local_authority_provider_v0", + provider: "postgresql", + goal_id: goalId, + tenant_id: tenantId, + store_identity: STORE_IDENTITY, + })); + const handle = await openLocalAuthorityStoreHandle(root, goalId, { + openPostgresqlStore: selection => new PostgreSqlAuthorityStore(database, { + tenant_id: selection.tenant_id, + goal_id: selection.goal_id, + }), + }); + assert.equal(handle.provider, "postgresql"); + assert.equal(handle.sourceAuthority, "postgresql_v0"); + assert.equal(handle.store.providerKind, "postgresql"); + const applied = await handle.store.commitAuthority(commit(null, "selector-operation", 1, 1)); + assert.equal(applied.status, "applied"); + assert.equal((await handle.store.readReceipt("selector-operation")).status, "found"); + }); + test("PostgreSQL provider rolls back head, events, and receipts together", async (t) => { await installed; const tenantId = `tenant-${randomUUID()}`; diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.ts b/tests/control_plane_ts/production_scale_coordination_fixture.ts index 198db74814..c60c148e69 100644 --- a/tests/control_plane_ts/production_scale_coordination_fixture.ts +++ b/tests/control_plane_ts/production_scale_coordination_fixture.ts @@ -27,6 +27,12 @@ const envelope = JSON.parse(readFileSync(new URL( linked_decision_count: number; completion_target_index: number; supersede_target_index: number; + provider_matrix: { + default: "file"; + local_profiles: string[]; + service_profiles: string[]; + service_requires_factory: true; + }; semantic_cases: Record>; presentation_cases: Record>; update_cases: Record>; @@ -58,6 +64,12 @@ export interface ProductionScaleCoordinationFixture { readonly semantic_cases: Readonly>>; readonly presentation_cases: Readonly>>; readonly update_cases: Readonly>>; + readonly provider_matrix: Readonly<{ + default: "file"; + local_profiles: readonly string[]; + service_profiles: readonly string[]; + service_requires_factory: true; + }>; } function statusSeries( @@ -242,6 +254,12 @@ export function productionScaleCoordinationFixture( semantic_cases: envelope.semantic_cases, presentation_cases: envelope.presentation_cases, update_cases: envelope.update_cases, + provider_matrix: { + default: envelope.provider_matrix.default, + local_profiles: [...envelope.provider_matrix.local_profiles], + service_profiles: [...envelope.provider_matrix.service_profiles], + service_requires_factory: envelope.provider_matrix.service_requires_factory, + }, }; } diff --git a/tests/control_plane_ts/todo_semantic_fixture.test.ts b/tests/control_plane_ts/todo_semantic_fixture.test.ts index fffaef5fc1..7432f1b127 100644 --- a/tests/control_plane_ts/todo_semantic_fixture.test.ts +++ b/tests/control_plane_ts/todo_semantic_fixture.test.ts @@ -13,4 +13,10 @@ test("production-scale fixture carries cross-RFC semantic edge cases", () => { assert.equal(cases.global_gate_without_goal_binding.global_gate, true); assert.equal(cases.global_gate_without_goal_binding.goal_bound, false); assert.equal(cases.expired_lease.lease_epoch, 7); + assert.deepEqual(fixture.provider_matrix, { + default: "file", + local_profiles: ["file", "sqlite"], + service_profiles: ["postgresql"], + service_requires_factory: true, + }); }); diff --git a/tests/fixtures/control_plane/coordination_production_scale_v0.json b/tests/fixtures/control_plane/coordination_production_scale_v0.json index 72a5d03cff..59df6c70a4 100644 --- a/tests/fixtures/control_plane/coordination_production_scale_v0.json +++ b/tests/fixtures/control_plane/coordination_production_scale_v0.json @@ -20,6 +20,12 @@ "linked_decision_count": 12, "completion_target_index": 160, "supersede_target_index": 161, + "provider_matrix": { + "default": "file", + "local_profiles": ["file", "sqlite"], + "service_profiles": ["postgresql"], + "service_requires_factory": true + }, "semantic_cases": { "title_only_monitor": { "todo_id": "todo_fixture_title_monitor",