Shared Postgres — group 2 (policy + config) completion notes
Date: 2026-08-26 · Commit: c1a1b08 · Live on ahu-int-01
Spec: docs/superpowers/specs/2026-08-26-shared-postgres-state-design.md
Group 1 (accounts + threads) shipped earlier the same day. This closes the
remaining cross-app stores. All state that two apps share is now in one
database.
What moved
policies (7 versions) and config (1 row) — both written by the admin
console and read by the PUBLIC orchestrator on every request. The same
write-here/read-there shape that diverged for accounts on 24 Aug; it had not
bitten only because nobody edited a policy in between.
Verified live
console PUT /api/admin/orchestrator wall_clock_timeout 180 -> 150
chatbot.config in Postgres -> 150 (the orchestrator's own row)
/data-cfg/config.sqlite -> 180 (inert, no longer read)
/data/config.sqlite -> 20 (inert, stale, never read)
restored to 180
Console also reads the migrated history (versions 6, 5, 4) and config
(180s/90s). Guard behaviour unchanged with policy served from Postgres:
"siapa direktur PT Astra International?" refused, "ga bisa mendirikan
sendiri?" answered.
Config had a SECOND split, on a single host
/data/config.sqlite and /data-cfg/config.sqlite both existed with
different values — timeouts 20/12 versus 180/90. Both apps had since been
pointed at /data-cfg, so the stale file was inert, but it was sitting there
waiting for an env var to be pointed back at it.
Migrating the wrong one would have silently reverted the orchestrator from
180s to 20s and broken long queries. The script reads /data-cfg explicitly
and says so in a comment, because the next person will not know.
Drift found by comparing the two copies
internal-web's policy schema and defaults were both missing
refuse_without_allow_topic, the field added to public-web on 26 Aug for the
guard audit.
Harmless while they had separate tables. Not harmless now: both apps seed the
same table, so the seeded policy would have depended on which container booted
first. Synced, with a test asserting both agree.
Invariants the database now owns
| was | now |
|---|---|
upsert ran deactivate + insert as two statements; a crash between them left zero active rows — which reads as "fall back to DEFAULT_POLICY", a silent guard reset |
partial unique index: exactly one active row, and a raw INSERT of a second is rejected (tested) |
delete(version) would happily remove the active policy |
refuses, so the surface cannot drop onto DEFAULT_POLICY without someone choosing it |
config singleton relied on callers |
CHECK (id = 1) — a second row is not insertable |
save() still validates and throws rather than clamping. My first port
invented a clamp() that never existed; silently correcting an admin's input
means the console displays a number the orchestrator is not using.
Seeding moved from the SQLite constructor to boot — it is async now and cannot
live in a constructor, which is an improvement: once, explicitly, instead of on
every store construction.
A test bug worth recording
My own ConfigStore test did DROP TABLE config to exercise the degradation
path. That poisoned the schema permanently: migrate records 0002 as
applied, so CREATE TABLE IF NOT EXISTS never runs again and every later run
started with no config table. Replaced with a probe against an empty schema —
same code path, nothing destroyed.
Still SQLite, by design
staff_users and audit. Both are written and read by ONE app, so there is no
sharing problem to solve. Moving state that has no sharing problem buys nothing
and costs a migration.
ORCHESTRATOR_CONFIG_DB and ORCHESTRATOR_POLICY_DB are now dead env vars.
The files remain on disk, unread.
Rollback
/data/policy.sqlite.pre-g2-20260826-131552 and
/data-cfg/config.sqlite.pre-g2-20260826-131552, taken through the SQLite
backup API. Originals untouched. Rollback is the previous image plus those
files.
515 tests with a database; skips cleanly without one.