zuka — technical specification
Status: scoped, not started. Milestones, risks and open decisions live in
ROADMAP.md; this document is the durable technical reference. Where
the two disagree, this one wins on how and the roadmap wins on when.
Product framing, positioning and non-goals: README.md.
1. Architecture
1.1 Three modes, one binary
standalone Engine only. Repos on local disk, tokens from a local file.
No outbound calls. This is what a self-hoster runs. Default.
control Control plane. Owns accounts, provisioning, routing. Holds no
repositories. Proxies data-plane requests to tenant containers.
tenant Engine inside an Incus container behind a control plane. Trusts a
signed identity assertion instead of verifying tokens itself.
standalone and tenant share the request-handling path exactly. They differ in
boot configuration and in the CI isolation model (§6.3) — that is the honest claim;
"identical but for one function" would not survive §6.3 or §9.
agent ──MCP───►┌──────────────────────────────┐
app ──REST──►│ facades: mcp/ api/ │
└──────────────┬───────────────┘
│ same core calls
┌──────────────▼───────────────┐
│ core: git/ ci/ account/ │
└───┬──────────────────────┬───┘
git CLI ──────►git/transport.rs │
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ filesystem │ │ KV │
│ bare repos │ │ metadata │
└─────────────┘ └─────────────┘
Facades carry wire-shape translation and client ergonomics. They never carry
authorization or concurrency rules. Defaults and argument-filling are allowed in
mcp/ (§5.2); access checks and ref preconditions are not, and never appear twice.
1.2 Git access
Everything goes through git subprocesses, funnelled through git::exec::Git —
the single place this service invokes git. Before that module existed there were five
wrappers and nine ad-hoc spawns with three different environment policies, so
/etc/gitconfig applied to git init, git config, merge-base --is-ancestor (the
ref rule) and count-objects (quota) but not to the wire protocol. Half a control is
not a control; Git clears the environment and sets GIT_CONFIG_NOSYSTEM on every
invocation.
gix is not used. A subprocess per read is a real cost, mitigated by running them on
a blocking pool rather than a runtime worker; migrating the read path is a
measurement-driven decision and no measurement has been taken.
Error mapping matters here. Git::query treats exit 1 as an answer ("no such
object", "no matches") and anything higher as a fault. An earlier version collapsed
every non-zero exit into 404, which meant a corrupt repository reported as empty
and nothing paged anyone. Existence is gated on rev-parse --verify --quiet, which
exits 1; cat-file exits 128 for the same condition and cannot be used for it.
Wire protocol
- Reads and API writes use
gix. Refs, trees, blobs, log, and building a tree+commit in-process. No working copy, no subprocess. - The wire protocol delegates to
git http-backend, the reference CGI. Notupload-pack --stateless-rpcdirectly —http-backendalready handles gzipped request bodies,GIT_PROTOCOLnegotiation, framing, and the exact content types git expects. Reimplementing those is the failure this choice exists to avoid.
Naming the binary matters: the two options differ by roughly everything in
ROADMAP.md R1.
Required around the CGI regardless: kill the child when the client disconnects (hyper
drops the body future and the process otherwise spins), set receive.fsckObjects=true
and receive.maxInputSize, and hold a semaphore across concurrent pack-objects so N
simultaneous clones cannot exhaust host memory.
git becomes a runtime dependency, minimum 2.41 (for --filter and protocol v2
behaviour we rely on). The README states it; "it is on every Linux host" is not a
version.
1.3 Git is the only writer
Nothing but git writes to a repository. No API creates commits, moves refs or
edits files. The wire protocol is the write path; the REST and MCP surfaces are
administration and read-only inspection (§4).
This is the single most load-bearing decision in the design, and it deletes a large
amount of machinery that an earlier draft of this document specified: no in-process
commit building, no base_sha optimistic-concurrency protocol, no ref
compare-and-swap against a concurrent receive-pack, no idempotency keys on writes,
no merge story. There is one writer, receive-pack, and it already does its own
locking correctly.
What remains is the ref policy: git::store::check_ref_move decides whether a move
is allowed. It has one caller, the update hook, which is a one-line exec back
into this binary so the rule is written in Rust rather than shell. Reflogs come from
git itself (core.logAllRefUpdates), not from us.
1.4 Stack
hyper 1.x + tokio, no web framework. anyhow internally, a typed Error at the
boundary (§8.3). serde on the wire. Structured single-line logs (§10). edition 2021, opt-level = 3, lto = "thin", committed Cargo.lock.
Beyond that: gix, gix-validate, ed25519-dalek, sha2, hex, uuid, toml,
base64, tokio-util.
2. Identity, auth, validation
2.1 Flat model
account the unit of ownership. Has repos, tokens.
token a scoped, expiring credential belonging to one account.
grant (repo, account, capability) — how a second account gets access.
capability read | write | admin
No orgs, no teams, no nesting. admin implies write implies read. admin may
manage grants but may not delete the repo or grant admin; both are owner-only.
write implies code execution in the repo owner's CI container, because a writer
can author .zuka.toml (§6). This is stated here because it materially changes
what granting write means, and it is repeated at the grant endpoint.
2.2 Tokens
POST /v1/tokens {name, scopes[], repos[]?, expires_in}. Scopes: repo:read,
repo:write, admin, ci. expires_in has a non-infinite default (90 days).
Stored as SHA-256; plaintext returned once, at creation. TokenRecord carries
last_used_at so revocation triage is possible.
A single unscoped forever-token is total account compromise, and the same secret gets
pasted into git (landing in ~/.git-credentials in plaintext), an MCP client
config, and potentially a CI environment. Scopes and expiry are not optional.
CI never receives an account token. A run gets a single-repo, ci-scoped,
run-lifetime credential that cannot mint tokens.
2.3 Resolving Identity
One function, three implementations, with the failure discipline borrowed from
yangu/atlas: a rejected credential is 401, an unreachable auth service is 502,
and there is never a silent fallback to a lesser identity.
| Mode | Source |
|---|---|
standalone (default) | $DATA_DIR/tokens.json, SHA-256 compared. No network. |
standalone (hosted) | GET {ZUKA_ME_URL}/verify, opt-in. Positive results cached 60s — otherwise every read blocks on a cross-service round trip. |
tenant | X-Zuka-Identity, Ed25519-verified (§2.4). |
2.4 The tenant identity assertion
A shared symmetric key would be readable from inside a tenant container by the CI code running there, and would then forge any account against any tenant. So:
- Ed25519. Control signs with a private key it never distributes; tenants hold only the public key. Two public keys accepted during rotation overlap.
- Signed payload, canonically serialized (field order fixed, no whitespace):
{account, issued_at, expires_at, nonce, method, path, body_sha256}. expires_at - issued_at <= 60s. Clock skew tolerance ±30s, stated not implied.- Nonce cached until expiry; replays rejected.
- Binding to
method/path/body_sha256means a captured assertion authorizes one request, not an identity. - Tenants bind to a private interface and reject connections not originating from the control plane.
2.5 Validation is normative, not a test case
These are the rules, not suggestions to be covered by a unit test later.
Account and repo names — ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$, no .., no .lock
suffix, unique case-insensitively (the KV key is case-sensitive and APFS is not;
without this, disk and metadata silently disagree). Reserved: ., .., .git.
Ref names — delegated to gix-validate's reference-name check. Never hand-rolled.
Must resolve under refs/heads/ or refs/tags/; after canonicalization the resolved
filesystem path must be a prefix-child of $GIT_DIR/refs/. An unvalidated ref name is
remote code execution: ../../config writes git config, and core.fsmonitor,
core.sshCommand and core.pager are all command-execution keys.
Tree paths — reject absolute, empty, ., .., and any component equal to .git
after Unicode normalization (NFC and NFD) and case folding. Reject .gitmodules
entries whose submodule path escapes (the CVE-2018-11235 family). Cap component count
and total path length.
File modes — 100644 and 100755 only. 120000 (symlink) is rejected on write,
and GET .../raw never follows one. 160000 (gitlink) and 040000 are not
constructible through the API.
Cursors — opaque and HMAC'd. A client-supplied cursor must never become a raw KV start-key; denokv's key escaping makes that a silent-correctness risk rather than a loud failure.
3. Path shape
Decided: /v1/repos/{account}/{repo}. The account is in the path.
The short form /v1/repos/{repo} was rejected because it breaks four things at once:
the control plane cannot route to the owner's container without it; grants become
unaddressable (a grantee has no way to name someone else's repo); the KV keys
grant/run/run_index collide across accounts; and it disagrees with the git path
/{account}/{repo}.git, so an agent holding a clone URL cannot derive the REST URL.
4. REST
Base /v1. JSON in, JSON out. {a} = account, {r} = repo.
This surface administers repositories; it does not modify their contents. Create,
delete, list and configure a repository, manage credentials, read what is in a
repository, watch CI. To change code you use git, over HTTP or SSH, exactly as you
would anywhere else.
The reason is that an agent already knows git. What it cannot do without help is the part GitHub bolted on top — provisioning, keys, access, CI. That is what this API is for, and keeping writes out of it means there is no second write path to keep consistent with the first.
4.1 Rules
POSTto a collection creates →201+Location.DELETEof an absent resource →404. Idempotency is about the effect on the server (RFC 9110 §9.2.2), not the status code; an agent that typos a repo name must not be told it deleted something.PATCHisapplication/merge-patch+json(RFC 7396);nullclears.Idempotency-Keyaccepted onPOST /repos;(key → response)stored 24h and replayed, so an agent whose response is lost can retry a create safely.- ETags are quoted strong entity-tags:
ETag: "<sha>".If-None-Match→304. - Lists return
{items, truncated}. There is no cursor. An earlier draft specified one and emittednext_cursor: nullon every list including truncated ones, which told a client the list had ended when it had not.truncatedsays what is true; a real cursor can be added later without having lied in the meantime. Defaultlimit50, max 200, both configurable. - Errors are RFC 9457
application/problem+jsonwith a realtypeURI fromhttps://<host>/problems/{slug}—ref-moved,precondition-required,invalid-path,invalid-ref,quota-exceeded,rate-limited,payload-too-large,storage-full,not-found,forbidden. Clients discriminate ontypeand read extension members; never ondetail. - Rate limits per token and per IP.
429+Retry-After.
4.2 Repositories
| Method | Path | Notes |
|---|---|---|
GET | /v1/repos | Owned + granted. ?role=owner|grantee. |
POST | /v1/repos | {name, default_branch, description} → 201. Duplicate → 409. |
GET | /v1/repos/{a}/{r} | Unreadable repo → 404, not 403 (no existence leak). |
PATCH | /v1/repos/{a}/{r} | default_branch must reference an existing branch → else 409. Updates HEAD. |
DELETE | /v1/repos/{a}/{r} | 204. Disk move first, then KV (§9.2). |
visibility is private (the default) or public. A record written before the
field existed reads as private: absence meaning public would have published every
repository on a host at the first upgrade that understood it.
Public grants anonymous read and nothing else — the browser views (§4.9) and
git clone. It never grants a write: git-receive-pack demands an identity before
anything else, and there is no way to spell an anonymous write to have to refuse it.
Anonymous access is granted in exactly one function, AppState::open_public_repo;
every other path in the service requires an Identity, so a handler that forgets it
produces a 401, not a disclosure.
A credential that is presented and rejected stays rejected on every surface. It never
degrades to an anonymous visitor, because that turns an expired token into a success
on public repositories and a bare 404 on private ones.
On the git paths an anonymous read that is refused answers 401 with a challenge,
not 404 — git only runs its credential helper on a challenge. The answer is 401
whether the repository is private or absent, so existence is still not disclosed. The
browser surface answers 404 instead, because a challenge there raises a login
dialog no visitor can satisfy.
4.8a Account management is operator-only
/v1/accounts* on the control plane creates, lists and destroys tenants. Creating one
provisions a container; deleting one destroys its repositories. None of that is a
tenant operation, so it is not reachable with an ordinary account's credential — a
tenant admin token must not be able to delete a different tenant.
The operator is named by <PREFIX>OPERATOR and must present an admin-scoped token
for that account, unconfined by a repository list. When the variable is unset every
account endpoint is refused: the alternative default was a host on which a stranger
could enumerate every tenant and delete them.
4.9 Browser views
A read-only, server-rendered HTML surface, written for people who have never used a code host. No session, no cookie, no form, and no write of any kind; all state lives in the URL.
The front page of a repository is its README rendered as a document — the
Markdoc idea applied to a repository — with the file listing one tab away. A
repository without a README falls back to the listing. Every repository page keeps
one row of tabs (Overview · Files · History · Branches) and every tab carries the
current ref, so switching views never resets the reader to the default branch; the
branch menu is a native <details> element and preserves the reader's path on the
branch it switches to.
Markdown carries a small block-tag dialect, a subset of Markdoc's syntax:
{% hero %}, {% callout %}, {% cards %}/{% card %} and {% details %}, each
alone on a line at column zero, closed with {% /name %}. Recognised tags become
styled wrappers with escaped attributes and never a URL; unknown tags are dropped
while their content renders; tags inside code fences are shown literally.
Relative links resolve against the document's directory and stay inside the
repository (.. clamps at the root): another markdown file opens as a rendered
page, an image serves through /raw/, a directory opens as a listing. Headings get
generated anchor ids.
Server-rendered from the binary with no bundler, no CDN and no client framework — the
product ships as one file a stranger drops on a VM, and a viewer needing npm install at release time would end that. It reads through git::discover, the same
code the REST API and the MCP tools use, so the browser cannot disagree with the API.
URLs are /{account}/{repo}, exactly the clone URL without .git, plus
/tree/{ref}/{path}, /blob/{ref}/{path} (?plain=1 shows a markdown file as
text), /raw/{ref}/{path} (bytes, attachment, sandbox CSP — the REST raw endpoint
requires a credential and this surface deliberately has none), /commits/{ref}
(?from={sha} pages older; the boundary must parse as an object id before it may
become a git argument), /commit/{sha} and /refs. / redirects to the repository
named by ZUKA_HOME_REPO, so every page has one address; serving the same
repository at two prefixes would make /blob/x impossible to tell from an account
named blob.
A missing page is answered with a friendly HTML 404 rather than problem+json —
identical for "absent" and "private", so the copy leaks nothing the status code
hides. A presented-but-rejected credential stays a 401: an expired token must never
quietly read as "this repository does not exist".
Every interpolated value is escaped. Markdown drops raw HTML and rewrites link
schemes to an allow-list, because a README is written by anyone who can push — a
stranger, on a public repository. The response carries
content-security-policy: default-src 'none' with no script source at all, so an
injection that survived the escaping would still have nowhere to execute.
The control plane proxies this surface to tenants without signing an assertion for anonymous visitors. An assertion is the control plane stating "this request is that account"; minting one for a stranger would hand them that account's private repositories.
4.3 Refs — read only
Full names (refs/heads/main), so branches, tags and refs/notes/* are all
addressable.
| Method | Path | Notes |
|---|---|---|
GET | /v1/repos/{a}/{r}/refs | Paginated. ?type=branch|tag|note filters. |
GET | /v1/repos/{a}/{r}/refs/{name} | ETag: "<sha>". |
Refs are moved with git push. There is no PUT or DELETE here: a second way to
move a ref is a second place for the fast-forward rule to be wrong, and §1.3 exists
to avoid exactly that.
4.4 Content — read only
Ref is a query parameter, never a path segment. feature/login and src/main.rs
both contain slashes, so /raw/{ref}/{path} has no unique parse — and percent-encoding
does not save it, because most reverse proxies normalize %2F before the handler sees
it.
| Method | Path | Notes |
|---|---|---|
GET | /v1/repos/{a}/{r}/tree/{path}?ref= | Paginated. |
GET | /v1/repos/{a}/{r}/raw/{path}?ref= | HEAD and Range supported. |
GET | /v1/repos/{a}/{r}/commits?ref=&path=&cursor= | |
GET | /v1/repos/{a}/{r}/commits/{sha} | |
GET | /v1/repos/{a}/{r}/compare/{base}...{head} | Diff. |
GET | /v1/repos/{a}/{r}/search?q=&ref=&path= | Bounded content search. |
These exist so a caller can answer "what is in here" without a clone — a dashboard rendering a file, an agent checking whether a path exists before it bothers fetching. They are a convenience over the git transport, not a substitute for it, and they are strictly read-only.
GET /commits?path= walks the DAG to find matching commits and is a trivial DoS
(?path=nonexistent walks all history). It carries a walk budget and returns a
truncated flag rather than running unbounded.
4.6 Runs, tokens, grants, account
| Method | Path | Notes |
|---|---|---|
GET | /v1/repos/{a}/{r}/runs | Newest first. ?status=. |
POST | /v1/repos/{a}/{r}/runs | {ref} → 201. |
GET | /v1/repos/{a}/{r}/runs/{id} | |
PATCH | /v1/repos/{a}/{r}/runs/{id} | {status:"cancelled"}. Terminal → 409. Not DELETE — the run still exists after. |
GET | /v1/repos/{a}/{r}/runs/{id}/logs | text/plain, complete-so-far, Range-capable. |
GET | /v1/repos/{a}/{r}/runs/{id}/logs/stream | SSE. Honours Last-Event-ID; event ids are byte offsets, so a dropped connection resumes. |
GET POST | /v1/tokens | §2.2. Never returns a secret after creation. |
DELETE | /v1/tokens/{id} | |
GET | /v1/repos/{a}/{r}/grants | |
PUT DELETE | /v1/repos/{a}/{r}/grants/{account} | Owner-only. write ⇒ code execution (§2.1). |
GET | /v1/account | Identity, scopes, quota usage. |
DELETE | /v1/account | Cascades repos, tokens, grants, container teardown. |
Log content type is fixed per endpoint. Negotiating on run state — SSE while running, plain text once finished — makes an identical request's content type depend on a race with the runner. That is not content negotiation.
4.7 Non-REST endpoints
Shape dictated by an external spec, so deliberately outside /v1:
GET /{account}/{repo}.git/info/refs?service=…
POST /{account}/{repo}.git/git-upload-pack
POST /{account}/{repo}.git/git-receive-pack
POST /mcp
GET /mcp SSE channel, per MCP transport
GET /healthz includes free-disk check
The git paths have their own error contract and RFC 9457 does not apply to them.
401 must carry WWW-Authenticate: Basic realm="zuka" or git never invokes its
credential helper and the clone fails instead of prompting. Responses use
application/x-git-*-result; info/refs sets Cache-Control: no-cache, max-age=0, must-revalidate. problem+json here is noise git will render at the user.
4.8 /raw is a hostile-content endpoint
Always application/octet-stream, Content-Disposition: attachment,
X-Content-Type-Options: nosniff, Content-Security-Policy: sandbox. Never sniffed,
never caller-specified. GitHub runs raw.githubusercontent.com on a separate origin
for exactly this reason.
CORS is disabled by default — §3 of the README says there is no web UI here, so
there is no first-party browser origin to serve. Where enabled it is an explicit
allowlist; Origin is never reflected. CORS plus bearer auth plus attacker-controlled
bytes is the standard cross-origin theft recipe.
Cache: public, max-age=31536000, immutable when ref is a full sha, no-cache
otherwise.
4.9 Limits
Stated, not implied: max request body 10 MiB; max changes[] length 1,000; max blob
32 MiB via API (pushes are bounded by receive.maxInputSize); tree and ref pages
paginated. Over-quota → 413 or quota-exceeded. Free disk below the reserve
threshold → 507 before the disk fills, because receive-pack interrupted by
ENOSPC leaves a partial pack.
5. MCP
POST /mcp JSON-RPC 2.0 plus the GET SSE channel and session identification the
current transport revision requires. Auth is a bearer token; whether that satisfies
the target client's authorization expectations is verified in M4, not assumed.
5.1 Tools
Administration and inspection. There is no tool that writes to a repository — an
agent that wants to change code runs git, which it already knows how to do.
| Tool | What it is for |
|---|---|
repo_create repo_list repo_get repo_delete | Provisioning. repo_create returns both clone URLs and may seed from import_url. |
key_add key_list key_remove | Register the agent's own SSH key so it can then use git. |
ref_list tree_list file_read search commit_log commit_get compare blame | Read without cloning. |
run_list run_get run_logs | Watch CI after a push. |
Each maps onto the same core call the REST handler uses.
5.2 What the facade may and may not do
Facades may fill defaults and reshape payloads — resolving ref to the repository's
default branch, for instance. They may not carry authorization rules; those live on
Identity so REST, MCP and SSH cannot drift (§1.1).
The intended shape of an agent session is: repo_create → key_add → then plain
git clone, git commit, git push in the agent's own workspace → run_status to
see whether CI passed. The MCP surface removes the GitHub-shaped work; git does the
git-shaped work.
6. CI
6.1 Trigger and hooks
Hooks are symlinks into $DATA_DIR/hooks/, installed via init.templateDir at
repo creation. Symlinks, not copies, so upgrading a hook is atomic across every
existing repo rather than leaving pre-change repos on the old version forever.
| Hook | Job |
|---|---|
update | Calls git::store::check_ref_move, the single implementation of the ref rule. |
post-receive | Writes a queued run record. Never blocks the push. |
The queue is the run records (ci::run::RunStore::queued), not a separate
channel: a push that lands while the server is down is still picked up when it
returns, and the API and the executor cannot disagree about what is pending. The cost
is latency — the executor polls, so a run waits up to POLL before starting. A socket
would remove that; durability across a restart mattered more.
Hooks receive ZUKA_DATA_DIR and ZUKA_CI_ENABLED through
Config::hook_env. Both git spawn sites clear the environment deliberately, so a
hook would otherwise inherit nothing and resolve the default data directory.
pre-receive commit-signature enforcement is cut from v1: it needs a keyring, a
trust model and an API surface, none of which exist. Branch protection beyond
fast-forward is cut for the same reason. Asserting a feature into existence with a
table row is how specs lie.
6.2 Spec
.zuka.toml at the repo root:
[run]
steps = ["cargo test", "cargo build --release"]
timeout_secs = 600
branches = ["refs/heads/main"]
There is no runtime key. An earlier draft specified one, validated it against a
list of toolchains, and then ignored it — every step ran under /bin/sh regardless.
A knob that appears to select a toolchain and does not is worse than no knob. Steps
run with whatever is on the host PATH; provisioning toolchains is the operator's
job.
A repository may lower timeout_secs but never raise it past the host ceiling,
otherwise one repository can hold a runner slot indefinitely.
6.3 Isolation
tenant mode. The container is the boundary between accounts. It is not a
boundary between untrusted CI code and the service process sharing that container, so:
- The runner has its own uid.
RLIMIT_NPROCis per-uid — sharing one with the service means a fork bomb in CI takes down the service. - The environment is scrubbed (
env -i). The service's credentials must not be readable from/proc/self/environor the unit file. - The tenant holds a per-tenant KV credential, not a shared one. A shared
DENO_KV_ACCESS_TOKENinside a container running arbitrary code means every tenant can read every other account's rows — and would make the isolation milestone's proof false. - Limits:
RLIMIT_CPU,RLIMIT_AS,RLIMIT_FSIZE,RLIMIT_NOFILE, wall clock and an output cap.RLIMIT_NPROCis off by default and this is not timidity: it is per-UID, so with the runner sharing a uid with the service it counts processes we do not control — a useful-looking value makesforkfail on the first step. It becomes meaningful only alongside a dedicated runner uid, which is also what makes it safe. - Timeout kills the cgroup, not the shell — killing the shell orphans the tree.
- No network: a network namespace with no route out, stated as the mechanism rather than as an intention.
standalone mode. Same uid separation and rlimits, no container. This is not a
security boundary, CI defaults to off, and the README says so in those words.
A step allowlist was considered and rejected as theatre — sh -c defeats it in one
character. Enforcement is at the OS or it is not enforcement. That principle then has
to be followed through, which is what the bullets above are.
6A. SSH
Shipped in M1 (pulled forward from M7). A russh server in-process — not the host's
sshd, which would need per-host configuration and break the single-binary promise.
Authentication is public-key only. Password and keyboard-interactive are rejected outright. The offered key is matched against stored keys by raw bytes and the account falls out of the match; the SSH username is never read.
A session may exec exactly three commands. git-upload-pack,
git-receive-pack and git-upload-archive, against one repository. There is no
shell, no pty and no subsystem. The exec string is parsed into a fixed service plus a
validated repository and is never handed to a shell, so git-upload-pack '/a/b.git'; id is a parse error rather than an execution.
upload-archive was refused in an earlier draft on the grounds that it exposes tree
contents through a separate code path. That was wrong: it exposes exactly what
upload-pack already exposes to a caller who has read access, so refusing it withheld
a normal git capability without withholding any data. It requires read, not write.
One environment variable crosses the boundary. GIT_PROTOCOL, capped at 64
bytes. Forwarding arbitrary client environment into a subprocess is an injection
primitive: GIT_CONFIG_*, GIT_ALTERNATE_OBJECT_DIRECTORIES and LD_PRELOAD all
change what the child does.
Keys carry their own scope. read_only makes a key clone-and-fetch only;
repos[] confines it to named repositories. A key is a credential with the reach of
a repo:write token and is treated as one.
The host key is generated once and reused, so clients are not trained to accept a changed fingerprint.
Concurrency, the disconnect kill and the ref rule are shared with the HTTP path: the
same semaphore bounds both, the same process-group kill applies, and a forced
non-fast-forward is refused by check_ref_move regardless of which transport carried
it.
7. Control plane
A tenant never learns its own public address. It sits on a private bridge behind the
control plane, so the control plane passes PUBLIC_URL and PUBLIC_SSH down when it
provisions one. Without that a tenant advertises its bind address and every clone URL
it returns points at a container the caller cannot reach — which is what it did until
it was tested on real infrastructure.
7.1 Provisioning
Account creation writes state provisioning and returns immediately. A reconciler
drives it to ready or failed. Never a synchronous wait on container boot — the
non-durable queueMicrotask in yangu/roadmap/mailbox-provisioning.md is the
specific mistake not to repeat: no due index, no retry, no failure surface.
Containers stop when idle and start on demand, with a stated cold-start latency budget. Always-on would mean one permanently running container per account for users who push twice a year.
7.2 Proxying
The control plane proxies long-lived, bidirectional, sideband-multiplexed,
multi-gigabyte git streams, plus SSE. Backpressure must be preserved end to end and
SSE must not be buffered. Shelling out to git at the engine does not help at the
proxy hop — this is its own risk (ROADMAP.md R6), not a free
consequence of §1.2.
8. Source layout
zuka/
├── Cargo.toml Cargo.lock README.md openapi.json
├── src/
│ ├── main.rs boot, CLI, git hook entry point
│ ├── brand.rs the one place the product name appears
│ ├── config.rs Config::from_env(); fails boot on a bad value
│ ├── error.rs Error -> problem+json
│ ├── core/ domain operations shared by every facade
│ ├── git/
│ │ ├── exec.rs the only place this service invokes git
│ │ ├── validate.rs names, refs, tree paths, blob modes
│ │ ├── store.rs repositories, hooks, ref rules, protection
│ │ ├── discover.rs the read core, shared by REST and MCP
│ │ ├── transport.rs streaming CGI bridge
│ │ └── import.rs SSRF-guarded cloning
│ ├── http/ routing, limits, responses, auth, rate limiting
│ ├── api/ REST facade + the OpenAPI document
│ ├── mcp/ MCP facade
│ ├── ssh/ SSH server and exec-command parser
│ ├── ci/ spec, run store, runner, executor
│ ├── jobs/ gc, sweep, fsck
│ ├── account/ identity, tokens, SSH keys
│ └── store/ metadata, quotas, change-aware file cache
└── test/live/ spawned-binary suite driven by real git and ssh
8.1 The core layer
core/ exists because REST and MCP each grew their own copy of repository create and
delete, and the copies drifted: the MCP one had no rollback (so a failed git init
wedged the name with a permanent Creating record), did not support protected refs or
import, and left run records behind on delete — which meant re-creating a name
resurrected the previous repository's CI history.
AppState::open_repo is the same lesson one layer down: the sequence "resolve names,
check access, confirm ready, get path" appeared seven times, three of them subtly
different. Access::Write also enforces the disk reserve and the storage quota, so
no write path can forget them.
8.2 Why nested rather than flat
yangu/atlas keeps everything in a 1,158-line main.rs; yangu/kazi's is 3,050.
That is a judgment call about four protocol surfaces — REST, MCP, git wire,
control-plane RPC — against atlas's one, not a claim that the flat pattern has been
measured to fail. Stating it as proven would apply a lower evidentiary standard than
the convention it deviates from demands.
8.3 Scheduled work has its own unit
jobs/ runs in a separate zuka-jobs systemd unit, not the web entrypoint: GC and
repack, the deleted-repo sweep, the provisioning reconciler, and quota recomputation.
Running them in the server process would violate the same rule for the same reason it
exists.
8.4 Errors
anyhow internally; one enum at the boundary.
enum Error {
NotFound(&'static str),
Conflict { slug: &'static str, detail: String },
Unauthorized, Forbidden,
Invalid { slug: &'static str, detail: String },
Unprocessable(String),
MethodNotAllowed,
PayloadTooLarge { limit: u64 },
QuotaExceeded { limit: u64, used: u64 },
RateLimited { retry_after: u32 },
StorageFull,
Internal(anyhow::Error),
}
StorageFull → 507, Internal → 500 with the detail logged and never returned.
There is no Upstream variant: nothing makes an upstream call, because hosted auth
refuses to boot rather than being half-implemented.
9. Data
9.1 Disk
$ZUKA_DATA_DIR/
├── git/{account}/{repo}.git/
├── hooks/ templateDir; symlink targets
├── runs/{account}/{repo}/ one JSON record and one log per run
└── tmp/deleted/ soft-deleted repos, 7-day sweep
9.2 KV
| Key | Value |
|---|---|
["zuka","account",account] | AccountRecord incl. provisioning state |
["zuka","container",account] | container id + endpoint (control) |
["zuka","repo",account,repo] | RepoRecord |
["zuka","repo_index",account,repo] | repo |
["zuka","token",tokenHash] | TokenRecord |
["zuka","token_id",account,tokenId] | tokenHash |
["zuka","grant",account,repo,grantee] | Capability |
["zuka","run",account,repo,runId] | RunRecord |
["zuka","run_index",account,repo,ts,runId] | runId |
["zuka","idem",tokenId,key] | cached response, 24h TTL |
["zuka","audit",account,ts,rand] | AuditRow, 90-day TTL |
token_id → tokenHash exists so DELETE /v1/tokens/{id} can find the canonical row;
without it the delete has no path to the record. The audit key carries a random suffix
because two operations in the same millisecond are ordinary for git and would
otherwise overwrite each other.
Audit writes are fire-and-forget — a failed log row must never block the operation it describes.
Delete ordering: disk move first, then KV. A crash between them leaves a repo in
tmp/deleted/ with live KV rows, which zuka fsck reports and can roll back. The
reverse leaves an unreachable repo and a 500.
9.3 Backup and reconciliation
Repos live on a local disk and metadata lives in a remote KV; they are never
snapshotted together, so a restore reconciles two stores with different recovery
points. zuka fsck lists orphans in both directions. Disk is authoritative for
repository existence; KV is authoritative for access control. Documented because the
system enters a split state deliberately on every delete.
9.4 Garbage collection
Every API commit writes loose objects; every push leaves a pack; packed-refs is
never rewritten. Without GC, inode count and disk grow without bound.
jobs/gc runs git repack -Ad + git prune --expire with gc.auto=0, holding a
lock coordinated with git/transport.rs — unsynchronized GC concurrent with a live
upload-pack hands the client a corrupt clone.
The support case this creates is worth stating: "the agent committed my API key." Force-moving the ref leaves the blob fetchable by sha until the next GC. Product docs must say so rather than implying deletion is immediate.
10. Config and observability
std::env::var with defaults, gathered into Config::from_env() -> Result<Config>
that fails boot loudly. Prefix ZUKA_ — including ZUKA_BIND; only genuinely
shared infra names (KV_URL) go unprefixed.
| Var | Default | Meaning |
|---|---|---|
ZUKA_BIND | 127.0.0.1:8790 | Listen address |
ZUKA_MODE | standalone | standalone | control | tenant |
ZUKA_DATA_DIR | /var/lib/zuka | |
ZUKA_ME_URL | unset | Opt-in hosted auth. Unset → local token file. |
ZUKA_CI_ENABLED | 0 | Must be set explicitly in every mode (§6.3) |
ZUKA_CONTROL_PUBKEY | — | Ed25519 public key; required in tenant |
ZUKA_DISK_RESERVE_MB | 2048 | Below this, writes → 507 |
KV_URL | — | Per-tenant credential in tenant mode |
ZUKA_ME_URL deliberately has no default. Defaulting it to worklyn.me would
mean a self-hoster who configures nothing gets a binary that phones Worklyn to
authenticate and returns 502 when it cannot — for the mode described as what a
self-hoster runs.
Observability: structured single-line logs carrying method, route, status,
duration, account and request id. Counters for repo bytes, object counts, CI queue
depth and run outcomes — the queue-depth counter is what makes the overflow policy in
ROADMAP.md D5 decidable instead of guessed. Whether this emits OTLP to
product/observability/ is D6.