docs(architecture): resolve OQ-06/07/08 — per-repo authz, registry backing, CRUD ops

ADR-011 (resolves OQ-08): per-repo authorization — grants live in repo
records keyed on the stable logical identity id (alkcall ADR-025,
referenced); policy is alkgit-core's authorize() function (public+read
anonymous-first-class, write always authenticated+granted); alkgit
stores no identity records; vault placement resolved as nothing to
place in v1.

ADR-012 (resolves OQ-06/OQ-07): registry backing + write surface —
GitRegistryStore write supertrait (alknet ADR-035 read/write split
shape); registry-file default (per-repo record files + in-memory
index, config-seeded, op-mutable, no gitoxide); git/repo/* CRUD ops
shipped External with scope+ownership ACL (create mints ownership and
seeds creator grants; ownership never implies git access); the
two-op-kind classification recorded (open op + call ops from one
crate, per alkcall ADR-047); recorded split trigger for a downstream
platform crate.

Doc sync: backend.md (five-trait family, feature model split,
two-op-kinds), doors.md + overview.md (authorize policy, dual-kind
crate map), open-questions.md (OQ-06/07/08 resolved), README (ADR
table, current state), oq-06 tracker task closed (resolved early).

Verification: cargo test (default + --no-default-features), clippy
-D warnings, fmt --check.
This commit is contained in:
glm-5.3-flash committed 2026-09-21 16:26:59 +00:00
1 parent 86bf5a0cf0
commit addc874667
8 files changed
+492 -104

No files matched your search

@@ -0,0 +1,127 @@
# ADR-011: Per-repo authorization — grants live in repo records
## Status
Accepted (resolves OQ-08)
## Context
OQ-08 (narrowed by ADR-010) asked for the registry's identity model: what
an identity *is* to the `GitRegistry`, what identity records exist, where
they live, and where credential material goes.
The family precedents constrain the answer:
- alkcall resolves credentials to a stable logical `Identity`
(`IdentityProvider`, ADR-003; `PeerEntry.peer_id` stability across key
and token rotation, ADR-025). `AccessControl::check` (ADR-017) is a
flat scope/resource match against that resolved identity.
- Doors own credential presentation (alkhttp bearer → token; ssh →
fingerprint) and resolve identity before alkgit sees anything.
- alktty/alktunnels gate with a single scope constant because their
resources are session- or deployment-scoped. Git cannot use that shape:
its authorization is **per-repo**, and its primary deployment target
(vision §"Primary deployment target") makes **anonymous fetch on
explicitly-public repos first-class** — which an identity-scoped gate
cannot express, since a static `AccessControl` with restrictions fails
closed on `identity: None`.
So the git analog of `TTY_OPEN_SCOPE` is not a scope constant — it is the
per-repo check itself, at ADR-007's step-3 position. The gap is real and
known: ADR-007 already states "anonymous access on explicitly-public
repos grants the same full read path," a clause the static ACL engine
cannot express.
Two sub-questions rode along: whether identity *records* (credential
paths, scopes) live in the git metadata store, and what vault material
metadata must hold.
## Decision
1. **Identity space is alkcall's; alkgit stores no identity records.**
The subject of every authorization decision is the resolved alkcall
`Identity`, keyed by its stable logical id (`Identity.id` — the
alkcall ADR-025 `peer_id` property, referenced here, not
reimplemented). Credential material, fingerprints, and token hashes
remain in the door/assembly identity-provider seam (alkcall ADR-003,
ADR-025). alkgit's metadata store holds no identity records.
2. **Grants live in repo records, keyed on the logical identity id.**
`RepoRecord` carries `visibility` (Public/Private) and
`grants: identity_id → {read, write}`. Per-repo grants are repo
lifecycle data — created with the repo, administered with it — so
they are stored with the record, not folded into `Identity.resources`
(which is static identity-provider data resolved per credential path;
per-repo entries would bend that model).
3. **Authorization is a policy function in alkgit core**, evaluated
against the record:
```
authorize(record, identity: Option<&Identity>, action: read|write) -> bool
```
- `read` on a Public repo → allowed for anyone, including `None`
(anonymous-first-class; ADR-007's operative clause).
- `read` on a Private repo → identity required with a read grant.
- `write` → identity required **and** a write grant, on every repo
(push is always authenticated — vision §"Primary deployment
target"; ADR-007).
Enforcement timing is unchanged: resolve → authorize →
authorized-repo marker → transport (ADR-007 steps 1–4). Unknown-repo
and unauthorized remain indistinguishable (ADR-008).
4. **Data and policy are split across the backend seam.** The
`GitRegistry` trait returns the *record* (`resolve`); the policy
function is alkgit-core code evaluated on it. An embedder with an
existing permission system implements `resolve` and maps its own ACL
into grants at resolve time — no policy hook is needed, and none is
offered in v1 (a custom-policy override would be an additive default
method, a two-way door, added when an embedder needs it).
5. **Vault placement: nothing to place in v1.** With no identity records
and no credentials in metadata (AGENTS.md convention 4), alkvault
holds nothing for alkgit in v1. Vault references enter later only if
a record grows credential-shaped material (the plausible case:
mirroring/remotes records in the consumer half's orbit) — and by then
the rule is already fixed: metadata holds references, never secrets.
6. **`resolve` is async.** Unlike alkcall's accept-loop identity reads
(alknet ADR-035's sync-hot-path constraint — that trait stays sync),
the registry resolve runs once per session/request before the first
protocol byte; an `.await` there costs nothing and keeps the backend
trait family uniform (ADR-010 sub-decision 4). No cache-invalidation
machinery is load-bearing in alkgit v1 — the hot paths (pack
generation, ref listing) hit the object DB, not metadata. Backing
stores that need invalidation strategies own that inside their impl.
## Consequences
- **Positive:** anonymous-public fetch is expressible without bending
`AccessControl` (the clause ADR-007 pinned is now mechanically true);
per-repo grants are administered with the repo, in one store; policy
is one function, tested once, in core; the embedder seam for foreign
permission systems is `resolve`-time mapping (no hook, no trait
growth); no vault placement question remains for v1.
- **Negative:** two authorization data sources exist by design — op ACL
(scopes + ownership, for *managing records*) and repo grants (for *git
access*). Ownership never implies git access: repo creation seeds the
creator's grants explicitly (ADR-012). This split is deliberate
(managing vs accessing are different capabilities) but is a thing a
reader must hold both of.
- **Neutral:** identity-id stability (key rotation) is inherited from
alkcall's `PeerEntry` model, not guaranteed here — alkgit's contract
is only "grants key on the stable logical id."
## References
- OQ-08 (resolved by this ADR)
- ADR-007 (enforcement point, anonymous-public read rule), ADR-008
(registry-resolved ids), ADR-010 (trait family, no-doors rule)
- alkcall ADR-003/025 (IdentityProvider, `PeerEntry` stable logical id),
ADR-017 (the static ACL engine this complements)
- alktty `tty:open` posture (the scope-constant pattern this ADR
deliberately does NOT reuse)
- vision.md §"Primary deployment target", §"Guiding principles" 1–2
- backend.md §"The trait family" (GitRegistry), ADR-012 (record store +
op set)
@@ -0,0 +1,186 @@
# ADR-012: Registry backing, write surface, and the two-op-kind shape
## Status
Accepted (resolves OQ-06, OQ-07; amends ADR-010's feature story)
## Context
With OQ-08's model fixed (ADR-011: grants in repo records, policy in
core), three decisions remained, tangled together:
1. **Backing store for the gix feature's `GitRegistry` impl** (OQ-06,
deferred on "metadata-scale requirements"). The deferral assumed the
backing choice gated something; under the alknet repo/adapter pattern
(traits + in-memory/config default in-crate, persistence adapters
additive in separate crates — alknet ADR-033/035), the default's
choice never depended on scale. Scale only ever gates a *persistence
adapter*, which is additive by construction.
2. **Registry mutation ops** (OQ-07): whether the gix feature ships
repo create/delete/visibility/grant management as reusable alkcall
ops, or leaves all mutation to downstream assembly. The user-session
resolution: CRUD is wanted, with a two-tier shape — a global admin
has the scopes for most things, while a user who creates a repo
administers *their own* repos.
3. **Op-kind classification** (user session, 2026-09-21): git is the
first family payload that naturally wants both alkcall op kinds —
call ops (JSON RPC, schema-validated) for record management, an open
op (channels, `BiStream`) for git sessions. The alkcall registry
already supports this (openable ALPNs are operations — alkcall
ADR-047; `ChannelOpenSpec` + `OperationSpec` in one
`OperationRegistry`); alktty/alktunnels ship only open ops, and the
coming docker crate will ship only call ops (bollard management)
plus consume alk/tty's open op. Git supplying both kinds from one
crate is new for the family but classification-only — no new
registry machinery.
Also carried: the "gix feature" name conflated the gitoxide engine
components with the `gix` facade crate (a client-oriented entry point)
and with the registry store (which needs no gitoxide at all).
## Decision
### 1. Read/write split on the record family
```
pub trait GitRegistry: Send + Sync {
async fn resolve(&self, repo_id: &str) -> Result<RepoRecord, RegistryError>;
}
pub trait GitRegistryStore: GitRegistry {
async fn put_repo(&self, record: &RepoRecord) -> Result<(), RegistryError>;
async fn update_repo(&self, record: &RepoRecord) -> Result<(), RegistryError>;
async fn remove_repo(&self, repo_id: &str) -> Result<(), RegistryError>;
}
```
- The write supertrait follows alknet ADR-035's shape (read on the wire
hot path, write on the management path; `ConfigIdentityProvider` does
not implement the write trait there, and the config-backed default
here follows the same posture — its write path is record files, §2).
- Grants ride `RepoRecord` (ADR-011); grant mutation is `update_repo`.
Convenience wrappers (grant/revoke as named methods) are additive
later, two-way.
- The `git/repo/*` ops are thin wrappers over `Arc<dyn
GitRegistryStore>`. An embedder with a DB registry registers the same
ops over their own store.
### 2. Default backing: per-repo record files, in-memory index (`registry-file` feature)
- One small record file per repo (repo id, storage root, visibility,
grants), plus an in-memory index (the resolve path reads the index,
never the file). Config-seeded at boot; op-mutable at runtime.
- Writes are atomic (write-temp + rename) and update the index on
change. Reload-on-change via file watching is the file-scale analog
of alknet's honker NOTIFY story (alknet ADR-035) — adequate at
repo-record scale; if multi-process sharing ever matters, that is a
persistence-adapter concern, not a trait change.
- Storage roots are derived by the store's own naming rules at create
time. Wire names never touch paths (ADR-008 unchanged): creation is
not a wire-input-to-path operation, and resolution returns the
configured root.
- Feature: **`registry-file`**, default-on, **no gitoxide
dependency**. Persistence adapters (SQLite et al.) are future,
separate, additive — built when a deployment with dynamic repo
management at persistence scale exists.
### 3. CRUD ops shipped in-crate, always-on, External with scope+ownership ACL
Ops (names indicative, schema-stable once published):
| Op | Acts on | Gate |
|---|---|---|
| `git/repo/create` | store: mint id, allocate root, seed record | scope `git:repo:create` |
| `git/repo/delete` | store: remove record | scope `git:admin` **or** ownership |
| `git/repo/update` | store: visibility/grants/root | scope `git:admin` **or** ownership |
| `git/repo/get` (list/read) | registry reads | scope `git:admin` **or** ownership |
- **Shape:** ordinary alkcall call ops (`OperationSpec`, JSON,
schema-validated), `Visibility::External`, registered by the
assembler wherever it wants them exposed. They are **not**
feature-gated (thin over the store trait, no gitoxide) and not
Internal: ops an end user must call are wire-callable by definition;
the gitea lesson is served by the ACL (visible-surface =
authorized-surface), not by hiding the ops. OQ-07's old
"Internal ops over an admin listener" framing had the right mechanism
and the wrong axis.
- **Two-tier authorization** uses the checks `AccessControl::check`
already composes: `required_scopes` for the global tier,
`resource_type` + ownership for the self-owned tier (alkcall ADR-011
— repo create mints ownership via `OwnershipStore::record`, so
"creator administers their own repos" is the ownership path, not a
grant table).
- **Ownership never implies git access.** Repo create seeds the
creator's read/write grants in the record explicitly (ADR-011's
two-data-sources rule: op ACL governs *managing records*, repo grants
govern *git access*).
### 4. Feature split (amends ADR-010's single-`gix` feature story)
| Feature | Default | Contents | Needs gitoxide |
|---|---|---|---|
| `gix` | yes | `GitRefs` / `GitPackGen` / `GitPackIngest` impls (the local-disk object-storage engine: odb/pack/ref/object/fsck components) | yes |
| `registry-file` | yes | `GitRegistry`/`GitRegistryStore` default impl + the record store | no |
| neither | — | wire/protocol-only embed (`default-features = false`) | — |
- Two features, not one: independent seams (backend.md's full-family
rationale) — a downstream with a DB registry still wants the gix
engine; a downstream with S3 object storage still wants a simple
registry. Batteries-included unchanged (both default-on).
- **The `gix` facade crate is dropped from the dependency list.** POC-2
drove `gix-odb`/`gix-pack` at the component level; the backend needs
`gix-discover` + `gix-odb::Store::at` from a registry-resolved root,
not facade machinery. Verified at implementation; the facade comes
back only if a concrete need appears.
- `gix-protocol`/`gix-transport` (client-side) remain in the wire
layer's dependency set only pending the consumer-half decision
(`GitSession` may reuse `gix-protocol`'s response parsing or
hand-roll the small client surface; `gix-transport` is almost
certainly droppable). Implementation-time call, recorded, not an
architecture commitment.
### 5. The recorded future trigger for a crate split
If the op set ever grows past repo records — users, orgs, cross-repo
permission management — that is a platform application, and it is
downstream's crate, not alkgit's. The ops stay minimal ("ops wrap a
method on the repo"); the trait family and the one-crate shape are
unpublished, so the split remains a two-way door.
## Consequences
- **Positive:** OQ-06 resolves without its deferred "scale
requirements" ever arriving (the trait is the boundary; the default
is file-scale by design; persistence adapters stay additive); OQ-07
resolves with a shipped, small, reusable op set; the docker-crate
comparison (call ops / open op separation) lands as a classification,
not a structural change; the feature story now matches what the
features actually contain.
- **Negative:** the op set is public API before any downstream exists
(names + schemas become compat surface at first publish — OQ-03's
freeze inventory). Grant-convenience methods deferred means grant
edits go through full-record `update_repo` in v1 (last-writer races
are the assembler's concern; acceptable at record scale, and the
additive path exists).
- **Neutral:** the registry-file store's watch/reload loop is an
implementation detail; its failure mode (watch loss) degrades to
"records update on next process start," never to wrong-path or
wrong-authz behavior (the index is authoritative per record file
content, re-verified on reload).
## References
- OQ-06, OQ-07 (resolved by this ADR), OQ-03 (the publish freeze this
op set enters)
- ADR-010 (superseded sub-parts: single `gix` feature story), ADR-008
(registry-resolved ids), ADR-007 (enforcement order), ADR-011
(grants in records, two-data-sources rule)
- alkcall ADR-047 (openable ALPNs are operations — the classification),
ADR-017 (visibility + handler identity), ADR-011 (ownership model —
repo-create mint-and-own), alkcall `AccessControl` (scope + resource
composition)
- alknet ADR-033/035 (repo/adapter pattern; read-sync/write-async split;
honker NOTIFY — the pattern this ADR's file-watch analog follows)
- alktty (open-op-only posture), alknet-docker (planned call-op-only
posture — the two siblings this ADR's dual-kind shape sits between)
- backend.md (trait family, feature model), doors.md (registration
surface), vision.md §"Registry management ops" (amended by §3)