Evidence relay daemon and session-fence authority (local, default-off)
Proposed record for the first deployable evidence relay process: an explicitly gated, least-privilege daemon that authoritatively rejects tombstoned or stale-version evidence using a PostgreSQL session fence.
ADR-0085: Evidence relay daemon and session-fence authority (local, default-off)
- Status: proposed for owner review;
implemented-localin issue #1002. This ADR is not an acceptance record and GAP-011 remains open. - Date: 2026-10-19
- Owners: Evidence/Operations + Platform/Data + Security/Privacy; Rick1330 acting across roles; no independent reviewer named.
- Scope: a single deployable evidence relay process, its default-off feature gate, its least-privilege PostgreSQL identity, and its authoritative session fence check
- Supersedes: none; narrows the accepted
evidence-relay-sink.v1shape (decision 21) from "library only" to "library plus a local, default-off daemon" without accepting a new contract.
Context
The accepted evidence-relay-sink.v1 contract shape (decision 21, 2026-10-10) fixes PostgreSQL as the evidence/outbox authority and ClickHouse as a non-production projection pilot. Until this change the repository had only the packages/evidenceoutbox library: NewRelay was invoked by tests, and the pilot was "default-disabled by the absence of a runtime daemon", not by a tested flag. The gap register recorded that the shared relay "still has no production daemon/feature gate or tenant/agent allowlist" (GAP-005/GAP-011 finding).
That left two unresolved problems for the security-sensitive relay path:
- There was no way to run the relay as a bounded process with a least-privilege identity, so the retry/poison/shutdown semantics could not be exercised outside unit tests.
- There was no authoritative, deployment-real fence check. The producer reads the PostgreSQL session lifecycle row, but nothing in a relay process verified the same row before projecting, which is the tombstone/version race GAP-005 and GAP-011 describe.
Decision
1. Add services/relay as a deployable process, default off
services/relay follows the existing Go service layout (cmd/relay entrypoint, internal/{bootstrap,config,fence}). The process is inert unless IBEX_RELAY_ENABLED=true (default false); a disabled daemon with valid configuration logs a documented reason and exits 0 (configuration, including the required explicit IBEX_ENV, is validated before the gate, so an invalid environment fails closed first). Enabling it requires both a PostgreSQL DSN and a ClickHouse DSN — startup fails closed (exit 1) rather than degrading to a partial runtime. When enabled, a missing or unreachable dependency is a fatal startup error, not a silent fallback.
The runtime loops claim → fence-check → deliver (ClickHouse pilot sink) → finalize, recovers stale in-flight claims, retries with bounded jittered backoff, and dead-letters permanent failures. Shutdown stops claiming, drains the in-flight batch, and stops reporting readiness before closing dependencies; claims abandoned by an interrupted batch are left to the loop's stale-recovery window on restart rather than released at shutdown (releasing them would also reset claims owned by other relay instances). Dependency spans are emitted through the shared OpenTelemetry setup.
2. Least-privilege PostgreSQL identity pinned per connection
The daemon connects as a login role and pins ibex_evidence_relay per connection via the PostgreSQL startup option options=-c role=ibex_evidence_relay (supported for both URL and keyword DSN forms; an existing options value is merged or rejected, never overwritten). The daemon asserts current_user = ibex_evidence_relay at startup and fails closed otherwise.
ibex_evidence_relay remains NOLOGIN, is not granted to ibex_app, and has EXECUTE only on the relay outbox helpers. It has no direct table access; the role's membership grant from a login role is a deployment prerequisite, not a repository grant.
3. Authoritative session fence via a SECURITY DEFINER function
Migration 000047_relay_fence_checker adds ibex_core.evidence_outbox_check_session_fence(p_org_id, p_resource_id, p_resource_ver), owned by ibex_service, STABLE SECURITY DEFINER, returning alive / tombstoned / stale_version / not_found. EXECUTE is granted to ibex_evidence_relay only (revoked from PUBLIC and ibex_app). Because a SECURITY DEFINER function runs with the owner's ACLs, the migration also adds a column-level GRANT SELECT (id, org_id, deleted_at, evidence_fence_version) ON ibex_core.sessions TO ibex_service; BYPASSRLS alone does not bypass table ACLs.
services/relay/internal/fence maps the outcome to the library's typed errors: alive → deliver, tombstoned / stale_version / not_found → ErrTombstonedResource (poison), and a non-session or malformed fence → ErrUnsupportedFenceResource (poison without retrying). Database or timeout errors stay retryable. The sink remains a mandatory TombstoneFencedDeliverer; legacy Deliver without a fence fails closed.
4. Observability and bounded labels
The daemon exposes /health, /ready, and /metrics using the shared health-check and metrics packages. Relay counters/histograms use bounded labels only: ibex_relay_batches_total{result}, ibex_relay_claimed_total, ibex_relay_delivered_total, ibex_relay_retried_total, ibex_relay_dead_letter_total, ibex_relay_recovered_total, ibex_relay_batch_duration_seconds, ibex_relay_delivery_duration_seconds{result}, ibex_relay_fence_check_duration_seconds{outcome}, and ibex_process_up. Logs carry IDs, counts, and reasons, never payload content.
What this does not authorize
This record is deliberately narrow. It does not:
- accept a new contract shape or promote any G1–G7 registry entry;
- authorize a production or hosted ClickHouse sink, HA topology, backup/restore drill, or operator runbook evidence (GAP-011 stays open);
- add a tenant/agent allowlist, request-auth layer, or admin/operator endpoint to the relay;
- make chat acknowledgement response-bound, or make fence-check, outbox write, and projection a single atomic transaction (GAP-005 stays open);
- enable the daemon by default in any profile.
The ClickHouse sink adapter remains a non-production pilot gated at G4. Production enablement requires independent review, an accepted deployment profile, and the recovery/backup evidence GAP-011 tracks.
Evidence
infra/migrations/postgres/000047_relay_fence_checker.{up,down}.sqlandmigrate_relay_fence_checker_integration_test.go(definition/grants, fence outcomes, role isolation, clean rollback) pass against local PostgreSQL.services/relay/internal/fence/session_fence_integration_test.goandservices/relay/internal/bootstrap/relay_e2e_integration_test.goexercise the pinned role, cross-tenant isolation,PersistRun→ProcessBatchdelivery, tombstone → poison, and in-flight recovery on real PostgreSQL.- Unit suites cover config bounds, fence mapping, loop/backoff, health/readiness, and shutdown ordering. Relay package coverage is recorded in the status ledger.
- All three
golangci-lintconfigs andgo test -race -tags=integration -p 1 ./services/relay/...pass locally.
These are local, disposable-PostgreSQL results. They are not hosted/HA, restore, or G4 sink evidence.
Alternatives considered
- Keep the library only. Rejected: it leaves the fence race and retry/shutdown semantics unexercised and keeps the "default-disabled by absence of a daemon" statement true, which blocks any honest enforcement claim.
SET ROLEper transaction instead of a pinned startup option. Rejected:SET ROLEon a pooled connection can leak role state across borrowers; pinning at connect time keeps every pool connection inside the relay role, including after reconnects.ALTER ROLE <login> SET role = ...remains a documented deployment alternative.- Read the session lifecycle row directly as the relay role. Rejected: it would grant the relay direct table access and duplicate RLS/lifecycle logic; a
SECURITY DEFINERhelper keeps the ACL surface minimal and centralizes the version semantics.
Review and expiry
Owner review is required before any status change or production enablement. Review/expiry is 2026-11-10, or earlier on a material authority, profile, or fence change.
Was this page helpful?
Last updated on