← jason fetzerShift Investigatordocs
Back to the investigatorGolden evalsSynthetic data / read-only tools

Deployment readiness and low-cost path

The application runs two ways: the loopback learning workbench, and the hosted synthetic demo on Google Cloud Run at https://corrugator-shift-investigator-65612311806.us-central1.run.app. The demo answers with the offline planner by default and offers the Anthropic planner as a choice behind the guards below (issue #42; the deploy mounts no OpenAI secret unless OPENAI_SECRET_NAME names one). The deploy is keyless (issue #28): GitHub Actions authenticates to Google with Workload Identity Federation as soon as two repository variables name the pool and the deployer account, falls back to the stored key with a warning until then, and after the cutover below no credential is stored anywhere. The shape of the service (two instances, per-container ledgers, the prebuilt dataset) is written down under Decisions on record. What remains before anything beyond a demo is the release sequence further down; the September 7 review's calibration and export defects are repaired and the review itself is archived under archive/.

Implemented: hosted public-demo mode (offline planner)

PUBLIC_DEMO_ORIGIN switches the server from the loopback workbench to the hosted synthetic demo. It is the smallest change that makes the existing app safe to put behind a platform's TLS proxy; it does not replace the release sequence below for anything beyond a demo.

SettingEffect
PUBLIC_DEMO_ORIGIN (required; comma-separated https origins)Bind 0.0.0.0:$PORT (default 7860) with a threading server. The loopback Host rule is dropped (the proxy owns the hostname); every POST must carry an Origin in this list, in addition to the session token.
PUBLIC_DEMO_EMBED_ORIGINS (optional)Content-Security-Policy: frame-ancestors so the page can be framed by those sites. A Hugging Face Space page frames its own app, so include https://huggingface.co there. Default stays 'none'.
PUBLIC_DEMO_LIVE_MODEL=1 + OPENAI_API_KEY and/or ANTHROPIC_API_KEYAllows that provider's mode with the server's key under the existing reservation ledger. Off by default: the UI removes the live options and the server answers 400 to live requests. /api/config → live_modes lists what is enabled.
Visitor keysNever read in public mode. X-OpenAI-Key and X-Anthropic-Key are ignored server-side and the key field is removed client-side.

Offline investigations and PDFs run one at a time under a lock, for milliseconds (the DuckDB handles and in-memory caches are not shared across threads); static files and /api/health do not wait. A live analyst round streams outside that lock under its own two-slot semaphore and takes it only while one of its tools reads the database, so offline questions never wait behind a model call (ANTHROPIC_DEMO.md, issue #23). The in-memory 60-requests-per-minute window is per process, which on a single-container host is effectively service-wide; it is still not an abuse control. The model-budget ledger lives in the container's artifacts/ directory and resets with the container unless the host provides persistent storage — keep the provider-side spend limit as the real cap.

Anthropic on Cloud Run. The deploy workflow mounts provider keys from Secret Manager by name and passes the guards through from repository variables; no key is ever stored in GitHub. Put the key in Secret Manager once (a key used only by this demo, with a console spend limit) and let the Cloud Run runtime service account read it:

printf '%s' "$KEY" | gcloud secrets create anthropic-api-key --data-file=-
gcloud secrets add-iam-policy-binding anthropic-api-key --member "serviceAccount:${NUMBER}-compute@developer.gserviceaccount.com" --role roles/secretmanager.secretAccessor

Then set repository variables (not secrets): ANTHROPIC_SECRET_NAME = the Secret Manager secret name (anthropic-api-key above; empty = Anthropic is not mounted), OPENAI_SECRET_NAME (unset = OpenAI not mounted; the historical default openai-api-key must now be set explicitly), PUBLIC_DEMO_LIVE_MODEL=1 to enable any live provider at all, and optionally ANTHROPIC_MODEL, DEMO_RATE_LIMIT_PER_MIN, DEMO_DAILY_CAP, DEMO_MAX_TURNS (the Worker's values are 20 / 400 / 14; see ANTHROPIC_DEMO.md). The workflow always sets TRUST_PROXY=1 on Cloud Run, because its front end terminates TLS and appends the visitor's address as the rightmost X-Forwarded-For entry; without it every visitor would share the proxy's address and the per-IP limit would behave as a service-wide limit. With PUBLIC_DEMO_LIVE_MODEL unset the workflow clears the service's secrets. The daily counter lives in the same artifacts/model-budget.sqlite3 file as the monthly budget, so with --max-instances 2 and no persistent volume each instance counts separately and a redeploy resets both; the console spend limit remains the hard stop.

.github/workflows/deploy-cloudrun.yml deploys main from source (Cloud Build reads the Dockerfile) whenever the repository has the variable GCP_PROJECT_ID; it authenticates with Workload Identity Federation through the variables GCP_WORKLOAD_IDENTITY_PROVIDER and GCP_DEPLOYER_SERVICE_ACCOUNT (set-up below), falls back to the GCP_SA_KEY secret with a ::warning:: annotation on the run while either variable is missing, and fails with the instruction only when neither is available. It allow-lists every URL the service answers on — the legacy <service>-<hash>-<region code>.a.run.app form and the deterministic <service>-<project number>.<region>.run.app form, since which one gcloud reports as primary varies — as the comma-separated PUBLIC_DEMO_ORIGIN, sets PUBLIC_DEMO_EMBED_ORIGINS (variable, default https://justfetz.github.io), deploys with --min-instances 0 --max-instances 2 --cpu 1 --memory 1Gi --concurrency 20 --timeout 180, names the revision main-<sha7>-<run>-<attempt> (the commit part shortened when a long CLOUD_RUN_SERVICE leaves less than Cloud Run's 63 characters for the revision name) and routes 100% of the traffic to that name (below), and curls /api/health (and, when live mode is on, checks that /api/config reports a live mode). Optional variables: WORKBENCH_SECRET (any string; rotates the shared session token, see Current known limitations), GCP_REGION (default us-central1), CLOUD_RUN_SERVICE (default corrugator-shift-investigator), and the live-mode set above: PUBLIC_DEMO_LIVE_MODEL, ANTHROPIC_SECRET_NAME, OPENAI_SECRET_NAME, ANTHROPIC_MODEL, DEMO_RATE_LIMIT_PER_MIN, DEMO_DAILY_CAP, DEMO_MAX_TURNS. Secrets are mounted as ANTHROPIC_API_KEY / OPENAI_API_KEY from Secret Manager (<name>:latest) at start-up. After the cutover the Cloud Run path uses no GitHub secret at all (the pull-request previews below authenticate the same way); the optional Space mirror still needs HF_TOKEN. Its smoke test counts only the service's own URLs: a tagged preview revision (pr-<n>---…) answers on the same service but runs a pull request's image, so it is skipped. The run passes when one of those URLs answers /api/health with 200, checks the live modes from that same URL's /api/config when the deploy turned live mode on (PUBLIC_DEMO_LIVE_MODEL=1 and a provider secret mounted), and ends Live at <url>. That URL, also the one whose /api/config the live-mode check reads, is chosen by rank among the URLs that answered, not by their order in the allow-list: the deterministic <service>-<project number>.<region>.run.app URL (the one the README publishes) whenever it answers, else the custom domain (CUSTOM_ORIGINS, below), else the legacy *.a.run.app URL. Each URL's line in the log shows its own response body only; a curl that fails before any body arrives (Could not resolve host, for one) logs HTTP 000 with nothing after it.

One-time project setup (Cloud Shell). Skip the first block on a project that already has the deployer account:

PROJECT_ID=<globally-unique-id>          # e.g. corrugator-demo-<something>
gcloud projects create "$PROJECT_ID" && gcloud config set project "$PROJECT_ID"
# attach billing to the project in the console (Billing → link account); the demo stays inside the free tier
gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com
gcloud iam service-accounts create github-deployer --display-name "GitHub Actions deployer"
SA="github-deployer@${PROJECT_ID}.iam.gserviceaccount.com"
for ROLE in roles/run.admin roles/cloudbuild.builds.editor roles/artifactregistry.admin roles/storage.admin \
            roles/iam.serviceAccountUser roles/serviceusage.serviceUsageConsumer; do
  gcloud projects add-iam-policy-binding "$PROJECT_ID" --member "serviceAccount:${SA}" --role "$ROLE" --quiet >/dev/null
done
NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
gcloud projects add-iam-policy-binding "$PROJECT_ID" --member "serviceAccount:${NUMBER}-compute@developer.gserviceaccount.com" \
  --role roles/cloudbuild.builds.builder --quiet >/dev/null   # the build runs as the default compute service account

Keyless deploy (Workload Identity Federation)

Once the cutover below is finished the workflows hold no credential. On each run GitHub issues a short-lived OIDC token that names this repository; a Workload Identity Pool in the project trusts GitHub's issuer for exactly this repository, and the deployer service account lets that identity impersonate it (roles/iam.workloadIdentityUser). google-github-actions/auth@v2 does the exchange and the job's id-token: write permission is what allows it to request the token. After the key is deleted, nothing long-lived exists to leak, rotate or delete. deploy-cloudrun.yml and both jobs of preview.yml carry one identical credential block: both variables set means Workload Identity Federation and the log's first step says deploying as <account> through <provider>; either missing means the GCP_SA_KEY secret, with a ::warning:: annotation that names the unset variable; neither means the run fails at that step with the instruction. tests/test_repo_hygiene.py keeps the three blocks identical and runs the block's shell under all eight combinations of the two variables and the secret. Create the pool and provider once:

PROJECT_ID=<the project>; REPO=justfetz/corrugator-shift-investigator
gcloud config set project "$PROJECT_ID"
gcloud services enable iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com cloudresourcemanager.googleapis.com
gcloud iam workload-identity-pools create github --location global --display-name "GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc github-oidc --location global --workload-identity-pool github \
  --display-name "GitHub OIDC" --issuer-uri https://token.actions.githubusercontent.com \
  --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition "assertion.repository == '${REPO}'"
SA="github-deployer@${PROJECT_ID}.iam.gserviceaccount.com"
POOL=$(gcloud iam workload-identity-pools describe github --location global --format 'value(name)')
gcloud iam service-accounts add-iam-policy-binding "$SA" --role roles/iam.workloadIdentityUser \
  --member "principalSet://iam.googleapis.com/${POOL}/attribute.repository/${REPO}"
gcloud iam workload-identity-pools providers describe github-oidc --location global --workload-identity-pool github --format 'value(name)'

The last line prints projects/<number>/locations/global/workloadIdentityPools/github/providers/github-oidc. Set two repository variables (Settings → Secrets and variables → Actions → Variables): GCP_WORKLOAD_IDENTITY_PROVIDER = that value and GCP_DEPLOYER_SERVICE_ACCOUNT = github-deployer@<project>.iam.gserviceaccount.com, next to the existing GCP_PROJECT_ID. The attribute condition is what keeps a fork or another repository from using the pool, so keep it exact. Run Deploy Cloud Run (workflow_dispatch) and read Live at … at the end of the smoke test.

Cutover from the stored key. Deploys before issue #28 authenticated with a service-account key pasted into the GitHub secret GCP_SA_KEY, and so do the pull-request previews until the variables exist. Because the fallback is in the workflows, main is never undeployable and the order is: merge the workflow change (it deploys with the key and warns), create the pool and set the two variables, watch one Deploy Cloud Run run and one preview run log deploying as … with no warning, then delete the GitHub secret GCP_SA_KEY and the key itself in Google:

gcloud iam service-accounts keys list --iam-account "$SA" --managed-by user
gcloud iam service-accounts keys delete <KEY_ID> --iam-account "$SA"

Deleting the secret is what ends the fallback: from then on a run without both variables fails at its first step with the instruction above instead of deploying with a credential that was meant to be gone, and the warning on every run until then is the reminder that the cutover is not finished. After the cutover the project holds no user-managed key for the deployer, and gcloud iam service-accounts keys list … --managed-by user printing nothing is the check.

Responses over 1 KB are gzipped when the client accepts it (an answer is 20–145 KB raw, 3–21 KB on the wire; the PDF is left alone), and app.js, style.css and the pages carry an ETag with Cache-Control: no-cache, so a returning visitor's browser revalidates them with If-None-Match and gets a 304 instead of a download (issue #33). API responses stay no-store. The page builds the query boxes (a few hundred bytes per step: tool, arguments, rows, timing and the SQL) with the answer, in the first paint, and the per-step result data ("What came back", 17–70 KB) two animation frames later, so the summary, tables and charts paint first (a frame callback runs before its own paint; issues #33 and #40).

The dataset is a rolling year (issue #35): every production day (Monday to Friday) from 365 days before the window's last day to that day, each day generated from its own date-keyed random streams, so a day reads the same in every window that contains it. The image builds the year ending yesterday (UTC, at build time) once (python -m investigator.prebuilt /app/prebuilt in the Dockerfile, issue #19), with the per-shift KPI sums the period reports read (shift_kpis, issue #34), in about 15 s; the server loads it at start in about 200 ms instead of generating for about 12 s (the tables are copied in, and each production day's record objects stay pickled until a question reads that day). The window is fixed for the image: a container that starts days later still serves the window it was built with (a store with no INVESTIGATOR_AS_OF takes the prebuilt folder's window). GET /api/health reports store.source (prebuilt or generated), store.boot_ms and store.window (as_of, first_day, last_day, production_days). A prebuilt folder that does not match the code or the inputs is ignored, so a stale image falls back to generating rather than serving wrong data. INVESTIGATOR_AS_OF=YYYY-MM-DD pins the window's last day anywhere (the tests and the golden set use 2026-09-30).

The nightly deploy. A "daily load" is the window moving by one day, and nothing is loaded: deploy-cloudrun.yml also runs on a schedule (0 13 * * 1-5, 13:00 UTC Monday to Friday, which is 07:00 or 08:00 in Chicago, once the overnight shift has ended; owner decision on PR #91) and rebuilds and redeploys main exactly as a push does, so the demo's window ends on the day before the run. One Cloud Build a night. Run it by hand from the Actions tab (Deploy Cloud Run, Run workflow) to move the window now. The smoke test fails the run unless /api/health reports a window that ends yesterday (.github/scripts/check_window.py): store.window.as_of is the UTC day before the build started (or, for a build that crossed midnight, before the smoke test), and store.window.last_day is the last production day on or before it, so yesterday on a Tuesday-to-Saturday run and the Friday on a Sunday or Monday one. An image layer that froze an older window cannot pass silently. GitHub disables a scheduled workflow in a public repository after 60 days without repository activity, and the window then stops moving without an error: the Actions tab shows the workflow as disabled, and Enable workflow (or any push) restarts it.

The golden set's window. The demo's window moves every night; the golden set's never does. evals/run.py scores every case against the year ending GOLDEN_AS_OF (2026-09-30), so the image also builds that year (/app/prebuilt-golden, named by INVESTIGATOR_GOLDEN_PREBUILT), and the /evals page loads it on its first run (about 200 ms) instead of scoring against the demo's window. Memory, measured locally: 164 MB RSS after boot with the demo's year, 285 MB once the /evals page has loaded the golden year and run cases, about 340 MB with both stores holding their 64 most recently read days unpickled; a store that has to generate instead (a stale folder) peaks near 580 MB. All inside the service's 1 GiB.

Set a billing budget alert on the project regardless of how small the demo is.

Preview URL per pull request

.github/workflows/preview.yml (issue #41) gives every same-repository pull request its own URL before anything reaches visitors. On open, push and reopen it deploys the PR merged onto main as a tagged revision with no traffic — gcloud run deploy … --no-traffic --tag pr-<n> --revision-suffix pr-<n>-<sha7>-<run> — which Cloud Run serves at https://pr-<n>---<service host> while the live demo keeps 100% of the traffic on its own revision. The revision scales to zero like the service, so a preview costs nothing while nobody looks at it. The workflow posts the URL as one PR comment (edited in place on every push). On close it removes the tag, which is what stops the URL from serving, and deletes the revision — with one Cloud Run rule to know: a service's latest created revision cannot be deleted, and a fresh preview is exactly that until the next deploy of any kind. So every preview run (deploy or close) sweeps all orphaned <service>-pr-<n>- revisions — no tag, no traffic, not the one just deployed — and reports, rather than fails on, the one Cloud Run still protects; the next main deploy or preview deletes it. Untagged, zero-traffic revisions cost nothing in the meantime. Fork PRs never reach the job: they get neither the GCP_SA_KEY secret nor an OIDC token, and the job's if requires the head repository to be this one. The close job checks out main, not the PR head, because the helper script it runs is tooling the PR branch may predate (#53). Both preview jobs authenticate with the credential block of the main deploy (Workload Identity Federation once the two variables are set, the key with a warning until then, see Keyless deploy above), so deleting the key after the cutover breaks no preview.

The preview revision inherits the serving template (scaling, demo guards, mounted secrets, WORKBENCH_SECRET) and changes one variable. The server's Origin check is an exact match, so PUBLIC_DEMO_ORIGIN on the preview revision is production's list followed by the pr-<n>--- form of each entry; .github/scripts/preview_origins.py computes it from gcloud run services describe and drops any tag URL an earlier preview left behind, so the list never grows. Production's order is kept, so origins[0] — the session-token seed when WORKBENCH_SECRET is unset — is the same on both. The next main deploy sets PUBLIC_DEMO_ORIGIN outright and so resets the template. One side effect to know: the main workflow collects every *.run.app URL in the service status into its allow-list, which now includes any live tag URL; harmless (same service), and it disappears once the tags are gone.

The smoke test proves three things or fails the run: the tag carries 0% and traffic still sums to 100% on the production revisions; GET /api/health answers; and a POST /api/ask sent with Origin: <preview URL> and the session token returns 200, so the allow-list change works. Previews deploy from source like main (Cloud Build), about three to five minutes per push. gcloud run deploy --tag with a tag already on another revision moves it; if two pushes race, the per-PR concurrency group cancels the older run, while a close queues behind an in-flight deploy rather than cancelling it (so the tag it then removes is the final one). Two events can also arrive seconds apart for one PR (a reopen carries the head GitHub knew at close time, then a synchronize carries the new one) and the run for the older head may be the one that survives, so every deploy run first compares the event's head with the live PR head and, when it moved, fetches the current refs/pull/<n>/merge and deploys that; the comment names the head it deployed. A cancelled run can leave a revision that the next push or the close job deletes, since cleanup goes by the <service>-pr-<n>- name prefix.

Runs of different pull requests, and the main deploy, write the same service and are not serialized against each other. Cloud Run updates are optimistically locked: gcloud reads the service, builds the image when deploying from source, reads the service again (its source says why: "building takes a while which leaves a long time for potential write conflicts") and writes it back naming the version it just read. When another run's write lands in that last second, Cloud Run answers ABORTED: Conflict for resource '<service>': version '…' was specified but current version is '…' and gcloud exits 1 without retrying; PR #50's preview failed this way after its 50-second build while PR #54's close job was removing its tag (run 16, attempt 1), and a plain re-run passed. GitHub serializes runs only within one concurrency group (preview-pr-<n> per pull request, cloud-run-deploy for main), and one group for all of them would not do: GitHub keeps at most one pending run per group and cancels the earlier pending one when another arrives, so a third pull request's push would cancel the second's preview outright, and every preview would also wait behind a three-minute main deploy. So the writes are retried instead. Every gcloud command that changes the service — the preview deploy, the close job's --remove-tags, every revisions delete, and on main the deploy, the allow-list services update and the traffic step — runs through .github/scripts/cloud_run_update.sh, which runs the command again when Cloud Run rejected its write with that ABORTED: Conflict for resource message, and only then: four attempts in all, with a growing pause between them (15–30 s, then 30–45 s, then 45–60 s; the jitter so that two runs that collided do not retry in step). A rejected write changed nothing, so the repeat is the same command: gcloud reads the current version, and a --source deploy rebuilds, about a minute. Each retry is a ::notice:: annotation on the run; when the fourth attempt is rejected too, the step fails with gcloud's status under a ::warning::, and a re-run of the job is the remedy. Any other error is not retried. The two sweeps can also race for the same orphan revision: a revisions delete that fails is followed by a revisions describe, and a revision that is already gone (the describe says Cannot find revision […], gcloud's 404 wording, or NOT_FOUND) counts as deleted, while a describe that fails for any other reason is treated as the failed delete was, a warning in the deploy job and a failure in the close job (before, the close job failed on could not be found when the other run's sweep had won). tests/test_preview_workflow.py runs the helper under bash against a stand-in gcloud that rejects the first write, and checks that every write in both workflows goes through it. One bootstrap rule follows from the close job taking its scripts from main: a pull request that adds a script the close job calls (this one did) has to merge; closed unmerged, its close job checks out a main without the script and fails before removing the tag, which gcloud run services update-traffic <service> --remove-tags pr-<n> then does by hand.

The one change previews force on the main deploy. gcloud documents that after any --no-traffic deploy the service's traffic is pinned to a named revision, and later deploys without a traffic flag get 0% — the live demo would freeze on the revision that was serving when the first preview ran, and the health check would keep passing against it. So deploy-cloudrun.yml names the revision it deploys (--revision-suffix main-<sha7>-<run>-<attempt>; the allow-list services update, when it runs, names its own revision …-urls and pins --image to the revision this run just built, because the update otherwise copies the service's current template, which a preview landing in between could have filled with the PR's image) and after the deploy runs gcloud run services update-traffic --to-revisions <that revision>=100, then asserts that this revision carries 100%. Not --to-latest: LATEST follows whichever revision became ready last, and a preview can land between the deploy and the traffic step (the runs are not serialized, see above), so --to-latest would have put production's traffic on a pull request's revision until the next deploy of main, with every check still green. Percentages only: other PRs' tags survive. If a main deploy ever fails on the assertion, gcloud run services describe <service> --format='yaml(status.traffic)' shows what is serving.

Custom domain: shift.talktomymachine.ai

The demo also answers at https://shift.talktomymachine.ai through a Cloud Run domain mapping (Preview; free; available in us-central1). The owner verified talktomymachine.ai in Google Search Console (a TXT record at GoDaddy), created the mapping in the Cloud Run console, and added the CNAME shift → ghs.googlehosted.com at GoDaddy; Google issues and renews the HTTPS certificate. The deploy appends the domain to the PUBLIC_DEMO_ORIGIN allow-list (CUSTOM_ORIGINS, overridable with the repository variable PUBLIC_DEMO_CUSTOM_ORIGINS), so the analyst's POST is accepted from it. The main deploy's smoke test probes it alongside the *.run.app URLs; it is reported as Live at only when the deterministic URL does not answer, and a failure there (DNS, certificate) does not fail the run while another URL answers. To remove it: delete the mapping and the CNAME, and set the variable to an empty string.

Deploy to a Hugging Face Space (Docker Spaces require a PRO subscription since July 2026)

  1. Create a Space with the Docker SDK (e.g. justfetz/corrugator-shift-investigator), public visibility.
  2. In the Space's Settings → Variables add PUBLIC_DEMO_ORIGIN = the Space's direct URL (https://<owner>-<space>.hf.space) and PUBLIC_DEMO_EMBED_ORIGINS = https://huggingface.co,https://justfetz.github.io.
  3. In this GitHub repository add the secret HF_TOKEN (a Hugging Face token with write access) and the repository variable HF_SPACE = <owner>/<space>.
  4. Push to main or run the Deploy Space workflow. It force-pushes the tree with deploy/huggingface/README.md as the Space README (the front matter tells the Space it is a Docker app on port 7860).
  5. Open the Space; /api/health returns {"ok": true, "public": true, "store": {"source": "prebuilt", "boot_ms": ..., "dataset_hash": "..."}}.

The same container runs on Fly.io or Render with PORT injected; set PUBLIC_DEMO_ORIGIN to the service's https origin.

Decisions on record

Each of these was a choice, made with a measurement where there was one. Revisit them when the traffic or the backlog changes, not before.

DecisionChosenWhy, and what it costs
Instances: --min-instances 0 --max-instances 2Scale to zero; at most two containersZero keeps an idle demo free; the first visitor after idle pays the container start plus the prebuilt-dataset load (issue #19; about 150 ms on a laptop; 864 ms on Cloud Run's single vCPU, read from store.boot_ms in the smoke-test output of deploy run 46). Two, not one, because a deploy rolls a new revision beside the old one and a second container absorbs a burst while the first is inside the investigation lock. Two, not Google's suggested three, because every extra container is another independent ledger (next row) and the demo's traffic has never needed it. Issue #17 made two instances safe to run: the session token is derived, not minted per process, and an unknown conversation id starts a fresh conversation instead of failing. Since issue #20 an offline answer and its PDF are a GET of the same address, so no instance needs another's report cache; only a live answer's PDF still depends on the instance that produced it.
Per-container ledgersAccepted, documented, not fixedThe 60-requests-a-minute window, the per-IP DEMO_RATE_LIMIT_PER_MIN, the daily DEMO_DAILY_CAP and the $5 monthly budget all live in the container (artifacts/model-budget.sqlite3 for the last two). With two instances a visitor can see up to twice the configured number, and a deploy resets all of them. The Anthropic console spend limit on a key used only by this demo is the hard stop, and the offline default (next row) means a visitor spends nothing unless they choose the model. A shared ledger (Firestore, Memorystore) is release-sequence work, not demo work.
Planner defaultOffline; the model is a choice (issue #42)Measured from the owner's browser and recorded in issue #42 and the commit that closed it: "Why was shift 2 slow?" took 17,970 ms with five model calls in Anthropic mode and 93 ms offline. The dropdown keeps the model with help text saying what it adds and that it takes seconds under the site's spend cap.
DatasetA rolling year, built into the image at docker build (issues #19, #35) and moved by a nightly deploypython -m investigator.prebuilt /app/prebuilt runs once per image (about 15 s for the year); the server loads it in about 200 ms instead of generating for about 12 s on every cold start. The window ends on the build's yesterday and stays fixed for the image, so the content hash is stable per image and moves once a night. The generator stays the source of truth: a prebuilt folder whose fingerprint does not match the code or the inputs is ignored and the server generates instead, so a stale image serves slow data, never wrong data. GET /api/health reports store.source and store.boot_ms; the dataset hash in evals/history.jsonl did not change.
Golden set in the image (issue #26)COPY evals ./evals, INVESTIGATOR_EVALS=/app/evals/evals lists every golden case, its last tracked result and the score history from the files the image was built with (about 0.5 MB with the recordings). The page runs a case with the offline planner or replays its recording; it never calls a model, with or without a server key. history.jsonl and last_run.json change only through a pull request: the page has no write path. A whole-set run (about 3 s of CPU) takes the investigation lock one case at a time and is served from the previous run for a minute. Runs from the page have their own window (120 a minute for everyone together) and are not counted against the question limits or a visitor's per-IP rounds.
Docs in the image (issue #93)COPY docs/*.md ./docs/, rendered once by python -m investigator.docsite build /app/docs-site --root /app, INVESTIGATOR_DOCS=/app/docs-site/docs serves the prebuilt pages with an ETag like the other static files; nothing is rendered per request and the renderer is the standard library. The changelog page is docs/CHANGELOG.md, which the deploy of main regenerates before the build, so it lists the pull request being deployed. That step can never fail the deploy: it fetches the full history itself (the checkout is unchanged), it is continue-on-error with a three-minute timeout, and the generator replaces the committed file only with a list at least as long, written by rename; anything else ships the committed file. Once the revision is live, the step Changelog lists the merged pull request reads /docs/changelog from it and fails the run when the merge being deployed is missing (.github/scripts/check_changelog.py; the owner's rule of 2026-11-12, every merge is on the changelog): visitors keep the revision, and a stale page turns the run red instead of shipping silently. A preview builds the committed file. /guide, the old page, is a 301 to /docs.
Container size: --cpu 1 --memory 1Gi --concurrency 20 --timeout 180One small containerThe whole dataset is in-memory DuckDB and an answer takes tens of milliseconds offline. Concurrency 20 is fine because static files and /api/health never wait on the investigation lock; investigations and PDFs serialize per container. 180 s covers a live-model investigation with its ten tool calls.
Deploy credentialKeyless, Workload Identity Federation (issue #28)A pasted service-account key was the one long-lived secret in GitHub and the one thing a repository leak would have handed over. The pool trusts one repository and the job's OIDC token lives for one run. The key stays as a fallback, announced by a warning on every run that uses it, only until the two variables exist; the cutover ends with its deletion. See "Keyless deploy" above.
Where keys liveSecret Manager, mounted by nameThe workflow passes secret names through repository variables; Cloud Run reads the value at start. No key value ever passes through GitHub.

Use one small Google Cloud Run service for the public offline demo. Cloud Run can scale to zero and charges for consumed resources after its free tier; exact cost depends on region, traffic, build storage and network use. The official pricing page currently lists a request-based free tier, but treat that as a discount rather than a promise of a zero bill: https://cloud.google.com/run/pricing.

Cloud Run requires the container to listen on 0.0.0.0 and the injected PORT; the default workbench binds to 127.0.0.1, and public-demo mode (above) is what makes the container contract hold. See the official container contract: https://docs.cloud.google.com/run/docs/container-contract.

Release sequence

  1. Replace the built-in HTTP server with a production ASGI application and server if traffic ever warrants it. Preserve the existing request schemas, response shapes and tests. (Public-demo mode uses the standard-library threading server with a serialized investigation lock, which is enough for a demo.)
  2. Container with a non-root user, a health endpoint and 0.0.0.0:$PORT binding: done (Dockerfile, /api/health; /healthz also answers, but Google's front end reserves that path on *.run.app and never forwards it, so the smoke test and container healthcheck use /api/health).
  3. Offline-only public configuration: done (PUBLIC_DEMO_ORIGIN without PUBLIC_DEMO_LIVE_MODEL). Server-held Anthropic live mode with per-IP and daily round guards: done, behind the DEMO_* variables, and exercised against the real endpoint by the recorded live goldens.
  4. Configured canonical origins instead of the loopback Host/Origin rules: done. The static-file allowlist, content-security policy, bounded JSON and text-only rendering are unchanged.
  5. Make PDF generation stateless or use a durable, short-lived report reference. The present report cache is process memory; another instance or a restart will not have it.
  6. Put a durable edge or datastore-backed rate limit in front of /api/ask and /api/report. The current in-memory 60-request window is per process and is not a public abuse control.
  7. If anonymous public querying is allowed, add an abuse challenge and validate it on the server. Cloudflare says Turnstile client tokens require server-side Siteverify validation, are single-use and expire after five minutes: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
  8. Deploy staging with minimum instances zero and a small service-level maximum instance count. Google documents maximum instances as a cost guard and recommends starting at three, while noting that the limit can briefly be exceeded: https://docs.cloud.google.com/run/docs/configuring/max-instances. The demo runs two; the reasoning is under Decisions on record.
  9. Add cloud billing alerts, request/error/latency dashboards, dependency and container scanning, deploy logs, and an immediate disable/rollback procedure. Load-test the period report and PDF endpoints with synthetic requests.
  10. Map the custom domain only after staging passes the security and behavior checks. Cloud Run terminates TLS for the service; keep the application HTTP-only behind that boundary as required by the container contract.

Live models on the hosted demo

The hosted analyst is Anthropic's, on the server's key: Ask the analyst on an answer, behind the per-visitor rate limit, the daily round cap, the monthly reservation ledger and the console spend limit (Anthropic in the hosted demo). What the model may and may not do is on the analyst page.

OpenAI is an optional provider. The deploy mounts its key only when the repository variable OPENAI_SECRET_NAME names a secret. Before offering it on the hosted demo:

What holds for either provider before the demo becomes more than a synthetic showcase:

  • Durable limits and identity. The guards are per container (see Per-container ledgers above), so the console spend limit is the real stop. Wider access needs user identity and per-user, per-IP and service-wide limits in a shared store, checked before each provider request. If anonymous access stays, send the provider a stable hashed session identifier so abuse signals can be linked without identity; OpenAI documents safety_identifier for this: https://help.openai.com/en/articles/5428082.
  • Secrets stay managed. Least-privilege access, audited rotation and access logs on the keys: https://docs.cloud.google.com/secret-manager/docs/best-practices.
  • No visitor keys. The loopback key field is a learning feature. A hosted bring-your-own-key proxy would still receive visitors' credentials, with the trust, logging and disclosure obligations that brings.
  • Evaluate the hard questions. Keep the asking questions guidance at the question entry point (/docs/asking-questions), and evaluate broad requests such as "How can we be better?", unsupported savings estimates and requests to prescribe machine adjustments. Outputs must stay in the selected scope, unavailable evidence must stay unavailable, and a reader must be able to tell a recorded loss from a proven cause or an achievable gain. The rules prove that every number came from a query; they do not prove the advice is useful.

Outstanding decisions

  • Public-demo release also requires the order/quantity calibration described in METRICS.md. Order identities and remaining quantities now reconcile across shifts. Quantity sampling, cutoff alignment, accepted delivery and replacement orders still need further calibration. Keep requested waste/location categories distinct from proven causes.

  • The daily LLM narrative is implemented with field-reference validation and deterministic fallback. Follow WORKBENCH.md and run live quality evaluation before enabling it publicly.

  • Confirm the plant's actual 4-4-5 fiscal-year start date and week-start convention. Monday 3 August 2026 (52-week years of four 4-4-5 quarters, the prior year 52 weeks earlier) is only a demo anchor.

  • Confirm plant timezone and daylight-saving treatment for the 07:00 production-day boundary.

  • Decide whether public access is fully anonymous, invitation-only or account-based. This changes the correct rate-limit and model-budget design.

  • Choose the final host, region, domain and acceptable monthly cost ceiling.

  • Decide whether PDF reports must survive restarts and multiple instances.

  • Select an email provider, verify a sender/domain, and define consent, unsubscribe and retention rules before adding delivery.

  • Run one deliberate live OpenAI evaluation (evals/run.py --live --provider openai) and record its fixtures before offering that provider anywhere but the local workbench.

  • Expand synthetic scenario calibration and add more than one month before treating trends as representative.

  • Define a privacy and retention policy before connecting real production data. No private plant records belong in the public demo.

Current known limitations

  • Automated checks pass, including quantity/time audits, narrative validation/fallback, period PDFs and retained comparison history. They do not certify model prose quality, all question semantics or visual PDF layout. See TESTING.md; the September 7 review that first listed these gaps is archived under archive/.
  • The URL answers of issue #20 (GET /corr/….json, format=pdf) carry no session token, by design: a GET reads nothing secret and changes nothing. The cost is that a third-party page can make a visitor's browser fetch such addresses and spend that visitor's own per-IP rounds; no key, no model and no shared cap is reachable that way.
  • The web server, rate limit, report cache and model-budget database are single-process local implementations. Since issue #17 the session token is shared across instances and restarts in public mode (derived from WORKBENCH_SECRET, or from the demo origin when that is unset; the value is public by design, since /api/config serves it, and the CSRF defence is the Origin allowlist plus the custom header), the browser refreshes /api/config once and resends on a 403, and a conversation id this instance has never seen starts a fresh conversation (the answer carries session_reset: true and the page says so) instead of failing with "Session expired". Since issue #20 an offline question is a URL (/corr/…?measure=…, URLS) and its PDF is that URL with format=pdf, rendered on request by whichever instance answers, so the per-instance report cache no longer matters there. A live answer's PDF still comes from the retained report of the instance that produced it: with two instances, a PDF requested from the other returns 400 "Report expired; run the investigation again".
  • The data covers one rolling year (about 262 production days, one seed), ending on the day before the image was built. Windows reaching before its first day or after its last are correctly shown as unavailable, never as zero. The seeded stories keep their 2026 dates. The slitter rebuild's trim creep runs from 20 August to 31 October 2026, after which trim is back at its pre-story level (owner decision on PR #91); its knife-jam effect has no end date and stays at three times the baseline.
  • The synthetic paper-run-size distribution is plausible demo behavior supplied by code, not a measured plant distribution.
  • Notes and reason codes are synthetic observations; recurrence does not prove causation.
  • Email is not implemented. Live OpenAI access is implemented locally and has no recorded fixtures; the Anthropic path is the one scored by the golden set.
  • Local browser checks verified calendar-month and 4-4-5 heatmap shading, complete day details, daily offline investigation, OpenAI mode controls, and the question-guidance link on September 6, 2026. Broader browser and assistive-technology QA remains before public release. The implementation follow-up records the newer test and browser results.

Rendered from docs/DEPLOYMENT.md in the repository.