RFC 0110: Daemon → PostgreSQL authentication and the database-enforced write boundary
June 29, 2026 · View on GitHub
Status: accepted (D164); implementation spec published 2026-06-03 (see below)
Date: 2026-06-03
author: proposer-claude-opus-4-8-001
Context: RFC 0033 (PostgreSQL as sole substrate; §3 append-only audit invariant), RFC 0043 (daemon-required runtime), RFC 0079 §5 (owner-applied migrations), RFC 0096 (supervised-lane trust boundary; session-bound capability tokens), RFC 0107 (multi-principal trust model), GH #87 (lane PG-reachability). Touchpoints: go/pkg/db/connection.go (Connect/ConnectAndMigrate/ResolveConfig, pgx v5 + pgxpool, simple protocol), go/pkg/db/audit.go (AuditRecorder, V2RowHash, VerifyRows), go/pkg/reads/doctor_lane_sandbox.go (the advisory lane_pg_reachable warning), go/pkg/mutations/supervision_control.go (lane env allowlist), docs/how-to/postgres-transition.md (the documented striatumd_rw / owner two-role posture), migrations 0001/0005/0006/0013 (existing REVOKE UPDATE/DELETE on append-only tables).
Implementation spec (cycle-2 + revision deltas, accepted)
The authoritative implementation spec is
docs/operator/artifacts/rfc-0110-pg-auth/spec_publication/synthesis/SPEC_PUBLICATION.md, produced by the 3-model adjudicated-constraint-extraction design panel (run_8e14cb48, accepted via operator decisiondec_b95396ff). It carries the normative constraint→gate→lands matrix (§14) and the gate-first release sequencing (§15). Where the prose below disagrees with the published spec, the spec governs. The narrowing/hardening deltas it introduced over the original proposal:
- The L1 trust boundary is a non-spoofable daemon-authority gate, not GUC
attribution. Every owner-owned
SECURITY DEFINERwrite function begins withassert_daemon_authority(), which comparessha256(presented‖salt)against a digest held in an owner-onlydaemon_auth_registrythe runtime role cannot read; the secret is a RAM-onlycrypto/randvalue generated per daemon instance. Astriatumd_rwcaller cannot forge it, read the digest, or learn it from the function (closes the cycle-1 criticalC-EXEC-AUTH). - L3 attribution is corrected to an in-transaction prelude over the extended
protocol — not
pgxpool.BeforeAcquire(aSET LOCALinvariant cannot hold there, sinceBeforeAcquirefires before the mutation transaction). A single constructorBeginAuthorizedMutationbegins the tx and issuesset_config('striatum.daemon_auth', secret, true)(statement 1) plus therpc_id/principal_id/session_idlabels via a bound-parameterExecBoundpath (pgx.QueryExecModeExec), so neither secret nor labels appear inpg_stat_activity.query(closes the cycle-2 criticalC-EXTENDED-AUTH-PRELUDE; the pool default stays simple protocol for multi-statement migration DDL). GUCs are labels only, never authority (C-GUC-NONAUTH); RLS row-scoping is defense-in-depth under an already-authorized session. - The in-DB chain hash uses a new v3 length-prefixed
byteacanonical, not a PL/pgSQL port of Goencoding/json. Escaping-free and key-order-free, so it is byte-identical in Go (V3RowHash) and PL/pgSQL by construction;V2RowHashis preserved permanently as the reader of pre-cutover rows;VerifyRowsdispatches onhash_format_version(unknown ⇒ verifier failure). The cutover is one release gate (R-V3) behind an operator flag defaulting to v2. - The events surface (P2
full) computes its v3 chain hash entirely in-DB, with no Go counterpart (append_event_row→event_v3_row_hash, owner bundle 0004). This is the rigorous in-DB path that still satisfies G1 with zero Go↔PL/pgSQL porting hazard, because — unlike the audit chain, whose doctor verifier recomputes row hashes in Go — nothing in Go ever recomputes an event row hash (canonicalEventHashis write-only; the only event-chain verifier checks chain linkage, not hash content). The chain continues linearly across the v2→v3 boundary (the SD fn reads the head'slast_hashasprevious_hash). Transcript exclusion (C-EVENT-NO-TRANSCRIPTS) is enforced in the same SD fn — a payload with a top-levelstdout/stderr/transcript/raw_output/provider_outputkey, or one over a 256 KiB cap, isRAISEd (23514) before any row lands. See decision D167. - Audit append becomes fail-closed and mutation-coupled. For mutating RPCs
the audit row is the final write inside the same transaction (atomic with
the mutation); standalone appends (reads/denials/transport errors) convert an
append failure into an
audit_append_failederror. The today's fail-openauditErr-ignored path is removed. - Claims are narrowed and phase-keyed. G1 (invariant integrity) and G2
(daemon issuance) are separated; the "the daemon's durable write paths are
DB-enforced" claim is reserved to phase P2
fullunder the fixed phase nomenclature P0audit_only→ P1audit_artifacts→ P2full, each keyed to the doctorpg_write_boundaryposture string. The earlier "a leaked DSN is uninteresting" framing is retracted as overbroad: read confidentiality against a live runtime credential is not claimed this phase (bounded by L0 rotation + L2 isolation); a read-scope least-privilege successor (#164) is filed before the first behavior-changing PR merges. - #87 status language is fixed: "mitigated, pending lane-OS-user default."
#87 closes only when all four L2 gates are live — the PG-less lane OS user, the
0700startup-asserted socket directory,T-LANE-ISOLATION-NEGgreen in CI, and blockingdaemon doctorfor PG-reachable lanes under the secure profile. The negative gate is implemented asmake lane-isolation-check; it requires a configured lane OS identity plus explicit UNIX-socket and loopback TCP probe URLs and fails if that identity can connect over either path. - Owner DDL ships as versioned atomic owner bundles (
go/pkg/db/sql/owner/) applied out-of-band as the owner role, with a startup capability-parity check (both directions) and an N→N+1 two-release sequencing so the old-binary check is real.pgtestconsumes the production owner-bundle SQL (no imperative GoGRANT) so the42501negative-path gate cannot false-green. - Authority validity is lifetime-of-instance (no freshness window — the
self-wedge mode is structurally absent); a missing/superseded registry row
raises a loud
daemon_auth_lostdiagnostic. The concurrent-rotator probe is role-scoped (daemon_auth_registry.role_name) so per-instance roles on a shared PG don't false-trip.
The four-layer model below (L0–L3), acceptance, non-goals, sequencing, and the rejected-alternatives appendix remain the design context; the published spec is the binding implementation contract.
Problem
striatumd is a long-lived local process that owns the authoritative PostgreSQL
state. How it authenticates to that database is currently underspecified and
sits in one of two postures, each with a distinct hole:
-
Unix-socket PEER auth as the daemon's OS user, no password (the live deployment, e.g.
postgres://halbritt@/postgres?host=/var/run/postgresql). Convenient and secret-free, but it conflates the daemon's identity with its OS user. Supervised AI agent lanes (untrusted coding agents) are spawned as that same OS user, so a lane can open PostgreSQL directly over the socket and bypass the artifact/RPC API and the hash-chained audit log entirely — the #87 trust hole. The env allowlist (supervision_control.go) scrubsPG*/*DSN*/DATABASE_URL, but PEER needs no secret, so scrubbing does not help. The only mitigation today is an opt-in, advisory-only dedicated PG-less lane OS user (STRIATUM_LANE_OS_USER), surfaced as alane_pg_reachablewarning indaemon doctor— never enforced. -
Password auth as a least-privilege
striatumd_rwrole (docs/how-to/postgres-transition.md). This separates the daemon's PG identity, but introduces a standing secret in a config file — a leak surface that survives restarts and never rotates — and still does nothing about a same-host lane that obtains it.
Three further forces are unaddressed:
- Migrations need a higher-privileged owner role the runtime role lacks
(
--as-owner, RFC 0079 §5) — a second credential, needed at the worst time (upgrade). - The future is multi-principal (RFC 0107: several humans + AI operators
sharing one
striatumd+ PG across repos) and possibly PG on another host (PEER is same-host-only). - The audit log knows the daemon wrote a row, not which RPC/principal caused it — an attribution gap RFC 0107 needs closed.
The unifying observation: today the "artifact/RPC API is the sole write path" invariant is enforced only in the daemon process. Anything that authenticates to PG as the runtime role — including a lane that scraped a credential — can write the authoritative tables directly. Authentication should not be the only thing standing between an untrusted same-host process and the audit chain.
Proposal (design RFC; phased implementation follows)
A layered model. Each layer is independently shippable and independently valuable; together they make the write contract true in the database, not just in the process. The design rule throughout: make a leaked runtime credential uninteresting rather than relying on perfect secrecy.
L0 — Credential: ephemeral, owner-bootstrapped, RAM-only
At startup striatumd opens a short-lived owner connection over the
existing unix-socket PEER path (the same path --as-owner migrations already
use), runs ALTER ROLE striatumd_rw PASSWORD '<crypto/rand>', closes the owner
connection, then opens its runtime pgxpool as striatumd_rw with the password
held only in process memory (zeroed after pgxpool.ParseConfig consumes
it). The password is never written to disk, env, or daemon.toml. Every
systemctl --user restart striatumd re-rotates, so any password a lane scraped
from a previous run's core dump or swap is already dead.
- Remote-PG escape hatch: when PG is on another host there is no owner-PEER
socket. Add an explicit
STRIATUM_OWNER_DB_URL(parallel to the existingSTRIATUM_DAEMON_DB_URL) for the bootstrap connection; when unset, fall through to PEER. For the at-rest secret in this case, prefer a systemd encrypted credential (LoadCredentialEncrypted=+sd_get_credentials(), TPM/host-key encrypted, never in/proc/<pid>/environ) over a plaintext file — near-zero daemon code, and the lowest-friction floor for operators who cannot use owner-PEER bootstrap. - Single-role dev guard: if the resolved owner role is the runtime role
(common in dev), skip rotation with a
WARNrather than rotating the password out from under the bootstrapping connection. - Two-role adoption prereq (PostgreSQL 16+, GH #169): a non-superuser owner
role may only
ALTER ROLE striatumd_rw PASSWORD …when it holds admin option on the runtime role (PG 16 removed the blanket "CREATEROLEcan alter any role" behavior). Grant it once as a superuser:GRANT striatumd_rw TO <owner> WITH ADMIN OPTION, INHERIT FALSE, SET FALSE. A superuser owner or the single-role posture needs no grant. The operator runbook (docs/how-to/postgres-transition.md) carries the copy-paste form; without it, boot fails closed (§9.2) withdaemon_pg_owner_bootstrap_failed. - Diagnosability:
daemon doctorgains adb-credential-postureprobe that asserts a password is set (SELECT rolpassword IS NOT NULL FROM pg_authid …) without ever reading or logging the value; the daemon emits a single structureddb_credential_rotatedlog line on success.
L1 — Enforcement: PostgreSQL guards the write contract
Make striatumd_rw unable to bypass the audit/artifact contract at the
database level. Migrations 0001/0005/0006/0013 already REVOKE UPDATE/DELETE
on audit_log/events/artifacts (RFC 0033 §3 append-only). The next tier
also revokes INSERT and exposes writes only through SECURITY DEFINER
PL/pgSQL functions owned by the owner role, which enforce the invariants
currently checked in Go (hash-chain append locked atomically with the mutation;
attempt-scope artifact immutability). striatumd_rw keeps only EXECUTE.
Result: an attacker who obtains the runtime DSN cannot forge artifacts or
tamper with the audit chain — the role literally lacks table-level DML.
- Phasing: Phase 0
audit_log(highest value, single call siteAuditRecorder.RecordRPC) → Phase 1artifacts(attempt-scope immutability in-DB) → Phase 2events. Each phase is provable green before the next. - RLS as a second tier, not the primary: enable Row-Level Security on
per-session tables (
leases,sessions) keyed oncurrent_setting('app.session_id'), set per transaction. Functions own the write path; RLS limits which rows a valid call may touch. - CI gate: the
pgtestharness already runs mutations through a per-teststriatumd_rw_<db>role. Add a negative-path test asserting a directINSERTfrom that role fails with42501(insufficient_privilege), so the REVOKE invariant is machine-verified on every migration forward.
L2 — Isolation: lanes cannot reach PG out-of-band, by default
Promote the dedicated PG-less lane OS user from opt-in advisory to the
hardened default, and back it with a filesystem boundary: move PG's socket
into a 0700 directory (unix_socket_directories) owned by a daemon identity
distinct from the lane identity, so a lane cannot traverse the directory to
reach the socket even sharing nothing else. doctor_lane_sandbox.go escalates
lane_pg_reachable from a warning to a startup-blocking error when no
distinct lane user is configured — gated behind a config flag
(security.pg_socket_hardened, currently exposed as
STRIATUM_SECURITY_PG_SOCKET_HARDENED, default-false on upgrade, flipping to
default-on a minor version later) so existing installs are not stranded on upgrade day.
Also scrub PGHOST from the lane env (defense-in-depth against a lane handing a
guessed socket path to a libpq tool).
make lane-isolation-check is the configured T-LANE-ISOLATION-NEG harness for
the hardened-profile CI/operator job. It is not part of the ordinary unit suite
because it depends on host OS users, sudo, psql, and PostgreSQL listener /
pg_hba.conf posture.
L3 — Attribution: every mutation names its RPC and principal
Add a pgxpool.BeforeAcquire/AfterRelease hook pair in connection.go that
issues SET LOCAL striatum.rpc_id = '<id>' and SET LOCAL striatum.principal_id = '<principal>' at the start of every transaction
(cleared on release so provenance never bleeds across pooled checkouts). The
hash-chained audit log then attributes every row mutation to the originating
RPC call and principal, not merely "the daemon process" — directly closing
the RFC 0107 attribution gap. This layer is useful standalone, today, before
any other layer lands. Record auth posture and transitions in a daemon-owned
daemon_auth_log table that daemon doctor can read via the owner
connection even when the runtime credential is broken — turning the
"we rotated something and forgot to tell the daemon" 3am page into a one-command
diagnosis.
Future — cross-host identity
For the multi-host path, issue a client TLS certificate from a local
self-signed CA generated at daemon init (no external CA, no ACME, no cloud),
with daemon doctor warning 30 days before expiry. Purely additive: the
same-host default stays owner-PEER bootstrap + L1/L2.
Acceptance
- L0: a fresh
striatumdstart rotates thestriatumd_rwpassword with no on-disk secret in the default local-PEER posture; a captured runtime DSN from before a restart fails after it;daemon doctorreports credential posture without leaking the secret. Remote-PG operators have a documentedSTRIATUM_OWNER_DB_URL/ systemd-credential path. - L1:
striatumd_rwcannot directlyINSERTintoaudit_log(thenartifacts,events) — proven by apgtestnegative-path test asserting SQLSTATE42501; the hash-chainVerifyRowsinvariant still holds end-to-end after the write path moves behindSECURITY DEFINER. - L2: with the hardened default configured, a lane process cannot open the
daemon's PostgreSQL socket;
daemon doctorblocks (not just warns) when no distinct lane identity is configured under the enforcement flag. - L3: every authoritative mutation carries an attributable
rpc_id+principal_idreadable from the audit log; provenance resets across pool checkouts (test-pinned).daemon doctorcan diagnose auth failures via the owner connection when the runtime credential is dead. docs/reference/spec.mdcarries the daemon→PG authentication model and the database-enforced write-boundary invariant;docs/decisions/decision-log.mdrecords the decision on acceptance.
Non-goals
- Not SaaS / hosted identity. No external IdP/SSO, no hosted control plane, no telemetry, no new hosted services (reaffirms RFC 0107). The local self-signed CA and systemd credentials are entirely operator-owned.
- Not a PostgreSQL C extension or background worker. In-database
enforcement uses only stock PL/pgSQL + RLS + GRANT/REVOKE — no compiled
.so, no PG-version-fragile native code. - Not a from-scratch PG wire proxy. The L2 boundary is a
0700socket directory + distinct OS identity, not a new in-process protocol server (that alternative is recorded as rejected below). - Does not, by itself, deliver the RFC 0107 multi-principal model — it supplies the credential, enforcement, isolation, and attribution substrate RFC 0107 builds the principal layer on.
Sequencing
L3 (attribution) and the L0 daemon doctor posture probe are the cheapest and
land first (no behavior change, immediate RFC 0107 value). L0 credential
rotation and L1 Phase 0 (audit_log) follow as the security core. L2 ships
behind the default-false enforcement flag so upgrades are non-breaking; the flip
to default-on is a separate, announced minor version. Cross-host certs are
deferred until a real multi-host deployment exists. This RFC is sequenced
after the RFC 0104/0105 reliability foundation and does not block RFC 0103's
remaining work.
Relationship to prior RFCs
- RFC 0033 §3 (append-only audit invariant) is the seed L1 generalizes: from "revoke UPDATE/DELETE" to "revoke all direct DML; writes only via vetted owner-owned functions."
- RFC 0079 §5 (owner-applied migrations) supplies both the L0 bootstrap path
(owner-PEER
ALTER ROLE) and the L1 function DDL delivery (--as-owner). - RFC 0096 (lane trust boundary, env allowlist, session-bound tokens) is the
per-session substrate; L2 hardens its #87 residue from advisory to default,
and L3's
app.session_idRLS reuses its session binding. - RFC 0107 (multi-principal) consumes L3 attribution (per-principal audit rows) and L1 isolation (cross-principal write confinement) directly.
- GH #87 (
doctor_lane_sandbox.go) is the open item L2 closes structurally.
Appendix — rejected alternatives (ideation trap log)
Recorded so the design record carries why these were declined:
- PG C background worker / hook checking a daemon-signed token per DML — shipping and maintaining a compiled C extension for a Go local-first tool; fragile across PG versions and a heavy install burden. L1's PL/pgSQL functions achieve in-engine enforcement with stock SQL.
- Run PostgreSQL inside a Linux user namespace so the daemon's effective UID
differs from lanes' — changes the deployment model and is fragile under
systemd --user. seccomp-blocksocket()in the daemon after the pool opens — also blocks the daemon's own reconnect on PG restart / pool churn; it would page on-call rather than protect.- Custom PAM module verifying the connecting process's ppid chain — non-portable, and ppid is spoofable once the daemon exits.
- Quorum N-of-N PL/pgSQL gate that unlocks the role — security theater; a compromised subsystem is already inside the quorum.
pg_notifynonce consumed at connect / systemd socket-activation fd handed to a client — infeasible as stated: authentication precedes any SQL, and PostgreSQL owns its own listen socket (a client cannot be handed it viaSD_LISTEN_FDS). The salvageable "access path constructed before lanes exist" insight survives only inside a daemon-owned forwarder.- "PostgreSQL listens on a Linux abstract-namespace socket" — PG binds only
filesystem sockets; an abstract socket is reachable solely in front of a
daemon-owned forwarder, which L2 deliberately avoids in favor of the simpler
0700filesystem-socket directory.
Provenance: this RFC's option space was generated by the adhd divergent-ideation
skill (5 cognitive frames × 6 ideas → score/cluster/trap → deepen top 3); the
trap log above is its critic phase, preserved.