# MCR Development Progress

Reverse-chronological log of development work. Most recent entries first.
See `PROJECT_PLAN.md` for the implementation roadmap and
`ARCHITECTURE.md` for the full design specification.

## Current State

**Phase:** 13 complete
**Last updated:** 2026-03-25

### Completed

- Phase 0: Project scaffolding (all 4 steps)
- Phase 1: Configuration & database (all 3 steps)
- Phase 2: Blob storage layer (all 2 steps)
- Phase 3: MCIAS authentication (all 4 steps)
- Phase 4: Policy engine (all 4 steps)
- Phase 5: OCI pull path (all 5 steps)
- Phase 6: OCI push path (all 3 steps)
- Phase 7: OCI delete path (all 2 steps)
- Phase 8: Admin REST API (all 5 steps)
- Phase 9: Garbage collection (all 2 steps)
- Phase 10: gRPC admin API (all 4 steps)
- Phase 11: CLI tool (all 3 steps)
- Phase 12: Web UI (all 5 steps)
- `ARCHITECTURE.md` — Full design specification (18 sections)
- `CLAUDE.md` — AI development guidance
- `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps)
- `PROGRESS.md` — This file

### Next Steps

1. Deploy to rift (issue MCR service token, generate TLS cert, update mc-proxy routes)
2. Consider adding roles to MCIAS login response to eliminate the extra ValidateToken round-trip

### 2026-03-26 — Web UI: block guest login

**Task:** Prevent MCIAS guest accounts from logging into the web UI.

**Changes:**

- `internal/webserver/server.go`: Added `ValidateFunc` type; `New()`
  accepts a validate function to inspect tokens post-login.
- `internal/webserver/auth.go`: After `loginFn` succeeds, calls
  `validateFn` to retrieve roles. Rejects accounts with the `guest`
  role before setting the session cookie.
- `cmd/mcr-web/main.go`: Wires `ValidateFunc` via `authClient.ValidateToken()`.
- `internal/webserver/server_test.go`: Added guest/user test accounts,
  `validateFn` returning role-appropriate responses, `TestLoginDeniesGuest`.
- `ARCHITECTURE.md`: Updated Web UI security section and threat mitigations
  to document guest blocking as defense-in-depth.

**Design note:** MCIAS `/v1/auth/login` does not return roles, so the
web UI makes a second `ValidateToken` call after login to inspect them.
This is an extra MCIAS round-trip at login time (cached for 30s). A
future MCIAS change to include roles in the login response would
eliminate this.

### 2026-03-25 — Phase 13: Deployment Artifacts

**Task:** Create Dockerfiles, systemd units, install script, and rift deployment config.

**Changes:**

Step 13.1 — Dockerfiles:
- `Dockerfile.api`: Multi-stage build for mcrsrv (golang:1.25-alpine → alpine:3.21,
  non-root `mcr` user, ports 8443/9443, volume /srv/mcr)
- `Dockerfile.web`: Multi-stage build for mcr-web (same pattern, port 8080)

Step 13.2 — systemd units:
- `deploy/systemd/mcr.service`: Registry server with full security hardening
- `deploy/systemd/mcr-web.service`: Web UI with read-only /srv/mcr
- `deploy/systemd/mcr-backup.service`: Oneshot snapshot + 30-day prune
- `deploy/systemd/mcr-backup.timer`: Daily 02:00 UTC with 5-min jitter

Step 13.3 — Install script and configs:
- `deploy/scripts/install.sh`: Idempotent install (user, binaries, dirs, units)
- `deploy/mcr-rift.toml`: Rift-specific config (MCIAS auth, TLS, storage paths)
- `deploy/docker/docker-compose-rift.yml`: Docker compose for rift with
  loopback port bindings (28443, 29443, 28080) for mc-proxy fronting

---

### 2026-03-19 — Batch C: Phase 11 (CLI tool) + Phase 12 (Web UI)

**Task:** Implement the admin CLI and HTMX-based web UI — the two
remaining user-facing layers. Both depend on Phase 10 (gRPC) but not
on each other; implemented in parallel.

**Changes:**

Phase 11 — `cmd/mcrctl/` (Steps 11.1–11.3):

Step 11.1 — Client and connection setup:
- `client.go`: `apiClient` struct wrapping both `*http.Client` (REST)
  and gRPC service clients (Registry, Policy, Audit, Admin); `newClient()`
  builds from flags; TLS 1.3 minimum with optional custom CA cert;
  gRPC dial uses `grpc.ForceCodecV2(mcrv1.JSONCodec{})` for JSON codec;
  `restDo()` helper with `Authorization: Bearer` header and JSON error
  parsing; transport auto-selected based on `--grpc` flag

Step 11.2 — Status and repository commands:
- `main.go`: global persistent flags `--server`, `--grpc`, `--token`
  (fallback `MCR_TOKEN`), `--ca-cert`, `--json`; `PersistentPreRunE`
  resolves token and creates client; `status` command (gRPC + REST);
  `repo list` with table/JSON output; `repo delete` with confirmation
  prompt
- `output.go`: `formatSize()` (B/KB/MB/GB/TB), `printJSON()` (indented),
  `printTable()` via `text/tabwriter`

Step 11.3 — Policy, audit, GC, and snapshot commands:
- `main.go`: `policy list|create|update|delete` (full CRUD, `--rule`
  flag for JSON body, confirmation on delete); `audit tail` with
  `--n` and `--event-type` flags; `gc` with `--reconcile` flag;
  `gc status`; `snapshot`; all commands support both REST and gRPC
- `client_test.go`: 10 tests covering formatSize, printJSON, printTable,
  token resolution from env/flag, newClient REST mode, CA cert error
  handling, restDo success/error/POST paths

Phase 12 — `cmd/mcr-web/` + `internal/webserver/` + `web/` (Steps 12.1–12.5):

Step 12.1 — Web server scaffolding:
- `cmd/mcr-web/main.go`: reads `[web]` config section, creates gRPC
  connection with TLS 1.3 and JSON codec, creates MCIAS auth client for
  login, generates random 32-byte CSRF key, creates webserver, starts
  HTTPS with TLS 1.3, graceful shutdown on SIGINT/SIGTERM
- `internal/webserver/server.go`: `Server` struct with chi router,
  gRPC service clients, CSRF key, login function; `New()` constructor;
  chi middleware (Recoverer, RequestID, RealIP); routes for all pages;
  session-protected route groups; static file serving from embedded FS
- `web/embed.go`: `//go:embed templates static` directive
- `web/static/style.css`: minimal clean CSS (system fonts, 1200px
  container, table styling, form styling, nav bar, stat cards, badges,
  pagination, responsive breakpoints)

Step 12.2 — Login and authentication:
- `internal/webserver/auth.go`: session middleware (checks `mcr_session`
  cookie, redirects to `/login` if absent); login page (GET renders
  form with CSRF token); login submit (POST validates CSRF, calls
  `loginFn`, sets session cookie HttpOnly/Secure/SameSite=Strict);
  logout (clears cookie, redirects); CSRF via signed double-submit
  cookie (HMAC-SHA256)
- `web/templates/login.html`: centered login form with CSRF hidden field

Step 12.3 — Dashboard and repository browsing:
- `internal/webserver/handlers.go`: `handleDashboard()` (repo count,
  total size, recent audit events via gRPC); `handleRepositories()`
  (list table); `handleRepositoryDetail()` (tags, manifests, repo
  name with `/` support); `handleManifestDetail()` (manifest info
  by digest)
- `internal/webserver/templates.go`: template loading from embedded FS
  with layout-page composition, function map (formatSize, formatTime,
  truncate, joinStrings), render helper
- `web/templates/layout.html`: HTML5 base with nav bar, htmx CDN
- `web/templates/dashboard.html`: stats cards + recent activity table
- `web/templates/repositories.html`: repo list table
- `web/templates/repository_detail.html`: tags + manifests tables
- `web/templates/manifest_detail.html`: digest, media type, size

Step 12.4 — Policy management (admin only):
- `internal/webserver/handlers.go`: `handlePolicies()` (list rules
  with CSRF token); `handleCreatePolicy()` (form with body limit,
  CSRF validation); `handleTogglePolicy()` (get+toggle enabled via
  UpdatePolicyRule with field mask); `handleDeletePolicy()` (CSRF +
  delete); PermissionDenied → "Access denied"
- `web/templates/policies.html`: create form + rules table with
  toggle/delete actions

Step 12.5 — Audit log viewer (admin only):
- `internal/webserver/handlers.go`: `handleAudit()` with pagination
  (fetch N+1 for next-page detection), filters (event type, repository,
  date range), URL builder for pagination links
- `web/templates/audit.html`: filter form + paginated event table

**Verification:**
- `make all` passes: vet clean, lint 0 issues, all tests passing,
  all 3 binaries built
- CLI tests (10 new): formatSize (5 values: B through TB), printJSON
  output correctness, printTable header and row rendering, token from
  env var, token flag overrides env, newClient REST mode, CA cert
  errors (missing file, invalid PEM), restDo success with auth header,
  restDo error response parsing, restDo POST with body
- Web UI tests (15 new): login page renders, invalid credentials error,
  CSRF token validation, dashboard requires session (redirect),
  dashboard with session, repositories page, repository detail,
  logout (cookie clearing), policies page, audit page, static files,
  formatSize, formatTime, truncate, login success sets cookie

---

## Log

### 2026-03-19 — Phase 10: gRPC admin API

**Task:** Implement the gRPC admin API server with the same business
logic as the REST admin API, per ARCHITECTURE.md section 7.

**Changes:**

Step 10.1 — Proto definitions (`proto/mcr/v1/`):
- `common.proto`: `PaginationRequest` shared type
- `registry.proto`: `RegistryService` with `ListRepositories`,
  `GetRepository`, `DeleteRepository`, `GarbageCollect`, `GetGCStatus` RPCs
- `policy.proto`: `PolicyService` with `ListPolicyRules`,
  `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`,
  `DeletePolicyRule` RPCs; `UpdatePolicyRuleRequest` includes field mask
- `audit.proto`: `AuditService` with `ListAuditEvents` RPC; filter
  fields for event_type, actor_id, repository, time range
- `admin.proto`: `AdminService` with `Health` RPC

Step 10.2 — Generated code (`gen/mcr/v1/`):
- Hand-written message types and gRPC service descriptors matching
  protoc-gen-go v1.36+ and protoc-gen-go-grpc v1.5+ output patterns
- `codec.go`: JSON codec implementing `encoding.CodecV2` via
  `mem.BufferSlice`, registered globally via `init()` as stand-in
  until protobuf code generation is available
- Handler functions properly delegate to interceptor chain (critical
  for auth/admin enforcement; handlers that ignore the interceptor
  parameter bypass security checks entirely)
- Client and server interfaces with `mustEmbedUnimplemented*Server()`
  for forward compatibility

Step 10.3 — Interceptor chain (`internal/grpcserver/interceptors.go`):
- `loggingInterceptor`: logs method, peer IP, status code, duration;
  never logs the authorization metadata value
- `authInterceptor`: extracts `authorization` metadata, validates
  bearer token via `TokenValidator` interface, injects claims into
  context; `Health` bypasses auth via `authBypassMethods` map
- `adminInterceptor`: requires admin role for GC, policy, delete,
  audit RPCs via `adminRequiredMethods` map; returns
  `codes.PermissionDenied` for insufficient role
- gRPC errors: `codes.Unauthenticated` for missing/invalid token,
  `codes.PermissionDenied` for insufficient role

Step 10.4 — Server implementation (`internal/grpcserver/`):
- `server.go`: `New(certFile, keyFile, deps)` creates configured
  gRPC server with TLS 1.3 minimum (skips TLS if paths empty for
  testing); `Serve()`, `GracefulStop()`
- `registry.go`: `registryService` implementing all 5 RPCs with
  same DB calls as REST handlers; GC runs asynchronously with its
  own `GCStatus` tracking (separate from REST's `GCState` since
  `GCState.mu` is unexported); shares `gc.Collector` for actual GC
- `policy.go`: `policyService` implementing all 5 RPCs with
  validation (effect, actions, priority), field mask updates, engine
  reload, audit events
- `audit.go`: `auditService` implementing `ListAuditEvents` with
  pagination and filter pass-through to `db.ListAuditEvents`
- `admin.go`: `adminService` implementing `Health` (returns "ok")

**Dependencies added:**
- `google.golang.org/grpc` v1.79.3
- `google.golang.org/protobuf` v1.36.11
- Transitive: `golang.org/x/net`, `golang.org/x/text`,
  `google.golang.org/genproto/googleapis/rpc`

**Verification:**
- `make all` passes: vet clean, lint 0 issues, all tests passing,
  all 3 binaries built
- Interceptor tests (12 new): Health bypasses auth, no token rejected
  (Unauthenticated), invalid token rejected (Unauthenticated), valid
  token accepted, non-admin denied policy RPCs (PermissionDenied),
  admin allowed policy RPCs, admin required methods completeness
  check, auth bypass methods completeness check, delete repo requires
  admin, GC requires admin, audit requires admin
- Registry service tests (7 new): list repositories empty, get repo
  not found, get repo empty name (InvalidArgument), delete repo not
  found, delete repo empty name, GC status initial (not running),
  GC trigger returns ID
- Policy service tests (8 new): create policy rule (with engine
  reload verification), create validation (5 subtests: zero priority,
  empty description, invalid effect, no actions, invalid action), get
  policy rule, get not found, list policy rules, delete + verify
  gone + reload count, delete not found, update with field mask
- Audit service tests (3 new): list empty, list with data (verify
  fields and details map), list with pagination
- Admin service tests (2 new): Health returns "ok", Health without
  auth succeeds

---

### 2026-03-19 — Phase 9: Garbage collection

**Task:** Implement the two-phase GC algorithm for removing unreferenced
blobs per ARCHITECTURE.md §9.

**Changes:**

Step 9.1 — GC engine (`internal/gc/`):
- `gc.go`: `Collector` struct with `sync.Mutex` for registry-wide lock;
  `New(db, storage)` constructor; `Run(ctx)` executes two-phase algorithm
  (Phase 1: find unreferenced blobs + delete rows in transaction;
  Phase 2: delete files from storage); `Reconcile(ctx)` scans filesystem
  for orphaned files with no DB row (crash recovery); `TryLock()` for
  concurrent GC rejection
- `errors.go`: `ErrGCRunning` sentinel
- `DB` interface: `FindAndDeleteUnreferencedBlobs()`, `BlobExistsByDigest()`
- `Storage` interface: `Delete()`, `ListBlobDigests()`
- `db/gc.go`: `FindAndDeleteUnreferencedBlobs()` — LEFT JOIN blobs to
  manifest_blobs, finds unreferenced, deletes rows in single transaction;
  `BlobExistsByDigest()`
- `storage/list.go`: `ListBlobDigests()` — scans sha256 prefix dirs

Step 9.2 — Wire GC into server:
- `server/admin_gc.go`: updated `GCState` to hold `*gc.Collector` and
  `AuditFunc`; `AdminTriggerGCHandler` now launches `collector.Run()`
  in a goroutine, tracks result, writes `gc_started`/`gc_completed`
  audit events

**Verification:**
- `make all` passes: vet clean, lint 0 issues, all tests passing,
  all 3 binaries built
- GC engine tests (6 new): removes unreferenced blobs (verify both DB
  rows and storage files deleted, referenced blobs preserved), does not
  remove referenced blobs, concurrent GC rejected (ErrGCRunning), empty
  registry (no-op), reconcile cleans orphaned files, reconcile empty
  storage
- DB GC tests (3 new): FindAndDeleteUnreferencedBlobs (unreferenced
  removed, referenced preserved), no unreferenced returns nil,
  BlobExistsByDigest (found + not found)

---

### 2026-03-19 — Phase 7: OCI delete path

**Task:** Implement manifest and blob deletion per OCI Distribution Spec.

**Changes:**

Step 7.1 — Manifest delete:
- `db/delete.go`: `DeleteManifest(repoID, digest)` — deletes manifest
  row; ON DELETE CASCADE handles manifest_blobs and tags
- `oci/delete.go`: `handleManifestDelete()` — policy check
  (registry:delete), rejects deletion by tag (405 UNSUPPORTED per OCI
  spec), returns 202 Accepted, writes `manifest_deleted` audit event
- Updated `oci/routes.go` dispatch to handle DELETE on manifests

Step 7.2 — Blob delete:
- `db/delete.go`: `DeleteBlobFromRepo(repoID, digest)` — removes
  manifest_blobs associations only; does NOT delete the blob row or
  file (GC's responsibility, since other repos may reference it)
- `oci/delete.go`: `handleBlobDelete()` — policy check, returns 202,
  writes `blob_deleted` audit event
- Updated `oci/routes.go` dispatch to handle DELETE on blobs
- Extended `DBQuerier` interface with delete methods

**Verification:**
- `make all` passes: vet clean, lint 0 issues, all tests passing,
  all 3 binaries built
- DB delete tests (5 new): delete manifest (verify cascade to tags and
  manifest_blobs, blob row preserved), manifest not found, delete blob
  from repo (manifest_blobs removed, blob row preserved, manifest
  preserved), blob not found, blob exists globally but not in repo
- OCI delete tests (8 new): manifest delete by digest (202), delete by
  tag (405 UNSUPPORTED), manifest not found (404 MANIFEST_UNKNOWN),
  repo not found (404 NAME_UNKNOWN), cascading tag deletion verified,
  blob delete (202), blob not in repo (404 BLOB_UNKNOWN), blob delete
  repo not found

---

### 2026-03-19 — Phase 6: OCI push path

**Task:** Implement blob uploads (monolithic and chunked) and manifest
push per ARCHITECTURE.md §5 and OCI Distribution Spec.

**Changes:**

Step 6.1 — Blob upload initiation:
- `db/upload.go`: `UploadRow` type, `ErrUploadNotFound` sentinel;
  `CreateUpload()`, `GetUpload()`, `UpdateUploadOffset()`, `DeleteUpload()`
- `db/push.go`: `GetOrCreateRepository()` (implicit repo creation on
  first push), `BlobExists()`, `InsertBlob()` (INSERT OR IGNORE for
  content-addressed dedup)
- `oci/upload.go`: `uploadManager` (sync.Mutex-protected map of in-progress
  BlobWriters by UUID); `generateUUID()` via crypto/rand; `handleUploadInitiate()`
  — policy check (registry:push), implicit repo creation, DB row + storage
  temp file, returns 202 with Location/Docker-Upload-UUID/Range headers
- Extended `DBQuerier` interface with push/upload methods
- Changed `BlobOpener` to `BlobStore` adding `StartUpload(uuid)` method

Step 6.2 — Blob upload chunked and monolithic:
- `oci/upload.go`: `handleUploadChunk()` (PATCH — append body, update offset),
  `handleUploadComplete()` (PUT — optional final body, commit with digest
  verification, insert blob row, cleanup upload, audit event),
  `handleUploadStatus()` (GET — 204 with Range header),
  `handleUploadCancel()` (DELETE — cancel BlobWriter, remove upload row)
- `oci/routes.go`: `parseOCIPath()` extended to handle `/blobs/uploads/`
  and `/blobs/uploads/<uuid>` patterns; `dispatchUpload()` routes by method

Step 6.3 — Manifest push:
- `db/push.go`: `PushManifestParams` struct, `PushManifest()` — single
  SQLite transaction: create repo if not exists, upsert manifest (ON
  CONFLICT DO UPDATE), clear and repopulate manifest_blobs join table,
  upsert tag if provided (atomic tag move)
- `oci/manifest.go`: `handleManifestPut()` — full push flow per §5:
  parse JSON, compute SHA-256, verify digest if push-by-digest, collect
  config+layer descriptors, verify all referenced blobs exist (400
  MANIFEST_BLOB_UNKNOWN if missing), call PushManifest(), audit event
  with tag details, returns 201 with Docker-Content-Digest/Location/
  Content-Type headers

**Verification:**
- `make all` passes: vet clean, lint 0 issues, 198 tests passing,
  all 3 binaries built
- DB tests (15 new): upload CRUD (create/get/update/delete/not-found),
  GetOrCreateRepository (new + existing), BlobExists (found/not-found),
  InsertBlob (new + idempotent), PushManifest by tag (verify repo creation,
  manifest, tag, manifest_blobs), by digest (no tag), tag move (atomic
  update), idempotent re-push
- OCI upload tests (8 new): initiate (202, Location/UUID/Range headers,
  implicit repo creation), unique UUIDs (5 initiates → 5 distinct UUIDs),
  monolithic upload (POST → PUT with body → 201), chunked upload
  (POST → PATCH → PATCH → PUT → 201), digest mismatch (400 DIGEST_INVALID),
  upload status (GET → 204), cancel (DELETE → 204), nonexistent UUID
  (PATCH → 404 BLOB_UPLOAD_UNKNOWN)
- OCI manifest push tests (7 new): push by tag (201, correct headers),
  push by digest, digest mismatch (400), missing blob (400
  MANIFEST_BLOB_UNKNOWN), malformed JSON (400 MANIFEST_INVALID), empty
  manifest, tag update (atomic move), re-push idempotent
- Route parsing tests (5 new subtests): upload initiate (trailing slash),
  upload initiate (no trailing slash), upload with UUID, multi-segment
  repo upload, multi-segment repo upload initiate

---

### 2026-03-19 — Batch B: Phase 5 (OCI pull) + Phase 8 (admin REST)

**Task:** Implement the OCI Distribution Spec pull path and the admin
REST API management endpoints. Both phases depend on Phase 4 (policy)
but not on each other — implemented in parallel.

**Changes:**

Phase 5 — `internal/db/` + `internal/oci/` (Steps 5.1–5.5):

Step 5.1 — OCI handler scaffolding:
- `db/errors.go`: `ErrRepoNotFound`, `ErrManifestNotFound`, `ErrBlobNotFound`
  sentinel errors
- `oci/handler.go`: `Handler` struct with `DBQuerier`, `BlobOpener`,
  `PolicyEval`, `AuditFunc` interfaces; `NewHandler()` constructor;
  `checkPolicy()` inline policy check; `audit()` helper
- `oci/ocierror.go`: `writeOCIError()` duplicated from server package
  (15 lines, avoids coupling)
- `oci/routes.go`: `parseOCIPath()` splits from the right to handle
  multi-segment repo names (e.g., `org/team/app/manifests/latest`);
  `Router()` returns chi router with `/_catalog` and `/*` catch-all;
  `dispatch()` routes to manifest/blob/tag handlers

Step 5.2 — Manifest pull:
- `db/repository.go`: `ManifestRow` type, `GetRepositoryByName()`,
  `GetManifestByTag()`, `GetManifestByDigest()`
- `oci/manifest.go`: GET/HEAD `/v2/{name}/manifests/{reference}`;
  resolves by tag or digest; sets Content-Type, Docker-Content-Digest,
  Content-Length headers

Step 5.3 — Blob download:
- `db/repository.go`: `BlobExistsInRepo()` — joins blobs+manifest_blobs+
  manifests to verify blob belongs to repo
- `oci/blob.go`: GET/HEAD `/v2/{name}/blobs/{digest}`; validates blob
  exists in repo before streaming from storage

Step 5.4 — Tag listing:
- `db/repository.go`: `ListTags()` with cursor-based pagination
  (after/limit)
- `oci/tags.go`: GET `/v2/{name}/tags/list` with OCI `?n=`/`?last=`
  pagination and `Link` header for next page

Step 5.5 — Catalog listing:
- `db/repository.go`: `ListRepositoryNames()` with cursor-based pagination
- `oci/catalog.go`: GET `/v2/_catalog` with same pagination pattern

Phase 8 — `internal/db/` + `internal/server/` (Steps 8.1–8.5):

Step 8.1 — Auth endpoints:
- `server/admin_auth.go`: POST `/v1/auth/login` (JSON body → MCIAS),
  POST `/v1/auth/logout` (204 stub), GET `/v1/health` (no auth)

Step 8.2 — Repository management:
- `db/admin.go`: `RepoMetadata`, `TagInfo`, `ManifestInfo`, `RepoDetail`
  types; `ListRepositoriesWithMetadata()`, `GetRepositoryDetail()`,
  `DeleteRepository()`
- `server/admin_repo.go`: GET/DELETE `/v1/repositories` and
  `/v1/repositories/*` (wildcard for multi-segment names)

Step 8.3 — Policy CRUD:
- `db/admin.go`: `PolicyRuleRow` type, `ErrPolicyRuleNotFound`;
  `CreatePolicyRule()`, `GetPolicyRule()`, `ListPolicyRules()`,
  `UpdatePolicyRule()`, `SetPolicyRuleEnabled()`, `DeletePolicyRule()`
- `server/admin_policy.go`: full CRUD on `/v1/policy/rules` and
  `/v1/policy/rules/{id}`; `PolicyReloader` interface; input validation
  (priority >= 1, valid effect/actions); mutations trigger engine reload

Step 8.4 — Audit endpoint:
- `server/admin_audit.go`: GET `/v1/audit` with query parameter filters
  (event_type, actor_id, repository, since, until, n, offset);
  delegates to `db.ListAuditEvents`

Step 8.5 — GC endpoints:
- `server/admin_gc.go`: `GCState` struct with `sync.Mutex`; POST `/v1/gc`
  returns 202 (stub for Phase 9); GET `/v1/gc/status`; concurrent
  trigger returns 409

Shared admin infrastructure:
- `server/admin.go`: `writeAdminError()` (platform `{"error":"..."}`
  format), `writeJSON()`, `RequireAdmin()` middleware, `hasRole()` helper
- `server/admin_routes.go`: `AdminDeps` struct, `MountAdminRoutes()`
  mounts all `/v1/*` endpoints with proper auth/admin middleware layers

**Verification:**
- `make all` passes: vet clean, lint 0 issues, 168 tests passing,
  all 3 binaries built
- Phase 5 (34 new tests): parseOCIPath (14 subtests covering simple/
  multi-segment/edge cases), manifest GET by tag/digest + HEAD + not
  found, blob GET/HEAD + not in repo + repo not found, tags list +
  pagination + empty + repo not found, catalog list + pagination + empty,
  DB repository methods (15 tests covering all 6 query methods)
- Phase 8 (51 new tests): DB admin methods (19 tests covering CRUD,
  pagination, cascade, not-found), admin auth (login ok/fail, health,
  logout), admin repos (list/detail/delete/non-admin 403), admin policy
  (full CRUD cycle, validation errors, non-admin 403, engine reload),
  admin audit (list with filters, pagination), admin GC (trigger 202,
  status, concurrent 409), RequireAdmin middleware (allowed/denied/
  no claims)

---

### 2026-03-19 — Phase 4: Policy engine

**Task:** Implement the registry-specific authorization engine with
priority-based, deny-wins, default-deny evaluation per ARCHITECTURE.md §4.

**Changes:**

Step 4.1 — `internal/policy/` core types and evaluation:
- `policy.go`: `Action` (6 constants), `Effect` (Allow/Deny), `PolicyInput`,
  `Rule` types per ARCHITECTURE.md §4
- `Evaluate(input, rules)` — stateless evaluation: sort by priority (stable),
  collect all matching rules, deny-wins, default-deny
- Rule matching: all populated fields ANDed; empty fields are wildcards;
  `Repositories` glob matching via `path.Match`; empty repo (global ops)
  only matches rules with empty Repositories list

Step 4.2 — `internal/policy/` built-in defaults:
- `defaults.go`: `DefaultRules()` returns 3 built-in rules (negative IDs,
  priority 0): admin wildcard (all actions), human user content access
  (pull/push/delete/catalog), version check (always accessible)

Step 4.3 — `internal/policy/` engine wrapper with DB integration:
- `engine.go`: `Engine` struct with `sync.RWMutex`-protected rule cache;
  `NewEngine()` pre-loaded with defaults; `SetRules()` merges with defaults;
  `Evaluate()` thread-safe evaluation; `Reload(RuleStore)` loads from DB
- `RuleStore` interface: `LoadEnabledPolicyRules() ([]Rule, error)`
- `internal/db/policy.go`: `LoadEnabledPolicyRules()` on `*DB` — loads
  enabled rules from `policy_rules` table, parses `rule_json` JSON column,
  returns `[]policy.Rule` ordered by priority

Step 4.4 — `internal/server/` policy middleware:
- `policy.go`: `PolicyEvaluator` interface, `AuditFunc` callback type,
  `RequirePolicy(evaluator, action, auditFn)` middleware — extracts claims
  from context, repo name from chi URL param, assembles `PolicyInput`,
  returns OCI DENIED (403) on deny with optional audit callback

**Verification:**
- `make all` passes: vet clean, lint 0 issues, 69 tests passing
  (17 policy + 14 server + 15 db + 9 auth + 7 config + 14 storage - some
  overlap from updated packages), all 3 binaries built
- Policy evaluation tests: admin wildcard, user allow, system account deny,
  exact repo match (allow + deny on different repo), glob match
  (production/* matches production/myapp, not production/team/myapp),
  deny-wins over allow, priority ordering, empty repo global operation
  (admin catalog allowed, repo-scoped rule doesn't match), multiple
  matching rules (highest-priority allow returned)
- Default rules tests: admin allowed for all 6 actions, user allowed for
  pull/push/delete/catalog but denied policy:manage, system account denied
  for all except version_check, version_check allowed for both human and
  system accounts
- Engine tests: defaults-only (admin allow, system deny), custom rules
  (matching subject allowed, different subject denied), reload picks up new
  rules (old rules gone), reload with empty store (disabled rules excluded,
  falls back to defaults)
- DB tests: LoadEnabledPolicyRules returns only enabled rules ordered by
  priority, parses rule_json correctly (effect, subject_uuid, actions,
  repositories), empty table returns nil
- Middleware tests: admin allowed, user allowed, system denied (403 with
  OCI DENIED error), system with matching rule allowed, explicit deny
  rule blocks access (403)

---

### 2026-03-19 — Batch A: Phase 2 (blob storage) + Phase 3 (MCIAS auth)

**Task:** Implement content-addressed blob storage and MCIAS authentication
with OCI token endpoint and auth middleware.

**Changes:**

Phase 2 — `internal/storage/` (Steps 2.1 + 2.2):
- `storage.go`: `Store` struct with `layersPath`/`uploadsPath`, `New()`
  constructor, digest validation (`^sha256:[a-f0-9]{64}$`), content-addressed
  path layout: `<layers>/sha256/<first-2-hex>/<full-64-hex>`
- `writer.go`: `BlobWriter` wrapping `*os.File` + `crypto/sha256` running hash
  via `io.MultiWriter`. `StartUpload(uuid)` creates temp file in uploads dir.
  `Write()` updates both file and hash. `Commit(expectedDigest)` finalizes hash,
  verifies digest, `MkdirAll` prefix dir, `Rename` atomically. `Cancel()` cleans
  up temp file. `BytesWritten()` returns offset.
- `reader.go`: `Open(digest)` returns `io.ReadCloser`, `Stat(digest)` returns
  size, `Delete(digest)` removes blob + best-effort prefix dir cleanup,
  `Exists(digest)` returns bool. All validate digest format first.
- `errors.go`: `ErrBlobNotFound`, `ErrDigestMismatch`, `ErrInvalidDigest`
- No new dependencies (stdlib only)

Phase 3 — `internal/auth/` (Steps 3.1) + `internal/server/` (Steps 3.2–3.4):
- `auth/client.go`: `Client` with `NewClient(serverURL, caCert, serviceName,
  tags)`, TLS 1.3 minimum, optional custom CA cert, 10s HTTP timeout.
  `Login()` POSTs to MCIAS `/v1/auth/login`. `ValidateToken()` with SHA-256
  cache keying and 30s TTL.
- `auth/claims.go`: `Claims` struct (Subject, AccountType, Roles) with context
  helpers `ContextWithClaims`/`ClaimsFromContext`
- `auth/cache.go`: `validationCache` with `sync.RWMutex`, lazy eviction,
  injectable `now` function for testing
- `auth/errors.go`: `ErrUnauthorized`, `ErrMCIASUnavailable`
- `server/middleware.go`: `TokenValidator` interface, `RequireAuth` middleware
  (Bearer token extraction, `WWW-Authenticate` header, OCI error format)
- `server/token.go`: `LoginClient` interface, `TokenHandler` (Basic auth →
  bearer token exchange via MCIAS, RFC 3339 `issued_at`)
- `server/v2.go`: `V2Handler` returning 200 `{}`
- `server/routes.go`: `NewRouter` with chi: `/v2/token` (no auth),
  `/v2/` (RequireAuth middleware)
- `server/ocierror.go`: `writeOCIError()` helper for OCI error JSON format
- New dependency: `github.com/go-chi/chi/v5`

**Verification:**
- `make all` passes: vet clean, lint 0 issues, 52 tests passing
  (7 config + 13 db/audit + 14 storage + 9 auth + 9 server), all 3 binaries built
- Storage tests: new store, digest validation (3 valid + 9 invalid), path layout,
  write+commit, digest mismatch rejection (temp cleanup verified), cancel cleanup,
  bytes written tracking, concurrent writes to different UUIDs, open after write,
  stat, exists, delete (verify gone), open not found, invalid digest format
  (covers Open/Stat/Delete/Exists)
- Auth tests: cache put/get, TTL expiry with clock injection, concurrent cache
  access, login success/failure (httptest mock), validate success/revoked,
  cache hit (request counter), cache expiry (clock advance)
- Server tests: RequireAuth valid/missing/invalid token, token handler
  success/invalid creds/missing auth, routes integration (authenticated /v2/,
  unauthenticated /v2/ → 401, token endpoint bypasses auth)

---

### 2026-03-19 — Phase 1: Configuration & database

**Task:** Implement TOML config loading with env overrides and validation,
SQLite database with migrations, and audit log helpers.

**Changes:**

Step 1.1 — `internal/config/`:
- `config.go`: `Config` struct matching ARCHITECTURE.md §10 (all 6 TOML
  sections: server, database, storage, mcias, web, log)
- Parsed with `go-toml/v2`; env overrides via `MCR_` prefix using
  reflection-based struct walker
- Startup validation: 6 required fields checked (listen_addr, tls_cert,
  tls_key, database.path, storage.layers_path, mcias.server_url)
- Same-filesystem check for layers_path/uploads_path via device ID
  comparison (walks to nearest existing parent if path doesn't exist yet)
- Default values: read_timeout=30s, write_timeout=0, idle_timeout=120s,
  shutdown_timeout=60s, uploads_path derived from layers_path, log.level=info
- `device_linux.go`: Linux-specific `extractDeviceID` using `syscall.Stat_t`
- `deploy/examples/mcr.toml`: annotated example config

Step 1.2 — `internal/db/`:
- `db.go`: `Open(path)` creates/opens SQLite via `modernc.org/sqlite`,
  sets pragmas (WAL, foreign_keys, busy_timeout=5000), chmod 0600
- `migrate.go`: migration framework with `schema_migrations` tracking table;
  `Migrate()` applies pending migrations in transactions; `SchemaVersion()`
  reports current version
- Migration 000001: `repositories`, `manifests`, `tags`, `blobs`,
  `manifest_blobs`, `uploads` — all tables, constraints, and indexes per
  ARCHITECTURE.md §8
- Migration 000002: `policy_rules`, `audit_log` — tables and indexes per §8

Step 1.3 — `internal/db/`:
- `audit.go`: `WriteAuditEvent(eventType, actorID, repository, digest, ip,
  details)` with JSON-serialized details map; `ListAuditEvents(AuditFilter)`
  with filtering by event_type, actor_id, repository, time range, and
  offset/limit pagination (default 50, descending by event_time)
- `AuditFilter` struct with all filter fields
- `AuditEvent` struct with JSON tags for API serialization

Lint fix:
- `.golangci.yaml`: disabled `fieldalignment` analyzer in govet (micro-
  optimization that hurts struct readability; not a security/correctness
  concern)

**Verification:**
- `make all` passes: vet clean, lint 0 issues, 20 tests passing
  (7 config + 13 db/audit), all 3 binaries built
- Config tests: valid load, defaults applied, uploads_path default,
  5 missing-required-field cases, env override (string + duration),
  same-filesystem check
- DB tests: open+migrate, idempotent migrate, 9 tables verified,
  foreign key enforcement, tag cascade on manifest delete,
  manifest_blobs cascade (blob row preserved), WAL mode verified
- Audit tests: write+list, filter by type, filter by actor, filter by
  repository, pagination (3 pages), null fields handled

---

### 2026-03-19 — Phase 0: Project scaffolding

**Task:** Set up Go module, build system, linter config, and binary
entry points with cobra subcommands.

**Changes:**
- `go.mod`: module `git.wntrmute.dev/kyle/mcr`, Go 1.25, cobra dependency
- Directory skeleton: `cmd/mcrsrv/`, `cmd/mcr-web/`, `cmd/mcrctl/`,
  `internal/`, `proto/mcr/v1/`, `gen/mcr/v1/`, `web/templates/`,
  `web/static/`, `deploy/docker/`, `deploy/examples/`, `deploy/scripts/`,
  `deploy/systemd/`, `docs/`
- `.gitignore`: binaries, `srv/`, `*.db*`, IDE/OS files
- `Makefile`: standard targets (`all`, `build`, `test`, `vet`, `lint`,
  `proto`, `proto-lint`, `clean`, `docker`, `devserver`); `all` runs
  `vet → lint → test → mcrsrv mcr-web mcrctl`; `CGO_ENABLED=0` on binary
  builds; version injection via `-X main.version`
- `.golangci.yaml`: golangci-lint v2 config matching mc-proxy conventions;
  linters: errcheck, govet, ineffassign, unused, errorlint, gosec,
  staticcheck, revive; formatters: gofmt, goimports; gosec G101 excluded
  in test files
- `buf.yaml`: protobuf linting (STANDARD) and breaking change detection (FILE)
- `cmd/mcrsrv/main.go`: root command with `server`, `init`, `snapshot`
  subcommands (stubs returning "not implemented")
- `cmd/mcr-web/main.go`: root command with `server` subcommand (stub)
- `cmd/mcrctl/main.go`: root command with `status`, `repo` (list/delete),
  `gc` (trigger/status), `policy` (list/create/update/delete),
  `audit` (tail), `snapshot` subcommands (stubs)
- All binaries accept `--version` flag

**Verification:**
- `make all` passes: vet clean, lint 0 issues, test (no test files),
  all three binaries built successfully
- `./mcrsrv --version` → `mcrsrv version 3695581`
- `./mcr-web --version` → `mcr-web version 3695581`
- All stubs return "not implemented" error as expected
- `make clean` removes binaries

---

### 2026-03-19 — Project planning

**Task:** Create design documents and implementation plan.

**Changes:**
- `README.md`: Existing one-line description
- `ARCHITECTURE.md`: Full design specification covering OCI Distribution
  Spec compliance, MCIAS authentication, policy engine, storage design,
  API surface (OCI + admin REST + gRPC), database schema, garbage collection,
  configuration, web UI, CLI tools, deployment, security model
- `CLAUDE.md`: Development guidance for AI-assisted implementation
- `PROJECT_PLAN.md`: 14-phase implementation plan with discrete steps,
  acceptance criteria, dependency graph, and batchable work identification
- `PROGRESS.md`: This progress tracker

**Notes:**
- No code written yet. All files are documentation/planning.
- ARCHITECTURE.md reviewed and corrected for: GC algorithm crash safety,
  policy glob semantics, tag FK cascade, OCI error format, API sync
  violations, timeout configuration, backup considerations, and other
  consistency issues.
