compass
The AI transformation program lived in one spreadsheet, two dead apps, and a Jira project nobody owned. Compass is the program as one system — with Jira as the truth.
team one internal tool, deployed in production on the agency's internal aws/eks platform. pillar taxonomy, people, and internal urls withheld; jira project referred to generically.
The AI transformation program — six strategic pillars, ~108 roadmap items, ~266 workflow automations, a tool inventory, a council — lived in one tracker spreadsheet maintained by one person (me). Around it sat two apps that were each half the answer: a v1 hub with a real data model and no public face, and a public tool directory with a lovely shell, hardcoded data, and forms that submitted nowhere. A Google Form for AI help requests landed in a sheet a human read. None of it was in use; none of it was being developed.
Meanwhile the actual backlog had quietly moved into a Jira project — 439 issues, all re-pillared in a June bulk edit to the parent company's new taxonomy, which meant v1's seeded pillars were already wrong. A census on day one: 91% of the issues had no owner, 78% sat in New, and 92% had last moved in that single bulk edit. An imported list, not a living backlog. Two of six pillar triage owners didn't have a Jira account at all — 27% of the issues were routed to someone who couldn't receive a ticket.
The plan was a merge: port the directory's shell onto the hub's backend, sync it to Jira both ways, seed from the spreadsheet. The decisive finding, verified against the API on day one, was that Jira already held the whole schema as custom fields: roadmap flag = Yes on 108 issues, automation flag = Yes on 266, neither on 65 — 108 + 266 + 65 = 439, reconciling exactly. Scoring fields, feasibility, ease, idea source, pillar — all there. Impact, which I had budgeted an AI backfill for, turned out to be populated on every scored issue and validated 265 for 265 against the quadrant it implied.
So the hub's roadmap and automation tables weren't something to migrate. They were a duplicate of Jira, to be deleted. Bi-directional sync — v1's largest unbuilt subsystem — was cut entirely. Compass became Jira-primary with a read-only cached mirror and exactly two writes: create a ticket on intake, comment on triage.
The second reframe was editorial. The ownership gap wasn't a data-quality note to clean before launch — it was the product. Ship “91% of the backlog has no owner” as the home-page headline, leave the New items deliberately unassigned so the number stays real, and make bulk-assign a first-class admin surface with guardrails instead of a one-time script.
Fifteen decisions locked before code. The product brief closed them on day one so the build didn't reopen them: Jira-primary; one new org-owned repo porting both donors; the LLM classifies the pillar and the ticket goes to that pillar's triage owner, never the eventual doer (“smart routing, not smart assignment”); public by default, sign-in only to write; magic-link identity over the email service the sibling apps already used; workshop requests are capture-and-queue, humans run the sessions; a human lifecycle (To Do → Defining Requirements → In Development → Beta Testing → In Use → Complete) mapped from Jira, with a real In Use status added to the workflow so display and storage are one-to-one; no forcing date. Both donors are organs, not systems to migrate — no cutover, no migration risk.
Three intake forms were three intents wearing one label. Build/automate, help/train, share a win, access/approval — merged into one 28-field monster they would have been ignored. One entry, an intent switch, short branching forms; only the workshop path carries the long tail.
The intake pipeline's order is the design. Persist first — the row exists before anything else runs, so a request can never be lost to a downstream failure (the form was split into a fast half and a slow half after a silent multi-second submit caused double-clicks and duplicate tickets). Then dedupe: trigram similarity on the mirror's summaries or word-boundary matches on up to five distinctive tokens — substring matching was rejected because “build” matched “Building Blocks”, and trigrams alone missed acronyms. A near-duplicate offers “support this instead”; an upvote closes the request with no ticket. Then classify the pillar with a fast model in JSON mode — and below 0.6 confidence, leave it unclassified on purpose, because a wrong routing costs more than an unrouted request. Then resolve an assignee down a fallback chain (pillar triage owner → intent-routed escalation → a holding account) so no ticket is ever created unowned. Then file, add watchers, notify. Every step after persist is recoverable from the council queue.
The “ask” posture: the model never sees the database. Conversational search over the backlog is retrieval first — one small call turns the question into keyword queries and source choices, retrieval runs against the mirror with a public-fields-only projection (the council-only scoring label physically cannot reach the model), and a second call answers from the rows handed over. Citations are filtered to keys actually retrieved; anything the model names that wasn't retrieved is dropped. The council planner (“I have an hour — what should I do?”) is deliberately non-LLM: it ranks real rows by decay and unblocking value, because a plan that invented a task would be indistinguishable from a good one until someone clicked through.
Two auth lessons paid for in production. The corporate mail tenant silently dropped HTML email containing a link from a new subdomain — measured across six controlled variants (HTML alone fine, URL alone fine, HTML plus link blocked) — so every production email is plain text with bare URLs. And the tenant's link scanner pre-fetched every magic link and spent the single-use token before the recipient clicked — so the landing page became read-only, consumption moved to an explicit POST, and the TTL doubled. Both are asserted in tests now.
One deployable, not SPA + API + proxy: a Next.js App Router app with server components on the anonymous read path, plus two cron workloads from the same image — a 15-minute sync that pages the entire Jira project, upserts by key, soft-marks missing rows (with a refusal guard if “missing” suddenly means everything), derives confidentiality and display status, and records a sync run; and an hourly health probe over the internal app directory with hysteresis, because a wrong link that reads green is worse than a flaky one. Read surfaces never call Jira live — an outage degrades freshness, not availability.
Postgres in a dedicated schema (so v1's tables could coexist during the switch), twelve tables, pg_trgm for dedupe and search. Roles come from a roster table, not the token — anonymous → employee → triage/council → admin — and a break-glass key that fails closed when unset and logs every use. The Jira binding is hard-coded and tested, not configured: a shared env file once pointed the first sync at a different company's Jira instance, and only a downstream 404 stopped it from soft-marking the whole mirror as missing.
The hub grew into the front end of a small ecosystem: the council's telemetry tab reads a fleet-wide usage service (see beacon) through a read-only key; a “share a win” submission is a case-study draft by definition, so after the ticket it's forwarded to the case-study intake queue; and the deploy runbook — fifteen documented platform traps, from a cron-schedule syntax that fails only after the image builds to a catch → [] that hid a broken dedupe query for weeks — became the reference for the sibling migrations.
Planning docs on day one — product brief with the fifteen decisions, product spec, technical architecture, 169 acceptance criteria with stable IDs, brand plan, seven ADRs — then the data layer, Jira client, sync, and first read surfaces the same day, 41 tests green. Production deploy and a live intake form on day four. Eighty-one commits in thirteen calendar days, one author, built the way everything here gets built now: spec → agentic build → validate → ship. By the end: 229 tests across 40 suites, 24 pages, 8 API routes, 12 tables, ~11.7k lines of application code, and 97 of the 169 acceptance criteria verified at the first re-baseline — Phase 4 (coverage: “what am I allowed to use, with whose data”) is the open frontier, and is content work more than code.
Live on the internal platform since early August 2026, healthy, answering in a third of a second. Active-but-unowned work went 25 → 3 → 1 once triage owners were resolved (including the surname typo that had kept one of them unassignable for months). Overall unassigned moved from 91% to 83% — not because the backlog was bulk-assigned, but because it wasn't: the 329 New items stay unowned so the headline stays honest. The directory went from two quick-start guides to fifteen, written from source; a platform estate audit corrected several tools that were wrongly marked safe for client data.
Adoption, honestly: early and real. In the two weeks after launch the intake created at least nine roadmap tickets from at least four different submitters — including a senior executive, whose confidential-request edge case was fixed and redeployed the same day — each auto-assigned to a pillar triage owner, with the duplicate detector surfacing one overlap. The hub was picked as the headline demo for the agency's org-wide AI training series, listed as a system-of-record link in the council's official recap, and a second, unrelated team asked for its own instance after a demo. The Google Form is not yet retired and identified users are still a handful a month. The system is built and working; the behavior change is the next phase — and the headline on the home page is designed to make that phase uncomfortable to ignore.
The LLM is an interface, not the brain. Structured data is the truth; the model classifies and searches, code decides and files — and an honest headline ("91% of this has no owner") is worth more than a clean one.
the insight