Phase 4 slice 1: backend release image on the v0.3 line (single worker, non-root) + the phase-4 gate ported onto it #32

Merged
jonathan merged 4 commits from po/phase4-backend-image into milestone/v0.3-hosted 2026-10-31 07:35:49 +00:00
Collaborator

What — the backend release image for the v0.3 line, plus the phase-4 gate ported onto the branch the phase actually ships on.

  • backend/Dockerfile — multi-stage like frontend/Dockerfile: dependencies from the committed lock (uv sync --frozen --no-dev --no-editable), runtime on python:3.12-slim, uvicorn researchx.api.app:app --host 0.0.0.0 --port 8000 --workers 1 as the non-root user researchx, data dir /var/lib/researchx (RESEARCHX_DATA_DIR), and a HEALTHCHECK on /healthz.
  • backend/.dockerignore — keeps .env*, caches and the local data dir out of the build context.
  • .gitea/workflows/ci.yml — the phase-4 gate from main (researchx#24) copied here, with its pull_request trigger extended to milestone/v0.3-hosted.

Why — REQ-83 of the owner-directed roadmap (milestone v0.3, phase 4, status pending): "Backend container: Dockerfile for the FastAPI backend (single uvicorn worker, non-root), two-service deployment (frontend + backend) via deploy.sh extension; RESEARCHX_DATA_DIR volume (default /var/lib/researchx); unauthenticated /healthz outside /api for proxy health checks". Tracked on coreci/researchx#30. This pull request is the Dockerfile half of that requirement; the two-service deploy script is the next slice.

The correction it carries — the first cut of this work (coreci/researchx#31) was based on main, which is not where phase 4 ships: every v0.3 phase so far landed on milestone/v0.3-hosted (PRs #16..#19) and tags v0.3.0..v0.3.3 are cut from it. main and the milestone branch have diverged — 14 commits only on main, 8 only on the branch — and main still carries the v0.2-era app. #31 is closed with that evidence; this one replaces it, based on milestone/v0.3-hosted @ fd3d6c2e03 (tag v0.3.3).

Reading the app on that branch also corrects two notes in #31: /healthz does exist (a plain GET outside /api, never auth-wrapped — D-103), and the mutable data root does come from RESEARCHX_DATA_DIR (researchx.config.paths.data_root() — D-107). The image below is written against those contracts, so the healthcheck and the volume mount are real rather than aspirational.

How to review — three files, in this order:

  1. backend/Dockerfile — the deps stage reads the committed lock, not the index; the runtime stage drops the build toolchain and the dev dependencies and runs as a non-root user; RESEARCHX_DATA_DIR is the variable the app actually reads; the healthcheck hits /healthz.
  2. backend/.dockerignore — secrets and caches stay out of the image.
  3. .gitea/workflows/ci.yml — the same gate that ran green on main, plus one added trigger line.

Done looks like: the deploy host builds this image from the backend/ context the way deploy.sh builds the frontend, and the container answers on port 8000 as user researchx, with /healthz returning {"status":"ok"}.

Risk — low for the product, two things a reviewer should know:

  • The gate has never run against the v0.3 line: the milestone branch had no .gitea/ directory at all, which is why the gate is ported here. The first run on this branch is the first gating evidence this code has ever had, and its result is reported on coreci/researchx#30 rather than claimed here.
  • No image build was run. The repo gate does not build images, this seat has no Docker and no outbound network, and the image is built on the deploy host (research.coreci.dev deploys only on owner directive — D-019). The build needs pypi reachable from that host, the same way the frontend image already needs the npm registry.

Evidence — branch po/phase4-backend-image off milestone/v0.3-hosted @ fd3d6c2e03 (tag v0.3.3); the gate that ran green on main is run 58204 (backend job 60493, frontend job 60494, both success).

**What** — the backend release image for the v0.3 line, plus the phase-4 gate ported onto the branch the phase actually ships on. - `backend/Dockerfile` — multi-stage like `frontend/Dockerfile`: dependencies from the committed lock (`uv sync --frozen --no-dev --no-editable`), runtime on python:3.12-slim, `uvicorn researchx.api.app:app --host 0.0.0.0 --port 8000 --workers 1` as the non-root user `researchx`, data dir `/var/lib/researchx` (`RESEARCHX_DATA_DIR`), and a `HEALTHCHECK` on `/healthz`. - `backend/.dockerignore` — keeps `.env*`, caches and the local data dir out of the build context. - `.gitea/workflows/ci.yml` — the phase-4 gate from `main` (researchx#24) copied here, with its `pull_request` trigger extended to `milestone/v0.3-hosted`. **Why** — REQ-83 of the owner-directed roadmap (milestone v0.3, phase 4, status pending): "Backend container: Dockerfile for the FastAPI backend (single uvicorn worker, non-root), two-service deployment (frontend + backend) via deploy.sh extension; RESEARCHX_DATA_DIR volume (default /var/lib/researchx); unauthenticated /healthz outside /api for proxy health checks". Tracked on coreci/researchx#30. This pull request is the Dockerfile half of that requirement; the two-service deploy script is the next slice. **The correction it carries** — the first cut of this work (coreci/researchx#31) was based on `main`, which is not where phase 4 ships: every v0.3 phase so far landed on `milestone/v0.3-hosted` (PRs #16..#19) and tags v0.3.0..v0.3.3 are cut from it. `main` and the milestone branch have diverged — 14 commits only on `main`, 8 only on the branch — and `main` still carries the v0.2-era app. #31 is closed with that evidence; this one replaces it, based on `milestone/v0.3-hosted` @ fd3d6c2e03 (tag v0.3.3). Reading the app on that branch also corrects two notes in #31: `/healthz` does exist (a plain GET outside `/api`, never auth-wrapped — D-103), and the mutable data root does come from `RESEARCHX_DATA_DIR` (`researchx.config.paths.data_root()` — D-107). The image below is written against those contracts, so the healthcheck and the volume mount are real rather than aspirational. **How to review** — three files, in this order: 1. `backend/Dockerfile` — the deps stage reads the committed lock, not the index; the runtime stage drops the build toolchain and the dev dependencies and runs as a non-root user; `RESEARCHX_DATA_DIR` is the variable the app actually reads; the healthcheck hits `/healthz`. 2. `backend/.dockerignore` — secrets and caches stay out of the image. 3. `.gitea/workflows/ci.yml` — the same gate that ran green on `main`, plus one added trigger line. Done looks like: the deploy host builds this image from the `backend/` context the way `deploy.sh` builds the frontend, and the container answers on port 8000 as user `researchx`, with `/healthz` returning `{"status":"ok"}`. **Risk** — low for the product, two things a reviewer should know: - The gate has never run against the v0.3 line: the milestone branch had no `.gitea/` directory at all, which is why the gate is ported here. The first run on this branch is the first gating evidence this code has ever had, and its result is reported on coreci/researchx#30 rather than claimed here. - No image build was run. The repo gate does not build images, this seat has no Docker and no outbound network, and the image is built on the deploy host (research.coreci.dev deploys only on owner directive — D-019). The build needs pypi reachable from that host, the same way the frontend image already needs the npm registry. **Evidence** — branch `po/phase4-backend-image` off `milestone/v0.3-hosted` @ fd3d6c2e03 (tag v0.3.3); the gate that ran green on `main` is run 58204 (backend job 60493, frontend job 60494, both success).
coreci-researchx-po added 3 commits 2026-10-30 23:53:07 +00:00
build(backend): keep secrets, caches and the local data dir out of the build context
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 57s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m40s
6c85ad2824
ci: port the phase-4 gate onto the v0.3 line and extend its pull_request trigger to the milestone branch
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 59s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m37s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 56s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m59s
1eebe94beb
coreci-hap was assigned by coreci-researchx-po 2026-10-30 23:53:08 +00:00
coreci-cpo was assigned by coreci-researchx-po 2026-10-30 23:53:08 +00:00
Author
Collaborator

TL;DR — the branch is green on all three gate runs: push 69643, push 69644, pull_request 69645, each with both jobs green (backend 71953/71955/71957, frontend 71954/71956/71958). That is the first gating evidence the v0.3 line has ever had — the branch carried no .gitea/ directory before this port.

Action — @coreci-hap, @coreci-cpo: review and merge; the merge is the approval (v1.3). Merge this before PR #33, whose two-service deploy script builds the image named here.

Not claimed — no image was built: the gate does not build images and this seat has no Docker. The first build rides the deploy host.

**TL;DR** — the branch is green on all three gate runs: push 69643, push 69644, pull_request 69645, each with both jobs green (backend 71953/71955/71957, frontend 71954/71956/71958). That is the first gating evidence the v0.3 line has ever had — the branch carried no `.gitea/` directory before this port. **Action** — @coreci-hap, @coreci-cpo: review and merge; the merge is the approval (v1.3). Merge this before PR #33, whose two-service deploy script builds the image named here. **Not claimed** — no image was built: the gate does not build images and this seat has no Docker. The first build rides the deploy host.
Author
Collaborator

TL;DR — the gate evidence in my earlier comment on this PR now stands confirmed at the current stamp: all three runs concluded success with both jobs green. One new fact for the merge order: the gate file rides in this PR, so PR #33's branch has no gate at all and will need a rebase onto the merged base before it is gated.

What — measured at this seat (own PAT, 04:0xZ):

  • Run 69643 (push, head 1eebe94beb) — backend job 71953 success, frontend job 71954 success.
  • Run 69644 (push, head 1eebe94beb) — backend job 71955 success, frontend job 71956 success.
  • Run 69645 (pull_request, head 1eebe94beb) — backend job 71957 success, frontend job 71958 success. This is the run I reported as in flight last cycle; it is now concluded green, so the claim on this PR is confirmed, not corrected.

Why — the new fact matters for the merge order I asked for. The gate file .gitea/workflows/ci.yml exists only on this PR's branch: on milestone/v0.3-hosted and on po/phase4-deploy-two-service the path .gitea/ returns 404 — the milestone branch has never carried a workflow. So PR #33 has zero CI runs: no run record exists for either of its head commits (8afbf1cd, 593be870), and its branch cannot produce one, because there is no workflow file on it to run.

Action — @coreci-hap, @coreci-cpo: review and merge this PR; the merge is the approval (v1.3). Then, before merging PR #33: rebase it onto the merged milestone/v0.3-hosted so the gate file it inherits fires. Merging #33 as it stands would put an ungated two-service deploy script into the release path — the one file in this phase that can take the hosted instance down, and the one thing no run has ever exercised.

Details — runs 69643/69644/69645 with jobs 71953-71958 read through the actions API at 04:0xZ; .gitea/workflows/ci.yml presence checked per branch by the contents API (200 on po/phase4-backend-image, 404 on milestone/v0.3-hosted and po/phase4-deploy-two-service). The gate does not build images, so a green gate says nothing about the backend image — that leg still rides the deploy host (D-019). PR #33 carries a reviewed fix (comment 16014) that is read-verified and shell-syntax-checked only, not executed: this seat has no Docker.

**TL;DR — the gate evidence in my earlier comment on this PR now stands confirmed at the current stamp: all three runs concluded success with both jobs green. One new fact for the merge order: the gate file rides in *this* PR, so PR #33's branch has no gate at all and will need a rebase onto the merged base before it is gated.** **What —** measured at this seat (own PAT, 04:0xZ): - **Run 69643** (push, head 1eebe94beb) — backend job 71953 success, frontend job 71954 success. - **Run 69644** (push, head 1eebe94beb) — backend job 71955 success, frontend job 71956 success. - **Run 69645** (pull_request, head 1eebe94beb) — backend job 71957 success, frontend job 71958 success. *This is the run I reported as in flight last cycle; it is now concluded green, so the claim on this PR is confirmed, not corrected.* **Why** — the new fact matters for the merge order I asked for. The gate file `.gitea/workflows/ci.yml` exists **only** on this PR's branch: on `milestone/v0.3-hosted` and on `po/phase4-deploy-two-service` the path `.gitea/` returns 404 — the milestone branch has never carried a workflow. So **PR #33 has zero CI runs**: no run record exists for either of its head commits (`8afbf1cd`, `593be870`), and its branch cannot produce one, because there is no workflow file on it to run. **Action** — @coreci-hap, @coreci-cpo: review and merge this PR; the merge is the approval (v1.3). Then, before merging **PR #33**: rebase it onto the merged `milestone/v0.3-hosted` so the gate file it inherits fires. Merging #33 as it stands would put an ungated two-service deploy script into the release path — the one file in this phase that can take the hosted instance down, and the one thing no run has ever exercised. **Details** — runs 69643/69644/69645 with jobs 71953-71958 read through the actions API at 04:0xZ; `.gitea/workflows/ci.yml` presence checked per branch by the contents API (200 on `po/phase4-backend-image`, 404 on `milestone/v0.3-hosted` and `po/phase4-deploy-two-service`). The gate does not build images, so a green gate says nothing about the backend image — that leg still rides the deploy host (D-019). PR #33 carries a reviewed fix (comment 16014) that is read-verified and shell-syntax-checked only, not executed: this seat has no Docker.
coreci-researchx-po added 1 commit 2026-10-31 03:42:50 +00:00
fix(backend): serve the production entrypoint, not the test factory
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m58s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m59s
b964c60e72
The image's CMD ran researchx.api.app:app - the module-global built by
create_app() with no arguments, which serves FakeModelAdapter and
cwd-based storage. Hosting it would have shipped fakes on the public
URL while /healthz answered 200 (P-3 closure; RESEARCH-v03 S8,
PLAN T-4.1). Now: researchx.api.main:app with --proxy-headers, so the
TLS front's forwarded headers are honoured.

Also pins uid/gid 10001 for the non-root user, so the host-side volume
chown in the deploy script is deterministic.
Author
Collaborator

TL;DR — review of this image found one ship-blocking defect in it, and it is fixed on this branch (commit b964c60e, new head): the container's CMD ran the module-global researchx.api.app:app, which is the test factory — it serves FakeModelAdapter and cwd-based storage. Hosting it would have shipped a fake backend behind a public URL that answers /healthz 200. The command now runs the production entrypoint researchx.api.main:app.

What — read at source, not inferred:

  • The defect. backend/src/researchx/api/app.py ends with app = create_app() — no arguments — and carries the comment "this serves FAKES with default storage — the production entrypoint (researchx.api.main, Phase 2) must never import this global". create_app() with no model_factory and no model_adapter constructs a FakeModelAdapter with two canned responses (a fake planner and a fake writer), and storage_base falls back to Path.cwd(). The production wiring is a different module: researchx/api/main.py builds the app with the live ModelAdapterFactory, the env-file secrets provider, the key store, auth_enabled=True and cookie_secure=True hardwired — and its own docstring says uvicorn researchx.api.main:app is "the ONLY serving entry the container ever runs (T-4.1)". The repo's own plan agrees: PLAN.md T-4.1 and RESEARCH-v03.md §8 both specify CMD ["uvicorn", "researchx.api.main:app", ..., "--proxy-headers"], and RESEARCH-v03.md §8 says it in as many words: "today app.py's module-level app = create_app() serves FakeModelAdapter + cwd-based storage — hosting that would silently ship fakes."
  • Why it would have passed review. The image's HEALTHCHECK hits /healthz, which is a plain public GET outside /api (app.py:371-373) and answers 200 on either entrypoint. A fake backend and a real one are indistinguishable through the health surface — the difference only shows up in a run's actual model output, on the public URL, in front of a user.
  • The fix (commit b964c60e, one file, backend/Dockerfile): the CMD is now ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"]. --proxy-headers is what the plan asks for and what the TLS front needs — the host proxy terminates TLS and forwards X-Forwarded-*, so without it the app sees the proxy's connection, not the browser's.
  • Second, smaller item, same file: the non-root user is now created with explicit uid/gid 10001 (addgroup --gid 10001 / adduser --uid 10001 --gid 10001), matching PLAN.md's "non-root uid 10001" must-have and ARCHITECTURE.md's topology. The deploy script's volume chown reads the ownership back out of the built image, so this pins the number instead of letting it drift with the base image's uid allocation.
  • Not executed: this seat has no Docker, so the image was not built here. The gate does not build images either — the first build rides the deploy host (D-019). The file was read back byte-identical after the push (blob 22a451f0fe0b, 3168 B, sha256 e0629405cf1febbd).

Action — @coreci-hap, @coreci-cpo: review and merge; the merge is the approval (v1.3). The gate has fired on this head — a push run (70266) and a pull_request run (70267) are dispatched on b964c60e7; neither has concluded, so no green is claimed here. Merge order still applies: #32 before #33, and #33 needs a rebase onto the merged base afterwards, because the gate file rides in this PR (comment 16036).

Details — backend/src/researchx/api/app.py and backend/src/researchx/api/main.py read at the v0.3 tip (milestone/v0.3-hosted @ fd3d6c2e03); backend/Dockerfile read on this branch at its previous head 1eebe94beb; PLAN.md T-4.1/T-4.2/T-4.4, RESEARCH-v03.md §8 and ARCHITECTURE.md "Deployment Topology" read at the same tip. The one leg this seat cannot close: a real inference call on the hosted instance rides the deploy host and the owner's directive (D-019) — the "live-mode default proven" criterion in PLAN.md T-4.7 is exactly that leg, and it is not provable from here.

**TL;DR — review of this image found one ship-blocking defect in it, and it is fixed on this branch (commit `b964c60e`, new head): the container's `CMD` ran the module-global `researchx.api.app:app`, which is the *test* factory — it serves `FakeModelAdapter` and cwd-based storage. Hosting it would have shipped a fake backend behind a public URL that answers `/healthz` 200. The command now runs the production entrypoint `researchx.api.main:app`.** **What —** read at source, not inferred: - **The defect.** `backend/src/researchx/api/app.py` ends with `app = create_app()` — no arguments — and carries the comment "this serves FAKES with default storage — the production entrypoint (`researchx.api.main`, Phase 2) must never import this global". `create_app()` with no `model_factory` and no `model_adapter` constructs a `FakeModelAdapter` with two canned responses (a fake planner and a fake writer), and `storage_base` falls back to `Path.cwd()`. The production wiring is a different module: `researchx/api/main.py` builds the app with the live `ModelAdapterFactory`, the env-file secrets provider, the key store, `auth_enabled=True` and `cookie_secure=True` hardwired — and its own docstring says `uvicorn researchx.api.main:app` is "the ONLY serving entry the container ever runs (T-4.1)". The repo's own plan agrees: `PLAN.md` T-4.1 and `RESEARCH-v03.md` §8 both specify `CMD ["uvicorn", "researchx.api.main:app", ..., "--proxy-headers"]`, and `RESEARCH-v03.md` §8 says it in as many words: *"today `app.py`'s module-level `app = create_app()` serves FakeModelAdapter + cwd-based storage — hosting that would silently ship fakes."* - **Why it would have passed review.** The image's `HEALTHCHECK` hits `/healthz`, which is a plain public GET outside `/api` (`app.py:371-373`) and answers 200 on either entrypoint. A fake backend and a real one are indistinguishable through the health surface — the difference only shows up in a run's actual model output, on the public URL, in front of a user. - **The fix** (commit `b964c60e`, one file, `backend/Dockerfile`): the `CMD` is now `["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"]`. `--proxy-headers` is what the plan asks for and what the TLS front needs — the host proxy terminates TLS and forwards `X-Forwarded-*`, so without it the app sees the proxy's connection, not the browser's. - **Second, smaller item, same file:** the non-root user is now created with explicit **uid/gid 10001** (`addgroup --gid 10001` / `adduser --uid 10001 --gid 10001`), matching `PLAN.md`'s "non-root uid 10001" must-have and `ARCHITECTURE.md`'s topology. The deploy script's volume chown reads the ownership back out of the built image, so this pins the number instead of letting it drift with the base image's uid allocation. - **Not executed:** this seat has no Docker, so the image was not built here. The gate does not build images either — the first build rides the deploy host (D-019). The file was read back byte-identical after the push (blob `22a451f0fe0b`, 3168 B, sha256 `e0629405cf1febbd`). **Action** — @coreci-hap, @coreci-cpo: review and merge; the merge is the approval (v1.3). The gate has fired on this head — a push run (70266) and a pull_request run (70267) are dispatched on `b964c60e7`; **neither has concluded, so no green is claimed here**. Merge order still applies: **#32 before #33**, and #33 needs a rebase onto the merged base afterwards, because the gate file rides in this PR (comment 16036). **Details** — `backend/src/researchx/api/app.py` and `backend/src/researchx/api/main.py` read at the v0.3 tip (`milestone/v0.3-hosted` @ fd3d6c2e03); `backend/Dockerfile` read on this branch at its previous head `1eebe94beb`; `PLAN.md` T-4.1/T-4.2/T-4.4, `RESEARCH-v03.md` §8 and `ARCHITECTURE.md` "Deployment Topology" read at the same tip. The one leg this seat cannot close: a real inference call on the hosted instance rides the deploy host and the owner's directive (D-019) — the "live-mode default proven" criterion in `PLAN.md` T-4.7 is exactly that leg, and it is not provable from here.
jonathan merged commit ad404b6659 into milestone/v0.3-hosted 2026-10-31 07:35:49 +00:00
jonathan deleted branch po/phase4-backend-image 2026-10-31 07:35:49 +00:00
Sign in to join this conversation.