/** * Subject erasure inside a module's OWN tables (#2068) — the half of open question 17 that * K-37 left named and unbuilt. * * `shredSubject` redacts the spine and destroys the key that seals the platform's copies. A * module's rows were outside that, and §13.1 said so (limit 2). This file brings them in, two * ways, both run by the kernel in the SAME transaction as the spine redaction and before the * key is destroyed: * * - **Declared.** An entity names its subject columns beside its `erasable` fields * (`erasure: { subjects }`, `subject-erasure.ts` in contracts), and the kernel blanks those * fields — or deletes the row — wherever a subject column equals the id. No module code * runs, so a vertical gets the right behaviour by declaring, and a declaration cannot forget * a column the way a hand-written UPDATE can. * - **A hook.** `onSubjectErased(ctx, { subjectId })` on the registration, for a link the row * does not hold itself (a rating reached through its conversation). It is narrow on purpose: * a `sql` that reaches the module's own tables and nothing else, and `now()`. No emit — an * event about the erasure would be a new immutable copy of what is being erased — no * check, grant, link or platform request, and no await: it is synchronous, so it runs inside * the erasure's one transaction and nothing interleaves with it. * * **Failure is all or nothing.** A hook that throws rolls the whole scope-side unit back, the * spine redaction with it, and the erasure throws before the key is touched or a receipt is * written. Nothing claims an erasure that did not happen, and a re-run converges: a declared * blank skips rows already blank, and a hook is idempotent by contract. * * **Derived stores.** A search index over an erased column is kept in step by its own triggers, * but FTS5 keeps a deleted row's terms in older index segments until they merge — the words * are still in the database file, findable by anyone reading the shadow tables. So the erasure * runs with FTS5's `secure-delete` on for the indexes over the erasing modules' tables, which * removes each entry where it sits rather than recording its deletion. A list index is an ordinary b-tree and loses the old value with the * UPDATE. What neither reaches — free pages SQLite has not reused, and attachment text — is * written down in kernel-design.md §13.1 rather than implied. */ import { type ErasedEntityCount, type ErasureHookCount, type SubjectErasureDeclaration, type UnreachedEntity } from '@substrat-run/contracts'; import type { ModuleRegistration, ScopedSql } from './scope-host.js'; import type { SearchIndexPlan } from './search-index.js'; /** What a module's `onSubjectErased` hook is handed — and all it is handed. */ export interface SubjectErasureContext { /** * The module's own tables, and only those — the ones its migrations create: a statement * naming any other table in the scope (another module's, a view, a TEMP object, the spine) * is refused before it runs, reads included. Only `SELECT`, `UPDATE`, `DELETE`, `INSERT` and * `REPLACE`; no `WITH`, no DDL, no `PRAGMA`. Revoked when the hook returns: a call after that * throws and writes nothing. */ readonly sql: ScopedSql; /** The erasure's instant, the same one the spine's tombstones carry. Revoked with `sql`. */ now(): string; } /** * A module's own erasure step (#2068). Synchronous and returning nothing — a promise, an * iterator or any other returned value refuses the whole erasure — and * idempotent: an erasure is re-run after any failure, so a second call for the same subject * must find nothing left to do. */ export type OnSubjectErased = (ctx: SubjectErasureContext, subject: { readonly subjectId: string; }) => void; /** One registered module's erasure, validated once at registration. */ export interface ModuleErasurePlan { readonly moduleId: string; readonly declaration: SubjectErasureDeclaration; readonly hook?: OnSubjectErased; /** * The tables this module's erasure may touch, lowercased: the declaration's, each one VERIFIED * at registration to be created by the module's own migrations. The hook's whole reach. */ readonly ownTables: ReadonlySet; } /** What the module half of one erasure did — the receipt's three new lines. */ export interface ModuleErasureCounts { verticalRows: ErasedEntityCount[]; hookRows: ErasureHookCount[]; unreachedEntities: UnreachedEntity[]; } /** * The tables a module's own migration TEXT creates, lowercased — read at registration, where no * scope exists yet, as early feedback only: it can refuse a module whose erasure names a table it * plainly never creates, and it never grants anything. `CREATE TABLE IF NOT EXISTS` on another * module's table reads as a creation here and creates nothing in a scope, which is why the * authority is the ownership each scope RECORDS as its migrations run (`table-ownership.ts`), * checked by the erasure itself before it writes. */ export declare function tablesCreatedBy(migrations: readonly { readonly sql: string; }[]): Set; /** * The erasure plan for one registration, or undefined when it has nothing to erase. Throws, * at registration, for every way a module can claim an erasure it cannot deliver, or one that * would reach past itself: * * - a hook with no `manifest.erasure` — the hook's reach IS that block's `tables`, and a hook * with no reach declared is refused rather than handed the whole scope; * - an entity declared `custom` with no hook to reach it; * - an entity the erasure would write (`blank`, `delete`, `custom`) on a table the module's own * migrations do not create, and — when there is a hook — any table in its reach that they do * not create. The declaration is a claim; the migrations are what the kernel ran. A misdeclared * entity naming another module's table would otherwise have that table erased through the * kernel's own handle. An `unreached` entity writes nothing and is not held to it. */ export declare function moduleErasurePlan(registration: ModuleRegistration): ModuleErasurePlan | undefined; /** * Refuse a hook statement that reaches past the module's own tables (#2068). * * An ALLOWLIST, judged where a table can stand: every name in a table position — after `FROM`, * `JOIN`, `INTO` and `UPDATE`, and each further name in a comma-joined `FROM` list, at any * nesting depth — must be one of `own`, unqualified. Whatever else the scope holds — another * module's table, a view, a TEMP object, the spine — is refused without having to be listed, * so nothing the schema grows later widens the reach. Also refused: a qualified name * (`main.x`, `temp.x`), a table-valued function other than `json_each`/`json_tree`, `WITH` * (a CTE could shadow an own table's name), and any spine- or `sqlite_`-prefixed name * anywhere. Every statement chained after a `;` starts with an allowed verb. * * It errs toward refusing: a name in a table position that is not a table at all * (`a IS DISTINCT FROM b`) is held to the allowlist too, and fails closed with a message. */ export declare function assertWithinErasureReach(moduleId: string, sql: string, own: ReadonlySet): void; /** * The oldest SQLite that may run an erasure over a search index (#2068): FTS5 `secure-delete` * arrived in 3.42.0, and an index deleted from under it is recorded at FTS5 format version 5, * which no older SQLite can read. A dump never carries a search index (it is rebuilt on load), * so the reader that matters is the runtime holding the scope's own file. */ export declare const SECURE_DELETE_MIN_SQLITE = "3.42.0"; /** * Run the module half of one erasure: every declared entity, then every hook, with FTS5's * `secure-delete` on for the search indexes over those modules' tables. `sql` is the kernel's * own handle on the scope — never module code's — and the caller holds it inside the * transaction the spine redaction runs in, so a throw from here rolls both back. * * Order between the two halves: declared first, hooks after, so a hook reading a declared * entity sees it already blank and a hook that deletes cannot strand a declared blank. A record a * binding shape binds (#1686) is released LAST, from rows read before either half ran. Order * between modules is registration order, which nothing here depends on: each touches its own * tables only. */ export declare function eraseSubjectFromModules(input: { readonly sql: ScopedSql; readonly plans: readonly ModuleErasurePlan[]; readonly searchPlans: Iterable; /** The module tables carrying archive/trash columns (#119), lowercased — `guardSpine`'s. */ readonly statefulTables?: ReadonlySet; readonly subjectId: string; readonly at: string; /** A registered module's migration text, for backfilling a scope migrated before ownership was recorded. */ readonly migrationSqlOf: (moduleId: string, version: string) => string | undefined; }): ModuleErasureCounts; /** Every row the module half changed — what the access log adds to "how much evidence this destroyed". */ export declare function moduleRowsErased(counts: ModuleErasureCounts): number; /** * Whether a host's reply carries the module half (#2068) in the shape this kernel wrote — what a * coordinator checks before destroying the key, so a host built before the half existed is * refused rather than read as having reached nothing. */ export declare function isModuleErasureCounts(value: unknown): value is ModuleErasureCounts; //# sourceMappingURL=module-erasure.d.ts.map