# syntax=docker/dockerfile:1
#
# claude-archive serve image (dispatch 000305, Release M / M2; split into two arms by dispatch
# 000313, Release N / N7).
#
# M1 made the serve plane read-only in software: mode=ro + PRAGMA query_only, a refusal on schema
# mismatch, no persistent derived-state write. This image makes it read-only in the OPERATING
# SYSTEM, and the two together are the claim Release M is built on -- the retrieval plane behaves
# identically natively or in a hardened container, and is physically incapable of altering its
# evidence corpus in either.
#
# The runtime stage has no network, a read-only root filesystem, dropped capabilities, a non-root
# uid, and mounts the corpus read-only.
#
# --- TWO ARMS, ONE FILE (dispatch 000313, RULED BY MIKE ANDERSEN 2026-09-05) ---------------------
#
# 000307 measured both arms: 2.38 GB versus 362 MB, eleven of eleven tools green at their defaults
# in BOTH, and rerank=true / fuse=true degrading with an explicit notice rather than an error in the
# base arm (reports/image_base_only_1.md). Two gigabytes of the shipped image existed for two
# optional booleans, and Release O is about to make that a per-pull, per-version cost forever. The
# ruling: the SMALL thing is the default.
#
#   default arm  `runtime`             the LAST stage, so a bare `docker build .` builds it.
#                                      No torch, no sentence-transformers, no baked models.
#                                      rerank=true and fuse=true return the default lexical +
#                                      sidecar tiers with a notice naming the missing extra.
#   model arm    `runtime-embeddings`  --target runtime-embeddings. Carries torch (CPU), the
#                                      [embeddings] extra and both models baked at the revisions
#                                      pinned below. Offline rerank and fusion work in it.
#
# BUILD (default):  docker build -t claude-archive:1.14.0 --build-arg VERSION=1.14.0 .
# BUILD (model):    docker build -t claude-archive:1.14.0-embeddings \
#                       --target runtime-embeddings --build-arg VERSION=1.14.0 .
# RUN:    docker run --rm -i --network none --read-only --cap-drop ALL \
#           --security-opt no-new-privileges --user 10001:10001 --pids-limit 128 --memory 2g \
#           --tmpfs /tmp --tmpfs /var/log/claude-archive:uid=10001,gid=10001,mode=0700 \
#           -v /path/to/data:/data:ro claude-archive:1.14.0
#
# THE SHARED LAYERS ARE DEFINED EXACTLY ONCE. `builder-embeddings` is built FROM `builder`, and
# both concrete runtimes are built FROM `runtime-common`; neither the base image, the apt layer,
# the venv, the lock, the source copy, the uid, the env block, the labels, the healthcheck nor the
# entrypoint is spelled twice. The cost of that choice is stated rather than hidden: because the
# source copy precedes the torch install, editing src/ invalidates the model arm's torch layer on a
# local rebuild. The default arm -- the one CI builds on every pull request, with no layer cache at
# all -- is the one that got faster, and a duplicated COPY pair is exactly the kind of drift this
# file exists to prevent.
#
# The image runs identically under plain `docker run`. Docker MCP Gateway is preferred
# infrastructure and is never a correctness dependency (see server.yaml and docs/SECURITY.md).

# --- builder (shared) ------------------------------------------------------------------------
# python:3.12-slim (Debian trixie), PINNED BY DIGEST (dispatch 000310, Release N / N3).
#
# The tag is retained beside the digest for readability ONLY. Docker resolves the digest and
# ignores the tag when both are present, so the human-readable half can never silently become the
# thing that is actually pulled.
#
# The digest below is the multi-platform INDEX digest of python:3.12-slim as resolved on
# 2026-09-05 (`docker buildx imagetools inspect python:3.12-slim`), which reported
# org.opencontainers.image.version 3.12.14-slim-trixie for its linux/amd64 manifest. An index
# digest rather than a per-architecture manifest digest, so the image still builds on arm64.
#
# THE PREVIOUS COMMENT HERE WAS FALSE and is deleted rather than softened. It told the reader that
# a tag was carried here because the immutable reference was supplied by the continuous-integration
# lane; .github/workflows/docker-smoke.yml has never done any such thing -- its build step is a
# bare `docker build` and names no immutable reference anywhere. A comment claiming a supply-chain
# control that does not exist is worse than no comment, because a reader who believes it stops
# looking. The pin now lives in this file, which is the only place a `docker build` reads it from,
# and tests/test_container_pins.py asserts both halves: the pin is here, and the lane has none.
#
# It is REFRESHED BY DEPENDABOT (.github/dependabot.yml, docker ecosystem, weekly), not by hand.
# That is the answer to the objection the old comment raised -- a digest going stale with no
# machinery to update it -- rather than a reason to keep floating on a tag.
FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea \
     AS builder

ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /src

# Build deps for the wheel only; none of this reaches the runtime stage.
RUN apt-get update \
    && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*

# THE LOCK (dispatch 000310). constraints-container.txt is the full resolved closure of the MODEL
# arm's venv, pinned with `==`, and it is passed to EVERY pip invocation below -- including the one
# that upgrades pip itself, which floated before. Its own header records how it was produced and
# how it is refreshed. A constraints file constrains only what is actually resolved, so the default
# arm installing from it takes the pins for the packages it needs and never pulls torch.
COPY constraints-container.txt /src/

RUN python -m venv /opt/venv \
    && /opt/venv/bin/pip install --no-cache-dir -c /src/constraints-container.txt --upgrade pip

# The package itself, with NO extras. This is the whole of the default arm's install: the eleven
# tools, measured green at their defaults in 362 MB (reports/image_base_only_1.md section 2).
COPY pyproject.toml README.md /src/
COPY src /src/src
RUN /opt/venv/bin/pip install --no-cache-dir -c /src/constraints-container.txt "."

# --- builder-embeddings ------------------------------------------------------------------------
# Everything the MODEL arm adds, and nothing the default arm pays for. Built FROM `builder`, so the
# base image, the apt layer, the venv, the lock and the source copy above are not repeated here.
FROM builder AS builder-embeddings

# Keep PyPI authoritative for ordinary packages. Using the PyTorch CPU repository as
# --index-url makes it the sole resolver for torch's transitive dependencies too, which is why
# Dependabot bumps for ordinary packages such as filelock and setuptools were forced back to the
# versions visible on that repository. That is a resolver-side supply-chain boundary failure:
# the CPU repository should provide the torch wheel, not become the general package index.
#
# --find-links is deliberately scoped to the torch wheel page. pip still resolves torch's
# dependencies from PyPI, and the complete closure remains constrained by the lock above. The
# exact +cpu constraint continues to prevent a CUDA wheel from entering this image.
RUN /opt/venv/bin/pip install --no-cache-dir -c /src/constraints-container.txt \
        --index-url https://pypi.org/simple \
        --find-links https://download.pytorch.org/whl/cpu/torch/ torch

# The runtime extra on top. [embeddings] pulls sentence-transformers, which finds the CPU torch
# already installed above.
RUN /opt/venv/bin/pip install --no-cache-dir -c /src/constraints-container.txt ".[embeddings]"

# Bake both models into an in-image Hugging Face cache. This is what makes --network none possible
# rather than merely permitted: sentence-transformers resolves a model id over the network on first
# use, so an image without this would import fine and then fail the first rerank call.
#
# BOTH MODELS ARE FETCHED AT AN IMMUTABLE REVISION (dispatch 000310). A bare repo id resolves to
# whatever `main` points at on the day of the build, which is a moving target on a gate: the
# docker-smoke lane could go red because a Hugging Face maintainer pushed a commit overnight, on a
# pull request that touched nothing. The two commit hashes below are the ones this image already
# resolved on 2026-09-05, read back out of the pre-change image's own cache
# (`/opt/hf/hub/models--*/snapshots/*`), so pinning them changed no bytes -- it only froze which
# bytes.
#
# THE refs/main WRITE IS LOAD-BEARING, not tidiness. huggingface_hub writes
# `models--<id>/refs/<rev>` only when the requested revision is a BRANCH NAME; downloading by
# commit hash populates `snapshots/<hash>` and leaves `refs/` empty. The serve plane loads these
# models by bare repo id (src/claude_archive_mcp/embeddings.py:27, rerank.py:36) with
# HF_HUB_OFFLINE=1, which resolves `main` through that ref -- so an image baked by hash and no
# more would import fine and then fail the first rerank call with a local-cache miss, offline,
# with no network to recover from. Writing the ref makes the pinned snapshot the answer to the
# unrevisioned lookup the runtime actually performs.
ENV HF_HOME=/opt/hf
RUN mkdir -p /opt/hf && /opt/venv/bin/python <<'BAKE'
from pathlib import Path

from sentence_transformers import CrossEncoder, SentenceTransformer

BI_ENCODER = ("sentence-transformers/all-MiniLM-L6-v2",
              "1110a243fdf4706b3f48f1d95db1a4f5529b4d41")
CROSS_ENCODER = ("cross-encoder/ms-marco-MiniLM-L-6-v2",
                 "233902d25c440f23af6f7d6e94d2946bac0bee0a")

SentenceTransformer(BI_ENCODER[0], revision=BI_ENCODER[1])
CrossEncoder(CROSS_ENCODER[0], revision=CROSS_ENCODER[1], max_length=512)

for repo_id, revision in (BI_ENCODER, CROSS_ENCODER):
    ref = Path("/opt/hf/hub", "models--" + repo_id.replace("/", "--"), "refs", "main")
    ref.parent.mkdir(parents=True, exist_ok=True)
    ref.write_text(revision, encoding="ascii")
    assert ref.read_text(encoding="ascii") == revision
    assert Path("/opt/hf/hub", "models--" + repo_id.replace("/", "--"),
                "snapshots", revision).is_dir()
BAKE

# --- runtime-common --------------------------------------------------------------------------
# EVERYTHING BOTH ARMS SHARE, and the only place any of it is written. Both `runtime` and
# `runtime-embeddings` are built FROM this stage, so the posture cannot differ between the arms by
# accident: a flag relaxed here is relaxed in both, and one added to one arm has to be added
# visibly, in that arm's stage.
#
# The SAME digest as the builder, spelled out rather than referenced, because that is what
# Dependabot rewrites: it edits `FROM` lines, so both have to carry the pin for both to move.
# tests/test_container_pins.py asserts every FROM that names an EXTERNAL image carries a digest AND
# that they agree, so a refresh that updated one and not the other is a test failure rather than a
# two-base-image image.
FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea \
     AS runtime-common

# A distinct, high, fixed uid/gid. Fixed because the corpus is bind-mounted and its ownership has
# to be predictable; high because it cannot collide with a host system account.
RUN groupadd --gid 10001 archive \
    && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin archive

# Everything the image is is declared here rather than passed at run time, so a `docker run` with
# no -e flags is already correct and a compose file cannot silently disagree with the image.
#
# HF_HOME is set in BOTH arms deliberately. The model arm populates /opt/hf; the default arm leaves
# it absent, and that is the honest configuration -- with HF_HUB_OFFLINE=1 and no cache, any model
# lookup fails locally with no network reached, which is precisely the path the *_notice
# degradation is built on.
#
# CLAUDE_ARCHIVE_GENERATIONS is declared EMPTY, and the empty value is the decision rather than an
# oversight (the N9 Docker closure). Its presence is what SELECTS the topology --
# `config.generations_configured()` reads a non-blank value, and `serve.open_serve_plane` branches
# on it -- so baking `/generations` here would make every `docker run` over this image a
# generation run, and the working-store mode below would become unreachable in a container. The
# name is declared so the image says which knob it understands and so `server.yaml` may name a
# variable the image actually carries (tests/test_server_yaml_spec.py asserts exactly that); the
# VALUE is set by the deployment -- compose.yml's serve service and server.yaml both set
# `/generations` -- which is where the choice of topology belongs.
ENV PATH="/opt/venv/bin:${PATH}" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    HF_HOME=/opt/hf \
    HF_HUB_OFFLINE=1 \
    TRANSFORMERS_OFFLINE=1 \
    CLAUDE_ARCHIVE_DB=/data/archive.db \
    CLAUDE_ARCHIVE_GENERATIONS="" \
    CLAUDE_ARCHIVE_LOG=/var/log/claude-archive/server.log \
    CLAUDE_ARCHIVE_TRANSPORT=stdio

# TWO CORPUS TOPOLOGIES, and the image carries the mount point for both.
#
# /generations is the DEFAULT DEPLOYMENT PATH (Release N / N9, docs/design/snapshot_publisher.md
# sections 4 and 8): a directory of published generations, each an identified, hash-verified
# artifact. Serve mounts it READ-ONLY and validates one generation completely before opening
# anything; the publisher mounts the same volume READ-WRITE and is the only thing that writes one.
# It is NOT declared with VOLUME: a `docker run` with no mount would then get an empty anonymous
# volume per run, and an empty generations directory is a refusal rather than a default.
#
# /data is the WORKING-STORE topology M2 shipped, and it stays: archive.db, memory.db and the
# structural-index sidecar in one directory, opened mode=ro. It is what a `docker run` gets when
# CLAUDE_ARCHIVE_GENERATIONS is left empty, and it remains the mode `scripts/container_controls.py`
# measures the WAL rule and the schema refusal in.
#
# /var/log/claude-archive is the ONLY writable location the process needs, and compose gives it a
# tmpfs. /data and the log directory are declared so a `docker run` that forgets them fails visibly
# instead of writing into the image layer.
VOLUME ["/data"]
# OWNERSHIP IS LOAD-BEARING on these two, and it is measured rather than assumed. Docker seeds a
# freshly created NAMED VOLUME from the image directory at the mount point and preserves its
# ownership -- so a root-owned /generations produces a root-owned volume that the publisher, which
# runs as 10001 like every other process in this image, cannot write. Chowning them here is what
# makes `docker compose run --rm worker publish` work on a first run without relaxing the uid.
# /state is the worker-private working store the publisher reads; nothing else ever mounts it.
RUN mkdir -p /generations /state && chown 10001:10001 /generations /state
# NOTE: a bare `--tmpfs /var/log/claude-archive` mounts a ROOT-owned filesystem over this chowned
# directory, and the server then dies at startup with PermissionError on its log file. Pass the
# owner with the mount -- `--tmpfs /var/log/claude-archive:uid=10001,gid=10001,mode=0700` -- or use
# compose.yml, which does. Measured on this image.
RUN mkdir -p /var/log/claude-archive && chown 10001:10001 /var/log/claude-archive

# The digest this image was published under, stamped by the publisher (Release O owns the signing
# and the immutable reference). Empty by default, which the capability manifest reports as null --
# an honest "this build does not know its own digest" rather than a fabricated one.
ARG IMAGE_DIGEST=""
ENV CLAUDE_ARCHIVE_IMAGE_DIGEST=${IMAGE_DIGEST}

ARG VERSION="0.0.0"
ARG REVISION="unknown"
LABEL \
  org.opencontainers.image.title="claude-archive" \
  org.opencontainers.image.description="Read-only MCP server over a unified Claude chat archive." \
  org.opencontainers.image.version="${VERSION}" \
  org.opencontainers.image.revision="${REVISION}" \
  org.opencontainers.image.source="https://github.com/manderse-dispatch/claude-archive-mcp" \
  org.opencontainers.image.licenses="MIT" \
  org.opencontainers.image.vendor="Mike Andersen" \
  io.docker.server.name="claude-archive" \
  io.docker.server.transport="stdio" \
  io.docker.server.network="none"

USER 10001:10001

# Opens WHAT SERVE WOULD OPEN and compares user_version; exit 0 healthy, exit 2 with one stderr
# line otherwise. It reads CLAUDE_ARCHIVE_GENERATIONS through the same function the serve plane
# does, so in the generation topology it validates the current published generation -- manifest,
# both schema stamps and every hash -- and in the working-store topology it does exactly what it
# did in M2. A probe that opened /data/archive.db in either mode would report a container healthy
# while the server it probes refuses to start.
#
# Still deliberately NOT a handshake: a health check that spawned a second server and paid a model
# load every interval is a health check an operator disables. The verification it does pay for is
# measured at 0.9 s for a 1 GiB generation (reports/snapshot_publisher_measurements_1.md section
# 4), which is what the 10 s timeout below is sized against.
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD ["python", "-m", "claude_archive_mcp.healthz"]

ENTRYPOINT ["python", "-m", "claude_archive_mcp"]

# --- runtime-embeddings (the MODEL arm; --target runtime-embeddings) ---------------------------
# Suffixed on purpose. An operator who wants offline rerank and fusion asks for this one by name;
# everybody else gets the small default below without having to know either arm exists.
FROM runtime-common AS runtime-embeddings

COPY --from=builder-embeddings --chown=root:root /opt/venv /opt/venv
COPY --from=builder-embeddings --chown=root:root /opt/hf /opt/hf

# The arm label is what `container_controls.control_image_split` reads back out of each tag. Two
# tags that both resolved to the same target would otherwise pass every per-arm assertion twice
# over and prove nothing; this is the discriminator that makes the split falsifiable.
LABEL io.claude-archive.arm="embeddings"

# --- runtime (the DEFAULT arm; the last stage, so a bare `docker build .` builds it) -----------
# No torch, no sentence-transformers, no /opt/hf. rerank=true and fuse=true still ANSWER here --
# with the default lexical + sidecar tiers, a zero count and a notice naming the missing extra --
# and tests/test_container_equivalence.py pins that they never error.
FROM runtime-common AS runtime

COPY --from=builder --chown=root:root /opt/venv /opt/venv

LABEL io.claude-archive.arm="base"
