company_dns

V4 deployment: image, secrets, environments, and the on-prem cluster

Status: Planned (2026-09-30); sequencing and data decisions added (2026-10-04). Consolidates and supersedes v4-container-packaging.md and the forward-looking parts of onprem-k8s-migration.md (that doc’s own Azure→K8s migration history for V3 stays in place, unchanged — see §0). Grounded in real measurements (binary size, data size) and a real, audited live-cluster survey, not assumed capacity or a clean-slate design. Owner: michael.hay@mediumroast.io Scope: everything about getting a V4 change from source to a running, correctly-sized, correctly-secured, environment-appropriate deployment on the existing on-prem microk8s cluster — the container image itself (size, secrets, data bundling), and the dev → staging → prod environment pipeline that verifies a build before it reaches production, including the specific platform-parity gap that motivated adding real environments in the first place.


0. Why this doc, and why it supersedes two others

Three pieces of related work happened separately in this session and are being merged here, on request, into one canonical source:

  1. Container packaging — raised while designing v4-security-hardening.md §3.5’s rolling shared secret: “we can look at something like docker secrets to keep the secret sealed… the rust binary appears to be almost 200MB in size… we should consider how we bundle in the feather files.” Audited into two independent problems (§1.1/§1.2), a secret-injection design that deliberately does not do what was initially suggested (§2), and a binary-size reduction plan (§3.3).
  2. Deployment environments — raised immediately after v4-dynamic-log-level.md’s spike flagged an open gap: its reload design was proven on macOS, not on the real Linux/microk8s target, and “this is a point we need to add to the deployment plan as a deliberate step for verification… flesh out the dev → staging → prod deployment methodology… add tests that account for that… an intermediate step will be to simply run the image in plain docker on Ubuntu which suggests our image builds must be multiplatform.”
  3. A process correction: a first pass at item 2 was written as a third new doc (v4-deployment-environments.md) without checking whether it belonged in one of the two existing related docs instead. Caught directly — “Wait don’t we already have a plan for build/deployment?” — and the explicit resolution: consume both existing docs plus the new content into one file, and mark the originals as superseded rather than deleting them.

What “superseded” means here, precisely: v4-container-packaging.md is fully absorbed — nothing forward-looking is left there that isn’t also here. onprem-k8s-migration.md is partially absorbed — its reusable facts (live cluster survey: nodes, namespaces, ingress, TLS, storage, SealedSecrets — §1.3 below) and its proven manifest pattern (§3.8) are carried forward here, but its actual migration narrative (the Azure Container App decommissioning: DNS cutover steps, the two real bugs hit during that migration, the teardown plan, the rollback plan) is V3-specific history that already concluded — “Migration complete” — and stays exactly where it is, untouched, as the historical record of how that migration actually happened. Nothing in this doc asks anyone to go re-read that narrative to understand V4’s deployment; both older docs get a status-line pointer here instead.

0.1 Where this is in the sequence (decided 2026-10-04)

The goal before any deployment is to get the major functions operable. The order of work:

  1. Make the major functions operable — in progress. The data side was settled on 2026-10-04 (§3.2): one data-directory variable, a rebuilt EDGAR catalog, the SIC files staged by hand.
  2. Thin the binary (§3.3, spike §4.1–4.3).
  3. Package — phase 1: a single Docker image, delivered multi-platform (linux/amd64 and linux/arm64), run first with plain docker run (§3.5 step 2).
  4. Phase 2: stage it at company-dns-staging.mediumroast.io on the microk8s cluster, to check stability and functionality before anything is promoted to production (§3.4–3.6).

Running alongside all of it: a robust API test suite, built by extending the existing Python performance suite (perf_tests/, §3.9). It is the check that runs against the image in phase 1 and at the staging gate in phase 2.

The dev tier (company-dns-dev, §3.4) is not part of phases 1–2. It stays in the design as written and is scheduled only if it is wanted later.

1. Current state (audited)

1.1 Binary size — measured, not assumed

1.2 Feather/data-file size — measured, and small

1.3 The live on-prem cluster (from onprem-k8s-migration.md’s survey, carried forward)

Run 2026-09-25/26 against the live cluster, re-confirmed live during the rate-limiting incident investigation (kubectl get pods -o wide showing the real node names below):

1.4 Existing container/CI state

1.5 The concrete gap that motivated adding real environments

experiments/dynamic-log-level-spike/ proved its ConfigMap-mount reload design (notify watching the parent directory, plus a polling fallback) live — on macOS. The real target is amd64 Ubuntu 24.04 under microk8s (a different filesystem-event backend: inotify, not FSEvents, with its own different edge cases around renames). Nothing about that design is assumed broken on Linux — the parent-directory watch technique is a generally-correct pattern chosen specifically to sidestep most inotify rename gotchas too — but “sound design, proven on a different platform” and “confirmed working on the actual target” are different claims, and this doc exists so the second one always gets checked before something like it reaches production, not just this one case.

2. The secret-injection mismatch worth flagging

Two genuinely different things both get called “secret” across the requests that opened the container-packaging half of this doc, and treating them as one would undo a decision v4-security-hardening.md §3.5 already made deliberately.

Build-time secrets (BuildKit --secret / “Docker secrets” in the sense originally raised; docker/build-push-action’s secrets: input): mounted into the build container’s ephemeral filesystem only for the RUN step that needs them, and — the actual property that makes them worth using — never written into any image layer or the image’s layer history. Right tool for a secret genuinely needed only during docker build — e.g. a private crate-registry auth token, or credentials to fetch SIC feather data from an internal source as part of the build if §3.2 lands on that option.

Runtime secrets — specifically MEDIUMROAST_SHARED_SECRET, which v4-security-hardening.md §3.5 already designed: the running server process reads it at startup and recomputes an hourly rolling HMAC against it on every incoming request. Not a build-time need. Baking it into the image via a build-time secret mechanism would be a regression from what §3.5 already decided: (1) rotation would need a rebuild+redeploy instead of just updating a K8s Secret and rolling pods; (2) a built image is pulled by more environments and cached more widely than a deploy-time secret store, so baking a secret into every image pull widens exposure; (3) it conflates the build artifact (should be identical across dev/staging/prod — §3.4) with environment-specific config (the secret differs per environment) — the same reasoning that already keeps SIC_DATA_PATH/EDGAR_CATALOG_PATH/ PORT as runtime env vars rather than baked in.

Recommendation, unchanged: keep MEDIUMROAST_SHARED_SECRET a runtime env var sourced from each environment’s own K8s Secret (distinct per tier — §3.4), never touched by the image build. Reserve BuildKit --secret for a genuine build-time need, if one turns out to exist once §3.2 is decided.

Addendum (2026-10-05, SQL endpoint, v4-sql-endpoint.md 5a/5b): runtime secrets are no longer a single value. The experimental SQL endpoint needs several credentialed parties, so the single secret becomes one profiles files: credentials (COMPANY_DNS_CREDENTIALS_FILE, profile:sha256 lines like /etc/passwd, the only secret) and optional rules (COMPANY_DNS_RULES_FILE, JSON, no secrets: defaults plus per-profile overrides) for profiles (mediumroast.io, partners, an open-source operator’s own), each with a token hash and per-feature grants (rate-limit treatment, SQL access and limits). It is a general mechanism for V4, not an SQL-only one. It follows the same rule as above: a runtime secret, never baked into the image, never in the repo, one per tier. It is delivered as a mounted file, which fits a SealedSecret mounted as a volume on the cluster and a Docker secret (/run/secrets/...) for plain-Docker runs, and a gitignored file for local development. (A general <NAME>_FILE convention for secret env vars was considered and dropped 2026-10-06: the only secret is the credentials file, which is already a mounted file, and no secret env var remains.) The credentials file holds token hashes, clients authenticate with HTTP Basic Auth (v4-sql-endpoint.md 5a), and the old MEDIUMROAST_SHARED_SECRET is removed from the code (2026-10-05), replaced by the mediumroast.io profile in phase 2 (nothing depended on it). The remaining mentions of that secret in this document (§1.3, the §2 recommendation, §3.4, step 8) describe the earlier design and are superseded: provision the credentials file (and the rules ConfigMap) instead.

3. Design

3.1 Build-time secrets, for whichever actual build-time need exists

Pattern, following the existing workflow’s own shape (.github/workflows/main.yml:29-37):

- name: Build and push
  uses: docker/build-push-action@v4
  with:
    context: .
    push: true
    secrets: |
      SOME_BUILD_SECRET=$
RUN --mount=type=secret,id=SOME_BUILD_SECRET \
    SOME_BUILD_SECRET=$(cat /run/secrets/SOME_BUILD_SECRET) cargo build ...

No concrete build-time secret is identified yet — documented and ready for whenever §3.2 lands on an option that needs one.

3.2 Data files: how they reach a running container — decided (2026-10-04)

What the data is, and where each file comes from:

File (standard name in the data directory) Produced by Size
us_flat_embedded.feather, japan_rev13_flat_embedded.feather, nace_rev2_flat_embedded.feather, isic_rev4_flat_embedded.feather Mediumroast’s embedding export pipelines, outside this repo. Checked here with docs/plans/research/check_ic_feather.py. 5.2MB together
edgar_10x_catalog.feather ingest-edgar, in this repo (v4/crates/edgar/src/bin/ingest_edgar.rs), from the SEC’s quarterly filing indexes 11.6MB

Decisions:

  1. One environment variable, COMPANY_DNS_DATA_DIR, names the directory holding every data file. For now, in development, point it at the repo’s top-level tmp/. A per-file variable (SIC_DATA_PATH, JAPAN_SIC_DATA_PATH, EU_NACE_DATA_PATH, ISIC_DATA_PATH, EDGAR_CATALOG_PATH) still overrides its one file. With neither set, the default is the repo-root tmp/ found from the source tree at build time, so it no longer depends on the directory the server was started from (the old default was ../../tmp/... relative to the working directory, and resolved to the wrong place from anywhere else — that is exactly how an EDGAR catalog went missing during testing). Implemented in crates/edgar/src/data_dir.rs, shared by the server and the ingest tool. In the image: the files are copied into the image’s data directory (the Dockerfile sets COMPANY_DNS_DATA_DIR), so the image is self-contained and the plain-Docker smoke test (§3.5 step 2) can exercise real searches. A volume mounted at that same path replaces them without a rebuild.
  2. ingest-edgar takes years and quarters, defaulting to two years of quarters back from today. Built and run (2026-10-04). With no arguments it pulls the 8 most recent completed quarters (today is in 2026Q4, so 2024Q4–2026Q3), merges them into one catalog and writes it to the data directory. --years N changes the window, --from/--to (e.g. 2024Q1) set an explicit range, and the original <year> <quarter> form still works. It is a rolling window, rebuilt from scratch each run. Only completed quarters are in the default window — the running quarter’s index keeps growing, so including it would change the catalog under the rebuild. Safety: any quarter that fails aborts the run and leaves the existing catalog untouched (written to a temporary name and renamed into place), an empty result is refused, and the file records first_quarter, last_quarter, quarters, generated_at and rows in its own metadata. Rows are de-duplicated by (CIK, accession number), not accession alone — co-registrants such as Ameren Corp, Union Electric and Ameren Illinois file under one accession number and each is its own row (a first version de-duplicated by accession alone and would have silently dropped them; caught by comparing row counts, now covered by a test). Result of the first real run: 72,813 filings across the 8 quarters, 69,465 distinct accessions, 11.6MB; Apple went from 1 filing to 8 in the EDGAR Explorer. It needs network access to sec.gov and the project User-Agent; it cannot read the local V3 index files.
  3. (Superseded 2026-10-09: see v4-release-to-staging.md, item H “Data supply”. v4/scripts/fetch_data.py fetches the SIC files from a directory or URL with a pinned manifest, and the image build makes the EDGAR catalog itself. Only the delivery location is still open.) The SIC data is staged by hand in tmp/ for now, because the delivery mechanism from mediumroast.io is not confirmed. Consequence worth stating plainly: tmp/ is gitignored, so a CI runner cannot see it. Until that delivery mechanism exists, an image that contains the SIC data has to be built on a machine where tmp/ is staged (a developer machine), and the scheduled workflow below can refresh the EDGAR catalog but cannot produce a complete image on its own. This is the main thing standing between phase 1 and a fully automated build (§6).
  4. The scheduled workflow is quarterly, not monthly. It runs on the first day of January, April, July and October (cron: '0 12 1 1,4,7,10 *'), at which point the quarter that just ended is complete and becomes the newest in the window; each run therefore ships the last two years of completed quarters. Steps: ingest the catalog (no arguments) → check it (rows, quarter range from its metadata) → obtain the SIC files (mechanism open, point 3) → run check_ic_feather.py on each → build the multi-platform image → push. This replaces the monthly cadence planned in v4-server-prototype.md §4 (updated the same day) and is distinct from V3’s own monthly workflow (.github/workflows/main.yml), which this does not touch. Not built yet.

Why bake-and-override rather than only a volume or a fetch (the three options the earlier version of this section weighed — borrowed back from v4-container-packaging.md §3.2): baking in couples the code image to one data snapshot, so every refresh needs a rebuild and redeploy, which worked against the separate ingest-edgar/server design. That cost is now small: refresh is already a scheduled quarterly build, the data is about 17MB, and the volume override keeps an escape hatch for an out-of-cycle refresh without rebuilding. A volume populated by a separate process gives the cleanest decoupling but needs something to fill it before the server starts, a moving part this size of data does not justify. Fetching at startup from object storage adds a startup-time dependency and a new failure mode the local-file model does not have.

Known gap — a missing file is quiet. Today a missing data file only logs a warning and the server carries on (the EDGAR endpoints then return 500 “catalog not loaded”, and merged search degrades to Wikipedia-only with a note). That is fine for development and wrong for a deployment, where a bad mount should stop the pod. A strict startup mode (refuse to start, or fail readiness, when a required file is absent) is recommended; not decided (§6).

3.3 Binary size reduction — independent levers, each needs its own spike number

  1. [profile.release] tuning.
    • strip = true — already confirmed to save ~44MB (26%, §1.1). Free, no known downside for a shipped server binary.
    • lto = "thin"/"fat" — not yet measured; Arrow’s generic- monomorphization-heavy code is exactly the shape that often benefits, but has to be measured on this dependency graph, not assumed.
    • codegen-units = 1 — smaller/faster binary, slower build; worth measuring alongside LTO.
    • panic = "abort" — explicitly NOT recommended without further study. Tokio’s per-task unwind isolation is what keeps one panicking request handler from taking down the whole server today; panic = "abort" would turn that into a full-process crash on a single bad request. An availability regression, not a free size win — flagged so it isn’t reached for reflexively.
  2. DataFusion feature trimming — two very different risk categories, not one flat list.
    • File-format/codec support (parquet, avro-adjacent, compression) — safe to cut. Orthogonal to SQL expressiveness; §1.1 already confirmed no Parquet API use anywhere, and the SIC source is Arrow IPC, not Parquet.
    • Expression-function libraries (crypto_expressions, datetime_expressions, encoding_expressions, regex_expressions, string_expressions, unicode_expressions, nested_expressions) — deliberately kept on. These gate what a ctx.sql() query can actually express; more expressive SQL may well be needed for future SIC/EDGAR lookups, and cutting these now risks silently blocking a future query. Decided: keep the full set enabled in the first pass, revisit only with an explicit tradeoff conversation if binary size is still a real problem after the format/codec cut and profile tuning are both applied.
    • sql itself stays on regardless (genuinely required).
  3. Multi-stage Docker build. The Rust build stage (full toolchain, large) should never be the runtime image — final stage carries only the compiled binary plus whatever minimal base a stripped/trimmed binary needs (debian:bookworm-slim, or a distroless/scratch base if fully static via musl — needs checking reqwest’s rustls-tls against *-unknown-linux-musl targets).

3.4 Environment tiers: dev, staging, prod

All three run on the same microk8s cluster — no new hardware. Namespaces company-dns (existing, unchanged shape), company-dns-staging, company-dns-dev, mirroring mediumroast-web’s already-proven three-namespace convention (§1.3) rather than inventing a new pattern.

Distinct MEDIUMROAST_SHARED_SECRET per tier, not shared — each environment gets its own SealedSecret (already installed cluster-wide, §1.3), so staging/dev testing never has access to, or could leak, the real production secret mediumroast.io’s live site trusts. Cheap to do; the alternative gives a noisy dev environment the same trust as prod.

SQL endpoint configuration per tier (added 2026-10-05, experimental, off by default). Each tier gets its own sealed credentials file and its own rules ConfigMap (same reasoning as the distinct shared secret: staging and dev never hold prod’s credentials) and its own server-wide SQL limits in the tier’s ConfigMap (concurrency, query memory pool, request budget, row and timeout ceilings). The limits are sized against the tier’s real pod: prod is 4 replicas with requests 256Mi/100m and limits 1Gi/500m; staging 2 replicas. Request budgets are aggregate divided by replicas, the rate_limit.rs convention, with its known drift if the replica count and constant diverge. The SQL flag stays off until a tier’s credentials file exists; with the flag on and no valid file the server refuses to start. Staging’s capacity check (§4.7) must include the SQL capacity test (v4-sql-endpoint.md §5c): deliberately heavy SQL under steady lookup load, raising the caps until lookups degrade, to set the server-wide values.

3.5 The pipeline, with the plain-Docker-on-Ubuntu step explicit

Per what was asked directly — an intermediate step isolating “is it the image/binary” from “is it something K8s-specific”:

  1. Build. Multi-platform image (linux/amd64 and linux/arm64 — §3.7; linux/amd64 is the one that matters for this doc’s purpose, since that’s the real cluster’s architecture).
  2. NEW: plain docker run on real Ubuntu amd64 — no K8s at all. Smoke-test the built image directly: does it start, does /health respond, do real searches return results from the copied data files (the API test suite, §3.9), does the dynamic-log-level reload mechanism (once promoted per v4-dynamic-log-level.md §5) actually pick up a file change on real Linux. If this fails, it’s the binary/image; if it passes but a later K8s step fails, the problem is specifically in the K8s ConfigMap-mount layer. Cheapest real place to run this: a GitHub Actions ubuntu-latest runner is genuinely native linux/amd64 (not emulated) — a plain CI job, no new infrastructure needed.
  3. Deploy to company-dns-dev (not part of phases 1–2, §0.1 — skipped for now; the first real-microk8s test is staging). Real-microk8s, real-ConfigMap-mount test with fast iteration, not a hard gate.
  4. Promote to company-dns-staging (company-dns-staging.mediumroast.io, phase 2). §3.6’s full platform-parity suite runs here — the actual gate.
  5. Promote to company-dns (prod) only after staging passes. Manual kubectl apply, matching how prod deploys today (scripts/build-and-deploy.sh) — no GitOps/ArgoCD reconciler exists on this cluster (checked directly: helm list -A showed no such release), so automatic promotion isn’t free to add and isn’t designed here.

3.6 The platform-parity test suite (what runs at the staging gate)

  1. The specific thing that started this: confirm v4-dynamic-log-level.md’s promoted reload mechanism reacts to a real kubectl edit configmap on the real cluster — not just the hand-simulated symlink swap the spike used (a faithful reproduction of kubelet’s real mechanism, precisely so this step has something solid to confirm against, but a real edit is still the final word).
  2. Confirm v4-security-hardening.md’s rate_limit.rs behaves correctly with staging’s own replica count (2, §3.4) — exercises the REPLICA_COUNT/per-pod-quota-division logic at a different N than prod’s hardcoded 4, not just re-confirming prod’s own assumption.
  3. Once §6’s static-musl-vs-glibc-slim question is decided: confirm the chosen base image actually runs correctly on the real target, not just that cargo build succeeded for that target triple.
  4. General template for future platform-sensitive work: if a spike only proved something on a developer’s Mac, it doesn’t graduate to prod without a staging run first. Grows over time, doesn’t stay frozen at today’s items.

3.7 Multi-platform build — a hard requirement, and phase 1’s delivery target

Previously an open question. Phase 1 (§0.1) is explicitly one image delivered for both linux/amd64 and linux/arm64, and linux/amd64 is the one that matters for the real cluster, whose workers are amd64 (§1.3). The mechanism is still undecided (§6). The reasoning that makes it a real decision, borrowed back from v4-container-packaging.md §6: V3’s existing workflow builds both platforms with docker/setup-buildx-action under QEMU emulation, which is fine for an interpreted Python image but makes Rust compilation dramatically slower than native, and this build compiles DataFusion. The options are per-architecture native runners, a cross-compilation toolchain (cross), or accepting a slow emulated build. §3.5’s plain-Docker-on-Ubuntu step needs a real, working linux/amd64 image on every build, so the amd64 path cannot be the slow or flaky one.

3.8 K8s manifests, mirroring the proven k8s/prod/ shape

2026-10-10: the staging half of this section is now planned as work packages in v4-staging-rollout.md (V4 listens on 4000, not 8000, so the prod manifests are a pattern, not a copy; the rate limiter’s replica count must become a setting before two replicas are meaningful).

k8s/prod/’s existing pattern (namespace.yaml, deployment.yaml, service.yaml, ingress.yaml, middleware-redirect.yaml, certificate.yaml — authored during V3’s migration, proven live) gets a dev/ and staging/ sibling each, same shape, different sizing:

k8s/
  dev/      (designed, not scheduled - 1 replica, relaxed resources, hostname not chosen)
  staging/  (phase 2 - 2 replicas, company-dns-staging.mediumroast.io)
  prod/     (existing, unchanged)

Each environment’s Certificate/TLS reuses the same letsencrypt-prod ClusterIssuer prod already uses (§1.3) — no new issuer, just new hostnames on the existing one. New Ingress/Middleware pairs per namespace, same ingressClassName: traefik pattern, same namespace-prefixed Middleware reference convention (§1.3).

3.9 The API test suite (extend perf_tests/)

What is missing is a robust test suite for the APIs. What exists is a performance suite written in Python, perf_tests/, which is the right base to extend rather than replace:

4. Research spike, before touching v4/ for real

Following this project’s established experiments/-first pattern:

  1. [profile.release] tuning: strip = true, lto = "thin"/"fat", codegen-units = 1 in combination — measure real binary size and build-time cost, don’t guess.
  2. Trim DataFusion’s default-features down to the format/codec-only cut (§3.3.2), confirm cargo build/cargo test still pass across crates/sic, crates/edgar, crates/server — measure the binary size delta. Don’t widen the cut to expression-function features as part of this spike even if savings are small — that’s a separate, deliberately-deferred tradeoff.
  3. Prototype a real multi-stage Dockerfile with the smallest binary from 1/2, measure final image size end to end — including whether a static-musl build is realistic given rustls-tls, or glibc-slim is the practical floor.
  4. Data bundling is decided (§3.2.1): copy the feather files into the image’s data directory, with COMPANY_DNS_DATA_DIR set. Prototype it as part of 3 — confirm the image starts and serves real searches from the copied files, and that mounting a volume at the same path replaces them.
  5. Confirm a GitHub Actions ubuntu-latest runner really is linux/amd64 natively, and prove the plain-docker run smoke test as a real CI job, including a scripted version of the configmap_sim.rs-style update technique against a real file mount inside that container.
  6. Stand up company-dns-dev for real on the cluster, confirm a kubectl edit configmap there actually triggers v4-dynamic-log-level.md’s reload mechanism on real microk8s — the actual thing §3.4–3.6 exist to make possible.
  7. Confirm dev/staging’s chosen resource requests (§3.4) don’t starve prod or each other — a real kubectl top nodes/describe node check against the two real worker nodes’ actual headroom, not an assumed-safe number.

5. Implementation steps, in the order decided (2026-10-04)

Step A — make the major functions operable. Data side done 2026-10-04: COMPANY_DNS_DATA_DIR and the shared data-directory module, the server using it, ingest-edgar taking years/quarters (default: the last two years of completed quarters), the EDGAR catalog rebuilt (72,813 filings, 8 quarters), the README updated. Remaining under this step is whatever the owner still finds inoperable.

Step B — thin the binary (spike §4.1–4.2, then implement):

  1. v4/Cargo.toml: add [profile.release] with whatever §4.1 confirms (at minimum strip = true, already confirmed).
  2. v4/Cargo.toml: trim datafusion’s features per §4.2’s confirmed safe list (format/codec support only; the expression-function libraries stay on).

Step C — packaging, phase 1: one Docker image, multi-platform

  1. Add a real v4/Dockerfile (multi-stage, §3.3.3/§4.3) — the first Rust Dockerfile in the repo — built for linux/amd64 and linux/arm64 (§3.7), with the feather files copied into the data directory and COMPANY_DNS_DATA_DIR set (§3.2.1). Built where tmp/ is staged until the SIC delivery mechanism exists (§3.2.3).
  2. Prove the image with plain docker run on real Ubuntu amd64 (§3.5 step 2), running the API test suite (§3.9) against it.
  3. Extend perf_tests/ into the API test suite (§3.9).
  4. Update v4/README.md with a “Building and running the container” section.

Step D — the quarterly build

  1. Add the scheduled workflow (§3.2.4): quarterly, ingest → check → SIC files (once deliverable) → gate → multi-platform build → push. Decide whether it extends .github/workflows/main.yml or is its own V4 workflow, and whether it replaces V3’s or runs alongside it during the transition (v4-server-prototype.md scope question, not decided here).

Step E — phase 2: staging at company-dns-staging.mediumroast.io

  1. Add k8s/staging/ (§3.8), sized by §4.7’s real capacity check; its own MEDIUMROAST_SHARED_SECRET SealedSecret (§3.4); DNS and a letsencrypt-prod certificate for the hostname.
  2. Deploy the image there, run the API test suite and the platform-parity suite (§3.6), and leave it up long enough to judge stability — this is the gate before anything is promoted.

Step F — promotion to company-dns (prod) only after staging passes (§3.5 step 5). k8s/dev/ (§3.8) is not part of any of the above.

Step G — SQL endpoint wiring (after the feature exists, v4-sql-endpoint.md section 8): add credentials.example, rules.example.json and the .gitignore pattern; a SealedSecret and ConfigMap entries per tier (k8s/staging/, k8s/prod/); document the Docker-secret equivalent in the README; run the SQL capacity test at staging (§3.4 addendum); flag stays off in prod until that passes.

If §3.1’s build-time secret mechanism ends up needed (for example to fetch the SIC files in CI, §3.2.3), wire it in following §3.1’s pattern.

6. Open questions

Resolved 2026-10-04 (kept visible, struck through):

Still open:

7. Explicitly out of scope for this doc