Skip to content

Administration — Technical Reference

Last verified: 26/07/2026 — refreshed for ID-372 {372.2} type-scoped claims: claim_next_job widened with optional p_job_types / p_exclude_job_types so the cron route and scripts/bid_worker.py claim disjoint job-type sets (see lib/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 in lib/queue/dispatch.ts; cron route maxDuration 50→60 per D-3) + W5 (cron cadence */5* per Vercel Pro). Prior S223 — added background-queue worker route + cancel + status endpoints + */5 cron registration per S221 W1 infra DDL, S222 W2 worker shell, S223 W3 claim_next_job backoff window + reap_stuck_jobs RPC. Prior: S210 A5-administration (29 April 2026) against S195-S209 + kh-prod-readiness-S10/S11/S12/S13.

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.

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 reasonHTTPMeaning
success: trueAuthenticated and authorised; route receives { user, supabase, role }
unauthenticated401No valid session
auth_service_failed500Supabase Auth service error (transient — surfaces so ops alerting fires)
forbidden403Authenticated but wrong role
role_lookup_failed500DB 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 (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 via getAuthorisedClient.
  • Redirects unauthenticated users on non-public, non-API routes to /login.
  • Wraps the proxy body in an AsyncLocalStorage request context so any log lines emitted by the proxy carry requestId.

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 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.

MethodRouteAuthPurposeFile
GET/api/admin/usersAdminList users (reads user_profiles + user_roles)app/api/admin/users/route.ts
PATCH/DELETE/api/admin/users/[userId]AdminUpdate or deactivate a userapp/api/admin/users/[userId]/route.ts
POST/api/admin/users/inviteAdminInvite a new userapp/api/admin/users/invite/route.ts
GET/api/admin/pipeline-runs/recentAdminPipeline health monitoringapp/api/admin/pipeline-runs/recent/route.ts
GET/api/admin/provenance/pipeline-runsAdminPipeline runs (keyset paging)app/api/admin/provenance/pipeline-runs/route.ts
GET/api/admin/provenance/export/verification-historyAdminMonthly verification PDF exportapp/api/admin/provenance/export/verification-history/route.ts
GET/api/admin/tag-morphology/flagsAdmin/EditorList drift flags from corpus regression evalapp/api/admin/tag-morphology/flags/route.ts
POST/api/admin/tag-morphology/flagsAdmin/EditorBulk insert/upsert drift flagsapp/api/admin/tag-morphology/flags/route.ts
PATCH/api/admin/tag-morphology/flags/[id]Admin/EditorTriage individual flag (accept/dismiss)app/api/admin/tag-morphology/flags/[id]/route.ts
POST/api/admin/taxonomy-syncAdminCompare taxonomy hash; dispatch GH workflow on driftapp/api/admin/taxonomy-sync/route.ts
GET/api/admin/taxonomy-sync/statusAdminLatest sync stateapp/api/admin/taxonomy-sync/status/route.ts
POST/api/admin/taxonomy-sync/callbackWorkflow PATGH Actions callback to flip pipeline_runs statusapp/api/admin/taxonomy-sync/callback/route.ts
GET/api/provenance/itemAdminPer-item provenance dataapp/api/provenance/item/route.ts

Cron routes (Vercel-scheduled, Authorization: Bearer <CRON_SECRET>)

Section titled “Cron routes (Vercel-scheduled, Authorization: Bearer <CRON_SECRET>)”
MethodRouteSchedule (UTC)PurposeFile
GET/api/cron/freshness-transitions15 3 * * *Detect freshness state changes; bridge to governanceapp/api/cron/freshness-transitions/route.ts
GET/api/cron/review-cadence45 3 * * *Flag items past next_review_date; notify owners/adminsapp/api/cron/review-cadence/route.ts
GET/api/cron/classification-quality0 4 * * 0Audit low-confidence classifications (weekly)app/api/cron/classification-quality/route.ts
GET/api/cron/coverage-alerts0 5 * * 1Domain coverage threshold checks (weekly)app/api/cron/coverage-alerts/route.ts
GET/api/cron/content-gaps30 5 * * 1Scan template requirement gaps; create notificationsapp/api/cron/content-gaps/route.ts
GET/api/cron/quality-score0 5 * * 0Periodic quality score recalculation (weekly)app/api/cron/quality-score/route.ts
GET/api/cron/intelligence-poll*/15 * * * *Poll Sector Intelligence feedsapp/api/cron/intelligence-poll/route.ts
GET/api/cron/intelligence-cleanup0 3 * * 0Clean 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):

  1. Reap stuck jobslib/queue/visibility-timeout.ts calls the reap_stuck_jobs(p_timeout_seconds) RPC (S223 W3-A migration 20260505153750_*) to flip orphaned status='processing' rows whose started_at < NOW() - 5 min back to pending, incrementing attempts atomically server-side.
  2. Claim next jobclaim_next_job() RPC selects the oldest pending row with updated_at <= NOW() (S223 W3-A backoff window — gates re-claim on lib/queue/failure.ts’s requeue updated_at = NOW() + <backoff> write), FOR UPDATE SKIP LOCKED for concurrency safety. Per ID-372 {372.2} (26/07/2026, migration 20260726231847_*) the cron passes p_exclude_job_types = WORKER_JOB_TYPES so 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}).
  3. Re-validate auth contextlib/queue/auth.ts:reValidateAuthContext reads the enqueueing user’s current role from user_roles and faileds the job if the role is below the requiredRole recorded in the envelope (e.g. user demoted from editor to viewer between enqueue and claim).
  4. Dispatchlib/queue/dispatch.ts:runJobByType switches on job.job_type. Post-S224 W4 the FIRST migration candidate is registered: case 'form_draft_all' (renamed from bid_draft_all in the bid→forms rename) validates the envelope via queueJobPayloadSchema.safeParse, calls reValidateAuthContext(... 'editor') per spec §4.2 + D-1, dispatches runBidDraftAllJob (export name unchanged) from lib/queue/handlers/procurement-draft-all.ts, then finalises the caller-allocated pipeline_runs row via DIRECT UPDATE (NOT recordPipelineRun() — INSERT-only helper would create a 2nd row; drift documented inline; per feedback_record_pipeline_run_signature items_created: string[] is the array of form_responses.id UUIDs 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 default PermanentJobError('no_handler_registered: …') until each §5.4.x candidate registers its own case. Throws PermanentJobError for unrecoverable failures (envelope schema mismatch, unknown user, quality-gate refusal); other errors classified as transient by isTransientError in lib/queue/failure.ts.
  5. 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:

ConsumerHandlesFails everything else with
app/api/cron/process-queue/route.tsform_draft_all, batch_reclassifyno_handler_registered (permanent)
scripts/bid_worker.pytemplate_fill, analyse_formUnknown 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.py passes p_job_types = WORKER_JOB_TYPES (claim ONLY what it processes).
  • The process-queue route passes p_exclude_job_types = WORKER_JOB_TYPES — deliberately an EXCLUDE list, not an include list: the route’s PermanentJobError default 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:

MethodRouteAuthPurposeFile
GET/api/jobs/[id]/statusAdmin/EditorSingle-row poll endpoint for UI in-progress widgetsapp/api/jobs/[id]/status/route.ts
PATCH/api/jobs/[id]/cancelAdmin/EditorCancel a pending job (409 if already processing); race-safe filterapp/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)”
MethodRouteAuthPurposeFile
GET/api/taxonomy/domainsAuthList domainsapp/api/taxonomy/domains/route.ts
POST/api/taxonomy/domainsAdminCreate domainapp/api/taxonomy/domains/route.ts
PATCH/api/taxonomy/domains/[id]AdminUpdate domainapp/api/taxonomy/domains/[id]/route.ts
DELETE/api/taxonomy/domains/[id]AdminDelete domainapp/api/taxonomy/domains/[id]/route.ts
GET/api/taxonomy/subtopicsAuthList subtopicsapp/api/taxonomy/subtopics/route.ts
POST/api/taxonomy/subtopicsAdminCreate subtopicapp/api/taxonomy/subtopics/route.ts
PATCH/api/taxonomy/subtopics/[id]AdminUpdate subtopicapp/api/taxonomy/subtopics/[id]/route.ts
DELETE/api/taxonomy/subtopics/[id]AdminDelete subtopicapp/api/taxonomy/subtopics/[id]/route.ts
GET/api/tagsAuthList tagsapp/api/tags/route.ts
PATCH/api/tags/[id]AdminUpdate tagapp/api/tags/[id]/route.ts
DELETE/api/tags/[id]AdminDelete tagapp/api/tags/[id]/route.ts
GET/api/layersAuthList layersapp/api/layers/route.ts
POST/api/layersAdminCreate layerapp/api/layers/route.ts
PATCH/api/layers/[id]AdminUpdate layerapp/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.

MethodRouteAuthPurposeFile
GET/api/governanceAdminGet governance configurationapp/api/governance/route.ts
POST/api/governanceAdminUpdate governance configurationapp/api/governance/route.ts
POST/api/governance/reviewAdminApprove / request changes / revertapp/api/governance/review/route.ts
GET/api/notificationsAuthList user notificationsapp/api/notifications/route.ts
POST/api/notifications/readAuthMark notifications as readapp/api/notifications/read/route.ts
GET/api/notifications/preferencesAuthFetch user notification prefsapp/api/notifications/preferences/route.ts
PUT/api/notifications/preferencesAuthUpsert user notification prefsapp/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).

PageRouteAuthFile
Settings/settingsAll rolesapp/settings/page.tsx
Provenance/provenanceAdminapp/provenance/page.tsx
Quality Review/reviewAdmin/Editorapp/review/page.tsx

SettingsContent lazy-loads admin-only sections via React.lazy so they stay out of the viewer-bundle.

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.

SectionGroupVisible toComponent
ProfilePersonalAllcomponents/settings/profile-section.tsx
OrganisationPersonalAdmin/Editorcomponents/settings/organisation-section.tsx
ConnectionsPersonalAllcomponents/settings/connections-section.tsx
Content OrganisationContent MgmtAdmincomponents/settings/content-organisation-section.tsx
Content OwnersContent MgmtAdmincomponents/settings/content-owner-management.tsx
Organisations & PeopleContent MgmtAdmincomponents/settings/entities-section.tsx
GuidesContent MgmtAdmincomponents/settings/guides-section.tsx
Tag MorphologyContent MgmtAdmin/Editorcomponents/settings/tag-morphology-section.tsx
TeamSystemAdmincomponents/settings/team-section.tsx
Quality Review (Governance)SystemAdmincomponents/settings/governance-section.tsx
Reviewer AssignmentsSystemAdmincomponents/review/assignment-manager.tsx
ProvenanceSystemAdmin (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 is admin-only with five tabs in components/provenance/:

TabComponentStatusPurpose
per-itemper-item-tab.tsxLiveUUID lookup → classification, processing, cost
pipeline-healthpipeline-health-tab.tsxLiveServer-side rollup, failure drawer, time-range
auditaudit-tab.tsxLiveActivity feed (lifted from old Settings Activity)
costcost-tab-stub.tsxPlaceholderAggregate cost queries (planned)
disputesdisputes-tab-stub.tsxPlaceholderClassification 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).

TablePurposeKey columnsRLS
user_rolesRole assignment (one row per user)user_id, role (admin/editor/viewer), display_nameSelf SELECT; Admin UPDATE
user_profiles (S8 WP-G3.4)Mirror of auth.users for app readsid (FK auth.users), email, full_name, created_at, updated_atSelf SELECT; Admin/Editor SELECT all; INSERT/UPDATE/DELETE revoked
user_notification_prefsEmail notification togglesuser_id PK, three booleans + auto_generate_change_reportsSelf SELECT/UPDATE
notificationsIn-app notificationsuser_id, type, entity_type, entity_id, title, message, expires_atApp: SELECT own row
taxonomy_domainsDomain configsname, description, key_signal, display_order, is_activeAll SELECT; Admin write
taxonomy_subtopicsSubtopic configsname, domain_id, description, display_order, is_activeAll SELECT; Admin write
taxonomy_sync_stateSingleton row tracking last sync hashlast_sync_hash, last_synced_atAdmin SELECT/UPDATE
tag_morphology_drift_flagsCorpus regression queue (S195)stored_tag, proposed_canonical, usage_count, decision, decided_byAdmin/Editor write
layer_vocabularyDynamic layer definitionskey, label, description, display_order, is_activeAll SELECT; Admin write
pipeline_runsCron + ingest run logpipeline_name, status, started_at, duration_ms, result, items_createdAdmin read
governance_configDomain-level governance rulesdomain, auto_flag_on_freshness_transition, reviewer_idAdmin write
classification_disputesHITL classification override logcontent_item_id, original_domain, corrected_domain, reasonAdmin write
review_assignmentsScheduled review assignmentsassignee, content_item_id, due_date, priorityAdmin write

user_profiles is populated by an AFTER INSERT trigger (on_auth_user_createdhandle_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_updatedhandle_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.

FunctionPurposeNotes
get_user_role()Resolve calling user’s role for RLS predicatesSECURITY DEFINER, STABLE
get_user_display_names(uuid[])Batch resolve UUIDs to display namesReads user_profiles mirror (no auth.users dependency); PIPELINE_SYSTEM_USER_ID returns 'Pipeline (system)'
count_auth_users() (S205)Service-role parity probe vs user_profilesSECURITY DEFINER; admin-only EXECUTE
list_public_tables()Auto-inventory for db-row-count-diff.tsExcludes 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.

ModuleFilePurpose
getAuthorisedClientlib/auth/client.tsDiscriminated-union auth helper for API routes (admin/editor/viewer)
authFailureResponselib/auth/client.tsHelper that converts a failed AuthorisedResult to the correct HTTP status
verifyCronAuth, getUsersByRolelib/cron-auth.tsBearer CRON_SECRET verification + role-recipient resolution for cron handlers
recordPipelineRun()lib/pipeline/record-run.tsCanonical insert into pipeline_runs; uses sb(), never throws, used by every cron handler
sb(), tryQuery, SupabaseErrorlib/supabase/safe.tsFail-fast / Result-returning Supabase wrappers (silent-failure prevention)
warningsEnvelopelib/supabase/warnings.tsComposite-response wrapper for partial-failure semantics
logBestEffortWarnlib/supabase/telemetry.tsBest-effort swallow with structured warn log
clientEnv, serverEnvlib/env-client.ts, lib/env.tsZod-parsed env at module load; literal process.env.NEXT_PUBLIC_* access for Next.js substitution
BRANDING, loadBrandinglib/client-config.tsPer-client build-time branding (logo, colour, metadata) via NEXT_PUBLIC_CLIENT_ID
runWithRequestContextlib/logger/request-context.tsAsyncLocalStorage scope for the per-request requestId
SettingLocationPurpose
.env.localRepo 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.localDefault points at staging branch turayklvaunphgbgscat
SUPABASE_SERVICE_ROLE_KEY.env.localRenamed S201 WP-FU.1 (was SUPABASE_SERVICE_KEY); ~115 files touched
SUPABASE_PUBLISHABLE_KEY, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.env.localReplaced anon-key naming during April 2026 Vercel env rotation
CRON_SECRET.env.local + VercelAuthorization: Bearer ... header on every cron route
NEXT_PUBLIC_CLIENT_ID.env.local + VercelSelects lib/branding/clients/{id}.json (defaults to knowledge-hub)
vercel.json crons[]Repo rootSchedule + path map for Vercel cron jobs
GitHub EnvironmentsProduction + StagingCase-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.

ScriptPurposeSpec / runbook
scripts/seed-e2e-users.tsIdempotent E2E user provisioning via auth.admin.createUser() (--check / --dry-run modes); verifyPipelineUserShape() pre-flight detects S156-class corruptiondocs/operations/database-rebuild-runbook.md
scripts/verify-user-profiles-parity.tsCompare user_profiles vs auth.users count via count_auth_users() RPC; --env=prod|staging|autodocs/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 baselinedocs/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-diffdocs/runbooks/staging-refresh.md
scripts/migration-replay-check.tsEphemeral preview branch + supabase db push --linked to catch squash-divergence at PR timeWP-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 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.

WorkflowFileTriggersEnvironment scopePurpose
Quality gates.github/workflows/ci.yml qualityPR + push to mainProductioninstall → 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-smokeSame as aboveStaging~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-evalSame as above; matrix [l1, l3, l4]StagingLayer 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.ymlPR + push to main/production-readiness when supabase/{migrations,seed.sql,config.toml} changeProductionEphemeral 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.ymlnightly 02:00 UTC + push to main on supabase/migrations/**/related; workflow_dispatchProductionDiffs Management API advisor findings vs committed baseline; PR-blocking on new entries
Taxonomy sync.github/workflows/taxonomy-sync.ymlrepository_dispatch from admin Settings UIProductionRegenerates 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.

.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 separate
  • pip — requirements.txt for the Python pipeline; minor/patch grouped
  • github-actions — workflow uses: 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:

  1. Calls verifyCronAuth(request) → 401 on header mismatch.
  2. Uses createServiceClient() (RLS-bypass) for cross-user reads.
  3. Wraps DB calls in sb() (fail-fast) or tryQuery() (Result-returning) — silent-failure prevention enforced via ESLint local/no-unchecked-supabase-error.
  4. Records the run via recordPipelineRun({ supabase, pipelineName, status, itemsProcessed, errorMessage?, result? }) from lib/pipeline/record-run.ts. result is JSONB; items_created is uuid[]. status is one of running/completed/completed_with_errors/failed.
  5. On batch >threshold, switches to summary notifications per recipient (e.g. review-cadence switches 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.

  • Sentry release taggingwithSentryConfig sets release.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.ts read DSN directly from process.env, decoupled from the clientEnv Zod gate so env-validation failures cannot silence Sentry itself.
  • Sentry Turbopack sourcemap upload (S10) — silent: !process.env.CI
    • release.create: false, finalize: false to surface upload failures in prod build logs.
  • Sentry slug — single project knowledge-hub-phew-design (org tw-group-s3, region DE).
  • Structured logging Phase 1 (S9 W4) — Pino root logger at lib/logger/index.ts with AsyncLocalStorage request-context mixin, Sentry bridge forwarding warn/error/fatal, PII-redacting serialisers (REDACT_PATHS). Proxy mints crypto.randomUUID() per request and propagates via x-request-id headers. lib/error.ts is intentionally client-safe (no logger import). Phase 2-6 (route migrations, console-to-logger sweep, Axiom destination, Python correlation) carry forward per docs/specs/structured-logging-spec.md v1.1.

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.

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:

  1. loadBranding() resolves the client ID from the env var.
  2. The matching lib/branding/clients/{id}.json is parsed against BrandingConfigSchema (Zod). Schema violations fail the build.
  3. 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 brandPrimaryForeground if auto-derive cannot meet the threshold.
  4. buildBrandStyleProps() generates a <style> element injected in app/layout.tsx overriding --primary, --primary-foreground, and --ring for both :root and .dark scopes.

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.

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.”

  1. Create lib/branding/clients/{id}.json following BrandingConfigSchema.
  2. Add logo + favicon assets to public/clients/{id}/.
  3. Register a static import + map entry in lib/client-config.ts (CLIENT_BRANDING_MAP).
  4. Set NEXT_PUBLIC_CLIENT_ID={id} in Vercel environment variables.
  5. Deploy. Schema validation, asset-path checks, and WCAG contrast validation block any failure.
SurfaceWhat is brandedConsuming file(s)
Site headerLogo image + product name (screen reader)components/shell/site-header.tsx, components/shell/brand-logo.tsx
Login pageLogo, product name, taglineapp/login/page.tsx, app/login/error.tsx
HomepageProduct name headingapp/page.tsx
OAuth consentProduct nameapp/oauth/consent/page.tsx, app/oauth/consent/error.tsx
Browser tabTitle, favicon (SVG + PNG)app/layout.tsx
Settings pagesProduct name in connection/developer copycomponents/settings/connections-section.tsx, components/settings/connected-apps-section.tsx
Command paletteSearch placeholder textcomponents/shell/command-palette.tsx
DashboardEmpty-state product referencecomponents/dashboard/reorient-section.tsx
Batch importPage headingapp/item/new/batch/page.tsx
Guide pagesTitle suffixapp/guide/[slug]/page.tsx
Change report exportEmail subject linecomponents/change-reports/change-report-export-menu.tsx
RSS feedGenerator taglib/intelligence/rss-generator.ts
CSS primary colour--primary, --primary-foreground, --ring (light + dark)app/layout.tsx via buildBrandStyleProps()

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.

ScopePathNotes
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.tsNotification dedup, preferences upsert
Cron handlers__tests__/api/cron/*.test.tsfreshness-transitions, review-cadence, quality-score, intelligence-poll, coverage-alerts targets
Taxonomy sync__tests__/api/admin/taxonomy-sync*.test.tsHash 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.tsKeyset paging, PDF export
Settings hooks__tests__/hooks/use-layer-admin.test.ts, __tests__/hooks/use-taxonomy-admin.test.ts, __tests__/hooks/use-notifications.test.tsTanStack Query keys
Admin users + S156 regression__tests__/integration/admin-users.integration.test.tsReal-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.tsChunk-grep guard against process.env.NEXT_PUBLIC_* substitution-defeat (S7 P0 regression)
Doc freshness__tests__/lib/doc-freshness.test.tsTracked-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.

  • Role creation is handled by a database trigger linking auth.users to user_roles (via handle_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, and migration-replay workflows currently sit with continue-on-error: true until 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 cost and disputes Provenance 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_at for the admin user list still depends on a single auth.admin.listUsers() call, soft-failing to NULL on GoTrue regression (S156-class) — degraded gracefully but the underlying upstream bug (supabase/auth#1940) remains unfixed.
DecisionRationaleAlternative considered
Build-time per-client branding (S158 + S163)Single Vercel project per client, simple isolation, no multi-tenant RLSRuntime Supabase-driven branding (deferred — adds DB read on every render)
getAuthorisedClient discriminated unionForces handlers to disambiguate the four failure modes (401/403/500/500) and routes ops alerting on real auth failuresSingle boolean return — silently downgrades real DB failures to “viewer”
recordPipelineRun() helper everywhereSingle insert path makes it easier to add Sentry forwarding, structured logging, retries; uses sb() so silent failures are impossibleRaw 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 readsDirect 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 KBPre-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 themSingle 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 timeManual 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 minutesManual dashboard scan — proven unreliable cadence
tag_morphology_drift_flags queue + admin/editor writeCorpus regression eval populates queue; humans triage individual flags; preserves single-source-of-truth taxonomyAuto-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 + MCPSurface-specific renewal logic (rejected — inevitable drift)