zuka — roadmap
Status: complete. M0–M7 shipped 2026-08-01. Standalone and multi-tenant both
work; 253 unit and 59 live tests green, clippy clean at -D warnings.
M6 was verified on a real Linux host with Incus
(infra/README.md): an account provisions its own
container, code pushes through the control plane into it, CI runs inside it, and one
tenant cannot reach another's repositories.
Technical detail lives in SPEC.md; this file owns sequencing, risk and
open decisions. Product framing: README.md.
Milestones
Trunk stays green at every boundary. Each milestone is independently shippable and each proof is falsifiable — "it works" is not a proof.
| M | Name | Ships | Proof |
|---|---|---|---|
| M0 | Skeleton | Crate, config.rs, http shell, error.rs + problem types, /healthz, CI workflow. | Shipped. cargo test + clippy -D warnings + cargo fmt --check green in CI. |
| M1 | Storage + wire | Repo create/delete, name validation, hook symlinks, git http-backend, Basic auth + WWW-Authenticate, local token file, zuka token. | Shipped. Real git clone + git push against a repo created via POST /v1/repos, protocol v2 negotiated; a --force non-fast-forward is rejected by the server-side hook and the ref does not move; a foreign account gets 404 on read and cannot clone. |
| M2 | Read API | GET tree, raw, commits. Ref as a query parameter; blob ETags and immutable caching by object id; bounded page sizes. | Shipped. A pushed repository reads end to end over REST; traversal, .git components and smuggled git arguments are all refused. Compare and search deferred — see below. |
| M3 | MCP + discovery | /mcp JSON-RPC (initialize, ping, tools/list, tools/call), 15 admin and discovery tools, plus compare, search and blame on the REST side. git-upload-archive enabled over SSH. | Shipped. A live test drives the whole loop: repo_create → key_add → the key authenticates a real git clone/push → tree_list/file_read see what was pushed. Access rules and traversal refusals are proven identical to REST. |
| M4 | Limits + jobs | Per-account repo/byte quotas, token-bucket rate limiting on /v1 and /mcp, jobs/ module with its own service unit, GC + repack + pack-refs, deleted-repo sweep, fsck --repair. | Shipped. Over-quota create returns 413 with limit/used while reads keep working; GC concurrent with a clone of a 12 MB repo leaves it byte-identical; fsck finds a planted orphan in both directions and repairs only the safe one. |
| M5 | CI | .zuka.toml, durable run queue derived from the records themselves, executor with rlimits + scrubbed env + process-group kill, runs API, MCP tools. | Shipped. A push runs and captures output; a failing step stops the run; a timeout kills the process group with nothing orphaned; a step cannot read the service's environment; a spec cannot raise its timeout past the host ceiling. |
| M7 | Import | POST /v1/repos {import_url}, two-stage SSRF check (URL shape, then every resolved address), transfer.fsckObjects, adoption into our hooks and config. | Shipped. A real repository imports with history intact and is indistinguishable from one we created; loopback, private, link-local, file://, ssh:// and credential-bearing URLs are all refused. |
| M6 | Control plane | Incus provisioning + reconciler, Ed25519 request assertions, streaming proxy, idle stop/start, setup --standalone|--isolated. | Shipped and verified on real infrastructure. POST /v1/accounts/alice returns in 64 ms while a container is provisioned behind it; a push through the control plane lands in alice's container and nowhere on the host; CI reports uname -n = zuka-alice; bob gets 404 on alice's repository and an empty repo list. |
Deferred past v1: SSH transport, webhooks, mirroring, large-file offload, public repos, commit-signature enforcement, branch protection beyond fast-forward.
What the M1 review changed
An external review of M0/M1 found ten defects, several verified against a running instance. All are fixed and each has a regression test. The ones that changed the design rather than a line:
- Cross-account theft on a case-insensitive filesystem.
Identitycompared the raw path segment, soGET /v1/repos/Alice/sitepassed the ownership check and resolved toalice's repository on APFS. Names are now a canonicalNametype, case-folded at the edge; the raw string never becomes a path component or a comparison operand. - A repo-confined admin token could delete repositories it was denied read on.
require_adminskipped the confinement checkcheckapplied. Split intorequire_repo_admin(confined) andrequire_account_admin(requires an unconfined token). - The streaming design in SPEC §1.2 was specified and never built. Both
directions buffered: a 600 MB push cost 1.0 GB of RSS and was rejected after the
allocation, and four concurrent clones of a 257 MB repository cost 1.5 GB. There
was no semaphore, and the disconnect kill the file's own header comment claimed
reached only the direct child while
pack-objectsran on. Now streamed end to end, semaphore-bounded, process-group killed: 5 MB for both cases. - A duplicated
serviceparameter split authorization from behaviour. This server took the first value,http-backendtakes the last. A read-scoped token could obtain the receive-pack advertisement. Duplicates are refused and the query handed to the CGI is rebuilt rather than forwarded. - The ref rule existed only in shell, contradicting both the spec and
AGENTS.md. It is nowgit::store::check_ref_move, and the hook is a one-lineexecinto the binary. - Comments described controls that did not exist — the disconnect kill, "bounded before they are buffered", "the API refuses to mint" a non-expiring token. That pattern is worse than the individual bugs, because it is what made them survive review. Each comment now describes code that is there.
Also fixed: ZUKA_ME_URL was validated, logged and ignored (now refuses to boot);
the CLI could only mint unscoped non-expiring admin tokens; tokens.json was 0644;
ensure_space was never called on the push path and receive.maxInputSize was never
set; git paths answered problem+json on every status except 401; there were no
connection timeouts.
Found while fixing, not in the review: caching credentials at boot meant a token minted by the CLI did not work until restart. Now re-read on change, keeping the last good contents when a file stops parsing.
What M1 changed in the plan
/v1/repos/{account}/{repo}/refsshipped early, inside M1 rather than M2 — the force-push test needs to read a ref back to prove it did not move, and a test that cannot observe the invariant it asserts is not a test.- Tree-path validation was written, tested, removed, and returned with M2, which is the milestone that gave it a caller. Blob-mode validation was removed again for the same reason and returns with M3's write path.
libc::statvfsreplaced a hand-declared struct. The layout differs between macOS and Linux; declaring it by hand reads garbage rather than failing loudly.- The disk reserve is enforced at repo create, not deferred to M5. A repository created without room for its object store is corrupt rather than empty.
The write API was cut, 2026-08-01
An earlier draft of this roadmap and of SPEC.md made "commit without cloning" the
load-bearing capability, with POST /commits, base_sha optimistic concurrency, ref
compare-and-swap and idempotency keys. That was wrong and it is cut. No code was
written for it; the correction is documentation only.
The reasoning that replaced it: agents already run git perfectly well. What blocks a non-developer is the forge — provisioning, keys, permissions, PRs, CI config — not the committing. Building a second write path would have meant two homes for the fast-forward rule, two concurrency models and a merge story, all of which git already has and does correctly.
So the boundary is now strict: git writes, the API administers and reads. That
deletes POST /commits, PUT /files, PUT/DELETE on refs, and the MCP write
tools; it also deletes the gix-ref transaction requirement from SPEC §1.3, because
with receive-pack as the only writer there is no second writer to race.
compare and search survive the cut and move into M3. They were justified as
"an agent must reconcile a 409" — that reason is gone, but a better one remains:
answering "what is in here" without a full clone is worth an endpoint on its own.
What M4 found
- git's auto-gc was on.
gc.autowas unset, soreceive.autogc(default true) fired a repack inside pushes once loose objects passed 6700 — uncoordinated, at a moment nobody chose. SPEC §9.4 had said to disable it; it had never been done. Now off at creation and reasserted by the job. - The accumulation is loose objects, not packs. Measured at ~3 per commit: small
pushes fall under
receive.unpackLimitand get exploded rather than kept as packs. The earlier assumption in this doc was wrong. - A delete reclaimed nothing.
soft_deletewas built without its drain, sotmp/deleted/grew forever on the same disk as live repositories. - The grace window, not a lock, is what makes GC safe. A lock cannot span the
server and the jobs process.
upload-packcan reference an object between resolving and streaming it, so the prune expiry is the real control — which is also whyZUKA_GC_PRUNE_GRACEis configuration rather than a constant. fsck --repairdeletes records, never repositories. A repository with no metadata is reported and kept: deleting real bytes on a mismatch is how a reconciler turns a small inconsistency into data loss.
Deferred
gix. Reads shell out togittoday. That is a process spawn per request, which SPEC §1.2 explicitly argued against; it is mitigated by running them on a blocking pool rather than a runtime worker. Migrating the read path togixis worth doing on measurement, not on principle, and no measurement has been taken.
Risks
| # | Risk | Mitigation |
|---|---|---|
| R1 | The wire protocol is subtle. Sideband, protocol v2, shallow, --filter, gzipped bodies, half-closed connections. Easy to get almost right and ship a hang. | Delegate to git http-backend, named explicitly (SPEC §1.2). M1's proof is a real clone and push, not a unit test. |
| R2 | Ref-name injection is RCE. ../../config writes git config; core.pager/core.sshCommand/core.fsmonitor are execution keys. | gix-validate, never hand-rolled; post-canonicalization prefix check. Normative in SPEC §2.5, not a test bullet. |
| R3 | Tenant secret compromise. CI runs arbitrary code in the container that holds the service's credentials. A symmetric key or shared KV token there breaks multi-tenancy entirely. | Ed25519 with the private key never distributed; per-tenant KV credentials; separate runner uid; env -i. SPEC §2.4, §6.3. |
| R4 | Ref TOCTOU. Two writers per ref — gix in-process and receive-pack in a subprocess. Checking then writing is a race. | gix-ref transaction with PreviousValue::MustExistAndMatch; preconditions enforced inside it. SPEC §1.3. |
| R5 | gix API churn. Pre-1.0. | Pin exactly. All use behind git/read.rs + git/write.rs. Add cargo deny — pinning stops build breakage, not advisories, and parsing untrusted git objects is where they will land. |
| R6 | Control-plane proxying. Multi-gigabyte bidirectional streams with backpressure, plus unbuffered SSE. Shelling out at the engine does not help at the proxy hop. | Its own milestone scope, its own proof. Not treated as free. |
| R7 | CI is arbitrary code execution. In standalone there is no container. | Off by default in standalone, said plainly in the README. uid separation, cgroup kill, rlimits, netns. No step allowlist — sh -c defeats it. |
| R8 | The differentiator is thinner than it looks. With git doing the writing, what is left is administration — which Gitea also has, behind more UI. | The bet is that absence is the feature: no orgs, teams, PRs or review, one binary, MCP-native. M3's gate is what tests that bet, and it is a real test because it can fail. |
| R9 | Destructive operations. An agent will eventually delete the wrong repo. | Soft delete to tmp/deleted/ with a 7-day sweep. Reflog on every ref move. Disk-move-then-KV ordering with fsck reconciliation. |
| R10 | Storage exhaustion. Loose objects per API commit, packs per push, CI workspaces, soft-deleted repos on the same disk. | GC + repack + sweeps in M5, before CI in M6 adds workspace churn. 507 at a reserve threshold, before ENOSPC corrupts a pack mid-receive. |
Open decisions
Each blocks the milestone named.
D1 — Name. Resolved: zuka, renamed from the sanduku placeholder
2026-08-02. The edit was one line in Cargo.toml plus a directory move, because
brand.rs derives the env prefix, data directory, problem-type URIs, auth realm, git
config namespace, header names and CI filename from CARGO_PKG_NAME. A test asserts
the name appears nowhere else in src/, and it caught three literals during the
rename.
D9 — The control plane is deferred. Resolved by provisioning a Linux host
rather than by relaxing the standard. Everything above the Incus shim is tested
against a fake provisioner on any machine; the shim itself was verified on the VM in
infra/README.md.
D2 — Commit authorship. Resolved by cutting the write API: zuka never
authors a commit, so there is nothing to attribute. Whatever the agent's local git
identity is, is what lands — which is also the honest answer.
D3 — Public repos. Built, after rate limiting landed in M5. Deliberately narrower
than the original framing: public grants anonymous read — git clone and the
browser views — and never an anonymous REST write or an anonymous principal threaded
through the identity model. Identity was left alone; anonymous access lives in one
function, open_public_repo, that can only be asked for read. The DoS surface is
bounded by the existing per-IP rate limiter.
D4 — Large files. The original design carried a separate SHA-256 content-addressed blob store with a SQLite path→hash index. Cut: git's object database is already content-addressed with dedup, so a second store only earns its place for large binaries that should stay out of the pack. Revisit as LFS-shaped offload when a real repo hurts. Recorded so it is not rebuilt by reflex.
D5 — CI concurrency. Runs per account, queue depth, and overflow behaviour — reject, queue, or evict oldest. The token-bucket limiter from M4 is the shape to reuse. Blocks M5.
D8 — Quota defaults. Shipped at 100 repos / 2 GB per repo / 10 GB per account, 600 requests per minute. Those are guesses with no usage data behind them; revisit once anything real runs on this.
D6 — Observability transport. Whether this emits OTLP into
product/observability/ or stays with structured logs plus counters. Blocks nothing;
decide before M6 makes multi-tenant debugging urgent.
D7 — Hosting shape. Incus is chosen over Firecracker because these are persistent servers with SSH, networking and storage rather than short-lived functions. Not revisited unless the idle stop/start cost model in SPEC §7.1 fails.