VelaWind · Projects

Veritas

A catalogue of knowledge claims where Postgres enforces the rules, not the application.
Live site · Source

A dark dashboard of seeded data. Five tiles read 15 hypotheses, 31 evidence entries, 15 open questions, 5 open contradictions and 3 simulation runs. Below them a bar chart of hypotheses per confidence band shows 1 in 0 to 20, 10 in 21 to 40, 4 in 41 to 60 and none above 60, beside a horizontal bar chart of hypotheses per domain across ten domains.

The problem

A catalogue of claims is only worth reading if its rules hold. In Veritas every hypothesis has an epistemic status and a confidence score, and the two have to agree: a speculation cannot sit at 90. A confidence change has to carry a reason. The history of changes cannot be rewritten afterwards.

Those rules have many writers to survive. The admin pages, the API routes, contributors proposing changes, maintenance scripts holding the service key, and, later, AI research agents that read sources and propose hypotheses and evidence. Each writer is a place where a rule can be skipped.

Why the obvious approach was not used

The obvious place for the rules is the application: validate every write in the route that handles it. That holds only for writers that go through those routes, and it holds only as long as every new route remembers to do it. A script with the service key, a route added next year, or an agent runner with a bug all write without passing that check.

So the rules live in PostgreSQL. A CHECK constraint binds status to confidence. A trigger refuses a confidence change without a rationale and writes the history row itself. The history tables have no update or delete policy, so under row-level security no application role can alter them. The application still validates, but only to give a readable error message; the database is what refuses.

The agents were added later and were given no new way to write. They propose into the same queue a human contributor uses, an admin approves every proposal, and a trigger enforces each agent's quota and domain scope even if the runner skips its own limits. The worst a prompt injection in a source can produce is a pending proposal that a reviewer rejects. The dashboard follows the same idea: its figures come from a database view read under the viewer's own row-level security, so they cannot go stale.

One trade-off

The query layer does not use generated database types. It casts query results to hand-written types in 13 places, so a select whose shape stops matching those types still compiles. That is deliberate: regenerating the types can never break the build. The cost is that this class of mismatch is found at runtime, by the smoke run and the verification scripts, not by the compiler.

What should change

The worst failure in the project's history was not a crash. The deployed site served every page with HTTP 200 for about two months while its database was unreachable: a placeholder database URL ended up in the production build, every query failed, and every page rendered a normal looking empty state. The project's decision log records it as the third instance of one shape, a wrong value producing a plausible success. A production build now refuses the placeholder, a failed query raises once a real database is configured, and a smoke run checks the deployed site every 30 minutes.

A dependency upgrade found the same shape again. Moving the validation library to its next major version produced no type error, but the new version fills in defaults for fields a request leaves out, so an edit to a hypothesis title alone would have wiped its assumptions and open questions, and an empty note edit would have unpublished the note. Edits now apply defaults only to fields the caller sent. Every schema was run on the same 7,415 inputs under both versions with no difference in what is accepted or produced, and a unit test fails if the old behaviour returns.

The scripts that exercise the database end to end used to run by hand, because they write to it and there was one database. CI now starts a throwaway Postgres inside the runner, applies every migration and the seed, and runs them there on every pull request, with a guard that stops the job unless every target is on the runner itself. The search rate limit moved into the database the same way: one atomic function counts each client across every server instance, and a test sends 60 simultaneous requests at a limit of 30 and expects exactly 30 through.

What remains: unit tests cover five modules, with no component or browser tests, and that CI database runs on local images rather than the hosted platform. The research graph can be driven by keyboard, but it was tested in Chromium only, not with a screen reader.

The rest of this page is fetched from the Veritas README at build time and rendered here, so it is maintained in the repository and not in this site. It covers the architecture, how it is verified and, under "Known limits", what does not work.

Architecture

The database is the enforcement layer

The schema is 16 migrations under supabase/migrations/. Across all of them, PostgreSQL parses 24 CREATE TABLE, 25 CREATE TRIGGER and 45 CREATE POLICY statements, and every table has row-level security enabled. The first migration, 0001_core.sql (842 lines), defines 14 tables, 9 enums, 20 indexes, 20 functions and 17 triggers, with 26 policies. The parts that carry the design:

Migration 0002 is a postmortem. Public reads came back empty against a live database. scripts/diagnose-rls.mjs showed SQL state 42501, "permission denied for table", for both anon and service_role: a missing table-level GRANT, which Postgres evaluates before RLS runs, so it was never an RLS row-filter problem. 0002_fix_rls.sql (117 lines) adds the GRANTs and default privileges, makes is_admin() null-safe, splits the public-read policies so the public condition stands alone, and casts 'system'::actor_type in scan_contradictions(), where a bare literal resolves to text under SELECT DISTINCT and would have failed with 42804 the first time the scan found anything. It is idempotent, and every fix is mirrored into 0001 so fresh installs land in the same state. It is also why lib/queries/log.ts exists: the query layer logs code and message before returning its fallback, so a privilege failure cannot present as a silently empty page. The same failure shape recurred, which DECISIONS.md records as a recurring class, so the fallback is now gated on whether credentials are live at all.

Application layering

About 15,900 lines of TypeScript and TSX across 189 files, with no any in a type position.

The front end is Tailwind with CSS-variable tokens and a signal palette reserved for epistemic state (unknown is grey, never red). components/graph/ResearchGraph.tsx (732 lines) runs d3-force and paints to canvas: device-pixel-ratio scaling, cursor-anchored zoom on a non-passive wheel listener so the page cannot scroll underneath it, its own hit testing, an inspector on hover and select, and a synchronous settle under prefers-reduced-motion. It is keyboard-operable: Tab focuses it, arrow keys move along connections to the node lying most nearly in that direction, Enter opens the inspector and Escape clears it. Below the lg breakpoint, where the inspector stacks under the graph, Enter scrolls it into view and moves focus to it; Escape there, or its Close button, returns focus to the graph on the same node. The focused node gets a dashed ring, and the canvas (role="application") carries node and edge counts plus the focused node's type, label and connection count in its aria-label. Dialogs and the command palette use a focus trap; Tabs implements the roving-tabindex keyboard pattern.

Verification

Continuous integration. .github/workflows/ci.yml runs on every push to master and every pull request, in three tiers.

.github/workflows/production-smoke.yml runs the same smoke script against the deployed site every 30 minutes, read-only and under the anon role.

What each check covers. These run with no database and no credentials:

npm run typecheck      # tsc --noEmit
npm run lint           # eslint .
npm run test:unit      # 107 checks in five files: 25 for sanitizeHeadline (the
                       # search-snippet XSS guard), 31 for the council context
                       # budget, 17 for the Internal Affairs report parser, 15
                       # for the validation schemas (an edit keeps fields it did
                       # not send), 19 for the rate limiter's decision logic
npm run validate:sql   # parses the 16 migrations and the seed with the PostgreSQL
                       # grammar: 421 statements across 17 files
node scripts/contrast.mjs  # 8 palette pairs, two surfaces, both themes, against
                           # WCAG AA thresholds
npm run build          # succeeds with no environment variables set; next/font/google
                       # needs network access at build time

Scripts that write to a database. These need the service-role key, so CI runs them in tier 3 against the disposable database described above, never against production. Run by hand they read their target from .env.local. The counts are from tier 3.

Known limits