/** * Cell-level provenance capture (ADR 0019). * * ADR 0018 answered *which producer* filled a cell. This module carries the * other half — *what that producer was given*, *which branch was taken*, and * *where the row came from* — as additive fields on writes the runtime already * makes. Nothing here performs a round trip, and every value is a fact the * runtime held at the write site rather than a declaration it trusted or a * mapping it reconstructed afterwards. * * Shared by the runtime (`ctx-types`/`context`), the run ledger, and the * durable sheet parse path, so all three agree on one shape and one bound. */ /** * One cell version a cell was computed from. * * Both addressing components are omitted when they match the cell that carries * the ref: no `table` means the same table, no `rowKey` means the same row. * That is not shorthand for the reader's convenience — it is what keeps the * trace inside the retained-row memory budget. Runtime sheet row keys are * namespace-salted hashes, and repeating one per ref per cell inflated a * mapped row by roughly forty percent, which the streaming-dataset budget test * rejects. Readers fill both in from the cell's own address; the durable sheet * read model does this so every consumer sees a complete ref. * * `version` is the attempt/fence identity of the value read and is set only * where the runtime already held one: absent means "not witnessed", never * "version 0". */ export interface PlayCellReadRef { table?: string; rowKey?: string; column: string; version?: number; } /** * The branch a per-row control-flow evaluation selected, with the read-set the * evaluator was handed. `branch` is the runtime's own vocabulary, never * authored free text. */ export interface PlayCellDecision { branch: string; reads: PlayCellReadRef[]; } /** Row-grain birth: the source row the runtime held while building this row. */ export type PlayRowBornFromRow = { table: string; rowKey: string }; /** * Dataset-grain birth: rows arrived from free-form play-body code and no * per-row mapping was witnessed. It names the destination table and how many * rows entered, and deliberately says nothing about any single row. * * There is no `step` field. The only step name available at the write site is * `map:`, which restates `table` — a derived join key, not an observed * fact — and the record already hangs off the dataset's own lifecycle entry. */ export type PlayDatasetBornFrom = { table: string; rowCountIn: number; }; export type PlayRowBornFrom = PlayRowBornFromRow | PlayDatasetBornFrom; /** * Per-cell cap on the durable read trace, matching the producer trace bound. * The read-set is the row's column list, so an unbounded trace would put a wide * row's whole schema into `_cell_meta` once per cell. Truncation keeps the * first columns in row order, which is stable across runs. */ export const MAX_CELL_READ_REFS = 12; /** * Cap on how many cells one row's read order describes. A dataset has a handful * of columns; this only bounds pathological graphs, and it reports what it * dropped rather than trimming silently. */ export const MAX_ROW_READ_CELLS = 24; /** Reserved `_cell_meta` key holding row-grain facts rather than a cell. */ export const ROW_META_CELL_KEY = '_row'; /** * Row-grain read order (ADR 0019). * * Every cell in a row reads a prefix of the same list: the row's input columns, * then each column this run wrote, in the order they became available. Storing * the list once per row and a cut point per cell is the same information as a * per-cell array at O(columns) instead of O(columns x cells) — which matters, * because this rides the retained-row memory budget on every mapped row. * * A written column is appended to `columns` before its own cell runs, so its * position *is* its cut point: everything before it was available, itself and * everything after it was not. `upto` therefore only carries cells the list * cannot place — non-persisted fields, and cells past the column cap. * `droppedCells` counts cells no cut describes at all, so an incomplete order * is visible instead of looking like a cell that read nothing. */ export interface PlayRowReadOrder { columns: string[]; upto?: Record; droppedCells?: number; } /** * How many leading `columns` entries a cell read, or null when the order * describes no read for it. Null is not zero: a cell that read nothing and a * cell nobody recorded must not render the same. */ export function cellReadCut( order: PlayRowReadOrder | null | undefined, column: string, ): number | null { if (!order) return null; const explicit = order.upto?.[column]; if (typeof explicit === 'number' && Number.isFinite(explicit)) { return Math.max(0, Math.min(Math.trunc(explicit), order.columns.length)); } const index = order.columns.indexOf(column); return index >= 0 ? index : null; } /** Row-grain `_cell_meta._row` record. */ export interface PlayRowMeta { reads?: PlayRowReadOrder; bornFrom?: PlayRowBornFromRow; } /** * Expand one cell's read-set out of the row order. Returns undefined when the * order describes no read for that cell, which is not the same as an empty * read-set and must not be rendered as one. */ export function hydrateCellReadRefs( order: PlayRowReadOrder | null | undefined, column: string, rowKey: string, ): Array | undefined { const cut = cellReadCut(order, column); if (cut === null || cut <= 0 || !order) return undefined; const refs = order.columns .slice(0, Math.min(cut, MAX_CELL_READ_REFS)) .map((readColumn) => ({ rowKey, column: readColumn })); return refs.length > 0 ? refs : undefined; } /** Parse a stored row read order off untrusted transport. */ export function normalizeRowReadOrder(value: unknown): PlayRowReadOrder | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const record = value as Record; const columns = Array.isArray(record.columns) ? record.columns .filter((column): column is string => typeof column === 'string') .slice(0, MAX_CELL_READ_REFS) : []; if (columns.length === 0) return null; const rawUpto = record.upto && typeof record.upto === 'object' && !Array.isArray(record.upto) ? (record.upto as Record) : {}; const upto: Record = {}; let entries = 0; for (const [column, cut] of Object.entries(rawUpto)) { if (entries >= MAX_ROW_READ_CELLS) break; if (typeof cut !== 'number' || !Number.isFinite(cut) || cut < 0) continue; upto[column] = Math.min(Math.trunc(cut), columns.length); entries += 1; } const droppedCells = finiteNonNegativeInteger(record.droppedCells); return { columns, ...(entries > 0 ? { upto } : {}), ...(droppedCells ? { droppedCells } : {}), }; } /** Enforce {@link MAX_CELL_READ_REFS} without reordering. */ export function boundCellReadRefs(refs: readonly T[]): T[] { return refs.length > MAX_CELL_READ_REFS ? refs.slice(0, MAX_CELL_READ_REFS) : [...refs]; } /** * Fold a newly stated dataset birth into the one already on the snapshot. * * A paged dataset registers more than once, each registration stating the rows * admitted so far, so the larger `rowCountIn` is the later truth. A phase event * that states no birth never erases one that was witnessed. */ export function mergeDatasetBornFrom( current: PlayDatasetBornFrom | null | undefined, next: PlayDatasetBornFrom | null | undefined, ): PlayDatasetBornFrom | null { if (!next) return current ?? null; if (!current) return next; return next.rowCountIn > current.rowCountIn ? next : current; } function finiteNonNegativeInteger(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { return null; } return Math.trunc(value); } function nonEmptyString(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed ? trimmed : null; } /** * Parse a dataset-grain birth record off untrusted transport. Returns null for * anything that is not a complete record: a partial birth record would claim a * lineage the writer never stated. */ export function normalizeDatasetBornFrom( value: unknown, ): PlayDatasetBornFrom | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const record = value as Record; const table = nonEmptyString(record.table); const rowCountIn = finiteNonNegativeInteger(record.rowCountIn); if (!table || rowCountIn === null) return null; return { table, rowCountIn }; }