import { type EntityRef, type EventId, type HistoryEntry, type CauseChain, type EffectsTree, type InvocationEvents, type DeadLetter, type ListPage, type Page, type TimelineEntry, type EventFacetInput, type EventFacetResult } from '@substrat-run/contracts'; import type { ScopedSql } from './scope-host.js'; /** * Reading an entity's history out of the spine (#800). * * `_substrat_outbox` is the kernel's table and a timeline read of it is a * SANCTIONED projection — rule 3 bans writes to `_substrat_*`, not reads, because * "show me the history of this thing" has no other honest source. What was * missing is a supported SHAPE for it, and five demos wrote the query by hand in * its absence. They did not agree, and the disagreements were not cosmetic: * * | Demo | Order | Cursor | * |---|---|---| * | callout, handlebar | `rowid` | `rowid` | * | meridian, rally | `occurred_at, rowid` | `occurred_at` | * | manyfold | `rowid` | *(unpaged)* | * * **Meridian's and rally's paging drops events.** The cursor is `occurred_at` * and the walk is `occurred_at > ?`, so every row sharing the last row's * timestamp is skipped — and sharing it is the NORM, not a rare tie: `ctx.now()` * is stable for the whole invocation (#812), so every event one operation emits * carries the identical instant. A page boundary landing inside an operation's * events silently loses the rest of them, and no test would catch it. * * So the walk here is `ORDER BY id`, and the cursor is `id`: * * - **`id` IS the entity's version at that point** (#901). The same token * `ctx.versionOf` returns and `If-Match` compares (#129/#906), so listing the * history, naming a version, and refusing a stale write stop being three * vocabularies. * - **Creation order, exactly.** `ulid()` uses the spec's monotonic factory, so * two ids minted in one millisecond still sort in the order they were made. * That property is what `entityVersionQuery` already relies on; a timeline * ordering by `rowid` was the odd one out. * - **The index already exists.** `OUTBOX_ENTITY_INDEX` is * `(entity_type, entity_id, id)`, built for `entityVersionQuery`, and it makes * this a seek with no new DDL. A `rowid` cursor cannot use it. * - **`rowid` does not survive a restore. `id` does.** * * ## These do NOT check a permission, deliberately * * Every caller does its own `assertAllowed(await ctx.check(read, entity))` first, * and that stays the caller's job: a helper that gated itself would become a * second, invisible policy surface, and one that gated itself on nothing would be * an unchecked read path into every event in the scope. Neither is better than * the one line at the call site. * * ```ts * assertAllowed(await ctx.check(WO.read, entity)); * return readTimeline(ctx, entity, input); * ``` * * ## Read-only by construction * * Both build a `SELECT` and nothing else. `boundary-lint`'s ban on writing * `_substrat_*` is untouched and must stay that way — this is a way to read the * spine, never a way to forge it. */ /** What the reads need from a context: the scope's SQL, and only its query half. */ export interface TimelineReader { readonly sql: Pick; } /** * An entity's timeline — WHAT happened to it, WHEN, and BY WHOM. * * The envelope only: no payload, so there is no disclosure decision to make and * nothing an erasure can leave behind. `readHistory` is the same walk with what a * history VIEW needs. * * Paged like an HTTP list read rather than like a kernel read — an unset `limit` * is `LIST_PAGE_DEFAULT`, not unbounded — because the caller is an app walking a * screen, and an entity that has been touched ten thousand times must not answer * with ten thousand rows because nobody said a number. */ export declare function readTimeline(ctx: TimelineReader, entity: EntityRef, page?: ListPage): Page; /** * An entity's history — the timeline, plus what was said and under what * authority. * * Three fields beyond the envelope, each answering something a history strip * needs and a timeline cannot: * * - **`payload`** — the fat event, i.e. the NEW values. Field-level "X → Y" is * reconstructed by diffing consecutive payloads; nothing stores a before-state. * **Null after a shred** — a supported result, not an error (see `historyEntry`). * - **`authorization`** (K-34) — the checks the emitting operation passed, and * which grant allowed each. Not just who changed it but under what authority, * which most systems cannot answer at all and this one gets for free. * - **`piiClass` / `subjectId`** — so the caller can decide what is safe to * render before it renders it. * - **`causedBy`** (#1237) — the event this one was emitted in reaction to, which * is what makes a backwards walk possible: `authorization` says under what * authority and `operation` says under what invocation, but neither says * BECAUSE OF WHAT, and a consumer emit has no operation at all. * * Same permission posture as `readTimeline`: the caller checks, this does not. * The payload makes that more load-bearing here, not less — this is the read that * can disclose what an event said. */ export declare function readHistory(ctx: TimelineReader, entity: EntityRef, page?: ListPage): Page; /** * Walk one event's causal chain backwards (#1237) — "this invoice exists; what * started that?" * * The sanctioned read, for the reason `readHistory` is: the walk's whole value is in * telling five endings apart, and a hand-rolled loop over `caused_by` distinguishes * none of them. * * **A null cause is two different endings, and the pair with `operation` decides * which.** An event with an operation and no cause is a COMPLETE chain: an operation * emitted it directly, and there is nothing above it. An event with neither is a * TRUNCATED one: something emitted it — a consumer, which records no operation — back * when the cause was not being recorded (pre-#1237). Reading the second as the first * presents a fragment as the whole story, which is the single thing this view must * never do; a reader would conclude a consumer started a chain it merely continued. * * Bounded, and terminating even on input the spine should not be able to produce. * `maxDepth` caps the walk and reports `depth` rather than trimming silently, and a * revisited id ends it as `cycle`: ids are monotonic and a cause is always older, so * a cycle is impossible — but "impossible" is not a reason for a read on the audit * spine to be able to hang, and it is a reason to report it as the integrity failure * it is rather than as a long chain. `depth` promises more above; a cycle has none. */ export declare function walkEventCause(ctx: TimelineReader, eventId: EventId, maxDepth?: number): CauseChain; /** * Walk forward from one event: what it set off (#1237). * * The mirror of `walkEventCause`, and the honest answer to "expand this invocation". * It is assembled from what the spine already recorded — which consumers the event * reached, and which events they emitted in turn (`caused_by`, #1437) — rather than * from spans, because nothing in the platform emits a span for an operation, a * permission check or an engine call. So this is a tree of recorded steps with real * timestamps, NOT a timing waterfall, and it does not pretend to be one. * * Sanctioned for the same reason the backwards walk is: the two readings of * `delivered_at` and the ambiguity of an empty delivery list are both traps a * hand-rolled join falls into, and both are resolved here once. */ export declare function walkEventEffects(ctx: TimelineReader, eventId: EventId, maxNodes?: number): EffectsTree; /** * Everything ONE call did (#1237), oldest first. * * The third of the three reads, and the one neither walk can reach. `walkEventCause` * goes backwards along a chain and `walkEventEffects` goes forwards down a tree — both * follow CAUSE, so both miss a sibling. An operation that emits `order.placed` and * `stock.reserved` independently has two events with no causal edge between them, and * from either one the other is invisible. They are still the same call, and that is what * a reader means by "what did this request do". * * Ordered by id, which is ULID and therefore chronological — the same ordering the * timeline and the drain use, so an invocation's events read in the order they happened * without a second sort key. * * Bounded like every other read here. `truncated` says the call did more than is shown, * which is a different statement from the call having done this much. */ export declare function readInvocation(ctx: TimelineReader, invocationId: string, limit?: number): InvocationEvents; /** * Every delivery in the scope that gave up (#1525), newest event first. * * The question the two walks cannot answer, because they reach a delivery only through * its event: "which deliveries in this app gave up?" is the first question in most * incidents, and it names no record to start from. * * **Dead is `error IS NOT NULL AND next_attempt_at IS NULL`, and both halves matter.** * A retrying row carries an error too; dropping the second half would list a delivery * that is still going to run as one that will not. It is `deliveryOf`'s predicate, * spelled in SQL, and the two must not drift. * * Keyset-paged on `(event_id, consumer_module)` — the delivery table's own primary key, * walked backwards — because one event can give up on several consumers, and a cursor on * the event alone would skip the rest of them at a page boundary. `event_id` is a ULID, * so newest-first is by when the event happened, not when the delivery gave up: an * executor that exhausts its retries an hour later still files under its event. * * Same permission posture as every read here: the caller checks, this does not. */ export declare function readDeadLetters(ctx: TimelineReader, page?: Pick): Page; /** * Facet a scope's own outbox (#1239 stage 1): narrow by type and window, group by * one envelope column or one payload field, count. * * The sanctioned read, for the same reason `readHistory` is: the spine has rules a * hand-rolled `SELECT` does not know, and this one is load-bearing — * * **an erased payload is not a missing value.** A shred keeps the row and drops * the content (§5.3), so `json_extract(payload, '$.x')` over a shredded event * yields NULL exactly as it does for an event that never carried `x`. Grouped * naively, redacted history disappears into a "no value" bucket and the reader * sees a clean distribution with no hint that part of it was erased. So erased * rows are counted in their own total and kept out of the buckets entirely. * * A bare `payload IS NULL` is NOT that predicate, which is the subtlety here. * `DomainEvent.payload` is `unknown`, so `payload: undefined` is a legal thing to * emit, and `emit` stores it as the same SQL NULL a shred writes — so the naive * predicate calls every payload-less event erased. The shred only ever nulls rows whose * `pii_class` is not `'none'` (it needs a data subject to key the erasure, which * `piiInvariant` guarantees such a row has), so that is the condition carried * here: it admits every erased row and excludes the ordinary payload-less one. * What it cannot separate is an event that declares PII and then carries nothing * — indistinguishable from a shredded row in this schema, and counted as erased, * which is the safe direction to be wrong in: over-reporting redaction tells a * reader to go and look, under-reporting it does not. The exact answer wants a * persisted erasure state on the spine and a migration for existing scopes; the * reader cannot invent one. * * The group-by is a fixed shape, never interpolated SQL: an envelope grouping * selects a known column, and a payload grouping binds `'$.'` as a * parameter, with the field's own pattern enforced by `eventFacetGroupBy`. */ export declare function facetEvents(ctx: TimelineReader, input: EventFacetInput): EventFacetResult; //# sourceMappingURL=timeline.d.ts.map