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.md39.8 KBFormattedDownload
1# zuka — technical specification
2
3Status: scoped, not started. Milestones, risks and open decisions live in
4[`ROADMAP.md`](ROADMAP.md); this document is the durable technical reference. Where
5the two disagree, this one wins on *how* and the roadmap wins on *when*.
6
7Product framing, positioning and non-goals: [`README.md`](README.md).
8
9---
10
11## 1. Architecture
12
13### 1.1 Three modes, one binary
14
15```
16standalone Engine only. Repos on local disk, tokens from a local file.
17 No outbound calls. This is what a self-hoster runs. Default.
18
19control Control plane. Owns accounts, provisioning, routing. Holds no
20 repositories. Proxies data-plane requests to tenant containers.
21
22tenant Engine inside an Incus container behind a control plane. Trusts a
23 signed identity assertion instead of verifying tokens itself.
24```
25
26`standalone` and `tenant` share the request-handling path exactly. They differ in
27boot configuration and in the CI isolation model (§6.3) — that is the honest claim;
28"identical but for one function" would not survive §6.3 or §9.
29
30```
31 agent ──MCP───►┌──────────────────────────────┐
32 app ──REST──►│ facades: mcp/ api/ │
33 └──────────────┬───────────────┘
34 │ same core calls
35 ┌──────────────▼───────────────┐
36 │ core: git/ ci/ account/ │
37 └───┬──────────────────────┬───┘
38 git CLI ──────►git/transport.rs │
39 │ │
40 ┌──────▼──────┐ ┌──────▼──────┐
41 │ filesystem │ │ KV │
42 │ bare repos │ │ metadata │
43 └─────────────┘ └─────────────┘
44```
45
46**Facades carry wire-shape translation and client ergonomics. They never carry
47authorization or concurrency rules.** Defaults and argument-filling are allowed in
48`mcp/` (§5.2); access checks and ref preconditions are not, and never appear twice.
49
50### 1.2 Git access
51
52Everything goes through `git` subprocesses, funnelled through `git::exec::Git` —
53the single place this service invokes git. Before that module existed there were five
54wrappers and nine ad-hoc spawns with three different environment policies, so
55`/etc/gitconfig` applied to `git init`, `git config`, `merge-base --is-ancestor` (the
56ref rule) and `count-objects` (quota) but not to the wire protocol. Half a control is
57not a control; `Git` clears the environment and sets `GIT_CONFIG_NOSYSTEM` on every
58invocation.
59
60`gix` is not used. A subprocess per read is a real cost, mitigated by running them on
61a blocking pool rather than a runtime worker; migrating the read path is a
62measurement-driven decision and no measurement has been taken.
63
64**Error mapping matters here.** `Git::query` treats exit 1 as an answer ("no such
65object", "no matches") and anything higher as a fault. An earlier version collapsed
66every non-zero exit into `404`, which meant a corrupt repository reported as empty
67and nothing paged anyone. Existence is gated on `rev-parse --verify --quiet`, which
68exits 1; `cat-file` exits 128 for the same condition and cannot be used for it.
69
70#### Wire protocol
71
72- **Reads and API writes** use `gix`. Refs, trees, blobs, log, and building a
73 tree+commit in-process. No working copy, no subprocess.
74- **The wire protocol** delegates to **`git http-backend`**, the reference CGI. Not
75 `upload-pack --stateless-rpc` directly — `http-backend` already handles gzipped
76 request bodies, `GIT_PROTOCOL` negotiation, framing, and the exact content types
77 git expects. Reimplementing those is the failure this choice exists to avoid.
78
79Naming the binary matters: the two options differ by roughly everything in
80[`ROADMAP.md`](ROADMAP.md) R1.
81
82Required around the CGI regardless: kill the child when the client disconnects (hyper
83drops the body future and the process otherwise spins), set `receive.fsckObjects=true`
84and `receive.maxInputSize`, and hold a semaphore across concurrent `pack-objects` so N
85simultaneous clones cannot exhaust host memory.
86
87`git` becomes a runtime dependency, minimum **2.41** (for `--filter` and protocol v2
88behaviour we rely on). The README states it; "it is on every Linux host" is not a
89version.
90
91### 1.3 Git is the only writer
92
93**Nothing but `git` writes to a repository.** No API creates commits, moves refs or
94edits files. The wire protocol is the write path; the REST and MCP surfaces are
95administration and read-only inspection (§4).
96
97This is the single most load-bearing decision in the design, and it deletes a large
98amount of machinery that an earlier draft of this document specified: no in-process
99commit building, no `base_sha` optimistic-concurrency protocol, no ref
100compare-and-swap against a concurrent `receive-pack`, no idempotency keys on writes,
101no merge story. There is one writer, `receive-pack`, and it already does its own
102locking correctly.
103
104What remains is the ref *policy*: `git::store::check_ref_move` decides whether a move
105is allowed. It has one caller, the `update` hook, which is a one-line `exec` back
106into this binary so the rule is written in Rust rather than shell. Reflogs come from
107git itself (`core.logAllRefUpdates`), not from us.
108
109### 1.4 Stack
110
111`hyper` 1.x + `tokio`, no web framework. `anyhow` internally, a typed `Error` at the
112boundary (§8.3). `serde` on the wire. Structured single-line logs (§10). `edition
1132021`, `opt-level = 3`, `lto = "thin"`, committed `Cargo.lock`.
114
115Beyond that: `gix`, `gix-validate`, `ed25519-dalek`, `sha2`, `hex`, `uuid`, `toml`,
116`base64`, `tokio-util`.
117
118---
119
120## 2. Identity, auth, validation
121
122### 2.1 Flat model
123
124```
125account the unit of ownership. Has repos, tokens.
126token a scoped, expiring credential belonging to one account.
127grant (repo, account, capability) — how a second account gets access.
128capability read | write | admin
129```
130
131No orgs, no teams, no nesting. `admin` implies `write` implies `read`. `admin` may
132manage grants but **may not** delete the repo or grant `admin`; both are owner-only.
133
134**`write` implies code execution** in the repo owner's CI container, because a writer
135can author `.zuka.toml` (§6). This is stated here because it materially changes
136what granting `write` means, and it is repeated at the grant endpoint.
137
138### 2.2 Tokens
139
140`POST /v1/tokens {name, scopes[], repos[]?, expires_in}`. Scopes: `repo:read`,
141`repo:write`, `admin`, `ci`. `expires_in` has a non-infinite default (90 days).
142
143Stored as SHA-256; plaintext returned once, at creation. `TokenRecord` carries
144`last_used_at` so revocation triage is possible.
145
146A single unscoped forever-token is total account compromise, and the same secret gets
147pasted into `git` (landing in `~/.git-credentials` in plaintext), an MCP client
148config, and potentially a CI environment. Scopes and expiry are not optional.
149
150**CI never receives an account token.** A run gets a single-repo, `ci`-scoped,
151run-lifetime credential that cannot mint tokens.
152
153### 2.3 Resolving `Identity`
154
155One function, three implementations, with the failure discipline borrowed from
156`yangu/atlas`: a rejected credential is `401`, an unreachable auth service is `502`,
157and there is never a silent fallback to a lesser identity.
158
159| Mode | Source |
160|---|---|
161| `standalone` (default) | `$DATA_DIR/tokens.json`, SHA-256 compared. No network. |
162| `standalone` (hosted) | `GET {ZUKA_ME_URL}/verify`, opt-in. Positive results cached 60s — otherwise every read blocks on a cross-service round trip. |
163| `tenant` | `X-Zuka-Identity`, Ed25519-verified (§2.4). |
164
165### 2.4 The tenant identity assertion
166
167A shared symmetric key would be readable from inside a tenant container by the CI
168code running there, and would then forge any account against any tenant. So:
169
170- **Ed25519.** Control signs with a private key it never distributes; tenants hold
171 only the public key. Two public keys accepted during rotation overlap.
172- **Signed payload**, canonically serialized (field order fixed, no whitespace):
173 `{account, issued_at, expires_at, nonce, method, path, body_sha256}`.
174- `expires_at - issued_at <= 60s`. Clock skew tolerance ±30s, stated not implied.
175- Nonce cached until expiry; replays rejected.
176- Binding to `method`/`path`/`body_sha256` means a captured assertion authorizes one
177 request, not an identity.
178- Tenants bind to a private interface and reject connections not originating from the
179 control plane.
180
181### 2.5 Validation is normative, not a test case
182
183These are the rules, not suggestions to be covered by a unit test later.
184
185**Account and repo names** — `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, no `..`, no `.lock`
186suffix, unique **case-insensitively** (the KV key is case-sensitive and APFS is not;
187without this, disk and metadata silently disagree). Reserved: `.`, `..`, `.git`.
188
189**Ref names** — delegated to `gix-validate`'s reference-name check. Never hand-rolled.
190Must resolve under `refs/heads/` or `refs/tags/`; after canonicalization the resolved
191filesystem path must be a prefix-child of `$GIT_DIR/refs/`. An unvalidated ref name is
192remote code execution: `../../config` writes git config, and `core.fsmonitor`,
193`core.sshCommand` and `core.pager` are all command-execution keys.
194
195**Tree paths** — reject absolute, empty, `.`, `..`, and any component equal to `.git`
196after Unicode normalization (NFC and NFD) and case folding. Reject `.gitmodules`
197entries whose submodule path escapes (the CVE-2018-11235 family). Cap component count
198and total path length.
199
200**File modes** — `100644` and `100755` only. `120000` (symlink) is rejected on write,
201and `GET .../raw` never follows one. `160000` (gitlink) and `040000` are not
202constructible through the API.
203
204**Cursors** — opaque and HMAC'd. A client-supplied cursor must never become a raw KV
205start-key; denokv's key escaping makes that a silent-correctness risk rather than a
206loud failure.
207
208---
209
210## 3. Path shape
211
212**Decided: `/v1/repos/{account}/{repo}`.** The account is in the path.
213
214The short form `/v1/repos/{repo}` was rejected because it breaks four things at once:
215the control plane cannot route to the *owner's* container without it; grants become
216unaddressable (a grantee has no way to name someone else's repo); the KV keys
217`grant`/`run`/`run_index` collide across accounts; and it disagrees with the git path
218`/{account}/{repo}.git`, so an agent holding a clone URL cannot derive the REST URL.
219
220---
221
222## 4. REST
223
224Base `/v1`. JSON in, JSON out. `{a}` = account, `{r}` = repo.
225
226**This surface administers repositories; it does not modify their contents.** Create,
227delete, list and configure a repository, manage credentials, read what is in a
228repository, watch CI. To change code you use `git`, over HTTP or SSH, exactly as you
229would anywhere else.
230
231The reason is that an agent already knows git. What it cannot do without help is the
232part GitHub bolted on top — provisioning, keys, access, CI. That is what this API is
233for, and keeping writes out of it means there is no second write path to keep
234consistent with the first.
235
236### 4.1 Rules
237
238- `POST` to a collection creates → `201` + `Location`.
239- `DELETE` of an absent resource → `404`. Idempotency is about the effect on the
240 server (RFC 9110 §9.2.2), not the status code; an agent that typos a repo name must
241 not be told it deleted something.
242- `PATCH` is `application/merge-patch+json` (RFC 7396); `null` clears.
243- `Idempotency-Key` accepted on `POST /repos`; `(key → response)` stored 24h and
244 replayed, so an agent whose response is lost can retry a create safely.
245- ETags are quoted strong entity-tags: `ETag: "<sha>"`. `If-None-Match` → `304`.
246- Lists return `{items, truncated}`. **There is no cursor.** An earlier draft
247 specified one and emitted `next_cursor: null` on every list including truncated
248 ones, which told a client the list had ended when it had not. `truncated` says what
249 is true; a real cursor can be added later without having lied in the meantime.
250 Default `limit` 50, max 200, both configurable.
251- Errors are RFC 9457 `application/problem+json` with a real `type` URI from
252 `https://<host>/problems/{slug}` — `ref-moved`, `precondition-required`,
253 `invalid-path`, `invalid-ref`, `quota-exceeded`, `rate-limited`, `payload-too-large`,
254 `storage-full`, `not-found`, `forbidden`. Clients discriminate on `type` and read
255 extension members; never on `detail`.
256- Rate limits per token and per IP. `429` + `Retry-After`.
257
258### 4.2 Repositories
259
260| Method | Path | Notes |
261|---|---|---|
262| `GET` | `/v1/repos` | Owned + granted. `?role=owner\|grantee`. |
263| `POST` | `/v1/repos` | `{name, default_branch, description}` → `201`. Duplicate → `409`. |
264| `GET` | `/v1/repos/{a}/{r}` | Unreadable repo → `404`, not `403` (no existence leak). |
265| `PATCH` | `/v1/repos/{a}/{r}` | `default_branch` must reference an existing branch → else `409`. Updates `HEAD`. |
266| `DELETE` | `/v1/repos/{a}/{r}` | `204`. Disk move first, then KV (§9.2). |
267
268`visibility` is `private` (the default) or `public`. A record written before the
269field existed reads as private: absence meaning public would have published every
270repository on a host at the first upgrade that understood it.
271
272Public grants **anonymous read and nothing else** — the browser views (§4.9) and
273`git clone`. It never grants a write: `git-receive-pack` demands an identity before
274anything else, and there is no way to spell an anonymous write to have to refuse it.
275Anonymous access is granted in exactly one function, `AppState::open_public_repo`;
276every other path in the service requires an `Identity`, so a handler that forgets it
277produces a `401`, not a disclosure.
278
279A credential that is presented and rejected stays rejected on every surface. It never
280degrades to an anonymous visitor, because that turns an expired token into a success
281on public repositories and a bare `404` on private ones.
282
283On the git paths an anonymous read that is refused answers `401` with a challenge,
284not `404` — git only runs its credential helper on a challenge. The answer is `401`
285whether the repository is private or absent, so existence is still not disclosed. The
286browser surface answers `404` instead, because a challenge there raises a login
287dialog no visitor can satisfy.
288
289### 4.8a Account management is operator-only
290
291`/v1/accounts*` on the control plane creates, lists and destroys tenants. Creating one
292provisions a container; deleting one destroys its repositories. None of that is a
293tenant operation, so it is not reachable with an ordinary account's credential — a
294tenant admin token must not be able to delete a different tenant.
295
296The operator is named by `<PREFIX>OPERATOR` and must present an admin-scoped token
297for that account, unconfined by a repository list. When the variable is unset every
298account endpoint is refused: the alternative default was a host on which a stranger
299could enumerate every tenant and delete them.
300
301### 4.9 Browser views
302
303A read-only, server-rendered HTML surface, written for people who have never used a
304code host. No session, no cookie, no form, and no write of any kind; all state lives
305in the URL.
306
307**The front page of a repository is its README rendered as a document** — the
308Markdoc idea applied to a repository — with the file listing one tab away. A
309repository without a README falls back to the listing. Every repository page keeps
310one row of tabs (Overview · Files · History · Branches) and every tab carries the
311current ref, so switching views never resets the reader to the default branch; the
312branch menu is a native `<details>` element and preserves the reader's path on the
313branch it switches to.
314
315Markdown carries a small block-tag dialect, a subset of Markdoc's syntax:
316`{% hero %}`, `{% band %}`, `{% big %}`, `{% callout %}`, `{% cards %}`/`{% card %}`
317and `{% details %}`, each alone on a line at column zero, closed with `{% /name %}`.
318Recognised tags become styled wrappers with escaped attributes and never a URL;
319unknown tags are dropped while their content renders; tags inside code fences are
320shown literally. Styling attributes (`tone` from a six-colour palette, `align`,
321`columns`) are allow-lists that emit fixed classes — a document chooses from the
322palette and can never invent a style, so expressiveness adds no injection surface.
323The repository front page renders full-bleed: prose keeps a readable measure while
324heroes and bands paint to the viewport edge; the generated `llms.txt` documents the
325dialect so any agent discovers it at creation time.
326Relative links resolve against the document's directory and stay inside the
327repository (`..` clamps at the root): another markdown file opens as a rendered
328page, an image serves through `/raw/`, a directory opens as a listing. Headings get
329generated anchor ids.
330
331Server-rendered from the binary with no bundler, no CDN and no client framework — the
332product ships as one file a stranger drops on a VM, and a viewer needing `npm
333install` at release time would end that. It reads through `git::discover`, the same
334code the REST API and the MCP tools use, so the browser cannot disagree with the API.
335
336URLs are `/{account}/{repo}`, exactly the clone URL without `.git`, plus
337`/tree/{ref}/{path}`, `/blob/{ref}/{path}` (`?plain=1` shows a markdown file as
338text), `/raw/{ref}/{path}` (bytes, attachment, sandbox CSP — the REST raw endpoint
339requires a credential and this surface deliberately has none), `/commits/{ref}`
340(`?from={sha}` pages older; the boundary must parse as an object id before it may
341become a git argument), `/commit/{sha}` and `/refs`. `/` redirects to the repository
342named by `ZUKA_HOME_REPO`, so every page has one address; serving the same
343repository at two prefixes would make `/blob/x` impossible to tell from an account
344named `blob`.
345
346Every repository also answers `/{account}/{repo}/llms.txt` — a committed `llms.txt`
347served verbatim, else a generated summary (name, description, documents, clone URL,
348API and MCP access) — because on an agent-first host "no file" must not mean "no
349front door". The root `/llms.txt` follows the same rule as `/`: it redirects to the
350featured repository's, or describes the service itself when none is named. Access
351follows the page rule; a private repository's `llms.txt` is not readable
352anonymously.
353
354A missing page is answered with a friendly HTML 404 rather than `problem+json` —
355identical for "absent" and "private", so the copy leaks nothing the status code
356hides. A presented-but-rejected credential stays a 401: an expired token must never
357quietly read as "this repository does not exist".
358
359Every interpolated value is escaped. Markdown drops raw HTML and rewrites link
360schemes to an allow-list, because a README is written by anyone who can push — a
361stranger, on a public repository. The response carries
362`content-security-policy: default-src 'none'` with no script source at all, so an
363injection that survived the escaping would still have nowhere to execute.
364
365The control plane proxies this surface to tenants **without signing an assertion**
366for anonymous visitors. An assertion is the control plane stating "this request is
367that account"; minting one for a stranger would hand them that account's private
368repositories.
369
370### 4.3 Refs — read only
371
372Full names (`refs/heads/main`), so branches, tags and `refs/notes/*` are all
373addressable.
374
375| Method | Path | Notes |
376|---|---|---|
377| `GET` | `/v1/repos/{a}/{r}/refs` | Paginated. `?type=branch\|tag\|note` filters. |
378| `GET` | `/v1/repos/{a}/{r}/refs/{name}` | `ETag: "<sha>"`. |
379
380Refs are moved with `git push`. There is no `PUT` or `DELETE` here: a second way to
381move a ref is a second place for the fast-forward rule to be wrong, and §1.3 exists
382to avoid exactly that.
383
384### 4.4 Content — read only
385
386Ref is a **query parameter**, never a path segment. `feature/login` and `src/main.rs`
387both contain slashes, so `/raw/{ref}/{path}` has no unique parse — and percent-encoding
388does not save it, because most reverse proxies normalize `%2F` before the handler sees
389it.
390
391| Method | Path | Notes |
392|---|---|---|
393| `GET` | `/v1/repos/{a}/{r}/tree/{path}?ref=` | Paginated. |
394| `GET` | `/v1/repos/{a}/{r}/raw/{path}?ref=` | `HEAD` and `Range` supported. |
395| `GET` | `/v1/repos/{a}/{r}/commits?ref=&path=&cursor=` | |
396| `GET` | `/v1/repos/{a}/{r}/commits/{sha}` | |
397| `GET` | `/v1/repos/{a}/{r}/compare/{base}...{head}` | Diff. |
398| `GET` | `/v1/repos/{a}/{r}/search?q=&ref=&path=` | Bounded content search. |
399
400These exist so a caller can answer "what is in here" without a clone — a dashboard
401rendering a file, an agent checking whether a path exists before it bothers fetching.
402They are a convenience over the git transport, not a substitute for it, and they are
403strictly read-only.
404
405`GET /commits?path=` walks the DAG to find matching commits and is a trivial DoS
406(`?path=nonexistent` walks all history). It carries a walk budget and returns a
407`truncated` flag rather than running unbounded.
408
409### 4.6 Runs, tokens, grants, account
410
411| Method | Path | Notes |
412|---|---|---|
413| `GET` | `/v1/repos/{a}/{r}/runs` | Newest first. `?status=`. |
414| `POST` | `/v1/repos/{a}/{r}/runs` | `{ref}` → `201`. |
415| `GET` | `/v1/repos/{a}/{r}/runs/{id}` | |
416| `PATCH` | `/v1/repos/{a}/{r}/runs/{id}` | `{status:"cancelled"}`. Terminal → `409`. Not `DELETE` — the run still exists after. |
417| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs` | `text/plain`, complete-so-far, `Range`-capable. |
418| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs/stream` | SSE. Honours `Last-Event-ID`; event ids are byte offsets, so a dropped connection resumes. |
419| `GET` `POST` | `/v1/tokens` | §2.2. Never returns a secret after creation. |
420| `DELETE` | `/v1/tokens/{id}` | |
421| `GET` | `/v1/repos/{a}/{r}/grants` | |
422| `PUT` `DELETE` | `/v1/repos/{a}/{r}/grants/{account}` | Owner-only. `write` ⇒ code execution (§2.1). |
423| `GET` | `/v1/account` | Identity, scopes, quota usage. |
424| `DELETE` | `/v1/account` | Cascades repos, tokens, grants, container teardown. |
425
426Log content type is fixed per endpoint. Negotiating on *run state* — SSE while
427running, plain text once finished — makes an identical request's content type depend
428on a race with the runner. That is not content negotiation.
429
430### 4.7 Non-REST endpoints
431
432Shape dictated by an external spec, so deliberately outside `/v1`:
433
434```
435GET /{account}/{repo}.git/info/refs?service=…
436POST /{account}/{repo}.git/git-upload-pack
437POST /{account}/{repo}.git/git-receive-pack
438POST /mcp
439GET /mcp SSE channel, per MCP transport
440GET /healthz includes free-disk check
441```
442
443**The git paths have their own error contract and RFC 9457 does not apply to them.**
444`401` must carry `WWW-Authenticate: Basic realm="zuka"` or git never invokes its
445credential helper and the clone fails instead of prompting. Responses use
446`application/x-git-*-result`; `info/refs` sets `Cache-Control: no-cache, max-age=0,
447must-revalidate`. `problem+json` here is noise git will render at the user.
448
449### 4.8 `/raw` is a hostile-content endpoint
450
451Always `application/octet-stream`, `Content-Disposition: attachment`,
452`X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`. Never sniffed,
453never caller-specified. GitHub runs `raw.githubusercontent.com` on a separate origin
454for exactly this reason.
455
456CORS is **disabled by default** — §3 of the README says there is no web UI here, so
457there is no first-party browser origin to serve. Where enabled it is an explicit
458allowlist; `Origin` is never reflected. CORS plus bearer auth plus attacker-controlled
459bytes is the standard cross-origin theft recipe.
460
461Cache: `public, max-age=31536000, immutable` when `ref` is a full sha, `no-cache`
462otherwise.
463
464### 4.9 Limits
465
466Stated, not implied: max request body 10 MiB; max `changes[]` length 1,000; max blob
46732 MiB via API (pushes are bounded by `receive.maxInputSize`); tree and ref pages
468paginated. Over-quota → `413` or `quota-exceeded`. Free disk below the reserve
469threshold → `507` **before** the disk fills, because `receive-pack` interrupted by
470ENOSPC leaves a partial pack.
471
472---
473
474## 5. MCP
475
476`POST /mcp` JSON-RPC 2.0 plus the `GET` SSE channel and session identification the
477current transport revision requires. Auth is a bearer token; whether that satisfies
478the target client's authorization expectations is verified in M4, not assumed.
479
480### 5.1 Tools
481
482Administration and inspection. There is no tool that writes to a repository — an
483agent that wants to change code runs `git`, which it already knows how to do.
484
485| Tool | What it is for |
486|---|---|
487| `repo_create` `repo_list` `repo_get` `repo_delete` | Provisioning. `repo_create` returns both clone URLs and may seed from `import_url`. |
488| `key_add` `key_list` `key_remove` | Register the agent's own SSH key so it can then use git. |
489| `ref_list` `tree_list` `file_read` `search` `commit_log` `commit_get` `compare` `blame` | Read without cloning. |
490| `run_list` `run_get` `run_logs` | Watch CI after a push. |
491
492
493Each maps onto the same core call the REST handler uses.
494
495### 5.2 What the facade may and may not do
496
497Facades may fill defaults and reshape payloads — resolving `ref` to the repository's
498default branch, for instance. They may not carry authorization rules; those live on
499`Identity` so REST, MCP and SSH cannot drift (§1.1).
500
501The intended shape of an agent session is: `repo_create` → `key_add` → then plain
502`git clone`, `git commit`, `git push` in the agent's own workspace → `run_status` to
503see whether CI passed. The MCP surface removes the GitHub-shaped work; git does the
504git-shaped work.
505
506---
507
508## 6. CI
509
510### 6.1 Trigger and hooks
511
512Hooks are **symlinks** into `$DATA_DIR/hooks/`, installed via `init.templateDir` at
513repo creation. Symlinks, not copies, so upgrading a hook is atomic across every
514existing repo rather than leaving pre-change repos on the old version forever.
515
516| Hook | Job |
517|---|---|
518| `update` | Calls `git::store::check_ref_move`, the single implementation of the ref rule. |
519| `post-receive` | Writes a queued run record. Never blocks the push. |
520
521The queue **is** the run records (`ci::run::RunStore::queued`), not a separate
522channel: a push that lands while the server is down is still picked up when it
523returns, and the API and the executor cannot disagree about what is pending. The cost
524is latency — the executor polls, so a run waits up to `POLL` before starting. A socket
525would remove that; durability across a restart mattered more.
526
527Hooks receive `ZUKA_DATA_DIR` and `ZUKA_CI_ENABLED` through
528`Config::hook_env`. Both git spawn sites clear the environment deliberately, so a
529hook would otherwise inherit nothing and resolve the default data directory.
530
531`pre-receive` commit-signature enforcement is **cut from v1**: it needs a keyring, a
532trust model and an API surface, none of which exist. Branch protection beyond
533fast-forward is cut for the same reason. Asserting a feature into existence with a
534table row is how specs lie.
535
536### 6.2 Spec
537
538`.zuka.toml` at the repo root:
539
540```toml
541[run]
542steps = ["cargo test", "cargo build --release"]
543timeout_secs = 600
544branches = ["refs/heads/main"]
545```
546
547**There is no `runtime` key.** An earlier draft specified one, validated it against a
548list of toolchains, and then ignored it — every step ran under `/bin/sh` regardless.
549A knob that appears to select a toolchain and does not is worse than no knob. Steps
550run with whatever is on the host `PATH`; provisioning toolchains is the operator's
551job.
552
553A repository may lower `timeout_secs` but never raise it past the host ceiling,
554otherwise one repository can hold a runner slot indefinitely.
555
556### 6.3 Isolation
557
558**`tenant` mode.** The container is the boundary between accounts. It is *not* a
559boundary between untrusted CI code and the service process sharing that container, so:
560
561- The runner has its own uid. `RLIMIT_NPROC` is per-uid — sharing one with the service
562 means a fork bomb in CI takes down the service.
563- The environment is scrubbed (`env -i`). The service's credentials must not be
564 readable from `/proc/self/environ` or the unit file.
565- The tenant holds a **per-tenant KV credential**, not a shared one. A shared
566 `DENO_KV_ACCESS_TOKEN` inside a container running arbitrary code means every tenant
567 can read every other account's rows — and would make the isolation milestone's proof
568 false.
569- Limits: `RLIMIT_CPU`, `RLIMIT_AS`, `RLIMIT_FSIZE`, `RLIMIT_NOFILE`, wall clock and
570 an output cap. **`RLIMIT_NPROC` is off by default** and this is not timidity: it is
571 per-UID, so with the runner sharing a uid with the service it counts processes we
572 do not control — a useful-looking value makes `fork` fail on the first step. It
573 becomes meaningful only alongside a dedicated runner uid, which is also what makes
574 it safe.
575- Timeout kills the **cgroup**, not the shell — killing the shell orphans the tree.
576- No network: a network namespace with no route out, stated as the mechanism rather
577 than as an intention.
578
579**`standalone` mode.** Same uid separation and rlimits, no container. **This is not a
580security boundary**, CI defaults to **off**, and the README says so in those words.
581
582A step allowlist was considered and rejected as theatre — `sh -c` defeats it in one
583character. Enforcement is at the OS or it is not enforcement. That principle then has
584to be followed through, which is what the bullets above are.
585
586---
587
588## 6A. SSH
589
590Shipped in M1 (pulled forward from M7). A `russh` server in-process — not the host's
591`sshd`, which would need per-host configuration and break the single-binary promise.
592
593**Authentication is public-key only.** Password and keyboard-interactive are rejected
594outright. The offered key is matched against stored keys by raw bytes and the account
595falls out of the match; the SSH username is never read.
596
597**A session may exec exactly three commands.** `git-upload-pack`,
598`git-receive-pack` and `git-upload-archive`, against one repository. There is no
599shell, no pty and no subsystem. The exec string is parsed into a fixed service plus a
600validated repository and is never handed to a shell, so `git-upload-pack '/a/b.git';
601id` is a parse error rather than an execution.
602
603`upload-archive` was refused in an earlier draft on the grounds that it exposes tree
604contents through a separate code path. That was wrong: it exposes exactly what
605`upload-pack` already exposes to a caller who has read access, so refusing it withheld
606a normal git capability without withholding any data. It requires read, not write.
607
608**One environment variable crosses the boundary.** `GIT_PROTOCOL`, capped at 64
609bytes. Forwarding arbitrary client environment into a subprocess is an injection
610primitive: `GIT_CONFIG_*`, `GIT_ALTERNATE_OBJECT_DIRECTORIES` and `LD_PRELOAD` all
611change what the child does.
612
613**Keys carry their own scope.** `read_only` makes a key clone-and-fetch only;
614`repos[]` confines it to named repositories. A key is a credential with the reach of
615a `repo:write` token and is treated as one.
616
617**The host key is generated once** and reused, so clients are not trained to accept a
618changed fingerprint.
619
620Concurrency, the disconnect kill and the ref rule are shared with the HTTP path: the
621same semaphore bounds both, the same process-group kill applies, and a forced
622non-fast-forward is refused by `check_ref_move` regardless of which transport carried
623it.
624
625## 7. Control plane
626
627A tenant never learns its own public address. It sits on a private bridge behind the
628control plane, so the control plane passes `PUBLIC_URL` and `PUBLIC_SSH` down when it
629provisions one. Without that a tenant advertises its bind address and every clone URL
630it returns points at a container the caller cannot reach — which is what it did until
631it was tested on real infrastructure.
632
633### 7.1 Provisioning
634
635Account creation writes state `provisioning` and returns immediately. A reconciler
636drives it to `ready` or `failed`. **Never a synchronous wait on container boot** — the
637non-durable `queueMicrotask` in `yangu/roadmap/mailbox-provisioning.md` is the
638specific mistake not to repeat: no due index, no retry, no failure surface.
639
640Containers stop when idle and start on demand, with a stated cold-start latency
641budget. Always-on would mean one permanently running container per account for users
642who push twice a year.
643
644### 7.2 Proxying
645
646The control plane proxies long-lived, bidirectional, sideband-multiplexed,
647multi-gigabyte git streams, plus SSE. Backpressure must be preserved end to end and
648SSE must not be buffered. Shelling out to `git` at the engine does not help at the
649proxy hop — this is its own risk ([`ROADMAP.md`](ROADMAP.md) R6), not a free
650consequence of §1.2.
651
652---
653
654## 8. Source layout
655
656```
657zuka/
658├── Cargo.toml Cargo.lock README.md openapi.json
659├── src/
660│ ├── main.rs boot, CLI, git hook entry point
661│ ├── brand.rs the one place the product name appears
662│ ├── config.rs Config::from_env(); fails boot on a bad value
663│ ├── error.rs Error -> problem+json
664│ ├── core/ domain operations shared by every facade
665│ ├── git/
666│ │ ├── exec.rs the only place this service invokes git
667│ │ ├── validate.rs names, refs, tree paths, blob modes
668│ │ ├── store.rs repositories, hooks, ref rules, protection
669│ │ ├── discover.rs the read core, shared by REST and MCP
670│ │ ├── transport.rs streaming CGI bridge
671│ │ └── import.rs SSRF-guarded cloning
672│ ├── http/ routing, limits, responses, auth, rate limiting
673│ ├── api/ REST facade + the OpenAPI document
674│ ├── mcp/ MCP facade
675│ ├── ssh/ SSH server and exec-command parser
676│ ├── ci/ spec, run store, runner, executor
677│ ├── jobs/ gc, sweep, fsck
678│ ├── account/ identity, tokens, SSH keys
679│ └── store/ metadata, quotas, change-aware file cache
680└── test/live/ spawned-binary suite driven by real git and ssh
681```
682
683### 8.1 The core layer
684
685`core/` exists because REST and MCP each grew their own copy of repository create and
686delete, and the copies drifted: the MCP one had no rollback (so a failed `git init`
687wedged the name with a permanent `Creating` record), did not support protected refs or
688import, and left run records behind on delete — which meant re-creating a name
689resurrected the previous repository's CI history.
690
691`AppState::open_repo` is the same lesson one layer down: the sequence "resolve names,
692check access, confirm ready, get path" appeared seven times, three of them subtly
693different. `Access::Write` also enforces the disk reserve and the storage quota, so
694no write path can forget them.
695
696### 8.2 Why nested rather than flat
697
698`yangu/atlas` keeps everything in a 1,158-line `main.rs`; `yangu/kazi`'s is 3,050.
699That is a judgment call about **four protocol surfaces** — REST, MCP, git wire,
700control-plane RPC — against atlas's one, not a claim that the flat pattern has been
701measured to fail. Stating it as proven would apply a lower evidentiary standard than
702the convention it deviates from demands.
703
704### 8.3 Scheduled work has its own unit
705
706`jobs/` runs in a separate `zuka-jobs` systemd unit, not the web entrypoint: GC and
707repack, the deleted-repo sweep, the provisioning reconciler, and quota recomputation.
708Running them in the server process would violate the same rule for the same reason it
709exists.
710
711### 8.4 Errors
712
713`anyhow` internally; one enum at the boundary.
714
715```rust
716enum Error {
717 NotFound(&'static str),
718 Conflict { slug: &'static str, detail: String },
719 Unauthorized, Forbidden,
720 Invalid { slug: &'static str, detail: String },
721 Unprocessable(String),
722 MethodNotAllowed,
723 PayloadTooLarge { limit: u64 },
724 QuotaExceeded { limit: u64, used: u64 },
725 RateLimited { retry_after: u32 },
726 StorageFull,
727 Internal(anyhow::Error),
728}
729```
730
731`StorageFull` → `507`, `Internal` → `500` with the detail logged and never returned.
732There is no `Upstream` variant: nothing makes an upstream call, because hosted auth
733refuses to boot rather than being half-implemented.
734
735---
736
737## 9. Data
738
739### 9.1 Disk
740
741```
742$ZUKA_DATA_DIR/
743├── git/{account}/{repo}.git/
744├── hooks/ templateDir; symlink targets
745├── runs/{account}/{repo}/ one JSON record and one log per run
746└── tmp/deleted/ soft-deleted repos, 7-day sweep
747```
748
749### 9.2 KV
750
751| Key | Value |
752|---|---|
753| `["zuka","account",account]` | `AccountRecord` incl. provisioning state |
754| `["zuka","container",account]` | container id + endpoint (control) |
755| `["zuka","repo",account,repo]` | `RepoRecord` |
756| `["zuka","repo_index",account,repo]` | `repo` |
757| `["zuka","token",tokenHash]` | `TokenRecord` |
758| `["zuka","token_id",account,tokenId]` | `tokenHash` |
759| `["zuka","grant",account,repo,grantee]` | `Capability` |
760| `["zuka","run",account,repo,runId]` | `RunRecord` |
761| `["zuka","run_index",account,repo,ts,runId]` | `runId` |
762| `["zuka","idem",tokenId,key]` | cached response, 24h TTL |
763| `["zuka","audit",account,ts,rand]` | `AuditRow`, 90-day TTL |
764
765`token_id → tokenHash` exists so `DELETE /v1/tokens/{id}` can find the canonical row;
766without it the delete has no path to the record. The audit key carries a random suffix
767because two operations in the same millisecond are ordinary for git and would
768otherwise overwrite each other.
769
770Audit writes are fire-and-forget — a failed log row must never block the operation it
771describes.
772
773**Delete ordering: disk move first, then KV.** A crash between them leaves a repo in
774`tmp/deleted/` with live KV rows, which `zuka fsck` reports and can roll back. The
775reverse leaves an unreachable repo and a `500`.
776
777### 9.3 Backup and reconciliation
778
779Repos live on a local disk and metadata lives in a remote KV; they are never
780snapshotted together, so a restore reconciles two stores with different recovery
781points. `zuka fsck` lists orphans in both directions. **Disk is authoritative for
782repository existence; KV is authoritative for access control.** Documented because the
783system enters a split state deliberately on every delete.
784
785### 9.4 Garbage collection
786
787Every API commit writes loose objects; every push leaves a pack; `packed-refs` is
788never rewritten. Without GC, inode count and disk grow without bound.
789
790`jobs/gc` runs `git repack -Ad` + `git prune --expire` with `gc.auto=0`, holding a
791lock coordinated with `git/transport.rs` — unsynchronized GC concurrent with a live
792`upload-pack` hands the client a corrupt clone.
793
794The support case this creates is worth stating: *"the agent committed my API key."*
795Force-moving the ref leaves the blob fetchable by sha until the next GC. Product docs
796must say so rather than implying deletion is immediate.
797
798---
799
800## 10. Config and observability
801
802`std::env::var` with defaults, gathered into `Config::from_env() -> Result<Config>`
803that fails boot loudly. Prefix `ZUKA_` — including `ZUKA_BIND`; only genuinely
804shared infra names (`KV_URL`) go unprefixed.
805
806| Var | Default | Meaning |
807|---|---|---|
808| `ZUKA_BIND` | `127.0.0.1:8790` | Listen address |
809| `ZUKA_MODE` | `standalone` | `standalone` \| `control` \| `tenant` |
810| `ZUKA_DATA_DIR` | `/var/lib/zuka` | |
811| `ZUKA_ME_URL` | *unset* | Opt-in hosted auth. Unset → local token file. |
812| `ZUKA_CI_ENABLED` | `0` | Must be set explicitly in every mode (§6.3) |
813| `ZUKA_CONTROL_PUBKEY` | — | Ed25519 public key; required in `tenant` |
814| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes → `507` |
815| `KV_URL` | — | Per-tenant credential in `tenant` mode |
816
817`ZUKA_ME_URL` deliberately has **no default**. Defaulting it to `worklyn.me` would
818mean a self-hoster who configures nothing gets a binary that phones Worklyn to
819authenticate and returns `502` when it cannot — for the mode described as what a
820self-hoster runs.
821
822**Observability**: structured single-line logs carrying method, route, status,
823duration, account and request id. Counters for repo bytes, object counts, CI queue
824depth and run outcomes — the queue-depth counter is what makes the overflow policy in
825[`ROADMAP.md`](ROADMAP.md) D5 decidable instead of guessed. Whether this emits OTLP to
826`product/observability/` is D6.