Running observer-org day to day: backup/restore, key rotation, upgrades, and
troubleshooting. See teams-getting-started.md to
install and teams-architecture.md for how it works.
| State | Location | Backup priority |
|---|---|---|
| Server database | /var/lib/observer-org/server.db (+ -wal, -shm) |
Critical — all org/identity/audit data |
| Config | /etc/observer-org/config.toml |
Low (reproducible) |
| Bearer signing key | bearer-signing.key |
Critical — losing it invalidates every agent bearer |
| Session HMAC key | session.key |
Low (rotating just logs everyone out) |
| SAML SP cert/key | sp.crt / sp.key |
Medium (rotation needs IdP re-trust) |
| SCIM token | scim-token |
Medium (rotation needs IdP update) |
The server uses SQLite in WAL mode (pure-Go modernc.org/sqlite, no CGO).
Use SQLite's online backup so you capture a consistent snapshot even while the server is running:
# Docker:
docker exec observer-org sh -c \
'sqlite3 /var/lib/observer-org/server.db ".backup /var/lib/observer-org/backup.db"' \
|| true # distroless has no shell — see the volume-copy fallback belowThe distroless image has no shell, so prefer one of:
- Stop-and-copy (brief downtime):
docker stop observer-org, copy/var/lib/observer-org/server.db*(all three files) from the volume, then start again. - Volume snapshot: snapshot the underlying PV/volume. With WAL mode, copy
server.db,server.db-wal, andserver.db-shmtogether. - Sidecar: run a small
sqlite3-equipped sidecar mounting the same volume read-only and run.backup.
On Kubernetes, snapshot the PVC (VolumeSnapshot) or run a backup CronJob with a sidecar that mounts the claim.
- Stop the server.
- Replace
/var/lib/observer-org/server.dbwith the backup (remove stale-wal/-shmif restoring a.backupoutput, which is already checkpointed). - Ensure ownership is
65532:65532. - Start the server and run
observer-org migrate --config ...— it reports the schema version and applies any pending migrations idempotently.
Agents keep working across a restore: their cursors are local, and re-pushes are deduped on composite keys, so a restored-slightly-stale DB self-heals on the next push cycle.
Lowest-stakes. Replace the file and restart. Effect: all dashboard sessions are invalidated; users re-authenticate via SAML on next visit. No data impact.
- Generate a new token (
openssl rand -hex 32). - Update the IdP's SCIM client with the new token.
- Replace the file and restart.
Sequence the IdP update close to the restart to minimise the window where
provisioning calls
401.
- Generate a new keypair.
- Re-upload the SP metadata / cert to the IdP so it trusts the new signing cert.
- Replace the files and restart. Until the IdP trusts the new cert, signed AuthnRequests are rejected — do this in a maintenance window.
This key signs every agent bearer. Rotating it invalidates all existing
bearers: every enrolled agent must re-enrol (observer enroll with a fresh
token). Rotate only on suspected compromise. Procedure: replace the key,
restart, then re-issue enrolment tokens and have developers re-enrol. Prefer
revoking individual bearers (below) over rotating the signing key.
From the dashboard (admin → bearers) or by recording the bearer's jti in
revoked_bearers. VerifyBearer checks the revocation list on every push, so
a revoked agent's next push gets 401. This does not require rotating the
signing key or disturbing other agents.
Agent and server ship at the same semver tag; compatibility is "matching minor". Upgrade the server first, then agents, within the same minor.
v1.7.x → v1.8.x note. The 2026-06-03 release introduced the new privacy posture (hashes ship by default; raws gated behind a per-node TOML opt-in). Both directions of the upgrade are forward-compatible:
- v1.7.x agent → v1.8.x server: ingest computes the missing
*_hashcolumns server-side from the raw values the old agent ships (internal/orgserver/ingest/ingest.go::hashOrComputed). Rows land with both shapes populated and rollups continue to work. After all agents have upgraded, runobserver-org scrub-content --all --confirmto purge the now-redundant raw columns (the hash counterparts are preserved; rollup queries that keyed on the raws are already migrated in v1.8.x). - v1.8.x agent → v1.7.x server: the new
*_hashJSON keys are additive and ignored by the older schema. Theomitemptyraw columns ship empty in metadata-only mode, which the v1.7.x server accepts. Rollups still work; the server just doesn't have the hashes to dedup against.
# Docker
docker pull ghcr.io/marmutapp/observer-org:v1.7.2
# verify the signature before rolling
cosign verify ghcr.io/marmutapp/observer-org:v1.7.2 \
--certificate-identity-regexp 'https://github.com/marmutapp/superbased-observer-private/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
docker stop observer-org && docker rm observer-org && docker run ... :v1.7.2
# Helm
helm upgrade observer-org charts/observer-org -n observer-org \
--reuse-values --set image.tag=v1.7.2Migrations run automatically on serve startup (and can be applied explicitly
with observer-org migrate). Back up the DB before any upgrade that may
carry a migration. The Helm chart uses a Recreate rollout so the old pod
releases the ReadWriteOnce volume before the new one binds it.
Supply-chain artefacts attached to each release: per-binary CycloneDX SBOMs
(observer.cdx.json / observer-org.cdx.json), SLSA Level 3 provenance
(multiple.intoto.jsonl), and the cosign-signed image. To verify the provenance
of a downloaded binary, use slsa-verifier v2.7.0 or newer (older versions
fail with unexpected tlog entry type … got dsse:0.0.1) and pass the private
origin repo as the source — the build runs there:
slsa-verifier verify-artifact ./observer-org \
--provenance-path multiple.intoto.jsonl \
--source-uri github.com/marmutapp/superbased-observer-privateIf your server ran v1.7.x agents before the v1.8.0 cutover, the
actions.target / source_file / project_root / git_remote /
token_usage.source_file columns hold raw command bodies, assistant
prose, and filesystem paths from those earlier pushes. The v1.8.x
server logs a startup WARN naming each affected column. To purge:
# Dry-run first — prints per-column row counts and exits.
docker exec observer-org observer-org scrub-content \
--config /etc/observer-org/config.toml --all
# Apply (writes a .scrubbed-<ts>.bak alongside the DB first).
docker exec observer-org observer-org scrub-content \
--config /etc/observer-org/config.toml --all --confirmThe *_hash counterparts are preserved, so dedup keys + rollup
queries continue to work. Per-column flags (--actions-target,
--sessions-git-remote, etc.) let you scrub a subset instead of
--all. --skip-backup is available for installations with an
external backup pipeline.
Run the built-in health check first — it validates config, DB access, secret file presence/permissions, and reports the schema version:
docker exec observer-org observer-org doctor --config /etc/observer-org/config.toml
# or: kubectl exec deploy/observer-org -- observer-org doctor --config ...fetch IdP metadata from ... failed—saml.idp_metadata_urlis unreachable from the pod, or wrong. The server fetches it eagerly at startup. Fix the URL/network and restart.unable to open database file/ SQLITE_CANTOPEN — the data volume is not writable by uid 65532. Ensure/var/lib/observer-orgis owned65532:65532(Docker) or that the HelmfsGroup: 65532is in effect (it is by default).- Secret read errors — the mounted secret files must be readable by uid
65532; the SCIM token must be
0600ordoctorflags it.
- Clock skew — SAML assertions are time-bounded. If the IdP and server clocks differ by more than the tolerance, logins fail with an assertion validity error. Ensure NTP on both sides; skew is the most common cause.
- Attribute mapping — if the dashboard shows a user with no email/teams,
the IdP isn't emitting the attributes named in
saml.attribute_mapping. Inspect the assertion and align the mapping. - Audience/ACS mismatch — the IdP's Audience must equal
saml.sp_entity_idand its ACS must be<external_url>/saml/acs.
401— wrong SCIM bearer token; the IdP's configured token doesn't matchscim-token. Re-sync after a rotation.409on create — the user/group already exists (idempotent provisioning re-run); usually benign.404on patch/delete — the IdP references a resource id the server doesn't have (provisioning drift); a full re-sync from the IdP reconciles it.
401with a stale-timestamp error — agent and server clocks differ by more than ±300s. Fix NTP on the agent host.401after working before — the bearer was revoked, or the bearer signing key was rotated. The developer must re-enrol with a fresh token.- persistent
5xx/network — the agent backs off and retries; it never blocks local use. Checkobserver org statusfor the last error and the server logs for the corresponding request id.
- You are not in
dashboard.admin_emailsand are not a team lead. Members see nothing by design. Add your email toadmin_emailsand restart, or have an admin assign you a lead role via SCIM group membership.
Agents with the guard layer enabled push content-free guard-event rows
(rule id, category, severity, decision, enforced, hashes, chain links —
see guard spec §10.2); the server lands them in guard_events and the
dashboard's Security page rolls them up: verdict trends, rule-hit
leaderboards, per-team posture, and per-agent audit-chain continuity.
The chain report is a per-developer disclosure and is audited like
the cost drill-down (view_guard_agents rows in the audit log).
Two new [dashboard] config email lists layer the §14.5 roles onto the
existing admin/lead model (an admin implicitly holds both):
[dashboard]
admin_emails = ["ops@acme.example"]
policy_admin_emails = ["seceng@acme.example"] # author/publish bundles + org-wide guard reads
security_viewer_emails = ["audit@acme.example"] # org-wide guard reads, no policy writeTeam leads see their teams' guard slice; plain members see nothing.
The Policy page shows the bundle version history and, for a
policy_admin, authors new versions through the same lint+sign+insert
gate as observer-org policy publish (each publish is audit-logged as
publish_policy_bundle). Dashboard publishing requires
[policy].signing_key_path; the server loads the key per publish and
retains nothing between requests. Operators who prefer the CLI-only
signing posture (the v1.8.3/G13 default) simply leave
policy_admin_emails empty — the authoring panel then refuses every
publish.
server.data_retention_days (default 730) is the horizon for pushed org data,
and it is now ENFORCED by the daily sweep (internal/orgserver/retention):
rows older than the horizon are deleted in batches across the pushed-data tables
— session/action/api-turn/token rows, obs trace/span structure, every day-keyed
aggregate (routing / obs summaries, per-end-user spend, the cc/codex/copilot/m365
analytics dailies), obs alert events, and the content stores
(otel_content / obs_content / m365_copilot_content, whole-row past the
horizon). Set 0 to keep forever. Deprovisioning a user via SCIM removes their
access immediately.
Never pruned (deliberately): identity / enrollment / [REDACTED] (pruning a
revocation could un-revoke it), admin-authored config (budgets, alert rules,
signed routing-policy bundles + their TOFU keys), the append-only audit logs,
and the tamper-evident guard_events chain.
This whole-row age prune is independent of the otel_content_days
body-NULLing below (which reclaims only body bytes while keeping the row +
content_hash); the sweeper starts when either horizon is positive.
Upgrade behavior. Because the default is 730, upgraded org servers begin deleting rows older than ~2 years on the first daily sweep — enforcing the contract the config field has always documented. Set
data_retention_days = 0before upgrading if you need to keep everything.
The one server-side prune that IS implemented is for stored message bodies
(otel_content, surfaced by the session message-content viewer). It mirrors the
individual node's retention model:
[dashboard.content_retention]
otel_content_days = 0 # 0 = keep forever (default). >0 = prune bodies older than N days.When otel_content_days > 0, a daily sweep (internal/orgserver/retention)
NULLs the content body of any row past the horizon while keeping its
content_hash and the row itself — so the audit trail, dedup, and idempotent
re-push all survive; only the large body bytes are reclaimed. The sweep is
idempotent (a second run clears nothing new) and runs alongside the HTTP server,
like the analytics pollers. Large bodies are accepted by design; turn this on
only if you want a hard age cap on message content.
Message bodies only ever reach the server from nodes that enabled native-OTel capture AND content sharing (
full_content/admin_managed). Viewing them is an audited, deeper disclosure (view_session_messagesaudit_log rows). There is no remote toggle to force content collection on.