Retire only after ten minutes as well as five passes, and never through remove

Five passes are 25 s, shorter than a real hiccup. And a provider's remove can
destroy — a ledger file, a public DNS record — so an adapter without a retire
of its own is mark-only: the consumer keeps its access, is marked and said, and
remove is reached only by a person's delete (hq ADR 0230).
This commit is contained in:
jochen
2026-10-06 14:30:39 +02:00
parent e0453316b8
commit 0704908d0b
4 changed files with 196 additions and 65 deletions
+12 -9
View File
@@ -29,17 +29,20 @@ module. This is the ADR 0039 line drawn through provisioning: stable loop here,
in the module.
**A consumer the mesh stops asking for is retired, not withdrawn whole** (novox/hq ADR 0230). ACTIVE
becomes RETIRED — access disabled through the adapter's `retire` (or `remove`, for an adapter that
has none), marked with when and why, nothing deleted — only once the same set of consumers has gone
unasked in five consecutive passes that read the file (20 s at the default 5 s interval; a pass that
cannot read the file resets the count). A stable set of more than 3, or more than half of those held
where more than one is held, is not retired without a person: the provider waits for
becomes RETIRED only once the same set of consumers has gone unasked in five consecutive passes that
read the file AND for ten minutes (`stableForMs`, `STABLE_FOR_MS`; a pass that cannot read the file
starts both again). An adapter with `retire` disables the consumer's access, reversibly, and marks it;
`reenable` (optional) undoes that before `create` when it is asked for again. **An adapter without
`retire` is mark-only**: nothing in the backend changes — the consumer keeps its access — and the
harness marks it "retired, access kept, needs deletion", says so loudly and announces it. **`remove` is
never called to retire**; it is the delete of an adapter without `delete`, reached only through a
person's `provisioner_delete`. A stable set of more than 3, or more than half of those held where more
than one is held, is not retired without a person: the provider waits for
`provisioner_retire_approve` or `provisioner_retire_reject`, and says `provisioner.retirement`
(`waiting`) through the `announce` option. Asked for again, a retired consumer is created — re-enabled
— at once. DELETED only through `provisioner_delete`, of a retired consumer. Given `module`, the four
tools (`provisioner_retirement`, `provisioner_retire_approve`, `provisioner_retire_reject`,
`provisioner_delete`) are registered for it; `provisionerTools(handle)` builds them otherwise.
Additions and changes are never delayed.
— at once. Given `module`, the four tools (`provisioner_retirement`, `provisioner_retire_approve`,
`provisioner_retire_reject`, `provisioner_delete`) are registered for it; `provisionerTools(handle)`
builds them otherwise. Additions and changes are never delayed.
### `tools` — the tool-serving harness
`registerModuleTools`, the `ToolDefinition` type, and the registry/worker that loads a module's
+91 -35
View File
@@ -47,11 +47,13 @@ export interface Provision {
* the credential is the mesh's, already delivered to the consumer. */
export interface Adapter {
create(p: Provision): Promise<void>;
/** Retire what `create` made for this consumer when the adapter has no `retire`: disable, never destroy (issue 241). `derived` is what the mesh last derived for
* it, remembered by the harness: the mesh has already dropped the consumer from the
* contributions file by the time this is called, so it cannot be read again, and a provider
* that recomputed it would be keeping the second copy of a rule this removed
* (novox/hq ADR 0201). */
/** Remove what `create` made for this consumer. **Called only to DELETE a retired consumer, on a
* person's approved `cleanup delete`, and only when the adapter has no `delete`** (novox/hq ADR
* 0230): many providers' remove destroys something — a ledger file, a public DNS record, a user —
* and nothing is destroyed without a person. Retiring never calls it. `derived` is what the mesh
* last derived for it, remembered by the harness: the mesh has already dropped the consumer from
* the contributions file by then, so it cannot be read again, and a provider that recomputed it
* would be keeping the second copy of a rule this removed (novox/hq ADR 0201). */
remove(p: { readonly as: string; readonly derived: Readonly<Record<string, unknown>> }): Promise<void>;
/** Optional: whether the backend still holds this consumer's credential exactly as `p` says.
* Asked of every consumer already applied, every `verifyEveryMs`. `false` makes the harness apply
@@ -60,17 +62,22 @@ export interface Adapter {
* of being trusted from memory (novox/hq issue 120). An adapter without it is trusted from memory,
* as before. It must only read: it is asked often, and must never change the backend. */
holds?(p: Provision): Promise<boolean>;
/** Optional: RETIRE a consumer the mesh stopped asking for (novox/hq ADR 0230) — disable its
/** Optional: RETIRE a consumer the mesh stopped asking for (novox/hq ADR 0230) — DISABLE its
* access, reversibly, and mark it in the backend "to delete" with when and why; delete nothing.
* `create` must re-enable what this disabled and clear the mark, so a consumer asked for again is
* back as it was. An adapter without it is retired through `remove`, which since issue 241 already
* disables rather than destroys. */
* Asked for again, `reenable` (when given) and then `create` bring it back as it was.
*
* **An adapter without it is MARK-ONLY**: retiring a consumer then changes nothing in the backend —
* its access stays as it was — and it is only marked, in this harness, "retired, access kept, needs
* deletion", said loudly and announced. Only a person's `cleanup delete` then removes it. */
retire?(p: {
readonly as: string;
readonly derived: Readonly<Record<string, unknown>>;
readonly why: string;
readonly at: Date;
}): Promise<void>;
/** Optional, with `retire`: undo what `retire` disabled, before `create` runs for a consumer asked
* for again. An adapter whose `create` already re-enables needs none. */
reenable?(p: { readonly as: string; readonly derived: Readonly<Record<string, unknown>> }): Promise<void>;
/** Optional: every consumer the backend holds retired — what `cleanup list` shows. */
retired?(): Promise<RetiredConsumer[]>;
/** Optional: DELETE one retired consumer and its data. Only ever called for a consumer this
@@ -92,6 +99,9 @@ export interface RetiredConsumer {
readonly size_bytes: number;
/** "consumer", or a backend's own word for something else it keeps retired. */
readonly kind: string;
/** "disabled" when the adapter retired it; "kept" for a mark-only provider, whose consumer keeps its
* access until a person deletes it (novox/hq ADR 0230). */
readonly access?: "disabled" | "kept";
}
/** What `provisioner_retirement` answers: the provider's retirement state (novox/hq ADR 0230). */
@@ -100,6 +110,9 @@ export interface Retirement {
readonly node: string;
readonly held: string[];
readonly stable_passes: number;
readonly stable_for_seconds: number;
/** "disable" when the adapter can retire; "mark-only" when it cannot (novox/hq ADR 0230). */
readonly retires_by: "disable" | "mark-only";
readonly bound: string;
readonly waiting: { consumers: { consumer: string; node?: string }[]; since: string; held: number } | null;
readonly rejected: { consumers: { consumer: string; node?: string }[]; by: string; why: string; at: string } | null;
@@ -140,9 +153,13 @@ export interface ProvisionerOptions {
* loop is sequential, so a check that hangs would otherwise stop every consumer's provisioning. */
holdsTimeoutMs?: number;
/** How many consecutive passes that read the file must name the same set of consumers no longer
* asked for before they are retired (novox/hq ADR 0230). Defaults to 5: at the default interval a
* retirement comes 20 s after the first pass that saw it. Additions and changes are never delayed. */
* asked for before they are retired (novox/hq ADR 0230). Defaults to 5. Additions and changes are
* never delayed. */
stablePasses?: number;
/** And how long, in ms, that same set must have held since the first pass that saw it. Defaults to
* STABLE_FOR_MS, ten minutes: five passes alone are 25 s, shorter than a controller restart, a store
* reconnecting or a file half written. Both must hold. */
stableForMs?: number;
/** A stable set larger than this is not retired without a person. Defaults to 3. */
retireAtOnce?: number;
/** Nor a set larger than this share of the consumers held, where more than one is held. Defaults
@@ -160,6 +177,9 @@ export interface ProvisionerOptions {
module?: string;
}
/** The least time the same unasked set must hold before it is retired (novox/hq ADR 0230). */
export const STABLE_FOR_MS = 10 * 60_000;
/** The longest a consumer whose `create` keeps failing to satisfy `holds` waits between checks. */
const MAX_BACKOFF_MS = 60 * 60_000;
@@ -190,8 +210,9 @@ interface Contribution {
*
* **A consumer the mesh stops asking for is retired, never withdrawn whole (novox/hq ADR 0230).**
* ACTIVE → RETIRED once the same set of consumers has gone unasked in `stablePasses` consecutive
* passes that read the file; RETIRED is access disabled and marked, nothing deleted, and asked for
* again it is re-enabled at once. A stable set of more than `retireAtOnce`, or more than
* passes that read the file AND for `stableForMs` (ten minutes); RETIRED is access disabled and
* marked — or, for an adapter with no `retire`, only marked here with its access kept — nothing
* deleted, and asked for again it is re-enabled at once. `remove` is never called to retire. A stable set of more than `retireAtOnce`, or more than
* `retireFraction` of those held where more than one is held, is not retired without a person: the
* provider waits for approve or reject. RETIRED → DELETED only through `provisioner_delete`.
*/
@@ -201,6 +222,8 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
const verifyEveryMs = opts.verifyEveryMs ?? 60_000;
const holdsTimeoutMs = opts.holdsTimeoutMs ?? 30_000;
const stablePasses = opts.stablePasses ?? 5;
const stableForMs = opts.stableForMs ?? STABLE_FOR_MS;
const retiresBy: "disable" | "mark-only" = adapter.retire ? "disable" : "mark-only";
const retireAtOnce = opts.retireAtOnce ?? 3;
const retireFraction = opts.retireFraction ?? 0.5;
const sayAgainEveryMs = opts.sayAgainEveryMs ?? 15 * 60_000;
@@ -209,10 +232,12 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
// Retirement (novox/hq ADR 0230). The unasked set last seen and in how many consecutive readable
// passes; a set too large, waiting for a person; a set a person rejected; and every consumer known
// retired, by name.
let stable = { set: "", count: 0 };
let stable = { set: "", count: 0, since: 0 };
let pending: { consumers: string[]; since: number; held: number; saidAt: number } | null = null;
let rejected: { consumers: string[]; by: string; why: string; at: number } | null = null;
const retired = new Map<string, { node?: string; at: number; why: string }>();
const retired = new Map<string, {
node?: string; at: number; why: string; derived?: Readonly<Record<string, unknown>>; access: "disabled" | "kept";
}>();
let seeded = false;
let verifiedAt = 0;
// Consumers the backend reported lost, and how many times in a row. A `create` that succeeded and
@@ -246,7 +271,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
// waits for the next one.
if (given === null) {
// Not a result: the passes that make a retirement stable must each have read the file.
stable = { set: "", count: 0 };
stable = { set: "", count: 0, since: 0 };
return;
}
if (!seeded) await seed();
@@ -326,6 +351,10 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
}
}
try {
const was = retired.get(g.as);
if (was && was.access === "disabled" && adapter.reenable) {
await adapter.reenable({ as: g.as, derived: was.derived ?? p.derived });
}
await adapter.create(p);
applied.set(g.as, { hash: h, derived: p.derived, node: g.node });
if (retired.has(g.as)) {
@@ -369,11 +398,13 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
rejected = null;
}
if (unasked.length === 0) {
stable = { set: "", count: 0 };
stable = { set: "", count: 0, since: 0 };
return;
}
stable = stable.set === key ? { set: key, count: stable.count + 1 } : { set: key, count: 1 };
if (stable.count < stablePasses) return;
stable = stable.set === key
? { set: key, count: stable.count + 1, since: stable.since }
: { set: key, count: 1, since: Date.now() };
if (stable.count < stablePasses || Date.now() - stable.since < stableForMs) return;
if (rejected) return; // a person said keep them, and the mesh still says the same
const held = applied.size;
if (overTheBound(unasked.length, held)) {
@@ -392,11 +423,16 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
}
return;
}
const why = `the mesh stopped asking for it: ${stablePasses} passes from ${iso(Date.now() - (stablePasses - 1) * everyMs)}`;
const why = `the mesh stopped asking for it: the same answer in ${stable.count} passes over ` +
`${Math.round((Date.now() - stable.since) / 1000)}s, from ${iso(stable.since)}`;
const done = await retireAll(unasked, why);
if (done.length > 0) await tell("retired", { consumers: done.map((as) => retiredBody(as)), held, why });
}
function retiresByAccess(): "disabled" | "kept" {
return adapter.retire ? "disabled" : "kept";
}
function overTheBound(n: number, held: number): boolean {
return n > retireAtOnce || (held > 1 && n > retireFraction * held);
}
@@ -405,27 +441,36 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
return `more than ${retireAtOnce}, or more than ${Math.round(retireFraction * 100)}% of the ${held} held`;
}
// Retire each, as it is said: disabled and marked by the adapter, nothing deleted. Answers those
// that went; one that failed stays active and is asked again by the next stable pass.
// Retire each, as it is said: disabled and marked by the adapter — or, where the adapter cannot
// disable, only marked here, its access kept. Nothing is deleted, and `remove` is never called: it
// is the delete of a provider without one. Answers those that went; one that failed stays active and
// is asked again once its set is stable again.
async function retireAll(names: readonly string[], why: string): Promise<string[]> {
const done: string[] = [];
for (const as of names) {
const was = applied.get(as);
if (was === undefined) continue;
say(`${as}: no longer in ${receives}; retiring it (access disabled, nothing deleted): ${why}`);
try {
const at = new Date();
if (adapter.retire) await adapter.retire({ as, derived: was.derived, why, at });
else await adapter.remove({ as, derived: was.derived });
if (adapter.retire) {
say(`${as}: no longer in ${receives}; RETIRED — access disabled, nothing deleted: ${why}`);
await adapter.retire({ as, derived: was.derived, why, at });
} else {
say(
`${as}: no longer in ${receives}; RETIRED, MARK ONLY — this provider cannot disable a consumer ` +
`(its adapter has no retire), so ${as} KEEPS ITS ACCESS and its data until a person deletes it ` +
`with \`cleanup delete\`. Nothing was changed in the backend (novox/hq ADR 0230): ${why}`,
);
}
applied.delete(as);
lost.delete(as);
retired.set(as, { node: was.node, at: at.getTime(), why });
retired.set(as, { node: was.node, at: at.getTime(), why, derived: was.derived, access: adapter.retire ? "disabled" : "kept" });
done.push(as);
} catch (err) {
say(`${as}: retiring failed, will retry: ${err}`);
}
}
stable = { set: "", count: 0 };
stable = { set: "", count: 0, since: 0 };
return done;
}
@@ -435,7 +480,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
function retiredBody(as: string): Record<string, unknown> {
const r = retired.get(as);
return { consumer: as, node: r?.node, retired_at: r ? iso(r.at) : "", why: r?.why ?? "" };
return { consumer: as, node: r?.node, retired_at: r ? iso(r.at) : "", why: r?.why ?? "", access: r?.access ?? retiresByAccess() };
}
// Said on the bus, never failing the act it reports (the journal still has it).
@@ -459,7 +504,9 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
}
for (const r of (await adapter.retired?.()) ?? []) {
if (r.kind === "consumer" && !retired.has(r.consumer)) {
retired.set(r.consumer, { node: r.node, at: r.retired_at ? Date.parse(r.retired_at) : 0, why: r.why });
retired.set(r.consumer, {
node: r.node, at: r.retired_at ? Date.parse(r.retired_at) : 0, why: r.why, access: r.access ?? retiresByAccess(),
});
}
}
seeded = true;
@@ -484,11 +531,15 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
const list: RetiredConsumer[] = [...fromBackend];
for (const [as, r] of retired) {
if (!list.some((x) => x.consumer === as && x.kind === "consumer")) {
list.push({ consumer: as, node: r.node, retired_at: r.at ? iso(r.at) : "", why: r.why, size_bytes: -1, kind: "consumer" });
list.push({
consumer: as, node: r.node, retired_at: r.at ? iso(r.at) : "", why: r.why, size_bytes: -1, kind: "consumer",
access: r.access,
});
}
}
return {
resource, node, held: [...applied.keys()].sort(), stable_passes: stablePasses, bound: bound(applied.size),
resource, node, held: [...applied.keys()].sort(), stable_passes: stablePasses,
stable_for_seconds: Math.round(stableForMs / 1000), retires_by: retiresBy, bound: bound(applied.size),
waiting: pending && { consumers: named(pending.consumers), since: iso(pending.since), held: pending.held },
rejected: rejected && { consumers: named(rejected.consumers), by: rejected.by, why: rejected.why, at: iso(rejected.at) },
retired: list,
@@ -546,11 +597,16 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
if (applied.has(consumer)) throw new Error(`delete: ${consumer} is active, not retired — only a retired consumer is deleted`);
const known = retired.has(consumer) || ((await adapter.retired?.()) ?? []).some((r) => r.consumer === consumer);
if (!known) throw new Error(`delete: ${consumer} is not retired here`);
if (!adapter.delete) throw new Error(`delete: this provider (${resource}) cannot delete; its adapter has no delete`);
say(`${consumer}: DELETING, asked by ${act.by || "someone"}: ${act.why}`);
const r = await adapter.delete({ as: consumer, why: act.why });
const freed = (r && typeof r.freedBytes === "number") ? r.freedBytes : -1;
const was = retired.get(consumer);
say(`${consumer}: DELETING, asked by ${act.by || "someone"}: ${act.why}`);
let freed = -1;
if (adapter.delete) {
const r = await adapter.delete({ as: consumer, why: act.why });
if (r && typeof r.freedBytes === "number") freed = r.freedBytes;
} else {
// The one path to `remove`: a person's approved deletion (novox/hq ADR 0230).
await adapter.remove({ as: consumer, derived: was?.derived ?? {} });
}
retired.delete(consumer);
await tell("deleted", {
consumers: [{ consumer, node: was?.node, size_bytes: freed }], why: act.why, by: act.by ?? "", via: act.via ?? "",
+70 -10
View File
@@ -1,6 +1,7 @@
// Retirement (novox/hq ADR 0230): a consumer the mesh stops asking for is retired — access disabled,
// data kept — only after the same result in five consecutive passes, and a set too large waits for a
// person. Deletion is a person's act, of a retired consumer only.
// data kept — only after the same result in five consecutive passes AND ten minutes (shortened here
// unless a test is about it), and a set too large waits for a person. An adapter that cannot disable
// is mark-only: remove is never called to retire. Deletion is a person's act, of a retired consumer only.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
@@ -28,13 +29,17 @@ interface World {
retired: { as: string; why: string }[];
removed: string[];
deleted: string[];
reenabled: string[];
events: Record<string, unknown>[];
said: string[];
p: Provisioner;
done(): void;
}
async function world(names: string[], opts: { retire?: boolean; held?: string[] } = {}): Promise<World> {
async function world(
names: string[],
opts: { retire?: boolean; held?: string[]; del?: boolean; reenable?: boolean; stableForMs?: number } = {},
): Promise<World> {
const dir = await mkdtemp(join(tmpdir(), "retire-"));
const receives = join(dir, "postgres-database.json");
const ask = async (list: string[]) => {
@@ -46,18 +51,19 @@ async function world(names: string[], opts: { retire?: boolean; held?: string[]
await writeFile(receives, JSON.stringify({ contributions: 1, requirement: "postgres-database", given }));
};
await ask(names);
const w = { receives, ask, created: [], retired: [], removed: [], deleted: [], events: [], said: [] } as unknown as World;
const w = { receives, ask, created: [], retired: [], removed: [], deleted: [], reenabled: [], events: [], said: [] } as unknown as World;
const was = console.error;
console.error = (...a: unknown[]) => w.said.push(a.join(" "));
const adapter = {
async create(p: { as: string }) { w.created.push(p.as); },
async remove(p: { as: string }) { w.removed.push(p.as); },
async delete(p: { as: string }) { w.deleted.push(p.as); return { freedBytes: 42 }; },
...(opts.del === false ? {} : { async delete(p: { as: string }) { w.deleted.push(p.as); return { freedBytes: 42 }; } }),
...(opts.reenable ? { async reenable(p: { as: string }) { w.reenabled.push(p.as); } } : {}),
...(opts.retire === false ? {} : { async retire(p: { as: string; why: string }) { w.retired.push({ as: p.as, why: p.why }); } }),
...(opts.held ? { async held() { return opts.held!; } } : {}),
};
w.p = runProvisioner("postgres-database", adapter, {
receives, everyMs: EVERY, node: "anchor",
receives, everyMs: EVERY, node: "anchor", stableForMs: opts.stableForMs ?? 0,
announce: (event, body) => { assert.equal(event, RETIREMENT_EVENT); w.events.push(body); },
});
// A pass is counted by the file being read: wait wall time for n passes and some slack.
@@ -85,7 +91,7 @@ test("a transient empty list for four passes retires nothing; five stable passes
await w.ask(["b", "c"]);
await until(() => w.retired.length === 1);
assert.equal(w.retired[0].as, "a");
assert.match(w.retired[0].why, /5 passes/);
assert.match(w.retired[0].why, /\d+ passes over/);
assert.deepEqual(w.removed, []);
assert.deepEqual(w.deleted, []);
assert.deepEqual(changes(w), ["retired"]);
@@ -144,17 +150,71 @@ test("asked for again, a retired consumer is created again and announced re-enab
}
});
test("an adapter without retire is retired through remove, as before", async () => {
const w = await world(["a", "b", "c"], { retire: false });
test("five passes are not enough: the same set must also hold for stableForMs", async () => {
const HOLD = 400;
const w = await world(["a", "b", "c"], { stableForMs: HOLD });
try {
await w.ask(["b", "c"]);
await until(() => w.removed.length === 1);
const start = Date.now();
await sleep(EVERY * 10); // well past five passes, well short of the hold
assert.deepEqual(w.retired, [], "retired after five passes, before the set held long enough");
await until(() => w.retired.length === 1, 3000);
assert.ok(Date.now() - start >= HOLD - 5, `retired after ${Date.now() - start} ms, before ${HOLD} ms`);
assert.equal((await w.p.retirement()).stable_for_seconds, 0); // 400 ms rounds to 0 s
} finally {
w.done();
}
});
test("the default hold is ten minutes", async () => {
const { STABLE_FOR_MS } = await import("../dist/provisioner/index.js");
assert.equal(STABLE_FOR_MS, 10 * 60_000);
});
test("an adapter without retire is MARK-ONLY: nothing changes in the backend, remove only on delete", async () => {
const w = await world(["a", "b", "c"], { retire: false, del: false });
try {
await w.ask(["b", "c"]);
await until(() => changes(w).includes("retired"));
await sleep(EVERY * 10);
assert.deepEqual(w.removed, [], "remove was called to retire");
const ev = w.events.find((e) => e.change === "retired")!;
assert.equal((ev.consumers as { access: string }[])[0].access, "kept");
assert.ok(w.said.some((l) => /MARK ONLY/.test(l) && /KEEPS ITS ACCESS/.test(l)), "mark-only was not said loudly");
const r = await w.p.retirement();
assert.equal(r.retires_by, "mark-only");
assert.deepEqual(r.retired.map((x) => [x.consumer, x.access]), [["a", "kept"]]);
// Asked again: created again, nothing to undo.
await w.ask(["a", "b", "c"]);
await until(() => changes(w).includes("reenabled"));
assert.deepEqual(w.removed, []);
// Gone again, then a person's delete: the one path to remove.
await w.ask(["b", "c"]);
await until(async () => (await w.p.retirement()).retired.length === 1);
await w.p.deleteRetired("a", { why: "gone", confirm: "a" });
assert.deepEqual(w.removed, ["a"]);
} finally {
w.done();
}
});
test("an adapter with retire and reenable: disabled on retire, reenabled before create when asked again", async () => {
const w = await world(["a", "b", "c"], { reenable: true });
try {
await w.ask(["b", "c"]);
await until(() => w.retired.length === 1);
assert.equal((await w.p.retirement()).retires_by, "disable");
assert.equal((await w.p.retirement()).retired[0].access, "disabled");
const before = w.created.length;
await w.ask(["a", "b", "c"]);
await until(() => w.created.length > before);
assert.deepEqual(w.reenabled, ["a"]);
assert.deepEqual(w.removed, []);
} finally {
w.done();
}
});
test("four of four waits for a person: approve the exact set retires, another set is refused", async () => {
const w = await world(["a", "b", "c", "d"]);
try {
+23 -11
View File
@@ -31,7 +31,7 @@ test("module tools register and collect, a thrower is skipped not fatal", () =>
assert.equal(broken?.tools.length, 0);
});
test("provisioner creates each consumer with the mesh's login and password, removes on withdrawal", async () => {
test("provisioner creates each consumer with the mesh's login and password; remove waits for a person's delete", async () => {
const dir = await mkdtemp(join(tmpdir(), "prov-"));
const created: { as: string; password: string; values: unknown }[] = [];
const removed: string[] = [];
@@ -57,7 +57,7 @@ test("provisioner creates each consumer with the mesh's login and password, remo
removed.push(p.as);
},
},
{ receives, everyMs: 20 },
{ receives, everyMs: 20, stableForMs: 0 },
);
await waitFor(() => created.length === 1, 2000);
@@ -67,10 +67,18 @@ test("provisioner creates each consumer with the mesh's login and password, remo
assert.equal(created[0].password, "minted-pw");
assert.deepEqual(created[0].values, { name: "webapp" });
// Withdraw: the mesh drops the consumer from the file → the harness removes it via the adapter.
// The mesh drops the consumer: it is RETIRED, mark only — this adapter cannot disable, so remove is
// NOT called (novox/hq ADR 0230); it keeps its access until a person deletes it.
await writeFile(receives, doc([]));
await waitFor(() => removed.length === 1, 2000);
assert.equal(removed[0], "webapp-anchor");
for (let i = 0; i < 200 && (await stop.retirement()).retired.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
const r = await stop.retirement();
assert.equal(r.retires_by, "mark-only");
assert.deepEqual(r.retired.map((x) => [x.consumer, x.access]), [["webapp-anchor", "kept"]]);
await new Promise((r) => setTimeout(r, 100));
assert.deepEqual(removed, [], "remove was called to retire");
// Only a person's delete reaches remove.
await stop.deleteRetired("webapp-anchor", { why: "gone for good", confirm: "webapp-anchor" });
assert.deepEqual(removed, ["webapp-anchor"]);
stop();
});
@@ -103,7 +111,7 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w
removed.push(p.derived);
},
},
{ receives, everyMs: 20 },
{ receives, everyMs: 20, stableForMs: 0 },
);
await waitFor(() => created.length === 1, 2000);
@@ -115,10 +123,12 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w
await waitFor(() => created.length === 2, 2000);
assert.deepEqual(created[1], { bucket: "mesh-anchor-files" });
// Withdrawn: the file no longer says anything about this consumer, and the adapter is still
// told which bucket to take away.
// Retired, then deleted by a person: the file no longer says anything about this consumer, and the
// adapter is still told which bucket to take away.
await writeFile(receives, doc([]));
await waitFor(() => removed.length === 1, 2000);
for (let i = 0; i < 200 && (await stop.retirement()).retired.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(removed, []);
await stop.deleteRetired("mesh_anchor_ncloud", { why: "unassigned", confirm: "mesh_anchor_ncloud" });
assert.deepEqual(removed[0], { bucket: "mesh-anchor-files" });
stop();
});
@@ -185,7 +195,7 @@ test("provisioner removes nothing while its contributions file cannot be used (n
removed.push(p.as);
},
},
{ receives, everyMs: 20 },
{ receives, everyMs: 20, stableForMs: 0 },
);
await waitFor(() => created.length === 2, 2000);
@@ -210,7 +220,9 @@ test("provisioner removes nothing while its contributions file cannot be used (n
assert.notEqual((await stop.retirement()).waiting, null, "the retirement never waited for a person");
assert.deepEqual(removed, [], "two of two consumers were retired without a person");
await stop.approve(["mesh_anchor_mail", "mesh_anchor_forge"], { why: "both unassigned on purpose", by: "test" });
assert.deepEqual([...removed].sort(), ["mesh_anchor_forge", "mesh_anchor_mail"]);
// Retired mark-only: still nothing removed; that is a deletion, a person's further act.
assert.deepEqual(removed, []);
assert.deepEqual((await stop.retirement()).retired.map((x) => x.consumer).sort(), ["mesh_anchor_forge", "mesh_anchor_mail"]);
stop();
});