Skip to main content

Admin Console — Build Log

This page is a milestone-by-milestone record of how the admin console was actually built: what shipped in each milestone, real bugs found and fixed along the way, and the conventions that emerged and were then applied consistently across later work. It is written for an engineer picking up this codebase who needs to understand not just what exists but why it looks the way it does.

The admin console's milestones are labeled A0 through A4, a separate numbering track from the public site's M0/M1/M2 milestones (see the project overview docs), because the two apps share one backend but ship on independent timelines. The milestones were not built in numeric order — the actual build sequence was A0 → A1 → A3 → A2 → A4. That sequence is preserved below since later milestones sometimes reference decisions made in an "earlier-numbered but later-built" milestone.

Recurring conventions

A handful of patterns recur across every milestone. They're introduced here once so the per-milestone sections below can reference them by name instead of re-explaining them.

Honest placeholder, never fabricate operational data. Wherever a metric or feature has no real backing data source yet, the admin console shows an explicit placeholder (a literal placeholder: true flag from the API, an em dash in the UI, or a labeled "not yet implemented" state) rather than a plausible-looking fake number. This rule exists because a real operator could act on a wrong number — a fabricated escalation count or a fake "CONNECTED" badge is worse than an honest blank. Every screen in the finished console follows this rule; none fabricates operational data.

Audit logging on every mutation. Every state-changing admin action — status changes, flag resolutions, config edits, admin invites, broadcast sends — is written through a shared AuditService into an append-only audit_log table. Later milestones (System/Ops in A4) read that same table back out to power a real audit-log viewer and to detect "last run" times for jobs, rather than tracking that separately.

Category-taxonomy mismatch. The admin console's real category set is six categories: Infrastructure, Garbage & filth, Traffic, Public transport, Safety, and Corruption. The old reference repository (see below) used a different taxonomy, and several of its seeded reference tables (department categories, in particular) were carried into this project without being remapped. This mismatch surfaced independently three separate times across three milestones (A3's keyword detector, A3's routing crosswalk, and A2's officials-department crosswalk), each requiring its own small CATEGORY_TO_DEPARTMENT_CATEGORY-style mapping table rather than a single shared one. If a future milestone touches departments or categories again, it's worth extracting a single shared crosswalk constant instead of adding a fourth copy.

RBAC role model. Admin identities live in their own realm, entirely separate from the public site's citizen (Lucia) auth. There are four roles: super_admin, city_admin (scoped to one city), reviewer (moderation only), and auditor (read-only). Roles are enforced server-side via a @Roles() decorator and RolesGuard, not by trusting client-side checks.

Old reference repo. Several milestones refer to "the old repo" — a prior, discarded implementation (Express + DynamoDB) kept around purely as a scope checklist, never as an implementation to port. It's worth being explicit about what was and wasn't real in it, since this tripped up early planning: it contained two admin surfaces, of which only one (a standalone /admin single-page app with its own JWT and DynamoDB admins table) was real. The admin panel embedded inside the old citizen app was broken prototype code — a client-trust-only role check, mock moderation with no real fetch() calls, and a localStorage key nothing else in that repo ever set. A third layer, routes/index.js, contained working RBAC-gated moderation endpoints that no frontend ever called — useful precisely because, never having been wired to a UI, it never went stale, making it the best reference for issue-moderation endpoint shapes. None of the old repo's persistence (DynamoDB PK/SK, flat-JSON-file storage, Express JWT) was ported; it served as a feature checklist only.

A0 — Foundations (auth, RBAC, audit, shell)

A0 established the admin identity system, the session and audit infrastructure, and the shell application that every later milestone builds inside.

Schema (packages/db/src/schema/admin.ts): admins (a separate identity realm from any citizen table, with a role enum of super_admin / city_admin / reviewer / auditor, a nullable cityScope for city-scoped roles, and TOTP fields that are reserved in the schema but not yet wired to a real verification flow — deliberately deferred, not silently dropped), admin_sessions, and audit_log.

Auth (apps/api/src/admin-auth/): hand-rolled sessions following the Lucia pattern rather than the lucia npm package — a random 32-byte token is stored in an httpOnly cookie, only its SHA-256 hash is persisted server-side, and sessions use a 12-hour sliding expiry. Passwords are hashed with argon2, and login is timing-safe (an unknown email still runs a dummy hash verification, so response timing doesn't leak whether an account exists). AdminAuthGuard and @Roles()/RolesGuard enforce authentication and RBAC on every admin route. AuditService is the shared logger used by every subsequent mutation. A packages/db script, bootstrap-admin (run via pnpm db:bootstrap-admin), idempotently creates the first super-admin account from SUPERADMIN_EMAIL / SUPERADMIN_PASSWORD / SUPERADMIN_NAME environment variables.

Dashboard (apps/api/src/admin-dashboard/): GET /api/admin/dashboard returns real numbers derived from the issues table wherever they can actually be computed — open count, overdue count, resolution rate, reports today/this week, and a 14-day series — and an explicit placeholder: true flag (rendered as an em dash) for metrics with no backing subsystem yet, such as armed escalations, average first response, intake backlog, and applications/flags counts. This endpoint is the first application of the honest-placeholder convention described above, and every later milestone follows the same pattern.

Frontend shell (apps/admin/): a new Vite + React + TypeScript + CSS Modules app on port 5175, mirroring apps/web's conventions (path aliases, ESLint/Prettier, the cssVars() bridge for dynamic inline values, no raw inline styles). The theme is dark-first via data-cp-theme CSS custom properties, with a light theme also implemented. The shell consists of a TopBar (logo, city switcher, search, theme toggle, notifications, profile), a Sidebar with 5 groups and 12 nav items — of which only Dashboard was wired at this point, the rest showing an honest "lands in a later milestone" toast rather than a dead link or a fabricated screen — and an AdminLayout composing them. A login page (no direct design-prototype reference existed for it) and a RequireAuth route guard round out the shell.

CORS fix: the public API's blanket cors: true setting doesn't work with credentialed cookie requests. main.ts was updated to an explicit origin allow-list (localhost:5173 for the public web app, localhost:5175 for admin) plus credentials: true.

Verification gotcha: toasts with a short auto-dismiss (roughly 2.6s) are unreliable to verify across separate browser-automation tool calls, because the round-trip latency between a click call and a following screenshot call isn't bounded — it can be slow enough that the toast has already vanished, or fast enough (inside a single synchronous eval) that React 18's async state batching hasn't flushed yet. The reliable check is a single evaluation call that clicks and then awaits a short (~100–150ms) timeout before asserting DOM state, rather than concluding a toast or async-UI bug from a plain click-then-screenshot sequence.

Explicitly out of scope for A0 (tracked for later): the 2FA verification flow, every sidebar section beyond Dashboard, and real content behind the notifications bell (which showed an honest empty state).

A1 — Feed Management & Review

A1 built issue moderation, flag handling, and duplicate-cluster review on top of the read-only issue data that already existed from the public site's M1 milestone.

Schema (packages/db/src/schema/admin-feed.ts): issue_admin_state, a mutable overlay table keyed on issueId (holding forced, slaPaused, closed, hidden, pinned, chainRoleOverride, redactedMedia), created lazily on the first mutation so the append-only issues and issue_status_events tables stay untouched in spirit. flags (target type issue / comment / media / reporter). duplicate_clusters. Dev-demo flags were seeded against real M1 issue IDs (CP-4809, CP-4788) rather than invented ones — a rule worth repeating for any future admin-queue seed data: never reference an issue ID that doesn't exist in the seeded issues table. duplicate_clusters was deliberately left unseeded: the 8 real seeded issues don't naturally share ward and category, so seeding a "duplicate" pairing would itself be a fabricated relationship (unlike a single flag record, which only claims one thing happened). Its empty state is correct, not a gap.

Backend (apps/api/src/admin-feed/): IssueModerationService provides list, drawer-detail, and status-change operations. Status changes ran through a hardcoded transition matrix at this point — that matrix became a real Configuration-driven table in A2 (status_transitions), without moving the enforcement point, only its data source. IssueModerationService also implements force-escalate, pause-SLA, reassign, toggle-closed/hidden/pinned, and redact-media, with every mutation routed through AuditService. FlagsService implements dismiss/warn/remove/ban actions. DuplicateClustersService implements merge-to-canonical. admin-issue-logic.ts layers a manual chain-role override on top of the shared, pure detailOf() function from domain/issue-logic.ts via a new adminDetailOf() wrapper, without modifying the shared function itself — both the public site and the admin console depend on detailOf() staying pure.

Bug found and fixed — wrong chain length. The real domain responsibility chain (ROLE_DEFS in domain/constants.ts) is a fixed 5-step chain: Local Police → Corporator → BMC Ward Officer → MLA → MP. The Claude Design mockup had assumed a 4-step chain that omitted the police step. Any admin code that hardcodes chain-role names must derive them from ROLE_DEFS directly rather than redeclaring the list — this had already caused a bug in the reassign action's role-cycling logic before it was caught.

Bug found and fixed — SLA sign vs. magnitude. SLA countdown and overdue labels must take their sign from the issue's stored status field (the seed data's authoritative narrative — there is no pg_cron SLA-sweep job to keep status in lockstep with real elapsed time) and only their magnitude from real elapsed hours, clamped to stay non-negative. Computing the label purely from createdAt + 48h versus "now" produced a self-contradictory result on a real seeded issue: an "Overdue" pill next to an "SLA in 37h" countdown. This status-is-authoritative / clock-is-magnitude-only pattern is expected to recur anywhere else a demo-seeded status meets a real timestamp.

Frontend: IssueModerationTab and IssueDrawer (with chain/activity/media tabs, reusing the exact chain-step colors and notes the API computes rather than duplicating that color logic client-side), FlagsTab, and DuplicateClustersTab (which correctly shows an honest empty state rather than fabricated clusters). Two new shared UI atoms were introduced here and reused by every later admin screen: Chip (fully-rounded filter pills) and SubTabs (8px-radius section tabs).

Intake triage and Comments & media sub-tabs were left as honest "lands in a later milestone" placeholders rather than built: intake triage has no real data source until Integrations (A3) or citizen self-reporting (a public-site M2 feature) exists, and comments/media moderation has no comment-thread schema yet — a deliberate, tracked open decision rather than a silent omission.

Verification workflow note: when live-testing mutations against real seeded rows (as opposed to disposable test fixtures), issue_admin_state, flags, and audit_log were reset back to a clean state afterward via direct SQL, so that the seeded demo state doesn't drift from what packages/db/src/seed.ts actually produces on a fresh pnpm db:reset && pnpm db:seed.

A3 — Integrations

A3 built the channel-integration layer (X, Instagram, WhatsApp), the first real write path into the issues table, and message routing. It was built before A2 in the actual sequence.

Schema (packages/db/src/schema/channels.ts): channel_configs (one row per channel; single-tenant). Its errorCount and requestCount columns are real counters, not a fabricated static error rate like the design mockup's hardcoded percentages (1.2% / 0.4% / 4.1% / 0.1%) — connection health is computed from these real counters and starts at 0. channel_mentions (deduped per channel by externalId). message_templates. delivery_logs (deliberately unseeded and empty, since no real WhatsApp send capability exists yet — an honest empty state). outbound_config (singleton). routing_keywords.

Keyword detection (apps/api/src/admin-integrations/text-detection.ts): a verbatim port of the old repo's textDetection.js (pure functions for keyword-based category/city detection, relativeTime, and maskPhone), with its keyword map remapped to this project's real six categories (Infrastructure, Garbage & filth, Safety, Traffic, Public transport, Corruption). The old repo's taxonomy (road / traffic / infrastructure / hygiene / healthcare / systemic) doesn't line up one-to-one — there is no "healthcare" category in this project, and items like streetlights and manholes fall under Safety here rather than Infrastructure.

Channel adapters: three ChannelAdapter implementations (XAdapter, InstagramAdapter, WhatsAppAdapter) share one interface. X and Instagram are poll-based — the X API v2 search/recent call and the Graph API mentioned_media fetch logic were ported and are functional, but untested against real credentials since none are configured, so both currently always run in demo mode. WhatsApp is architecturally different: a real inbound webhook (whatsapp-webhook.controller.ts, mounted outside the admin auth guard since Meta calls it directly) implements the GET verify handshake and a POST inbound handler with real HMAC-SHA256 signature verification against the raw request body. This required disabling Nest's default body parser in main.ts and installing express.json({ verify }) to capture req.rawBody. This fixes a real bug present in the old repo's implementation, which had verified signatures against a re-serialized JSON string rather than the original raw bytes — re-serialization is not guaranteed to byte-for-byte match what was actually sent, which would make signature verification unreliable. Verified live: a bad signature is silently dropped, a correct signature is accepted, and both cases are acknowledged with an immediate 200 per Meta's sub-5-second requirement.

First write path into issues: ChannelMentionsService.convertToIssue() is the first code in the project that writes into issues / issue_status_events — the public site's M1 milestone was read-only. New issue IDs allocated through this path use a CP-9### range, distinct from the original 8 demo issues. Ward and pincode are deliberately left null on conversion: a channel mention only carries free-text city detection, not real GPS coordinates, so guessing a ward would be a fabrication. A moderator sets it later by hand.

Routing: RoutingService.categoryRouting() resolves real department names via a new crosswalk, CATEGORY_TO_DEPARTMENT_CATEGORY, bridging this project's six categories to departments.category — which turned out to still carry the old repo's taxonomy from the public site's M0 seeding (all / healthcare / hygiene / infrastructure / road / systemic / traffic). This is the same category-mismatch pattern described above, now appearing in a second table. Safety and Public transport have no clean seeded-department match and fall back to general-administration and helpline respectively; this fallback is flagged inline rather than silently guessed.

RoutingService.cityDetectionRules() deliberately does not replicate the design mockup's illustrative pincode-prefix-range rows (e.g., "4000–4009 → Mumbai", "411 → Pune"). Real seeded pincode data showed Mumbai, Thane, and Navi Mumbai all sharing the same 400-prefix, so a prefix-range rule cannot actually distinguish between them. The endpoint instead shows real per-city pincode counts (more precise, and honestly reflects what's actually backed by data) plus a plainly-labeled "not yet implemented" row for the GPS-fallback case.

Honest connection status: channel connection badges show amber "DEMO MODE" rather than a fake green "CONNECTED" whenever demoMode is true, matching an explicit instruction not to replicate the design mockup's "always green" gap. Platform (native) is the one channel seeded with demoMode: false; a bug where it had instead inherited the schema column's default (true) was caught and fixed before browser verification.

Verification gotcha (second occurrence of this class of bug): dispatching a synthetic KeyboardEvent('keydown', { key: 'Enter' }) via the browser-automation eval tool does not reliably reach React's onKeyDown handler in this environment, even with bubbles: true and legacy keyCode/which set. This was confirmed by isolating the same component tree with a plain-click test (removing a chip via its × button), which worked instantly. The lesson: don't conclude a real bug from a synthetic-Enter-key test that appears to do nothing — verify the underlying handler with a plain click or a direct curl call before assuming the application is broken.

Cleanup: every test mutation made during verification (synced demo mentions, the converted CP-9001 test issue, channel test-connection counters, a keyword add/remove round trip) was reset via direct SQL afterward, so pnpm db:seed still reproduces exactly what is documented.

A2 — Configuration, Geography & Reference, Officials & Accountability, Reporters, Admins & Access

A2 was the largest single milestone, covering five admin sections in one pass. It was built after A3 in the actual sequence.

Schema (packages/db/src/schema/admin-config.ts): feature_flags; sla_config (singleton, replacing A1's hardcoded SLA_HOURS = 48 constant); scoring_config (singleton, storing scoring weights and grade thresholds — persisted, audit-logged, and editable, but explicitly not yet wired into a live grading algorithm; the save confirmation says so directly: "not yet consumed by the live grading algorithm"); taxonomy_items (four lists); status_transitions (composite primary key on fromStatus, toStatus, replacing A1's hardcoded ALLOWED_TRANSITIONS matrix — Configuration → Taxonomy is now the single source of truth for allowed status transitions); city_branding; reporter_applications; public_submissions; score_overrides; chain_overrides. Every mutating Configuration endpoint requires a non-empty audit note, enforced server-side by requireNote() in ConfigurationService, which throws a 400 if the note is missing — a deliberately stricter bar than A0/A1/A3's mutations, since configuration changes are the ones most likely to need a documented "why" months later.

Bug found and fixed — composite-key update missing a column. The first draft of toggleStatusTransition() filtered only by fromStatus, which would have flipped allowed on every transition sharing that fromStatus instead of just the intended (fromStatus, toStatus) pair. Composite-key updates need every key column represented in the where clause, without exception.

Bug found and fixed — raw FK instead of human label. pincodeMap() initially surfaced a raw internal wardId (or a bare area string) instead of a proper ward code; fixed by actually joining the wards table. A general reminder for any new list endpoint: show the foreign key's human-readable label, not the raw FK value.

Category-taxonomy mismatch, third occurrence. officials-admin.service.ts needed its own CATEGORY_TO_DEPARTMENT_CATEGORY crosswalk — the same six real categories mapped to the same old-repo-seeded departments.category values as in A3, but implemented as a separate constant. This is now three separate places carrying essentially the same mapping, because the underlying seed data was never migrated to a single taxonomy. As noted above, a future milestone touching departments or categories again is a good opportunity to extract one shared crosswalk.

The officials directory correctly derives the responsibility chain from the real 5-step ROLE_DEFS rather than a fabricated per-category chain, and treats chainOverrides and scoreOverrides as real-but-not-yet- consumed data, following the same honesty pattern as scoring_config. recomputeScores() is deliberately honest about its own limits: it stamps a timestamp and returns an explanatory note rather than pretending to run a scoring algorithm that doesn't exist yet.

Admins & Access: the invite flow generates a real argon2-hashed temporary password and returns it once, in the API response, for the inviting admin to share out-of-band — there is no email infrastructure in this project. This was verified live end-to-end in the browser (not just via curl): a real email was entered, a role selected, the invite sent, the generated password shown in the resulting toast, the new row confirmed in the list, then cleaned up via SQL.

Encoding gotcha: IDs containing a literal # character (e.g. OFF#009, inherited from the old repo's PK#SK key convention) break raw URLs, since # is a fragment delimiter. Frontend code building URLs from these IDs must call encodeURIComponent(id) — confirmed already handled correctly in OfficialsPage.tsx's DirectoryTab. A raw curl test without %23 encoding will look like a 404 API bug when it's actually a test-methodology artifact.

Verification false alarm — narrow viewport, not a real overlap bug. A screenshot of the Admins & Access account row appeared to show bold name text overlapping the role column. The root cause was the preview tool's own default viewport, not the app: at the panel's narrow default width (roughly 716–800px), the flex row's fixed-width columns (role/cities/2FA-badge/last-active, about 335px combined) left only ~15px for the flexible name/email column, causing visible overflow. Re-testing at an explicit 1280×800 viewport (passing explicit width/height to the resize call, since the preset shorthand silently no-op'd and left the panel at its native narrow size) showed a clean 396px-wide name column with 14px of clear space before the role column starts. The general lesson for admin-console verification: always force an explicit desktop-width resize before judging a layout screenshot — this is a desktop-only operations tool with no responsive breakpoints, so a narrow preview viewport will produce layout artifacts that don't reflect real usage.

Stale build gotcha. Also caught mid-milestone: the API had been running from a stale compiled dist/main build, predating several milestones' routes, rather than the live nest start --watch dev server — every route was returning 404, including routes that had existed since M1. Before trusting a 404 as a real routing bug, confirm which process is actually listening on the API port; ps -p <pid> -o command and checking for a dist/main invocation versus a nest/ts-node watch process is the fast way to tell.

Cleanup: every sub-tab tested during verification was reverted via direct SQL — the SLA window (48 → 60 → 48), the scoring refresh cadence (daily → weekly → daily), branding (a test-script indexing slip had accidentally edited the donation UPI ID instead of the intended landing headline, because the header search <input> shifts all subsequent index-based lookups by one; restored to exact NULL, not just an empty string), and the invited test admin row. Final state was confirmed against documented seed counts: 6 feature flags, 1 sla_config row, 1 scoring_config row, 20 taxonomy_items, 12 status_transitions, 1 city_branding row, 0 reporter_applications, 0 public_submissions, 0 overrides.

A4 — Notifications & Broadcast, Analytics & Reports, System/Ops

A4 was the final milestone, covering three sections in one pass, and the only one built via multi-agent workflow orchestration rather than by a single agent working solo, with an adversarial review pass before integration. That pattern is worth calling out because it worked well: three parallel build agents, one per section, each restricted to its own disjoint set of new files (its own schema file, its own apps/api/src/admin-<section>/ module, its own apps/admin/src/features/<section>/ page), and explicitly forbidden from touching the five shared registration files — the schema barrel index.ts, seed.ts, app.module.ts, App.tsx, and Sidebar.tsx. Each build agent's output was piped into a dedicated review agent for that section. Integration, migration, seeding, route wiring, and all live browser verification were then done directly rather than delegated further, because at this point in the project's history the repository was not git-committed past packages/db's M0 baseline — the entire apps/admin tree, plus every admin-* backend module from A0 through A3, was still untracked. A fresh git worktree for a sub-agent would have silently been missing all of that prior work, so an isolated-worktree approach was correctly avoided for this milestone.

Schema (packages/db/src/schema/admin-notifications.ts): broadcasts (audience, message, scheduledAt, status — where status is type-limited to 'queued' | 'scheduled' only, deliberately never 'sent', since no push-delivery pipeline exists that could legitimately set that value). announcements (a per-city banner toggle). push_templates (three real seeded starter templates, mirroring A3's message_templates shape). push_subscriptions (a real VAPID-shaped table that genuinely stays empty, since the public site has no service worker yet — any subscriber count is honestly 0 today).

Analytics and Ops required no new schema — both sections are entirely read-only, deriving from tables that already existed. Analytics reads from issues, wards, and channel_mentions, and reuses RoleContactService — the same @Global()-provided, deterministic hash-based official scoring already shown on the public site's Administrators page since M1 — to power a new admin leaderboard, which is a reuse of existing scoring rather than new fabrication. Ops's audit-log viewer reads directly from the audit_log and admins tables built in A0.

Scheduled jobs — two real, two honestly not implemented. System/Ops's "Scheduled jobs" tab genuinely triggers 2 of its 4 listed jobs, rather than faking a job scheduler entirely: "Score refresh" and "Webhook health check" have frontend buttons that call the exact pre-existing endpoints from earlier milestones (POST /admin/officials/scores/recompute from A2, POST /admin/integrations/channels/:channel/test from A3) directly — the Ops backend does not re-implement that logic, it only reads audit_log for the most recent matching action string to report a real "last run" time. "SLA sweep" and "Config change digest" have no real implementation anywhere in the codebase (confirmed directly: zero pg_cron jobs are configured, and no job-queue table exists) and are honestly shown with runnable: false and an explanatory note, rather than a fake next-run prediction — deliberately not replicating the design mockup's own fabricated queue depths and failure counts. Verified live: clicking "Run now" on Webhook health check produced 3 real new channel.test_connection audit rows (one each for X, Instagram, WhatsApp) within the same second.

Adversarial review caught a real correctness bug before shipping. The first draft of Analytics' official leaderboard called RoleContactService.roleContact() per (role, ward) pair with no deduplication, skipping a piece of logic that exists in AdministratorsService.list() specifically to prevent this: deduping MLA and MP entries by real constituency code. Because roleContact()'s score is seeded per-ward rather than per-official-identity, two wards sharing one real MLA or MP would have shown that same real person twice under different ward labels with two unrelated, contradictory scores. The fix mirrors the reference implementation exactly — parallelized lookups for Corporator and Ward Officer, and a sequential, constituencyKey()-deduped loop for MLA and MP. The same review pass also removed a hardcoded CHANNEL_META color/name map that duplicated (and could silently drift from) the live, admin-editable channel_configs table; it now reads that table live instead.

Adversarial review also caught an honesty-principle violation in the UI, not just the backend. ScheduledJobsTab's runJob() function fell through to an unconditional "completed" toast and a last-run refresh whenever a job's key failed to match any handled branch — meaning a future drift between the frontend's and backend's job keys would have silently fabricated a success status with zero actual network calls, exactly the class of bug this module is otherwise careful to avoid. This was fixed by adding an explicit else branch that reports "no run handler wired up" instead of falling through silently. The same review pass also found that the backend's carefully-written honest job.note explanations (describing why SLA sweep and the config digest aren't implemented) were being fetched by the frontend but never rendered anywhere in the row UI — fixed by adding a note line under each job's name.

Real bug found during post-workflow browser verification. NotificationsService.announcements() sorted results only by desc(createdAt) with no secondary key, and both seeded announcement rows share a single insert-batch timestamp. Ties on that column have no guaranteed stable order across separate Postgres requests, so the rendered list could silently reorder itself between two fetches — this was hit directly: two sequential toggle clicks on "the first row" each ended up hitting a different underlying row, because the list order flipped between requests. Fixed by adding announcements.id as a secondary sort key. This is worth checking for anywhere else a batch-seeded table is ordered by a timestamp column alone.

A promise kept across milestones. officials.scores.recompute's doc comment in officials-admin.service.ts, written back in A2, had literally forward-referenced this milestone: "Live recompute pipeline lands with the SLA-sweep/score-refresh job (System/Ops)." A4's Ops "Score refresh" job is exactly that promised integration point.

Cleanup: all compose-broadcast test sends (via both curl and the live browser UI) were deleted from broadcasts (back to 0 rows), the announcement-toggle test was reverted (active = true on both seeded rows, with stable order confirmed afterward), and the final state was confirmed to match the seed exactly: 6 feature flags, 3 push_templates, 2 announcements (both active), 0 broadcasts, 0 push_subscriptions.

Where this leaves the admin console

A0 through A4 close out the full admin console build: auth/RBAC/audit shell, feed moderation, integrations, configuration/reference/reporters/RBAC screens, and notifications/analytics/ops. Every registered backend module — admin-auth, admin-dashboard, admin-feed, admin-integrations, admin-config, admin-geo, admin-officials, admin-reporters, admin-access, admin-notifications, admin-analytics, and admin-ops — is wired into apps/api/src/app.module.ts alongside the public-site modules. Every screen in the console is either backed by real, live Postgres data or is an explicit, honestly labeled placeholder; as of this writing, no screen in the admin console fabricates operational numbers.