Resolves review 001 findings A-2 (critical) and A-6 (major) — the same signature surface: - ADR-012 §1: registry traits amended to #[async_trait] (bare async fn in traits is not dyn-compatible, E0038; ops sit behind Arc<dyn GitRegistryStore>). Desugared boxed Future form pinned in OQ-03's freeze inventory. async-trait = "0.1" added to the manifest. - backend.md concurrency model: the five-trait family is #[async_trait] Send + Sync dyn-compatible; the wire layer enforces ADR-009's pipeline-concurrency budget itself (permit acquired around each GitPackGen/GitPackIngest call — the concrete admission point); impls must not block the async executor and own their internal threading (gix impls run spawn_blocking inside the impl — POC-2's shape restated at its true layer). - transport.md §fetch: spawn_blocking sentence rephrased to the trait-contract version (the wire spec stops speaking gix). - ADR-009: enforcement point of the blocking-pool budget made concrete. - ADR-013 §6: ingestion spawn_blocking line aligned. - review 001: A-2, A-6 marked resolved. Verification: cargo test, clippy -D warnings, fmt --check, doc --no-deps, check --no-default-features, check --all-features — all clean.
11 KiB
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; §3 gate table replaced by ADR-015)
Context
With OQ-08's model fixed (ADR-011: grants in repo records, policy in core), three decisions remained, tangled together:
- Backing store for the gix feature's
GitRegistryimpl (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. - 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.
- 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+OperationSpecin oneOperationRegistry); 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
(Signatures amended per review 001 A-2: bare async fn in a trait is
not dyn-compatible (E0038), and §3's ops sit behind Arc<dyn GitRegistryStore>. All five backend traits carry #[async_trait] —
the family-wide pattern (alktty TtyBackend, alkcall
ProtocolHandler); async-trait = "0.1" is in the manifest. The
desugared Pin<Box<dyn Future + Send>> boxed form is the published
vtable shape — pinned deliberately here, in OQ-03's freeze inventory.)
#[async_trait]
pub trait GitRegistry: Send + Sync {
async fn resolve(&self, repo_id: &str) -> Result<RepoRecord, RegistryError>;
}
#[async_trait]
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;
ConfigIdentityProviderdoes 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 isupdate_repo. Convenience wrappers (grant/revoke as named methods) are additive later, two-way. - The
git/repo/*ops are thin wrappers overArc<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+grant ACL
(Gate table amended by ADR-015 — the original "scope OR ownership"
formulation was not expressible in alkcall's AccessControl (review 001
A-1); the two-tier rule below is the ADR-015 shape.)
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 (static registry gate) |
git/repo/delete |
store: remove record | scope git:admin or manage grant (ADR-015) |
git/repo/update |
store: visibility/grants/root | scope git:admin or manage grant (ADR-015) |
git/repo/get (list/read) |
registry reads | scope git:admin or manage grant (ADR-015) |
- 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 (superseded by ADR-015's shape:
createkeeps a pure-scopeAccessControl;delete/update/getcarry an emptyAccessControland the handler evaluates "admin scope ORauthorize(record, identity, manage)" as one tested function in alkgit core, with the generic FORBIDDEN denial and the unknown-repo ≡ unauthorized rule (ADR-008) at the op layer. Repo create mints ownership viaOwnershipStore::recordand seeds the creator's{read, write, manage}grants — "creator administers their own repos" is now themanagegrant path.) - Grants govern access; scopes govern platform capability. Repo
create seeds the creator's
{read, write, manage}grants in the record explicitly (ADR-015: administration is themanagegrant — the record is the single per-repo authz surface; op scopes and record grants remain distinct sources, platform capability vs per-repo capability).
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
gixfacade crate is dropped from the dependency list. POC-2 drovegix-odb/gix-packat the component level; the backend needsgix-discover+gix-odb::Store::atfrom 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 (GitSessionmay reusegix-protocol's response parsing or hand-roll the small client surface;gix-transportis 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_repoin 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
gixfeature 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)