Skip to content

Commit be7789f

Browse files
wchwawahuangruiteng
authored andcommitted
feat(coordination): fence NoKV authority publication on the workbench incarnation
Every NoKVAuthorityStore publication now names the workbench incarnation the envelope was read from (`expected_workspace_incarnation_id`). NoKV 0.11.1 evaluates that fence atomically with the generation before any durable row or object exists and refuses a stale incarnation with a typed exception, so a workbench restored to a new incarnation between the read and the publish refuses the write instead of accepting it at a restarted generation. The JSON-lines helper maps that refusal to `failed/store_identity_mismatch` (the same vocabulary the PostgreSQL service uses for a stale incarnation) and keeps a refusal that names a different fence, or an untyped RuntimeError, on the ambiguous path. Successful publications are still accepted only after the current-incarnation readback required by RFC 6.2. The helper pins NoKV SDK 0.11.1 / API 1 and admits only a wheel whose `Client.publish_bytes` names the fence parameter and whose module exports `WorkspaceIncarnationMismatch`; a 0.11.1-labelled wheel without that surface is refused as `nokv_sdk_capability_mismatch` before any client is constructed, and the request path rejects a publication without a valid fence before the SDK call. The Stage 2A live probe gains two checks that publish the generation-1 envelope with a stale fence and prove the typed refusal, the unchanged generation and the unchanged workbench identity; the ladder row `s2a.nokv_live_qualification` requires both checks and the 0.11.1 pin. Tests: helper unit tests for the typed refusal, the fence validation and the admission matrix; a fake SDK fixture with 0.11.1, 0.11.0 and 0.11.1-unfenced shapes; transport tests through the real helper process for the refusal and both admission rejections; store tests for the fence on every publication, the refused incarnation race and the fence-ignoring owner; harness tests for the new checks and a fence-ignoring backend; a pin consistency test across the helper, ladder and probe. Signed-off-by: wchwawa <wch19961116@gmail.com>
1 parent 6a3caaf commit be7789f

13 files changed

Lines changed: 716 additions & 118 deletions

File tree

‎examples/nokv-authority-store/README.md‎

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,23 @@ This probe intentionally exercises the current Python SDK bridge because it is
1717
the available raw byte-CAS seam. It always starts this checkout's reviewed
1818
`NoKVJsonLinesTransport` and `nokv_jsonl_helper.py`; it has no fake, skip, or
1919
"unverified but successful" CLI path. The helper admits exactly NoKV SDK
20-
`0.11.0` / Python API `1`, and the successful report repeats both values.
20+
`0.11.1` / Python API `1` whose `Client.publish_bytes` names
21+
`expected_workspace_incarnation_id` and whose module exports
22+
`WorkspaceIncarnationMismatch`; the successful report repeats both version
23+
values. A `0.11.0` wheel is refused by the version pin (`invalid_config`), and a
24+
wheel labelled `0.11.1` without that publication surface is refused as
25+
`nokv_sdk_capability_mismatch` before any client is constructed.
2126

2227
## What it proves
2328

2429
Against one **already existing** NoKV workbench, the probe starts three
2530
independent helper processes and verifies:
2631

2732
- the selected tenant/goal path is initially absent and can be created;
33+
- a publication of the generation-1 envelope fenced on a stale workbench
34+
incarnation is refused typed (`store_identity_mismatch`) before any row or
35+
object exists: the stored generation stays 1 and the workbench identity is
36+
unchanged (every LoopX publication carries the incarnation it was read from);
2837
- the stored path generation advances from 1 to 2 under exact generation CAS;
2938
- after the generation-2 CAS lands, an injected response loss is reconciled
3039
from the durable authority envelope and operation receipt rather than from
@@ -42,30 +51,34 @@ If the SDK, helper, workbench, backend, CAS, or independent readback cannot be
4251
proved, the process exits nonzero. The normal test suite uses deterministic
4352
fakes only to test this sequence and does **not** count as live evidence.
4453

45-
The probe does not prove an atomic expected-incarnation publication fence,
46-
runtime shadow parity, a multi-Agent canary, authority promotion, HA, failover,
47-
restart recovery, capacity, or performance. NoKV generation can restart after
48-
workbench recreation, so the current adapter fails closed through authoritative
49-
post-write readback; preventing the stale-incarnation write itself requires a
50-
future provider primitive. The probe also does not create a workbench. A green
51-
run is Stage 2A single-node storage conformance evidence only.
54+
The probe proves the expected-incarnation publication fence only as far as a
55+
single live owner can show it: a stale fence is refused typed and leaves the
56+
generation untouched. It does not restore or recreate the workbench (NoKV
57+
exposes no client-side retire verb), so the incarnation rotation itself is
58+
covered by NoKV's own executor tests, not by this probe. Success is still
59+
accepted only after authoritative post-write readback in the current
60+
incarnation: the fence removes the stale write, not the readback obligation.
61+
The probe does not prove runtime shadow parity, a multi-Agent canary, authority
62+
promotion, HA, failover, restart recovery, capacity, or performance, and it does
63+
not create a workbench. A green run is Stage 2A single-node storage conformance
64+
evidence only.
5265

5366
## Inputs
5467

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

7083
```json
7184
{
@@ -136,7 +149,7 @@ unfenced, pre-existing, or unreadable state exits nonzero with a compact JSON
136149
reason; provider stderr, endpoints, credentials, and raw SDK errors are not
137150
copied into that result. A successful JSON report includes
138151
`"qualification_scope":"stage_2a_single_node_store_conformance"`,
139-
`"nokv_sdk_version":"0.11.0"`, and `"nokv_api_version":1`. The two version
152+
`"nokv_sdk_version":"0.11.1"`, and `"nokv_api_version":1`. The two version
140153
fields are the helper's admission constants: the helper refuses to open a client
141154
for any other SDK version or API version, so a successful report implies them,
142155
but they are not values read back from the NoKV server. The report is Stage

‎examples/nokv-authority-store/live-qualification.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030

3131
const REPORT_SCHEMA = "loopx_nokv_authority_live_qualification_v0";
3232
export const QUALIFICATION_SCOPE = "stage_2a_single_node_store_conformance";
33-
export const QUALIFIED_NOKV_SDK_VERSION = "0.11.0";
33+
export const QUALIFIED_NOKV_SDK_VERSION = "0.11.1";
3434
export const QUALIFIED_NOKV_API_VERSION = 1;
3535
const REPOSITORY_HELPER = fileURLToPath(
3636
new URL("../../loopx/control_plane/coordination/nokv_jsonl_helper.py", import.meta.url),
@@ -266,6 +266,16 @@ function applied(
266266
return result;
267267
}
268268

269+
/** A 32-hex incarnation that differs from `current` in its first digit. */
270+
function staleIncarnation(current: string): string {
271+
return `${current.startsWith("0") ? "1" : "0"}${current.slice(1)}`;
272+
}
273+
274+
/** A fresh lower-layer publication identity; the probe never reuses one. */
275+
function freshPhysicalIdentity(): string {
276+
return randomUUID().replaceAll("-", "");
277+
}
278+
269279
async function rawGeneration(
270280
transport: NoKVBlobTransport,
271281
store: NoKVAuthorityStore,
@@ -361,6 +371,47 @@ export async function exerciseQualificationSequence(
361371
await rawGeneration(firstTransport, first, 1, "create_generation_failed");
362372
passed("create_generation_one");
363373

374+
// A write prepared against one workbench incarnation must not land after
375+
// the workbench is restored to another. Send the generation-1 envelope back
376+
// through the raw transport with a fence naming a different incarnation:
377+
// NoKV must refuse it typed, before any row or object exists, and the
378+
// stored generation and the workbench identity must both be unchanged.
379+
const boundIdentity = firstIdentity.status === "available"
380+
? firstIdentity.store_identity
381+
: fail("workbench_identity_failed", "workbench identity was not available");
382+
const currentEnvelope = await firstTransport.readBlob(first.workbench, first.path);
383+
expect(
384+
currentEnvelope.status === "loaded" && currentEnvelope.generation === 1,
385+
"stale_incarnation_fence_probe_failed",
386+
"the generation-1 envelope could not be read for the fence probe",
387+
);
388+
const staleFence = await firstTransport.casPublishBlob({
389+
workbench: first.workbench,
390+
path: first.path,
391+
expected_generation: 1,
392+
expected_workspace_incarnation_id: staleIncarnation(
393+
boundIdentity.slice(`nokv:${workbench}:`.length),
394+
),
395+
bytes: currentEnvelope.bytes,
396+
operation_id: freshPhysicalIdentity(),
397+
artifact_revision_id: freshPhysicalIdentity(),
398+
});
399+
expect(
400+
staleFence.status === "failed" && staleFence.reason_code === "store_identity_mismatch",
401+
"stale_incarnation_fence_not_enforced",
402+
"NoKV did not refuse a publication fenced on a stale workbench incarnation",
403+
);
404+
passed("stale_incarnation_fence_rejected");
405+
const identityAfterFence = await first.storeIdentity();
406+
expect(
407+
identityAfterFence.status === "available" &&
408+
identityAfterFence.store_identity === boundIdentity,
409+
"stale_incarnation_fence_probe_failed",
410+
"the workbench incarnation changed during the fence probe",
411+
);
412+
await rawGeneration(firstTransport, first, 1, "stale_incarnation_fence_wrote");
413+
passed("stale_incarnation_fence_left_generation_unchanged");
414+
364415
const advanced = applied(
365416
await second.commitAuthority(
366417
commit(created.provider_revision, runId, operationIds.advance, 2, "advance"),

‎examples/shared-goal-authority-e2e/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ suites rather than in the pytest shards.
3535
| `s0.file_matrix_twelve_rows` | 0 | store_direct | deterministic | `examples/nokv-shadow-provider/live_e2e.py` reports exactly the twelve known file-provider scenario rows, all true |
3636
| `s0.nokv_live_matrix` | 0 | store_direct | env:nokv_legacy | the same twelve rows plus `restored_lineage_fails_closed` are true on a live NoKV stack and file/NoKV outcomes are identical |
3737
| `s1.cli_document_decodes_through_ts_store` | 1 | real_cli | deterministic | three CLI writes (`todo add`, `task-lease acquire`, `todo update`) read back through `FileAuthorityStore`: `loadAuthority` loaded at cursor `3`, paged `scanCommitted` yields the three `observation_id`s in order, `readReceipt` finds the first |
38-
| `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.0` / API `1`, 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 |
38+
| `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 |
3939
| `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` |
4040
| `s2c1.configure_enable_disable_roundtrip` | 2c1 | real_cli | deterministic | `configure-goal` preview does not write, enable writes, captured observations for a todo and a lease, read-back summary `enabled/file_one_way`, disable writes and later writes neither observe nor touch candidate bytes |
4141
| `s2c1.every_writer_family_captures` | 2c1 | real_cli | deterministic | handoff-mode set, todo add/update/complete/supersede/archive-completed, task-lease acquire/renew/transfer each carry `outcome in {captured, replayed, ambiguous_reconciled}`, `primary_writeback_preserved=true`, `provider_to_local_writes=false`, `candidate_read_for_decision=false`; an idempotent re-acquire carries no `authority_shadow`; candidate `cursor == captured count`, operation ids equal observation ids, no time-active lease in the head, head todos equal `todo list` |
@@ -86,7 +86,7 @@ TypeScript store read.
8686
| `deterministic` | none (needs `node` on `PATH` for the CLI's TypeScript runtime and the read-back probe) | `node_missing` when the probe cannot run |
8787
| `env:postgresql` | `LOOPX_TEST_POSTGRES_URL` plus `node_modules/pg` (`npm ci`) | `postgres_url_missing`, `pg_dependency_missing`, `node_missing` |
8888
| `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` |
89-
| `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` |
89+
| `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.1), `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` |
9090

9191
POSIX-only rows report `unverified/posix_only` on Windows.
9292

‎loopx/control_plane/coordination/nokv_authority_store.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export interface NoKVBlobCasRequest {
5454
bytes: Uint8Array;
5555
operation_id: string;
5656
artifact_revision_id: string;
57+
/**
58+
* The workbench incarnation this publication is bound to. NoKV evaluates it
59+
* atomically with `expected_generation` before any durable row or object
60+
* exists; a stale value is refused as `failed/store_identity_mismatch`.
61+
*/
62+
expected_workspace_incarnation_id: string;
5763
}
5864

5965
export type NoKVBlobCasResult =
@@ -186,6 +192,11 @@ function readFailure(error: unknown): AuthorityStoreReadFailure {
186192
};
187193
}
188194

195+
/** The incarnation half of a validated `nokv:{workbench}:{incarnation}` identity. */
196+
function boundIncarnation(storeIdentity: string, workbench: string): string {
197+
return storeIdentity.slice(`nokv:${workbench}:`.length);
198+
}
199+
189200
function validStoreIdentity(value: string, workbench: string): boolean {
190201
const prefix = `nokv:${workbench}:`;
191202
return value.startsWith(prefix) && HEX_128_PATTERN.test(value.slice(prefix.length));
@@ -402,13 +413,18 @@ export class NoKVAuthorityStore implements AuthorityStore {
402413
// attempt. Keep the LoopX operation id stable in the authority envelope,
403414
// while giving each physical retry a fresh pair of lower-layer ids. A
404415
// response-lost success is still settled only by reading that envelope.
416+
// The request also names the incarnation the envelope was read from, so a
417+
// workbench restored to a new incarnation between this read and the
418+
// publish refuses the write instead of accepting it at a restarted
419+
// generation.
405420
const attemptNonce = randomUUID();
406421
let result: NoKVBlobCasResult;
407422
try {
408423
result = await this.transport.casPublishBlob({
409424
workbench: this.workbench,
410425
path: this.path,
411426
expected_generation: expectedGeneration,
427+
expected_workspace_incarnation_id: boundIncarnation(current.identity, this.workbench),
412428
bytes: payload,
413429
operation_id: physicalAttemptIdentity(
414430
"operation",
@@ -433,11 +449,11 @@ export class NoKVAuthorityStore implements AuthorityStore {
433449
};
434450
}
435451
if (result.status === "applied" && result.generation === generation) {
436-
// Generation is not a workbench-incarnation fence: NoKV may restart it
437-
// after remove/recreate. Never expose success until a fresh read proves
438-
// this exact transaction in the current incarnation. Preventing the
439-
// stale-incarnation write itself still requires an atomic provider
440-
// primitive that accepts the expected incarnation.
452+
// The incarnation fence removes the stale write, not the readback
453+
// obligation: success is exposed only after a fresh read proves this
454+
// exact transaction in the current incarnation (RFC §6.2), so an owner
455+
// that ignored the fence still cannot make a restarted generation look
456+
// like a LoopX commit.
441457
return await this.settleCommitFromReadback(
442458
normalized.expected_provider_revision,
443459
transaction,

0 commit comments

Comments
 (0)