@adjudicate/admin-sdk
v8.0.0
Published
Admin Query Interface (AQI) for adjudicate — Zod-validated read-only audit query handlers + tRPC router. Adopter-deployed; framework-shipped contract.
Readme
@adjudicate/admin-sdk
Status:
2.1.0— stable wire contract. Read and mutating procedures both ship.
The Admin Query Interface (AQI) for the adjudicate framework. This
package owns the wire contract between an adopter's deployed audit
infrastructure and any consuming UI (the reference @adjudicate/console,
or an adopter-built one).
The framework ships:
- Zod schemas that validate payloads at the wire boundary
- An
AuditStoreinterface the adopter implements against their persistence - A framework-agnostic handler factory for raw HTTP wiring
- A tRPC router for TypeScript-end-to-end consumers
- A Next.js Route Handler adapter (the canonical case; other frameworks are 5-line ports)
The framework does not ship: auth (adopter wraps), persistence (adopter implements AuditStore and the optional read/mutation ports), or hosted infra (the SDK runs in the adopter's process).
Surface map
@adjudicate/admin-sdk — schemas, AuditStore, handler, in-memory ref
@adjudicate/admin-sdk/trpc — adminRouter (tRPC v11)
@adjudicate/admin-sdk/adapters/next — toNextRouteHandleradminRouter exposes these namespaces (see src/trpc/index.ts):
| Namespace | What it covers |
|-----------|----------------|
| audit | query / byHash — read kernel-emitted decision audits (actor-gated). |
| emergency | state / history (read) + update (mutation) — operator kill switch. |
| replay | run (mutation) — re-adjudicate a historical record against current policy. |
| governance | distributions, drift, red-team, token-budget, config-seal, policy descriptor/manifest reads + recordOutcome (mutation). |
| approval | list / history / chain (read) + resolve (mutation) — confirmation engine. |
| pack | aiBom / aiBomList / aiBomById — AI Bill-of-Materials reads. |
| memory | bySession — cross-session memory snapshot read. |
| escalate | raise (mutation) — friction-monotone escalate/recommend surface (escalate-only, rate-limited). |
Optional surfaces (governance/approval/pack/memory/escalate beyond the base
reads) are feature-detected at runtime: each procedure throws
PRECONDITION_FAILED when its backing port is not wired into AdminContext,
so the procedure shape stays static across adopters.
Read-only plane + the escalate write (readOnlyAdminRouter)
@adjudicate/admin-sdk/trpc also exports readOnlyAdminRouter — the §B/§G
Inspector-General OBSERVER plane. It is adminRouter MINUS the four
AUTHORIZE/WEAKEN mutations (emergency.update, replay.run,
governance.recordOutcome, approval.resolve), so a console mounting it
physically cannot authorize, weaken, or replay-mutate a decision — those
procedures do not exist on the wire (router-level write isolation, not a runtime
check).
It is not zero-mutation, though: it carries exactly ONE write —
escalate.raise. That is safe on the observer plane precisely because it is
friction-monotone by construction (§C / §D inv.7):
- Escalate-only enum.
recommendationis the closedEscalateRecommendationSchema—pause/review/escalate. There is noallow/bypass/override/lower-threshold/EXECUTEvalue, enforced at the wire (a raw-HTTP caller cannot smuggle a friction-decreasing verb), mirroring the emergency status enum's "no allow-all/bypass" invariant. - Records a fact, never a decision. The output is a
RecordedEscalation(kind: "escalation.raised") — the closed 6-outcomeDecisionalgebra is untouched; the kernel decision hot-path is never reached. - Reads, never mutates, the target. It resolves the target decision
read-only via
AuditStore.getByIntentHash(tenant-scoped); the audit record is never mutated. - Actor-gated + rate-limited.
UNAUTHORIZEDwithout an actor;TOO_MANY_REQUESTSonce a per-actor sliding window is exceeded (createEscalateRateLimiter, default 10/min). Wire anEscalationSinkintoAdminContext(createInMemoryEscalationSink, or your durable fail-OPEN log); when absent,escalate.raisethrowsPRECONDITION_FAILED.
The escalation log MAY be fail-OPEN within the governance plane (operator-action precedence): record into the live sink, fire-and-forget the durable write, log (don't throw) on log-infra failure — the same posture as the kill-switch governance log. This fail-OPEN is isolated to the governance plane and never touches the fail-CLOSED decision hot-path.
Governance views (read-only, on the observer plane)
The three governance surfaces below are pure .query procedures — they read
recorded snapshots through feature-detected AdminContext ports and never
authorize, weaken, or mutate a decision (§C / §D-7). They ride the read-only
plane verbatim (governanceReadOnlyRouter = the governance reads with no
recordOutcome), so an Inspector-General app exposes them while the lone
governance mutation stays on the operator console. Each omitted port self-fences
with PRECONDITION_FAILED, so the surface is runtime-feature-detectable (the
procedure shape stays static across adopters; an unwired view returns a typed
precondition error, never a crash or a fabricated empty value):
| View | Reads | Port | Omitted ⇒ |
|------|-------|------|-----------|
| Policy-version history | governance.describePolicy / governance.policyManifest | ctx.policyDescriptor / ctx.policyManifest (typically describePolicyBundle(pack.policy) / the ibx policy export artifact, computed at route-handler startup) | PRECONDITION_FAILED |
| Dashboards | governance.guardFireStats / governance.outcomeDistribution | ctx.guardFireStats (a GuardFireStats instance) / ctx.store (the read-only AuditStore — newest-first, limit capped at 500) | guardFireStats ⇒ PRECONDITION_FAILED; outcomeDistribution needs only store (always present) |
| Kill-switch read-status | governance.killSwitchTimeline | ctx.killSwitchTimeline — the adopter maps emergency.history (GovernanceEvent) → KillSwitchEvent[] and runs the pure analyzeKillSwitchTimeline (@adjudicate/audit) at the route handler, then threads the report | PRECONDITION_FAILED |
The kill-switch view is read-status only: the OBSERVER sees the engage/clear
timeline (emergency.state / emergency.history), but the kill-switch WRITE
(emergency.update) is structurally absent from the read-only plane — it stays
on the operator console. A read-only mount therefore omits replayer,
outcomeSink, and approvalPort.resolve while keeping the pure-read ports
(turnTrace, guardFireStats, policyDescriptor, policyManifest,
killSwitchTimeline). These views render store records verbatim; they make no
cryptographic tamper-detection claim (integrity-on-read is the Audit Explorer's
byHashVerified verdict), and they must not present byHash as tenant-safe
(tenant isolation is host-enforced upstream).
Authentication & actors
Every record-level read and every mutation requires an authenticated actor,
resolved by the adopter's createContext via extractActor(req) from the
x-adjudicate-actor-id header (plus optional -name / -tenant):
audit.query/audit.byHashand the record-level governance drill-downs (piiEvents,commandRiskEvents) throwUNAUTHORIZEDwith no actor — audit reads are not open.- Mutations (
emergency.update,replay.run,governance.recordOutcome,approval.resolve,escalate.raise) throwUNAUTHORIZEDwith no actor.escalate.raiseis additionally per-actor rate-limited (TOO_MANY_REQUESTS). - Aggregate-only stats (
outcomeDistribution,piiClassificationStats,commandRisk) leak no per-record data and do not require an actor.
extractActor does not authenticate — it trusts the headers. The
toNextRouteHandler adapter takes a requireAuth gate (REQUIRED in
production; the handler throws at construction without it) that runs before
every request. Wrap your IdP session check there (see the next.ts JSDoc for
Clerk / OIDC recipes).
Implementing AuditStore
The adopter read contract is two methods. Postgres / Memory / Kafka-archive / S3-cold all satisfy the same shape.
import type { AuditStore } from "@adjudicate/admin-sdk";
export const myPostgresStore: AuditStore = {
async query(filters) {
const rows = await db.query(
`SELECT * FROM intent_audit
WHERE ($1::text IS NULL OR envelope->>'kind' = $1)
AND ($2::text IS NULL OR decision->>'kind' = $2)
AND ($3::timestamptz IS NULL OR at >= $3)
ORDER BY at DESC
LIMIT $4`,
[filters.intentKind, filters.decisionKind, filters.since, filters.limit],
);
return { records: rows.map(rowToAuditRecord) };
},
async getByIntentHash(intentHash) {
const row = await db.queryOne(
`SELECT * FROM intent_audit WHERE intent_hash = $1`,
[intentHash],
);
return row ? rowToAuditRecord(row) : null;
},
};Implementations MUST:
- Return records newest-first by
at(ISO-8601 string sort = chronological). - Honor
query.limitexactly. The schema caps it at 500. - Apply all provided filter fields with AND semantics.
- Surface persistence failures by throwing — the SDK converts to a tRPC
INTERNAL_SERVER_ERRORwith safe message. - For multi-tenant stores, honor the optional
getByIntentHashtenantScopeand never return a record outside it.
Implementations MUST NOT:
- Mutate records.
- Re-validate against Zod (the SDK already validated input).
- Return records that don't match the filter.
Mounting the tRPC router (Next.js)
AdminContext requires store, emergencyStore, and actor (the optional
ports — replayer, approvalPort, outcomeSink, aiBom, etc. — are wired in
only for the surfaces you expose):
// app/api/admin/[trpc]/route.ts
import {
adminRouter,
toNextRouteHandler,
createInMemoryAuditStore,
extractActor,
} from "@adjudicate/admin-sdk";
const store = createInMemoryAuditStore({ records: myRecords });
export const { GET, POST } = toNextRouteHandler({
router: adminRouter,
endpoint: "/api/admin",
requireAuth: withClerkAuth, // REQUIRED in production
createContext: (req) => ({
store,
emergencyStore: myEmergencyStore,
actor: extractActor(req),
}),
});For Express/Fastify/Hono, import createAuditQueryHandler directly and wire it into your router. The handler signature is (input: AuditQuery) => Promise<AuditQueryResult> — REST-native by construction.
Schema drift policy
@adjudicate/core defines the canonical TypeScript types. @adjudicate/admin-sdk ships matching Zod schemas. Drift is caught by three independent gates:
- Build-time TS assignability — the schemas that mirror a core type carry
_coreToSchema/_schemaToCoreconst guards whose bodies fail to compile if the schema and core type disagree (e.g.src/schemas/envelope.ts, which mirrorscore/src/envelope.ts+taint.ts). Most schemas insrc/schemas/are AQI-only (governance, approval, pack, token-budget, …) with no core counterpart to guard. - Runtime fixture roundtrip —
tests/schemas-roundtrip.test.tsparses one fixture per Decision kind through the schemas. Drift fails by name. - CI gate — root
pnpm -r testruns both above as part of normal verification. A kernel change that breaks the SDK fails the workspace build.
If you edit a core type that a schema mirrors (packages/core/src/{decision,envelope,audit,refusal,basis-codes,taint}.ts), update the matching file in packages/admin-sdk/src/schemas/. CI will tell you if you forgot.
What's not in this package
- Auth — adopter-supplied. Pass
requireAuthto the route handler / wrap with your auth middleware. - Persistence & ports — adopter implements
AuditStoreand any optional read/mutation ports (replayer,approvalPort,outcomeSink, AI-BOM, drift, token-budget, …). - Postgres reference store — separate package
@adjudicate/audit-postgres-store(future). - Express/Fastify/Hono adapters — community PRs welcome. The handler is framework-agnostic by design.
- OpenAPI export — possible from Zod via
zod-to-openapiif a non-TS consumer ever appears.
