Veritas
A catalogue of knowledge claims where Postgres enforces the rules, not the
application.
Live site · Source
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:
- Status bound to confidence by CHECK constraint.
epistemics_consistentholdsunknownat 20 or below,speculationat 40 or below,plausible21 to 60,strong_evidence61 to 80,establishedat 81 or above. Out-of-band writes fail in the database;translateDbError()turns the constraint name into a readable message. - Confidence changes require a rationale and record themselves.
log_confidence_change()runsBEFORE UPDATEonhypotheses: if confidence changed and the rationale is empty it raises, otherwise it writes aconfidence_historyrow and atimeline_eventsrow with old and new values. No application code touches either table. - History is append-only.
timeline_eventsandconfidence_historyhave a SELECT policy and nothing else, so under RLS no application role can alter them; the trigger functions areSECURITY DEFINER, which is how their inserts get through. Further lifecycle triggers cover creation, status change, revision, evidence link and unlink, note publication, contradiction detection and resolution, and simulation completion, so history is a byproduct of the write path rather than something callers must remember to do. - Derived state is derived in the database. Linking evidence inserts the
matching
graph_edgesrow (supportsbecomes a supports edge,opposesa contradicts edge) and unlinking deletes it, both by trigger, so the graph cannot drift from the links it depicts.graph_nodesunions the node typeswith (security_invoker = on), so the reader's RLS applies, not the owner's. - Search is in the schema, not in a service. Searchable tables carry a generated
tsvectorcolumn with weighted fields and a GIN index.global_search()queries them withwebsearch_to_tsquery, orders byts_rank, and returnsts_headlinesnippets. Sincets_headlinereturns admin-authored text with<b>inserted,sanitizeHeadline()escapes the whole string and re-enables only the<b>markers before it reachesdangerouslySetInnerHTML. suggested_confidence()is advisory and damped. Each link contributessign(relation) * weight * strength * source_reliability; the sum is scaled around 50, divided bymax(sqrt(count), 1), clamped to 0 to 100. The square-root divisor stops one strong citation from implying certainty, and the database never overwrites the number a human set.scan_contradictions()is idempotent: a unique constraint on(hypothesis_a, hypothesis_b, kind)gives itson conflict do nothingsomething to conflict with.- Agents get no new write path. Migrations 0006 to 0011 add agent identities, scoped tokens, critiques, citation checks, councils and audits. None of them grants an agent admin rights or a route to the knowledge tables; the rules the database enforces on agents are listed in the README section on the research agents.
- The search rate limit is counted in the database.
rate_limit_hit()(0016) records a hit and decides in oneinsert ... on conflict do update, so concurrent requests from any number of serverless instances serialize on one row and share one count: 30 searches per client per minute. Onlyservice_rolemay execute it, the route passes a SHA-256 hash of the client address rather than the address, and if the database cannot answer the route falls back to a per-instance count instead of blocking search.
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.
- Route handlers (31 under
app/api/) parse with a Zod schema fromlib/validations, then return the{ data, error }envelope fromlib/api.ts.requireAdmin()hands back a session-bound client, so writes run under the admin's JWT and are attributed. - Query layer (
lib/queries/*, one file per entity) owns every PostgREST select; each function catches errors and logs. Whether it then returns an empty fallback or throws depends onHAS_LIVE_SUPABASE: with no database configured the fallback is the point, and with one configured a failed query is an outage and raises to an error boundary. Pages consume the hand-written types intypes/domain.ts, not raw rows. - Auth is gated three times: middleware redirects unauthenticated
/admin/*to/login,app/admin/layout.tsxre-checks the role server-side, RLS is the last gate. API writes swap the layout check forrequireAdmin(). - Public reads use a cookieless anon client (
lib/supabase/public.ts), because callingcookies()forces a route dynamic and would defeat ISR. It sees exactly what an anonymous visitor sees under RLS. - Rendering mixes static pages, ISR (1 hour on 15 public pages and the sitemap, 15 minutes
for the dashboard),
generateStaticParamson 8 dynamic routes, andforce-dynamicfor admin, contribute, search, timeline, and the hypothesis and evidence lists. Admin writes callrevalidateEntity(), hitting the affected paths plus thegraphandstatscache tags. - No credentials needed to build, except in production.
lib/supabase/env.tsfalls back to placeholders, sonext buildprerenders without a database and pages render empty states instead of failing. A production build refuses those placeholders and throws: shipping one once served every page at HTTP 200 against an unreachable database for about two months, asDECISIONS.mdrecords.
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.
- Tier 1 uses no credentials. It runs
npm run typecheck(tsc --noEmit),npm run lint(ESLint with the Next.js core-web-vitals and TypeScript rule sets),npm run test:unit,npm run validate:sql, the WCAG contrast audit, andnpm run buildwith no environment variables set. - Tier 2 builds with the public Supabase URL and anon key (stored as repository
variables, not secrets), starts the production server inside the runner, and runs
scripts/smoke.tsagainst it. The job fails if the smoke run reports fewer than 87 passing assertions, so a dropped check cannot pass silently. - Tier 3 runs the scripts that write to the database, against a disposable one. It
starts a local Supabase in the runner (
supabase start), which applies every migration and the seed to a fresh Postgres, builds the app against it, serves it on two ports, and runsverify-admin,verify-suggestions,verify-agentsandverify-rate-limit, each with a floor on its passing checks (19, 29, 94 and 12). It uses no secrets and no repository variables: the keys come from the local stack, and a guard step stops the job unless every target is127.0.0.1. Production is never contacted.
.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.
scripts/verify-admin.mjs(204 lines, 19 checks) provisions a temporary admin, forges the chunked@supabase/ssrauth cookie, and drives the real HTTP routes: create, edit, link and unlink evidence, change confidence, scan. It asserts the negative cases (401 unauthenticated, 422 for a missing or blank rationale, out-of-band confidence rejected by the database guard), the trigger side effects, and RLS draft invisibility, then removes everything it created.scripts/verify-suggestions.mjs(29 checks) drives the contributor queue over HTTP: propose, approve, reject, withdraw, and the RLS and authorization negatives (a contributor sees only their own rows; nobody can approve their own).scripts/verify-agents.mjs(94 checks) drives the agent layer end to end: scoped-token authorization, propose to pending to admin approval to credit, the caps enforced in Postgres, the skeptic and citation lanes, the council, and Internal Affairs sanctions. Its catalog checks (default privileges, function grants) readpg_catalogthroughpsqlwhenVERIFY_DB_URLis set, and through the Supabase Management API otherwise.scripts/verify-rate-limit.mjs(12 checks) sends 60 concurrent calls torate_limit_hit()with a limit of 30 and expects exactly 30 through, confirmsanoncannot call it, sends 31 searches alternating between two app servers and expects the 31st refused, and checks the stored bucket is a hash, not the address.scripts/audit-pages.mjsruns 21 read probes through the anon client across 15 routes (18 PostgREST selects copied from the pages and 3 RPC calls) and fails on any error or empty result.scripts/diagnose-rls.mjscomparesanonagainstservice_roleper table; it found the GRANT bug behind migration 0002.
Known limits
- Unit tests cover four modules. They are plain Node scripts with their own assertion helper; there is no test framework and no component or browser tests in the repository.
- ESLint has no stylistic rules and there is no Prettier config, so formatting is
not tool-enforced.
npm run lintpasses with no warnings. npm auditreports one moderate advisory, inminimatch, flagged against 14 packages of the ESLint toolchain. The fixed release (11.0.1 and later) exports a different shape from the 3.x line ESLint 9 and its plugins call, so it cannot be forced in; ESLint 10 is not an option whileeslint-plugin-reactsupports only up to ESLint- It is a development dependency:
npm audit --omit=devreports 0.
- It is a development dependency:
- The query layer casts PostgREST results with
as unknown as Tin 13 places, and the runtime clients are not generic over a generatedDatabasetype, so a select whose shape stops matchingtypes/domain.tswill not failtsc. This is a deliberate tradeoff that keepstypes/database.types.tsnon-load-bearing, so regenerating it can never break the build; such a mismatch surfaces at runtime through the smoke and verify scripts instead. - Tier 3's disposable database runs the Supabase CLI's local images, pinned by CLI version, not the hosted platform. Platform-only behaviour, such as default privileges the platform re-applies, is checked there only as the local images reproduce it.
- CI tier 2 and the production smoke read the production database. Tier 2 fails by design on pull requests from forks, because GitHub withholds repository variables from them.
- Graph keyboard navigation picks one neighbour per arrow key, so from a node with more than four connections some neighbours take more than one key press to reach. Where no connected node lies in a direction, the arrow moves to the nearest node of any kind in that direction and says so.
- The graph's announcements have been tested through the
aria-labelin Chromium, not with a screen reader. - Agent runs are manual and need a local Ollama server or a cloud API key.