# Plan v0.12: Security Hardening (Zero-Trust Identity)

**Status**: Phase 0 plan. 29 phases (P0 + P01..P27 + P28 final). Wave
ordering, persona assignments, and binding conditions. GRILL will
pressure-test and may split/merge.

## Milestone identity

- **Label**: `v0.12-security-hardening`
- **Type**: feature (P04, P05 ship `feat`)
- **Tag line**: v0.11.x patches (`v0.11.0`..`v0.11.28`)
- **Final phase patch** = milestone release = `v0.11.28` (no separate `v0.12.0`)
- **Branch**: `milestone/v0.12-security-hardening`
- **v1.0.0**: deferred for post-v0.12 UAT (per v0.11 PRD)

## Wave ordering

Waves are dependency-ordered. Within a wave, phases run in sequence
(parallelization disabled per config.json `parallelization.enabled=false`).

### Phase 0 — Pre-execution (all personas, lead-developer coordinates)

Stages: SPECIFY -> CLARIFY -> RESEARCH -> IDEATE -> PLAN -> GRILL -> SHIP.

- SPECIFY: v0.12 in config.json + PROJECT.md (done).
- CLARIFY: D-238..D-247 (done, CLARIFY_v0.12.md).
- RESEARCH: threat model F1..F25 + zero-trust identity model (done,
  RESEARCH_v0.12.md). Resolves RQ-1 (WebAuthn as password-free upstream).
- IDEATE: 30 ideas accepted -> REQ-119..REQ-148 (done, IDEATION_v0.12.md).
- PLAN: this document.
- GRILL: ratify C-29..C-38, split/merge as needed.
- SHIP: tag `v0.11.0`.

**Commit**: `docs(P00): v0.12 security-hardening phase 0 (specify/clarify/research/ideate/plan/grill)`

### Wave A — Critical injection & traversal (backend-engineer)

Vertical slice: stop the bleeding first. Three independent fixes, no
inter-dependencies.

#### P01 — Command injection (podman/wasm) — REQ-119, F3

- **Persona**: backend-engineer (territory: `internal/runtime/`)
- **Tasks**:
  1. Add `shellQuote` helper (or use `golang.org/x/crypto/ssh`-safe quoting) to `internal/runtime/`.
  2. Fix `podman.go:57`: `fmt.Sprintf("podman run -d --name %s %q %s", name, image, shellQuote(cmdStr))`.
  3. Fix `wasm.go:39`: same pattern for `wasmtime run`.
  4. Add Go regression tests: `;`, `|`, `$()`, backticks, newline, `$IFS`, `<>()` injection attempts.
  5. Add bats test: a jobspec with a malicious command runs the literal command, not the injected shell.
- **Must-haves**: all injection tests pass; existing podman/wasm tests still pass.
- **Commit**: `fix(P01): command injection in podman/wasm runtimes (REQ-119, F3)`
- **Tag**: `v0.11.1`

#### P02 — Namespace path traversal — REQ-120, F4

- **Persona**: backend-engineer (territory: `internal/ns/`, `internal/cli/ns.go`)
- **Tasks**:
  1. Add `validateNamespaceName(name)` to `internal/ns/`: reject `..`, `/`, leading `-`, null bytes, control chars, empty, length > 128.
  2. Wire into `ns create`, `ns inherit`, `ns set-constraint`, and any path-accepting ns command.
  3. Add Go fuzz test (`FuzzValidateNamespaceName`).
  4. Add regression test: `orca ns create "../../etc"` fails with a clear error.
- **Must-haves**: fuzz test passes 10k iterations; `..`/`/`/null rejected.
- **Commit**: `fix(P02): namespace path traversal (REQ-120, F4)`
- **Tag**: `v0.11.2`

#### P03 — Txn apply path allowlist — REQ-121, F5

- **Persona**: backend-engineer (territory: `internal/txn/`)
- **Tasks**:
  1. In `txn.go:renderApplyScript`, add path validation to the python heredoc: every `path` in `desired-state.json` must match a prefix in the allowlist (`/etc/orca/`, `/etc/traefik/orca*`, `/etc/systemd/system/orca-*`, `/etc/nftables.d/orca*`, `/etc/syncthing/orca*`).
  2. Reject with a clear error + exit code on mismatch.
  3. Add Go test: a desired-state with `"path": "/etc/shadow"` is rejected.
  4. Add bats test: `orca-pull.sh` with a crafted manifest refuses.
- **Must-haves**: arbitrary-path writes rejected; legitimate paths still apply.
- **Commit**: `fix(P03): txn apply path allowlist (REQ-121, F5)`
- **Tag**: `v0.11.3`

### Wave B — Zero-trust identity (backend-engineer + lead-developer)

The architectural foundation. P04/P05 are `feat` phases; P06/P07/P08
are `fix`/`refactor` that depend on them.

#### P04 — OIDC client + bundled Dex — REQ-144

- **Persona**: backend-engineer (territory: `internal/cli/`, `internal/identity/`)
- **Tasks**:
  1. Add `github.com/coreos/go-oidc/v3` dependency.
  2. `internal/identity/oidc.go`: OIDC client (provider discovery, JWKS cache + refresh, ID token verification, token storage at `~/.orca/credentials.json` 0600).
  3. `orca auth login`/`logout`/`status` CLI: browser auth-code + PKCE + local loopback redirect (`127.0.0.1:<port>/callback`); headless device-code fallback.
  4. `orca auth init-idp`: bootstrap bundled Dex (systemd unit + config template + Traefik route) on the lead; `--rp-id <domain>` config.
  5. OIDC config block in `internal/config/`: `oidc.issuer`, `client_id`, `client_secret`, `scopes`.
  6. BYO external IdP override: `oidc.issuer` repoint bypasses bundled Dex.
  7. Go tests: mock OIDC provider, JWKS rotation, token refresh, login/logout flow.
- **Must-haves**: `orca auth login` produces a valid ID token; `orca auth status` shows it; `--oidc` flag gated; offline Dex quickstart doc'd.
- **Commit**: `feat(P04): OIDC client + bundled Dex (REQ-144, D-239, D-242)`
- **Tag**: `v0.11.4`

#### P05 — WebAuthn connector for Dex — REQ-148

- **Persona**: backend-engineer (territory: `internal/identity/`, new `internal/webauthn/`)
- **Tasks**:
  1. Add `github.com/go-webauthn/webauthn` dependency.
  2. `internal/webauthn/connector.go`: Dex connector (~300 LoC) -- registration + login ceremonies at `/orca/webauthn/{register,login}`.
  3. `internal/webauthn/store.go`: passkey storage SQLite at `ClusterDir()/webauthn-credentials.db` (0600); schema: `credentials(user_id, credential_id, public_key, sign_count, aaguid, created_at)`.
  4. `orca auth register` CLI: browser flow to register a new passkey.
  5. RP ID = cluster Traefik domain (from `orca auth init-idp --rp-id`); secure context via step-ca cert (R-017).
  6. Go tests using `go-webauthn` virtual-authenticator test helpers (no hardware key).
- **Must-haves**: register + login flow works end-to-end against the bundled Dex; public keys only stored; virtual-authenticator tests pass.
- **Commit**: `feat(P05): WebAuthn connector for Dex (REQ-148, D-240, C-38)`
- **Tag**: `v0.11.5`

#### P06 — ACL rewrite to OIDC claims + enforcement — REQ-145, REQ-122, F1

- **Persona**: backend-engineer (territory: `internal/acl/`, `internal/daemon/`, `internal/sshpush/`)
- **Tasks**:
  1. Remove `KindToken` from `internal/acl/acl.go` entirely.
  2. Add `KindOidc`: maps `sub` + `groups` -> namespace permissions.
  3. `acl.Check` takes an OIDC claims struct (or SPIFFE SVID for machine identity).
  4. Wire `acl.Check` into daemon handlers (read/write/admin by route).
  5. Wire `acl.Check` into SSH-push applier: validate `ORCA_OIDC_TOKEN` env var against JWKS before applying any txn.
  6. `acl.json` file mode tightened to 0600.
  7. Deny-by-default enforced; actor recorded in audit log.
  8. Go tests: ACL-negative (unauthorized sub denied), ACL-positive, machine identity (SVID) still works.
- **Must-haves**: no request applies without a valid OIDC token or SVID; `KindToken` removed; deny-by-default enforced.
- **Commit**: `fix(P06): ACL rewrite to OIDC claims + enforcement (REQ-145, REQ-122, F1)`
- **Tag**: `v0.11.6`

#### P07 — Remove all password/token paths — REQ-146, R-021, C-34

- **Persona**: lead-developer (territory: `internal/proxmox/`, `internal/stepca/`, `internal/cli/`)
- **Tasks**:
  1. Remove `--password`/`$ORCA_PROXMOX_PASSWORD` from Proxmox join (`proxmox/bootstrap.go:29`); replace with pre-staged-key-only or `step ssh` OIDC cert exchange.
  2. Remove step-ca `--password-file` provisioner; migrate to OIDC provisioner (step-ca natively supports OIDC).
  3. Remove any bare-token CLI paths (already removed in P06, but sweep for stragglers).
  4. Add deprecation/migration docs: `--accept-identity-migration` flag on `orca upgrade` (P22 enforces).
  5. Go tests: `--password` flag is rejected with a clear error pointing to the migration guide.
- **Must-haves**: no password accepted anywhere; `--password` rejected; step-ca OIDC provisioner works.
- **Commit**: `fix(P07): remove all password/token paths (REQ-146, R-021, C-34) -- BREAKING`
- **Tag**: `v0.11.7`

#### P08 — Master key seal-to-OIDC + Shamir — REQ-147, D-241, C-35

- **Persona**: backend-engineer (territory: `internal/secrets/`, new `internal/seal/`)
- **Tasks**:
  1. `internal/seal/seal.go`: seal/unseal using HKDF-SHA256 of OIDC ID token `sub` + fresh 32-byte salt; sealed blob at `ClusterDir()/master.key.sealed` (0600).
  2. Shamir 3-of-5: `internal/seal/shamir.go` (using `golang.org/x/crypto/...` or a vendored Shamir impl); print 5 shards at seal time.
  3. `orca cluster unseal`/`seal` CLI: unseal via OIDC auth; `--recovery` + 3 shards for IdP-lost case.
  4. mTLS-only offline path: seal key derived from cluster CA.
  5. Master key zeroed on shutdown (use `memguard` or manual `crypto/rand` overwrite).
  6. Go tests: seal -> unseal round-trip; recovery with 3 shards; 2 shards fails; raw key never on disk (assert no `master.key` file, only `master.key.sealed`).
- **Must-haves**: raw master key never touches disk; unseal works via OIDC; recovery works with 3-of-5 shards.
- **Commit**: `feat(P08): master key seal-to-OIDC + Shamir 3-of-5 (REQ-147, D-241, C-35)`
- **Tag**: `v0.11.8`

### Wave C — Auth & integrity (backend-engineer + data-engineer)

#### P09 — Daemon auth hardening — REQ-123, REQ-124, F6, F24

- **Persona**: backend-engineer (territory: `internal/daemon/`)
- **Tasks**:
  1. Remove plaintext mode entirely (mandatory mTLS).
  2. Accept OIDC bearer as second factor on human-facing endpoints.
  3. `http.MaxBytesReader` on all JSON-decoding handlers; `MaxHeaderBytes` set.
  4. pprof loopback-only by default; `--pprof-allow-public` requires confirmation.
  5. Go tests: plaintext mode rejected; oversized body rejected; pprof non-loopback rejected.
- **Commit**: `fix(P09): daemon auth hardening (REQ-123, REQ-124, F6, F24)`
- **Tag**: `v0.11.9`

#### P10 — Audit log tamper-evidence — REQ-125, F2

- **Persona**: data-engineer (territory: `internal/audit/`, `internal/store/`)
- **Tasks**:
  1. Add `prev_hash` + `entry_hash` columns to `audit_log` table (migration 0008).
  2. `AuditRepo.Append` computes `entry_hash = sha256(prev_hash || payload)`, stores it; HMAC-SHA256 under master key on the chain head (stored separately).
  3. SQLite trigger blocks UPDATE/DELETE on `audit_log`.
  4. `orca doctor audit` verifies the chain (recomputes hashes, checks HMAC).
  5. Actor field carries OIDC `sub` or SPIFFE SVID.
  6. Go tests: tamper detection (modify a row -> doctor fails); append-only enforcement (DELETE fails).
- **Commit**: `fix(P10): audit log tamper-evidence (REQ-125, F2)`
- **Tag**: `v0.11.10`

#### P11 — SVID chain validation — REQ-126, F9

- **Persona**: backend-engineer (territory: `internal/identity/`)
- **Tasks**:
  1. `VerifySVID` loads the CA pool (from `ClusterDir()/ca.crt` or step-ca root) and validates the full cert chain.
  2. Reject certs signed by unknown CAs even with correct URI SAN.
  3. Go tests: cert from wrong CA rejected; cert from correct CA + correct URI accepted; expired cert rejected.
- **Commit**: `fix(P11): SVID chain validation (REQ-126, F9)`
- **Tag**: `v0.11.11`

#### P12 — Backup symlink validation — REQ-127, F7

- **Persona**: data-engineer (territory: `internal/backup/`)
- **Tasks**:
  1. `Restore` rejects `Linkname` that's absolute, contains `..`, or points outside `ORCA_HOME`.
  2. Regression test with crafted tarball containing a symlink to `/etc/shadow`.
- **Commit**: `fix(P12): backup symlink validation (REQ-127, F7)`
- **Tag**: `v0.11.12`

### Wave D — Crypto & secrets (backend-engineer)

#### P13 — step-ca /tmp hardening — REQ-128, F10

- **Persona**: backend-engineer (territory: `internal/stepca/`, `internal/identity/`)
- **Tasks**:
  1. `step ca certificate` writes to `0600` temp under `ClusterDir()/step-tmp/` (or `TMPDIR` override).
  2. Cleanup in `defer`; `mkdir -p` with 0700 on the temp dir.
  3. Go test: assert temp file mode is 0600; assert cleanup on success + failure.
- **Commit**: `fix(P13): step-ca /tmp hardening (REQ-128, F10)`
- **Tag**: `v0.11.13`

#### P14 — Master key rotation — REQ-129, F12, C-30

- **Persona**: backend-engineer (territory: `internal/secrets/`, `internal/seal/`)
- **Tasks**:
  1. `orca secrets rotate-master`: generate new master key, re-encrypt all namespace secrets, re-seal to OIDC.
  2. `--dry-run` reports affected namespaces without writing.
  3. Atomic per-namespace re-encryption; auto-rollback to old sealed key on any ns failure.
  4. Go tests: rotation succeeds; partial failure rolls back; dry-run doesn't write.
- **Commit**: `fix(P14): master key rotation (REQ-129, F12, C-30)`
- **Tag**: `v0.11.14`

#### P15 — File-mode audit expansion — REQ-130, F13

- **Persona**: backend-engineer (territory: `internal/security/`, `internal/cli/doctor.go`)
- **Tasks**:
  1. `EnforceFileModes` extended to SSH key, master key (sealed blob), server cert/key, known_hosts.
  2. `orca doctor modes` checks all.
  3. Startup refuses to run on violation.
  4. Go tests: looser mode -> doctor fails + startup refuses.
- **Commit**: `fix(P15): file-mode audit expansion (REQ-130, F13)`
- **Tag**: `v0.11.15`

### Wave E — OS scripts & emitters (backend-engineer + lead-developer)

#### P16 — aggregate.sh JSON injection + drift-gate fix — REQ-131, F11, F18

- **Persona**: lead-developer (territory: `scripts/`)
- **Tasks**:
  1. Replace `printf` interpolation in `orca-aggregate.sh:64` with `jq`-based JSON construction (or a Go-side aggregator emitting JSON).
  2. Fix `orca-pull.sh` R-020 parsing to use `jq` instead of grep.
  3. Bats tests: malicious peer output doesn't corrupt `cluster.json`; drift gate correctly excludes acknowledged drift.
- **Commit**: `fix(P16): aggregate.sh JSON injection + drift-gate fix (REQ-131, F11, F18)`
- **Tag**: `v0.11.16`

#### P17 — install.sh checksum+GPG verification — REQ-132, F14

- **Persona**: lead-developer (territory: `scripts/install.sh`, `scripts/release.sh`)
- **Tasks**:
  1. `release.sh` publishes `SHA256SUMS` + `SHA256SUMS.asc` (GPG-signed) alongside the tarball.
  2. `install.sh` verifies SHA256 + GPG signature before `tar -xzf`; fail closed on mismatch.
  3. `--no-verify` escape hatch (documented, warns).
  4. Bats tests: tampered tarball rejected; valid tarball accepted.
- **Commit**: `fix(P17): install.sh checksum+GPG verification (REQ-132, F14)`
- **Tag**: `v0.11.17`

#### P18 — nftables ruleset hardening — REQ-133, F21

- **Persona**: backend-engineer (territory: `internal/emitter/nft.go`)
- **Tasks**:
  1. Add conntrack bounds (`ct state established,related accept`).
  2. Input default-deny on the orca chain.
  3. Drop invalid packets (`ct state invalid drop`).
  4. `orca doctor nft` audits live ruleset against emitted one.
  5. Go tests: emitted ruleset contains the new rules; doctor detects drift.
- **Commit**: `fix(P18): nftables ruleset hardening (REQ-133, F21)`
- **Tag**: `v0.11.18`

#### P19 — sudoers hardening — REQ-134, F22

- **Persona**: backend-engineer (territory: `internal/proxmox/bootstrap.go`)
- **Tasks**:
  1. Add NOEXEC to `apt-get`/`dpkg` in the OrcaOperator sudoers (or remove if unused).
  2. `orca doctor proxmox` audits the sudoers file against the expected allowlist.
  3. Go tests: emitted sudoers has NOEXEC; doctor detects drift.
- **Commit**: `fix(P19): sudoers hardening (REQ-134, F22)`
- **Tag**: `v0.11.19`

#### P20 — System user consistency — REQ-135, F23

- **Persona**: backend-engineer (territory: `internal/proxmox/bootstrap.go`, `internal/cli/peer_setup.go`)
- **Tasks**:
  1. Proxmox bootstrap creates `nologin` system user (`-r -s /usr/sbin/nologin`), matching peer-setup.
  2. `orca doctor` flags inconsistency on existing peers.
  3. `orca upgrade` migrates existing `-m -s /bin/bash` users to `-r -s /usr/sbin/nologin`.
  4. Go tests: emitted useradd matches; doctor detects the old style.
- **Commit**: `fix(P20): system user consistency (REQ-135, F23)`
- **Tag**: `v0.11.20`

### Wave F — State storage & migration (data-engineer)

#### P21 — SQLite file-mode + at-rest encryption — REQ-136, F8, C-31

- **Persona**: data-engineer (territory: `internal/store/`)
- **Tasks**:
  1. `store.Open` sets DB file mode 0600 (via `os.Chmod` after open, since SQLite creates with umask).
  2. Evaluate SQLCipher envelope (CGO-free check). If infeasible without CGO (breaks D-008), fall back to file-mode 0600 + documented threat per C-31.
  3. Document the decision in RESEARCH/PROJECT.
  4. Go tests: DB file mode is 0600 after open.
- **Commit**: `fix(P21): SQLite file-mode + at-rest encryption (REQ-136, F8, C-31)`
- **Tag**: `v0.11.21`

#### P22 — Migration safety + identity migration — REQ-137, F19, C-34

- **Persona**: data-engineer (territory: `internal/migration/`, `internal/cli/upgrade.go`)
- **Tasks**:
  1. `copyFile` -> atomic temp+rename.
  2. `migrateDBSchema` runs in a transaction with `foreign_keys(ON)`.
  3. Pre-migration backup step (uses `internal/backup`).
  4. Document manual rollback (restore from backup).
  5. `orca upgrade` refuses v0.11 clusters using `--password`/bare-tokens without `--accept-identity-migration` (C-34).
  6. Go tests: migration is atomic; partial failure rolls back; `--accept-identity-migration` gate works.
- **Commit**: `fix(P22): migration safety + identity migration (REQ-137, F19, C-34)`
- **Tag**: `v0.11.22`

### Wave G — Dual-write closure (lead-developer, gated by C-29)

#### P23 — Legacy CA/mTLS/daemon + step-ca password-provisioner deletion — REQ-138, F16

- **Persona**: lead-developer (territory: `internal/security/ca.go`, `internal/transport/mtls.go`, `internal/daemon/`, `internal/stepca/`, `internal/certpaths/`)
- **Pre-gate (C-29)**: P06, P08, P09, P11 must all be shipped.
- **Tasks**:
  1. Remove `internal/security/ca.go` legacy CA; migrate `orca init` and `orca cert *` to step-ca exclusively.
  2. Remove `internal/transport/mtls.go` deprecated path.
  3. Remove daemon plaintext mode (already killed in P09, but delete the code path).
  4. Remove `internal/certpaths/` (v0.8 flat layout); `internal/paths/` is the only layout.
  5. Delete step-ca `--password-file` provisioner (already replaced by OIDC provisioner in P07).
  6. Full test suite must pass after deletion.
- **Must-haves**: `orca init` + `orca cert *` work via step-ca only; no legacy code compiled.
- **Commit**: `refactor(P23): delete legacy CA/mTLS/daemon + step-ca password-provisioner (REQ-138, F16, C-29)`
- **Tag**: `v0.11.23`

### Wave H — Defense-in-depth (backend-engineer)

#### P24 — known_hosts tightening + transport hardening — REQ-139, F15, F25

- **Persona**: backend-engineer (territory: `internal/security/flock.go`, `internal/sshpush/`)
- **Tasks**:
  1. `Flock` tightens pre-existing looser perms to 0600 (chmod after open if looser).
  2. `classifyDialErr` switched from substring to typed errors (use `*ssh.ExitError`, `net.Error` type assertions).
  3. Add SSH-exec rate limiting (token bucket per peer, default 10 req/s).
  4. Go tests: looser perms tightened; typed errors classified correctly; rate limit enforced.
- **Commit**: `fix(P24): known_hosts tightening + transport hardening (REQ-139, F15, F25)`
- **Tag**: `v0.11.24`

#### P25 — Drift event authentication — REQ-140, F18

- **Persona**: backend-engineer (territory: `internal/drift/`, `scripts/orca-drift-notify.sh`)
- **Tasks**:
  1. Per-peer HMAC key (derived from master key via HKDF); deployed to peers at `0600` owned by `orca`.
  2. `orca-drift-notify.sh` signs each event with the HMAC; aggregator rejects unsigned/forged events.
  3. Go tests: forged event rejected; valid event accepted.
- **Commit**: `fix(P25): drift event authentication (REQ-140, F18)`
- **Tag**: `v0.11.25`

#### P26 — Security integration test suite — REQ-141, C-33

- **Persona**: backend-engineer (territory: `tests/`)
- **Tasks**:
  1. Hermetic harness exercising: injection (P01), traversal (P02), symlink (P12), drift-forgery (P25), audit-tamper (P10), daemon-auth-negative (P09), OIDC mock-IdP flow (P04), ACL-with-OIDC-claims negative (P06), unseal/seal (P08), WebAuthn virtual-authenticator ceremony (P05), password-removal regression (P07 -- assert `--password` rejected).
  2. Gates in `.coreci.yml` `validate` pipeline (C-33).
  3. Bats + Go test runner.
- **Commit**: `test(P26): security integration test suite (REQ-141, C-33)`
- **Tag**: `v0.11.26`

### Wave I — Documentation & release (lead-developer)

#### P27 — Zero-trust + OIDC + WebAuthn + threat-model docs — REQ-142

- **Persona**: lead-developer (territory: `docs/`, `README.md`)
- **Tasks**:
  1. `docs/threat-model.md`: STRIDE per component, zero-trust model, OIDC data-flow diagram, OS surface diagram, residual risk register.
  2. `docs/oidc.md`: configure your IdP, bundled Dex offline quickstart, claim-to-namespace mapping, BYO-IdP override.
  3. `docs/webauthn.md`: passkey registration, RP ID, secure context, recovery flow.
  4. `docs/security-runbook.md`: unseal/seal, master key rotation, incident response, sudoers audit, nft audit, Shamir recovery.
  5. README security section names "no orca credentials" as an invariant (R-021).
- **Commit**: `docs(P27): zero-trust + OIDC + WebAuthn + threat-model docs (REQ-142)`
- **Tag**: `v0.11.27`

#### P28 — Final review + ship + audit — REQ-143

- **Persona**: lead-developer (coordinates)
- **Tasks**:
  1. `ciagent-review` multi-persona review across all phases.
  2. `ciagent-audit` reconstruction test (git log matches `.ciagent/` files).
  3. C-32 human-gate: confirm GITEA_TOKEN rotated + `.env` re-seeded (escalation hook if pending).
  4. Merge `phase/28` -> `milestone/v0.12-security-hardening`.
  5. Merge `milestone/v0.12-security-hardening` -> `main` (rebase-then-fast-forward).
  6. Tag `v0.11.28` (= v0.12 milestone release per feature-milestone rule).
  7. Create Gitea release with full milestone summary.
  8. Delete milestone + phase branches (tags preserve history).
  9. Update REQUIREMENTS.md (mark all v0.12 REQs complete) + ROADMAP.md (mark v0.12 complete).
- **Commit**: `docs(milestone): complete v0.12 -- Security Hardening (Zero-Trust Identity) (29 phases shipped)`
- **Tag**: `v0.11.28`
