Skip to content
Draft
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
18 changes: 14 additions & 4 deletions examples/nokv-authority-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,19 @@ run is Stage 2A single-node storage conformance evidence only.
## Inputs

Use a current NoKV Python environment. Keep the client configuration in an
ignored local file; do not commit credentials. Static routing is valid for a
single-node NoKV deployment—etcd is not required by this probe. The following
shape is illustrative:
ignored local file; do not commit credentials. The helper accepts three routing
kinds and passes each to the matching `RoutingConfig` constructor of the
installed SDK: `etcd` and `static` (the 0.11.0 release wheel) and `seeds`
(`{"kind": "seeds", "endpoints": ["IP:PORT", ...]}`, the NoKV
metadata-runtimes line, which names serving owners directly and drops the etcd
constructor). Static routing is valid for a single-node NoKV deployment; etcd
is not required by this probe. A routing kind the installed wheel cannot build
fails the open handshake with `nokv_sdk_capability_mismatch` before any client
is constructed, so a seeds configuration against a 0.11.0 wheel (or an etcd
configuration against a metadata-runtimes wheel) is reported as the wrong
wheel, not as an outage. The `ready` handshake echoes `nokv_protocol_schema`:
the SDK's `WORKSPACE_PROTOCOL_SCHEMA` when the wheel exports one, otherwise
`null` (the 0.11.0 release does not). The following shape is illustrative:

```json
{
Expand Down Expand Up @@ -126,7 +136,7 @@ unfenced, pre-existing, or unreadable state exits nonzero with a compact JSON
reason; provider stderr, endpoints, credentials, and raw SDK errors are not
copied into that result. A successful JSON report includes
`"qualification_scope":"stage_2a_single_node_store_conformance"`,
`"nokv_sdk_version":"0.11.0"`, and `"nokv_api_version":1`. The two version
`"nokv_sdk_version":"0.11.0"`, `"nokv_api_version":1`, and `"nokv_protocol_schema"` (the wheel's `WORKSPACE_PROTOCOL_SCHEMA`, or `null` for the 0.11.0 release, which exports none). The two version
fields are the helper's admission constants: the helper refuses to open a client
for any other SDK version or API version, so a successful report implies them,
but they are not values read back from the NoKV server. The report is Stage
Expand Down
21 changes: 18 additions & 3 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,6 +74,8 @@ export interface QualificationReport {
availability_or_ha_proven: false;
nokv_sdk_version: typeof QUALIFIED_NOKV_SDK_VERSION;
nokv_api_version: typeof QUALIFIED_NOKV_API_VERSION;
/** `null` when the wheel exports no `WORKSPACE_PROTOCOL_SCHEMA` (0.11.0). */
nokv_protocol_schema: string | null;
}

export interface QualificationSequenceResult {
Expand Down Expand Up @@ -499,12 +504,16 @@ export async function exerciseQualificationSequence(
export async function qualifyNoKVAuthorityStore(
options: QualificationOptions,
): Promise<QualificationReport> {
const sequence = await exerciseQualificationSequence(options, async () =>
await NoKVJsonLinesTransport.open({
let protocolSchema: string | null = null;
const sequence = await exerciseQualificationSequence(options, async () => {
const transport = await NoKVJsonLinesTransport.open({
argv: qualificationHelperArgv(options.python_executable),
config: options.client_config,
request_timeout_ms: options.request_timeout_ms,
}));
});
if (protocolSchema === null) protocolSchema = transport.sdkProtocolSchema;
return transport;
});
return {
schema_version: REPORT_SCHEMA,
qualification_scope: QUALIFICATION_SCOPE,
Expand All @@ -515,6 +524,7 @@ export async function qualifyNoKVAuthorityStore(
availability_or_ha_proven: false,
nokv_sdk_version: QUALIFIED_NOKV_SDK_VERSION,
nokv_api_version: QUALIFIED_NOKV_API_VERSION,
nokv_protocol_schema: protocolSchema,
};
}

Expand Down Expand Up @@ -610,6 +620,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
7 changes: 5 additions & 2 deletions examples/nokv-shadow-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,11 @@ shared lifecycle scenarios (including renew, reclaim after grace, stale-fence
rejection, atomic completion/successor, competition, replay, lost response,
retention, and revision advancement) through the production
`CoordinationAuthorityExecutor` against the file-backed control provider and,
when `NOKV_COORDINATION_LIVE=1` and the stack variables are set, this NoKV
provider. One NoKV-only row performs a real commit/snapshot/restore and proves
when `NOKV_COORDINATION_LIVE=1`, the stack variables and exactly one routing
group are set (`NOKV_SEEDS` for a seed-routed owner, or `NOKV_ETCD` plus
`NOKV_ETCD_PREFIX` for the 0.11.0 etcd control path), this NoKV provider. A
routing kind the installed `nokv` wheel cannot build, or both groups set at
once, is reported as a typed unverified reason, not as a failed row. One NoKV-only row performs a real commit/snapshot/restore and proves
the restored lineage fails closed as `store_lineage_mismatch`. Without a
reachable stack the NoKV rows report unverified and the script stays green, so
it is evidence tooling, not a merge gate.
Expand Down
75 changes: 68 additions & 7 deletions examples/nokv-shadow-provider/live_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,15 +324,72 @@ def file_matrix(root: Path) -> dict:
)


def nokv_matrix() -> tuple[dict | None, str | None]:
NOKV_SEEDS_VARIABLE = "NOKV_SEEDS"
NOKV_ETCD_VARIABLES = ("NOKV_ETCD", "NOKV_ETCD_PREFIX")


def nokv_routing(nokv, env) -> tuple[object | None, dict | None, tuple[str, str] | None]:
"""Build the SDK routing from exactly one routing variable group.

``NOKV_SEEDS`` (comma-separated ``IP:PORT``) selects seed routing, the kind
of the NoKV metadata-runtimes line; ``NOKV_ETCD`` plus ``NOKV_ETCD_PREFIX``
selects etcd routing, the kind of the 0.11.0 release line. Both set is a
configuration error, and a kind the installed wheel cannot build is a
capability mismatch, never an outage. Returns ``(routing, sdk_facts,
None)`` or ``(None, None, (reason, reason_code))``; the facts carry counts
and kinds only, never endpoint values.
"""
seeds_raw = env.get(NOKV_SEEDS_VARIABLE)
etcd_complete = all(env.get(name) for name in NOKV_ETCD_VARIABLES)
if seeds_raw and etcd_complete:
return None, None, (
"NOKV_SEEDS and NOKV_ETCD/NOKV_ETCD_PREFIX are both set; choose one routing kind",
"nokv_routing_env_ambiguous",
)
if seeds_raw:
kind = "seeds"
seeds = [item.strip() for item in seeds_raw.split(",") if item.strip()]
arguments: tuple = (seeds,)
elif etcd_complete:
kind = "etcd"
arguments = ([env["NOKV_ETCD"]], env["NOKV_ETCD_PREFIX"], 10)
else:
return None, None, (
"neither NOKV_SEEDS nor NOKV_ETCD/NOKV_ETCD_PREFIX is set",
"nokv_routing_env_missing",
)
constructor = getattr(nokv.RoutingConfig, kind, None)
if not callable(constructor):
return None, None, (
f"the installed nokv SDK does not provide RoutingConfig.{kind}",
"nokv_sdk_capability_mismatch",
)
try:
routing = constructor(*arguments)
except (TypeError, ValueError):
return None, None, ("NoKV routing configuration is invalid", "nokv_routing_invalid")
schema = getattr(nokv, "WORKSPACE_PROTOCOL_SCHEMA", None)
facts = {
"version": getattr(nokv, "__version__", None),
"api_version": getattr(nokv, "API_VERSION", None),
"protocol_schema": schema if isinstance(schema, str) and schema else None,
"routing_kind": kind,
"seed_count": len(arguments[0]) if kind == "seeds" else None,
}
return routing, facts, None


def nokv_matrix() -> tuple[dict | None, tuple[str, str] | None, dict | None]:
if os.environ.get("NOKV_COORDINATION_LIVE") != "1":
return None, "NOKV_COORDINATION_LIVE unset"
return None, ("NOKV_COORDINATION_LIVE unset", "nokv_live_flag_unset"), None
try:
import nokv
except ImportError:
return None, "nokv SDK not installed"
return None, ("nokv SDK not installed", "nokv_sdk_missing"), None
env = os.environ
routing = nokv.RoutingConfig.etcd([env["NOKV_ETCD"]], env["NOKV_ETCD_PREFIX"], 10)
routing, sdk_facts, skip = nokv_routing(nokv, env)
if routing is None:
return None, skip, None

def make_client():
objects = nokv.ObjectStoreConfig.s3(
Expand Down Expand Up @@ -367,7 +424,7 @@ def make_provider(goal_id):
rows["restored_lineage_fails_closed"] = _restored_lineage_fails_closed(
make_client
)
return rows, None
return rows, None, sdk_facts


def _restored_lineage_fails_closed(make_client) -> bool:
Expand Down Expand Up @@ -435,11 +492,15 @@ def main() -> int:
matrix: dict[str, dict] = {}
with tempfile.TemporaryDirectory() as root:
matrix["file_provider"] = file_matrix(Path(root))
nokv_rows, skip_reason = nokv_matrix()
nokv_rows, skip, sdk_facts = nokv_matrix()
if nokv_rows is None:
matrix["nokv_provider"] = {"unverified": skip_reason}
assert skip is not None
reason, reason_code = skip
matrix["nokv_provider"] = {"unverified": reason, "reason_code": reason_code}
else:
matrix["nokv_provider"] = nokv_rows
# Wheel facts the ladder pins into its bindings: kinds and counts only.
matrix["nokv_sdk"] = sdk_facts or {}
shared = {
row: value
for row, value in nokv_rows.items()
Expand Down
6 changes: 3 additions & 3 deletions examples/shared-goal-authority-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ TypeScript store read.
| --- | --- | --- |
| `deterministic` | none (needs `node` on `PATH` for the CLI's TypeScript runtime and the read-back probe) | `node_missing` when the probe cannot run |
| `env:postgresql` | `LOOPX_TEST_POSTGRES_URL` plus `node_modules/pg` (`npm ci`) | `postgres_url_missing`, `pg_dependency_missing`, `node_missing` |
| `env:nokv_legacy` | `NOKV_COORDINATION_LIVE=1` and `NOKV_ETCD`, `NOKV_ETCD_PREFIX`, `NOKV_ROOT_ID`, `NOKV_BUCKET`, `NOKV_OBJECT_ENDPOINT`, `NOKV_OBJECT_ROOT`, `NOKV_OBJECT_KEY`, `NOKV_OBJECT_SECRET`; the `nokv` SDK importable | `nokv_live_env_missing`, `nokv_coordination_live_not_enabled`, `nokv_sdk_missing` |
| `env:nokv_legacy` | `NOKV_COORDINATION_LIVE=1`, `NOKV_ROOT_ID`, `NOKV_BUCKET`, `NOKV_OBJECT_ENDPOINT`, `NOKV_OBJECT_ROOT`, `NOKV_OBJECT_KEY`, `NOKV_OBJECT_SECRET`, plus exactly one routing group: `NOKV_SEEDS` (comma-separated `IP:PORT` owners, the NoKV metadata-runtimes line) or `NOKV_ETCD` + `NOKV_ETCD_PREFIX` (the 0.11.0 release line); the `nokv` SDK importable | `nokv_live_env_missing` (`missing_one_of` names both routing groups), `nokv_routing_env_ambiguous`, `nokv_sdk_capability_mismatch` (the installed wheel cannot build the configured routing kind), `nokv_sdk_missing`, `nokv_matrix_unverified` |
| `env:nokv_authority` | `LOOPX_NOKV_AUTHORITY_LIVE=1` (the probe writes durable test data), `LOOPX_NOKV_AUTHORITY_CONFIG_JSON` (absolute path to the ignored NoKV client configuration), `LOOPX_NOKV_AUTHORITY_PYTHON` (absolute path to the Python executable that resolves NoKV SDK 0.11.0), `LOOPX_NOKV_AUTHORITY_WORKBENCH` (an existing workbench); `node` on `PATH` | `nokv_authority_env_missing`, `loopx_nokv_authority_live_not_enabled`, `nokv_authority_config_missing`, `nokv_authority_python_missing`, `node_missing` |

POSIX-only rows report `unverified/posix_only` on Windows.
Expand All @@ -96,8 +96,8 @@ The report schema is `loopx_shared_goal_authority_e2e_report_v0`:
`rows[]` (`status in {pass, fail, unverified}`, `reason_code`, public-safe
`evidence`, `duration_ms`), `pending[]`, `summary{pass, fail, unverified,
pending, executed, privacy_violations}`, `bindings{loopx_commit, loopx_tree_dirty, probe_sha256[],
nokv_client_config_sha256, nokv_sdk_version, postgres_url_sha256_prefix,
pg_package_version}` (`null` when unknown), and `exit_policy`.
nokv_client_config_sha256, nokv_sdk_version, nokv_protocol_schema,
nokv_routing_group, postgres_url_sha256_prefix, pg_package_version}` (`null` when unknown), and `exit_policy`.

Exit code is `0` iff `fail == 0` and `privacy_violations == 0` and
(`unverified == 0` or `--allow-unverified`) and (`pending == 0` or
Expand Down
58 changes: 52 additions & 6 deletions loopx/control_plane/coordination/nokv_jsonl_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@
}
)
_ETCD_ROUTING_KEYS = frozenset({"kind", "endpoints", "key_prefix", "lease_ttl_seconds"})
# Seed routing names one or more serving NoKV owners directly (numeric IP:port);
# the SDK rejects hostnames, empty lists and unspecified ports itself.
_SEEDS_ROUTING_KEYS = frozenset({"kind", "endpoints"})
# Every routing kind this helper can express. A kind outside this set is a
# configuration error; a kind inside it that the installed SDK cannot build is
# a capability mismatch between the wheel and the configuration.
_ROUTING_KINDS = frozenset({"etcd", "seeds", "static"})
_STATIC_ROUTING_KEYS = frozenset(
{
"kind",
Expand Down Expand Up @@ -67,6 +74,14 @@ class RequestError(ValueError):
"""The JSON-lines caller violated the raw storage protocol."""


class SdkCapabilityMismatch(RequestError):
"""The installed NoKV SDK lacks the constructor this configuration needs.

Raised only after the configuration itself validated, so the caller can
tell "wrong wheel for this routing kind" apart from "invalid config".
"""


class ProviderProtocolError(RuntimeError):
"""The NoKV SDK returned a shape that violates its reviewed contract."""

Expand Down Expand Up @@ -420,6 +435,8 @@ def build_client(config_value: object) -> Any:
_require_exact_keys(config, _CONFIG_KEYS, "config")
routing_value = _mapping(config.get("routing"), "routing")
routing_kind = _required_string(routing_value, "kind")
if routing_kind not in _ROUTING_KINDS:
raise RequestError(f"unsupported routing kind {routing_kind!r}")
if routing_kind == "etcd":
_require_exact_keys(routing_value, _ETCD_ROUTING_KEYS, "routing")
routing_arguments: tuple[object, ...] = (
Expand All @@ -443,7 +460,8 @@ def build_client(config_value: object) -> Any:
_generation(routing_value.get("owner_epoch"), "owner_epoch"),
)
else:
raise RequestError(f"unsupported routing kind {routing_kind!r}")
_require_exact_keys(routing_value, _SEEDS_ROUTING_KEYS, "routing")
routing_arguments = (_string_list(routing_value, "endpoints"),)

object_value = _mapping(config.get("object_store"), "object_store")
object_kind = _required_string(object_value, "kind")
Expand Down Expand Up @@ -516,12 +534,17 @@ def build_client(config_value: object) -> Any:
except AttributeError as error:
raise RequestError("the NoKV Python SDK surface is incomplete") from error

try:
routing = (
RoutingConfig.etcd(*routing_arguments)
if routing_kind == "etcd"
else RoutingConfig.static(*routing_arguments)
# The kind was checked against _ROUTING_KINDS above, so this attribute
# lookup never reaches an arbitrary caller-chosen name. The 0.11.0 release
# wheel provides etcd/static; the metadata-runtimes line provides seeds.
routing_constructor = getattr(RoutingConfig, routing_kind, None)
if not callable(routing_constructor):
raise SdkCapabilityMismatch(
"the installed NoKV Python SDK does not provide "
f"RoutingConfig.{routing_kind}"
)
try:
routing = routing_constructor(*routing_arguments)
except (TypeError, ValueError) as error:
raise RequestError("NoKV routing configuration is invalid") from error
try:
Expand Down Expand Up @@ -550,6 +573,19 @@ def build_client(config_value: object) -> Any:
raise RequestError("NoKV client configuration is invalid") from error


def _sdk_protocol_schema() -> str | None:
"""Return the wire schema the imported SDK declares, if it declares one.

The 0.11.0 release wheel has no such attribute; newer wheels export
``WORKSPACE_PROTOCOL_SCHEMA`` so a deployment can compare it against the
server before trusting a nominally equal ``__version__``.
"""
schema = getattr(sys.modules.get("nokv"), "WORKSPACE_PROTOCOL_SCHEMA", None)
if isinstance(schema, str) and schema and schema.strip() == schema:
return schema
return None


def main() -> int:
first = sys.stdin.readline()
try:
Expand All @@ -561,6 +597,15 @@ def main() -> int:
client = build_client(values.get("config"))
except json.JSONDecodeError as error:
result = _failure(None, "failed", "invalid_json", error)
except SdkCapabilityMismatch as error:
result = _failure(
_request_id(value.get("request_id"))
if isinstance(value, Mapping)
else None,
"failed",
"nokv_sdk_capability_mismatch",
error,
)
except RequestError as error:
result = _failure(
_request_id(value.get("request_id"))
Expand Down Expand Up @@ -592,6 +637,7 @@ def main() -> int:
"ready",
nokv_sdk_version=QUALIFIED_NOKV_SDK_VERSION,
nokv_api_version=QUALIFIED_NOKV_API_VERSION,
nokv_protocol_schema=_sdk_protocol_schema(),
),
sort_keys=True,
separators=(",", ":"),
Expand Down
Loading
Loading