Administration — Technical Reference
Administration — Technical Reference
Section titled “Administration — Technical Reference”Last verified: 26/07/2026 — refreshed for ID-372 {372.2} type-scoped claims:
claim_next_jobwidened with optionalp_job_types/p_exclude_job_typesso the cron route andscripts/bid_worker.pyclaim disjoint job-type sets (seelib/queue/worker-job-types.ts). Prior: Session 224 (05 May 2026) — refreshed for §5.4 W4 (= §5.4.1 batch-draft-all FIRST migration candidate SHIPPED —case 'bid_draft_all'registered inlib/queue/dispatch.ts; cron routemaxDuration50→60 per D-3) + W5 (cron cadence*/5→*per Vercel Pro). Prior S223 — added background-queue worker route + cancel + status endpoints +*/5cron registration per S221 W1 infra DDL, S222 W2 worker shell, S223 W3claim_next_jobbackoff window +reap_stuck_jobsRPC. Prior: S210 A5-administration (29 April 2026) against S195-S209 + kh-prod-readiness-S10/S11/S12/S13.
Overview
Section titled “Overview”Administration covers the system-level surfaces that admins (and, for a few sections, editors) use to configure Canonical: settings UI, role management, taxonomy + tag morphology, governance configuration, content ownership, layer vocabulary, notifications, scheduled cron pipelines, the admin-only Provenance surface, and operator-side CLI tooling that lives alongside the product (GDPR data export, cutover row-count diff, Supabase advisor lint, etc.).
Authorisation is enforced at three layers: (1) the proxy redirects
unauthenticated UI requests to /login; (2) every admin/editor API route
calls getAuthorisedClient([...roles]) and routes failures via
authFailureResponse; (3) Postgres RLS enforces role-based reads/writes via
the get_user_role() SECURITY DEFINER function.
For session-by-session capability detail, see
docs/reference/state-of-the-product.md §5 User Management,
§8 Background Automation, §9 Test Infrastructure / Observability & Build
Chain. Auto-generated counts live in docs/generated/codebase-stats.md.
Auth Model
Section titled “Auth Model”getAuthorisedClient(requiredRoles) discriminated union
Section titled “getAuthorisedClient(requiredRoles) discriminated union”Source: lib/auth/client.ts. Every admin/editor API route uses this single helper
to gate access. It returns a discriminated union, never throws, and surfaces
each failure mode through authFailureResponse to the correct HTTP status:
| Result reason | HTTP | Meaning |
|---|---|---|
success: true | — | Authenticated and authorised; route receives { user, supabase, role } |
unauthenticated | 401 | No valid session |
auth_service_failed | 500 | Supabase Auth service error (transient — surfaces so ops alerting fires) |
forbidden | 403 | Authenticated but wrong role |
role_lookup_failed | 500 | DB failure on user_roles read; never silently downgraded to viewer |
Default role-grant for a user with no user_roles row is viewer (matches
RLS behaviour). Routes call auth.success (not auth.authorised); the
failure-response helper is the only sanctioned way to convert a failed
result into an HTTP response.
proxy.ts middleware
Section titled “proxy.ts middleware”proxy.ts (project root) runs Supabase Auth on every non-static request and
mints a crypto.randomUUID() x-request-id header on the request +
response surface (per
docs/specs/structured-logging-spec.md v1.1 §4.3). The proxy:
- Allows
publicRoutes(/login,/auth/callback,/oauth/consent,/.well-known) plus any/api/**route through without redirect — the routes themselves enforce authorisation viagetAuthorisedClient. - Redirects unauthenticated users on non-public, non-API routes to
/login. - Wraps the proxy body in an
AsyncLocalStoragerequest context so any log lines emitted by the proxy carryrequestId.
PUBLIC_ROUTES is the shared constant in lib/routes.ts, imported by both
the proxy and client components. Adding a new public route requires editing
that constant — anything left out silently redirects to /login.
Cron auth
Section titled “Cron auth”Cron routes verify a Authorization: Bearer <CRON_SECRET> header via
verifyCronAuth(request) from lib/cron-auth.ts. The same module exposes
getUsersByRole(supabase, ['admin']) for resolving notification recipients.
API Routes
Section titled “API Routes”Admin routes
Section titled “Admin routes”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/admin/users | Admin | List users (reads user_profiles + user_roles) | app/api/admin/users/route.ts |
| PATCH/DELETE | /api/admin/users/[userId] | Admin | Update or deactivate a user | app/api/admin/users/[userId]/route.ts |
| POST | /api/admin/users/invite | Admin | Invite a new user | app/api/admin/users/invite/route.ts |
| GET | /api/admin/pipeline-runs/recent | Admin | Pipeline health monitoring | app/api/admin/pipeline-runs/recent/route.ts |
| GET | /api/admin/provenance/pipeline-runs | Admin | Pipeline runs (keyset paging) | app/api/admin/provenance/pipeline-runs/route.ts |
| GET | /api/admin/provenance/export/verification-history | Admin | Monthly verification PDF export | app/api/admin/provenance/export/verification-history/route.ts |
| GET | /api/admin/tag-morphology/flags | Admin/Editor | List drift flags from corpus regression eval | app/api/admin/tag-morphology/flags/route.ts |
| POST | /api/admin/tag-morphology/flags | Admin/Editor | Bulk insert/upsert drift flags | app/api/admin/tag-morphology/flags/route.ts |
| PATCH | /api/admin/tag-morphology/flags/[id] | Admin/Editor | Triage individual flag (accept/dismiss) | app/api/admin/tag-morphology/flags/[id]/route.ts |
| POST | /api/admin/taxonomy-sync | Admin | Compare taxonomy hash; dispatch GH workflow on drift | app/api/admin/taxonomy-sync/route.ts |
| GET | /api/admin/taxonomy-sync/status | Admin | Latest sync state | app/api/admin/taxonomy-sync/status/route.ts |
| POST | /api/admin/taxonomy-sync/callback | Workflow PAT | GH Actions callback to flip pipeline_runs status | app/api/admin/taxonomy-sync/callback/route.ts |
| GET | /api/provenance/item | Admin | Per-item provenance data | app/api/provenance/item/route.ts |
Cron routes (Vercel-scheduled, Authorization: Bearer <CRON_SECRET>)
Section titled “Cron routes (Vercel-scheduled, Authorization: Bearer <CRON_SECRET>)”| Method | Route | Schedule (UTC) | Purpose | File |
|---|---|---|---|---|
| GET | /api/cron/freshness-transitions | 15 3 * * * | Detect freshness state changes; bridge to governance | app/api/cron/freshness-transitions/route.ts |
| GET | /api/cron/review-cadence | 45 3 * * * | Flag items past next_review_date; notify owners/admins | app/api/cron/review-cadence/route.ts |
| GET | /api/cron/classification-quality | 0 4 * * 0 | Audit low-confidence classifications (weekly) | app/api/cron/classification-quality/route.ts |
| GET | /api/cron/coverage-alerts | 0 5 * * 1 | Domain coverage threshold checks (weekly) | app/api/cron/coverage-alerts/route.ts |
| GET | /api/cron/content-gaps | 30 5 * * 1 | Scan template requirement gaps; create notifications | app/api/cron/content-gaps/route.ts |
| GET | /api/cron/quality-score | 0 5 * * 0 | Periodic quality score recalculation (weekly) | app/api/cron/quality-score/route.ts |
| GET | /api/cron/intelligence-poll | */15 * * * * | Poll Sector Intelligence feeds | app/api/cron/intelligence-poll/route.ts |
| GET | /api/cron/intelligence-cleanup | 0 3 * * 0 | Clean up stale intelligence (weekly) | app/api/cron/intelligence-cleanup/route.ts |
| GET | /api/cron/process-queue | * * * * * | Background-job worker (S221 W1 + S222 W2 + S223 W3 + S224 W4 bid_draft_all registered + W5 cadence flip */5→* per Vercel Pro) | app/api/cron/process-queue/route.ts |
review-cadence (S201 §5.5 Phase 2) runs 30 minutes after
freshness-transitions so freshness state is settled before cadence checks
fire. intelligence-cleanup enforces the 90-day SI article retention.
Background-queue worker (S221–S224 §5.4)
Section titled “Background-queue worker (S221–S224 §5.4)”The process-queue cron handler is the chokepoint for processing_queue
job dispatch. Every cron tick (* * * * * post-S224 W5 cadence flip from
prior */5 * * * * per Vercel Pro; maxDuration=60 post-S224 W4 §5.4.1
D-3 ratification from prior maxDuration=50; TIMEOUT_BUFFER_MS=50_000
keeping 10s headroom):
- Reap stuck jobs —
lib/queue/visibility-timeout.tscalls thereap_stuck_jobs(p_timeout_seconds)RPC (S223 W3-A migration20260505153750_*) to flip orphanedstatus='processing'rows whosestarted_at < NOW() - 5 minback topending, incrementingattemptsatomically server-side. - Claim next job —
claim_next_job()RPC selects the oldest pending row withupdated_at <= NOW()(S223 W3-A backoff window — gates re-claim onlib/queue/failure.ts’s requeueupdated_at = NOW() + <backoff>write),FOR UPDATE SKIP LOCKEDfor concurrency safety. Per ID-372 {372.2} (26/07/2026, migration20260726231847_*) the cron passesp_exclude_job_types = WORKER_JOB_TYPESso it never claims a job owned by the Python bid worker (see “Two-consumer topology” below); an optional?idempotency_key_prefix=query param narrows the tick to one tranche (ID-128 {128.21}). - Re-validate auth context —
lib/queue/auth.ts:reValidateAuthContextreads the enqueueing user’s current role fromuser_rolesandfaileds the job if the role is below therequiredRolerecorded in the envelope (e.g. user demoted from editor to viewer between enqueue and claim). - Dispatch —
lib/queue/dispatch.ts:runJobByTypeswitches onjob.job_type. Post-S224 W4 the FIRST migration candidate is registered:case 'form_draft_all'(renamed frombid_draft_allin the bid→forms rename) validates the envelope viaqueueJobPayloadSchema.safeParse, callsreValidateAuthContext(... 'editor')per spec §4.2 + D-1, dispatchesrunBidDraftAllJob(export name unchanged) fromlib/queue/handlers/procurement-draft-all.ts, then finalises the caller-allocatedpipeline_runsrow via DIRECT UPDATE (NOTrecordPipelineRun()— INSERT-only helper would create a 2nd row; drift documented inline; perfeedback_record_pipeline_run_signatureitems_created: string[]is the array ofform_responses.idUUIDs created during the run, NOT a count). Remaining historic job_types (embed,classify,extract_qa,summarise,validate,reprocess,template_analyse) fall through to the permanent-failure defaultPermanentJobError('no_handler_registered: …')until each §5.4.x candidate registers its owncase. ThrowsPermanentJobErrorfor unrecoverable failures (envelope schema mismatch, unknown user, quality-gate refusal); other errors classified as transient byisTransientErrorinlib/queue/failure.ts. - Failure handling — transient → requeue with linear-with-jitter
backoff (
(attempts * 30s) + random(0..5s)),attempts++,updated_at = NOW() + backoff. Permanent →status='failed', no retry. Retry exhaustion →status='dead_lettered'.
Two-consumer topology + type-scoped claims (ID-372 {372.2})
Section titled “Two-consumer topology + type-scoped claims (ID-372 {372.2})”processing_queue has a SECOND consumer: scripts/bid_worker.py (Coolify
poller, 2 s cadence, deployed against Platform prod + staging and the
client boxes). The two consumers have disjoint job-type coverage:
| Consumer | Handles | Fails everything else with |
|---|---|---|
app/api/cron/process-queue/route.ts | form_draft_all, batch_reclassify | no_handler_registered (permanent) |
scripts/bid_worker.py | template_fill, analyse_form | Unknown job type: <type> |
Without type scoping, each consumer destroyed the other’s jobs whenever it
won the claim race — measured on Platform staging (17–26/07/2026): 24
form_draft_all rows falsely failed by the 2 s bid-worker poller with
Unknown job type: form_draft_all (one killed during a CI integration
run). Both production DBs read zero such rows at fix time — latent
exposure only.
Migration 20260726231847_id372_claim_next_job_type_scoping.sql widens
claim_next_job (and its api.* SECURITY INVOKER wrapper, same migration
per DR-032) with two optional parameters:
p_job_types text[]— include list. NULL = all types; an empty array claims nothing (fails closed, never widens to a global claim).p_exclude_job_types text[]— exclude list. NULL / empty = excludes nothing.
NULL for either preserves the prior behaviour byte-for-byte, so the no-arg call keeps working at apply time. Consumers then opt in:
bid_worker.pypassesp_job_types = WORKER_JOB_TYPES(claim ONLY what it processes).- The
process-queueroute passesp_exclude_job_types = WORKER_JOB_TYPES— deliberately an EXCLUDE list, not an include list: the route’sPermanentJobErrordefault is the queue’s loud dead-letter for types nobody registered a handler for, and an include list would need hand-maintaining as types are added (a forgotten entry would leave rows invisibly pending forever).
WORKER_JOB_TYPES is canonically ['template_fill', 'analyse_form'] in
lib/queue/worker-job-types.ts and mirrored as a Python literal in
scripts/bid_worker.py; scripts/tests/test_bid_worker.py runs a
py↔ts parity test so an edit to either side without the other fails CI.
Note analyse_form (ID-145 {145.13} Plane-1 + Plane-2 form-analysis
lane) is distinct from the legacy template_analyse historic job_type.
Companion endpoints surface job state to the UI:
| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/jobs/[id]/status | Admin/Editor | Single-row poll endpoint for UI in-progress widgets | app/api/jobs/[id]/status/route.ts |
| PATCH | /api/jobs/[id]/cancel | Admin/Editor | Cancel a pending job (409 if already processing); race-safe filter | app/api/jobs/[id]/cancel/route.ts |
12 acceptance criteria cover the queue lifecycle (docs/specs/background-queue-infra-spec.md
§8); integration coverage at
__tests__/integration/queue/{lifecycle,concurrency}.integration.test.ts
runs against the staging Supabase branch and asserts on observable DB state
transitions (no mocked supabase).
Taxonomy + content-organisation routes (admin-only writes)
Section titled “Taxonomy + content-organisation routes (admin-only writes)”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/taxonomy/domains | Auth | List domains | app/api/taxonomy/domains/route.ts |
| POST | /api/taxonomy/domains | Admin | Create domain | app/api/taxonomy/domains/route.ts |
| PATCH | /api/taxonomy/domains/[id] | Admin | Update domain | app/api/taxonomy/domains/[id]/route.ts |
| DELETE | /api/taxonomy/domains/[id] | Admin | Delete domain | app/api/taxonomy/domains/[id]/route.ts |
| GET | /api/taxonomy/subtopics | Auth | List subtopics | app/api/taxonomy/subtopics/route.ts |
| POST | /api/taxonomy/subtopics | Admin | Create subtopic | app/api/taxonomy/subtopics/route.ts |
| PATCH | /api/taxonomy/subtopics/[id] | Admin | Update subtopic | app/api/taxonomy/subtopics/[id]/route.ts |
| DELETE | /api/taxonomy/subtopics/[id] | Admin | Delete subtopic | app/api/taxonomy/subtopics/[id]/route.ts |
| GET | /api/tags | Auth | List tags | app/api/tags/route.ts |
| PATCH | /api/tags/[id] | Admin | Update tag | app/api/tags/[id]/route.ts |
| DELETE | /api/tags/[id] | Admin | Delete tag | app/api/tags/[id]/route.ts |
| GET | /api/layers | Auth | List layers | app/api/layers/route.ts |
| POST | /api/layers | Admin | Create layer | app/api/layers/route.ts |
| PATCH | /api/layers/[id] | Admin | Update layer | app/api/layers/[id]/route.ts |
Taxonomy edits flip the taxonomy_sync_state.last_sync_hash against the
DB-computed hash. The Settings UI surfaces a TaxonomyDriftBanner while the
hashes diverge; POST /api/admin/taxonomy-sync dispatches a GitHub Actions
workflow that regenerates lib/ai/skills/classification.md,
scripts/tests/fixtures/taxonomy_snapshot.json, and the plugin bundle, then
calls POST /api/admin/taxonomy-sync/callback to flip the
pipeline_runs row status.
Governance + notifications
Section titled “Governance + notifications”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/governance | Admin | Get governance configuration | app/api/governance/route.ts |
| POST | /api/governance | Admin | Update governance configuration | app/api/governance/route.ts |
| POST | /api/governance/review | Admin | Approve / request changes / revert | app/api/governance/review/route.ts |
| GET | /api/notifications | Auth | List user notifications | app/api/notifications/route.ts |
| POST | /api/notifications/read | Auth | Mark notifications as read | app/api/notifications/read/route.ts |
| GET | /api/notifications/preferences | Auth | Fetch user notification prefs | app/api/notifications/preferences/route.ts |
| PUT | /api/notifications/preferences | Auth | Upsert user notification prefs | app/api/notifications/preferences/route.ts |
/review (the Quality Review queue page) is the admin-only review surface
for content edits. The /api/governance/review endpoint is symmetric with
the MCP governance.review tool — both apply cadence-driven auto-renewal
on approve (advance next_review_date to GREATEST(current, today) + review_cadence_days, stamp verified_at).
| Page | Route | Auth | File |
|---|---|---|---|
| Settings | /settings | All roles | app/settings/page.tsx |
| Provenance | /provenance | Admin | app/provenance/page.tsx |
| Quality Review | /review | Admin/Editor | app/review/page.tsx |
SettingsContent lazy-loads admin-only sections via React.lazy so they
stay out of the viewer-bundle.
Components
Section titled “Components”Settings sidebar + sections
Section titled “Settings sidebar + sections”SettingsSidebar (components/settings/settings-sidebar.tsx) renders 12
sections in 3 groups (Personal, Content Management, System). Provenance
links out to /provenance; the others switch the content panel.
| Section | Group | Visible to | Component |
|---|---|---|---|
| Profile | Personal | All | components/settings/profile-section.tsx |
| Organisation | Personal | Admin/Editor | components/settings/organisation-section.tsx |
| Connections | Personal | All | components/settings/connections-section.tsx |
| Content Organisation | Content Mgmt | Admin | components/settings/content-organisation-section.tsx |
| Content Owners | Content Mgmt | Admin | components/settings/content-owner-management.tsx |
| Organisations & People | Content Mgmt | Admin | components/settings/entities-section.tsx |
| Guides | Content Mgmt | Admin | components/settings/guides-section.tsx |
| Tag Morphology | Content Mgmt | Admin/Editor | components/settings/tag-morphology-section.tsx |
| Team | System | Admin | components/settings/team-section.tsx |
| Quality Review (Governance) | System | Admin | components/settings/governance-section.tsx |
| Reviewer Assignments | System | Admin | components/review/assignment-manager.tsx |
| Provenance | System | Admin (out-link) | app/provenance/page.tsx |
Within ContentOrganisationSection are three tabs: Categories
(TaxonomySection + TaxonomyDriftBanner), Tags (TagsSection — 2-tab
container wrapping TagsCleanup + TagsBrowse), and Layers
(LayersSection). ProfileSection embeds NotificationPreferences as a
sub-card. ConnectionsSection includes a “For developers” disclosure
gated by useUserRole().canAdmin.
Provenance surface
Section titled “Provenance surface”/provenance is admin-only with five tabs in components/provenance/:
| Tab | Component | Status | Purpose |
|---|---|---|---|
per-item | per-item-tab.tsx | Live | UUID lookup → classification, processing, cost |
pipeline-health | pipeline-health-tab.tsx | Live | Server-side rollup, failure drawer, time-range |
audit | audit-tab.tsx | Live | Activity feed (lifted from old Settings Activity) |
cost | cost-tab-stub.tsx | Placeholder | Aggregate cost queries (planned) |
disputes | disputes-tab-stub.tsx | Placeholder | Classification disputes (planned) |
Tab IDs are the single source of truth in
components/provenance/tab-ids.ts. Non-admins see <AccessDenied>.
The legacy /activity route and /settings?section=activity redirect to
/provenance?tab=audit. The Provenance surface is the only place where
model names, token counts, cost figures, and classification reasoning are
visible per the AI-visibility policy
(docs/reference/ai-visibility-policy.md).
Database Tables
Section titled “Database Tables”| Table | Purpose | Key columns | RLS |
|---|---|---|---|
user_roles | Role assignment (one row per user) | user_id, role (admin/editor/viewer), display_name | Self SELECT; Admin UPDATE |
user_profiles (S8 WP-G3.4) | Mirror of auth.users for app reads | id (FK auth.users), email, full_name, created_at, updated_at | Self SELECT; Admin/Editor SELECT all; INSERT/UPDATE/DELETE revoked |
user_notification_prefs | Email notification toggles | user_id PK, three booleans + auto_generate_change_reports | Self SELECT/UPDATE |
notifications | In-app notifications | user_id, type, entity_type, entity_id, title, message, expires_at | App: SELECT own row |
taxonomy_domains | Domain configs | name, description, key_signal, display_order, is_active | All SELECT; Admin write |
taxonomy_subtopics | Subtopic configs | name, domain_id, description, display_order, is_active | All SELECT; Admin write |
taxonomy_sync_state | Singleton row tracking last sync hash | last_sync_hash, last_synced_at | Admin SELECT/UPDATE |
tag_morphology_drift_flags | Corpus regression queue (S195) | stored_tag, proposed_canonical, usage_count, decision, decided_by | Admin/Editor write |
layer_vocabulary | Dynamic layer definitions | key, label, description, display_order, is_active | All SELECT; Admin write |
pipeline_runs | Cron + ingest run log | pipeline_name, status, started_at, duration_ms, result, items_created | Admin read |
governance_config | Domain-level governance rules | domain, auto_flag_on_freshness_transition, reviewer_id | Admin write |
classification_disputes | HITL classification override log | content_item_id, original_domain, corrected_domain, reason | Admin write |
review_assignments | Scheduled review assignments | assignee, content_item_id, due_date, priority | Admin write |
user_profiles is populated by an AFTER INSERT trigger
(on_auth_user_created → handle_new_user()) that consolidates the S157
viewer-default user_roles seed AND the mirror insert in one body, plus an
AFTER UPDATE trigger (on_auth_user_updated → handle_user_update()) that
keeps email + full_name + updated_at in sync. Both functions are
SECURITY DEFINER with SET search_path = public, extensions and EXECUTE
revoked from PUBLIC/anon/authenticated.
Key RPC functions
Section titled “Key RPC functions”| Function | Purpose | Notes |
|---|---|---|
get_user_role() | Resolve calling user’s role for RLS predicates | SECURITY DEFINER, STABLE |
get_user_display_names(uuid[]) | Batch resolve UUIDs to display names | Reads user_profiles mirror (no auth.users dependency); PIPELINE_SYSTEM_USER_ID returns 'Pipeline (system)' |
count_auth_users() (S205) | Service-role parity probe vs user_profiles | SECURITY DEFINER; admin-only EXECUTE |
list_public_tables() | Auto-inventory for db-row-count-diff.ts | Excludes partitions and views; returns setof text |
The S156 GoTrue NULL-token defensive stack remains in place: a runtime
BEFORE INSERT OR UPDATE trigger on auth.users coerces NULL → '' on 8
GoTrue token columns; app/api/admin/users reads user_profiles + user_roles via PostgREST and isolates the residual
auth.admin.listUsers() call (only used for last_sign_in_at) inside a
soft-fail try/catch.
Library Modules
Section titled “Library Modules”| Module | File | Purpose |
|---|---|---|
getAuthorisedClient | lib/auth/client.ts | Discriminated-union auth helper for API routes (admin/editor/viewer) |
authFailureResponse | lib/auth/client.ts | Helper that converts a failed AuthorisedResult to the correct HTTP status |
verifyCronAuth, getUsersByRole | lib/cron-auth.ts | Bearer CRON_SECRET verification + role-recipient resolution for cron handlers |
recordPipelineRun() | lib/pipeline/record-run.ts | Canonical insert into pipeline_runs; uses sb(), never throws, used by every cron handler |
sb(), tryQuery, SupabaseError | lib/supabase/safe.ts | Fail-fast / Result-returning Supabase wrappers (silent-failure prevention) |
warningsEnvelope | lib/supabase/warnings.ts | Composite-response wrapper for partial-failure semantics |
logBestEffortWarn | lib/supabase/telemetry.ts | Best-effort swallow with structured warn log |
clientEnv, serverEnv | lib/env-client.ts, lib/env.ts | Zod-parsed env at module load; literal process.env.NEXT_PUBLIC_* access for Next.js substitution |
BRANDING, loadBranding | lib/client-config.ts | Per-client build-time branding (logo, colour, metadata) via NEXT_PUBLIC_CLIENT_ID |
runWithRequestContext | lib/logger/request-context.ts | AsyncLocalStorage scope for the per-request requestId |
Configuration
Section titled “Configuration”| Setting | Location | Purpose |
|---|---|---|
.env.local | Repo root (gitignored) | Single source of truth for both TS and Python pipelines (.env retired in kh-prod-readiness-S6 27/04/2026) |
SUPABASE_URL + NEXT_PUBLIC_SUPABASE_URL | .env.local | Default points at staging branch turayklvaunphgbgscat |
SUPABASE_SERVICE_ROLE_KEY | .env.local | Renamed S201 WP-FU.1 (was SUPABASE_SERVICE_KEY); ~115 files touched |
SUPABASE_PUBLISHABLE_KEY, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | .env.local | Replaced anon-key naming during April 2026 Vercel env rotation |
CRON_SECRET | .env.local + Vercel | Authorization: Bearer ... header on every cron route |
NEXT_PUBLIC_CLIENT_ID | .env.local + Vercel | Selects lib/branding/clients/{id}.json (defaults to knowledge-hub) |
vercel.json crons[] | Repo root | Schedule + path map for Vercel cron jobs |
| GitHub Environments | Production + Staging | Case-sensitive; secrets/vars per environment per docs/runbooks/github-environments.md |
Prod-targeted CLI scripts opt-in via --env=prod flag (top-10 scripts) or
explicit SUPABASE_URL=<prod-url> ... bun run scripts/X.ts. The
default-staging move was WP-S5.2 (kh-prod-readiness-S6); full guidance lives
in docs/runbooks/local-development.md.
Operator-side CLI Tooling
Section titled “Operator-side CLI Tooling”| Script | Purpose | Spec / runbook |
|---|---|---|
scripts/seed-e2e-users.ts | Idempotent E2E user provisioning via auth.admin.createUser() (--check / --dry-run modes); verifyPipelineUserShape() pre-flight detects S156-class corruption | docs/operations/database-rebuild-runbook.md |
scripts/verify-user-profiles-parity.ts | Compare user_profiles vs auth.users count via count_auth_users() RPC; --env=prod|staging|auto | docs/reference/SCHEMA-QUICK-REFERENCE.md §30 |
scripts/export-user-data.ts (S10 W6) | UK GDPR Article 15 / Article 20 export bundle (20 PII tables, SHA-256 manifest) | docs/handover/gdpr-data-export.md |
scripts/run-supabase-advisors.ts (S13) | Supabase Management API security + performance advisor lint vs committed baseline | docs/runbooks/ci.md §6.5 |
scripts/db-row-count-diff.ts (S13) | Per-table row count diff source vs target (default prod→staging) with allowlist; bun run db:row-count-diff | docs/runbooks/staging-refresh.md |
scripts/migration-replay-check.ts | Ephemeral preview branch + supabase db push --linked to catch squash-divergence at PR time | WP-G4.5 spec slot |
The advisor lint baseline lives at
docs/audits/kh-production-readiness-phase-1/supabase-advisor-baseline.json.
The row-count-diff allowlist lives at
scripts/db-row-count-diff-allowlist.json ("expected-empty" for staging
tables that legitimately stay empty, or a numeric tolerance for ±N drift).
CI Gates
Section titled “CI Gates”CI workflows live in .github/workflows/. Branch protection enforcement
gates on the GitHub Pro upgrade (per docs/runbooks/ci.md §3); checks run
and report regardless.
| Workflow | File | Triggers | Environment scope | Purpose |
|---|---|---|---|---|
| Quality gates | .github/workflows/ci.yml quality | PR + push to main | Production | install → lint → format-check → knip baseline → build → vitest (changed-only on PR; full on push) → conditional pytest → build-output regression scan |
| E2E smoke (S10 WP-G4.3) | .github/workflows/ci.yml e2e-smoke | Same as above | Staging | ~54 @smoke-tagged Playwright tests against staging dev server (Chromium only). continue-on-error: true until staging eval-fixture seed lands (roadmap §9.16.10) |
| MCP eval (S10 WP-G4.4) | .github/workflows/ci.yml mcp-eval | Same as above; matrix [l1, l3, l4] | Staging | Layer 1 protocol compliance, Layer 3 response quality, Layer 4 functional correctness; skip-flag policy: full mode on push-to-main + MCP-touch PRs, --skip-ai/--skip-search otherwise |
| Migration replay (S10 WP-G4.5) | .github/workflows/migration-replay.yml | PR + push to main/production-readiness when supabase/{migrations,seed.sql,config.toml} change | Production | Ephemeral Supabase preview branch + supabase db push --linked from scratch; catches squash-divergence at PR time |
| Supabase advisors (S13 WP-G4.6) | .github/workflows/supabase-advisors.yml | nightly 02:00 UTC + push to main on supabase/migrations/**/related; workflow_dispatch | Production | Diffs Management API advisor findings vs committed baseline; PR-blocking on new entries |
| Taxonomy sync | .github/workflows/taxonomy-sync.yml | repository_dispatch from admin Settings UI | Production | Regenerates classification.md, snapshot, plugin bundle on taxonomy-hash drift |
POSTGRES_PASSWORD, SUPABASE_ACCESS_TOKEN, SENTRY_AUTH_TOKEN, and the
test-user credentials are env-scoped per docs/runbooks/github-environments.md.
Dependabot
Section titled “Dependabot”.github/dependabot.yml (S13 WP-CI.RES.4-IMPL) tracks three ecosystems with
weekly Monday cadence:
bun— package.json + bun.lock; minor/patch grouped, majors separatepip— requirements.txt for the Python pipeline; minor/patch groupedgithub-actions— workflowuses:versions; surfaces Node 20→24 deprecation as PRs (June 2026 default flip; September 2026 removal)
PR limits: 10 (bun), 5 (pip), 5 (github-actions). claude-code-action@v1 is
deliberately excluded — Routines via Claude.ai seats handle that surface
instead.
Background Automation (Cron + Pipeline Recording)
Section titled “Background Automation (Cron + Pipeline Recording)”Eight Vercel cron jobs (table above). Every cron handler:
- Calls
verifyCronAuth(request)→ 401 on header mismatch. - Uses
createServiceClient()(RLS-bypass) for cross-user reads. - Wraps DB calls in
sb()(fail-fast) ortryQuery()(Result-returning) — silent-failure prevention enforced via ESLintlocal/no-unchecked-supabase-error. - Records the run via
recordPipelineRun({ supabase, pipelineName, status, itemsProcessed, errorMessage?, result? })fromlib/pipeline/record-run.ts.resultis JSONB;items_createdisuuid[].statusis one ofrunning/completed/completed_with_errors/failed. - On batch >threshold, switches to summary notifications per recipient
(e.g.
review-cadenceswitches to a single batched notification per owner when more than 20 items flag in one run).
Auto-renewal for renewable certifications (S201 §5.5 Phase 2 T2) is the
symmetric pair to the cron flagging: when a reviewer approves a flagged
item via /api/governance/review or the MCP governance.review tool, the
helper advances next_review_date to GREATEST(current, today) + review_cadence_days and stamps verified_at.
Observability + Logging
Section titled “Observability + Logging”- Sentry release tagging —
withSentryConfigsetsrelease.name = process.env.VERCEL_GIT_COMMIT_SHA; production stack traces map to deploy SHA. - Sentry SDK init decoupling (S9 OPS-38) —
sentry.{client,server,edge}.config.tsread DSN directly fromprocess.env, decoupled from theclientEnvZod gate so env-validation failures cannot silence Sentry itself. - Sentry Turbopack sourcemap upload (S10) —
silent: !process.env.CIrelease.create: false, finalize: falseto surface upload failures in prod build logs.
- Sentry slug — single project
knowledge-hub-phew-design(orgtw-group-s3, region DE). - Structured logging Phase 1 (S9 W4) — Pino root logger at
lib/logger/index.tswithAsyncLocalStoragerequest-context mixin, Sentry bridge forwardingwarn/error/fatal, PII-redacting serialisers (REDACT_PATHS). Proxy mintscrypto.randomUUID()per request and propagates viax-request-idheaders.lib/error.tsis intentionally client-safe (no logger import). Phase 2-6 (route migrations, console-to-logger sweep, Axiom destination, Python correlation) carry forward perdocs/specs/structured-logging-spec.mdv1.1.
Pipeline Run Recording
Section titled “Pipeline Run Recording”recordPipelineRun() in lib/pipeline/record-run.ts is the canonical
insert into pipeline_runs. Required fields: supabase,
pipelineName, status. status is
'completed' | 'completed_with_errors' | 'failed' | 'running'; result is
JSONB; itemsCreated is string[] (uuid[]). Helper uses sb() internally,
fires Sentry on failures, never throws — safe for cron handlers and
background jobs. All cron routes use this helper rather than raw inserts.
Client Branding Configuration
Section titled “Client Branding Configuration”Build-time configuration (Option A from the S158 feasibility study). Each
Vercel deployment reads a Zod-validated JSON config selected by
NEXT_PUBLIC_CLIENT_ID. Default falls back to knowledge-hub (default
config) when unset.
The branding pipeline runs once at module initialisation:
loadBranding()resolves the client ID from the env var.- The matching
lib/branding/clients/{id}.jsonis parsed againstBrandingConfigSchema(Zod). Schema violations fail the build. - OKLCH contrast validation runs a two-tier WCAG 2.1 AA check:
- Tier 1 (non-text, 3:1) — primary vs light/dark backgrounds, warnings only.
- Tier 2 (text, 4.5:1) — foreground vs primary, errors fail the build.
Clients must supply explicit
brandPrimaryForegroundif auto-derive cannot meet the threshold.
buildBrandStyleProps()generates a<style>element injected inapp/layout.tsxoverriding--primary,--primary-foreground, and--ringfor both:rootand.darkscopes.
Brand asset paths are validated against public/ at build time via
brandAssetExists() — a typo in a logo path fails the build rather than
producing a broken image.
One-project-per-client model
Section titled “One-project-per-client model”Each client’s Vercel deployment pairs with its own Supabase project. The branding layer fits entirely inside a single deployment — there is no multi-tenant-within-one-project logic. This matches “One Supabase project per client — simple isolation, not multi-tenant RLS.”
Client onboarding workflow
Section titled “Client onboarding workflow”- Create
lib/branding/clients/{id}.jsonfollowingBrandingConfigSchema. - Add logo + favicon assets to
public/clients/{id}/. - Register a static import + map entry in
lib/client-config.ts(CLIENT_BRANDING_MAP). - Set
NEXT_PUBLIC_CLIENT_ID={id}in Vercel environment variables. - Deploy. Schema validation, asset-path checks, and WCAG contrast validation block any failure.
Branding surfaces
Section titled “Branding surfaces”| Surface | What is branded | Consuming file(s) |
|---|---|---|
| Site header | Logo image + product name (screen reader) | components/shell/site-header.tsx, components/shell/brand-logo.tsx |
| Login page | Logo, product name, tagline | app/login/page.tsx, app/login/error.tsx |
| Homepage | Product name heading | app/page.tsx |
| OAuth consent | Product name | app/oauth/consent/page.tsx, app/oauth/consent/error.tsx |
| Browser tab | Title, favicon (SVG + PNG) | app/layout.tsx |
| Settings pages | Product name in connection/developer copy | components/settings/connections-section.tsx, components/settings/connected-apps-section.tsx |
| Command palette | Search placeholder text | components/shell/command-palette.tsx |
| Dashboard | Empty-state product reference | components/dashboard/reorient-section.tsx |
| Batch import | Page heading | app/item/new/batch/page.tsx |
| Guide pages | Title suffix | app/guide/[slug]/page.tsx |
| Change report export | Email subject line | components/change-reports/change-report-export-menu.tsx |
| RSS feed | Generator tag | lib/intelligence/rss-generator.ts |
| CSS primary colour | --primary, --primary-foreground, --ring (light + dark) | app/layout.tsx via buildBrandStyleProps() |
Auth Hooks
Section titled “Auth Hooks”The before-user-created Supabase Auth event fires the
public.hook_restrict_signup_to_phew_domain(event jsonb) PL/pgSQL function,
which gates sign-ups to @phew.org.uk only (HTTP 403 on mismatch). Captured
in migration 20260424202806_capture_phew_domain_hook.sql with SET search_path = public, extensions plus REVOKE EXECUTE ... FROM public, anon, authenticated, service_role and GRANT EXECUTE ... TO supabase_auth_admin. Dashboard wiring is NOT captured in SQL — must be
re-configured after any project reset. Combined with the viewer-default
trigger (on_auth_user_created), new Phew users get least-privilege access
on first magic-link request. Multi-client table-driven allowlist is tracked
as backlog 33; admin invite-only flow as OPS-28. Full audit:
docs/reference/auth-hooks.md.
Testing
Section titled “Testing”| Scope | Path | Notes |
|---|---|---|
| Admin API routes | __tests__/api/admin*.test.ts, __tests__/api/admin/** | Mocked Supabase client |
| Notifications API + lib | __tests__/api/notifications.test.ts, __tests__/api/notifications/preferences.test.ts, __tests__/lib/notifications.test.ts, __tests__/lib/source-document-notifications.test.ts | Notification dedup, preferences upsert |
| Cron handlers | __tests__/api/cron/*.test.ts | freshness-transitions, review-cadence, quality-score, intelligence-poll, coverage-alerts targets |
| Taxonomy sync | __tests__/api/admin/taxonomy-sync*.test.ts | Hash drift, callback signature, status |
| Provenance + pipeline runs | __tests__/api/admin-provenance-pipeline-runs.test.ts, __tests__/api/admin-pipeline-runs.test.ts, __tests__/api/admin/provenance/export/verification-history.test.ts | Keyset paging, PDF export |
| Settings hooks | __tests__/hooks/use-layer-admin.test.ts, __tests__/hooks/use-taxonomy-admin.test.ts, __tests__/hooks/use-notifications.test.ts | TanStack Query keys |
| Admin users + S156 regression | __tests__/integration/admin-users.integration.test.ts | Real-DB integration; 13 scenarios incl. probe-row witness; cached per-role session helper at __tests__/integration/helpers/auth-session.ts |
| Build-output guards | __tests__/build/env-substitution.test.ts | Chunk-grep guard against process.env.NEXT_PUBLIC_* substitution-defeat (S7 P0 regression) |
| Doc freshness | __tests__/lib/doc-freshness.test.ts | Tracked-doc edit + Last-verified header bump must be atomic |
Test counts in docs/generated/codebase-stats.md. Run scopes: bun run test (Vitest unit + non-integration), bun run test:integration
(integration tier), bun run test:e2e (Playwright), bun run test:mcp-eval[:rq|:fc] for MCP eval Layers 1/3/4.
Current Limitations
Section titled “Current Limitations”- Role creation is handled by a database trigger linking
auth.userstouser_roles(viahandle_new_user); a silent trigger failure on sign-up leaves the user with no valid role and they cannot log in. - Settings forms (e.g. layers, taxonomy) perform direct updates without versioning/history tracking on admin metadata schemas.
e2e-smoke,mcp-eval, andmigration-replayworkflows currently sit withcontinue-on-error: trueuntil the staging PII-scrubbed live-mirror lands (roadmap §9.16.10). PR-blocking enforcement returns once data dependencies are met.- Branch protection enforcement is gated on the GitHub Pro upgrade
(
docs/runbooks/ci.md§3.1). - The
costanddisputesProvenance tabs are placeholder stubs. Aggregate cost queries and the classification disputes UI are scoped but unbuilt. - Auth hook dashboard wiring (
hook_restrict_signup_to_phew_domain) is NOT captured in SQL — must be re-configured after project reset. last_sign_in_atfor the admin user list still depends on a singleauth.admin.listUsers()call, soft-failing to NULL on GoTrue regression (S156-class) — degraded gracefully but the underlying upstream bug (supabase/auth#1940) remains unfixed.
Architecture Decisions
Section titled “Architecture Decisions”| Decision | Rationale | Alternative considered |
|---|---|---|
| Build-time per-client branding (S158 + S163) | Single Vercel project per client, simple isolation, no multi-tenant RLS | Runtime Supabase-driven branding (deferred — adds DB read on every render) |
getAuthorisedClient discriminated union | Forces handlers to disambiguate the four failure modes (401/403/500/500) and routes ops alerting on real auth failures | Single boolean return — silently downgrades real DB failures to “viewer” |
recordPipelineRun() helper everywhere | Single insert path makes it easier to add Sentry forwarding, structured logging, retries; uses sb() so silent failures are impossible | Raw supabase.from('pipeline_runs').insert() per cron — proven to silently swallow errors |
public.user_profiles mirror (S8 WP-G3.4) | PostgREST cannot expose auth.users; mirror sidesteps the S156-class GoTrue regression vector for bulk reads | Direct auth.admin.listUsers() — single corrupt token-column row poisons the entire scan |
.env.local single source, default-staging (S6 WP-S5.2) | Cheaper experimentation; staging typo is recoverable, prod typo corrupts canonical KB | Pre-flip default-prod (rejected — too easy to accidentally write to prod) |
| Sentry SDK init decoupled from Zod gate (S9 OPS-38) | Env-validation failures must NOT silence the very tool meant to surface them | Single Zod gate covering Sentry — was the S7 P0 regression class |
| Migration-replay smoke against ephemeral preview branch (S10 WP-G4.5) | Catches squash-divergence (the failure that hit S4 + S8 — extensions placed in public, patched out-of-band, never captured as a migration) at PR time | Manual post-merge verification — too slow |
| Supabase advisor lint baseline (S13 WP-G4.6) | RLS gaps, SECURITY DEFINER overexposure, missing FK indexes regress silently otherwise; PR-blocking lint surfaces them within 5 minutes | Manual dashboard scan — proven unreliable cadence |
tag_morphology_drift_flags queue + admin/editor write | Corpus regression eval populates queue; humans triage individual flags; preserves single-source-of-truth taxonomy | Auto-rewrite stored tags (rejected — irreversible, breaks data integrity) |
| Cron auto-renewal symmetric across API + MCP review surfaces (S201) | Reviewer approves flagged item from either surface; single helper advances next_review_date consistently. Eliminates drift between web app + MCP | Surface-specific renewal logic (rejected — inevitable drift) |