Stage 7 (partial, stopping point): persistent quarantine store in lumen-user v2 (additive migration, B-6 safe), quarantine no-loop skip in pullCandidate before any file fetch; verifier/pull wiring compiles clean, 275 tests green. Quarantine round-trip tests pending.
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-09-30 14:59:39 -05:00
commit 48685b3cc8
5 changed files with 169 additions and 9 deletions

132
src/data/user/quarantine.ts Normal file
View file

@ -0,0 +1,132 @@
/**
* Persistent quarantine store — rejected (edition, packageVersion) pairs so a
* known-bad version is never re-fetched until latest.json advances past it.
* Lives in `lumen-user` (diag concern, contract §9) — survives dataset GC.
* Written as lumen-user v2 via the openDB versionchange seam.
* Trace: IMPLEMENTATION-CONTRACT.md §11 (quarantine no-loop), Stage 7,
* ARCHITECTURE-DESIGN.md:675, SPIKE-02 F-5
*/
import { withTx, idbGet, idbPut, idbGetAll, idbDelete } from "../../platform/idb/wrapper.js";
import { USER_QUARANTINE } from "../../platform/idb/names.js";
/**
* Local structural twin of sync/verifier QuarantineRecord (B-boundary: data
* imports platform only). Field-compatible, so sync can pass its records
* straight through to addQuarantined / quarantineSink.
*/
export interface QuarantineRecordLike {
readonly edition: string | null;
readonly packageVersion: number | null;
readonly code: string;
readonly reason: string;
readonly at: number;
}
export const USER_DB_VERSION = 2;
export function quarantineKey(edition: string, packageVersion: number): string {
return `${edition}/${String(packageVersion)}`;
}
/** Record shape persisted under `edition/packageVersion`. */
export interface QuarantinedVersion {
readonly edition: string;
readonly packageVersion: number;
readonly code: string;
readonly reason: string;
readonly at: number;
}
export async function addQuarantined(db: IDBDatabase, record: QuarantineRecordLike): Promise<void> {
if (record.edition === null || record.packageVersion === null) {
// Cannot key an unidentified rejection — diag entry still gets written by
// the caller; quarantine (which prevents refetch) needs both coordinates.
return;
}
await idbPut(
db,
USER_QUARANTINE,
{
edition: record.edition,
packageVersion: record.packageVersion,
code: record.code,
reason: record.reason,
at: record.at,
} satisfies QuarantinedVersion,
quarantineKey(record.edition, record.packageVersion),
);
}
export async function isQuarantined(
db: IDBDatabase,
edition: string,
packageVersion: number,
): Promise<boolean> {
const v = await idbGet<QuarantinedVersion>(
db,
USER_QUARANTINE,
quarantineKey(edition, packageVersion),
);
return v !== undefined;
}
/** All quarantined versions for an edition (ascending by packageVersion). */
export async function listQuarantined(
db: IDBDatabase,
edition: string,
): Promise<QuarantinedVersion[]> {
const all = await idbGetAll<QuarantinedVersion>(db, USER_QUARANTINE);
return all
.filter((q) => q.edition === edition)
.sort((a, b) => a.packageVersion - b.packageVersion);
}
/** Clear one entry (e.g. organizer republished content at that version via rollback runbook). */
export async function clearQuarantined(
db: IDBDatabase,
edition: string,
packageVersion: number,
): Promise<void> {
await idbDelete(db, USER_QUARANTINE, quarantineKey(edition, packageVersion));
}
/** Clear every quarantined version for an edition except strictly-newer than a version. */
export async function pruneQuarantinedUpTo(
db: IDBDatabase,
edition: string,
throughInclusive: number,
): Promise<number> {
const doomed = (await listQuarantined(db, edition)).filter(
(q) => q.packageVersion <= throughInclusive,
);
if (doomed.length === 0) return 0;
await withTx(db, USER_QUARANTINE, "readwrite", async (tx) => {
const os = tx.objectStore(USER_QUARANTINE);
for (const q of doomed) {
const req = os.delete(quarantineKey(q.edition, q.packageVersion));
await new Promise<void>((resolve, reject) => {
req.onsuccess = () => {
resolve();
};
req.onerror = () => {
reject(req.error ?? new Error("IDB error"));
};
});
}
});
return doomed.length;
}
/**
* QuarantineSink backed by the store — wire into VerifyDependencies.quarantine
* so every verifier rejection lands in persistent quarantine automatically.
*/
export interface QuarantineSinkLike {
readonly add: (record: QuarantineRecordLike) => Promise<void> | void;
}
export function quarantineSink(db: IDBDatabase): QuarantineSinkLike {
return {
add: (record) => addQuarantined(db, record),
};
}

View file

@ -13,12 +13,12 @@ import {
idbDelete,
idbCount,
} from "../../platform/idb/wrapper.js";
import { DB, USER_FAVS, USER_PREFS, USER_DIAG } from "../../platform/idb/names.js";
import { DB, USER_FAVS, USER_PREFS, USER_DIAG, USER_QUARANTINE } from "../../platform/idb/names.js";
import type { FavoriteEntry, UserPrefs, DiagEntry } from "./types.js";
const USER_VERSION = 1;
const USER_VERSION = 2;
function upgradeUser(db: IDBDatabase): void {
function upgradeUser(db: IDBDatabase, oldVersion: number): void {
if (!db.objectStoreNames.contains(USER_FAVS)) {
db.createObjectStore(USER_FAVS);
}
@ -28,11 +28,16 @@ function upgradeUser(db: IDBDatabase): void {
if (!db.objectStoreNames.contains(USER_DIAG)) {
db.createObjectStore(USER_DIAG);
}
// v1 -> v2: persistent quarantine store (Stage 7 §11 no-loop). Additive;
// favorites/prefs/diag untouched (B-6).
if (oldVersion < 2 && !db.objectStoreNames.contains(USER_QUARANTINE)) {
db.createObjectStore(USER_QUARANTINE);
}
}
export function openUserDB(): Promise<IDBDatabase> {
return openDB(DB.USER, USER_VERSION, (db) => {
upgradeUser(db);
return openDB(DB.USER, USER_VERSION, (db, oldVersion) => {
upgradeUser(db, oldVersion);
});
}

View file

@ -26,5 +26,6 @@ export const SLOT_STAGING = "staging" as const;
export const USER_FAVS = "favorites" as const;
export const USER_PREFS = "prefs" as const;
export const USER_DIAG = "diag" as const;
export const USER_QUARANTINE = "quarantine" as const;
export type DbName = (typeof DB)[keyof typeof DB];

View file

@ -8,6 +8,14 @@ export interface CandidatePackage {
readonly result: VerifyResult;
}
/** Skip decision when the pointer targets a version already quarantined. */
export interface QuarantineSkipped {
readonly skipped: "quarantined";
readonly pointer: LatestPointer;
}
export type PullOutcome = CandidatePackage | QuarantineSkipped | null;
function siblingUrl(manifestUrl: string, name: string): string {
if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString();
return new URL(name, `https://transport.invalid${manifestUrl}`).pathname;
@ -16,16 +24,28 @@ function siblingUrl(manifestUrl: string, name: string): string {
/**
* Pulls through the verification boundary and stops at a verified package.
* No staging, activation, retry loop, or user-data operation belongs here.
*
* Quarantine no-loop (§11): when a `isQuarantined` predicate is supplied and
* the pointer targets a rejected version, the pull stops BEFORE fetching the
* manifest or any file — a known-bad version is never re-fetched until
* latest.json advances past it.
*/
export async function pullCandidate(
transport: Transport,
edition: string,
deps: VerifyDependencies,
options: { readonly signal?: AbortSignal; readonly timeoutMs?: number } = {},
): Promise<CandidatePackage | null> {
options: {
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
readonly isQuarantined?: (packageVersion: number) => Promise<boolean>;
} = {},
): Promise<PullOutcome> {
if (!transport.isAvailable()) return null;
const pointer = await transport.fetchPointer(edition, options);
if (!pointer) return null;
if (options.isQuarantined && (await options.isQuarantined(pointer.packageVersion))) {
return { skipped: "quarantined", pointer };
}
const manifestUrl = pointer.manifestUrl;
const manifestBytes = await transport.fetchBytes(manifestUrl, options);
const signatureBytes = await transport.fetchBytes(

View file

@ -154,7 +154,8 @@ describe("Stage 9 pull and verifier boundary", () => {
supportedSchemaRange: [1],
},
);
expect(result?.result.ok).toBe(true);
if (!result || "skipped" in result) throw new Error("expected candidate");
expect(result.result.ok).toBe(true);
expect(calls[0]).toMatch(/latest\.json$/);
expect(calls[1]).toMatch(/manifest\.json$/);
expect(calls[2]).toMatch(/signature\.json$/);
@ -187,7 +188,8 @@ describe("Stage 9 pull and verifier boundary", () => {
supportedSchemaRange: [1],
},
);
expect(result?.result).toMatchObject({ ok: false, code: "signature_mismatch" });
if (!result || "skipped" in result) throw new Error("expected candidate");
expect(result.result).toMatchObject({ ok: false, code: "signature_mismatch" });
expect(calls).toHaveLength(3);
expect(calls.some((url) => /emergency|schedule|map|info|assets\.json/.test(url))).toBe(false);
});