# MCR Project Plan

Implementation plan for the Metacircular Container Registry. Each phase
contains discrete steps with acceptance criteria. Steps within a phase are
sequential unless noted as batchable. See `ARCHITECTURE.md` for the full
design specification.

## Status

| Phase | Description | Status |
|-------|-------------|--------|
| 0 | Project scaffolding | **Complete** |
| 1 | Configuration & database | **Complete** |
| 2 | Blob storage layer | **Complete** |
| 3 | MCIAS authentication | **Complete** |
| 4 | Policy engine | **Complete** |
| 5 | OCI API — pull path | **Complete** |
| 6 | OCI API — push path | **Complete** |
| 7 | OCI API — delete path | **Complete** |
| 8 | Admin REST API | **Complete** |
| 9 | Garbage collection | **Complete** |
| 10 | gRPC admin API | **Complete** |
| 11 | CLI tool (mcrctl) | **Complete** |
| 12 | Web UI | **Complete** |
| 13 | Deployment artifacts | Not started |

### Dependency Graph

```
Phase 0 (scaffolding)
  └─► Phase 1 (config + db)
        ├─► Phase 2 (blob storage)     ──┐
        └─► Phase 3 (MCIAS auth)         │
              └─► Phase 4 (policy)        │
                    └─► Phase 5 (pull) ◄──┘
                          └─► Phase 6 (push)
                                └─► Phase 7 (delete)
                                      └─► Phase 9 (GC)
              └─► Phase 8 (admin REST) ◄── Phase 1
                    └─► Phase 10 (gRPC)
                          ├─► Phase 11 (mcrctl)
                          └─► Phase 12 (web UI)

Phase 13 (deployment) depends on all above.
```

### Batchable Work

The following phases are independent and can be assigned to different
engineers simultaneously:

- **Batch A** (after Phase 1): Phase 2 (blob storage) and Phase 3 (MCIAS auth)
- **Batch B** (after Phase 4): Phase 5 (OCI pull) and Phase 8 (admin REST)
- **Batch C** (after Phase 10): Phase 11 (mcrctl) and Phase 12 (web UI)

---

## Phase 0: Project Scaffolding

Set up the Go module, build system, and binary skeletons. This phase
produces a project that builds and lints cleanly with no functionality.

### Step 0.1: Go module and directory structure

**Acceptance criteria:**
- `go.mod` initialized with module path `git.wntrmute.dev/kyle/mcr`
- Directory skeleton created per `ARCHITECTURE.md` §13:
  `cmd/mcrsrv/`, `cmd/mcr-web/`, `cmd/mcrctl/`, `internal/`, `proto/mcr/v1/`,
  `gen/mcr/v1/`, `web/`, `deploy/`
- `.gitignore` excludes: `mcrsrv`, `mcr-web`, `mcrctl`, `srv/`, `*.db`,
  `*.db-wal`, `*.db-shm`

### Step 0.2: Makefile

**Acceptance criteria:**
- Standard targets per engineering standards: `build`, `test`, `vet`, `lint`,
  `proto`, `proto-lint`, `clean`, `docker`, `all`, `devserver`
- `all` target runs: `vet` → `lint` → `test` → build binaries
- Binary targets: `mcrsrv`, `mcr-web`, `mcrctl` with version injection via
  `-X main.version=$(shell git describe --tags --always --dirty)`
- `CGO_ENABLED=0` for all builds
- `make all` passes (empty binaries link successfully)

### Step 0.3: Linter and protobuf configuration

**Acceptance criteria:**
- `.golangci.yaml` with required linters per engineering standards:
  errcheck, govet, ineffassign, unused, errorlint, gosec, staticcheck,
  revive, gofmt, goimports
- `errcheck.check-type-assertions: true`
- `govet`: all analyzers except `shadow`
- `gosec`: exclude `G101` in test files
- `buf.yaml` for proto linting
- `golangci-lint run ./...` passes cleanly

### Step 0.4: Binary entry points with cobra

**Acceptance criteria:**
- `cmd/mcrsrv/main.go`: root command with `server`, `init`, `snapshot`
  subcommands (stubs that print "not implemented" and exit 1)
- `cmd/mcr-web/main.go`: root command with `server` subcommand (stub)
- `cmd/mcrctl/main.go`: root command with `status`, `repo`, `gc`, `policy`,
  `audit`, `snapshot` subcommand groups (stubs)
- All three binaries accept `--version` flag, printing the injected version
- `make all` builds all three binaries and passes lint/vet/test

---

## Phase 1: Configuration & Database

Implement config loading, SQLite database setup, and schema migrations.
Steps 1.1 and 1.2 can be **batched** (no dependency between them).

### Step 1.1: Configuration loading (`internal/config`)

**Acceptance criteria:**
- TOML config struct matching `ARCHITECTURE.md` §10 (all sections:
  `[server]`, `[database]`, `[storage]`, `[mcias]`, `[web]`, `[log]`)
- Parsed with `go-toml/v2`
- Environment variable overrides via `MCR_` prefix
  (e.g., `MCR_SERVER_LISTEN_ADDR`)
- Startup validation: refuse to start if required fields are missing
  (`listen_addr`, `tls_cert`, `tls_key`, `database.path`,
  `storage.layers_path`, `mcias.server_url`)
- Same-filesystem check for `layers_path` and `uploads_path` via device ID
- Default values: `uploads_path` defaults to `<layers_path>/../uploads`,
  `read_timeout` = 30s, `write_timeout` = 0, `idle_timeout` = 120s,
  `shutdown_timeout` = 60s, `log.level` = "info"
- `mcr.toml.example` created in `deploy/examples/`
- Tests: valid config, missing required fields, env override, device ID check

### Step 1.2: Database setup and migrations (`internal/db`)

**Acceptance criteria:**
- SQLite opened with `modernc.org/sqlite` (pure-Go, no CGo)
- Connection pragmas: `journal_mode=WAL`, `foreign_keys=ON`,
  `busy_timeout=5000`
- File permissions: `0600`
- Migration framework: Go functions registered sequentially, tracked in
  `schema_migrations` table, idempotent (`CREATE TABLE IF NOT EXISTS`)
- Migration 000001: `repositories`, `manifests`, `tags`, `blobs`,
  `manifest_blobs`, `uploads` tables per `ARCHITECTURE.md` §8
- Migration 000002: `policy_rules`, `audit_log` tables per §8
- All indexes created per schema
- `db.Open(path)` → `*DB`, `db.Close()`, `db.Migrate()` public API
- Tests: open fresh DB, run migrations, verify tables exist, run migrations
  again (idempotent), verify foreign key enforcement works

### Step 1.3: Audit log helpers (`internal/db`)

**Acceptance criteria:**
- `db.WriteAuditEvent(event_type, actor_id, repository, digest, ip, details)`
  inserts into `audit_log`
- `db.ListAuditEvents(filters)` with pagination (offset/limit), filtering
  by event_type, actor_id, repository, time range
- `details` parameter is `map[string]string`, serialized as JSON
- Tests: write events, list with filters, pagination

---

## Phase 2: Blob Storage Layer

Implement content-addressed filesystem operations for blob data.

### Step 2.1: Blob writer (`internal/storage`)

**Acceptance criteria:**
- `storage.New(layersPath, uploadsPath)` constructor
- `storage.StartUpload(uuid)` creates a temp file at
  `<uploadsPath>/<uuid>` and returns a `*BlobWriter`
- `BlobWriter.Write([]byte)` appends data and updates a running SHA-256 hash
- `BlobWriter.Commit(expectedDigest)`:
  - Finalizes SHA-256
  - Rejects with error if computed digest != expected digest
  - Creates two-char prefix directory under `<layersPath>/sha256/<prefix>/`
  - Renames temp file to `<layersPath>/sha256/<prefix>/<hex-digest>`
  - Returns the verified `sha256:<hex>` digest string
- `BlobWriter.Cancel()` removes the temp file
- `BlobWriter.BytesWritten()` returns current offset
- Tests: write blob, verify file at expected path, digest mismatch rejection,
  cancel cleanup, concurrent writes to different UUIDs

### Step 2.2: Blob reader and metadata (`internal/storage`)

**Acceptance criteria:**
- `storage.Open(digest)` returns an `io.ReadCloser` for the blob file, or
  `ErrBlobNotFound`
- `storage.Stat(digest)` returns size and existence check without opening
- `storage.Delete(digest)` removes the blob file and its prefix directory
  if empty
- `storage.Exists(digest)` returns bool
- Digest format validated: must match `sha256:[a-f0-9]{64}`
- Tests: read after write, stat, delete, not-found error, invalid digest
  format rejected

---

## Phase 3: MCIAS Authentication

Implement token validation and the OCI token endpoint.

### Step 3.1: MCIAS client (`internal/auth`)

**Acceptance criteria:**
- `auth.NewClient(mciastURL, caCert, serviceName, tags)` constructor
- `client.Login(username, password)` calls MCIAS `POST /v1/auth/login`
  with `service_name` and `tags` in the request body; returns JWT string
  and expiry
- `client.ValidateToken(token)` calls MCIAS `POST /v1/token/validate`;
  returns parsed claims (subject UUID, account type, roles) or error
- Validation results cached by `sha256(token)` with 30-second TTL;
  cache entries evicted on expiry
- TLS: custom CA cert supported; TLS 1.3 minimum enforced via
  `tls.Config.MinVersion`
- HTTP client timeout: 10 seconds
- Errors wrapped with `fmt.Errorf("auth: %w", err)`
- Tests: use `httptest.Server` to mock MCIAS; test login success,
  login failure (401), validate success, validate with revoked token,
  cache hit within TTL, cache miss after TTL

### Step 3.2: Auth middleware (`internal/server`)

**Acceptance criteria:**
- `middleware.RequireAuth(authClient)` extracts `Authorization: Bearer <token>`
  header, calls `authClient.ValidateToken`, injects claims into
  `context.Context`
- Missing/invalid token returns OCI error format:
  `{"errors":[{"code":"UNAUTHORIZED","message":"..."}]}` with HTTP 401
- 401 responses include `WWW-Authenticate: Bearer realm="/v2/token",service="<service_name>"`
  header
- Claims retrievable from context via `auth.ClaimsFromContext(ctx)`
- Tests: valid token passes, missing header returns 401 with
  WWW-Authenticate, invalid token returns 401, claims accessible in handler

### Step 3.3: Token endpoint (`GET /v2/token`)

**Acceptance criteria:**
- Accepts HTTP Basic auth (username:password from `Authorization` header)
- Accepts `scope` and `service` query parameters (logged but not used for
  scoping)
- Calls `authClient.Login(username, password)`
- On success: returns `{"token":"<jwt>","expires_in":<seconds>,"issued_at":"<rfc3339>"}`
- On failure: returns `{"errors":[{"code":"UNAUTHORIZED","message":"..."}]}`
  with HTTP 401
- Tests: valid credentials, invalid credentials, missing auth header

### Step 3.4: Version check endpoint (`GET /v2/`)

**Acceptance criteria:**
- Requires valid bearer token (via RequireAuth middleware)
- Returns `200 OK` with body `{}`
- Unauthenticated requests return 401 with WWW-Authenticate header
  (this is the entry point for the OCI auth handshake)
- Tests: authenticated returns 200, unauthenticated returns 401 with
  correct WWW-Authenticate header

---

## Phase 4: Policy Engine

Implement the registry-specific authorization engine.

### Step 4.1: Core policy types and evaluation (`internal/policy`)

**Acceptance criteria:**
- Types defined per `ARCHITECTURE.md` §4: `Action`, `Effect`, `PolicyInput`,
  `Rule`
- All action constants: `registry:version_check`, `registry:pull`,
  `registry:push`, `registry:delete`, `registry:catalog`, `policy:manage`
- `Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule)`:
  - Sorts rules by priority (stable)
  - Collects all matching rules
  - Deny-wins: any matching deny → return deny
  - First allow → return allow
  - Default deny if no match
- Rule matching: all populated fields ANDed; empty fields are wildcards
- `Repositories` glob matching via `path.Match`; empty list = match all
- When `input.Repository` is empty (global ops), only rules with empty
  `Repositories` match
- Tests: admin wildcard, user allow, system account deny (no rules),
  exact repo match, glob match (`production/*`), deny-wins over allow,
  priority ordering, empty repository global operation, multiple
  matching rules

### Step 4.2: Built-in defaults (`internal/policy`)

**Acceptance criteria:**
- `DefaultRules()` returns the built-in rules per `ARCHITECTURE.md` §4:
  admin wildcard, human user full access, version check allow
- Default rules use negative IDs (-1, -2, -3)
- Default rules have priority 0
- Tests: admin gets allow for all actions, user gets allow for pull/push/
  delete/catalog, system account gets deny for everything except
  version_check, user gets allow for version_check

### Step 4.3: Policy engine wrapper with DB integration (`internal/policy`)

**Acceptance criteria:**
- `Engine` struct wraps `Evaluate` with DB-backed rule loading
- `engine.SetRules(rules)` caches rules in memory (merges with defaults)
- `engine.Evaluate(input)` calls stateless `Evaluate` with cached rules
- Thread-safe: `sync.RWMutex` protects the cached rule set
- `engine.Reload(db)` loads enabled rules from `policy_rules` table and
  calls `SetRules`
- Tests: engine with only defaults, engine with custom rules, reload
  picks up new rules, disabled rules excluded

### Step 4.4: Policy middleware (`internal/server`)

**Acceptance criteria:**
- `middleware.RequirePolicy(engine, action)` middleware:
  - Extracts claims from context (set by RequireAuth)
  - Extracts repository name from URL path (empty for global ops)
  - Assembles `PolicyInput`
  - Calls `engine.Evaluate`
  - On deny: returns OCI error `{"errors":[{"code":"DENIED","message":"..."}]}`
    with HTTP 403; writes `policy_deny` audit event
  - On allow: proceeds to handler
- Tests: admin allowed, user allowed, system account denied (no rules),
  system account with matching rule allowed, deny rule blocks access

---

## Phase 5: OCI API — Pull Path

Implement the read side of the OCI Distribution Spec. Requires Phase 2
(storage), Phase 3 (auth), and Phase 4 (policy).

### Step 5.1: OCI handler scaffolding (`internal/oci`)

**Acceptance criteria:**
- `oci.NewHandler(db, storage, authClient, policyEngine)` constructor
- Chi router with `/v2/` prefix; all routes wrapped in RequireAuth middleware
- Repository name extracted from URL path; names may contain `/`
  (chi wildcard catch-all)
- OCI error response helper: `writeOCIError(w, code, status, message)`
  producing `{"errors":[{"code":"...","message":"..."}]}` format
- All OCI handlers share the same `*oci.Handler` receiver
- Tests: error response format matches OCI spec

### Step 5.2: Manifest pull (`GET /v2/<name>/manifests/<reference>`)

**Acceptance criteria:**
- Policy check: `registry:pull` action on the target repository
- If `<reference>` is a tag: look up tag → manifest in DB
- If `<reference>` is a digest (`sha256:...`): look up manifest by
  digest in DB
- Returns manifest content with:
  - `Content-Type` header set to manifest's `media_type`
  - `Docker-Content-Digest` header set to manifest's digest
  - `Content-Length` header set to manifest's size
- `HEAD` variant returns same headers but no body
- Repository not found → `NAME_UNKNOWN` (404)
- Manifest not found → `MANIFEST_UNKNOWN` (404)
- Writes `manifest_pulled` audit event
- Tests: pull by tag, pull by digest, HEAD returns headers only,
  nonexistent repo, nonexistent tag, nonexistent digest

### Step 5.3: Blob download (`GET /v2/<name>/blobs/<digest>`)

**Acceptance criteria:**
- Policy check: `registry:pull` action on the target repository
- Verify blob exists in `blobs` table AND is referenced by a manifest
  in the target repository (via `manifest_blobs`)
- Open blob from storage, stream to response with:
  - `Content-Type: application/octet-stream`
  - `Docker-Content-Digest` header
  - `Content-Length` header
- `HEAD` variant returns headers only
- Blob not in repo → `BLOB_UNKNOWN` (404)
- Tests: download blob, HEAD blob, blob not found, blob exists
  globally but not in this repo → 404

### Step 5.4: Tag listing (`GET /v2/<name>/tags/list`)

**Acceptance criteria:**
- Policy check: `registry:pull` action on the target repository
- Returns `{"name":"<repo>","tags":["tag1","tag2",...]}` sorted
  alphabetically
- Pagination via `n` (limit) and `last` (cursor) query parameters
  per OCI spec
- If more results: `Link` header with next page URL
- Empty tag list returns `{"name":"<repo>","tags":[]}`
- Repository not found → `NAME_UNKNOWN` (404)
- Tests: list tags, pagination, empty repo, nonexistent repo

### Step 5.5: Catalog listing (`GET /v2/_catalog`)

**Acceptance criteria:**
- Policy check: `registry:catalog` action (no repository context)
- Returns `{"repositories":["repo1","repo2",...]}` sorted alphabetically
- Pagination via `n` and `last` query parameters
- If more results: `Link` header with next page URL
- Tests: list repos, pagination, empty registry

---

## Phase 6: OCI API — Push Path

Implement blob uploads and manifest pushes. Requires Phase 5 (shared
OCI infrastructure).

### Step 6.1: Blob upload — initiate (`POST /v2/<name>/blobs/uploads/`)

**Acceptance criteria:**
- Policy check: `registry:push` action on the target repository
- Creates repository if it doesn't exist (implicit creation)
- Generates upload UUID (`crypto/rand`)
- Inserts row in `uploads` table
- Creates temp file via `storage.StartUpload(uuid)`
- Returns `202 Accepted` with:
  - `Location: /v2/<name>/blobs/uploads/<uuid>` header
  - `Docker-Upload-UUID: <uuid>` header
  - `Range: 0-0` header
- Tests: initiate returns 202 with correct headers, implicit repo
  creation, UUID is unique

### Step 6.2: Blob upload — chunked and monolithic

**Acceptance criteria:**
- `PATCH /v2/<name>/blobs/uploads/<uuid>`:
  - Appends request body to the upload's temp file
  - Updates `byte_offset` in `uploads` table
  - `Content-Range` header processed if present
  - Returns `202 Accepted` with updated `Range` and `Location` headers
- `PUT /v2/<name>/blobs/uploads/<uuid>?digest=<digest>`:
  - If request body is non-empty, appends it first (monolithic upload)
  - Calls `BlobWriter.Commit(digest)`
  - On digest mismatch: `DIGEST_INVALID` (400)
  - Inserts row in `blobs` table (or no-op if digest already exists)
  - Deletes row from `uploads` table
  - Returns `201 Created` with:
    - `Location: /v2/<name>/blobs/<digest>` header
    - `Docker-Content-Digest` header
  - Writes `blob_uploaded` audit event
- `GET /v2/<name>/blobs/uploads/<uuid>`:
  - Returns `204 No Content` with `Range: 0-<offset>` header
- `DELETE /v2/<name>/blobs/uploads/<uuid>`:
  - Cancels upload: deletes temp file, removes `uploads` row
  - Returns `204 No Content`
- Upload UUID not found → `BLOB_UPLOAD_UNKNOWN` (404)
- Tests: monolithic upload (POST then PUT with body), chunked upload
  (POST → PATCH → PATCH → PUT), digest mismatch, check progress,
  cancel upload, nonexistent UUID

### Step 6.3: Manifest push (`PUT /v2/<name>/manifests/<reference>`)

**Acceptance criteria:**
- Policy check: `registry:push` action on the target repository
- Implements the full manifest push flow per `ARCHITECTURE.md` §5:
  1. Parse manifest JSON; reject malformed → `MANIFEST_INVALID` (400)
  2. Compute SHA-256 digest of raw bytes
  3. If reference is a digest, verify match → `DIGEST_INVALID` (400)
  4. Parse layer and config descriptors from manifest
  5. Verify all referenced blobs exist → `MANIFEST_BLOB_UNKNOWN` (400)
  6. Single SQLite transaction:
     a. Create repository if not exists
     b. Insert/update manifest row
     c. Populate `manifest_blobs` join table
     d. If reference is a tag, insert/update tag row
  7. Return `201 Created` with `Docker-Content-Digest` and `Location`
     headers
- Writes `manifest_pushed` audit event (includes repo, tag, digest)
- Tests: push by tag, push by digest, push updates existing tag (atomic
  tag move), missing blob → 400, malformed manifest → 400, digest
  mismatch → 400, re-push same manifest (idempotent)

---

## Phase 7: OCI API — Delete Path

Implement manifest and blob deletion.

### Step 7.1: Manifest delete (`DELETE /v2/<name>/manifests/<digest>`)

**Acceptance criteria:**
- Policy check: `registry:delete` action on the target repository
- Reference must be a digest (not a tag) → `UNSUPPORTED` (405) if tag
- Deletes manifest row; cascades to `manifest_blobs` and `tags`
  (ON DELETE CASCADE)
- Returns `202 Accepted`
- Writes `manifest_deleted` audit event
- Tests: delete by digest, attempt delete by tag → 405, nonexistent
  manifest → `MANIFEST_UNKNOWN` (404), cascading tag deletion verified

### Step 7.2: Blob delete (`DELETE /v2/<name>/blobs/<digest>`)

**Acceptance criteria:**
- Policy check: `registry:delete` action on the target repository
- Verify blob exists and is referenced in this repository
- Removes the `manifest_blobs` rows for this repo's manifests (does NOT
  delete the blob row or file — that's GC's job, since other repos may
  reference it)
- Returns `202 Accepted`
- Writes `blob_deleted` audit event
- Tests: delete blob, blob still on disk (not GC'd yet), blob not in
  repo → `BLOB_UNKNOWN` (404)

---

## Phase 8: Admin REST API

Implement management endpoints under `/v1/`. Can be **batched** with
Phase 5 (OCI pull) since both depend on Phase 4 but not on each other.

### Step 8.1: Auth endpoints (`/v1/auth`)

**Acceptance criteria:**
- `POST /v1/auth/login`: accepts `{"username":"...","password":"..."}`
  body, forwards to MCIAS, returns `{"token":"...","expires_at":"..."}`
- `POST /v1/auth/logout`: requires bearer token, calls MCIAS token
  revocation (if supported), returns `204 No Content`
- `GET /v1/health`: returns `{"status":"ok"}` (no auth required)
- Error format: `{"error":"..."}` (platform standard)
- Tests: login success, login failure, logout, health check

### Step 8.2: Repository management endpoints

**Acceptance criteria:**
- `GET /v1/repositories`: list repositories with metadata
  (tag count, manifest count, total size). Paginated. Requires bearer.
- `GET /v1/repositories/{name}`: repository detail (tags with digests,
  manifests, total size). Requires bearer. Name may contain `/`.
- `DELETE /v1/repositories/{name}`: delete repository and all associated
  manifests, tags, manifest_blobs rows. Requires admin role. Writes
  `repo_deleted` audit event.
- Tests: list repos, repo detail, delete repo cascades correctly,
  non-admin delete → 403

### Step 8.3: Policy management endpoints

**Acceptance criteria:**
- Full CRUD per `ARCHITECTURE.md` §6:
  - `GET /v1/policy/rules`: list all rules (paginated)
  - `POST /v1/policy/rules`: create rule from JSON body
  - `GET /v1/policy/rules/{id}`: get single rule
  - `PATCH /v1/policy/rules/{id}`: update priority, enabled, description
  - `DELETE /v1/policy/rules/{id}`: delete rule
- All endpoints require admin role
- Write operations trigger policy engine reload
- Audit events: `policy_rule_created`, `policy_rule_updated`,
  `policy_rule_deleted`
- Input validation: priority must be >= 1 (0 reserved for built-ins),
  actions must be valid constants, effect must be "allow" or "deny"
- Tests: full CRUD cycle, validation errors, non-admin → 403,
  engine reload after create/update/delete

### Step 8.4: Audit log endpoint

**Acceptance criteria:**
- `GET /v1/audit`: list audit events. Requires admin role.
- Query parameters: `event_type`, `actor_id`, `repository`, `since`,
  `until`, `n` (limit, default 50), `offset`
- Returns JSON array of audit events
- Tests: list events, filter by type, filter by repository, pagination

### Step 8.5: Garbage collection endpoints

**Acceptance criteria:**
- `POST /v1/gc`: trigger async GC run. Requires admin role. Returns
  `202 Accepted` with `{"id":"<gc-run-id>"}`. Returns `409 Conflict`
  if GC is already running.
- `GET /v1/gc/status`: returns current/last GC status:
  `{"running":bool,"last_run":{"started_at":"...","completed_at":"...",
  "blobs_removed":N,"bytes_freed":N}}`
- Writes `gc_started` and `gc_completed` audit events
- Tests: trigger GC, check status, concurrent trigger → 409

---

## Phase 9: Garbage Collection

Implement the two-phase GC algorithm. Requires Phase 7 (delete path
creates unreferenced blobs).

### Step 9.1: GC engine (`internal/gc`)

**Acceptance criteria:**
- `gc.New(db, storage)` constructor
- `gc.Run(ctx)` executes the two-phase algorithm per `ARCHITECTURE.md` §9:
  - Phase 1 (DB): acquire lock, begin write tx, find unreferenced blobs,
    delete blob rows, commit
  - Phase 2 (filesystem): delete blob files, remove empty prefix dirs,
    release lock
- Registry-wide lock (`sync.Mutex`) blocks new blob uploads during phase 1
- Lock integration: upload initiation (Step 6.1) must check the GC lock
  before creating new uploads
- Returns `GCResult{BlobsRemoved int, BytesFreed int64, Duration time.Duration}`
- `gc.Reconcile(ctx)` scans filesystem, deletes files with no `blobs` row
  (crash recovery)
- Tests: GC removes unreferenced blobs, GC does not remove referenced blobs,
  concurrent GC rejected, reconcile cleans orphaned files

### Step 9.2: Wire GC into server and CLI

**Acceptance criteria:**
- `POST /v1/gc` and gRPC `GarbageCollect` call `gc.Run` in a goroutine
- GC status tracked in memory (running flag, last result)
- `mcrctl gc` triggers via REST/gRPC
- `mcrctl gc status` fetches status
- `mcrctl gc --reconcile` runs filesystem reconciliation
- Tests: end-to-end GC via API trigger

---

## Phase 10: gRPC Admin API

Implement the protobuf definitions and gRPC server. Requires Phase 8
(admin REST, to share business logic).

### Step 10.1: Proto definitions

**Acceptance criteria:**
- Proto files per `ARCHITECTURE.md` §7:
  `registry.proto`, `policy.proto`, `audit.proto`, `admin.proto`,
  `common.proto`
- All RPCs defined per §7 service definitions table
- `buf lint` passes
- `make proto` generates Go stubs in `gen/mcr/v1/`
- Generated code committed

### Step 10.2: gRPC server implementation (`internal/grpcserver`)

**Acceptance criteria:**
- `RegistryService`: `ListRepositories`, `GetRepository`,
  `DeleteRepository`, `GarbageCollect`, `GetGCStatus`
- `PolicyService`: full CRUD for policy rules
- `AuditService`: `ListAuditEvents`
- `AdminService`: `Health`
- All RPCs call the same business logic as REST handlers (shared
  `internal/db` and `internal/gc` packages)
- Tests: at least one RPC per service via `grpc.NewServer` + in-process
  client

### Step 10.3: Interceptor chain

**Acceptance criteria:**
- Interceptor chain per `ARCHITECTURE.md` §7:
  Request Logger → Auth Interceptor → Admin Interceptor → Handler
- Auth interceptor extracts `authorization` metadata, validates via
  MCIAS, injects claims. `Health` bypasses auth.
- Admin interceptor requires admin role for GC, policy, delete, audit.
- Request logger logs method, peer IP, status code, duration. Never
  logs the authorization metadata value.
- gRPC errors: `codes.Unauthenticated` for missing/invalid token,
  `codes.PermissionDenied` for insufficient role
- Tests: unauthenticated → Unauthenticated, non-admin on admin
  RPC → PermissionDenied, Health bypasses auth

### Step 10.4: TLS and server startup

**Acceptance criteria:**
- gRPC server uses same TLS cert/key as REST server
- `tls.Config.MinVersion = tls.VersionTLS13`
- Server starts on `grpc_addr` from config; disabled if `grpc_addr`
  is empty
- Graceful shutdown: `grpcServer.GracefulStop()` called on SIGINT/SIGTERM
- Tests: server starts and accepts TLS connections

---

## Phase 11: CLI Tool (mcrctl)

Implement the admin CLI. Can be **batched** with Phase 12 (web UI)
since both depend on Phase 10 but not on each other.

### Step 11.1: Client and connection setup

**Acceptance criteria:**
- Global flags: `--server` (REST URL), `--grpc` (gRPC address),
  `--token` (bearer token), `--ca-cert` (custom CA)
- Token can be loaded from `MCR_TOKEN` env var
- gRPC client with TLS, using same CA cert if provided
- REST client with TLS, `Authorization: Bearer` header
- Connection errors produce clear messages

### Step 11.2: Status and repository commands

**Acceptance criteria:**
- `mcrctl status` → calls `GET /v1/health`, prints status
- `mcrctl repo list` → calls `GET /v1/repositories`, prints table
- `mcrctl repo delete <name>` → calls `DELETE /v1/repositories/<name>`,
  confirms before deletion
- Output: human-readable by default, `--json` for machine-readable
- Tests: at minimum, flag parsing tests

### Step 11.3: Policy, audit, GC, and snapshot commands

**Acceptance criteria:**
- `mcrctl policy list|create|update|delete` → full CRUD via REST/gRPC
- `mcrctl policy create` accepts `--json` flag for rule body
- `mcrctl audit tail [--n N]` → calls `GET /v1/audit`
- `mcrctl gc` → calls `POST /v1/gc`
- `mcrctl gc status` → calls `GET /v1/gc/status`
- `mcrctl gc --reconcile` → calls reconciliation endpoint
- `mcrctl snapshot` → triggers database backup
- Tests: flag parsing, output formatting

---

## Phase 12: Web UI

Implement the HTMX-based web interface. Requires Phase 10 (gRPC).

### Step 12.1: Web server scaffolding

**Acceptance criteria:**
- `cmd/mcr-web/` binary reads `[web]` config section
- Connects to mcrsrv via gRPC at `web.grpc_addr`
- Go `html/template` with `web/templates/layout.html` base template
- Static files embedded via `//go:embed` (`web/static/`: CSS, htmx)
- CSRF protection: signed double-submit cookies on POST/PUT/PATCH/DELETE
- Session cookie: `HttpOnly`, `Secure`, `SameSite=Strict`, stores
  MCIAS JWT
- Chi router with middleware chain

### Step 12.2: Login and authentication

**Acceptance criteria:**
- `/login` page with username/password form
- Form submission POSTs to mcr-web, which calls MCIAS login via mcrsrv
  gRPC (or directly via MCIAS client)
- On success: sets session cookie, redirects to `/`
- On failure: re-renders login with error message
- Logout link clears session cookie

### Step 12.3: Dashboard and repository browsing

**Acceptance criteria:**
- `/` dashboard: repository count, total size, recent pushes
  (last 10 `manifest_pushed` audit events)
- `/repositories` list: table with name, tag count, manifest count,
  total size
- `/repositories/{name}` detail: tag list (name → digest), manifest
  list (digest, media type, size, created), layer list
- `/repositories/{name}/manifests/{digest}` detail: full manifest
  JSON, referenced layers with sizes
- All data fetched from mcrsrv via gRPC

### Step 12.4: Policy management (admin only)

**Acceptance criteria:**
- `/policies` page: list all policy rules in a table
- Create form: HTMX form that submits new rule (priority, description,
  effect, actions, account types, subject UUID, repositories)
- Edit: inline HTMX toggle for enabled/disabled, edit priority/description
- Delete: confirm dialog, HTMX delete
- Non-admin users see "Access denied" or are redirected

### Step 12.5: Audit log viewer (admin only)

**Acceptance criteria:**
- `/audit` page: paginated table of audit events
- Filters: event type dropdown, repository name, date range
- HTMX partial page updates for filter changes
- Non-admin users see "Access denied"

---

## Phase 13: Deployment Artifacts

Package everything for production deployment.

### Step 13.1: Dockerfile

**Acceptance criteria:**
- Multi-stage build per `ARCHITECTURE.md` §14:
  Builder `golang:1.25-alpine`, runtime `alpine:3.21`
- `CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"`
- Builds all three binaries
- Runtime: non-root user `mcr` (uid 10001)
- `EXPOSE 8443 9443`
- `VOLUME /srv/mcr`
- `ENTRYPOINT ["mcrsrv"]`, `CMD ["server", "--config", "/srv/mcr/mcr.toml"]`
- `make docker` builds image with version tag

### Step 13.2: systemd units

**Acceptance criteria:**
- `deploy/systemd/mcr.service`: registry server unit with security
  hardening per engineering standards (`NoNewPrivileges`, `ProtectSystem`,
  `ReadWritePaths=/srv/mcr`, etc.)
- `deploy/systemd/mcr-web.service`: web UI unit with
  `ReadOnlyPaths=/srv/mcr`
- `deploy/systemd/mcr-backup.service`: oneshot backup unit running
  `mcrsrv snapshot`
- `deploy/systemd/mcr-backup.timer`: daily 02:00 UTC with 5-min jitter
- All units run as `User=mcr`, `Group=mcr`

### Step 13.3: Install script and example configs

**Acceptance criteria:**
- `deploy/scripts/install.sh`: idempotent script that creates system
  user/group, installs binaries to `/usr/local/bin/`, creates `/srv/mcr/`
  directory structure, installs example config if none exists, installs
  systemd units and reloads daemon
- `deploy/examples/mcr.toml` with annotated production defaults
- `deploy/examples/mcr-dev.toml` with local development defaults
- Script tested: runs twice without error (idempotent)
