ADR-0008: Append-only published-prefix slab pages¶
Status: Proposed Date: 2026-07-13 Issue/PR: pending
Context¶
ADR-0007 adds a current, unwired seal-before-publish slab page. That safe implementation proves bounded layout, generation checks, exact descriptor membership, CRC32C validation, and page-level lifetime, but it withholds every descriptor until the complete page is sealed. The target event fabric must let the ingress owner publish each descriptor immediately after its payload becomes immutable so the consumer can overlap page production and consumption.
Publishing a mutable prefix creates a new unsafe and concurrency boundary. A reader must never observe partially copied bytes, an uninitialized descriptor, or a descriptor whose bytes can later be overwritten. Reclamation and reuse are separate obligations: solving publication does not establish ABA freedom, tenant-safe registry lookup, NUMA-owner destruction, or bounded epoch pins. Combining those mechanisms in one change would make failures difficult to localize and would remove ADR-0007 as an independent correctness oracle.
This decision therefore covers only an append-only, preallocated page whose writer and reader endpoints are created together before the first append. The prototype remains outside every production path. It cannot carry protected evidence, does not make the target event fabric qualified, and provides no authorization-latency or ingestion-throughput measurement.
Decision¶
Add PublishedSlabPage to aegis-event. Construction preallocates one bounded
byte-cell array and one bounded descriptor-cell array, then split consumes the
setup handle and returns exactly one non-cloneable writer and one non-cloneable
reader. The writer appends to never-before-written cells and publishes one
packed, monotonic state word containing descriptor count, byte watermark, and
closure with a Release store. The reader performs one Acquire load of that
state before reading a committed descriptor slot or borrowing its immutable
byte range.
The prototype contract is:
- it reuses
SlabPageConfiglimits: byte capacity is1..=64 MiB, descriptor capacity is1..=65,536, and one payload is1..=1 MiB; - payload-cell and descriptor-cell reservations are fallible and return the
existing typed
SlabConfigError; the smallArcmetadata allocation remains subject to the process OOM policy; - each reservation is filled to its configured length before the page enters
the
Arc; the privateVec<ByteCell>andVec<DescriptorCell>metadata, lengths, capacities, and backing addresses never change after construction; splitcreates one writer and one reader; neither endpoint is cloneable and both are!Sync; each may move to its owner thread;- the writer's staged descriptor cursor, used-byte cursor, and next sequence are
private, non-atomic state accessed through
&mut self; the packed atomic word is the authoritative cross-thread committed prefix; - append checks payload length, descriptor budget, checked offset arithmetic,
byte budget, and
u32representability before touching page cells; - FlatBuffer verification and redaction are upstream preconditions; this API validates slab-local framing and integrity only;
- append computes CRC32C, copies the payload once into a never-before-written
byte range, writes the complete canonical descriptor into a
never-before-written descriptor slot, and then
Release-stores one state word containingpublished_count = slot_index + 1andpublished_bytes = end_offset; only after publication does it commit the staged writer-private cursors; - no fallible, allocating, callback, formatting, indexing, or other
panic-capable operation occurs after the first cell write and before the
Releasepublication. The writer marks that interval poisoned; if unwinding is nevertheless forced and caught, future append attempts fail permanently rather than overwriting a partially initialized suffix; - the
Releasestore is the append linearization point. No descriptor is returned before that store completes; - published count starts at zero, increases by exactly one, never exceeds the configured descriptor capacity, and cannot wrap within one page; published bytes starts at zero, increases by the non-empty payload length, and never exceeds byte capacity;
- descriptor sequence advances modulo
2^64. Because a page holds at most2^16descriptors, modular distance fromfirst_sequenceis unambiguous; - the reader validates exact arena ID and generation, loads the packed state
with
Acquire, validates reserved bits/count/byte watermark against page capacities, bounds the modular sequence distance by the published count, reads the canonical descriptor slot, and requires exact descriptor equality before accessing caller-selected payload bytes; - only the canonical descriptor selects the payload range. Checked offset/length arithmetic against the coherently published byte watermark and CRC32C equality are then required before returning a view;
- the native reader returns a wrapper around a borrowed slice into the page; resolution performs no payload copy, allocation, or reference-count change;
- the Loom feature uses modeled atomics and checked cells. Its returned payload
wrapper owns a small model-only copy because a reference cannot escape a Loom
UnsafeCellaccess guard; Miri and sanitizers cover the native borrowed-view implementation; - dropping the writer
Release-publishes closure. The reader may drain every committed descriptor and retain page ownership after writer drop; - cells are never reset, overwritten, or reused. There is no registry, epoch, generation allocator, NUMA pool, rotation policy, priority lane, WAL, or production integration in this decision.
Ownership, publication, and memory-order proof¶
setup handle
-- consume/split --> one writer + one reader, both owning the page Arc
writer append i:
validate all bounds
write byte cells [offset_i, end_i) exactly once
write descriptor cell i exactly once
Release store pack(count = i + 1, bytes = end_i) <-- linearization
commit staged writer-private cursors
return descriptor i
reader resolve descriptor i:
validate page identity
state = Acquire load publication_state
validate state and require i < state.published_count
read canonical descriptor cell i
require exact descriptor equality
validate canonical byte range and CRC32C
borrow immutable byte cells [offset_i, end_i)
There is one writer, so descriptor slots and payload ranges are assigned in
strict append order without write/write races. A reader accesses slot i only
after an Acquire load observes a published count greater than i; that load
synchronizes with the Release store sequenced after both the byte copy and the
descriptor write. The byte watermark in the same state snapshot independently
bounds canonical ranges to initialized published bytes. Later appends touch
only disjoint cells, so an outstanding borrow into an earlier committed range
cannot alias a write. No committed cell is ever mutated.
Relaxed is insufficient for state publication because it would not
make preceding cell initialization visible. SeqCst adds a global order that
the single-writer prefix protocol does not require. Per-cell ready flags are
unnecessary because the committed prefix has no gaps. The packed AtomicU64 is
cache-line aligned with #[repr(align(64))]; bits 0..=31 encode published
bytes, bits 32..=48 encode the 17-bit published count, bits 49..=62 are
reserved and must be zero, and bit 63 encodes writer closure. The capacity
limits fit those fields exactly. Closure shares the publication line because it
is written once at shutdown and does not create an independent hot cursor.
The progress property of a successful append is wait-free with respect to
other threads after bounded O(n) CRC and copy work for payload length n.
Resolution is wait-free after bounded O(n) CRC work. Neither operation loops,
blocks, grows a collection, calls an allocator, waits on another core, or
silently overwrites data. Capacity exhaustion is an immediate typed error.
Unsafe invariants¶
The native implementation uses #[repr(transparent)] wrappers around
UnsafeCell<MaybeUninit<u8>> and
UnsafeCell<MaybeUninit<TelemetryDescriptor>> because safe slices cannot
express one writer mutating an unpublished suffix while another owner borrows
an immutable published prefix. Every unsafe block must preserve all of the
following:
UnsafeCell<T>andMaybeUninit<T>haveT's representation; the transparent wrappers therefore preserve the nativeu8andTelemetryDescriptorsize/alignment, and their arrays remain at stable allocation-derived addresses from construction through final page drop;- the writer is the only mutator and writes each addressed cell exactly once;
- all pointer arithmetic is derived from the owning allocation and preceded by
checked range validation against the allocated cell count; no
&u8,&mut u8,&TelemetryDescriptor, or&mut TelemetryDescriptorspans the whole allocation or an uncommitted suffix; - no reader accesses a descriptor or byte cell until an
Acquirestate snapshot proves the corresponding count and byte prefix was committed; - a descriptor is fully initialized before its state-word publication;
- a returned native slice contains initialized
u8cells only, is bounded by one canonical descriptor, and cannot overlap any later write; - the slice lifetime is bounded by the reader's page-owning
Arc; dropping the writer cannot invalidate it; TelemetryDescriptorandu8have no drop obligation in uncommitted cells;- final
Arcdrop occurs only after both endpoints and all reader borrows are gone; uncommittedMaybeUninitcells require no destruction; - there is no external endian, persistent, or shared-process ABI in this prototype.
The writer stages every slice/slot lookup, integer conversion, CRC, descriptor,
next cursor value, and packed state before setting its poison bit and performing
the first raw write. Native payload copying is then one copy_nonoverlapping
between proven disjoint ranges; the source may be an earlier committed payload
because its range cannot overlap the append-only destination. Descriptor
initialization, one atomic store, writer-local assignments, and poison clearing
are the only subsequent operations. If an injected or platform fault unwinds
before the store, the atomic prefix is unchanged, the partial tail is
unreachable, and the poisoned writer cannot resume. Writer drop sets only the
closed bit with an atomic read-modify-write against the authoritative published
state; it never derives closure from staged cursors and therefore cannot expose
the partial tail. If an interruption is forced after the Release store but
before cursor commit/return, the complete prefix remains published and closure
preserves it, while the poisoned endpoint cannot append again; production
integration must recover the resulting committed-but-undelivered descriptor
through its future composite admission/WAL protocol. Process abort needs no
in-process recovery.
The page cell arrays contain fixed-size elements and never resize. Native tests assert the transparent-cell sizes and alignments, stable view pointers, and borrow validity while later disjoint appends occur. The safe sealed page remains the differential oracle for descriptor values and resolved payload bytes.
Logical memory layout¶
publication cache line (64-byte aligned)
+-------------------------+---------------+---------------------------+
| bytes:u32 | count:u17 | reserved:14 | closed:1 |
+-------------------------+---------------+---------------------------+
payload cells (preallocated, append-only)
+=================+=================+-------------------------------+
| committed frame | committed frame | unpublished/uninitialized |
| 0 bytes | 1 bytes | suffix |
+=================+=================+-------------------------------+
0 offset[1] writer.used_bytes capacity
descriptor cells (preallocated, append-only)
+======================+======================+-----------------------+
| committed desc 0 | committed desc 1 | uninitialized suffix |
+======================+======================+-----------------------+
0 published_count
This is a process-local layout, not a stable wire or disk format. The 32-byte descriptor remains the prototype ABI from ADR-0006. Any mmap, cross-process, or persistent representation requires a version, endian contract, checksums, and a golden corpus in a separate ADR.
Copy and allocation ledger¶
| Boundary | Ownership before → after | Payload copies | Allocation/refcount behavior |
|---|---|---|---|
| page construction | allocator → setup handle | zero | one bounded byte reservation, one bounded descriptor reservation, and one small page Arc allocation |
| caller slice → unpublished suffix | caller → writer-owned cells | one bounded copy | no append-time allocation or growth |
| writer → reader publication | same page allocation remains endpoint-owned | zero | one 32-byte descriptor value returned; one Release atomic store |
| descriptor ring transfer | page remains reader-owned | zero | ring copies the 32-byte descriptor; no payload refcount |
| native resolve → parser | reader-owned page → borrowed view | zero | no allocation, copy, or Arc operation per resolve |
| Loom resolve | modeled cells → model assertion wrapper | one model-only copy | test-feature artifact; excluded from shipping copy claims |
| endpoint drop | last endpoint → allocator | zero | final Arc decrement may deallocate on either endpoint owner |
Kernel/user, TLS, gRPC, FlatBuffer verification, decompression, Arrow, browser, WASM, and GPU transfers are outside this page-local boundary and remain visible in their owning copy ledgers.
Failure and overload behavior¶
- invalid capacity fails before cell initialization;
- payload or descriptor reservation failure returns a typed allocation error;
- empty or oversized payload fails without changing writer state or cells;
- a poisoned writer fails closed without touching cells or publication state;
- descriptor or byte exhaustion fails without advancing sequence, committing a slot, growing storage, spilling, evicting, or overwriting;
- checked-add or descriptor-address conversion failure is non-mutating;
- identity/generation mismatch, unpublished sequence, invalid packed state, exact-descriptor mismatch, invalid canonical range, or CRC mismatch returns no payload view;
- CRC errors expose sequence only, not payload bytes or expected/computed CRC;
- reader polling is not built into the page. An unpublished descriptor returns immediately; bounded retry/admission belongs to the owning reactor;
- writer drop freezes the published prefix permanently. It does not seal, recycle, transfer tenant scope, or make uncommitted suffix cells readable;
- process OOM for the small
Arcmetadata allocation remains the workspace process policy; no append-path recovery allocation exists; - no failure can loosen Cedar, consume an approval, weaken receipt durability, or convert protected evidence to best effort.
Security consequences¶
The page is an internal post-authentication boundary. It does not derive tenant identity; construction and endpoint routing must remain inside one authenticated tenant/shard scope. A tenant-agnostic global lookup table is prohibited. Exact identity, generation, committed-prefix, and canonical descriptor checks prevent ordinary stale, forged, relabeled, or redirected descriptors from selecting bytes. Canonical membership is checked before CRC scanning so attacker-supplied offset/length/CRC fields cannot turn the resolver into an arbitrary-range CRC oracle.
CRC32C detects accidental corruption and is not authentication. It cannot
replace FlatBuffer verification, receipt chaining, action hashes, Ed25519, or
tenant binding. Payload bytes may contain sensitive telemetry, so upstream
redaction and the raw-credential prohibition remain mandatory. Public errors
and Debug implementations must never include payload contents.
Residual risk includes unsafe aliasing defects, process-local arena metadata, caller-managed generation identity, final deallocation on the consumer core, and absence of bounded page accounting. Production wiring remains blocked on maintainer unsafe/security review, sanitizers, authenticated registry design, generation/reuse proof, epoch/NUMA retirement, rotation/admission policy, and shadow loss/equality evidence.
Performance hypothesis and benchmark method¶
For payload length n, append performs O(n) CRC plus one O(n) copy and
O(1) descriptor initialization/publication. Resolve performs O(1) identity,
sequence, and table checks plus O(n) CRC and returns an O(1) borrowed view.
These costs are independent of total page population. The design removes the
whole-page seal latency and permits producer/consumer overlap, but it does not
prove a throughput or latency target.
Qualification must compare sealed and published-prefix pages on the declared hardware with identical payload distributions and include p50/p95/p99/p99.9, events/s, bytes/s, allocation/event, copied bytes/event, cycles/byte, branch and cache misses, cache-line transfers, producer stalls, errors, and saturation. Tests must verify no lost, duplicated, reordered, or mismatched descriptors. The measurement must separate CRC/copy cost from ring transfer and protected durability. Raw histograms and hardware/kernel/NUMA configuration are required.
Alternatives considered¶
- Keep seal-before-publish only — preserves safe Rust but adds page-fill or timeout latency and prevents overlap within a page; retained as the oracle, not the target publication mechanism.
- Add page reuse and epochs now — rejected because registry publication, generation allocation, pin duration, retirement owner, and ABA freedom are independent proof obligations with different failure modes.
- Atomic flag per descriptor or byte — rejected because it increases memory and cache traffic while a single writer already guarantees a gap-free prefix.
- Atomic committed count without a byte watermark — sound when the canonical descriptor is the sole initialization proof, but rejected because one packed word can also fail closed if malformed internal metadata selects reserved or unpublished tail bytes.
- Atomic committed byte count only — rejected because it does not prove a canonical descriptor slot is initialized and makes variable-length record lookup ambiguous.
- Return
Arc<[u8]>/Bytesper event — rejected because it creates per-event allocation or reference-count traffic and weakens page ownership. - Copy on native resolve — rejected because it hides lifetime mistakes and violates the page-local zero-copy objective; Loom alone uses a disclosed model-only copy.
Relaxedpublication — rejected because descriptor and byte initialization would have no happens-before edge to the reader.SeqCstpublication — rejected because no cross-page total order is needed; Acquire/Release is the minimal sufficient protocol.- Crossbeam epoch in an unwired page pair — rejected because there is no pointer registry or reuse path to reclaim, and adding an unused epoch guard would not prove production lifetime.
Verification¶
cargo test -p aegis-event
cargo test -p aegis-event --features loom loom_published
cargo +nightly miri test -p aegis-event
cargo clippy -p aegis-event --all-targets --all-features -- -D warnings
cargo tree -p aegis-event --edges normal
RUSTFLAGS='-Zsanitizer=address' cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu -p aegis-event --test published_slab
RUSTFLAGS='-Zsanitizer=thread' cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu -p aegis-event --test published_slab
Required coverage includes constructor bounds and allocation classification;
immediate same-page publication; identity/generation/metadata forgery rejection;
future-sequence rejection; byte and descriptor saturation with state and
sequence preservation; sequence wrap at u64::MAX; writer closure and page
lifetime; stable native borrowed pointers while later disjoint appends occur;
no per-resolve Arc change; deterministic corruption rejection; long
cross-thread descriptor-ring stress; byte-for-byte and descriptor-for-descriptor
differential tests against ADR-0007; Loom exploration of read-before/read-after
publication and shutdown; Miri native alias/lifetime checks; ASan/UBSan and TSan
where supported.
Loom must execute the same packed-state and cell-access algorithm. Its model-only payload copy is not accepted as native lifetime evidence. Miri and sanitizers must execute the native borrowed-view path.
The 2026-07-13 Rust nightly exposes address and thread sanitizers but rejects
-Zsanitizer=undefined; Miri is the current Rust undefined-behavior/provenance
gate. This limitation is recorded rather than relabeling another check as
UBSan. It remains an unmet CONTRIBUTING.md acceptance gate for this ADR; Miri
does not satisfy that requirement. Any future C/C++/FFI boundary must also add
its toolchain's real UBSan lane before acceptance.
Migration and rollback¶
The implementation is current only as isolated, unwired prototype code while this ADR is Proposed. It receives no production traffic, protected evidence, schema authority, or durable state. Rollback deletes the published-prefix module and ADR references while retaining ADR-0007's safe sealed page and ADR-0006's descriptor ring; no data migration or runtime flag is involved.
Future shadow integration must retain the current SQL/Tokio telemetry path and
use the documented per-tenant telemetry_wire and event_write generations.
Cutover additionally requires authenticated page routing, bounded outstanding
pages, epoch/NUMA reuse, crash/loss tests, shadow equality, reproducible tail
latency evidence, and release-artifact rollback.
Revisit when¶
Revisit before any arena registry, reset/reuse, crossbeam-epoch reclamation, NUMA pool, production reactor, or protected-evidence integration. The next ADR must define identity allocation and wrap, authenticated registry scope, pin and retire ownership, page rotation/flush deadlines, outstanding-page admission, priority policy, and sanitizer/benchmark qualification. It must retain the sealed oracle and this append-only implementation as differential references or justify their replacement.
References¶
- ADR-0006: Cache-padded SPSC event fabric
- ADR-0007: Sealed generation-tagged slab pages
- Mandatory architecture law
- Target HLD: Shared-memory SPSC event bus
- Target HLD: Protocol and copy contract
- Target LLD: Event fabric
- Target LLD: Descriptor ABI
- Migration matrix
- Contribution standard: lock-free structures
- Contribution standard: unsafe Rust
- Contribution standard: zero-copy claims