# SPEC — OCI A1 Keystone Migration (BCDR + Distribution)

**Date:** 2026-05-28
**Author:** Cris (DonjonSec) + Claude
**Status:** Design — approved in conversation; awaiting written review
**Methodology:** SDD (Specify → Plan → Tasks → Implement)
**Home for this doc:** scanner repo `docs/plans/` for now (closely tied to scanner distribution + the Forgejo-truth reconciliation); may relocate to `donjonsec-infrastructure`.

---

## 1. Problem

Today the **home R630 is a single point of failure** for both the *public product surface* (git, release distribution, definition freshness) and the *private build factory* (GPU/AI, dev). The R630 is "randomly unavailable." Anything a buyer or installed customer depends on must survive a home outage. The keystone migration decouples the always-on public surface from the flaky home box.

## 2. The core move — invert the topology

The unreliable node (R630) is pushed to the **leaf** of the dependency graph; the reliable node (OCI A1, Always Free) becomes the **root** of everything public.

```
        PUBLIC (always-on, survives home outage)              PRIVATE (flaky, GPU, may be offline)
  ┌───────────────────────────────────────────────┐    ┌──────────────────────────────────────┐
  │  OCI A1  (4 OCPU / 24 GB ARM, Always Free)      │    │  R630 / CT100 (factory + GPU brain)    │
  │  • Forgejo (canonical git, Postgres backend)    │◄───┤  • Dev/factory commits → push to A1    │
  │  • Definition-update cadence (NVD/KEV/feeds)    │    │  • Ollama 30B inference (GPU)          │
  │  • Release-package build + checksums            │    │  • Heavy factory CI                    │
  │  • Minimal Forgejo CI runner (release jobs)     │    │  • AI *enrichment* of intel (optional, │
  │  • Distribution → OCI Object Storage (20 GB)    │    │    best-effort, pushed up when up)     │
  │      releases.donjonsec.com                     │    │  • Pulls a Forgejo MIRROR (A1 backup)  │
  └───────────────┬─────────────────────────────────┘    └──────────────────────────────────────┘
                  │ Cloudflare Tunnel + Access (zero inbound ports)
                  ▼
        Cloudflare ── forgejo.donjonsec.com / releases.donjonsec.com
        License-validation Worker  ← STAYS on Cloudflare (already hardened; never moves to A1)
```

**Asymmetry is the point:** the public side depends only on A1; **A1 depends on nothing at home.** R630 *pushes up*; the public product never *pulls from* R630.

## 3. Goals

- **G1** — Forgejo relocated to OCI A1 (Postgres backend), behind Cloudflare Tunnel + Access, as canonical git. CT106 becomes a hot pull-mirror (DR), not decommissioned.
- **G2** — Definition-update cadence + release packaging run on A1 (R630-independent); this activity also keeps the Always-Free instance from idle reclamation (no PAYG needed).
- **G3** — Distribution served from OCI Object Storage (`releases.donjonsec.com`), not nginx on the A1.
- **G4** — **Phase 0 truth reconciliation:** every product's live deployed tree is committed to Forgejo and verified byte-equal to what runs, *before* Forgejo is treated as source of truth. (PolicyPro done 2026-05-28, commit `34c1c90`; scanner AI work done, commit `db6416a`.)
- **G5** — BCDR proven by a **restore drill**, not assumed.
- **G6** — Maximum security/isolation: no secrets on the internet-facing A1; least-privilege Postgres; license signing key never on A1.

## 4. Non-Goals

- PAYG upgrade (deferred — the only thing it buys today is live-demo egress/burst; the def-cadence solves idle-reclamation). Trigger to revisit: a live demo is actually requested.
- Demos / AI inference on A1 (explicitly out — A1 has no GPU; AI stays home or is API-backed per product).
- Moving the license-validation Cloudflare Worker (stays on Cloudflare).
- Deleting CT106 Forgejo or any data (archive/mirror only).

## 5. Decisions

| # | Decision | Rationale |
|---|---|---|
| **D1** | Expose A1 via **Cloudflare Tunnel + Access** (zero-trust auth), not the public IP | You already run Tunnels; no inbound ports on the internet-facing box; hides IP; Access gates the git host so it isn't anonymously public |
| **D2** | Definition build = **data-only, on A1**; AI *enrichment* stays home, optional/best-effort | NVD/KEV/feed aggregation is CPU+network (fits A1, no GPU); freshness is a customer-facing P0 so the cadence MUST be R630-independent; GPU enrichment is additive, never a blocker |
| **D3** | License/release **signing private key never lives on A1** | A1 is internet-facing; signing stays on the Cloudflare Worker / home keystore (honors never-replace-keys); A1 only assembles bundles + checksums |
| **D4** | A1 runs a **minimal Forgejo CI runner** scoped to release/definition jobs | Customer-critical pipeline (fresh defs → packaged release → Object Storage) runs even when R630 is dark; heavy factory CI stays on R630 |
| **D5** | Forgejo on **Postgres** (not SQLite), least-privilege DB user | Source-of-truth keystone needs concurrent access, real backups, PITR; consistent with PolicyPro's `policypro_app` least-priv pattern |
| **D6** | **CT106 stays hot** as the permanent Forgejo pull-mirror / DR | Especially during/after cutover; decommission is a separate, later, explicitly-approved step |
| **D7** | GTM: storefront sells **one free (Community) + one paid (Pro)**; MSSP + Enterprise deferred (flagged off, code retained) | Narrows the launch to the constraint (first paying customer); MSSP is a different business; reduces audit/attack surface |

## 6. Migration sequence

```
Phase 0  Truth reconciliation (commit deployed→Forgejo, secret-scanned)  [DONE: PolicyPro 34c1c90, scanner db6416a]
   │
Phase 1  Provision A1 base: OS hardening, Cloudflare Tunnel connector, Podman/Docker, Postgres (least-priv)
   │
Phase 2  Stand up Forgejo on A1 (Postgres); restore from CT106 dump; VERIFY integrity vs live CT106 before cutover
   │
Phase 3  DNS cutover forgejo.donjonsec.com → A1 Tunnel + Access; CT106 → hot pull-mirror (topology inverted)
   │
Phase 4  Definition cadence on A1 (systemd timer: incremental NVD + KEV + feeds → bundle → checksum → Object Storage).
   │       Emits a TRUTHFUL freshness signal + watchdog (fixes the scanner audit's staleness-stub-lies-"ok" P0).
   │       This activity is also the keep-alive (no idle reclamation → no PAYG).
   │
Phase 5  Release packaging on A1 (Forgejo tag → minimal runner → airgap package + checksums → Object Storage)
   │
Phase 6  R630 reconfigured: factory pushes code to A1 Forgejo; pulls mirror for DR; AI enrichment pushed up when up
   │
Phase 7  RESTORE DRILL (BCDR proof): fail Forgejo + the def-cadence over to CT106/restore and prove it serves
   │
Phase 8  GitHub minimization (retire the GitHub mirror to a single private archive or drop it) — destructive, explicit approval + fine-grained token at point of use
```

## 7. Capacity & keep-alive (fits Always Free, no PAYG)

| Resource | Budget | Fit |
|---|---|---|
| Compute | 4 OCPU / 24 GB ARM | Forgejo (light) + periodic def-build + minimal runner — comfortable |
| Block storage | ≤ 200 GB Always Free | Forgejo repos + def-build scratch + bundle staging — fits |
| Object Storage | 20 GB + 10 TB egress/mo | Distribution offloaded here (not nginx on A1); egress plentiful |
| Idle reclamation | Oracle reclaims *idle* free compute after ~7 d | Def-cadence + Forgejo traffic = clearly active → **no reclamation, no PAYG** |

## 8. Security / isolation (don't be the cause of a breach)

- **No secrets on the public A1.** License/release **signing key off-A1** (D3); Forgejo's authenticated remotes / tokens scoped and stored in the host keystore, not in repos.
- **Forgejo behind Cloudflare Access** (zero-trust auth); private repos only; enforce 2FA; SSH-key auth, not passwords.
- **Postgres** least-privilege user per service; no shared superuser; backups encrypted.
- **Secret-scan gate before every Phase-0 commit** (no `.env`/dumps/keys into git history — the discipline that caught `Tracy328!`/`backups/`/`security-assessment/` in the PolicyPro commit).
- **Key custody is part of BCDR:** signing keys backed up off-A1 (offline + Cloudflare side); if R630 dies, signing/distribution must survive.

## 9. Risks — inverse thinking ("what makes this fail?")

| Risk | Mitigation |
|---|---|
| A1 becomes the new SPOF | CT106 hot mirror + Object Storage versioning (R630 backs up cloud); license validation independently on Cloudflare |
| Forgejo restore corrupts/loses history | Verify dump vs live CT106 *before* DNS cutover; keep CT106 hot until verified |
| Def-cadence silently fails (the audit's staleness-stub-lies-"ok") | Truthful freshness signal + watchdog as an acceptance criterion, not just a code fix |
| Secret sprawl onto the internet-facing box | D3 (no signing key on A1) + secret-scan gate + Cloudflare Access |
| **PolicyPro SaaS availability still = R630** | Open item — PolicyPro *runtime* is still on CT100; this migration makes its *repo/recoverability* survive, not its uptime. Near-term: container-portable + tested failover + honest uptime until revenue funds cloud hosting. (User decision pending; tracked, not silently assumed.) |
| Tunnel/token outage isolates A1 | Documented break-glass: temporary OCI security-list ingress rule |

## 10. Acceptance Criteria (Given/When/Then — all require real execution)

- **AC1 (truth).** *Given* a product behind the keystone, *when* its repo HEAD is checked out fresh, *then* it boots and matches what runs in production (no uncommitted divergence).
- **AC2 (Forgejo on A1).** *Given* the migrated Forgejo, *when* a dev pushes from R630, *then* it lands on A1 over the Tunnel, behind Access, on Postgres; CT106 mirrors it within its sync window.
- **AC3 (freshness honest).** *Given* the A1 def-cadence, *when* the freshness indicator is read, *then* it reports true data age; a watchdog flags a missed update within its window; a forced incremental update fetches genuinely fresh CVEs.
- **AC4 (distribution).** *Given* a Forgejo release tag, *when* the A1 runner packages it, *then* a signed-checksum airgap bundle appears in Object Storage at `releases.donjonsec.com`, downloadable with R630 offline.
- **AC5 (keep-alive).** *Given* the def-cadence running, *when* 7+ idle-days would otherwise elapse, *then* instance activity is non-idle and not reclaimed (no PAYG).
- **AC6 (BCDR drill).** *Given* the hot CT106 mirror, *when* Forgejo + the def-cadence are failed over in a drill, *then* the public surface still serves and the drill is documented.
- **AC7 (zero egress on A1 secrets).** *Given* the internet-facing A1, *when* its filesystem + config are audited, *then* no license signing private key and no plaintext product secrets are present.

## 11. Artifacts (under `docs/plans/2026-05-28-oci-keystone-migration/`)

| File | Purpose |
|---|---|
| `SPEC.md` | this document |
| `PLAN.md` | technical plan (writing-plans output) — next |
| `TASKS.md` | atomic, one-session tasks |
| `decision-log.md` | numbered decisions w/ rationale |
| `RESTORE-DRILL.md` | BCDR drill procedure + evidence |
