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:
- Attribution — the commit log records who changed what, natively. The audit trail is the history. File history — every version of every document, diffable and recoverable, with no separate versioning system. Automation surface — once "a matter is a repo," future automation (push-time notation linting, agent commits) composes on one well-understood primitive instead of a bespoke Drive API.
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:
receive.denyNonFastForwards = trueandreceive.denyDeletes = truein each bare repo's config. Apre-receivehook rejects any ref update whose name is notrefs/heads/main, and rejects any non-fast-forward update tomain. The only writes that land are new commits appended tomain.
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.
- jj's only production-ready backend is Git — it reads and writes the same
.giton-disk format via gitoxide, and the commits it makes are ordinary Git commits. A jj user and a git user can share the same remote and neither knows the difference. - jj is a client-side ergonomic layer (no staging area, working-copy-as-commit, an operation log). None of that
changes the wire protocol our multi-tenant server speaks: staff/admin Git users still
git clone/git pushover smart-HTTP, and the repo on disk is still a bare.git.
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.
- Fetch / clone (
GET .../info/refs?service=git-upload-pack,POST .../git-upload-pack) → OPA query: may this identity read this Project? (participationpresent, oradmin). - Push (
GET .../info/refs?service=git-receive-pack,POST .../git-receive-pack) → a separate, stricter OPA query: may this identity write this Project? Push is a superset of fetch and is never granted implicitly.
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.
- Concurrency:
replicas: 1plus a per-repo advisory lock keyed by project id serializesreceive-packso two concurrent pushes cannot corrupt a bare repo. - Backup: scheduled volume snapshots of the PVC, explicitly distinct from the Cloud SQL backup story we already run. The matter record now lives in two backup domains (SQL rows + repo volume); the doc names that.
- KIND: the same role-flagged binary with a
hostPath/PVC volume; thenavigatorCLI wires the mount (kind-local-dev). - GKE Autopilot: Filestore CSI (RWO) or a PD-backed PVC; Filestore provisioning has real lead time, so it is
provisioned ahead of cutover (
project_gcp_production_stack).
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.
- Each repo ships a
.gitattributesrouting binary types to LFS:*.pdf,*.docx,*.png,*.jpg,*.jpeg→filter=lfs diff=lfs merge=lfs -text. webimplements the LFS batch API (POST .../info/lfs/objects/batch) plus object upload/download actions. An upload actionputs the object toStorageServiceunder the private documents bucket, with no CDN and no public object binding; a download action issues a short-livedsigned_urlonly after authorization. The LFS pointer (committed in the pack) and theStorageServiceobject reconcile by the pointer'soid(sha256) → storage key.- The same OPA fetch/push checks gate the LFS batch endpoints — read for download actions, write for upload actions.
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
- SeaORM migrations (
store,m-prefixed,inserted_at/updated_atperfeedback_timestamp_convention) add repo identity toprojects:code(unique, staff-facing repo name) andgit_initialized_at(nullable timestamp; set when the bare repo is created). There is no branch column — the ref is alwaysmain, enforced by thepre-receivehook and pinned once inrepos::DEFAULT_BRANCH, so a per-row branch name would only duplicate that constant. The originalgit_default_branchcolumn (defaultmain) was therefore dropped inm20260719_drop_git_default_branch_from_projects;drive_folder_idand the retireddrive_syncstable were likewise dropped (the latter inm20260718_drop_drive_syncs— see the note above). - Provisioning is a hard dependency of matter creation.
store::projects::provision_repo_hardcreates the bare repo and stampsgit_initialized_atbefore the surrounding create transaction commits — no secondgit init, and no committed Project row whose repo is missing. Every project-creation path uses that hard contract (the web matter-open form, the self-serve retainer walk, theaida_create_project/aida_create_notationMCP tools, and theprojectCLI subcommand). The filesystem half goes throughstore::projects::RepoEnsurer, an env-selected seam: a process that mounts the repo volume (NAVIGATOR_GIT_REPO_ROOT) runsrepos::RepoStore::ensurein-process, and a process that does not (the statelesswebtier in prod) POSTs/git-writer/ensureon the single mounted writer (NAVIGATOR_GIT_WRITER_URL+ the shared bearer inNAVIGATOR_GIT_WRITER_TOKEN; served byweb::git_writeronly when the pod holds both the volume and the token). In both variants thegit_initialized_atstamp happens on the caller's open transaction, so a failed create rolls back whole. The smart-HTTP transport still calls the idempotentprovision_repopath for older rows whose repo predates hard provisioning; new matter creation fails with the shared workspace-not-ready message if the repo cannot be provisioned within the create timeout, andweb::config::enforce_deployment_invariantsrefuses to boot awebprocess that has neither the volume nor the writer wiring. - A
git_access_tokenstable holds derived Git credentials:id,person_id,project_id(nullable = all the person's projects),token_hash,scope(read|write),expires_at,inserted_at/updated_at. Tokens are stored hashed; plaintext is returned only to the CLI minting request. - Backfill: existing Project documents (in GCS) become the initial commit(s) of each repo, with a
one-time migration that preserves authorship and date metadata where the source records it (commit author = the
personsidentity who uploaded; commit date = the document's recorded date), so the initial history is faithful rather than a single "import" blob. - Regenerate the ERD (
docs/erd.md+docs/erd.svgviaerd-visualization) when the migration lands.
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.
- Inbound-email attachments → matter documents become commits to
mainauthored as the sender'spersonsidentity. E-signature flow (project_esignature_design) → the generated and signed PDFs are committed (PDFs ride LFS). Northstar review surface (project_northstar_estate_flow,review_documents/document_comments) → reads the document content from the repo HEAD. /portaldocument listing → a plain, dated, named list rendered from the repo working tree at HEAD, with a one-click "Download all my documents" that produces a friendly ZIP of files (never a packfile or git bundle).
9. Confidentiality, retention & governed expunge (legal council)
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.
- Governed expunge is an admin-only primitive keyed by project id, used for a privilege clawback, a sealing
order, or a client's lawful deletion request. It rewrites history to remove the blob, deletes the corresponding object
from
StorageService,gcs the pack, and records the expunge itself — who authorized it, when, and the category (privilege / sealing / client-request), but not the content — so the audit trail survives the redaction. This is the only operation that is not append-only, and it is never reachable as a git push. Implemented:repos::RepoStore::expunge_path(history rewrite + prune + gc),store::expunge_records(the who/when/category audit log), andweb::expunge::expunge(the admin-gated orchestrator tying rewrite + storage deletion + record). An/adminHTTP route to drive it is the remaining UI wiring. - Retention: the repo is retained for the bar's record-retention period after the matter closes (a floor commonly cited at five years; the attorney of record confirms the exact period per jurisdiction), then becomes eligible for governed deletion. No indefinite-by-inertia retention.
- Confidentiality: the per-Project ACL (= the Project's participation set) is a confidentiality improvement over a
shared Drive. Read credentials are individually revocable and Project-scoped; force-push and expunge are restricted to
adminand logged. - Client export + lawful deletion are both first-class: "Download all my documents" (ZIP, no git jargon) and a client-initiated "Delete this document" that enqueues the attorney-authorized governed expunge and confirms honestly only once the working tree, history, and LFS object are all scrubbed.
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).
storemigration + entity for repo identity andgit_access_tokens; regenerate ERD.- Bare-repo store (init
main-only append-only bare repo, path-for-project, ensure-exists). - Derived Git credential minting + validation beside
google_oauth. - Read-only fetch transport (
info/refs+git-upload-pack) gated by the OPA read query. - Push transport (
git-receive-pack) gated by the OPA write query, with the append-onlypre-receivehook. - LFS batch API over
StorageService. - 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)
- Repoint the Northstar review (
review_documents, HTML-in-DB — decide whether it maps to the repo), and build the client-facing "Download all my documents" ZIP export. The governed-expunge primitive is built (see §9); only its/adminHTTP route + the client "Delete this document" button remain. - Revisit
jj-libfor server-side commit authoring when its public API stabilizes.