Every Project is a git repository

Every Project has one simple git repository with a single branch, main — large files (PDFs, docx, images) versioned through Git LFS — and that repo is the staff workspace and audit projection for matter files. Nothing fancier: no second branch, no tags, no pull requests, just commits appended to main.

Neon Law Navigator hosts one append-only git repository per Project, served Rust-native from web. The commit log is the matter's audit trail — who changed what, when — and every version of every document is recoverable from history. There is no separate versioning system: the same Rust web binary exposes a git smart-HTTP endpoint gated by Navigator identity, Project participation, and the same policy model we already run. Production runs that binary in a separate git-bearing Deployment so the public web tier can stay stateless and distroless.

This document is the durable design; the three councils' findings are folded into it. The raw deliberation is not kept.

The pivot, stated plainly

On 2026-05-25 we moved per-Project storage from Gitea to a Google Drive shared-drive folder. This design reverses that. Every Project becomes a real git repository because git gives us three things Drive never will:

Google Drive is fully removed. drive_folder_id was dropped (migration m20260713_drop_drive_folder_id_from_projects), along with the DriveSync workflow, the aida_drive_* tools, the cloud::drive REST client, and the cli drive OAuth door — the git repo is the per-Project staff workspace and audit projection, and staff/admin users reach that workspace by cloning its git URL (see below). Nothing in the dependency graph speaks to Drive any longer.

Anchor decision

Rust-native, hosted by the web binary. No Gitea, no Forgejo, no second app. web serves git over HTTPS, gated by the existing identity and Project-visibility model. Production uses two service roles for the same binary: the stateless navigator-web Deployment handles the portal/API surface, and the optional navigator-git Deployment runs from the git-bearing image with the repo PVC mounted. One auth model, one binary, one release tag; separate operational roles only where Git's POSIX filesystem requirement makes them useful.

The reference URL shape is https://www.your-domain.example/projects/<project-code>.git. projects.code is unique, letter-only kebab-case, and filesystem-safe; it names the bare repository while projects.name stays the client-facing matter name. The .git suffix is the thing that distinguishes the git client (HTTP Basic, pack protocol) from the portal's HTML documents view (session cookie) at the shared /projects/:id prefix — the router splits on it.

Project repository shape

Every Project repository carries the same small root contract:

<project-repo>/
├── .navigator.yaml
├── README.md
├── .gitattributes
├── templates/
└── notations/

.navigator.yaml is intentionally tiny:

version: "0.1.0"
code: project

code: project selects Navigator's built-in Project repository schema. The schema, not per-repo policy, defines which paths are staff workspace and which paths may feed the client portal.

templates/ contains inert markdown blueprints: staff-side drafts or Project-scoped Templates that have not themselves become runtime work. This name matches the templates table and the workspace top-level templates/ tree. A Template declares questions, workflow, and prose; it does not ask questions or advance state on its own.

notations/ contains the Project-bound runtime projection: reviewed Engagement packets, rendered PDFs, signature artifacts, workflow summaries, and other documents generated from notations, answers, notation_events, blobs, and workflow state. This replaces the earlier planning name assigned_notations/; assignment is an authorization and projection rule, not a third domain noun.

Clients never browse this raw tree or see Git vocabulary. The portal uses human labels such as Documents, Engagements, Invoices, and Matters and exposes only client-facing artifacts. Staff and admin views may show the clone URL and inspect the internal workspace as needed. Postgres and Restate remain authoritative for runtime state; Project repo commits are the matter document/audit projection.

Append-only, single main — the only ref

Per the matter-record requirement, each repo is append-only with exactly one branch, main. No other branches, no tags, no pull requests — additive history only. The server enforces this so a misconfigured client cannot violate it:

This is a deliberate simplification, not a limitation we apologize for. Stating what the system does: it keeps one linear, additive record per matter. There is therefore no branch-level ACL to design — push authorization is simply "may this identity append to this matter's main?" (see Authorization). The single documented exception is the governed expunge (see Confidentiality, retention & governed expunge), an out-of-band admin operation, never a push.

Jujutsu (jj) — evaluated, not adopted for the server

The append-only, additive, linear model is exactly jujutsu's mental model, so jj is worth a look. The finding: jj does not simplify the server.

So the server design below is identical whether a given lawyer drives it with git or with jj — and a lawyer who prefers jj's ergonomics can use it today against our git remote with zero server changes. We document jj as a supported client, not a server dependency.

jj-lib (Rust, built on gitoxide) is explicitly designed to be usable "in a server serving requests from multiple users," which makes it a candidate for server-side commit authoring (see Commit attribution) instead of shelling git commit. We do not adopt it now: the library API is young and only the Git backend is production-ready. We revisit jj-lib for server-side authoring when its public API stabilizes; until then server-side commits shell to git (the same binary the transport already requires).

1. Smart-HTTP transport

web serves git smart-HTTP by shelling to git's own git upload-pack (fetch) and git receive-pack (push) with --stateless-rpc, exactly as git's reference HTTP server drives them. The axum handler runs --advertise-refs for the GET .../info/refs ref advertisement (prefixing the pkt-line service banner), pipes the client's RPC body to the child stdin on POST, and streams stdout back; gzip-encoded upload-pack request bodies are inflated first. Implemented in web/src/git_http.rs (git http-backend CGI is an equivalent).

Why shell out, not pure-Rust gix: feedback_infra_kind_gke says lean on mature upstreams over hand-rolled infrastructure. gix server-side pack negotiation is not mature enough to own the protocol edge-cases today. Shelling to git's reference server gets the full, battle-tested protocol for free. Fallback: revisit a pure-Rust server when gix server-side support lands; the handler is the only thing that would change.

Cost owned explicitly — the runtime image must carry git. The current prod runtime is gcr.io/distroless/static:nonroot (images/Containerfile.web), which has no shell and no git binary; web/src/git_meta.rs already documents this. The git-serving path therefore runs from a minimal base image that includes the git binary (e.g. gcr.io/distroless/base with git and its runtime deps copied in, or a debian:stable-slim + git). This is a real, named cost of the transport choice.

2. Auth sharing — the credential a git client presents

A browser read can ride the session cookie, but git clone / git push from a CLI sends HTTP Basic or a bearer — it has no cookie. Staff sign in once with navigator auth login, then run navigator git clone <project-code>. The CLI creates ~/Projects when needed, checks the matter out at ~/Projects/<project-code>, asks web for a short-lived, Project-scoped Git credential, and feeds that credential to Git without putting it in the remote URL.

web validates the derived Git credential in the same place /mcp validates its bearer — beside web::google_oauth (require_google_oauth, web/src/google_oauth.rs:195) — so there is one token-validation seam, not a parallel password store. A Git credential resolves to a single persons identity and is revocable in one database row. Credentials are scoped (read vs. read-write) and Project-scoped; a leaked read credential is revoked by deleting its row.

In KIND the same path holds (Keycloak is the OIDC provider, but the git credential is still minted by web, so the git transport is identity-provider-agnostic).

3. Authorization — git verbs mapped to the model we already have

One repo ↔ one Project, so the existing project-scope check is the base repo ACL (authorization-model). The repo URL uses projects.code; the authorization decision resolves that code back to the Project row. Portal visibility and Git visibility stay separate in the product surface: clients can see their matter documents through portal routes, while staff/admin users get the navigator git clone workflow. Transport-level access still resolves through the derived Git credential, the caller's persons.role, person_project_roles.participation, and silent admin bypass.

The authorization middleware sits in front of the transport handler, keyed on the service (for info/refs) or the URL suffix (git-upload-pack vs git-receive-pack). The three failure modes return distinct statuses: 401 (no/invalid Git credential), 403 (valid identity, OPA denies), 404 (no such Project). A dying subprocess is 500.

4. Where bare repos physically live — the single-writer git store

Git needs a POSIX filesystem for a bare repo; GCS is not one. Working repo storage is therefore a persistent volume, not a bucket. This introduces the workspace's first stateful tier in the request path, so the topology matters.

Reference deploy: a dedicated git-serving Deployment running the same web image with a role flag, pinned to replicas: 1, mounting a single ReadWriteOnce PVC at the bare-repo root (GIT_PROJECT_ROOT). The public, stateless web tier proxies /projects/:id.git/* to it. This mirrors the shape we already run for Restate (a stateful backend the stateless tier talks to) — git hosting is isolated, not smeared across every web replica.

This is the single riskiest unknown the engineering council named: concurrent-write safety and backup of the bare-repo volume behind a stateless web tier. The single-writer Deployment + advisory lock + volume snapshots are the mitigation; it gets real weight in implementation and review.

5. Git LFS, backed by cloud::StorageService

PDFs, docx, and images go through Git LFS, and the LFS object store is our existing cloud::StorageService (cloud/src/lib.rs:65) — GCS in prod, the Fs backend in KIND. This is where GCS stays in the picture; the repos themselves do not live in a bucket.

The private documents bucket is not a client-facing file browser. Clients reach files through web routes that first resolve the session, load the persons row, and apply the Project visibility rule. In casual speech a person may be "disclosed on" or "added to" a matter; the authorization row is person_project_roles (Participation). That row, plus the admin bypass, is what lets someone see the matter's client-facing documents. A GCS IAM grant is never issued directly to a client.

6. Data model + migration

7. Commit attribution = the audit trail

Commits made on a person's behalf — portal upload, inbound-email attachment, e-sign completion, an agent action — are authored as that persons identity (name + email), so git log is a faithful "who did what, when." Server-side commits set GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL / GIT_COMMITTER_* (or the jj-lib equivalent if we later adopt it) from the acting person's row. Demo identities stay zodiac (project_zodiac_demo_users): commits in seeded matters are authored as <sign>@example.com.

8. Surfaces that currently touch Project documents

Each becomes a read/write against the repo or a portal projection from the same document rows. The client never sees the word "git" — the portal stays a documents view (the client council guards this), and a view-layer test asserts that no client-facing template emits git, clone, branch, or a commit SHA.

The commit log is an acceptable — indeed superior — record of a legal matter, framed precisely as tamper-evident and append-only by default, never "immutable" or "cannot be deleted." A reviewing court reads "we cannot delete it" as obstruction, so the design ships a governed deletion path from day one.

10. KIND + prod parity

The same Rust code path runs in both, per CLAUDE.md: transport and LFS are identical; only the volume class and the StorageService backend differ by env. KIND uses a hostPath/PVC volume and the Fs StorageService backend, wired by the navigator CLI (kind-local-dev). Prod uses an Autopilot RWO PVC and the GCS backend (project_gcp_production_stack). Every per-deploy value (bucket names, volume class, repo root) is env-driven.

Implementation sequence

Per the engineering council's Libra: ship fetch before push (clone-only is useful and halves the auth blast radius).

  1. store migration + entity for repo identity and git_access_tokens; regenerate ERD.
  2. Bare-repo store (init main-only append-only bare repo, path-for-project, ensure-exists).
  3. Derived Git credential minting + validation beside google_oauth.
  4. Read-only fetch transport (info/refs + git-upload-pack) gated by the OPA read query.
  5. Push transport (git-receive-pack) gated by the OPA write query, with the append-only pre-receive hook.
  6. LFS batch API over StorageService.
  7. Commit attribution + repoint the document surfaces; add a CLI git helper.

Deploying the git-serving tier

The transport and commit_as shell the git binary, which gcr.io/distroless/static (the navigator-web runtime) does not carry. The git-serving tier therefore runs from images/Containerfile.git — the same musl-static web binary on a debian:stable-slim + git base. Reference GKE manifests are in examples/deploy/k8s/gke/git/git-serving.yaml: a replicas: 1 Recreate Deployment with an RWO PVC mounted at NAVIGATOR_GIT_REPO_ROOT, plus a Service.

Because it is the full web binary, the git pod mirrors the navigator-web pod, not a stripped-down copy: the same three containers (the web binary, an OPA policy sidecar reached at localhost:8181, the Cloud SQL Auth Proxy), the same opa-policies volume, the same envFrom (the navigator-otel-env ConfigMap + the navigator-web-secrets Secret), and the same production inline env — so enforce_deployment_invariants is satisfied and readiness passes. The pod's readiness probe is /readyz, which pings OPA, so the sidecar is load-bearing, not decorative: without it the pod never goes Ready. Only two env values are git-tier deltas: NAVIGATOR_GIT_REPO_ROOT (this pod mounts the volume and is the writer, taking the repo-root branch of the boot invariant) replaces the stateless tier's NAVIGATOR_GIT_WRITER_URL, and OTEL_SERVICE_NAME=navigator-git. The env is duplicated inline per the overlay's house convention (workflows-service does the same); the real domain / client-id / project values are white-labeled placeholders that navigator ops ship renders from NAVIGATOR_* env at deploy time (see gke-prod.md).

The ingress adds one prefix rule (/projects/*navigator-git:3001, ahead of the /* catch-all) so the stateless navigator-web tier proxies the whole transport + LFS surface to the single writer. The reference overlay carries this rule; ship renders it with the real domain and applies it as part of the unconditional reconcile.

The repo PVC is the matter record's disk, so it has its own backup schedule, distinct from the Cloud SQL backup: git/repo-backup.yaml is a VolumeSnapshotClass plus a daily CronJob (with a least-privilege ServiceAccount/Role) that snapshots navigator-git-repos and prunes all but the newest two weeks. Installs that instead run Backup for GKE over the whole navigator namespace already cover the PVC and can drop the CronJob — pick one. The manifests are wired into the GKE overlay; validate the PVC bind, the VolumeSnapshotClass driver name (kubectl get csidrivers), and the ingress split on the cluster before rollout.

The stateless navigator-web tier mounts no repo volume; it provisions matter repos through the writer instead (§6): its Deployment sets NAVIGATOR_GIT_WRITER_URL=http://navigator-git:3001, and NAVIGATOR_GIT_WRITER_TOKEN — the shared bearer both Deployments read — lives in the navigator-web-secrets Secret (one kubectl create secret key next to the Restate/SendGrid values). The ensure endpoint is reachable only through the in-cluster Service: the ingress routes just /projects/* to navigator-git, and the route mounts only on a pod holding both the volume and the token.

Production rollout keeps the writer on the same dated binary as the public web tier. navigator ops ship --tag YY.M.D derives the git image from the same GHCR owner as the other service images (navigator-git:YY.M.D), checks whether deployment/navigator-git exists, and rolls it with navigator-web and workflows-service when present. A cluster that uses an in-process NAVIGATOR_GIT_REPO_ROOT volume instead of the remote writer can omit the Deployment; ship prints a skip notice and continues. Because the reference writer is replicas: 1 with strategy: Recreate on an RWO PVC, its ensure endpoint is briefly unavailable during the restart, and ship waits on that rollout so a stuck writer fails loudly instead of leaving matter creation blocked behind an unnoticed version skew.

In dev/KIND the web binary runs on the host (kind-local-dev), so there is no git-serving pod: point NAVIGATOR_GIT_REPO_ROOT at a local directory in .devx/env and the host binary serves repos from there against the in-cluster deps. The in-cluster KIND web pod is likewise its own writer — k8s/overlays/kind/web-repos.yaml runs it from the git-bearing navigator-git:dev image (the distroless web image carries no git) with an emptyDir at NAVIGATOR_GIT_REPO_ROOT and fsGroup: 65532 so the nonroot uid owns the mount — so no remote hop exists in KIND: same code, one fewer network edge.

Follow-ups (not done here)