acta-core 0.1.0-alpha.1

Append-only columnar file format for time-series data
Documentation
# Changelog

Entries under a format heading describe the binary format itself. Entries
under a crate heading describe this implementation's behavior, which can
tighten without the format changing.

## Rust crate 0.1.0-alpha.1 — 2026-08-14

First public alpha of the Rust implementation. The package is published as
`acta-core`, while its Rust library name remains `acta`.

- Added `Reader::refresh`, `Reader::tail`, `RefreshReport`, and `Tail`.
  `Reader::refresh` extends a snapshot in place with exactly the frames
  committed since the reader opened, resuming from its own last committed
  byte offset, expected sequence, and expected implicit row ID without
  rescanning the committed prefix. Every newly complete frame is validated
  exactly as initial opening validates it — prefix and prefix CRC, bounded
  lengths and checked arithmetic, header, payload, trailer, and body CRC,
  frame type and sequence, schema ID, block metadata, implicit row-ID
  continuity, and resource limits — and the snapshot is mutated only after
  the discovery pass succeeds, so a failed refresh leaves every field and
  block vector unchanged. A no-growth refresh succeeds with zero additions; a
  physically incomplete next frame is an interrupted append that exposes no
  block and is reported through `RefreshReport::incomplete_tail`, becoming a
  complete committed frame on a later refresh; a safe Stage 8b repair that
  removed the uncommitted tail is accepted and clears the tail. Refresh never
  truncates, repairs, or acquires the writer lock, so a reader and a writer
  coexist on the same path. A file that shrank below the committed boundary is
  refused with the new `ErrorKind::FileTruncated`.
- A refresh refuses to extend a snapshot from a file that is not the one it
  came from, and checks that two ways because neither alone is enough. It
  compares the path's current file-system identity — not the v0.2 file ID,
  which the deterministic writer stores as a non-unique zero — against the
  identity captured at open, which catches a replacement that unlinked and
  recreated the path. Because an in-place truncate-and-rewrite keeps that
  identity while replacing every byte, it also re-reads the commit trailer of
  the last frame the snapshot committed — its length, sequence, body CRC, own
  CRC, and commit magic — and refuses unless it still matches, at the cost of
  one read rather than a pass over the file. On growth it compares the
  prologue and schema it re-reads against the ones the snapshot holds, so a
  reader can never end up validating new frames against a schema its own
  `Reader::schema` does not return. Any mismatch is the new
  `ErrorKind::FileReplaced` rather than a silent adoption. A snapshot holding
  no data blocks has only its prologue and schema frame to be recognised by,
  so a replacement whose prologue and schema are byte-identical is accepted —
  but such a file agrees with everything that snapshot has exposed.
- `Reader::tail` returns a `Tail` that polls the path for newly committed
  frames synchronously, one block per poll, starting after the blocks the
  reader already holds. Polling never sleeps, spawns a thread, or owns a
  timer; `Ok(None)` means "pending now", never end-of-stream, so `Tail` is
  not an `Iterator` and cannot be fused, and cancellation is simply dropping
  the tail. A block that prunes on its bounds or filters down to no rows is
  consumed inside the poll that reaches it rather than reported as `None`, so
  waiting on `None` never means waiting for data already on disk.
  `Tail::metrics` reports more bytes than an equivalent `Scan`, because it
  counts the frames its refreshes read to discover the blocks as well as the
  bytes its decodes read; the row and decoded-byte allowances from `Limits`
  still bound decoding only. Projection, reordered and empty projection, timestamp and `date32`
  primary ranges, pruning, file order, per-block decode-error handling, and
  cumulative scan limits all match `Scan`, and a partial frame is never
  exposed. A tail borrows its reader, so the reader cannot be refreshed
  directly while a tail exists — the same snapshot safety boundary `Scan`
  draws.
- Added the `ErrorKind::FileReplaced` and `ErrorKind::FileTruncated` kinds for
  a long-lived reader whose path now names a different file than the one its
  snapshot came from, or still names its own file but has lost committed
  bytes. Neither is corruption of the file the reader holds, and a caller can
  now tell all three apart without matching on a message.
- Added the `acta_append` test-only binary used by the multi-process tail
  coverage to append one frame from a separate process. It is a bin target
  behind the non-default `test-fixtures` feature, so `cargo install` never
  puts a test fixture on a user's PATH; CI enables the feature.
- Added `Reader::refresh` and `Reader::tail` coverage for no-growth refreshes,
  one and several added blocks, exactly-once cycles, buffered versus flushed
  writer rows, reader/writer coexistence, every incomplete-tail cut,
  completion, repair acceptance and continuation, row-ID continuation,
  corruption/sequence/schema/row-ID/resource-limit rollback, committed-prefix
  truncation, same-path replacement with different schemas and identical
  lengths, in-place rewrite that preserves the file identity, pruned and
  emptied blocks that must not hide the block behind them, discovery bytes in
  tail metrics, clone and old-scan isolation, projection and primary ranges across
  refresh boundaries, zero-column row counts, validation agreement, pending
  polls, cancellation by drop, date32 and sorted/unsorted filtering, finite
  cumulative limits, and multi-process tailing.
- Added read-only `inspect_recovery` and explicit `repair_incomplete_tail`
  operations. Inspection is a point-in-time structural snapshot; repair
  independently locks and rescans the current file, then truncates only a
  physically incomplete final data frame before synchronizing and scanning it
  again. Complete corruption, checksum-failing final frames, and incomplete
  schema frames are refused, and repair never accepts a caller-provided offset.
  Every failure before the truncation leaves the file unchanged and says so;
  every failure that can follow one — the synchronization, the rescan, or the
  post-repair validation — instead says the tail was already removed, whatever
  its `ErrorKind`. The two sets of messages never overlap, so the two sides of
  the mutation are always distinguishable.
- Added `WriterOptions::zstd_level` and `WriterOptions::with_zstd_level`,
  selecting the Zstandard compression level the writer applies, alongside the
  new `DEFAULT_ZSTD_LEVEL` constant that names the level the writer has always
  used. This is additive: `WriterOptions` is `#[non_exhaustive]`, the field is
  reachable only through the builders, and the default is unchanged, so a file
  written without asking for a level is byte-for-byte what the previous writer
  produced. `Writer::create` and `Writer::open` refuse a level outside the
  range the linked Zstandard library reports, before the file is created or
  opened; the level is validated only when the selected codec is Zstandard.
- Added the `WriterStatistics` writer policy and `WriterOptions::statistics`,
  selected with `WriterOptions::with_statistics`. This is additive rather than
  breaking: `WriterOptions` is `#[non_exhaustive]` and the field is reachable
  only through the builders, and `WriterStatistics::None` is the default, so a
  file written without asking for statistics is byte-for-byte what the Stage 7b
  writer produced under every encoding, codec, row-ID, and block-target
  combination.
- `WriterStatistics::MinMax` writes a canonical section 11 minimum and maximum
  for every column whose logical type has a fixed canonical width, including
  the primary column, whose bounds section 11 says need not be repeated. Nulls
  and NaNs are ignored, a column left with no value gets no statistic,
  infinities are ordinary bounds, and equal floating-point values keep the
  first bit pattern seen, so `-0.0` and `0.0` resolve deterministically without
  either being claimed as the smaller number. `utf8`, `categorical`, and
  `binary` have no v0.2 min/max encoding and never receive one.
- `WriterStatistics::Automatic` applies one deterministic rule: not the primary
  column, at least 64 non-null non-NaN values, and raw dense value bytes at
  least eight times the pair. The size test binds only on `bool`, where it
  raises the floor to 121 values. The rule depends on nothing outside the
  block's contents and the schema, so equal blocks produce equal bytes whatever
  the append partitioning.
- Statistics are charged against the frame header budget while a block is
  priced, so the writer never assembles a block whose statistics it could not
  publish. A schema whose pairs cannot fit that budget — reachable only through
  very wide `fixed_binary` columns — is now refused by `append` with an error
  naming the statistics policy rather than reporting an oversize row.
- **Breaking:** Added `WriterEncoding::Fixed(WriterTransform)` and the public
  `WriterTransform` enum it carries. `WriterEncoding` is not
  `#[non_exhaustive]`, so a downstream match that covered `Raw` and `Adaptive`
  exhaustively no longer compiles. The default policy and the output of the
  existing policies are unchanged.
- The fixed policy applies one requested transform or layout to every column
  value stream, prices nothing, and never falls back to raw. `Writer::create`
  refuses a transform this writer does not offer for one of the schema's
  logical types, before the file is created; a block whose values the transform
  cannot describe fails as `ErrorKind::InvalidArgument` when it is published,
  which poisons the writer and leaves the blocks before it readable. The
  offered set is exactly the section 12 candidate set `Adaptive` prices, which
  is narrower than the format permits — a dictionary `timestamp64` column is
  legal v0.2 that neither policy writes — so a fixed file is always a shape
  adaptive could also have produced. Validity streams stay raw under `Fixed`.
- Added column projection, primary-range filtering, and block pruning to
  `Reader::scan`. `Scan` gained `project`, `primary_range`, `file_order`, and
  `metrics`; `PrimaryRange` and `ScanMetrics` are new public types. The default
  projection is every column in schema order, so an existing scan is unchanged.
  `Reader::read_block` is unchanged and still decodes every column.
- **Breaking:** `Scan` no longer implements `ExactSizeIterator`. Pruning and
  row filtering mean the number of remaining items cannot be known before the
  blocks are read: a block whose bounds overlap a range may hold no matching
  row and yield nothing. `Scan::size_hint` now reports a lower bound of zero
  and the candidate-block count as its upper bound. The nearest replacement for
  the removed `len` is `Scan::remaining_candidate_blocks`, which is deliberately
  named differently because it is an upper bound rather than a count; code that
  called `len` through the trait will not compile against the new method, which
  is preferred here to the same call silently changing meaning.
- Added cumulative `Limits::max_rows_per_scan` and
  `Limits::max_decoded_scan_bytes`, bounding one scan across all of its blocks.
  Both default to no limit, so existing scans do not acquire a cap. Pruned
  blocks and unprojected columns are never charged, per-block limits still
  apply independently, and `Reader::read_block` does not inherit a scan's
  cumulative state.
- A projected read now defers the unsupported-transform and unsupported-codec
  checks for columns it will not decode. Structural descriptor, stream-range,
  length, and frame-envelope validation still cover the whole block, and every
  read that does decode such a column still fails. This widens the set of files
  a projection can read; it does not widen the set of files that validate.
- Statistics verification, section 8 primary bounds, and `TS_SORTED` are
  checked for exactly the columns a read decodes, which for a range scan
  includes the primary column even when it is not projected.
- Added `ValidationLevel`, `ValidationOptions`, and `validate_with_options`.
  `validate` and `validate_with_limits` keep their existing structural
  behavior; full validation decodes every complete block and checks optional
  min/max statistics against the decoded values.
- Tightened data-frame column-descriptor validation: a column whose statistics
  flag is clear must now store a zero statistics offset and a zero statistics
  length. Section 8.1 requires statistics offsets to lie in the statistics
  area but does not spell out what the fields hold when a column claims no
  statistics, so this rule is stricter than the format text. It rejects a file
  whose descriptor carries stale or arbitrary bytes in fields it also declares
  meaningless, which is a writer defect this crate would rather name than
  ignore. This affects block decoding and full validation only; structural
  validation does not read the column descriptor table. No writer in this
  crate has ever emitted such a descriptor, and every checked-in v0.2 fixture
  satisfies the rule.
- Statistics verification now runs per column, immediately after that column's
  values are reconstructed, instead of after the whole block is assembled. The
  set of accepted files is unchanged; when a block has more than one fault the
  error that surfaces first may differ.
- Data-frame column-descriptor errors now name the offending column.

## Acta file format v0.2 RC1 — 2026-07-24

Second release candidate of the Acta binary format.

- Made the primary timestamp column optional while retaining time-series
  indexing as the normal optimized path.
- Assigned primary timestamp column ID `0` to mean that no primary time column
  is present.
- Required blocks without a primary time column to store zero timestamp bounds
  and leave `TS_SORTED` clear.
- Defined writer-facing primary selection as an explicit column name,
  `"auto"` for the first timestamp or date column in schema order, or `None`
  to omit native time indexing.
- Defined readers without a primary time column to scan in file order without
  timestamp-range filtering, timestamp pruning, or timestamp ordering.
- Added v0.2 compatibility fixtures for all existing cases and a new
  no-primary fixture with semantic, corruption, and interrupted-append tests.
- Preserved the complete v0.1 specification and fixture set unchanged.

Files use format version `(0, 2)` in the prologue. Readers that only implement
v0.1 must reject them as unsupported.

## Acta file format v0.1 RC1 — 2026-07-20

First release candidate of the Acta v0.1 binary format.

- Frozen the 64-byte file prologue and generic frame layout.
- Defined fixed-schema columnar data blocks and per-block encoding selection.
- Defined checksummed append, discovery, projected reads, and recovery semantics.
- Defined explicit file-format version and compatibility behavior.
- Froze compatibility fixtures covering minimal framing, representative typed
  columns, implicit row IDs, `TS_SORTED`, and a `date32` primary column.

Files use format version `(0, 1)` in the prologue. Any incompatible change to
the binary representation or its required interpretation will use a new format
version.