/** * The trash, held by the host (#119 PR 2) — the half of K-45 a handler used to have to remember. * * Three things, written once for both adapters, the way `entity-state.ts` is: * * 1. **The refusal.** An operation that addresses an entity by id (`permission: { entity, * idFrom }`) is refused on a TRASHED one before its guards and handler run, unless it * declares `trashed: 'admits'` or `'purges'`. PR 1's review found handlers forgetting the * per-handler check (`list-shares`, `revoke-share`); this makes it a mechanism. * 2. **The purge horizon.** An entity declaring `trash.purgeAfterDays` is permanently deleted * once it has been in the bin that long, by the sweep running the module's own * `trashed: 'purges'` operation as a derived schedule (`purgeSchedulesOf` in contracts), one * entity per transaction. * 3. **Registration.** The checks that make both of those true of a module rather than of the * modules that remembered a line. * * ## What the refusal does not reach, stated * * An operation whose check is `resolved` in the handler, narrowed by `refFrom`, or a `narrows` * walk has no entity the host can read off its input, so it is not refused here and keeps its * own `ctx.entityState` check. `trashRefusalGapsOf` names them and `lint:model` prints them. */ import { type EntityRef, type EntityStateDeclaration, type OperationTarget, type ScheduleSpec } from '@substrat-run/contracts'; import { type EntityStatePlan, type StateCheck } from './entity-state.js'; import type { ScopedSql } from './scope-host.js'; import type { SystemScheduleState } from './system-switch.js'; /** How many entities one purge pass deletes per entity type. A full batch leaves the schedule due. */ export declare const PURGE_BATCH = 50; /** * Is `entity` out of reach as a parent (#119): its type declares trash, and it is in the trash or * does not exist. The two answer as ONE, so `ctx.link` cannot be used to tell a binned entity from * a missing one. `false` for a type that declares no trash — the kernel never read those rows * before, and linking to one is unchanged. */ export declare function isUnreachableParent(sql: ScopedSql, plans: ReadonlyMap, entity: EntityRef): boolean; /** What the refusal needs from the adapter, inside the operation's own transaction. */ export interface TrashRefusalDeps { /** RAW access — the state columns are read, never written, but the guarded seam is module code's. */ readonly sql: ScopedSql; readonly plans: ReadonlyMap; /** The operation's own check, so a pass is one of its authorizations and a denial is recorded as its own. */ readonly check: StateCheck; /** The invocation's `ctx.now()` — the host's clock, never a caller's — that a purge cutoff is computed from. */ readonly now: string; } /** * Refuse an operation reaching a trashed entity it did not declare it reaches (#119). * * Runs after `BEGIN` and before the guards and the handler, for every caller and every door. * * **The order is the security property.** On a trashed entity the operation's DECLARED key is * checked first, exactly as the handler's first line would have: a caller without it gets the * `forbidden` an active entity would have given them, so the bin is not something they can * probe. Only a caller who holds the key learns it is gone — `not_found`, the answer the * per-handler check gave. An active entity is never checked here; the handler does it, as * always, so a pass is never recorded twice. * * `purge` is set only by the host's own purge sweep (`InvokeOptions` carries no such field, and an * adapter refuses a call that tries to supply one). The CUTOFF is computed here, from `now` — the * host's clock — and the entity's declared horizon, never taken from a caller: the entity must * still be trashed at or before it, or the purge is `conflict` (`purge_not_due`). So a restore * that landed between the sweep's selection and this turn wins, and the sweep never purges early. * * **Any other caller of the purge operation** (#2214) — a person holding its key through a role or * a direct grant — runs it as the permanent delete, at any time, unless the entity declares * `trash.purgeEarly: false`. Then the horizon is a floor for them too: on an entity that is active, * or was binned less than `purgeAfterDays` ago, the declared key is checked first (a caller * without it meets the `forbidden` any active entity gives) and a holder gets `not_found`, the bin * refusal's answer — and so does a missing entity, so the three cannot be told apart. There is * deliberately no opt-out: no invoke option, door or principal purges such an entity early. * * The floor holds the PURGE OPERATION, not the bin: an operation declared `trashed: 'admits'` whose * handler deletes the row, or a handler's own `ctx.sql`, still removes a binned entity at any time. */ export declare function refuseTrashedTarget(deps: TrashRefusalDeps, operation: string, target: OperationTarget | undefined, input: unknown, purge?: { readonly gate: PurgeGateFacts; }): Promise; /** * The keys a system principal's checks refuse on this call (#119): its module's purge-only keys, * unless the call is the host's own purge sweep invoking the purge operation. `undefined` — * nothing withheld — for any other subject, and for a module with no purge-only key. */ export declare function withheldKeysFor(purgeOnlyKeys: ReadonlySet | undefined, target: OperationTarget | undefined, purging: boolean): ReadonlySet | undefined; /** * Refuse an invoke that tries to supply a purge from outside (#119). Purge authority comes only from * the host's own sweep, through a path no caller can construct; an options object carrying the * field is somebody trying, and is told so rather than silently ignored. */ export declare function assertNoCallerPurge(options: object | undefined): void; /** The latest trash instant still due for purge at `now` — `now` minus the horizon. */ export declare function purgeCutoffOf(now: string, days: number): string; /** A position in the purge walk's order: the trash instant and the id of the last entity tried. */ export interface PurgeKey { readonly at: string; readonly id: string; } /** * The ids due for purge: trashed at or before `cutoff`, oldest trash first, at most `limit` — * and, given `after`, only those strictly after it in that order (#2096). The predicate leads with * the purge index's own `WHERE`, so the walk reads only the bin; `after` is compared as a VALUE, * so a key whose entity has since been purged or restored still places the walk correctly. */ export declare function purgeCandidates(sql: ScopedSql, plan: EntityStatePlan, cutoff: string, limit?: number, after?: PurgeKey | null): PurgeKey[]; /** * A purge LAP in progress (#2096) — what a schedule's cadence row holds in `purge_cursor` between * passes. A lap walks the whole due bin, oldest first, `PURGE_BATCH` at a time; each pass resumes * after the last entity the one before it tried, whatever became of that entity. So entities that * keep failing cannot hold the head of the walk: the walk moves past them, reaches everything due * behind them in the same lap, and tries them again in the next. * * `failed` counts the purges that failed in the lap's earlier passes, so the pass that closes the * lap — the one that writes the cadence row — records the lap `failed` even when its own batch * succeeded. */ export interface PurgeLap { /** Where the next pass resumes, or null at the start of a lap. */ readonly after: PurgeKey | null; readonly failed: number; } /** * The lap a stored `purge_cursor` holds. NULL — no lap in progress, a row written before the * column, or no row at all — is the start of a lap, and so is a value this code cannot read: the * worst a lost cursor costs is walking from the oldest again. */ export declare function purgeLapOf(stored: string | null | undefined): PurgeLap; /** Read the purge schedule's lap from its cadence row (`kind = 'schedule'`, keyed by the operation). */ export declare function readPurgeLap(sql: ScopedSql, operation: string): PurgeLap; /** One purge horizon's due work on a scope, for one pass of its lap. */ export interface PurgeDue { readonly cutoff: string; /** The purge operation's input field carrying the id. */ readonly idFrom: string; /** The ids this pass tries, in the walk's order. */ readonly ids: string[]; /** The lap as this pass found it. */ readonly lap: PurgeLap; /** Where the next pass resumes when more is due after these ids — or null: this pass closes the lap. */ readonly next: PurgeKey | null; } /** * One purge horizon's due work on a scope (#119, #2096): the cutoff, the input field carrying the * id, and the next `limit` due ids of the lap the cadence row records — read one past the batch, so * `more` is known rather than guessed from a full batch. Registration has tied the schedule to a * declared horizon and the entity's purge operation, so a miss here is a wiring fault. */ export declare function purgeDueOf(sql: ScopedSql, plans: ReadonlyMap, targets: ReadonlyMap, operation: string, entityType: string, now: string, limit?: number): PurgeDue; /** * What the purge sweep's gate reads (#119), each fact from the scope's OWN state as the adapter * holds it — never from the caller. The adapter reads them before the sweep selects anything and * again inside each purge's transaction, and both go through `purgeHeldBy`. */ export interface PurgeGateFacts { /** The module's kill switch on this scope (`systemScheduleState`). */ readonly switched: SystemScheduleState; /** The scope's lifecycle refusal (`lifecycleRefusal`), or null when it may run or none is held. */ readonly lifecycle: string | null; /** Whether the scope is a copy — a fork, a snapshot or a preview — rather than the primary install. */ readonly copy: boolean; /** Whether the tenant the sweep names is foreign to the scope's own record (`tenantVerdict`). */ readonly foreignTenant: boolean; } /** * THE purge sweep's gate (#119): why this scope may purge nothing now, or null when it may. The * same holds the coordinator applies before any schedule fires, applied by the scope itself from * whatever of them it records (below: on a directory-backed host, the kill switch). A tenant foreign to the scope is a REFUSAL (`not_found`, * the doors' answer for a pair that does not hold), not a hold: nothing about it is "not yet". * * On a directory-backed host the scope DO enforces the kill switch; tenant, lifecycle, copy and * rewind holds are enforced by the coordinator. The scope holds no tenant receipt, lifecycle or copy * classification of its own there (the directory is their authority), so those facts read clear — * an unrecorded tenant is `unknown`, which passes as at every door. */ export declare function purgeHeldBy(facts: PurgeGateFacts): string | null; /** What one purge schedule did on one scope in one pass. */ export interface PurgePass { /** Entities the operation deleted. */ purged: number; /** Entities restored or already gone by their turn — not failures. */ skipped: number; /** One entry per entity whose purge threw for any other reason; it stays in the bin and is retried. */ errors: { entityId: string; error: string; }[]; /** More is due after this batch: the lap continues, and the schedule stays due (`purgeStillDue`). */ more: boolean; /** * Purges that failed in the EARLIER passes of the lap this pass closed (#2096) — so the cadence * row this pass writes says the lap failed, even when this batch did not. 0 while the lap goes on. * Absent, like `more`, from a scope running code older than the lap, which ran no lap. */ lapFailed?: number; /** Why the scope ran no purge at all this pass (`purgeHeldBy`), when its gate held it. */ held?: string; } /** A pass the scope's gate held: nothing selected, nothing tried, the lap where it was. */ export declare function heldPurgePass(held: string): PurgePass; /** * Run one pass of a purge schedule over the ids the adapter selected (`purgeDueOf`), then record * what it did to its lap (#2096). One invoke per entity, each its own transaction, so a crash or a * failure loses nothing already committed and the next pass simply selects what is left. An entity * that was restored (`purge_not_due`) or is already gone (`not_found`) is skipped, never a failure — * the purge is idempotent by state. * * Then the lap moves on WHATEVER its purges did — that is the whole of #2096: resume after this * batch with its failures added while more is due, or close the lap. After the pass, so a pass cut * short repeats its batch rather than skipping it. `inScope` runs the write against the scope's * spine, in the turn the adapter's storage needs. */ export declare function runPurgePass(operation: string, due: PurgeDue, purgeOne: (entityId: string) => Promise, inScope: (write: (sql: ScopedSql) => void) => unknown): Promise; /** * Whether a purge pass leaves its schedule due, so the next sweep pass continues its lap rather * than waiting the cadence: more is due after its batch (#2096). It cannot spin (#2087): every pass * moves the lap strictly forward, whatever its purges did, and the pass that runs out of due * entities closes the lap and records the run — so a cadence window holds one lap, and an entity * that keeps failing is tried once per window, not once per sweep tick. A scope running code older * than the lap answers no `more`, and its pass waits its cadence. */ export declare function purgeStillDue(pass: PurgePass): boolean; /** * The keys a module's system principal holds ONLY for its purge schedules (#119): declared by a * purge schedule and by no other. Withheld from every system-principal check except the purge * sweep's own invoke of the purge operation, so the scope-wide grant seated for a purge cannot * run anything else — another operation checking the same key, a job step, a host-level call. */ export declare function purgeOnlyKeysOf(schedules: readonly ScheduleSpec[]): ReadonlySet | undefined; /** * A purge pass, as the schedule run reports it: one error row per entity that failed (labelled * with its id), and the error that marks the schedule's run `failed` when any did — in this pass, * or in an earlier pass of the lap this one closed (#2096), so the cadence row keeps the failure. */ export declare function purgeReportOf(operation: string, entityType: string, pass: PurgePass): { errors: { operation: string; error: string; }[]; failure?: Error; }; /** * Hold one module's registration to the trash rules (#119), and answer the targets the host * keeps. Refuses, never skips — each of these is a binned entity reachable while the module * believes it is not: * * The targets are DERIVED, never handed over: `operationInputsOf(ops)` records the declared * surface of the frozen map it returns (`declaredSurfaceOf`, which nothing else can write), and the * host reads each operation's target off the same declaration it parses with. A module with a trashable entity must therefore * - bind through `operationsFor`, whose `operations.inputs` is built by `operationInputsOf`, so there is a declared surface to read; * - declare every operation it binds there — an undeclared one could address a binned entity and * the host could not see which; * - declare a `trashed: 'purges'` operation whose input is a strict object holding the id and nothing else. * * Also refused: an opt-in on an entity that declares no trash here, a purge horizon without * exactly the schedule `purgeSchedulesOf` derives for it, and a purge schedule that does not run * the entity's `trashed: 'purges'` operation. */ export declare function registerTrashTargets(moduleId: string, ownOps: ReadonlySet, inputs: Readonly> | undefined, entityStates: readonly EntityStateDeclaration[] | undefined, schedules: readonly ScheduleSpec[] | undefined): Map; //# sourceMappingURL=entity-trash.d.ts.map