import { type FileHandle } from "node:fs/promises"; import { type Part } from "@cotal-ai/core"; import type { BracketState } from "./agui.js"; import type { SubjectFrontier } from "./subject-frontier.js"; /** * Bump ONLY with a migration. An OLDER document is migrated forward; a NEWER one fails loud. * * **v2 added `brackets`** — the AG-UI bracket machine's state, persisted so a mid-run restart can * continue instead of refusing the first event it re-reads. * * **v3 added `gen`** — a counter this writer bumps on every durable replace, so a write can tell * whether the document it is about to replace is the one it last read. See {@link EventWal.write}. * A v2 document has never been written by a generation-aware writer, so it migrates forward at * generation 0; from v3 on the field is REQUIRED, because "absent" and "zero" would otherwise be * the same value and stripping the field would disable the guard silently. * * **THE MIGRATION IS FORWARD-ONLY, AND THAT MAKES THE STATE OUTLIVE A CODE ROLLBACK.** Once a * process writes v2, reverting the code does NOT revert the state: the older build refuses the * document it now finds. That is the right trade — fail-loud beats silently reading a schema you do * not understand — but it converts "revert the commit" into "revert the commit and hand-migrate the * state", and the person doing the reverting will otherwise discover that at the worst moment. So * the refusal below distinguishes NEWER-than-this-code from unknown, and says which migration. * There is deliberately no downgrade path: a lossy downgrade is worse than a halt. */ export declare const EVENT_WAL_VERSION = 3; export interface WalFrontier { /** The `seq` of the last frame FOLDED into the frontier. 0 before the first frame lands. */ seq: number; /** The subject sequence the next publish must expect (`E`). 0 on a virgin thread. */ lastSubjectSeq: number; /** The durable source position everything up to `seq` was derived from. */ sourceCursor: string | undefined; } export interface WalPending { state: "sent_unacked" | "acked"; /** Frozen at transition 1 and NEVER re-minted — a retry must carry the same id. */ id: string; /** The expectation frozen at transition 1, likewise never recomputed on retry. */ E: number; seq: number; sourceCursor: string; /** * The frame's parts, FROZEN at transition 1 beside the id and `E` that identify them. * * Without this the WAL froze what NAMES a frame and not the frame: a restart holding * `sent_unacked` recovered the id and the expectation and had nothing to re-publish, so * "retry the same frame after a crash" — the one thing this file exists to make possible — * could not be performed from the document. The write-ahead rule requires it and it was absent. * * It is the parts and not a rendered message because `multicastExpecting` builds the envelope * (`ts`, `from`, `space`) at publish time; storing that too would freeze a second copy of fields * the publisher owns, and a retry would then carry a stale `ts` under a frozen id. */ body: Part[]; /** Present iff `state === "acked"`: the sequence the server assigned. */ ackSeq?: number; /** * The bracket machine's state AFTER this frame's events (v2). * * Frozen with the frame rather than derived on fold, for the same reason the body is: the state * that belongs to a frame is decided when the frame is decided. Transition 3 promotes it to the * document's own `brackets`, so what is persisted always describes exactly the events that have * been published AND folded — never a batch that was validated and not yet sent. */ brackets: BracketState; } export interface WalDoc { v: number; /** * How many durable replaces this document has been through (v3). * * It is a WITNESS, not a clock: its only job is to let a writer notice that the file it is about * to replace is no longer the file it read. Nothing orders two documents by it and nothing derives * a position from it. */ gen: number; /** * The space this WAL belongs to — stored because it is a PATH COMPONENT and a path component is * not a trusted input. `principal` and `threadId` were verified on * load and `space` was not, so a WAL copied or mis-resolved between two space directories under * the same principal and thread LOADED, and one space's frontier was adopted as another's. Two * thirds of a three-part guard is not the guard. */ space: string; epoch: string; threadId: string; principal: string; frontier: WalFrontier; pending: WalPending | null; /** * The bracket machine at the FOLDED position, or `null` for **unknown** (v2). * * `null` and an explicitly empty state are different facts and the difference is load-bearing. * An empty state is this writer saying "nothing is open"; `null` is a document that cannot say — * a WAL migrated from v1, which recorded no bracket state at all. A restart on `null` therefore * still takes the lost-state path and still says so, while a restart on a recorded state simply * continues. Collapsing the two would make a migrated document silently claim a clean boundary it * never observed, which is the same class as treating a zero-byte file as a virgin thread. */ brackets: BracketState | null; } /** Every refusal in this file is one of these, so a caller can never mistake it for an I/O blip. */ export declare class WalCorruptError extends Error { readonly path: string; readonly invariant: string; constructor(path: string, invariant: string, detail: string); } /** * Thrown when this handle's document is not the one on disk any more. * * A DISTINCT type from {@link WalCorruptError} because the file is not corrupt: it is FINE, and it * belongs to somebody else's newer view. An operator reading "corrupt" would go looking for a bad * disk; the actual situation is two writers, and only one of them is current. */ export declare class WalStaleWriterError extends Error { readonly path: string; readonly expectedGen: number; readonly foundGen: number | undefined; constructor(path: string, expectedGen: number, foundGen: number | undefined); } /** * Create a file EXCLUSIVELY and WITHOUT following a symlink, at mode 0600. * * **Exported so a test can drive the shipped flags rather than recompose them.** A cell that builds * `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` itself is testing a COPY of this rule: it stays green * while the production open drifts or loses a flag. That is not hypothetical here — the first * version of those cells did exactly that, and a mutation reverting the real open to `"w"` left the * suite fully green. Driving this function is what makes the mutation land. * * `O_EXCL` refuses a pre-existing file rather than adopting it — which matters because `"w"` does * NOT re-chmod an existing inode, so an adopted 0644 temp would carry its mode onto the renamed WAL * and expose `pending.id`. `O_NOFOLLOW` refuses a symlink rather than truncating its target. */ export declare function openExclusiveNoFollow(path: string): Promise; export declare class EventWal { readonly path: string; private doc; /** * Every mutation runs one-at-a-time on this chain. * * Without it, two concurrent `beginSend` calls both read `this.doc.pending === null` before either * durable replace finishes — the guard is an in-memory read that is NOT atomic with the write * across its `await` points. Reviewers reproduced the split: one call fulfils, one rejects, and * the process is left holding `pending.id === "A"` in memory while the disk says `"B"`. Recovery * would then resume the frame on disk while the live emitter retries the other, which breaks the * one thing this file exists to guarantee — that `id` and `E` are frozen and agreed. * * A per-instance chain is sufficient and honest about its scope, and the scope is narrower than it * once claimed: it serializes THIS INSTANCE's callers. It does nothing about a SECOND `EventWal` * on the same file, in this process or another — the chain is per object, so two objects are two * chains and both of them "succeed". That gap was described here as "solved upstream by the * principal-level lock" while no lock was ever acquired. Two things close it now, and neither is * this chain: `acquirePrincipalLock` refuses a second emitter for the principal at start, and * {@link EventWal.assertNotClobbering} refuses a stale handle's write even when it got past that. */ private chain; /** Run `op` after every previously-queued mutation, whether they resolved or threw. */ private serialize; private constructor(); get epoch(): string; /** The principal this WAL was loaded FOR — exposed so a consumer can prove it is holding its own. * `open()` already refuses a document whose stored principal disagrees, but that check protects * the FILE, not the caller: an emitter handed the wrong WAL object entirely would sail past it. */ get principal(): string; get threadId(): string; /** The bracket machine at the folded position, or `null` when the document cannot say (migrated * from v1). The two are different facts; see {@link WalDoc.brackets}. */ get brackets(): BracketState | null; get frontier(): WalFrontier; get pending(): WalPending | null; /** * Load an existing WAL, or start a virgin one. * * `subjectMayExist` is the caller's honest statement about whether this principal+thread could * already have published. It is NOT a convenience flag: with it true, a missing or empty WAL is a * refusal, because the tip cannot be inferred — agent creds hold no read shape over the subject, * and guessing `E := 0` either CAS-halts forever or appends under a stale expectation. Recovery * from that state is an explicit operator act, never a startup heuristic. */ /** Bound by {@link bindSubjectFrontier}; absent for a WAL nothing publishes from. */ private subject?; static open(path: string, opts: { space: string; threadId: string; principal: string; subjectMayExist: boolean; }): Promise; private static virgin; /** * Bind the PRINCIPAL-scoped subject frontier this thread publishes onto. * * **THE TIP IS NOT THIS THREAD'S TO REMEMBER, AND THAT IS THE WHOLE CORRECTION.** * `frontier.lastSubjectSeq` records the last sequence THIS thread was assigned, which is a true * fact about this log and was mistaken for the subject's tip. The subject is per principal, so a * second session of the same agent opened virgin, expected an empty subject its own predecessor * had filled, and halted forever. Once bound, the bound record is authoritative for the * expectation and this document's own number is history. * * Called once, by {@link AguiEmitter.start}, which is the only thing that drives a WAL toward a * publish. An UNBOUND log still opens, replays and reports its own frontier, so a caller that * only READS one needs no record; but every step toward a publish reads the subject's tip, so * `expectedTip`, `beginSend`, `recordAck` and `abandon` all throw until this has been called. * An earlier version of this sentence said an unbound WAL behaved exactly as it did before, * which was true when it was written and stopped being true in the same change that made the * unbound expectation throw. */ bindSubjectFrontier(frontier: SubjectFrontier): Promise; /** * The sequence a publish must expect, which is the SUBJECT's tip and not this thread's. * * **UNBOUND IT THROWS, AND AN EARLIER VERSION OF THIS RETURNED THIS DOCUMENT'S OWN LAST ACK.** * That number is the defect's own shape: per session, while the subject is per principal. The * argument for returning it was that no shipped path can reach it, because * {@link AguiEmitter.start} is the only route from a log to a publish and it binds before the * emitter exists. The argument was true, and it is the same argument the released seam shipped * on: two correct components with an assumption standing where a guard belongs, recorded in * prose. So the assumption is a guard now. A caller that drives a log toward a publish without a * frontier fails here rather than republishing an expectation that was never the subject's. */ get expectedTip(): number; /** Transition 1 — record the frame, with `id` and `E` frozen, BEFORE any publish. */ beginSend(frame: { id: string; E: number; seq: number; sourceCursor: string; body: Part[]; /** The bracket machine AFTER this frame's events — frozen with the frame, promoted on fold. */ brackets: BracketState; }): Promise; /** * Transition 2 — a NON-duplicate ack becomes durable before the frontier moves. * A duplicate ack must never reach here; the caller fails loud on one. */ recordAck(ackSeq: number): Promise; /** Transition 3 — fold the acked frame into the frontier and clear pending. */ fold(): Promise; /** * Transition 4 — a bounded source range that mapped to NOTHING. * * A mapper SUCCESS returning zero events advances the cursor atomically and alone: no * `seq` consumed, no pending written, no publish. A mapper ERROR never advances it. Empty and * failed must not share a path: conflating them turns a parser bug into silently skipped history. */ advanceCursorOnly(rangeEnd: string): Promise; /** * Abandonment — explicit, destructive and TOTAL. Mints a new epoch AND resets `seq`, * `lastSubjectSeq` and `sourceCursor` together, reusing the same subject; the new epoch is what * tells a consumer the chain broke. Partial abandonment is not a state: either all four move or * the emitter stays halted. Required after a filtered channel purge, which returns the subject * tip to 0 while the WAL still holds a non-zero `E`, permanently CAS-failing every later publish. */ abandon(): Promise; /** * Durable replace: write a sibling temp file, fsync it, then rename over the target. The rename * is what makes a reader see either the whole old document or the whole new one and never a torn * prefix — which is precisely why a zero-byte WAL is treated as corruption rather than as virgin. * * **MODE 0600, AND THE REASON IS NOT TIDINESS: `pending.id` IS A PRE-PUBLICATION SECRET.** * The dedup cache the frozen id is checked against is STREAM-WIDE, so anyone who learns an id * BEFORE its frame is published can pre-seed it and make the real publish come back * `duplicate: true`. The design attributes the safety of that entirely to `randomUUID()` entropy — * which holds only while the id is unguessable AND unread. An id already on the wire is harmless * (that message has landed); the only window where it is dangerous is exactly the window this * file holds it in, between transition 1 and the ack. * * So the residual a reviewer raised as "gated on a local disk read rather than mesh access" is * gated on a read OF THIS FILE. A world-readable WAL would convert a property the design credits * to entropy into one credited to filesystem luck. Under our own rules the attack yields a LOUD * halt rather than silent loss — a duplicate ack on a retry fails loud with the frontier and * cursor unmoved — so this is denial of service, not corruption. Closing it by construction is * cheap enough that naming it as an accepted residual would be the worse trade. */ private write; /** * Refuse to replace a document this handle did not read. * * **THE FAILURE THIS EXISTS FOR WAS EXECUTED, NOT IMAGINED.** Two `EventWal` objects were opened * on one file. A ran the full cycle and folded a frontier of `{seq:1, lastSubjectSeq:5}`. B, whose * in-memory document was frozen back at the pending write, then called `recordAck(99)` and * `fold()` — both SUCCEEDED, each replacing the whole file, and the WAL came back up claiming a * durable tip of 99: a subject sequence the broker never assigned. The next publish freezes * `E := 99` against a stream whose real tip is 5, so the emitter either CAS-halts forever or * recovers a frontier that never existed. Nothing about that is loud; it reads as a healthy WAL. * * The per-instance `serialize` chain cannot see it (two instances, two chains) and neither can the * principal lock (B's handle predates any lock B would take, and a lock is not held against a * process's own second object). The guard has to be HERE, on the write, where the two views * finally meet. * * **This is a check, not a transaction, and the difference is stated rather than glossed.** The * read and the `rename` are separate syscalls, so a writer that lands in between is not caught by * this; what is caught is every stale handle — the case that actually occurs, because a stale * handle stays stale for as long as it exists rather than for a syscall's width. The lock is what * keeps a second live writer from starting; this is what keeps one that already exists from * winning. */ private assertNotClobbering; } //# sourceMappingURL=event-wal.d.ts.map