ADR-0002: Use Single Events Table with JSONB Payload over Typed Per-Event Tables
Status: accepted Deciders: DeAcero Platform Team Date: 2026-03-17 Parent: ADR-0001
Context and Problem Statement
The observability service must store 6 distinct event types (project.generated, skill.invoked, ci.run, tool.executed, knowledge.created, knowledge.used). Each event type has a different payload structure. How should the PostgreSQL schema be designed?
Considered Options
- Single
eventstable withJSONB payload— one table, polymorphic payload column - Six typed tables — one table per event type, all columns strongly typed
- Single
eventstable with a normalized payload — common columns + adetailsJSONB for overflow
Decision Outcome
Chosen option: Option 1 — Single events table with JSONB payload.
Rationale: The 6 event types share a common envelope (event_type, project_slug, github_username, timestamp, schema_version) but have completely different payload structures. Six typed tables (Option 2) would require complex JOINs for cross-event queries and make adding new event types a migration per type. A single table with JSONB (Option 1) allows all cross-event queries to be simple SELECT ... WHERE event_type = '...', while JSONB operators (->, ->>, @>) provide efficient payload filtering. PostgreSQL's GIN index on the JSONB column can accelerate payload lookups when needed.
Schema
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
project_slug TEXT NOT NULL,
github_username TEXT NOT NULL DEFAULT '',
schema_version TEXT NOT NULL DEFAULT '1.0',
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_ts TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL
);
CREATE INDEX idx_events_event_type ON events (event_type);
CREATE INDEX idx_events_project_slug ON events (project_slug);
CREATE INDEX idx_events_received_at ON events (received_at DESC);
CREATE INDEX idx_events_type_project ON events (event_type, project_slug);
-- Materialized view for dashboard (refreshed on write)
CREATE MATERIALIZED VIEW mv_summary AS
SELECT
project_slug,
event_type,
date_trunc('day', event_ts) AS day,
count(*) AS event_count,
sum((payload->>'estimated_cost_usd')::float)
FILTER (WHERE event_type = 'skill.invoked') AS daily_cost_usd
FROM events
GROUP BY project_slug, event_type, day;
CREATE UNIQUE INDEX ON mv_summary (project_slug, event_type, day);
Positive Consequences
- All 6 event types in one table — simple cross-event queries
- Adding a new event type requires no schema migration
schema_versionfield future-proofs payload evolution- Materialized view provides cheap dashboard aggregations
Negative Consequences
- No database-enforced constraints on payload structure (mitigated by Pydantic validation at ingest)
- JSONB column may be slower than typed columns for high-cardinality payload field lookups (mitigated by targeted GIN index if needed)
Links
- Parent: ADR-0001
- Alembic migration:
services/observability/alembic/versions/0001_initial.py