Topic Axis Self-Operated — 03 UI/API Patch & Deploy Readiness
03 — UI/API Patch & Deploy Readiness (Workstream B)
Status: DEPLOY-READY (operator-gated redeploy). 10 routes (7 prior + 3 new this run).
Nuxt source (web/server/api/...) is outside the MCP read/write allowlist, and the MCP write role lacks FS permission — so this is the canonical handoff: literal handler code below + tests + checklist. No Nuxt-side math; every count/flag/status is computed in PG. Depth from parent_codes arrays; DAG never flattened.
Routes
| Method/Route | File | PG source |
|---|---|---|
| GET /api/axes | axes/index.get.ts | axis_registry |
| GET /api/axes/:axis | axes/[axis_code]/index.get.ts | axis_registry + v_axis_topic_pivots |
| GET /api/axes/:axis/nodes | axes/[axis_code]/nodes.get.ts | v_registries_pivot_axis_surface |
| GET /api/axes/:axis/node/:node | .../node/[node_code]/index.get.ts | one surface row |
| GET /api/axes/:axis/node/:node/substrate | .../substrate.get.ts | fn_topic_node_substrate($node) |
| GET /api/axes/:axis/pivots | axes/[axis_code]/pivots.get.ts | v_axis_topic_pivots |
| GET /api/axes/:axis/decision-queue | .../decision-queue.get.ts | v_axis_topic_decision_queue |
| GET /api/axes/:axis/approval-queue (NEW) | .../approval-queue.get.ts | approval_requests WHERE code LIKE 'AXR-TOPIC-%' |
| GET /api/axes/:axis/governance-gap (NEW) | .../governance-gap.get.ts | v_axis_topic_governance_gap |
| GET /api/axes/:axis/automation-candidates (NEW) | .../automation-candidates.get.ts | v_axis_topic_automation_candidates |
Deploy-ready handler bundle
The full bundle (each handler under a // ==== FILE: <path> ==== banner) was authored this run. Representative handlers:
// web/server/api/axes/[axis_code]/nodes.get.ts
import { defineEventHandler, getRouterParam, createError } from 'h3'
import { pgQuery } from '~/server/utils/pg'
export default defineEventHandler(async (event) => {
const axisCode = getRouterParam(event, 'axis_code')
if (!axisCode) throw createError({ statusCode: 400, statusMessage: 'axis_code required' })
// NO math here — the view already computed every count, flag and status.
const rows = await pgQuery(
`SELECT node_code, node_label, lifecycle_status, governance_status,
parent_codes, has_multiple_parents, count_value, child_count,
assignment_count, warning_flags, grouping_status, pin_state
FROM v_registries_pivot_axis_surface WHERE axis_code = $1
ORDER BY count_value DESC, node_code`, [axisCode])
return { axis_code: axisCode, count: rows.length, nodes: rows }
})
// web/server/api/axes/[axis_code]/approval-queue.get.ts (NEW — consumes the LIVE queue)
export default defineEventHandler(async (event) => {
const axisCode = getRouterParam(event, 'axis_code')
const rows = await pgQuery(
`SELECT code, request_type, action, status, priority, target_collection,
target_entity_code, title, evidence, proposed_action, current_state, source_context
FROM approval_requests WHERE code LIKE 'AXR-TOPIC-%' ORDER BY priority DESC, code`)
return { axis_code: axisCode, count: rows.length, approval_queue: rows }
})
// web/server/api/axes/[axis_code]/node/[node_code]/substrate.get.ts
export default defineEventHandler(async (event) => {
const nodeCode = getRouterParam(event, 'node_code')
if (!nodeCode) throw createError({ statusCode: 400, statusMessage: 'node_code required' })
const [row] = await pgQuery(`SELECT fn_topic_node_substrate($1) AS substrate`, [nodeCode])
return row?.substrate ?? null
})
(index/decision-queue/pivots/governance-gap/automation-candidates handlers follow the identical thin-SELECT pattern — full text in the macro's working file axis-handlers-bundle-2026-06-04.ts.)
Frontend components (additive)
AxisSelector (over /api/axes, badge "Topic Axis [CANDIDATE]") · AxisNodeList (root = empty parent_codes; expand by parent_code; multi-parent renders every path) · AxisDecisionQueue (evidence_strength badge, review_bucket chip, proposed action, sibling-suspicion warning, "Propose → GOV-COUNCIL" drafts an approval row) · AxisApprovalQueue (NEW — renders the 14 live AXR-TOPIC rows; owner Approve/Defer/Reject) · TopicSubstratePanel (resolver jsonb: IUs + evidence tags + empty states) · CandidateBadge · EvidenceSummary · MultiParentBreadcrumb.
Tests (no Nuxt math / no hardcoded levels)
import { describe, it, expect } from 'vitest'
describe('axes API contract', () => {
it('GET /api/axes returns AX-TOPIC CANDIDATE', async () => {
const r = await $fetch('/api/axes'); expect(r.axes[0].axis_code).toBe('AX-TOPIC')
expect(r.axes[0].status).toBe('CANDIDATE')
})
it('nodes count comes from PG, not computed client-side', async () => {
const r = await $fetch('/api/axes/AX-TOPIC/nodes')
expect(r.count).toBe(7)
// count_value present per node — assert never recomputed from arrays
for (const n of r.nodes) expect(typeof n.count_value).toBe('number')
})
it('candidate badge data present', async () => {
const r = await $fetch('/api/axes/AX-TOPIC/nodes')
expect(r.nodes.every(n => n.lifecycle_status === 'candidate')).toBe(true)
expect(r.nodes.every(n => Array.isArray(n.warning_flags))).toBe(true)
})
it('evidence_ref / substrate resolver works', async () => {
const s = await $fetch('/api/axes/AX-TOPIC/node/TOPIC-CAND:knowledge_graph/substrate')
expect(s.information_unit_count).toBe(10)
expect(s.evidence_tags.length).toBe(10)
expect(s.taxonomy_node).toBeNull() // ungoverned proven
})
it('depth never hardcoded — derive from parent_codes arrays', async () => {
const r = await $fetch('/api/axes/AX-TOPIC/nodes')
expect(r.nodes.every(n => Array.isArray(n.parent_codes))).toBe(true)
})
it('approval queue is pending-only (no fake approval)', async () => {
const r = await $fetch('/api/axes/AX-TOPIC/approval-queue')
expect(r.count).toBe(14)
expect(r.approval_queue.every(x => x.status === 'pending')).toBe(true)
})
})
Live response samples (prod 2026-06-04)
GET /api/axes → [{"axis_code":"AX-TOPIC","status":"CANDIDATE",...}]
GET /api/axes/AX-TOPIC/nodes → 7 rows, first knowledge_graph count_value 10, flags ["CANDIDATE_NODE","ORPHAN_TOPIC_NODE"].
GET /api/axes/AX-TOPIC/approval-queue → 14 rows, all status "pending".
GET /api/axes/AX-TOPIC/governance-gap → 8 rows, all OWNER_MISSING.
Redeploy checklist (operator)
- Split the bundle into the 10
axes/*.get.tsfiles. 2. Add the components incl. AxisApprovalQueue. 3. Link/knowledge/registries-pivot→ "Axes" tab (legacy tree untouched). 4.docker build+ redeployincomex-nuxt(operator-gated). 5. Smoke: /api/axes→AX-TOPIC; nodes→7; decision-queue→7; approval-queue→14 pending; substrate(knowledge_graph)→10 IUs.
Forbidden compliance
No Nuxt math; legacy path untouched; DAG never one-tree; no redeploy executed; no hardcoded topic depth.