Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions examples/nokv-authority-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
26 changes: 26 additions & 0 deletions examples/nokv-authority-store/live-qualification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -48,6 +49,8 @@ export class QualificationFailure extends Error {

export interface QualificationTransport extends NoKVBlobTransport {
close(): Promise<void>;
/** Wire schema declared by the helper's SDK; absent or `null` when unknown. */
readonly sdkProtocolSchema?: string | null;
}

export interface QualificationOptions {
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -661,6 +682,11 @@ async function main(): Promise<number> {
} 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";
Expand Down
2 changes: 1 addition & 1 deletion examples/shared-goal-authority-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
38 changes: 33 additions & 5 deletions loopx/control_plane/coordination/nokv_jsonl_transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/testing/authority_e2e_ladder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 32 additions & 1 deletion tests/control_plane_ts/nokv_jsonl_transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -37,6 +37,7 @@ const FAKE_INCARNATION = "a".repeat(32);
async function openSdkHelper(
routing: Record<string, unknown> = ETCD_ROUTING,
fakeSdkShape?: "0.11.0" | "0.11.1-unfenced",
extraEnv: Record<string, string> = {},
) {
return await NoKVJsonLinesTransport.open({
argv: [PYTHON, SDK_HELPER],
Expand All @@ -47,6 +48,7 @@ async function openSdkHelper(
},
env: {
...process.env,
...extraEnv,
PYTHONPATH: process.env.PYTHONPATH
? `${FAKE_SDK_ROOT}:${process.env.PYTHONPATH}`
: FAKE_SDK_ROOT,
Expand Down Expand Up @@ -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"] }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NoKVStoreIdentityResult> {
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 13 additions & 5 deletions tests/fixtures/nokv_fake_sdk/nokv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading