Files
0pt/sql/010_analytics_dashboard_releases.sql
Claude Opus 5 1a12e8a882
CI (SIAX Cloud) / quality (push) Successful in 50s
Security Scan (SIAX Cloud) / security (push) Successful in 45s
CI (SIAX Cloud) / security (push) Successful in 1m0s
CI (SIAX Cloud) / integration (push) Successful in 1m12s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m6s
CI (SIAX Cloud) / security (pull_request) Successful in 33s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / integration (pull_request) Successful in 47s
feat(analytics): BI-P061 — dashboard releases, with the publish race measured both ways
DRAFT -> PUBLISHED -> SUPERSEDED as a ledger (sql/010) rather than a column on
the frozen version: sql/008 already makes DashboardVersion immutable at the
database, and a lifecycle column there would have meant weakening that.

Rollback mints a NEW version carrying the old document and publishes it, so
"we went back to v3" is a timestamped fact (v5 = rollback_of_version 3) rather
than a hole where v4 used to be. It is verified by BI-P060 canonical hash, and
documentOf() refuses to serve a stored document whose hash no longer matches
the release — a dashboard that renders an unapproved definition is worse than
one that will not open.

The claim worth attacking is "only one release can be live". The application
check is read-then-write, so it is NOT the guarantee, and the evidence says so
in both directions against Postgres 16.15:

  guard_absent   partial unique index dropped -> BOTH concurrent publishes
                 commit, dashboard ends with TWO live versions
  guard_present  index in place -> loser fails 23505 on
                 opt_analytics_dashboard_release_one_published, one live

evidence/bi-p061/dashboard-release-race.json, reproducible with
scripts/bi-p061-release-race.mjs.

Two findings while writing the tests, both fixed here rather than papered over:
the one-draft-per-dashboard index makes the two-draft race impossible to even
construct (so the race is run as two create-and-publish transactions, which is
what a "save and publish" button does), and an UPDATE matching zero rows
raises nothing — which reads as "refused" to a careless test, so the
immutability test asserts the row exists first.

23 tests: 8 unit + 15 against live Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 03:51:04 +02:00

130 lines
6.8 KiB
PL/PgSQL

-- 0pt analytics: dashboard releases (BI-P061 — DASHBOARD VERSIONING).
--
-- Extends sql/008. Additive and idempotent (CREATE TABLE IF NOT EXISTS,
-- CREATE OR REPLACE FUNCTION, DROP TRIGGER IF EXISTS before CREATE TRIGGER),
-- so it replays onto an already-migrated database like 009 does.
--
-- Ordering: requires sql/008 (it references opt_analytics_dashboard_version).
--
-- WHY A SEPARATE TABLE RATHER THAN A `state` COLUMN ON THE VERSION.
-- sql/008 puts a BEFORE UPDATE OR DELETE trigger on
-- opt_analytics_dashboard_version, because BI-P002 lists DashboardVersion as
-- immutable. A lifecycle column on that row would need UPDATEs, so adding one
-- would mean weakening the immutability everything else depends on. Instead
-- the frozen DOCUMENT stays immutable in the version table, and the RELEASE —
-- the thing that legitimately moves DRAFT -> PUBLISHED -> SUPERSEDED — lives
-- here with its own, much narrower, mutation rule:
--
-- * `state` may only advance draft -> published -> superseded. Any other
-- transition RAISEs, including published -> draft ("un-publishing" is how
-- a rollback gets faked without leaving a trace) and superseded -> *.
-- * Every content column is frozen on UPDATE. A release may change state and
-- nothing else; repointing a published release at a different version is
-- the exact edit that would make "immutable release" a lie.
-- * DELETE is refused outright. Release history is a ledger; a rollback is
-- an entry in it, never the removal of one.
--
-- THE INVARIANT THAT NEEDS THE DATABASE: at most ONE published release per
-- dashboard. An application check reads, decides, then writes, and two
-- requests that interleave between the read and the write both decide "yes".
-- The partial unique index below turns that race into a constraint violation
-- for the loser. tests/analytics/dashboard-release-postgres.realinfra.test.ts
-- runs the race with the index DROPPED first and shows two published releases
-- for one dashboard, so the index is known to be what prevents it.
-- ── the release ledger ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_release (
release_id TEXT PRIMARY KEY,
dashboard_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_definition (dashboard_id),
dashboard_version_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_version (dashboard_version_id),
tenant_id TEXT NOT NULL,
project_id TEXT NOT NULL,
version INTEGER NOT NULL CHECK (version >= 1),
state TEXT NOT NULL CHECK (state IN ('draft', 'published', 'superseded')),
-- The BI-P060 canonical hash of the released document. Copied here so a
-- rollback can be VERIFIED (the new release must carry the hash of the
-- version it restores) without re-reading and re-hashing the document.
document_hash TEXT NOT NULL CHECK (document_hash ~ '^[0-9a-f]{64}$'),
-- Set when this release restores an earlier version. It is a NEW release of
-- a NEW version, never a resurrected old row -- that is what keeps the
-- history linear and the audit answerable ("what was live at 14:05?").
rollback_of_version INTEGER CHECK (rollback_of_version >= 1),
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
superseded_at TIMESTAMPTZ,
superseded_by_release_id TEXT,
UNIQUE (dashboard_id, version),
-- A state's timestamps must match the state. A 'published' row with no
-- published_at is the shape that makes an SLO or an audit answer wrong.
CONSTRAINT opt_analytics_dashboard_release_timestamps CHECK (
(state = 'draft' AND published_at IS NULL AND superseded_at IS NULL)
OR (state = 'published' AND published_at IS NOT NULL AND superseded_at IS NULL)
OR (state = 'superseded' AND published_at IS NOT NULL AND superseded_at IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_release_tenant_idx
ON opt_analytics_dashboard_release (tenant_id, project_id, dashboard_id, version);
-- At most one PUBLISHED release per dashboard. This is the concurrency
-- guarantee; the application's check is only an early, friendlier error.
CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_published
ON opt_analytics_dashboard_release (dashboard_id)
WHERE state = 'published';
-- At most one open DRAFT per dashboard. Two drafts race to publish and the
-- loser's edits vanish into a superseded release nobody asked for.
CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_draft
ON opt_analytics_dashboard_release (dashboard_id)
WHERE state = 'draft';
-- ── transition enforcement ────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION opt_analytics_dashboard_release_guard() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION
'BI-P061: dashboard releases are a ledger; delete is refused (release %)', OLD.release_id
USING ERRCODE = 'restrict_violation';
END IF;
-- Content is frozen for the life of the release.
IF NEW.dashboard_id IS DISTINCT FROM OLD.dashboard_id
OR NEW.dashboard_version_id IS DISTINCT FROM OLD.dashboard_version_id
OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
OR NEW.project_id IS DISTINCT FROM OLD.project_id
OR NEW.version IS DISTINCT FROM OLD.version
OR NEW.document_hash IS DISTINCT FROM OLD.document_hash
OR NEW.rollback_of_version IS DISTINCT FROM OLD.rollback_of_version
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
RAISE EXCEPTION
'BI-P061: a dashboard release is immutable apart from its state (release %)', OLD.release_id
USING ERRCODE = 'restrict_violation';
END IF;
IF NEW.state IS DISTINCT FROM OLD.state THEN
IF NOT ((OLD.state = 'draft' AND NEW.state = 'published')
OR (OLD.state = 'published' AND NEW.state = 'superseded')) THEN
RAISE EXCEPTION
'BI-P061: illegal dashboard release transition % -> % (release %)',
OLD.state, NEW.state, OLD.release_id
USING ERRCODE = 'restrict_violation';
END IF;
END IF;
-- A published timestamp, once set, is history.
IF OLD.published_at IS NOT NULL AND NEW.published_at IS DISTINCT FROM OLD.published_at THEN
RAISE EXCEPTION
'BI-P061: published_at is history and cannot be rewritten (release %)', OLD.release_id
USING ERRCODE = 'restrict_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS opt_analytics_dashboard_release_guard ON opt_analytics_dashboard_release;
CREATE TRIGGER opt_analytics_dashboard_release_guard
BEFORE UPDATE OR DELETE ON opt_analytics_dashboard_release
FOR EACH ROW EXECUTE FUNCTION opt_analytics_dashboard_release_guard();