The Witness Chain — Type-theoretic Scrollback Integrity¶
This is the design and proof obligations for maya's inline-mode correctness guarantee. The theorem we prove (modulo stated assumptions) is Scrollback Integrity:
For any well-typed maya program
P, for any sequence of host eventse₁…eₙ, the bytes that the terminal emulator commits to its native scrollback are exactly the bytesserializewould produce for the overflow-portion of past frames, in order. No row is ever targeted by an escape sequence emitted by maya once it has overflowed the viewport.
This document explains how the type system witnesses that claim. The implementation lives in:
include/maya/render/wire_row.hpp— Layer 1include/maya/render/serialize.hpp— Layer 2 (ContentRows) and Layer 3 (ShadowWitness)include/maya/render/canvas_witness.hpp— Layer 0 (canvas caches)include/maya/render/frame_bytes.hpp— Layer 4include/maya/render/inline_frame.hpp— Layer 5 (apex)include/maya/render/scrollback_ledger.hpp— Layer 6 (trim accounting)
The theorem in five parts¶
The integrity claim decomposes into five sub-claims, each closed by a distinct layer of the chain.
T0 (canvas-cache integrity). For every Canvas value reaching
diff(), the cached metadata — last_content_col(y) and
max_content_row() — is an exact function of the cell buffer. No diff
is ever computed against a canvas whose trailing-blank extent is
stale-high (the historical "shorter header painted over longer; old
tail not cleared" ghost-row class).
Proof.
diff(CanvasWitness&&, CanvasWitness&&, ...)is the only witness-gated diff entry point; the legacy raw-Canvas overload is kept solely so the existing 20+ scrollback tests keep exercising the production path while the migration bakes in.CanvasWitnessis move-only with a private constructor friended only toverify_canvas.verify_canvasre-deriveslast_col_[y]andmax_y_fromcells_(slow O(W·H) scan) and returnsnullopton disagreement — the host's only recovery isclear_row()+ repaint, never "emit anyway." On match it records FNV-1a hashes of both the cells buffer and the (last_col_, max_y_) tuple. Insidediff(), both hashes are recomputed against the moved-in canvases and compared with the witness-recorded values; mismatch triggersstd::abort(memory-corruption / use-after-free signal, single-threaded renderer). Therefore every byte thatdiff()emits is computed against a canvas whose cached metadata is true at the moment of emit. ∎
T1 (no-ghost). The renderer emits no cursor-positioning escape sequence that targets a row above the viewport top.
Proof. Every cursor move passes through
emit_move(out, from, to)inwire_row.hpp. Both arguments areWireRows. The only producers ofWireRowareViewport::row(y)(saturating),Viewport::top_row/bottom_row, andWireRow::up(n)/down(n)(saturating). The saturation is built intoWireRow's private constructor viastd::clamp(y, top, bottom-1). There is no public constructor that takes an uncheckedint, nodata()accessor, no other escape hatch. The set of representable values is[top, bottom). ∎
T2 (no-corruption). The renderer emits no cell-paint escape
sequence whose target cell disagrees with paint(view(model)) at the
corresponding canvas coordinate.
Proof.
compose_inline_frame_v2requiresShadowWitness&&as a typed argument. The only producer ofShadowWitnessisverify_shadow(state). The witness records the FNV-1a hash ofstate.prev_cellsat issue time. Inside compose, the hash is recomputed and compared against the witness's recorded value; any mismatch triggersstd::abort(memory-corruption signal). SinceShadowWitnessis move-only with a private constructor friended only toverify_shadow, no call site can construct one without first having provenstate.shadow_hash == FNV(state.prev_cells). Theprev_cellsfield itself is never mutated outside compose (Layer 5 makes the field a private member ofInlineFrame<Tag>and friends only the transitions that legitimately advance the cell buffer). Therefore the cells compose diffs against are exactly the cells the terminal is currently displaying, and the emitted bytes are the byte-for-byte difference. ∎
T3 (monotone scrollback). Once a row leaves the viewport via a
\r\n overflow, no subsequent render targets it.
Proof. Cursor movement is closed under
Viewport(T1). Scrollback advance happens only throughcommit(ScrollbackMarker), which takes a marker minted from the live state viascrollback_marker(rows). The marker's row count is clamped to[0, prev_rows]at issue time; the marker is consumed once (move-only token); the resulting state hasprev_rowsstrictly less than or equal to the prior. The set of row coordinates referenced by future cursor moves is bounded above byprev_rows_at_emit + WwhereWiswire_cursor_rowsfrom the most recent successful emit. Therefore no future emit targets a row index that has overflowed the viewport at any prior emit. ∎
T4 (trim accounting). For any front-trim of a host's sealed inline
prefix, the row count committed to the shadow
(commit_inline_prefix) equals the number of physical rows the
dropped blocks occupied on the wire in the previous frame.
Proof. A ledger host holds its sealed prefix in a
ScrollbackLedgerand renders it throughledger_ref(orConversation::Config::ledger). The paint pass records every block's laid-out height into the ledger each frame — the SAMElayout::computepass, at the SAME width, in the SAME frame as the bytes that reach the wire; there is no second measurement pipeline to drift.drop_front()accrues the dropped blocks' paint-recorded heights as debt, gated on provability: a block whose height has never been recorded by a ledger-tagged paint (recorded < 0) stops the drop walk — a block becomes droppable only after one paint has recorded the height the commit will use, so an under-commit of an unpainted-but-physical block is unrepresentable.harvest()mints the accumulated debt as aScrollbackDebt, whose constructor is private to the ledger;Cmd::commit_scrollback(ScrollbackDebt)is the only consumer. The host can only transport the token — it cannot fabricate, scale, or "adjust" a count. (The raw-int overload survives for non-ledger hosts,[[deprecated]], and is clamped to the provable overflow insidecommit_inline_prefixregardless.) Width-change race: if a trim lands between a width change and the next paint, the minted debt reflects the old width — safe, because a width change demotes inline coherence offSyncedandcommit_inline_prefixis a no-op in any non-Synced state; the debt evaporates against a frame that repaints from scratch. ∎
Together, T0+T1+T2+T3+T4 imply Scrollback Integrity:
- T0 ensures the cells
diff()reads, and the trailing-blank cache the EL optimisation trusts, are coherent with one another — closing the "stacked header / stale tail" ghost class. - T1 keeps the cursor inside the viewport, so no escape sequence reaches above the live region.
- T2 ensures whatever is written to the viewport is the truth (matches the canvas).
- T3 ensures the cumulative effect of overflows commits exactly the truth that was on the viewport at the moment of overflow.
- T4 ensures a host-initiated trim of the sealed prefix advances the shadow by exactly the rows the dropped content physically occupied — closing the stranded-duplicate / eaten-tail ghost classes that every historical host-side trim bug belonged to.
The bytes in emulator scrollback at any moment are the union of:
truths-committed-so-far + truth-currently-on-screen. Both are
identical to paint(view(model_at_that_time)) at the relevant
coordinates. ∎
Stated assumptions (hypotheses of the theorem)¶
Honest type theory names its hypotheses. Maya's claim holds only when all four hold:
-
A1 — Terminal emulator conformance. The terminal honors ECMA-48 cursor control codes, DEC mode 2026 if advertised, EL, ED, DECAWM. A buggy emulator that misinterprets
CSI Aas something other than cursor-up violates the assumption. Out of scope. -
A2 — Kernel write fidelity.
write(2)'s return value accurately reflects bytes delivered to the kernel's tty buffer. Bytes that the kernel reports as delivered are not corrupted in transit. This is a POSIX guarantee; treat as axiomatic. -
A3 — Exclusive fd ownership. Maya's
Writeris the only entity in the process writing to fd 1. Host code that callsstd::coutorfwrite(stdout)outsideCmd::printorRuntime::with_external_iobypasses the entire chain. Documented invariant; cannot be enforced by the type system because C++ does not let us sealstdout. -
A4 — No silent memory corruption. Between
verify_shadowreturning a witness andcompose_inline_frame_v2consuming it, theprev_cellsbuffer is not mutated by an unrelated bug (use-after- free, buffer overrun in adjacent code, cosmic ray). The FNV-1a re-hash inside compose catches mutation with probability1 - 2⁻⁶⁴. Hardware-detected memory errors are out of scope.
The layers in detail¶
Layer 1 — WireRow + Viewport¶
Saturating cursor coordinates. Viewport::for_fresh_frame(term_h) and
Viewport::for_redraw(term_h, wire_cursor_rows) are the two public
factories; both clamp term_h to ≥ 1 and assign a unique ViewportId.
Viewport::row(int) is the sole producer that accepts an arbitrary
absolute row index, and it clamps via std::clamp. WireRow::up(n)
and down(n) return rows clamped inside the same [top, bottom)
range. emit_move(out, from, to) is the sole emitter of cursor-up /
cursor-down escapes.
This closes T1. The set of cursor targets that any emit_move call
can produce is precisely the set of WireRow values, and the set of
WireRow values is precisely Viewport::[top, bottom).
Layer 2 — ContentRows¶
int-wrapper with a private constructor. Sole producer:
content_rows(canvas), which calls content_height(canvas) and binds
the resulting int to the canvas pointer. Future overloads of
compose_inline_frame consume ContentRows. The historical bug class
("computed row count from the wrong source" — layout's height, a
hard-coded constant, a stale prev_rows) becomes uncompilable: no
call site can construct a ContentRows from anything but the canvas
the renderer will paint.
Layer 3 — ShadowWitness¶
Move-only token. Sole producer: verify_shadow(state) -> std::optional<
ShadowWitness>. Returns nullopt iff the FNV-1a hash of
state.prev_cells no longer matches state.shadow_hash (the value
the last successful compose recorded). On match, returns a witness
binding the state pointer and the hash value.
compose_inline_frame_v2 consumes ShadowWitness&&. Inside compose,
the hash is recomputed against the freshly-moved state and compared
with witness.hash_at_issue(). Mismatch is unrecoverable
(std::abort); since the renderer is single-threaded, the only way
the hash can change between two consecutive function calls is
memory corruption.
This closes T2's "shadow matches wire" precondition.
Layer 4 — FrameBytes¶
Capsule type returned by compose_inline_frame_v2. Carries:
std::string bytes_— the byte stream the frame would emit.InlineFrameState successor_— the state value the caller should hold if the bytes ship.
Move-only. The only ways to consume a FrameBytes:
commit_to(writer) && -> CommitOutcome— variant ofcommit::Synced{state}(full delivery) orcommit::Stale{state, reason}(hard I/O error).abandon() && -> commit::Stale{state, ok()}— explicit drop.
The variant is mandatory: structured bindings on std::variant are
ill-formed, so every call site must std::visit both arms. There is
no "success-only" code path; every consumer handles both outcomes.
The state advance and the byte delivery are fused into one operation
whose outcome is a value the caller must destructure. The historical
bug ("state advanced; write returned partial; next compose diffed
against a state the wire doesn't show") becomes a compile error because
the only way to obtain a Synced state is the return value of a
successful commit_to.
Layer 5 — InlineFrame<Tag>¶
Phantom-tagged type-state. Five tags: Empty, Fresh, Synced,
Stale, Sealed. Each is a distinct class (template specialization);
each is move-only; each has a private constructor friended only to the
predecessors in the transition graph:
seed()
InlineFrame<Empty> ────────────▶ InlineFrame<Fresh>
InlineFrame<Fresh> ──render────▶ InlineFrame<Synced> | <Stale> (case A)
InlineFrame<Synced> ──verify────▶ optional<ShadowWitness>
InlineFrame<Synced> ──render────▶ InlineFrame<Synced> | <Stale> (Synced + witness)
InlineFrame<Synced> ─demote_to_stale─▶ InlineFrame<Stale>
InlineFrame<Synced> ──commit(marker)─▶ InlineFrame<Synced> (scrollback advance)
InlineFrame<Stale> ──render────▶ InlineFrame<Synced> | <Stale> (case B)
InlineFrame<*> ─finalize(out)──▶ InlineFrame<Sealed>
InlineFrame<Synced>::render requires ShadowWitness&& as an rvalue
parameter — you cannot render a Synced state without first calling
verify(). InlineFrame<Sealed> has no render method, so
rendering after finalize is a compile error.
The underlying InlineFrameState is held by value as a private member.
It is moved through every transition; the chain is linear.
Layer 6 — ScrollbackLedger + ScrollbackDebt (trim accounting)¶
The layers above close what MAYA emits. Layer 6 closes the one number a HOST is still trusted to supply: how many rows a front-trim of its sealed prefix sheds. Historically the host computed that itself — a parallel measurement pipeline (element re-measure at a host-reconstructed width, resize healing, N bookkeeping vectors kept in lockstep by discipline) — and every historical trim-corruption bug was drift between that pipeline and what maya painted: a 2-column wrap-width mis-reconstruction under-counting on narrow terminals (the phone-over-SSH duplication ghost), a stale post-resize height stamp over-committing and re-scrolling the visible tail, a one-row estimate drift dropping an on-screen entry.
Two moves—plus a third that closes the seal instant—retire the class by construction:
-
Maya measures, not the host.
ScrollbackLedgerowns the sealed blocks (elements + per-block meta in one structure). Rendering goes throughledger_ref(ledger)— anElementListReftagged with the ledger — and the paint pass stamps each block's laid-out height back into the ledger every frame: same layout pass, same width, same frame as the wire bytes. Recorded heights self-heal across resize within one paint. Seal-time estimates exist ONLY for trim policy (row_total()/block_rows()); they never feed debt. -
The commit count is a typed token.
drop_front()accrues the dropped blocks' recorded heights as debt — gated so only paint-recorded blocks may shed rows, and quantized so a separator never leads the remaining prefix.harvest()mints aScrollbackDebt(private constructor, friended only to the ledger);Cmd::commit_scrollback(ScrollbackDebt)is the sole consumer. Fabricating or adjusting a commit count no longer typechecks; the raw-int overload is[[deprecated]]and clamped insidecommit_inline_prefixregardless. -
Maya measures the freeze instant too (
seal_measured). The one measurement left on the host side after moves 1–2 was the seal-time layout that warms the renderer's hash-keyed measure cache so the freeze frame is byte-stable (a block sealed at the freeze instant carries the hash_id the live tail stamped, and the measure path trusts a cached height blindly — a stale live-phase entry would transiently shrink the tree vs prev_cells and fire the render gate). Hosts ran that layout at a width THEY reconstructed ("terminal columns minus the chrome paddings I believe wrap my fragment") — the same drift class as the accounting, at the seal site. Now the paint pass records the ACTUAL width constraintlayout::computehanded the ledger's fragment (record_paint_width), andseal_measured()lays the new block out at exactly that width, inside a scopedRenderContextpinned to it (so the component auto-measure clamp agrees with the paint pass at any terminal size). The measured height doubles as the policy estimate until the first ledger paint records the real value. Accounting still never touches it.
Sole producers, sole consumers, move-only tokens — the same discipline as Layers 1–5, applied to the host boundary. The residual host freedom is policy only: WHEN to trim and HOW MUCH context to keep. A sloppy policy estimate can trim too much or too little content; it structurally cannot corrupt scrollback.
What the test surface looks like¶
Two test files witness the chain at the type level:
-
tests/test_wire_row.cpp— 10 tests coveringWireRowsaturation, closed arithmetic, andemit_movebyte-correctness. Each one shows a representative case (saturate-at-top, saturate-at-bottom, clamp-on-construction, etc.); the proof of T1 is the absence of any public API that escapes the closure. -
tests/test_inline_frame.cpp— 5 happy-path runtime tests plus static_asserts proving: - All five
InlineFrame<Tag>specializations are non-copyable. ShadowWitnessis non-copyable.FrameBytesis non-copyable.ShadowWitnesshas no public constructor.ContentRowshas no public constructor.
The static_asserts are the compile-time proof. They fire if a
future refactor accidentally adds a copy constructor (e.g. via
= default after removing the move-only deletes), regressing the
linear discipline.
The pre-existing tests/test_scrollback.cpp and
tests/test_scrollback_vt.cpp remain unchanged and still pass — they
exercise the legacy compose_inline_frame directly and demonstrate
that the new chain coexists with the old API.
What this design does NOT claim¶
-
It does not prevent bugs inside
compose_inline_frame's diff algorithm. If the per-row emit miscalculates a span, or the wide-char snap fails on a new emoji range, the wrong bytes go out for the right state. Type-state cannot witness algorithmic correctness; property-based fuzzing of the diff is the right complement. -
It does not prevent the kernel or terminal emulator from dropping bytes. Out of scope by A1, A2.
-
It does not prevent the host process from writing directly to fd 1 outside the chain. Out of scope by A3.
-
It does not prevent silent memory corruption in the
verify_shadow→composewindow. The probabilistic FNV-1a re-check is the defense; this is an A4 hypothesis.
What this design DOES claim¶
For any code that compiles against the chain (i.e. uses
InlineFrame<Tag> and compose_inline_frame_v2 rather than the
legacy InlineFrameState + compose_inline_frame direct path):
- Forgetting to verify the shadow before compose is a compile error.
- Rendering after finalize is a compile error.
- Holding two parallel views of the state is a compile error (move-only).
- Advancing state without writing bytes is a compile error (state
is the return value of
commit_to). - Writing bytes without advancing state is a compile error
(
FrameBytesis the only producer of bytes;commit_tois the only consumer). - Targeting a cursor row outside the viewport is a compile error
(
WireRowis the only argument type accepted byemit_move). - Computing row count from the wrong source is a compile error
(
ContentRowshas one producer). - Over-committing scrollback to a state that has already
advanced is a compile error (
ScrollbackMarkeris consumed by exactly onecommitcall, which yields a new state). - Fabricating a trim commit count is a compile error for ledger
hosts (
ScrollbackDebthas one producer,harvest(), minted only from paint-recorded heights).
Each of these used to be a runtime failure detected by carefully- authored checks. Now they fail to typecheck. The compiler is the proof assistant.
Runtime migration — done¶
Runtime::render (src/app/app.cpp) dispatches inline frames through
std::visit over InlineCoherence — the variant of
InlineFrame<Empty | Fresh | Synced | Stale | HardReset> — so the
production renderer consumes the chain end-to-end: the only path into
Synced is a successful commit_to of a witness-verified compose.
The legacy free-function compose_inline_frame survives only as the
internal implementation detail the InlineFrame<Tag>::render
transitions call (frame_bytes.cpp / inline_frame.cpp); host code has
no direct route to it.
On the host side, agentty's sealed prefix is a ScrollbackLedger
(rendered via Conversation::Config::ledger), and its trims mint
commits exclusively through harvest() — the Layer 6 discharge. The
agent_session example demonstrates the same pattern for new hosts.