From 941498eec7384a7368b6b4b7ed4499922278d252 Mon Sep 17 00:00:00 2001 From: unidesk-code-queue-runner Date: Sat, 23 May 2026 00:33:59 +0000 Subject: [PATCH] fix: clarify runtime durability health contract --- docs/reference/dev-runtime-boundary.md | 8 ++ internal/cloud/health-contract.mjs | 79 ++++++++++++++- internal/cloud/json-rpc.test.mjs | 6 ++ internal/cloud/server.test.mjs | 131 ++++++++++++++++++++++++- internal/db/runtime-store.mjs | 56 +++++++++-- internal/db/runtime-store.test.mjs | 88 ++++++++++++++++- 6 files changed, 357 insertions(+), 11 deletions(-) diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index b3651ea5..6825bc22 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -177,6 +177,14 @@ non-durable until the schema, migration ledger, and read readiness gates are proven through the configured adapter. Documenting or testing this contract must not mutate runtime state or PROD. +`/health` and `/health/live` expose this split in `readiness.durability`. +`dbLiveEvidenceObserved=true` with +`dbLiveEvidenceIsDurabilityEvidence=false` means the DB TCP/live layer is +separate from runtime persistence. If runtime durability is blocked, +`blockedLayer` names the current adapter/auth/schema/migration/durability query +layer, and `requiredEvidence` remains the durable adapter schema, migration, +and read query contract. + ## Runtime Substitution Ban UniDesk services, provider-gateway, backend-core, microservice proxies, local diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index fd27512d..d958a406 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -1,15 +1,23 @@ import { RUNTIME_DURABLE_ADAPTER_MISSING, + RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, + RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING, + RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, + RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, + RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, + RUNTIME_DURABLE_ADAPTER_UNCONFIGURED, + RUNTIME_DURABILITY_REQUIRED_EVIDENCE, RUNTIME_STORE_KIND_POSTGRES } from "../db/runtime-store.mjs"; -export const CLOUD_API_HEALTH_CONTRACT_VERSION = "v2"; +export const CLOUD_API_HEALTH_CONTRACT_VERSION = "v3"; export function buildCloudApiReadiness({ db, codeAgent, runtime } = {}) { const dbReady = isDbReady(db); const codeAgentReady = isCodeAgentReady(codeAgent); const runtimeReady = isRuntimeReady(runtime); const blockers = []; + let runtimeReadinessBlocker = null; if (!dbReady) { blockers.push(dbBlocker(db)); @@ -20,7 +28,8 @@ export function buildCloudApiReadiness({ db, codeAgent, runtime } = {}) { } if (!runtimeReady) { - blockers.push(runtimeBlocker(runtime)); + runtimeReadinessBlocker = runtimeBlocker(runtime); + blockers.push(runtimeReadinessBlocker); } return { @@ -33,7 +42,13 @@ export function buildCloudApiReadiness({ db, codeAgent, runtime } = {}) { db: dbReady ? "ready" : "blocked", codeAgent: codeAgentReady ? "ready" : "blocked", runtime: runtimeReady ? "ready" : dbReady ? "blocked" : "waiting_for_db" - } + }, + durability: buildRuntimeDurabilityReadiness({ + db, + runtime, + runtimeReady, + runtimeBlocker: runtimeReadinessBlocker + }) }; } @@ -96,6 +111,7 @@ function runtimeBlocker(runtime) { const durableRequested = runtime?.durableRequested === true || runtime?.adapter === RUNTIME_STORE_KIND_POSTGRES; const unhealthy = (durable || durableRequested) && ["blocked", "degraded", "failed"].includes(runtime?.status); const blocker = runtime?.blocker; + const blockedLayer = classifyRuntimeDurabilityLayer(runtime); return { code: durableRequested && unhealthy ? blocker ?? "runtime_durable_adapter_unhealthy" : RUNTIME_DURABLE_ADAPTER_MISSING, type: "runtime_blocker", @@ -118,9 +134,12 @@ function runtimeBlocker(runtime) { migrationReady: Boolean(runtime?.migration?.ready), migrationChecked: Boolean(runtime?.migration?.checked), gates: runtime?.gates ?? null, + blockedLayer, queryAttempted: Boolean(runtime?.connection?.queryAttempted), queryResult: runtime?.connection?.queryResult ?? "unknown", dbBackedAdapterRequired: true, + dbLiveEvidenceIsDurabilityEvidence: false, + requiredEvidence: runtime?.durabilityContract?.requiredEvidence ?? RUNTIME_DURABILITY_REQUIRED_EVIDENCE, secretMaterialRead: false }, nextTask: durableRequested @@ -128,3 +147,57 @@ function runtimeBlocker(runtime) { : "Set HWLAB_CLOUD_RUNTIME_ADAPTER=postgres after the runtime DB schema is available; keep HWLAB_CLOUD_DB_URL injected from Secret." }; } + +function buildRuntimeDurabilityReadiness({ db, runtime, runtimeReady, runtimeBlocker } = {}) { + const dbLiveEvidenceObserved = Boolean(db?.ready && db?.connected && db?.liveConnected !== false && db?.liveDbEvidence); + const contract = runtime?.durabilityContract ?? {}; + return { + status: runtimeReady ? "ready" : "blocked", + ready: runtimeReady, + adapter: runtime?.adapter ?? "unknown", + durable: Boolean(runtime?.durable), + durableRequested: Boolean(runtime?.durableRequested || runtime?.adapter === RUNTIME_STORE_KIND_POSTGRES), + durableCapable: Boolean(runtime?.durableCapable), + runtimeStatus: runtime?.status ?? "unknown", + runtimeReady: Boolean(runtime?.ready), + liveRuntimeEvidence: Boolean(runtime?.liveRuntimeEvidence), + dbLiveEvidenceObserved, + dbLiveEvidenceIsDurabilityEvidence: false, + requiredEvidence: contract.requiredEvidence ?? RUNTIME_DURABILITY_REQUIRED_EVIDENCE, + blockedLayer: runtimeReady ? null : contract.blockedLayer ?? classifyRuntimeDurabilityLayer(runtime), + blocker: runtimeReady ? null : runtimeBlocker?.code ?? runtime?.blocker ?? RUNTIME_DURABLE_ADAPTER_MISSING, + gates: runtime?.gates ?? null, + queryAttempted: Boolean(runtime?.connection?.queryAttempted), + queryResult: runtime?.connection?.queryResult ?? "unknown", + secretMaterialRead: false + }; +} + +function classifyRuntimeDurabilityLayer(runtime = {}) { + const contractLayer = runtime?.durabilityContract?.blockedLayer; + if (typeof contractLayer === "string" && contractLayer.length > 0) { + return contractLayer; + } + + const blocker = runtime?.blocker; + if ( + blocker === RUNTIME_DURABLE_ADAPTER_MISSING || + blocker === RUNTIME_DURABLE_ADAPTER_UNCONFIGURED || + blocker === RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING + ) { + return "adapter"; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) { + return "auth"; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) { + return "schema"; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) { + return "migration"; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED || runtime?.connection?.queryAttempted) { + return "durability_query"; + } + return "adapter"; +} diff --git a/internal/cloud/json-rpc.test.mjs b/internal/cloud/json-rpc.test.mjs index 6800b911..17616ef6 100644 --- a/internal/cloud/json-rpc.test.mjs +++ b/internal/cloud/json-rpc.test.mjs @@ -72,6 +72,12 @@ test("system.health includes the redacted DEV DB env contract", async () => { assert.equal(response.result.db.redaction.secretMaterialRead, false); assert.equal(response.result.db.safety.liveDbEvidence, false); assert.equal(response.result.runtime.durable, false); + assert.equal(response.result.runtime.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(response.result.readiness.contractVersion, "v3"); + assert.equal(response.result.readiness.durability.status, "blocked"); + assert.equal(response.result.readiness.durability.dbLiveEvidenceObserved, false); + assert.equal(response.result.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(response.result.readiness.durability.blockedLayer, "adapter"); assert.ok(response.result.db.missingEnv.includes("HWLAB_CLOUD_DB_URL")); assert.equal(response.result.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); assert.equal(response.result.db.secretRefs[0].secretKey, "database-url"); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 0b7d2112..bf6a5d77 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -3,7 +3,10 @@ import { createServer as createTcpServer } from "node:net"; import test from "node:test"; import { createCloudApiServer } from "./server.mjs"; -import { RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED } from "../db/runtime-store.mjs"; +import { + RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, + RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED +} from "../db/runtime-store.mjs"; test("cloud api exposes /health, /health/live, and /live probes", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; @@ -50,6 +53,13 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.db.safety.liveDbEvidence, false); assert.equal(healthPayload.runtime.durable, false); assert.equal(healthPayload.runtime.status, "degraded"); + assert.equal(healthPayload.runtime.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(healthPayload.runtime.durabilityContract.blockedLayer, "adapter"); + assert.equal(healthPayload.readiness.contractVersion, "v3"); + assert.equal(healthPayload.readiness.durability.status, "blocked"); + assert.equal(healthPayload.readiness.durability.dbLiveEvidenceObserved, false); + assert.equal(healthPayload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(healthPayload.readiness.durability.blockedLayer, "adapter"); assert.equal(healthPayload.codeAgent.status, "blocked"); assert.equal(healthPayload.codeAgent.blocker, "凭证缺口"); assert.equal(healthPayload.codeAgent.reason, "provider_unavailable"); @@ -149,6 +159,15 @@ test("cloud api health reports DB env presence and live connection classificatio assert.equal(payload.runtime.durable, false); assert.equal(payload.runtime.status, "degraded"); assert.match(payload.runtime.reason, /process-local/u); + assert.equal(payload.runtime.durabilityContract.blockedLayer, "adapter"); + assert.equal(payload.runtime.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(payload.readiness.components.db, "ready"); + assert.equal(payload.readiness.components.runtime, "blocked"); + assert.equal(payload.readiness.durability.status, "blocked"); + assert.equal(payload.readiness.durability.dbLiveEvidenceObserved, true); + assert.equal(payload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(payload.readiness.durability.blockedLayer, "adapter"); + assert.equal(payload.readiness.durability.liveRuntimeEvidence, false); assert.equal(payload.db.redaction.valuesRedacted, true); assert.equal(payload.db.redaction.endpointRedacted, true); assert.equal(payload.db.redaction.secretMaterialRead, false); @@ -247,6 +266,12 @@ test("cloud api health separates DB connected from durable runtime schema readin assert.equal(payload.runtime.blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED); assert.equal(payload.readiness.components.db, "ready"); assert.equal(payload.readiness.components.runtime, "blocked"); + assert.equal(payload.readiness.durability.status, "blocked"); + assert.equal(payload.readiness.durability.dbLiveEvidenceObserved, true); + assert.equal(payload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(payload.readiness.durability.blockedLayer, "schema"); + assert.equal(payload.readiness.durability.queryAttempted, true); + assert.equal(payload.readiness.durability.queryResult, "schema_blocked"); assert.ok(payload.blockerCodes.includes(RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED)); assert.equal(JSON.stringify(payload).includes("password"), false); } finally { @@ -269,6 +294,110 @@ test("cloud api health separates DB connected from durable runtime schema readin } }); +test("cloud api health keeps DB live evidence separate when durable adapter query is blocked", async () => { + const originalUrl = process.env.HWLAB_CLOUD_DB_URL; + const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; + const fakeDb = createTcpServer((socket) => socket.end()); + await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); + const dbPort = fakeDb.address().port; + process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test:password@127.0.0.1:${dbPort}/hwlab`; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + + const server = createCloudApiServer({ + runtimeStore: { + async readiness() { + return { + adapter: "postgres", + durable: false, + durableRequested: true, + durableCapable: false, + ready: false, + status: "blocked", + blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, + reason: "test runtime durable read query is blocked", + liveRuntimeEvidence: false, + fixtureEvidence: false, + connection: { + queryAttempted: true, + queryResult: "query_blocked", + endpointRedacted: true, + valueRedacted: true, + errorCode: "57014" + }, + schema: { + checked: true, + ready: true, + missingTables: [], + missingColumns: [] + }, + migration: { + checked: true, + ready: true, + missing: false + }, + gates: { + auth: { checked: true, ready: true, status: "ready", blocker: null }, + schema: { checked: true, ready: true, status: "ready", blocker: null }, + migration: { checked: true, ready: true, status: "ready", blocker: null }, + durability: { + checked: true, + ready: false, + status: "blocked", + blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED + } + }, + counts: {} + }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/health/live`); + const payload = await response.json(); + assert.equal(response.status, 200); + assert.equal(payload.status, "degraded"); + assert.equal(payload.db.ready, true); + assert.equal(payload.db.connected, true); + assert.equal(payload.db.liveDbEvidence, true); + assert.equal(payload.runtime.durable, false); + assert.equal(payload.runtime.ready, false); + assert.equal(payload.runtime.blocker, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED); + assert.equal(payload.readiness.components.db, "ready"); + assert.equal(payload.readiness.components.runtime, "blocked"); + assert.equal(payload.readiness.durability.status, "blocked"); + assert.equal(payload.readiness.durability.dbLiveEvidenceObserved, true); + assert.equal(payload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(payload.readiness.durability.blockedLayer, "durability_query"); + assert.equal(payload.readiness.durability.queryAttempted, true); + assert.equal(payload.readiness.durability.queryResult, "query_blocked"); + assert.equal(payload.readiness.durability.liveRuntimeEvidence, false); + assert.ok(payload.blockerCodes.includes(RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED)); + assert.equal(JSON.stringify(payload).includes("password"), false); + assert.equal(JSON.stringify(payload).includes("127.0.0.1"), false); + assert.equal(JSON.stringify(payload).includes(String(dbPort)), false); + } finally { + if (originalUrl === undefined) { + delete process.env.HWLAB_CLOUD_DB_URL; + } else { + process.env.HWLAB_CLOUD_DB_URL = originalUrl; + } + if (originalSslMode === undefined) { + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + } else { + process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + fakeDb.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api health does not treat DB env presence-only as live readiness", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; diff --git a/internal/db/runtime-store.mjs b/internal/db/runtime-store.mjs index 3c6df7fd..86d4c335 100644 --- a/internal/db/runtime-store.mjs +++ b/internal/db/runtime-store.mjs @@ -27,6 +27,7 @@ export const RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED = "runtime_durable_adapter_aut export const RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED = "runtime_durable_adapter_schema_blocked"; export const RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED = "runtime_durable_adapter_migration_blocked"; export const RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED = "runtime_durable_adapter_query_blocked"; +export const RUNTIME_DURABILITY_REQUIRED_EVIDENCE = "runtime_adapter_schema_migration_read_query"; export const RUNTIME_ADAPTER_ENV = "HWLAB_CLOUD_RUNTIME_ADAPTER"; export const RUNTIME_DURABLE_ENV = "HWLAB_CLOUD_RUNTIME_DURABLE"; @@ -78,7 +79,7 @@ export class CloudRuntimeStore { } summary() { - return { + return addRuntimeDurabilityContract({ adapter: RUNTIME_STORE_KIND, durable: false, status: "degraded", @@ -97,7 +98,7 @@ export class CloudRuntimeStore { auditEvents: this.auditEvents.size, evidenceRecords: this.evidenceRecords.size } - }; + }); } registerGatewaySession(params = {}, requestMeta = {}) { @@ -719,7 +720,7 @@ export class PostgresCloudRuntimeStore { return this.summary(); } - this.lastReadiness = { + this.lastReadiness = addRuntimeDurabilityContract({ adapter: RUNTIME_STORE_KIND_POSTGRES, durable: true, durableRequested: true, @@ -747,7 +748,7 @@ export class PostgresCloudRuntimeStore { safety: runtimeSafety(), adapterContract: postgresAdapterContract(), counts - }; + }); return this.summary(); } @@ -1113,7 +1114,7 @@ export class PostgresCloudRuntimeStore { missingColumns: [] }; const blockedMigration = migration ?? notCheckedRuntimeMigration(); - return { + return addRuntimeDurabilityContract({ adapter: RUNTIME_STORE_KIND_POSTGRES, durable: false, durableRequested: true, @@ -1145,7 +1146,7 @@ export class PostgresCloudRuntimeStore { }), safety: runtimeSafety(), adapterContract: postgresAdapterContract() - }; + }); } } @@ -1283,6 +1284,49 @@ function postgresAdapterContract() { }; } +function addRuntimeDurabilityContract(summary) { + return { + ...summary, + durabilityContract: { + ready: summary.durable === true && summary.ready === true && summary.liveRuntimeEvidence === true, + status: summary.durable === true && summary.ready === true && summary.liveRuntimeEvidence === true ? "ready" : "blocked", + requiredEvidence: RUNTIME_DURABILITY_REQUIRED_EVIDENCE, + dbLiveEvidenceIsDurabilityEvidence: false, + liveRuntimeEvidence: Boolean(summary.liveRuntimeEvidence), + adapterQueryRequired: true, + blockedLayer: summary.durable === true && summary.ready === true && summary.liveRuntimeEvidence === true + ? null + : durabilityBlockedLayer(summary), + blocker: summary.blocker ?? null, + secretMaterialRead: false + } + }; +} + +function durabilityBlockedLayer(summary = {}) { + const blocker = summary.blocker; + if ( + blocker === RUNTIME_DURABLE_ADAPTER_MISSING || + blocker === RUNTIME_DURABLE_ADAPTER_UNCONFIGURED || + blocker === RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING + ) { + return "adapter"; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) { + return "auth"; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) { + return "schema"; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) { + return "migration"; + } + if (summary.connection?.queryAttempted || blocker === RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED) { + return "durability_query"; + } + return "adapter"; +} + function runtimeSafety() { return { devOnly: true, diff --git a/internal/db/runtime-store.test.mjs b/internal/db/runtime-store.test.mjs index 39686fd2..80670e95 100644 --- a/internal/db/runtime-store.test.mjs +++ b/internal/db/runtime-store.test.mjs @@ -4,7 +4,9 @@ import test from "node:test"; import { handleJsonRpcRequest } from "../cloud/json-rpc.mjs"; import { validateResponse } from "../protocol/index.mjs"; import { + RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, + RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, RUNTIME_STORE_KIND_POSTGRES, createCloudRuntimeStore, @@ -25,6 +27,45 @@ test("memory runtime store is never marked durable", () => { assert.equal(summary.status, "degraded"); assert.equal(summary.ready, undefined); assert.equal(summary.adapterContract.secretMaterialRequiredInSource, false); + assert.equal(summary.durabilityContract.ready, false); + assert.equal(summary.durabilityContract.status, "blocked"); + assert.equal(summary.durabilityContract.blockedLayer, "adapter"); + assert.equal(summary.durabilityContract.adapterQueryRequired, true); + assert.equal(summary.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(summary.durabilityContract.secretMaterialRead, false); +}); + +test("configured postgres runtime classifies auth blocker before schema and migration", async () => { + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error("auth failed"); + error.code = "28P01"; + throw error; + } + } + }); + + const readiness = await store.readiness(); + assert.equal(readiness.adapter, RUNTIME_STORE_KIND_POSTGRES); + assert.equal(readiness.durable, false); + assert.equal(readiness.ready, false); + assert.equal(readiness.status, "blocked"); + assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED); + assert.equal(readiness.connection.queryAttempted, true); + assert.equal(readiness.connection.queryResult, "auth_blocked"); + assert.equal(readiness.gates.auth.status, "blocked"); + assert.equal(readiness.gates.schema.status, "not_checked"); + assert.equal(readiness.gates.migration.status, "not_checked"); + assert.equal(readiness.durabilityContract.ready, false); + assert.equal(readiness.durabilityContract.blockedLayer, "auth"); + assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); }); test("configured postgres runtime reports schema blocker without green readiness", async () => { @@ -56,6 +97,9 @@ test("configured postgres runtime reports schema blocker without green readiness assert.equal(readiness.migration.checked, false); assert.equal(readiness.gates.schema.status, "blocked"); assert.equal(readiness.gates.migration.status, "not_checked"); + assert.equal(readiness.durabilityContract.ready, false); + assert.equal(readiness.durabilityContract.blockedLayer, "schema"); + assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); assert.equal(JSON.stringify(readiness).includes("redacted@db.example"), false); }); @@ -83,6 +127,38 @@ test("configured postgres runtime keeps durable false when migration ledger is m assert.equal(readiness.gates.schema.status, "ready"); assert.equal(readiness.gates.migration.status, "blocked"); assert.equal(readiness.liveRuntimeEvidence, false); + assert.equal(readiness.durabilityContract.ready, false); + assert.equal(readiness.durabilityContract.blockedLayer, "migration"); + assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); +}); + +test("configured postgres runtime classifies durable read query blocker after schema and migration", async () => { + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", + queryClient: createFakePostgresClient({ migrationReady: true, countErrorCode: "57014" }) + }); + + const readiness = await store.readiness(); + assert.equal(readiness.adapter, RUNTIME_STORE_KIND_POSTGRES); + assert.equal(readiness.durable, false); + assert.equal(readiness.ready, false); + assert.equal(readiness.status, "blocked"); + assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED); + assert.equal(readiness.schema.ready, true); + assert.equal(readiness.migration.ready, true); + assert.equal(readiness.connection.queryAttempted, true); + assert.equal(readiness.connection.queryResult, "query_blocked"); + assert.equal(readiness.gates.auth.status, "ready"); + assert.equal(readiness.gates.schema.status, "ready"); + assert.equal(readiness.gates.migration.status, "ready"); + assert.equal(readiness.gates.durability.status, "blocked"); + assert.equal(readiness.durabilityContract.ready, false); + assert.equal(readiness.durabilityContract.blockedLayer, "durability_query"); + assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); }); test("configured postgres runtime persists and queries records through query client", async () => { @@ -132,7 +208,12 @@ test("configured postgres runtime persists and queries records through query cli assert.equal(health.runtime.migration.ready, true); assert.equal(health.runtime.gates.migration.status, "ready"); assert.equal(health.runtime.gates.durability.status, "ready"); + assert.equal(health.runtime.durabilityContract.ready, true); + assert.equal(health.runtime.durabilityContract.blockedLayer, null); + assert.equal(health.runtime.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); assert.equal(health.readiness.components.runtime, "ready"); + assert.equal(health.readiness.durability.status, "ready"); + assert.equal(health.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); await rpc("req_01J00000000000000000001001", "gateway.session.register", { projectId: "prj_01J00000000000000000001000", @@ -182,7 +263,7 @@ test("configured postgres runtime persists and queries records through query cli assert.equal(evidence.records[0].operationId, invoke.operationId); }); -function createFakePostgresClient({ migrationReady = true } = {}) { +function createFakePostgresClient({ migrationReady = true, countErrorCode = null } = {}) { const state = { gateway_sessions: new Map(), box_resources: new Map(), @@ -208,6 +289,11 @@ function createFakePostgresClient({ migrationReady = true } = {}) { return { rows: schemaRows() }; } if (sql.startsWith("SELECT COUNT(*)::int AS count FROM ")) { + if (countErrorCode) { + const error = new Error("count query failed"); + error.code = countErrorCode; + throw error; + } const table = sql.match(/FROM ([a-z_]+)/u)?.[1]; return { rows: [{ count: state[table]?.size ?? 0 }] }; }