Skip to content
Closed
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
29 changes: 29 additions & 0 deletions src/lib/state/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# Local state durability

State modules own persisted NemoClaw files and must fail closed when durable state is missing,
malformed, or from an unsupported schema version.

The rebuild transaction store publishes a fully synced candidate from the destination directory.
Initial publication uses a same-directory hard link for atomic create-only semantics; update
publication uses rename. A copy fallback is intentionally excluded because it cannot retain the
same no-replace guarantee for competing creators. An unexpected cross-device error is therefore an
invariant failure, not a recoverable publication mode.

Transaction filenames are deterministic SHA-256 hashes of validated sandbox names. The hash is a
stable traversal-safe key, not a confidentiality boundary; transaction contents and names remain
protected by the `0700` state directory and `0600` record mode. Backup receipts accept canonical
millisecond UTC ISO timestamps and the filename-safe dashed form emitted by backup generation. The
dashed form is validated by a bijective conversion to canonical UTC ISO time and can be removed if
backup manifests later standardize on a filename-safe encoding separate from their timestamp.

After publication, the store calls `fsync` on the containing directory. Common Linux filesystems
use this to persist the directory entry, but exact guarantees remain filesystem and device
specific. On macOS, Node.js does not expose `F_FULLFSYNC`; strict persistence through sudden power
loss is unsupported and best-effort until Node exposes that primitive or NemoClaw adopts a native
adapter. Atomic visibility and fail-closed validation still apply on macOS.

Mutation reentrancy is limited to nested calls in one process. Independent processes always
contend on the per-sandbox filesystem lifecycle lock before revision validation and publication.
73 changes: 73 additions & 0 deletions src/lib/state/rebuild-transaction-concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it } from "vitest";

import {
advanceToReplacement,
cleanupRebuildTransactionTests,
deletedReceipts,
intent,
makeStore,
preparedReceipts,
SANDBOX,
} from "../../../test/helpers/rebuild-transaction-store";
import type { RebuildTransactionRecordV1, RebuildTransactionStore } from "./rebuild-transaction";

async function expectOneRevisionWinner(
store: RebuildTransactionStore,
operations: [Promise<RebuildTransactionRecordV1>, Promise<RebuildTransactionRecordV1>],
): Promise<void> {
const results = await Promise.allSettled(operations);
const fulfilled = results.filter(
(result): result is PromiseFulfilledResult<RebuildTransactionRecordV1> =>
result.status === "fulfilled",
);
const rejected = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);

expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect(rejected[0]?.reason).toMatchObject({ code: "REVISION_CONFLICT" });
expect(store.load(SANDBOX)).toEqual(fulfilled[0]?.value);
}

afterEach(cleanupRebuildTransactionTests);

describe("RebuildTransactionStore concurrency", () => {
it("serializes competing transitions before revision validation", async () => {
const { store } = makeStore();
const prepared = await store.create(intent(), preparedReceipts());

await expectOneRevisionWinner(store, [
store.transition(SANDBOX, prepared.revision, "old_deleted", deletedReceipts()),
store.transition(SANDBOX, prepared.revision, "old_deleted", deletedReceipts()),
]);
});

it("serializes competing failure records before revision validation", async () => {
const { store } = makeStore();
const prepared = await store.create(intent(), preparedReceipts());
const failure = (code: string) => ({
code,
recordedAt: "2026-07-08T00:00:30.000Z",
retryable: true,
});

await expectOneRevisionWinner(store, [
store.recordFailure(SANDBOX, prepared.revision, failure("FIRST_WRITER")),
store.recordFailure(SANDBOX, prepared.revision, failure("SECOND_WRITER")),
]);
});

it("serializes competing completions before revision validation", async () => {
const { store } = makeStore();
const replacement = await advanceToReplacement(store);

await expectOneRevisionWinner(store, [
store.complete(SANDBOX, replacement.revision),
store.complete(SANDBOX, replacement.revision),
]);
});
});
138 changes: 138 additions & 0 deletions src/lib/state/rebuild-transaction-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import { afterEach, describe, it } from "vitest";

import {
advanceToReplacement,
cleanupRebuildTransactionTests,
deletedReceipts,
expectCode,
intent,
makeStore,
preparedReceipts,
SANDBOX,
writeRawRecord,
} from "../../../test/helpers/rebuild-transaction-store";
import { getRebuildTransactionPath } from "./rebuild-transaction";

afterEach(cleanupRebuildTransactionTests);

describe("RebuildTransactionStore validation", () => {
it.each([
"2026-07-08T00:00:00.000Z",
"2026-07-08T00-00-00-000Z",
])("accepts the documented backup timestamp shape %s", async (manifestTimestamp) => {
const { store } = makeStore();
await store.create(intent(), {
backup: { ...preparedReceipts().backup, manifestTimestamp },
});
});

it("fails closed for malformed JSON and unknown future versions", async () => {
const { stateDir, store } = makeStore();
await store.create(intent(), preparedReceipts());
const filePath = getRebuildTransactionPath(SANDBOX, stateDir);

writeRawRecord(filePath, (record) => {
record.version = 2;
});
await expectCode(() => store.load(SANDBOX), "UNSUPPORTED_VERSION");

fs.writeFileSync(filePath, "{not-json", { mode: 0o600 });
await expectCode(() => store.load(SANDBOX), "CORRUPT");
});

it.each([
["transaction ID", (record: Record<string, unknown>) => (record.transactionId = "bad")],
["revision", (record: Record<string, unknown>) => (record.revision = -1)],
["phase/status", (record: Record<string, unknown>) => (record.status = "completed")],
[
"legacy recovery authorization",
(record: Record<string, unknown>) => {
const value = record.intent as Record<string, Record<string, unknown>>;
value.source!.legacyManagedImageRecoveryAuthorized = "yes";
},
],
[
"source shields posture",
(record: Record<string, unknown>) => {
const value = record.intent as Record<string, Record<string, unknown>>;
value.source!.shieldsLocked = "yes";
},
],
[
"credential environment variable",
(record: Record<string, unknown>) => {
const value = record.intent as Record<string, Record<string, unknown>>;
value.target!.credentialEnv = "not-an-env-name";
},
],
["timestamp", (record: Record<string, unknown>) => (record.updatedAt = "2026-07-08")],
[
"backup timestamp",
(record: Record<string, unknown>) => {
const receipts = record.receipts as Record<string, unknown>;
(receipts.backup as Record<string, unknown>).manifestTimestamp = "2026-07-08T00:00:00Z";
},
],
[
"old-sandbox deletion receipt",
(record: Record<string, unknown>) => {
(record.receipts as Record<string, unknown>).oldSandboxDeletion = "bad";
},
],
[
"replacement receipt",
(record: Record<string, unknown>) => {
(record.receipts as Record<string, unknown>).replacement = "bad";
},
],
])("rejects a malformed %s", async (_label, mutate) => {
const { stateDir, store } = makeStore();
await store.create(intent(), preparedReceipts());
writeRawRecord(getRebuildTransactionPath(SANDBOX, stateDir), mutate);
await expectCode(() => store.load(SANDBOX), "CORRUPT");
});

it("rejects replacement evidence timestamped before deletion", async () => {
const { stateDir, store } = makeStore();
await advanceToReplacement(store);
writeRawRecord(getRebuildTransactionPath(SANDBOX, stateDir), (record) => {
const receipts = record.receipts as Record<string, Record<string, unknown>>;
receipts.replacement!.observedAt = "2026-07-08T00:00:30.000Z";
});
await expectCode(() => store.load(SANDBOX), "CORRUPT");
});

it("classifies malformed mutation arguments separately from corrupt state", async () => {
const { store } = makeStore();
await expectCode(
() =>
store.create(
intent({ target: { ...intent().target, credentialEnv: "bad-name" } }),
preparedReceipts(),
),
"INVALID_INPUT",
);
const prepared = await store.create(intent(), preparedReceipts());
await expectCode(
() =>
store.transition(SANDBOX, prepared.revision, "old_deleted", {
...deletedReceipts(),
oldSandboxDeletion: { observedAt: "2026-07-08" },
}),
"INVALID_TRANSITION",
);
await expectCode(
() =>
store.recordFailure(SANDBOX, prepared.revision, {
code: "bad-code",
recordedAt: "2026-07-08T00:00:30.000Z",
retryable: true,
}),
"INVALID_INPUT",
);
});
});
Loading
Loading