diff --git a/README.md b/README.md index 19a722f..13420bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/provisioner/index.ts b/src/provisioner/index.ts index 4791ce7..3514e17 100644 --- a/src/provisioner/index.ts +++ b/src/provisioner/index.ts @@ -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; - /** 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> }): Promise; /** 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; - /** 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>; readonly why: string; readonly at: Date; }): Promise; + /** 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> }): Promise; /** Optional: every consumer the backend holds retired — what `cleanup list` shows. */ retired?(): Promise; /** 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(); + const retired = new Map>; 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 { 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 { 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 ?? "", diff --git a/test/retire.test.ts b/test/retire.test.ts index 557fb46..63550ee 100644 --- a/test/retire.test.ts +++ b/test/retire.test.ts @@ -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[]; said: string[]; p: Provisioner; done(): void; } -async function world(names: string[], opts: { retire?: boolean; held?: string[] } = {}): Promise { +async function world( + names: string[], + opts: { retire?: boolean; held?: string[]; del?: boolean; reenable?: boolean; stableForMs?: number } = {}, +): Promise { 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 { diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 44e8514..c23339c 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -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(); });