Layman manual
cdr_6 is the durable longitudinal store in the synthetic-data
pipeline:
sim.pdhc → cdr_6.pdhc → analyse.pdhc (future)
CREATE STORE READ + analyse
cohort data flat-row DB to be built later
sim.pdhc is the writer. analyse.pdhc (when it exists) will be the
primary analytical reader. In the meantime the sim.web run viewer,
human operators on an SSO login, notebooks, and ad-hoc psql sessions
read from the same instance. cdr_6 is the synthetic sink — a
distinct codebase from cdr1–5; it stores what sim generates and can
promote that content into cdr1–5 on request.
This manual is the operator entry point. For the architectural
reference see technical.md; for the consumer-side
integration contract see dashboard_handoff.md.
cdr_6 lives on miserver, loopback-only. There is no public URL —
not cdr_6.pdhc.se, not anything else. Every interaction goes
through:
docker-compose, psql), orsim/tunnel.py does this for sim;ssh -L 9055:127.0.0.1:9055 miserver@192.168.1.154 does itOperator is responsible for: deploy, .env, backups, key rotation.
Sim authors don't normally touch cdr_6 directly — they use
python -m sim run --target cdr_6 --tunnel and sim handles the rest.
The service does reach out to other PDHC services server-side:
sso.pdhc (to validate operator bearer tokens on reads), ips.pdhc (for
spärr blocks and analysis-consent), and cdr1–5 (for the transfer
endpoint). Those calls happen over the miserver loopback / docker
bridge — no tunnel needed.
ssh miserver@192.168.1.154
cd /usr/local/www/cdr_6.pdhc/current
docker-compose ps # see container state
docker exec -it cdr_6_app bash
psql -h 127.0.0.1 -p 9056 -U cdr_user -d cdr_6_db
From your Mac or any other dev host:
ssh -L 9055:127.0.0.1:9055 -L 9056:127.0.0.1:9056 miserver@192.168.1.154
# leave that terminal open; from another:
curl http://127.0.0.1:9055/healthz
psql -h 127.0.0.1 -p 9056 -U cdr_user -d cdr_6_db
This is what sim does automatically with --tunnel. It's what
analyse.pdhc will use too, and what a notebook would use.
For analyse.pdhc, Jupyter, or any aggregate query: tunnel 9056 and
talk SQL. cdr_6's HTTP surface is narrow on purpose — joins, window
functions, and cohort-wide aggregations belong in SQL. Note the two
tables: cdr_6_observations (the data) and cdr_6_read_audit (the
read audit trail).
.env file (operator)On miserver: /usr/local/www/cdr_6.pdhc/current/cdr_6_app/.env,
mode 600.
| Variable | Purpose |
|---|---|
COMPOSE_PROJECT_NAME |
Pins compose project name (cdr_6_pdhc) — load-bearing, don't remove |
CDR_INSTANCE |
Self-name (cdr_6); used by the transfer self-target check |
APP_PORT |
Host port for gunicorn (default 9055) |
DB_PORT |
Host port for Postgres (default 9056) |
DB_VOLUME |
Docker volume name for pgdata (cdr_6_pgdata) |
POSTGRES_USER |
DB role (default cdr_user) |
POSTGRES_PASSWORD |
Set on first deploy; only enforced from outside the container |
POSTGRES_DB |
DB name (default cdr_6_db) |
SIM_PDHC_SERVICE_KEY |
The write/strict-path token (ingest, DELETE, export, transfer) |
IPS_BASE_URL |
ips.pdhc base — spärr blocks + analysis-consent (#207/#422) |
CDR_PDHC_SERVICE_KEY |
cdr_6's own key for its outbound ips.pdhc calls |
SSO_BASE_URL / SSO_CLIENT_ID / SSO_CLIENT_SECRET |
validate operator bearer tokens on reads (#217) |
CDRn_BASE_URL (n=1..5) |
destination base URLs for the transfer endpoint |
CDRn_SERVICE_KEY (n=1..5) |
optional per-target key; defaults to SIM_PDHC_SERVICE_KEY |
CDR_TRANSFER_SOURCE_SERVICE |
outbound source header (default sim.pdhc) |
CDR_TRANSFER_TIMEOUT |
outbound push timeout, seconds (default 30) |
Never commit .env. Never paste SIM_PDHC_SERVICE_KEY (or the SSO
secret) into a ticket, log, or message. Memory
infra_ticket_api_respond_auto_closes applies — be careful with what
ends up in any system message.
Leaving the SSO_* / IPS_BASE_URL blocks blank simply disables those
integrations: bearer reads won't validate, and the spärr/consent joins
short-circuit. Leave the CDRn_* block blank until a transfer target is
actually wired — an unconfigured target is rejected at call time.
curl -s http://127.0.0.1:9055/healthz | jq .
# expected: {"status":"ok","service":"cdr_6.pdhc","database":"connected"}
503 with "database":"unavailable" → either Postgres is down or the
container has stale env (the memory infra_docker_restart_env applies).
SK=$(grep '^SIM_PDHC_SERVICE_KEY=' /usr/local/www/cdr_6.pdhc/current/cdr_6_app/.env | cut -d= -f2-)
curl -s -H "X-Source-Service: sim.pdhc" -H "X-Service-Key: $SK" \
http://127.0.0.1:9055/api/v1/runs | jq .
curl -s -H "X-Source-Service: sim.pdhc" -H "X-Service-Key: $SK" \
http://127.0.0.1:9055/api/v1/stats | jq .
Each run carries cohort_name, cohort_description, observation /
patient / concept counts, and the effective-time window. That's the
shape analyse.pdhc will browse.
The read endpoints (/runs, /runs/<id>/observations, /stats) also
accept an SSO bearer token instead of the service key (#217):
curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:9055/api/v1/stats | jq .
A bearer caller is scoped to their own organisation's rows (or sees
everything if they're an SU admin); the service key remains unfiltered.
Every read writes a row into cdr_6_read_audit.
curl -s -H "X-Source-Service: sim.pdhc" -H "X-Service-Key: $SK" \
"http://127.0.0.1:9055/api/v1/runs/<sim_run_id>/observations?limit=50" | jq .
This is the sim.web viewer path. It is the one read endpoint that
applies spärr (ips.pdhc PatientBlock) and analysis-consent
filtering before returning rows — an operator (bearer) read of a
blocked or non-consenting patient will silently drop those rows, and if
ips.pdhc can't answer the consent question the request fails closed
with 503.
curl -s -X DELETE -H "X-Source-Service: sim.pdhc" -H "X-Service-Key: $SK" \
http://127.0.0.1:9055/api/v1/runs/<sim_run_id> | jq .
200 with {"sim_run_id":"…","deleted":N} if rows were removed; 404
if no rows matched. There is no soft-delete.
NEW=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
ssh miserver@192.168.1.154
cd /usr/local/www/cdr_6.pdhc/current/cdr_6_app
sudo sed -i.bak "s|^SIM_PDHC_SERVICE_KEY=.*$|SIM_PDHC_SERVICE_KEY=$NEW|" .env
sudo docker-compose up -d --force-recreate app
--force-recreate is the load-bearing flag — docker restart alone
keeps the create-time env (memory infra_docker_restart_env).
After rotation, update every consumer's .env to match (sim.pdhc on
every Mac that pushes). Because cdr1–5 accept X-Source-Service:
sim.pdhc with the shared sim key, a rotation may also need propagating
to the transfer targets (or set a per-target CDRn_SERVICE_KEY).
cd /usr/local/www/cdr_6.pdhc/current
sudo ./start.sh
That's the single entry point per top_rules.md rule 8. It only
touches ports 9055 and 9056 — sibling services on the shared Colima
VM are unaffected.
~/pg_dump_cdr_6.sh on miserver runs nightly at 03:00 via these.pdhc.pgdump-cdr6, installed at/Library/LaunchDaemons/se.pdhc.pgdump-cdr6.plist.miserver-ops/launchd/se.pdhc.pgdump-cdr6.plist.infra_t9_spindown_trap). LaunchDaemons run in the/Volumes/T9/cdr_6_dumps/cdr_6-YYYYMMDD-HHMMSS.pgdump/Users/miserver/logs/pg_dump_cdr_6.log/Volumes/T9/.sim_run_lock exists, the wrappersim_run_lock present; skipping (so itcdr_6_dumps/.ssh miserver@192.168.1.154 'tail -40 /Users/miserver/logs/pg_dump_cdr_6.log; \
ls -lh /Volumes/T9/cdr_6_dumps/ | tail -5; df -h /Volumes/T9'
Look for:
- A done line per day
- New dump file per day, size growing with the DB
- T9 free space stable (or shrinking predictably)
- No long string of lock present, skipping unless you ran sim at 03:00
# from miserver, with the app shut down:
sudo docker-compose stop app
sudo docker exec -i cdr_6_db \
pg_restore -U cdr_user -d cdr_6_db --clean --if-exists \
< /Volumes/T9/cdr_6_dumps/cdr_6-YYYYMMDD-HHMMSS.pgdump
sudo docker-compose start app
Test against /healthz and /api/v1/runs after.
You don't normally write to cdr_6 by hand. sim.pdhc does it for you:
# on your Mac
cd ~/T7_sidewinder/sim.pdhc
.venv/bin/python -m sim run \
--profile profiles/smoke_v2.yaml \
--count 100 --seed 42 \
--target cdr_6 --tunnel
SIM_PDHC_SERVICE_KEY must match the value in cdr_6's .env. sim
opens the SSH tunnel, pushes flat-row NDJSON in batches of ≤ 1000
(HTTP 413 above that), and prints per-batch results. The cdr_6 side
dedups on (patient_guid, transaction_guid, effective_at) — re-running
the same (profile, count, seed) produces 100% duplicates and 0% new
rows (deterministic).
Two ingest field names are deprecated aliases, silently remapped and
then left NULL on the stored row (#294/#304):
activity_guid → transaction_guidauthor_org_guid → provider_org_guidNew callers should emit the canonical names (transaction_guid,
provider_org_guid, plus requesting_org_guid / requester_user_guid).
If you really need to write a single row by hand:
curl -X POST http://127.0.0.1:9055/api/v1/ingest \
-H "X-Source-Service: sim.pdhc" -H "X-Service-Key: $SK" \
-H "content-type: application/json" \
-d '{
"concept_guid":"22222222-2222-2222-2222-000000000001",
"patient_guid":"33333333-3333-3333-3333-000000000001",
"value":"42.0",
"sim_run_id":"manual-smoke-1",
"effective_at":"2026-05-30T12:00:00Z"
}' | jq .
# 202 accepted with the new row's guid
But that bypasses every sim contract — no canonical provenance, no run
manifest. Use it only for plumbing checks. (Note: the dedup key is only
computed when effective_at is present.)
For details on patterns + auth, see
dashboard_handoff.md. Short version — a
machine reader uses the service key:
import os, requests
HEADERS = {"X-Source-Service": "sim.pdhc",
"X-Service-Key": os.environ["SIM_PDHC_SERVICE_KEY"]}
runs = requests.get("http://127.0.0.1:9055/api/v1/runs",
headers=HEADERS).json()
for r in runs:
print(r["sim_run_id"], r["cohort_name"], r["observations"])
A human operator instead sends Authorization: Bearer <sso-token> and
sees only their organisation's rows (#217).
For analytical queries (joins across runs, window functions,
percentiles) go straight to Postgres on 9056. The data table is
cdr_6_observations; key columns for analytics are sim_run_id,
patient_guid, concept_guid, effective_at, value.
-- mean value per concept, per run
SELECT sim_run_id, concept_guid, AVG(value::numeric)
FROM cdr_6_observations
WHERE effective_at >= '2026-01-01'
GROUP BY sim_run_id, concept_guid;
When analyse.pdhc is built, it will own these queries; until then,
notebooks are the supported substitute.
cdr_6 can promote its own synthetic content into a working CDR. Two
service-key-only surfaces support this:
GET /api/v1/export (#399) — a complete, cursor-pagedafter_id / limit ≤ 5000 / sim_run_id), deliberatelyPOST /api/v1/transfer (#529/#530) — the server-side/ingest/batch. Because the source is always cdr_6# dry-run first: count what would move
curl -s -X POST -H "X-Source-Service: sim.pdhc" -H "X-Service-Key: $SK" \
-H "content-type: application/json" \
-d '{"to":"cdr1","dry_run":true}' \
http://127.0.0.1:9055/api/v1/transfer | jq .
# then transfer (copy). Add "purge_source":true to turn copy into move.
curl -s -X POST -H "X-Source-Service: sim.pdhc" -H "X-Service-Key: $SK" \
-H "content-type: application/json" \
-d '{"to":"cdr1","sim_run_id":"<run>","batch_size":100}' \
http://127.0.0.1:9055/api/v1/transfer | jq .
purge_source deletes the transferred run(s) from cdr_6 only after a
verified-complete copy (every batch 2xx, nothing rejected, all rows
accounted for) — a partial or failed transfer never loses data.
batch_size is clamped to ≤100. On transfer, cdr_6 synthesises a FHIR
R5 Observation per row (#531) so the destination materialises a
queryable observation, not just a staging row. plan.pdhc's trigger UI
(#530) calls this same endpoint.
| Symptom | Likely cause | Fix |
|---|---|---|
services.html shows cdr_6 green but /healthz returns 503 |
services.html uses mode: 'no-cors' and always shows green when nginx is up — don't trust it |
curl /healthz directly |
403 missing X-Source-Service |
Both auth headers absent on a strict endpoint | Set X-Source-Service: sim.pdhc AND X-Service-Key: <key> |
403 unknown source service: '<x>' |
Wrong X-Source-Service |
Must be exactly sim.pdhc |
401 missing X-Service-Key |
Source ok but no key | Add the X-Service-Key header |
403 invalid service key |
Key doesn't match .env |
Check the key / rotation drift |
401 invalid bearer token |
SSO token rejected or SSO_* unset |
Verify the token, and that SSO_BASE_URL/CLIENT_ID/CLIENT_SECRET are configured |
503 on /runs/<id>/observations for an operator |
analysis-consent fails closed — ips.pdhc unreachable | Check IPS_BASE_URL + ips.pdhc health |
App container (unhealthy), db (healthy) |
Postgres credential drift (CLAUDE.md §9) | docker exec cdr_6_db psql -U cdr_user -d cdr_6_db -c "ALTER USER cdr_user WITH PASSWORD '<env value>';" then restart app |
Connection refused |
Forgot --tunnel (sim) or no SSH forward open (curl) |
Open the tunnel |
duplicate on every row of a batch |
Same (profile, count, seed) already ingested |
Pick a new seed or purge the prior run |
transfer returns verified:false, nothing purged |
A destination batch failed (see http_errors) |
Fix the target (base URL / key / health); source is left intact |
| Backups don't appear on T9 | Disk-full, T9 not mounted, stuck .sim_run_lock, or the LaunchDaemon lacks Full Disk Access |
Check df -h /Volumes/T9, ls /Volumes/T9/.sim_run_lock, tail the log, re-grant FDA (add /bin/bash), then sudo launchctl kickstart -k system/se.pdhc.pgdump-cdr6 |
colima ssh -- docker info works but host docker ps doesn't |
Colima docker-socket forward died silently (memory infra_colima_default_socket_forward_dies) |
Restart Colima |
Does (contrary to older docs):
- Accepts SSO bearer tokens on reads, org-scoped per the SSO blob (#217).
- Calls ips.pdhc for spärr blocks and analysis-consent on
/runs/<id>/observations (#207/#422), failing closed on consent.
- Synthesises FHIR R5 Observations when transferring into cdr1–5 (#531).
- Keeps a read-audit trail in cdr_6_read_audit.
Doesn't:
- Validate FHIR on ingest — sim does that upstream; the write side
trusts sim's emissions verbatim.
- Authenticate any write/strict-path source other than sim.pdhc.
- Expose itself publicly. No DNS, no nginx, no SSL.
- Soft-delete. DELETE /runs/<id> (and verified /transfer purge) are
permanent.
- Render dashboards. Consumers (sim.web, analyse.pdhc, notebooks) render.
technical.md — architecture, schema, endpoints, authdashboard_handoff.md — consumer-sideprogress.md — current deployment statesim.pdhc/docs/phase_3_operator_runbook.md — T9 + miserver prepsim.pdhc/docs/user_manual.md — the writer's manual