zuka
zuka/SPEC.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/SPEC.md
MDSPEC.md38.8 KBPlain textDownload

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. Not upload-pack --stateless-rpc directly — http-backend already handles gzipped request bodies, GIT_PROTOCOL negotiation, 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.

ModeSource
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.
tenantX-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_sha256 means 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 modes100644 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

  • POST to a collection creates → 201 + Location.
  • DELETE of 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.
  • PATCH is application/merge-patch+json (RFC 7396); null clears.
  • Idempotency-Key accepted on POST /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-Match304.
  • Lists return {items, truncated}. There is no cursor. An earlier draft specified one and emitted next_cursor: null on every list including truncated ones, which told a client the list had ended when it had not. truncated says what is true; a real cursor can be added later without having lied in the meantime. Default limit 50, max 200, both configurable.
  • Errors are RFC 9457 application/problem+json with a real type URI from https://<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 on type and read extension members; never on detail.
  • Rate limits per token and per IP. 429 + Retry-After.

4.2 Repositories

MethodPathNotes
GET/v1/reposOwned + 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.

MethodPathNotes
GET/v1/repos/{a}/{r}/refsPaginated. ?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.

MethodPathNotes
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

MethodPathNotes
GET/v1/repos/{a}/{r}/runsNewest 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}/logstext/plain, complete-so-far, Range-capable.
GET/v1/repos/{a}/{r}/runs/{id}/logs/streamSSE. 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/accountIdentity, scopes, quota usage.
DELETE/v1/accountCascades 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.

ToolWhat it is for
repo_create repo_list repo_get repo_deleteProvisioning. repo_create returns both clone URLs and may seed from import_url.
key_add key_list key_removeRegister the agent's own SSH key so it can then use git.
ref_list tree_list file_read search commit_log commit_get compare blameRead without cloning.
run_list run_get run_logsWatch 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_createkey_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.

HookJob
updateCalls git::store::check_ref_move, the single implementation of the ref rule.
post-receiveWrites 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_NPROC is 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/environ or the unit file.
  • The tenant holds a per-tenant KV credential, not a shared one. A shared DENO_KV_ACCESS_TOKEN inside 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_NPROC is 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 makes fork fail 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),
}

StorageFull507, Internal500 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

KeyValue
["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.

VarDefaultMeaning
ZUKA_BIND127.0.0.1:8790Listen address
ZUKA_MODEstandalonestandalone | control | tenant
ZUKA_DATA_DIR/var/lib/zuka
ZUKA_ME_URLunsetOpt-in hosted auth. Unset → local token file.
ZUKA_CI_ENABLED0Must be set explicitly in every mode (§6.3)
ZUKA_CONTROL_PUBKEYEd25519 public key; required in tenant
ZUKA_DISK_RESERVE_MB2048Below this, writes → 507
KV_URLPer-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.