release-ledger / references/data-model.md
Release ledger — reference data model
A reference shipped with the skill · 1,187 words
Release ledger — reference data model
Portable SQL, written against ANSI types with PostgreSQL spellings where a choice was needed. Rename freely; the constraints are the part that matters, and each one is annotated with the failure it prevents.
Overview
| Table | Holds | Written by |
|---|---|---|
release_capture_queue |
Raw merge payloads, unanalysed | The capture endpoint |
release_entries |
Analysed, user-facing entries | The analysis job |
release_announcements |
Hand-written, scheduled entries | Admins |
release_dismissals |
One watermark per user | The dismiss endpoint |
release_sends |
One row per digest attempt | The digest job |
1. Capture queue
create table release_capture_queue (
id bigserial primary key,
source text not null, -- 'github', 'gitlab', 'manual'
repository text not null,
change_number integer not null, -- PR / MR number
merged_at timestamptz not null,
payload jsonb not null, -- the whole event, unmodified
analysed_at timestamptz,
attempt_count integer not null default 0,
last_error text,
created_at timestamptz not null default now(),
constraint release_capture_queue_natural_key
unique (source, repository, change_number)
);
create index release_capture_queue_pending
on release_capture_queue (merged_at)
where analysed_at is null;
unique (source, repository, change_number)is the idempotency key. Forges replay deliveries after a timeout or a manual redelivery; insert withon conflict do nothingand a replay costs one wasted statement.payloadstores the event unmodified. Do not extract fields at capture time. Every field you decide not to keep is a field the analysis prompt cannot use six months from now, and re-fetching from the forge needs a credential the capture path deliberately does not have.attempt_countandlast_errorexist so a permanently unanalysable row can be excluded from the batch instead of blocking it every night.- The partial index is what makes "select the unanalysed batch" cheap once the table has a year in it.
2. Entries
create table release_entries (
id uuid primary key default gen_random_uuid(),
queue_id bigint references release_capture_queue (id),
kind text not null
check (kind in ('feature','bug_fix','improvement',
'security','ops','docs','breaking')),
title text not null,
short_md text not null,
medium_md text,
detail_md text,
refs jsonb not null default '[]'::jsonb,
ticket_ref text,
reporter_user_id uuid references users (id),
published_at timestamptz not null,
created_at timestamptz not null default now(),
constraint release_entries_queue_unique unique (queue_id)
);
create index release_entries_published on release_entries (published_at desc);
kind's check constraint is thedescribe-changesclassification, exactly. Keep the two in step; a ledger that invents an eighth kind and a describer that emits seven produce rows nothing renders.unique (queue_id)stops a re-run of the analysis job from producing a second entry for the same change. A nullablequeue_idleaves room for entries an agent wrote by hand in a manual-only ledger.published_atis the merge date, not the analysis date. Backfill depends on this; so does the "since you were away" arithmetic.refsis thedescribe-changesreference array — path, line range, and an optional commit. Store it as given; render it on the detail page only.reporter_user_idwill be null for most rows in any real product, because most merged changes have no linked request. Render its absence as absence.
3. Announcements
create table release_announcements (
id uuid primary key default gen_random_uuid(),
title text not null,
body_md text not null,
starts_on date not null,
ends_on date,
audience text not null default 'everyone',
audience_ids text[] not null default '{}',
pinned boolean not null default false,
author_id uuid not null references users (id),
created_at timestamptz not null default now(),
constraint release_announcements_window
check (ends_on is null or ends_on >= starts_on)
);
- Separate from
release_entriesbecause the lifecycles differ: an announcement has a schedule and an audience, an entry has a diff and a classification, and a merged table is half-null in both directions. audienceplusaudience_idskeeps the common case (everyone) cheap and the targeted case explicit. Resolve it server-side, always.starts_onis a date, not a timestamp, because "goes live on Monday" is how people write announcements and a timezone-bearing instant invites a 00:00 UTC surprise.
4. Dismissals
create table release_dismissals (
user_id uuid primary key references users (id) on delete cascade,
watermark_at timestamptz not null,
updated_at timestamptz not null default now()
);
- One row per user, so
primary key (user_id). A per-event dismissal table grows without bound and answers the same question more slowly. - The watermark is a timestamp, not an entry id, so entries inserted out of order (a backfill, a late analysis) are still covered by a single comparison.
- Where access policies exist, this is the one ledger table an end user writes:
allow insert and update where the row's
user_idequals the caller, and nothing else.
5. Sends
create table release_sends (
id uuid primary key default gen_random_uuid(),
channel text not null,
recipient text,
provider text not null, -- 'slack', 'email', ...
status text not null
check (status in ('sent','failed','skipped')),
reason text,
entry_count integer not null default 0,
cutoff_at timestamptz not null, -- entries after this were included
external_id text,
external_url text,
error text,
sent_at timestamptz not null default now(),
triggered_by text
);
create index release_sends_channel_time on release_sends (channel, sent_at desc);
- A row per attempt, including the ones that sent nothing.
skippedalways carries areason. This is the difference between "quiet day" and "the job has been throwing for a week" — without it, both look like silence. cutoff_atis what the next run reads to find its range. Reading it from the last successful send means a failed night is covered by the next one instead of becoming a hole.external_idandexternal_urlmake a send auditable from the ledger side without querying the chat provider.
The popup query
select e.id, e.kind, e.title, e.short_md, e.published_at
from release_entries e
where e.published_at > greatest(
coalesce((select watermark_at from release_dismissals
where user_id = :user_id), 'epoch'::timestamptz),
now() - interval '30 days')
and e.published_at <= now()
order by e.published_at desc
limit 50;
greatest(watermark, now - window) is the cap. It is one expression and it covers
both the new user and the long-absent one; a code path that special-cases "no
watermark row" is a second place for the window to be wrong.
Announcements are a second query against release_announcements filtered by
starts_on <= current_date, ends_on is null or ends_on >= current_date, and the
caller's audience — unioned in the application, sorted with pinned first.
Grants and policies
Where the database enforces per-row access:
- Revoke first, then grant what is needed. A default that grants insert, update, and delete on every new table is how a browser-writable ledger happens without anything failing.
release_dismissals— the only table an end user writes. Insert and update whereuser_idequals the caller.release_entriesandrelease_announcements— read-only for end users if the popup queries the database directly; no end-user access at all if the popup goes through a server endpoint that filters by audience. Prefer the second: it keeps audience enforcement in one place.release_capture_queueandrelease_sends— no end-user access in either direction. They are written by privileged server code only.