Heimdall/tests/process-write-lease.test.ts

156 lines
5.7 KiB
TypeScript
Raw Normal View History

Retract the CultLib divergence claim; read each .cc with the right client I claimed this morning that cultcache-ts could not read a cultcache-rs store and that the CultLib forks had diverged. That was false, and it was false in the usual way: generalised from one file. cultcache-ts reads Idunn's live control store on yggdrasil directly -- 36 records, no complaint. Ordinary CultCache stores use the same framing in both runtimes, and that covers the control store, the runtime bundle's expected.cc and activation.cc, and the process write lease. The exception is narrow and deliberate: service identity private stores are written by cultnet-rs's atomic_create_private_store as a single bare positional envelope, because they are a private-key container rather than a record store. The consequence was a live bug. Both readers I wrote decoded the private-store framing, so the write lease and expected-incarnation readers would have failed on the first real deployment -- with green tests, because I had built the fixtures the same wrong way. process-write-lease.ts now uses the CultCache client, provider-health-identity.ts keeps the bare reader for the fd-passed identity where it belongs, and the lease fixtures are written with the client so a test can disagree with me. Also corrected the write-up in Idunn/docs/migration.md and the memory that recorded the false finding. Three places had it wrong; leaving any of them would have taught the next session the same mistake. 54 pass, 1 skipped, 12 files; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:11:19 +02:00
import { mkdtemp, mkdir } from "node:fs/promises";
Honour the Idunn process write lease, and stop claiming active while warming During a deployment two Heimdall processes exist at once under distinct UIDs and namespaces, and both are configured with the same CultCache store path. Exactly one may write it. Idunn decides which by publishing a lease record naming an incarnation; nothing in Heimdall read it, so both generations would have written heimdall.service.cc. src/process-write-lease.ts answers one question -- may this process write state right now -- by reading the lease and the runtime bundle's expected.cc, and comparing incarnation identity. It grants nothing and throws nothing outward: every failure is a decision of mayWrite:false with a reason, because for a warming candidate "not yet my turn" is the expected condition and crashing would turn it into a failed deployment. Records are decoded as the canonical positional MessagePack Idunn writes (14 fields for the lease, schema-checked), and the tests build the same envelope bytes rather than convenient objects. publishHeimdallVerseState is the single writer, so that is the only place gated. Outside Idunn no lease path is configured and writes proceed unconditionally, which keeps developer runs and the current plain unit working. Second defect found while wiring it: health publication hardcoded state "active". A candidate would have claimed active from its first pulse, which is both untrue and structurally broken -- Idunn must observe `warming` presence before it fences the incumbent, so a candidate that never reports warming cannot be promoted. State is now derived from the lease: warming until held, active after, with the reason carried in the detail string. 49 tests across 11 files; tsc clean under exactOptionalPropertyTypes. The recipe's status banner now records the contract as implemented, with the honest caveat that none of it has been through a live `idunn up` -- the daemon on yggdrasil is still the previous generation, so the first deployment remains the real verification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:25:38 +02:00
import os from "node:os";
import path from "node:path";
import { encode } from "@msgpack/msgpack";
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
Honour the Idunn process write lease, and stop claiming active while warming During a deployment two Heimdall processes exist at once under distinct UIDs and namespaces, and both are configured with the same CultCache store path. Exactly one may write it. Idunn decides which by publishing a lease record naming an incarnation; nothing in Heimdall read it, so both generations would have written heimdall.service.cc. src/process-write-lease.ts answers one question -- may this process write state right now -- by reading the lease and the runtime bundle's expected.cc, and comparing incarnation identity. It grants nothing and throws nothing outward: every failure is a decision of mayWrite:false with a reason, because for a warming candidate "not yet my turn" is the expected condition and crashing would turn it into a failed deployment. Records are decoded as the canonical positional MessagePack Idunn writes (14 fields for the lease, schema-checked), and the tests build the same envelope bytes rather than convenient objects. publishHeimdallVerseState is the single writer, so that is the only place gated. Outside Idunn no lease path is configured and writes proceed unconditionally, which keeps developer runs and the current plain unit working. Second defect found while wiring it: health publication hardcoded state "active". A candidate would have claimed active from its first pulse, which is both untrue and structurally broken -- Idunn must observe `warming` presence before it fences the incumbent, so a candidate that never reports warming cannot be promoted. State is now derived from the lease: warming until held, active after, with the reason carried in the detail string. 49 tests across 11 files; tsc clean under exactOptionalPropertyTypes. The recipe's status banner now records the contract as implemented, with the honest caveat that none of it has been through a live `idunn up` -- the daemon on yggdrasil is still the previous generation, so the first deployment remains the real verification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:25:38 +02:00
import { describe, expect, it } from "vitest";
import { resolveWriteLease } from "../src/process-write-lease.js";
/**
* Idunn writes CultCache envelopes whose payload is the canonical positional
* MessagePack encoding of the record. These helpers reproduce that shape, so
* the tests exercise the same bytes the daemon will actually be handed rather
* than a convenient object.
*/
Retract the CultLib divergence claim; read each .cc with the right client I claimed this morning that cultcache-ts could not read a cultcache-rs store and that the CultLib forks had diverged. That was false, and it was false in the usual way: generalised from one file. cultcache-ts reads Idunn's live control store on yggdrasil directly -- 36 records, no complaint. Ordinary CultCache stores use the same framing in both runtimes, and that covers the control store, the runtime bundle's expected.cc and activation.cc, and the process write lease. The exception is narrow and deliberate: service identity private stores are written by cultnet-rs's atomic_create_private_store as a single bare positional envelope, because they are a private-key container rather than a record store. The consequence was a live bug. Both readers I wrote decoded the private-store framing, so the write lease and expected-incarnation readers would have failed on the first real deployment -- with green tests, because I had built the fixtures the same wrong way. process-write-lease.ts now uses the CultCache client, provider-health-identity.ts keeps the bare reader for the fd-passed identity where it belongs, and the lease fixtures are written with the client so a test can disagree with me. Also corrected the write-up in Idunn/docs/migration.md and the memory that recorded the false finding. Three places had it wrong; leaving any of them would have taught the next session the same mistake. 54 pass, 1 skipped, 12 files; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:11:19 +02:00
async function writeStore(file: string, type: string, payload: Uint8Array): Promise<void> {
// Written with the CultCache client, the same way Idunn writes these. A
// hand-built fixture only proves the reader agrees with the fixture.
await new SingleFileMessagePackBackingStore(file).push({
key: "heimdall",
type,
schemaId: type,
storedAt: "2026-09-06T00:00:00.000Z",
payload,
});
Honour the Idunn process write lease, and stop claiming active while warming During a deployment two Heimdall processes exist at once under distinct UIDs and namespaces, and both are configured with the same CultCache store path. Exactly one may write it. Idunn decides which by publishing a lease record naming an incarnation; nothing in Heimdall read it, so both generations would have written heimdall.service.cc. src/process-write-lease.ts answers one question -- may this process write state right now -- by reading the lease and the runtime bundle's expected.cc, and comparing incarnation identity. It grants nothing and throws nothing outward: every failure is a decision of mayWrite:false with a reason, because for a warming candidate "not yet my turn" is the expected condition and crashing would turn it into a failed deployment. Records are decoded as the canonical positional MessagePack Idunn writes (14 fields for the lease, schema-checked), and the tests build the same envelope bytes rather than convenient objects. publishHeimdallVerseState is the single writer, so that is the only place gated. Outside Idunn no lease path is configured and writes proceed unconditionally, which keeps developer runs and the current plain unit working. Second defect found while wiring it: health publication hardcoded state "active". A candidate would have claimed active from its first pulse, which is both untrue and structurally broken -- Idunn must observe `warming` presence before it fences the incumbent, so a candidate that never reports warming cannot be promoted. State is now derived from the lease: warming until held, active after, with the reason carried in the detail string. 49 tests across 11 files; tsc clean under exactOptionalPropertyTypes. The recipe's status banner now records the contract as implemented, with the honest caveat that none of it has been through a live `idunn up` -- the daemon on yggdrasil is still the previous generation, so the first deployment remains the real verification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:25:38 +02:00
}
function expectedIncarnation(target: string, incarnationId: string): Uint8Array {
const fields = new Array(12).fill("x");
fields[0] = "idunn.expected_incarnation.v2";
fields[1] = target;
fields[3] = incarnationId;
return encode(fields);
}
function writeLease(target: string, incarnationId: string, epoch = 3): Uint8Array {
const fields: unknown[] = new Array(14).fill("x");
fields[0] = "idunn.process_write_lease.v1";
fields[1] = target;
fields[4] = incarnationId;
fields[10] = "runtime-instance-1";
fields[12] = epoch;
fields[13] = 1757116800000;
return encode(fields);
}
async function bundleWith(
expectedBytes: Uint8Array | undefined,
leaseBytes: Uint8Array | undefined
): Promise<{ bundle: string; lease: string }> {
const root = await mkdtemp(path.join(os.tmpdir(), "heimdall-lease-"));
const bundle = path.join(root, "runtime-instance-1");
await mkdir(bundle, { recursive: true });
if (expectedBytes) {
Retract the CultLib divergence claim; read each .cc with the right client I claimed this morning that cultcache-ts could not read a cultcache-rs store and that the CultLib forks had diverged. That was false, and it was false in the usual way: generalised from one file. cultcache-ts reads Idunn's live control store on yggdrasil directly -- 36 records, no complaint. Ordinary CultCache stores use the same framing in both runtimes, and that covers the control store, the runtime bundle's expected.cc and activation.cc, and the process write lease. The exception is narrow and deliberate: service identity private stores are written by cultnet-rs's atomic_create_private_store as a single bare positional envelope, because they are a private-key container rather than a record store. The consequence was a live bug. Both readers I wrote decoded the private-store framing, so the write lease and expected-incarnation readers would have failed on the first real deployment -- with green tests, because I had built the fixtures the same wrong way. process-write-lease.ts now uses the CultCache client, provider-health-identity.ts keeps the bare reader for the fd-passed identity where it belongs, and the lease fixtures are written with the client so a test can disagree with me. Also corrected the write-up in Idunn/docs/migration.md and the memory that recorded the false finding. Three places had it wrong; leaving any of them would have taught the next session the same mistake. 54 pass, 1 skipped, 12 files; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:11:19 +02:00
await writeStore(path.join(bundle, "expected.cc"), "idunn.expected_incarnation", expectedBytes);
Honour the Idunn process write lease, and stop claiming active while warming During a deployment two Heimdall processes exist at once under distinct UIDs and namespaces, and both are configured with the same CultCache store path. Exactly one may write it. Idunn decides which by publishing a lease record naming an incarnation; nothing in Heimdall read it, so both generations would have written heimdall.service.cc. src/process-write-lease.ts answers one question -- may this process write state right now -- by reading the lease and the runtime bundle's expected.cc, and comparing incarnation identity. It grants nothing and throws nothing outward: every failure is a decision of mayWrite:false with a reason, because for a warming candidate "not yet my turn" is the expected condition and crashing would turn it into a failed deployment. Records are decoded as the canonical positional MessagePack Idunn writes (14 fields for the lease, schema-checked), and the tests build the same envelope bytes rather than convenient objects. publishHeimdallVerseState is the single writer, so that is the only place gated. Outside Idunn no lease path is configured and writes proceed unconditionally, which keeps developer runs and the current plain unit working. Second defect found while wiring it: health publication hardcoded state "active". A candidate would have claimed active from its first pulse, which is both untrue and structurally broken -- Idunn must observe `warming` presence before it fences the incumbent, so a candidate that never reports warming cannot be promoted. State is now derived from the lease: warming until held, active after, with the reason carried in the detail string. 49 tests across 11 files; tsc clean under exactOptionalPropertyTypes. The recipe's status banner now records the contract as implemented, with the honest caveat that none of it has been through a live `idunn up` -- the daemon on yggdrasil is still the previous generation, so the first deployment remains the real verification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:25:38 +02:00
}
const lease = path.join(root, "process-write-lease.cc");
if (leaseBytes) {
Retract the CultLib divergence claim; read each .cc with the right client I claimed this morning that cultcache-ts could not read a cultcache-rs store and that the CultLib forks had diverged. That was false, and it was false in the usual way: generalised from one file. cultcache-ts reads Idunn's live control store on yggdrasil directly -- 36 records, no complaint. Ordinary CultCache stores use the same framing in both runtimes, and that covers the control store, the runtime bundle's expected.cc and activation.cc, and the process write lease. The exception is narrow and deliberate: service identity private stores are written by cultnet-rs's atomic_create_private_store as a single bare positional envelope, because they are a private-key container rather than a record store. The consequence was a live bug. Both readers I wrote decoded the private-store framing, so the write lease and expected-incarnation readers would have failed on the first real deployment -- with green tests, because I had built the fixtures the same wrong way. process-write-lease.ts now uses the CultCache client, provider-health-identity.ts keeps the bare reader for the fd-passed identity where it belongs, and the lease fixtures are written with the client so a test can disagree with me. Also corrected the write-up in Idunn/docs/migration.md and the memory that recorded the false finding. Three places had it wrong; leaving any of them would have taught the next session the same mistake. 54 pass, 1 skipped, 12 files; tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:11:19 +02:00
await writeStore(lease, "idunn.process_write_lease", leaseBytes);
Honour the Idunn process write lease, and stop claiming active while warming During a deployment two Heimdall processes exist at once under distinct UIDs and namespaces, and both are configured with the same CultCache store path. Exactly one may write it. Idunn decides which by publishing a lease record naming an incarnation; nothing in Heimdall read it, so both generations would have written heimdall.service.cc. src/process-write-lease.ts answers one question -- may this process write state right now -- by reading the lease and the runtime bundle's expected.cc, and comparing incarnation identity. It grants nothing and throws nothing outward: every failure is a decision of mayWrite:false with a reason, because for a warming candidate "not yet my turn" is the expected condition and crashing would turn it into a failed deployment. Records are decoded as the canonical positional MessagePack Idunn writes (14 fields for the lease, schema-checked), and the tests build the same envelope bytes rather than convenient objects. publishHeimdallVerseState is the single writer, so that is the only place gated. Outside Idunn no lease path is configured and writes proceed unconditionally, which keeps developer runs and the current plain unit working. Second defect found while wiring it: health publication hardcoded state "active". A candidate would have claimed active from its first pulse, which is both untrue and structurally broken -- Idunn must observe `warming` presence before it fences the incumbent, so a candidate that never reports warming cannot be promoted. State is now derived from the lease: warming until held, active after, with the reason carried in the detail string. 49 tests across 11 files; tsc clean under exactOptionalPropertyTypes. The recipe's status banner now records the contract as implemented, with the honest caveat that none of it has been through a live `idunn up` -- the daemon on yggdrasil is still the previous generation, so the first deployment remains the real verification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:25:38 +02:00
}
return { bundle, lease };
}
describe("Idunn process write lease", () => {
it("permits writes when not launched by Idunn", async () => {
// A developer run and the current plain systemd unit have no second
// generation to collide with.
await expect(resolveWriteLease({})).resolves.toMatchObject({
mayWrite: true,
reason: "not-under-idunn",
});
});
it("permits writes when the lease names this incarnation", async () => {
const { bundle, lease } = await bundleWith(
expectedIncarnation("heimdall", "incarnation-a"),
writeLease("heimdall", "incarnation-a", 7)
);
await expect(
resolveWriteLease({ leasePath: lease, runtimeBundlePath: bundle })
).resolves.toMatchObject({ mayWrite: true, reason: "held", incarnationId: "incarnation-a", leaseEpoch: 7 });
});
it("refuses writes when the lease names the other generation", async () => {
// The case the lease exists for: a warming candidate while the incumbent
// still holds the store.
const { bundle, lease } = await bundleWith(
expectedIncarnation("heimdall", "incarnation-candidate"),
writeLease("heimdall", "incarnation-incumbent")
);
const decision = await resolveWriteLease({ leasePath: lease, runtimeBundlePath: bundle });
expect(decision.mayWrite).toBe(false);
expect(decision.reason).toContain("incarnation-incumbent");
});
it("refuses writes when no lease has been granted yet", async () => {
const { bundle, lease } = await bundleWith(
expectedIncarnation("heimdall", "incarnation-a"),
undefined
);
const decision = await resolveWriteLease({ leasePath: lease, runtimeBundlePath: bundle });
expect(decision.mayWrite).toBe(false);
expect(decision.reason).toContain("no readable write lease");
});
it("refuses writes when the lease belongs to another target", async () => {
const { bundle, lease } = await bundleWith(
expectedIncarnation("heimdall", "incarnation-a"),
writeLease("ghostlight", "incarnation-a")
);
const decision = await resolveWriteLease({ leasePath: lease, runtimeBundlePath: bundle });
expect(decision.mayWrite).toBe(false);
expect(decision.reason).toContain("ghostlight");
});
it("refuses writes on a wrong-length or wrong-schema lease", async () => {
const short = encode(new Array(9).fill("x"));
const wrongSchema = encode(
Object.assign(new Array(14).fill("x"), { 0: "idunn.process_write_lease.v0", 12: 1 })
);
for (const bytes of [short, wrongSchema]) {
const { bundle, lease } = await bundleWith(
expectedIncarnation("heimdall", "incarnation-a"),
bytes
);
const decision = await resolveWriteLease({ leasePath: lease, runtimeBundlePath: bundle });
expect(decision.mayWrite).toBe(false);
}
});
it("refuses writes when a lease path is set without a runtime bundle", async () => {
// Launch inputs are incoherent; declining is the safe reading.
const decision = await resolveWriteLease({ leasePath: "/nonexistent/lease.cc" });
expect(decision.mayWrite).toBe(false);
expect(decision.reason).toContain("GAMECULT_IDUNN_RUNTIME_BUNDLE");
});
it("refuses writes when the runtime bundle is unreadable", async () => {
const { lease } = await bundleWith(undefined, writeLease("heimdall", "incarnation-a"));
const decision = await resolveWriteLease({
leasePath: lease,
runtimeBundlePath: "/nonexistent/bundle",
});
expect(decision.mayWrite).toBe(false);
expect(decision.reason).toContain("unreadable runtime bundle");
});
});