zuka
zuka/ROADMAP.md

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/ROADMAP.md
MDROADMAP.md15.4 KBPlain textDownload

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.

MNameShipsProof
M0SkeletonCrate, config.rs, http shell, error.rs + problem types, /healthz, CI workflow.Shipped. cargo test + clippy -D warnings + cargo fmt --check green in CI.
M1Storage + wireRepo 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.
M2Read APIGET 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.
M3MCP + 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_createkey_add → the key authenticates a real git clone/pushtree_list/file_read see what was pushed. Access rules and traversal refusals are proven identical to REST.
M4Limits + jobsPer-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.
M5CI.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.
M7ImportPOST /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.
M6Control planeIncus 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. Identity compared the raw path segment, so GET /v1/repos/Alice/site passed the ownership check and resolved to alice's repository on APFS. Names are now a canonical Name type, 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_admin skipped the confinement check check applied. Split into require_repo_admin (confined) and require_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-objects ran on. Now streamed end to end, semaphore-bounded, process-group killed: 5 MB for both cases.
  • A duplicated service parameter split authorization from behaviour. This server took the first value, http-backend takes 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 now git::store::check_ref_move, and the hook is a one-line exec into 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}/refs shipped 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::statvfs replaced 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.auto was unset, so receive.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.unpackLimit and get exploded rather than kept as packs. The earlier assumption in this doc was wrong.
  • A delete reclaimed nothing. soft_delete was built without its drain, so tmp/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-pack can reference an object between resolving and streaming it, so the prune expiry is the real control — which is also why ZUKA_GC_PRUNE_GRACE is configuration rather than a constant.
  • fsck --repair deletes 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 to git today. 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 to gix is worth doing on measurement, not on principle, and no measurement has been taken.

Risks

#RiskMitigation
R1The 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.
R2Ref-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.
R3Tenant 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.
R4Ref 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.
R5gix 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.
R6Control-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.
R7CI 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.
R8The 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.
R9Destructive 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.
R10Storage 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 readgit 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.