KB-4082

Topic Axis Self-Operated — 03 UI/API Patch & Deploy Readiness

7 min read Revision 1
topic-axisui-apinuxtpatch-packettests2026-06-04

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)

  1. Split the bundle into the 10 axes/*.get.ts files. 2. Add the components incl. AxisApprovalQueue. 3. Link /knowledge/registries-pivot → "Axes" tab (legacy tree untouched). 4. docker build + redeploy incomex-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.

Back to Knowledge Hub knowledge/dev/reports/architecture/topic-axis-self-operated-decision-ui-content-automation-2026-06-04/03-ui-api-patch-deploy-readiness.md