ACTIAS

The directory

How one row per object becomes a queryable index, and why a crash can only make it late, never wrong.

The contract

Queries can recheck candidates against the objects themselves, so the directory only has to be a superset of the truth.

FailureCostVerdict
stale row still matchesone skipped candidate on recheckfree
row missing or lostan object no query can ever findfatal

Everything below follows from that asymmetry. The write path is lazy, coalesced, and outside every acknowledgment; the one hard job is never losing a row.

flowchart TD
    call([Call that wrote]) --> txn[Row commits in the call's transaction]
    txn --> ship[Ship flight settles;<br/>the object manifest carries the row]
    ship --> sync[Node syncer coalesces every local object]
    sync --> delta[(One delta file per node per window)]
    delta --> reader[Readers apply the delta in place]
    delta --> comp[Compactor folds deltas into a new base]
    ship -. every repair path reads the manifest copy .-> repair[sweep, checksum gate, rebuild, backfill]
    repair --> delta

Declaration at publish

The field set is data

fields is a table of kind markers, so the publish reads the field set without running anything. The contract records the names and kinds.

Refused at publishWhy
a class with a directory and neither migrations nor an inita row derives from state, and the class has no schema to hold any
a directory with no fieldsa listing of names is what every class already has
a field named any, all, none or namereserved by the predicate grammar
an empty name, a name over 128 bytes, or one containing , : =the name is carried in the contract's own text form
a kind that is not one of the fivestring, integer, number, boolean, array

dver

Every publish gives the class's field set a version, dver. A publish whose field set is unchanged keeps the previous dver; a changed set takes the next one; a class's first publish is 1. A deploy that only changes values renumbers nothing and rebuilds nothing.

The row

Derived in the call's transaction

After a method that wrote returns, before its transaction commits, the platform runs from and the extractors on the call's own connection. The row lands in two reserved tables in the object's file, __actias_directory and __actias_directory_meta, which script SQL cannot see. It ships with the object's write-ahead log like every other byte of state.

The derivation runs read-only: the connection's authorizer refuses any write, and the state it sees is name, sql, store and now only. A row identical to the last one at the same dver bumps nothing and produces no delta, so a hot object with a stable row costs the index nothing.

Contained failure

from and each extractor are guest code the platform evaluates, so a failure is contained, never propagated. Read-only enforcement means a failed evaluation has nothing to roll back.

EventEffect
throws on a live writethe write commits; the row keeps its last good value plus a failed marker naming the rev and dver
exceeds the 5 ms budgetsame as a throw
returns an undeclared field, or a value of the wrong kindsame as a throw; an integer satisfies number, a fraction never satisfies integer
the encoded row exceeds 4 KBsame as a throw; nothing is truncated
throws during backfillsame marker, same counters
fixed function publishedthe new version's backfill re-evaluates exactly the marked rows

The budget is separate from the caller's call budget, but the evaluation runs on the object's pinned task, so 5 ms is also the bound it can add to the mailbox. A class with a broken function stays findable by every last good row.

Merge order

Rows carry (epoch, rev, dver) and merge last-writer-wins, compared in that order. Deltas are an unordered bag of content-addressed files, so this comparison is the entire ordering story.

ComponentMinted bySettles
epochthe placement sequence, at each lease claim; a delete takes the next value with its tombstonea reborn name outranks its own gravestone; a new holder's rows beat a dead node's ghost revs
reveach derivation within an epocha later write beats an earlier one; a placeholder is rev 0 and loses to any real row
dverthe publish that declared the field seta backfilled row beats a manifest copy of the same state under an older declaration
equalequal versions carry equal rows; keeping either is correct

A zombie ex-holder can keep uploading deltas forever. It cannot settle new state, because the object manifest's epoch fence refuses its flights, so its rows describe old durable states and lose on epoch.

The syncer

One syncer per node, not per object. It collects the rows of every settled flight on the node and flushes them as one delta every DIRECTORY_FLUSH_MS (200 ms), which is the coalescing window and therefore the floor of the freshness window.

Every settled flight offers the row its manifest carries, at the flight's epoch, so a row is emitted only after the state it describes is durable. The index describes durable states only; a crash cannot leave it describing a state that was rolled back.

A delta that fails to upload keeps its rows for the next flush. A row that cannot be encoded is dropped from the flush with a log line rather than retried forever, and the next repair pass re-offers it.

The files

Per class, in the blob store:

directory/{project}/{class}/manifest.json
directory/{project}/{class}/bases/b-{hash}.sqlite
directory/{project}/{class}/deltas/d-{hash}.sqlite

Every base and delta is immutable and named by its content hash. The manifest is the only mutable key, and only the compactor writes it. The layout is one base plus a bag of unfolded deltas; nothing is sharded.

Four loops put deltas: the syncer, the backfill, the crash sweep and the operator rebuild. Content addressing is what makes node identity irrelevant: a retried upload is idempotent, a duplicate delta is absorbed, and no name collision can corrupt anything.

The compactor

Fold cadence

flowchart LR
    d1[(delta)] --> d2[(delta)] --> d3[(…)] --> d16[(16th delta)]
    d1 -. 60 s old .-> fold
    d16 --> fold{fold}
    ask([reconciliation or<br/>the operator verb]) --> fold
    fold --> base[(new base, next generation)]
    base --> gc[folded deltas and the base before the previous one collected]

Readers apply deltas themselves, so a fold is not what makes rows visible. The compactor checks every DIRECTORY_COMPACT_SECS (10 s) and folds when sixteen deltas have gathered or the oldest is a minute old. A fold rewrites the whole base, about a kilobyte per row, so a class of 100k rows is a 100 MB put; the cadence exists to make that rare. The fold a reconciliation pass or an operator asks for runs at once.

What a fold records

The manifest names the base, its generation, the folded deltas, the row count, the identity checksum, the lowest dver any row carries, and the field declarations observed. The lowest dver is what tells a reader which fields are still building.

Fencing and garbage

A fold runs under the class's compaction lease. Writing the manifest rereads the current one and refuses if its generation moved, so a compactor that lost its lease cannot overwrite a newer fold; the lease is the serializer, the generation check the second guard. After a fold the folded deltas and any base older than the previous generation are collected, leaving one generation of rollback margin.

The overlay

Reader placement

flowchart TD
    q([query arrives on any node]) --> rv{rendezvous over the class key<br/>names this node?}
    rv -- yes --> local[answer from the local overlay]
    rv -- no --> hop[one hop to the reader,<br/>marked so it serves rather than routes]
    hop --> ok{answer?}
    ok -- page or refusal --> back[returned as is]
    ok -- transport failure --> local

Each class has a reader: the live node that wins a rendezvous hash over the class key, the same choice reconciliation makes for its owner. Membership is cached for ten seconds, so a query costs no registry read. A refusal from the reader is the caller's answer; any other failure answers locally, so placement is a preference and never a dependency.

Build once, apply in place

The reader keeps a local overlay per class: the base restored once per generation, then every delta that arrives in that generation applied in place, which costs the delta's rows rather than the class's. Applies are upserts guarded by the merge order in SQL, so applying one twice changes nothing and at-least-once delivery is safe.

The overlay is built beside its name and renamed into place, kept in WAL mode, with one indexed column per field generated from the manifest's field order. A query never sees a half-built file and never waits on an apply. An apply that fails for any reason falls back to a full build.

An overlay idle for DIRECTORY_OVERLAY_TTL_SECS (1800 s) is evicted and its file removed. It is a pure cache, so eviction costs the next query a build and never costs correctness.

Query translation

The wire carries a tree of names, operators and values, never SQL. The translation binds every value as a parameter, refuses an unknown operator rather than dropping it, and appends name as the final sort key so the order is total. Absent values sort last in both directions.

The cursor is keyed on the instance name alone, not the sort tuple: a name cannot change under a page, a sort value can. The cost is a page boundary that repeats or skips a row whose sort value moved, which is the staleness the index already has.

The declared view

A field published a second ago has no row carrying it yet. At query time the reader folds the owner's current contract into the manifest it reads, so the field exists with a dver above every row's, which is what "building" means. The same view is what starts the backfill for a class that has had no writes since the publish.

Repair and reconciliation

MechanismTriggerCost
re-offer on settleevery settled flightfree, rides the flight
crash-scoped sweepa node left without a clean drain; checked every DIRECTORY_SWEEP_SECS (15 s)one manifest read per object the dead node held
identity checksum gateevery reconciliation pass, DIRECTORY_REBUILD_SECS (900 s)one manifest read per owned class and one count rpc per scope; a walk only when they disagree
full rebuildactias objects <project> rebuild <class>one manifest read per object in the class
backfillrows below the declared dver, inside the reconciliation passone scratch restore per object, 256 per class per pass

The object's shipping manifest carries the current row, so every mechanism above but the backfill is a metadata copy: nothing restores an object's file or starts a vm. The two exceptions are the backfill and the verified read's scratch tail, both below.

Re-offer on settle

A flight is write-triggered, so a new residency heals its rows on its first write, not on arrival. An object that never writes again is healed by the sweep or the gate, which is why both exist.

The sweep and the rebuild disagree on purpose

The sweep writes deltas with no field declaration attached: guessing one would let a repair rewrite a class's field set. The operator rebuild attaches the owner's current declaration, because losing the manifest loses the field set and the rebuild is the one path meant to restore it.

The gate

Each class is checked by one node per pass, the rendezvous winner for that era, so a wedged node cannot strand its share for long: the salt rotates. The check compares the manifest's identity checksum with the placement store's; a match costs nothing more, a mismatch walks the manifests.

Placeholders

An identity the placement store knows but no manifest carries a row for (never shipped, or shipped before its class had a directory) is indexed as an empty row at rev 0: "exists, never derived" in the index's own vocabulary. The checksum then matches by construction, the object's first real derivation outranks the placeholder on rev, and the dver floor and the backfill both skip rev 0, since there is no state to re-derive.

The backfill

The backfill runs inside the reconciliation pass, under the compactor's lease, before the fold. It takes the rows below the declared dver, oldest dver first so the floor moves, skipping tombstones and placeholders. Each object is restored to scratch and evaluated in one reused vm, sequentially, 256 per class per pass; the pass then asks for a fold so the manifest's floor advances.

Verified reads

visit checks each candidate against its object without waking it.

The metadata ladder

The common path restores nothing. The reader fetches the object's shipping manifest (eight at a time) and decides from (epoch, rev, dver) alone: an equal version verifies the row as served; a newer settled row is rechecked in memory from the manifest's own pairs and served fresher than the index had it; a deleted object is dropped.

The scratch tail

A candidate whose newest derivation failed cannot be judged from metadata. Its shipped state is restored to a scratch copy, the class's migration ladder is applied there, and the declaration is evaluated read-only. Migrations are deterministic over data that is not changing, so the scratch result equals what first touch will produce; nothing durable advances.

At most sixteen recomputes run per page; the rest come back flagged unverified, with a message saying so, and are checked as the page is walked again. Recomputes are memoized by the failing (object, rev, dver, epoch), up to 4096 entries, so a console refreshing over a broken class does not storm the store.

Metrics

Every loop above counts itself at the worker's /_metrics, under actias_directory_*.

LoopSeries
syncerflushes_total, flushed_rows_total, flush_failures_total
compactorfolds_total, fold_failures_total
gatepasses_total, gate_checks_total, gate_opened_total
rebuild and sweeprebuilds_total, rebuilt_rows_total, placeholder_rows_total, rebuild_failures_total, sweeps_total, swept_rows_total
backfillbackfilled_rows_total, backfill_skipped_total, backfill_remaining
overlayoverlay_builds_total, overlay_build_ms_total, overlay_applies_total
placementforwarded_total, served_for_peer_total
verified readsvisit_verified_total, visit_recomputed_total, visit_flagged_total, visit_dropped_total
filesfile_reads_total, file_fetches_total, refusals_total

A healthy cluster reads as gate checks climbing while gate openings do not, and file reads climbing while fetches do not.

Knobs

VariableDefaultMeaning
DIRECTORY_FLUSH_MS200the syncer's coalescing window
DIRECTORY_COMPACT_SECS10how often the compactor checks the fold cadence
DIRECTORY_REBUILD_SECS900the reconciliation pass, gate and backfill included
DIRECTORY_SWEEP_SECS15how often departures are checked for a crash sweep
DIRECTORY_OVERLAY_TTL_SECS1800idle time before an overlay leaves the disk
DIRECTORY_CACHE_MB256the content-addressed file cache
DIRECTORY_EVAL_BUDGET_MS5compute per derivation

What a crash costs

EventDirectory effect
node dies before delta flushrows late until the next write, the sweep or the gate; never lost
blob write retriedsame content hash, idempotent
compactor dies mid-foldmanifest unwritten; the next fold redoes it
reader diesthe next query lands on the next rendezvous winner, which builds its own overlay

Every row in this table is a lateness. The design has no event whose effect is a loss.