diff --git a/examples/nokv-authority-store/README.md b/examples/nokv-authority-store/README.md index d33c5a65d6..8bb8a21e8b 100644 --- a/examples/nokv-authority-store/README.md +++ b/examples/nokv-authority-store/README.md @@ -26,6 +26,15 @@ wheel labelled `0.11.1` without that publication surface is refused as ## What it proves +The report records each helper's ready-handshake schema declaration in +`nokv_protocol_schema_observations` (open order; `null` where the SDK declares +none, as the 0.11.x releases do). `nokv_protocol_schema` names a schema only +when all three helpers declared the same one and is `null` otherwise. This is +observed SDK metadata, not a new qualification or write-authority gate: +disagreeing helpers do not fail the probe. An open refusal retains the +helper's typed reason code while omitting its free-form reason from CLI stderr. +The current SDK/API pin and incarnation-fenced publication checks still apply. + Against one **already existing** NoKV workbench, the probe starts three independent helper processes and verifies: diff --git a/examples/nokv-authority-store/live-qualification.ts b/examples/nokv-authority-store/live-qualification.ts index f5fe31031d..5914eed3f2 100644 --- a/examples/nokv-authority-store/live-qualification.ts +++ b/examples/nokv-authority-store/live-qualification.ts @@ -25,6 +25,7 @@ import { type NoKVStoreIdentityResult, } from "../../loopx/control_plane/coordination/nokv_authority_store.ts"; import { + NoKVHelperOpenRefusedError, NoKVJsonLinesTransport, } from "../../loopx/control_plane/coordination/nokv_jsonl_transport.ts"; @@ -48,6 +49,8 @@ export class QualificationFailure extends Error { export interface QualificationTransport extends NoKVBlobTransport { close(): Promise; + /** Wire schema declared by the helper's SDK; absent or `null` when unknown. */ + readonly sdkProtocolSchema?: string | null; } export interface QualificationOptions { @@ -71,12 +74,21 @@ export interface QualificationReport { availability_or_ha_proven: false; nokv_sdk_version: typeof QUALIFIED_NOKV_SDK_VERSION; nokv_api_version: typeof QUALIFIED_NOKV_API_VERSION; + nokv_protocol_schema: string | null; + nokv_protocol_schema_observations: readonly (string | null)[]; } export interface QualificationSequenceResult { checks: readonly { id: string; status: "passed" }[]; final_generation: number; final_cursor: string; + /** + * The wire schema every opened helper declared, or `null` when they did not + * all declare the same one. The 0.11.x release wheels declare none. + */ + nokv_protocol_schema: string | null; + /** Each opened helper's declared schema in open order; `null` where none. */ + nokv_protocol_schema_observations: readonly (string | null)[]; } export interface QualificationCliArguments { @@ -276,6 +288,12 @@ function freshPhysicalIdentity(): string { return randomUUID().replaceAll("-", ""); } +/** The schema every observation names; `null` when none declared one or they disagree. */ +function uniformProtocolSchema(observations: readonly (string | null)[]): string | null { + const [first = null, ...rest] = observations; + return rest.every((schema) => schema === first) ? first : null; +} + async function rawGeneration( transport: NoKVBlobTransport, store: NoKVAuthorityStore, @@ -536,10 +554,13 @@ export async function exerciseQualificationSequence( ); passed("loser_receipt_absent"); + const protocolSchemas = opened.map((transport) => transport.sdkProtocolSchema ?? null); return { checks, final_generation: 3, final_cursor: "3", + nokv_protocol_schema: uniformProtocolSchema(protocolSchemas), + nokv_protocol_schema_observations: protocolSchemas, }; } finally { await Promise.allSettled(opened.map(async (transport) => await transport.close())); @@ -661,6 +682,11 @@ async function main(): Promise { } else if (error instanceof NoKVTransportUnavailableError) { reasonCode = "nokv_backend_unavailable"; reason = "NoKV backend or SDK helper is unavailable"; + } else if (error instanceof NoKVHelperOpenRefusedError) { + // The helper's own typed code (for example nokv_sdk_capability_mismatch); + // its human-readable reason may echo configuration and stays out of stderr. + reasonCode = error.reasonCode; + reason = "NoKV SDK helper refused the open handshake"; } else if (error instanceof NoKVTransportProtocolError) { reasonCode = "nokv_transport_protocol_failed"; reason = "NoKV SDK helper violated the transport protocol"; diff --git a/examples/shared-goal-authority-e2e/README.md b/examples/shared-goal-authority-e2e/README.md index 7b16b0709a..fa70d29cd5 100644 --- a/examples/shared-goal-authority-e2e/README.md +++ b/examples/shared-goal-authority-e2e/README.md @@ -35,7 +35,7 @@ and the transaction-bound outbox instead of requiring a second writable history. | `s0.native_file_conformance` | 0 | store_direct | deterministic | Runs the complete `authority_store.test.ts` suite against the current TS FileAuthorityStore; every selected test must pass, with no skip or missing trailer | | `s0.native_sqlite_conformance` | 0 | store_direct | deterministic | Runs the complete `sqlite_authority_store.test.ts` suite against real SQLite files and the same shared conformance contract | | `s1.cli_document_decodes_through_ts_store` | 1 | real_cli | deterministic | Explicit bootstrap plus three CLI writes load at cursor `4`; paged `scanCommitted` returns four distinct transactions in source order and `readReceipt` finds the first source write | -| `s2a.nokv_live_qualification` | 2a | store_direct | env:nokv_authority | runs the merged `examples/nokv-authority-store/live-qualification.ts --execute-live` against an existing workbench with a fresh tenant/goal pair; requires `ok=true`, the single-node store-conformance scope, every check `passed`, NoKV SDK `0.11.1` / API `1`, the two stale-incarnation fence checks (`stale_incarnation_fence_rejected`, `stale_incarnation_fence_left_generation_unchanged`), and no promotion or availability claim; evidence carries check ids, counts, and config and workbench digest prefixes, never a configuration value or the workbench name | +| `s2a.nokv_live_qualification` | 2a | store_direct | env:nokv_authority | runs the merged `examples/nokv-authority-store/live-qualification.ts --execute-live` against an existing workbench with a fresh tenant/goal pair; requires `ok=true`, the single-node store-conformance scope, every check `passed`, NoKV SDK `0.11.1` / API `1`, the two stale-incarnation fence checks (`stale_incarnation_fence_rejected`, `stale_incarnation_fence_left_generation_unchanged`), and no promotion or availability claim; evidence carries check ids, counts, the helpers' declared SDK protocol schemas, and config and workbench digest prefixes, never a configuration value or the workbench name | | `s2b.postgresql_conformance_live` | 2b | store_direct | env:postgresql | `postgresql_authority_store.integration.test.ts` under node's TAP reporter: `# pass >= 9`, `# fail 0`, `# skipped 0` | | `s2c1.retired_observation_upgrade` | 2c1 | real_cli | deterministic | Old enable rejects without writes; retained settings create no history; explicit clear/configure/bootstrap captures the next source transaction | | `s2c2.outbox_prepared_then_committed_entries` | 2c2 | real_cli | deterministic | with the maintenance lock held, `todo add` (Python) and `task-lease acquire` (TypeScript) report `drain_deferred/drain_lock_busy`, `status` shows one `committed_pending` entry per partition with one prepared record and one committed marker on disk; one `drain` delivers both (`delivered=2`), history holds the bootstrap plus two committed receipts from both writer runtimes, and the next write delivers inline at cursor `4` | diff --git a/loopx/control_plane/coordination/nokv_jsonl_transport.ts b/loopx/control_plane/coordination/nokv_jsonl_transport.ts index b8b637f1dd..5a784a4d4b 100644 --- a/loopx/control_plane/coordination/nokv_jsonl_transport.ts +++ b/loopx/control_plane/coordination/nokv_jsonl_transport.ts @@ -31,6 +31,23 @@ export type NoKVJsonLinesProcessFactory = ( options: SpawnOptionsWithoutStdio, ) => ChildProcessWithoutNullStreams; +/** + * The helper answered the open handshake with a typed `failed` response. + * + * `reasonCode` is the helper's own code (for example + * `nokv_sdk_capability_mismatch` when the installed wheel cannot build the + * configured routing kind), so callers can classify a refused open without + * parsing the human-readable reason. + */ +export class NoKVHelperOpenRefusedError extends NoKVTransportProtocolError { + readonly reasonCode: string; + + constructor(reasonCode: string, message: string) { + super(message); + this.reasonCode = reasonCode; + } +} + export interface NoKVJsonLinesTransportOptions { /** Explicit command plus arguments, for example `[python, helper.py]`. */ argv: readonly string[]; @@ -100,6 +117,12 @@ export class NoKVJsonLinesTransport implements NoKVBlobTransport { private stdoutBuffer = Buffer.alloc(0); private terminalError: Error | null = null; private closing = false; + /** + * The wire schema the helper's SDK declared in its `ready` handshake, or + * `null` when the wheel exports none (the 0.11.x releases). Informational: + * the helper's own version guard stays the admission decision. + */ + sdkProtocolSchema: string | null = null; constructor( options: NoKVJsonLinesTransportOptions, @@ -167,12 +190,17 @@ export class NoKVJsonLinesTransport implements NoKVBlobTransport { ); } if (response.status !== "ready") { - throw new NoKVTransportProtocolError( - response.status === "failed" && typeof response.reason === "string" - ? response.reason - : "NoKV helper did not acknowledge its open handshake", - ); + const reason = response.status === "failed" && typeof response.reason === "string" + ? response.reason + : "NoKV helper did not acknowledge its open handshake"; + if (response.status === "failed" && typeof response.reason_code === "string" + && response.reason_code.length > 0) { + throw new NoKVHelperOpenRefusedError(response.reason_code, reason); + } + throw new NoKVTransportProtocolError(reason); } + const schema = response.nokv_protocol_schema; + transport.sdkProtocolSchema = typeof schema === "string" && schema.length > 0 ? schema : null; return transport; } catch (error) { await transport.close(); diff --git a/loopx/control_plane/testing/authority_e2e_ladder.py b/loopx/control_plane/testing/authority_e2e_ladder.py index ff7350c89b..826c1f04ee 100644 --- a/loopx/control_plane/testing/authority_e2e_ladder.py +++ b/loopx/control_plane/testing/authority_e2e_ladder.py @@ -414,6 +414,8 @@ def _row_nokv_live_qualification(context: RowContext) -> RowOutcome: final_cursor=report.get("final_cursor"), nokv_sdk_version=report.get("nokv_sdk_version"), nokv_api_version=report.get("nokv_api_version"), + nokv_protocol_schema=report.get("nokv_protocol_schema"), + nokv_protocol_schema_observations=report.get("nokv_protocol_schema_observations"), config_sha256_prefix=_nokv_authority_config_sha256(config_path)[:12], workbench_sha256_prefix=sha256_hex(workbench)[:12], tenant_id=tenant_id, diff --git a/tests/control_plane_ts/nokv_jsonl_transport.test.ts b/tests/control_plane_ts/nokv_jsonl_transport.test.ts index ee42a84812..eb99eb5b10 100644 --- a/tests/control_plane_ts/nokv_jsonl_transport.test.ts +++ b/tests/control_plane_ts/nokv_jsonl_transport.test.ts @@ -7,7 +7,7 @@ import { NoKVTransportProtocolError, NoKVTransportUnavailableError, } from "../../loopx/control_plane/coordination/nokv_authority_store.ts"; -import { NoKVJsonLinesTransport } from "../../loopx/control_plane/coordination/nokv_jsonl_transport.ts"; +import { NoKVHelperOpenRefusedError, NoKVJsonLinesTransport } from "../../loopx/control_plane/coordination/nokv_jsonl_transport.ts"; import { registerAuthorityStoreConformance } from "./authority_store_conformance.ts"; import { resolveTestPython } from "../../scripts/test-python.mjs"; @@ -37,6 +37,7 @@ const FAKE_INCARNATION = "a".repeat(32); async function openSdkHelper( routing: Record = ETCD_ROUTING, fakeSdkShape?: "0.11.0" | "0.11.1-unfenced", + extraEnv: Record = {}, ) { return await NoKVJsonLinesTransport.open({ argv: [PYTHON, SDK_HELPER], @@ -47,6 +48,7 @@ async function openSdkHelper( }, env: { ...process.env, + ...extraEnv, PYTHONPATH: process.env.PYTHONPATH ? `${FAKE_SDK_ROOT}:${process.env.PYTHONPATH}` : FAKE_SDK_ROOT, @@ -288,6 +290,35 @@ test("JSON-lines transport opens the real helper with seed routing", async () => } }); +test("JSON-lines transport retains the SDK schema from the exact ready handshake", async () => { + const unknown = await openSdkHelper(); + try { + assert.equal(unknown.sdkProtocolSchema, null); + } finally { + await unknown.close(); + } + const declared = await openSdkHelper(SEEDS_ROUTING, undefined, { + LOOPX_FAKE_NOKV_PROTOCOL_SCHEMA: "nokv.workspace.rpc.v10", + }); + try { + assert.equal(declared.sdkProtocolSchema, "nokv.workspace.rpc.v10"); + } finally { + await declared.close(); + } +}); + +test("a missing SDK routing constructor returns a typed open refusal", async () => { + await assert.rejects( + openSdkHelper(SEEDS_ROUTING, undefined, {LOOPX_FAKE_NOKV_RELEASE_SHAPE: "1"}), + (error: unknown) => error instanceof NoKVHelperOpenRefusedError + && error.reasonCode === "nokv_sdk_capability_mismatch" + && /RoutingConfig\.seeds/.test(error.message), + ); + const supported = await openSdkHelper(ETCD_ROUTING, undefined, + {LOOPX_FAKE_NOKV_RELEASE_SHAPE: "1"}); + await supported.close(); +}); + test("JSON-lines transport surfaces an unknown routing kind as a typed protocol failure", async () => { await assert.rejects( openSdkHelper({ kind: "gossip", endpoints: ["127.0.0.1:7750"] }), diff --git a/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts b/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts index 15ea6f309a..f3c41ad5f3 100644 --- a/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts +++ b/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts @@ -106,10 +106,12 @@ const FAKE_INCARNATION = "a".repeat(32); class FakeQualificationTransport implements QualificationTransport { readonly backend: Backend; + readonly sdkProtocolSchema?: string | null; closed = false; - constructor(backend: Backend) { + constructor(backend: Backend, sdkProtocolSchema?: string | null) { this.backend = backend; + this.sdkProtocolSchema = sdkProtocolSchema; } async storeIdentity(workbench: string): Promise { @@ -258,6 +260,40 @@ test("qualification proves create, ambiguous reconciliation, contention, and fre assert.ok(opened.every((transport) => transport.closed)); }); +test("qualification keeps every helper's SDK schema and names one only when all agree", async () => { + const v9 = "nokv.workspace.rpc.v9"; + const v10 = "nokv.workspace.rpc.v10"; + const cases: { declared: (string | null | undefined)[]; schema: string | null }[] = [ + { declared: [undefined, undefined, undefined], schema: null }, + { declared: [v9, v9, v9], schema: v9 }, + { declared: [null, v9, v10], schema: null }, + { declared: [v9, v10, v9], schema: null }, + { declared: [v9, v9, null], schema: null }, + ]; + for (const { declared, schema } of cases) { + const backend: Backend = { + blob: null, + ignoreCas: false, + publishCalls: 0, + pretendAppliedWithoutWriteOnCall: null, + }; + let opened = 0; + const report = await exerciseQualificationSequence(BASE_OPTIONS, async () => + new FakeQualificationTransport(backend, declared[opened++])); + + const label = JSON.stringify(declared); + assert.equal(opened, 3, label); + assert.deepEqual( + report.nokv_protocol_schema_observations, + declared.map((value) => value ?? null), + label, + ); + assert.equal(report.nokv_protocol_schema, schema, label); + // The schema is observed metadata: disagreeing helpers do not fail the probe. + assert.equal(report.checks.length, 15, label); + } +}); + test("qualification rejects a backend that accepts a stale incarnation fence", async () => { const backend: Backend = { blob: null, diff --git a/tests/fixtures/nokv_fake_sdk/nokv/__init__.py b/tests/fixtures/nokv_fake_sdk/nokv/__init__.py index 6844159d6f..fe1ebaf403 100644 --- a/tests/fixtures/nokv_fake_sdk/nokv/__init__.py +++ b/tests/fixtures/nokv_fake_sdk/nokv/__init__.py @@ -16,13 +16,21 @@ API_VERSION = 1 _CURRENT_INCARNATION = "a" * 32 +# Keep a missing schema distinct from an SDK-declared schema in the handshake. +# This fixture metadata does not qualify an actual distribution or backend. +_schema = os.environ.get("LOOPX_FAKE_NOKV_PROTOCOL_SCHEMA") +if _schema: + WORKSPACE_PROTOCOL_SCHEMA = _schema + class RoutingConfig: - # Union of the two real wheels the helper is qualified against: the 0.11.x - # releases provide etcd/static, the metadata-runtimes line provides seeds. - @staticmethod - def seeds(endpoints: list[str]) -> object: - return ("seeds", endpoints) + # Independently omit the selected constructor to exercise a routing + # capability mismatch without removing the current publication fence. + if os.environ.get("LOOPX_FAKE_NOKV_RELEASE_SHAPE") != "1": + + @staticmethod + def seeds(endpoints: list[str]) -> object: + return ("seeds", endpoints) @staticmethod def etcd(endpoints: list[str], key_prefix: str, lease_ttl_seconds: int) -> object: