Architecture · as of 2026-10-27 · authority model revision 3
How Zigcube is built
Desired state goes into a versioned store. Controllers turn it into replicas and record a decision for every action. A thin agent on each node pulls its replicas and runs them through a runtime interface. C0 builds all of that against a fake runtime on one node. This page says what exists, what C1 adds next, and what is planned after that.
The shape
Three executables, one store, one contract with Zigbox
zcube (the command line), zcube-server (HTTP API plus controllers) and zcube-agent (one per node) come from one Zig 0.17 build, with no third-party Zig dependencies. The server talks to the store. Everyone else talks to the server over HTTP/1.1 and JSON, and the server never pushes to an agent.
Built C0 · Loop
What exists today
Everything here is merged on main and exercised by demo-c0 on both Postgres and SQLite in CI. All of it runs against FakeRuntime, not real Zigbox, and on one node.
Store: a versioned key-value interface
The same semantics as etcd, on databases people already run. Every committed transaction gets one global revision, and the revision is assigned inside the transaction that writes the data.
- Operations:
get,list(at a revision, paged, reversible),txn(compare onmod_revision, then puts and deletes),watch(ordered, gap-free, an explicit error once compacted), leases (grant, keepalive, revoke, list), andcompact. - Postgres: each writer's first statement is
UPDATE store_meta SET revision = revision + 1 RETURNING revision. That serialises writers, so revision order is commit order and a rollback never burns a number.LISTEN/NOTIFYwakes watchers, a bounded poll backs it up, and the reader checks the log has no hole. The schema is plain heap; OrioleDB is optional and has not been run. - SQLite: WAL mode, with
BEGIN IMMEDIATEas the single writer. For development and single-node use. - Memory: the reference implementation, used by the tests and the console.
- One conformance suite runs on all three backends with a fresh seed on every run.
store.zig · store_memory.zig · store_sqlite.zig · store_postgres.zig · store_conformance.zig · docs/store-design.md
The core: controllers as steps
A controller is reconcile(ctx) → Wakeup. It reads, does what is due, writes each action with its decision record in one transaction, and returns the time or key prefixes that should wake it next. It never blocks.
- Level-triggered and idempotent. A converged system writes nothing, so a lost event costs nothing.
- Partial progress is normal. A transaction touches at most four replicas. A failed compare writes nothing, and the next step plans again.
- No hidden state. Whatever cannot finish now, such as a stop's grace period or a held replica, is written to the store.
- WASM-clean. No threads, sockets, files, libc,
std.Io, real clock or randomness. CI compiles it towasm32-freestandingand runs it in Node.
core.zig · controller.zig · replica_controller.zig · agent.zig · apply.zig · why.zig · retention.zig · docs/core.md
zcube-server
The shell around the core: an accept loop, one task per connection, a driver thread that runs the controllers when their Wakeup falls due, timers for lease expiry and compaction, and a watchdog over the connection table.
- API (
docs/api.md): reads under/v1;POST /v1/apply, with?dry_run=1to preview;DELETE /v1/services/{name}; agent endpoints under/v1/agent/nodes/{node}/. - Every request has an ID, returned in
X-Request-Id: the client's own if it sends one (the CLI does, with a random part), otherwiser-<start>-<counter>, unique across restarts. - A failing store is reported once as a diagnostic, not once per step, with backoff.
- Writes are unauthenticated: listen on a private address.
server.zig · server_drive.zig · server_http.zig · server_listen.zig · api.zig · agent_api.zig
zcube-agent and FakeRuntime
One single-threaded agent per node. It reconciles the node's desired replicas against what the runtime lists, and reports what it observes. The Runtime interface is the contract with Zigbox: a small serialisable Handle, start, stop with grace, inspect, list, and an event stream. There is no blocking wait.
- One supervisor per container (the owner's recorded exception):
zcube-agent superviseis the command's parent, runs in its own session, and records the exit, so the agent can restart without losing anything. - Re-adoption is
list: a restarted agent reads the state directory and never starts a duplicate. - A container whose supervisor vanished is
lost(ZC-E0509). Its process group is killed once. - Images come from
fixtures/images.zon. Any other digest fails with the placeholderZBX-E0410. Limits are recorded but not enforced.
zcube_agent.zig · agent_run.zig · agent_remote.zig · runtime.zig · fake_runtime.zig · fake_supervisor.zig · docs/agent.md
zcube, the command line
applyshows a diff and asks first.--yesskips the question, and--dry-runonly shows the diff.get service|replicas,delete service, andexplain <code>(works offline).why <kind> <name>follows decision records back to an observed fact or a desired key.bundle --lastwrites a redacted.tar.gzof evidence that never leaves the machine.- Output is plain,
--verboseor--output json. The exit code comes from the blame of the first diagnostic.
cli.zig · cli_*.zig · bundle*.zig · docs/cli.md
Diagnostics, shared with Zigbox
Every failure is a coded diagnostic with a blame, a cause and a fix, never a stack trace or a bare Zig error name. Zigcube's ZC- registry plugs into Zigbox's diagnostic, render and explain modules as a second product. Zigbox is a pinned package.
- Blame decides the exit code: 1 user, 2 environment, 3 external, 70 bug.
- Golden tests cover every code in all three output modes. A no-stack-trace test covers every failure path, and the redactor is fuzzed.
- An error page per code is generated from the registry and checked in CI.
diag.zig · src/diag/ · cli_golden.zig · errors_site.zig · DIAGNOSTICS.md
Web console
Plain HTML, CSS and ES modules, embedded in zcube-server. There is no build step and it makes no request to any other host. It lists once, then follows the store's watch, the same way an agent does, and shows the connection state at all times.
With ?sim=c0 it runs the real core, compiled to WASM, over a memory store and a simulated runtime in the browser. That module has exactly one import, env.zc_panic.
web/ · console_wasm.zig · docs/console.md
How it is checked
demo-c0is the milestone demo. Real server, agent and CLI processes, on Postgres and on SQLite, assert every step and acceptance criterion: kill, scale, an unknown image, server and agent restarts, the store going down, and a bundle.sim-c0runs the same scenario natively and as WASM on virtual time and compares the reports. It is a simulation, not the demo.agent-smokeis a host check of the server and agent as real processes.- Also:
test,tidy,test-storeon each backend,wasm-check,wasm-testandconsole-wasm. The WASM jobs run under each V8 tier separately, because Node 22 crashes when tiers mix.
One request, end to end
From zcube apply to a running process, and back through why
zcube apply hello.zonparses the ZON file and asks the server for a dry run, which returns the diff. Once confirmed,POST /v1/applywrites/services/hellowith a new generation and anapplydecision, in one transaction.- The driver wakes on the change under
/services/. The replica controller compares desired with actual and writesscale_up, then acreate_replicaand aplace_replicafor each new replica. Every one is a/decisions/<id>record with its cause, indexed under/decisions-by-subject/. - The agent's long-poll returns. It lists its replicas, compares them with
Runtime.list, and callsstart. FakeRuntime writesspec.jsonand launches a supervisor, which runs the command. - The agent reports what it sees under
/observed/<node>/<replica>. If a start fails, the report carries the runtime's full diagnostic, for exampleZBX-E0410. The controller then holds the replica rather than retrying a start that would fail the same way. zcube why service brokenwalks from the service through the decisions to that observed diagnostic and prints the chain:service broken: 0/1 running ← replica broken-1 failed to start on node1 ← error[ZBX-E0410]: no tree with that digest.
Data model
The key layout
Values are JSON. The full shapes are in docs/keys.md. Replica ids are never reused, so a replacement can never be mistaken for the instance it replaces. A service's generation moves only when what a replica runs changes (image, command, env, limits), so scaling never restarts running replicas. A replica whose start failed through the user's fault, such as an unknown digest, is held, not replaced.
As built in C0
| Key | Written by | Holds |
|---|---|---|
/services/<name> | API (apply) | desired service: image digest, command, env, replicas, generation |
/replicas/<service>/<id> | controller | desired replica and the node it is placed on |
/nodes/<node> | agent | registration and declared capacity, attached to the node's lease |
/observed/<node>/<replica> | agent | status, exit reason and, on failure, the full diagnostic |
/decisions/<id> | controllers, API | one action: actor, action, reason, and a cause that names a key or another decision |
/decisions-by-subject/<kind>/<name>/<id> | with each decision | the index zcube why reads |
C0 retention: the newest 200 decisions per subject and at most 7 days, whichever is fewer, and 24 hours once the subject is deleted.
Decided for C1 not built
| Key | Sole writer | Holds |
|---|---|---|
/replicas/<service>/<id> | replica controller | desired replica: spec and generation, no placement |
/assigned/<node>/<id> | scheduler | the placement: replica, service, resolved requests. Its revision is the placement epoch. The agent watches only its own node's prefix |
/allocations/<node> | scheduler | authoritative accounting: the sum of the node's assigned requests, updated in every placement and release transaction |
/audit/allocations/<node> | allocation auditor | an open finding: the recorded and reconstructed totals, and the revision both were read at. While it is open, nothing new is placed on the node |
/nodes/<node> | that node's agent | registration and declared capacity, bound to the lease. Its revision is the agent's tenure |
/node-status/<node> | node controller | ready, lost or fenced, the tenure judged, and the deciding decision |
/observed/<node>/<id> | that node's agent | as in C0, plus the tenure and assignment it was observed under. A write is refused unless the tenure is current |
/decisions/<id> | whoever acts | one action and its cause, immutable once written, with the evidence it acted on: the fields of the cited key's value at the cited revision |
/decision-summaries/<kind>/<name>/<action>/<cause-kind> | the actor it counts | count, first and last time, and the newest decision for repeated actions. A projection, never a cause |
C1 retention keeps, for each live subject, its newest decision of every action and the chain behind each one. Only decisions outside those chains age out under the C0 limits. zcube why returns complete, or truncated with the reason (retention, a deleted subject, or a compacted key). A terminal is rendered from the evidence the decision carries, never from the key's current value.
Guarantees and limits
What holds, and what does not yet
Holds, and is tested
- Gap-free watch. Revision order is commit order on every backend. The conformance suite checks it under concurrent writers.
- Restart-safe. Restarting the server or the agent changes no key and adds no decision, and the same processes keep running. The demo compares the store byte for byte and the host by process id.
- Explainable. Every action has a decision whose cause chain ends at a real key. The demo checks about 30 per run.
- Store outage. Postgres down gives
ZC-E0201, exit 3. Workloads keep running and recovery needs no restart.
Not yet
- One node, one server. No scheduling and no failover. A second server on the same store needs controller leadership: a store lease on
/leader/controllers, compared in every controller transaction, so a deposed leader's writes fail. This is decided, not built. - No real runtime. No isolation and no limits, and the
ZBX-codes are placeholders until Zigbox's adapter lands. - No authentication on writes.
- Explanations can be cut short. Retention keeps the newest 200 decisions per subject, so a long crash loop can evict the cause of a chain.
whythen ends at agonestep; C1 makes this an explicittruncatedverdict and stops evicting live chains. - Write throughput on Postgres is capped by the serialised writer: hundreds to low thousands of revisions a second. A benchmark is due at C1, and group commit is the fallback.
- OrioleDB has not been run.
Next C1 · Fleet
Placement and failover
Not started: the brief is the next piece of work, built to docs/authority.md. The goal is that workloads spread across nodes and survive the loss of one, still on FakeRuntime, with several agents and their own state directories on one machine or a few VMs.
What gets built
- Node registration on a lease.
/nodes/<node>is bound to the agent's lease and carries its declared CPU and memory; while it exists, the agent holds tenure.zcube get nodeslists the nodes. - A scheduler that filters on resource fit, then scores for spread, and writes the placement as
/assigned/<node>/<replica>with its reasons in the decision. Agents watch only their own prefix. - A node controller. On lease expiry it marks the node lost in
/node-status/<node>, waits a short grace, and marks its replicas lost; the replica controller replaces them on the survivors, with new ids. Nothing moves back when the node returns. - Fencing, with a stated window. Exclusive ownership of a replica is guaranteed by the store; exclusive execution only outside a stated window, and not on a wedged node. The agent stops its own workloads a margin before its lease can expire, timed from when it sent its last successful keepalive. Writes from an agent that lost tenure are refused by the store.
demo-c1tests a partition: the agent stays alive but cannot reach the server. - Complete or truncated.
zcube whysays whether its chain reached a terminal, retention never evicts a live subject's newest decision of each kind or its chain, and decisions stay immutable: repetition is counted in a separate summary that is never a cause. demo-c1(three agents, six replicas, one agent killed,zcube why node node3) andsim-c1, which checks the invariants under partitions, clock skew and kills that fail.
Decided before the brief
- One writer role per key prefix: the API, the replica controller, the scheduler, the node controller, each node's agent. The scheduler no longer writes into
/replicas. - The read-set rule: a controller's transaction compares every key whose value justified the action, so a stale plan writes nothing.
- Only the scheduler assigns and releases, and it updates the allocation record in the same transaction. A retired replica or a lost node is a fact the scheduler reacts to.
- The full placement compare set. A placement compares the replica, the absence of another assignment, the node's tenure and capacity, its readiness, its allocation record, and the absence of an open audit finding. A node lost between planning and commit gets nothing.
- No phantoms in capacity. A decision over a set compares one authoritative aggregate that every change to the set updates. For capacity that is
/allocations/<node>, owned by the scheduler and changed in the same transaction as every placement and release, so no placement overcommits a node. - Audited, never silently repaired. An auditor rebuilds each node's allocation from its assignments at one revision. A mismatch is recorded as a finding and reported as a bug, and the node gets no new placements. Repair is an operator's request, carried out by the scheduler as a decision of its own.
- Admissible observations: a controller acts on an observation only under the node's current tenure, the replica's current placement, and a ready node. Older ones may explain, never justify.
- Decisions are immutable and carry their evidence. A decision another decision cites never changes afterwards. Each one embeds what it read from the key it cites, so an explanation survives that key changing or being compacted. Repeated actions are counted in a separate summary.
- A lease expiry is not proof of a stop. Stateless replicas accept the stated window. A stateful replica (C7) moves only with a fencing proof: the node confirms, an external fence succeeds, or an operator asserts it, and durable writes check the placement epoch.
- Still to fix in the brief: what happens to a replica that fits no node (held as pending, with a decision naming the shortfall), the final fencing constants, a random part in server-assigned request IDs, and the watch fan-out benchmark.
The fencing timeline
- Agent. It records
s, the time it sent each successful keepalive, onCLOCK_BOOTTIME, and persists it with the lease id so a restarted agent inherits the deadline. Ats + ttl − fence_marginwith no newer success, it stops every container, kills them afterfence_stop_ms, and registers again with nothing running. - Server. The lease expires at least
ttlafter the keepalive was received. The node controller marks the node lost, then waitslost_grace_msbefore anything is placed elsewhere. - The condition.
fence_margin ≥ fence_stop_ms + ttl × skew + kill latency, whereskewbounds how far the two hosts' clock rates differ. Absolute clock values never matter. - Proposed:
ttl10 s,fence_stop_ms2 s,skew1 %,fence_margin3 s,lost_grace_ms1 s.
What sim-c1 asserts after every event
- I1a. Ownership is exclusive, always. Each replica has at most one assignment and at most one tenure whose observations count.
- I1b. Execution is exclusive outside the stated window, provided no node is wedged and skew is within bound.
- I2. No commit uses a superseded tenure or placement epoch.
- I3. No decision cites an inadmissible observation.
- I4. Every key is written only by its prefix's writer role.
- I5. Every
whyanswer is complete, or names its truncation. - I6. Allocation is conserved, and no placement overcommits a node.
- I7. No placement commits onto a node that is not ready.
- Evidence test. Every decision's explanation is captured, then every cited key is changed and compacted. Each answer must render the same evidence, or say it is truncated and why.
- Negative tests must find the failures: I1b under excess skew or a wedged node, I6 when a scheduler skips the allocation compare, and I7 when it skips the readiness compare.
Planned C2 to C8
Where the architecture goes after C1
From the design doc, Part II. Each milestone waits on specific Zigbox milestones. The real runtime replaces FakeRuntime behind the same Runtime interface once Zigbox M2 ships its exit reasons and cgroup scopes.
| Milestone | What changes in the architecture | Needs from Zigbox |
|---|---|---|
| C2 · Swarm | Agents advertise a compact summary of their chunk store, for example a Bloom filter per image tree. The scheduler adds a locality score, which never overrides fit or spread. Nodes fetch chunks from peers before the registry and verify each one by hash. zcube stats pull. | M3 chunk store, M4 for the demo |
| C3 · Rollout | Readiness and liveness checks, rolling updates within surge and unavailable limits, automatic rollback to the exact previous digest, and zcube history and rollback. The surge and unavailable limits are predicates over a set, so they get an aggregate record with one writer, like capacity. | M4 |
| C4 · Mesh | WireGuard between nodes, cluster DNS, and a workload identity bound to service name, image digest and placement epoch. The epoch is carried from C4 so C7 can enforce it without changing the protocol. Grants such as web → db:5432 compile into per-node rules, with default deny. | M5 network namespaces and grants |
| C5 · Edge | An edge proxy role on chosen nodes, ACME certificates stored encrypted, and public = "…" on a service. Edge traffic enters the mesh through a grant. | C4 |
| C6 · Trust | A policy check at admission in the server, and again in every agent before exec, using node-local trusted keys that the API cannot change. A compromised control plane cannot run unsigned code. | M8 signatures |
| C7 · State | Named state volumes pinned by the scheduler, snapshots shipped to object storage, and restore on another node after node loss. A move needs a fencing proof, not just an expired lease, snapshot writes are conditional on the placement epoch, and peers accept only the current epoch's identity. The recovery point is the snapshot interval. A TLA+ model of the tenure and fencing protocol comes before C7. | M6 state volumes |
| C8 · Jobs | A job object with retries and deadlines, gang scheduling, and Zigbox M7 build steps dispatched across nodes with one shared chunk-store cache. Gang admission is all or nothing against the allocation records, in one transaction. | M7 builds |
Stretch goals, not scheduled: a Kubernetes API facade (CS1), cells (CS2), tenant isolation on microVMs (CS3) and accelerators (CS4). Out of scope through C8: clusters beyond a few hundred nodes, a custom consensus store, multi-region active-active control planes, and synchronous replication.