Every route Voice serves, grouped by audience, because each audience is one endpoint with one ACL.
Bodies are JSON; absent values are left out, never null; times are ISO-8601 instants. A refusal is a
body { "rule", "status", "error", "names" }, listed in Refusals. The web interface
calls these routes and nothing else; the agents are components called by Voice's own workflows and
have no route of their own.
Endpoint
Prefix
Audience
ACL
PublicEndpoint
/public
anyone, no credential
allow all; every response type is checked at build time to contain no sealed field and no participant id
CitizenEndpoint
/me
a pseudonymous participant with a session
Voice's own authenticator over the session token, with no I/O, yielding the participant as the principal; every route is scoped to that participant's own records
StaffEndpoint
/staff
representative, reviewer, operator
a person's OIDC token through ankka-auth-oidc; the role is the token's realm role; the staff-account entity holds name, authority and active, never a role
IssueSummary[]: id, title, summary, counts with caveat text, lastDevelopmentAt
GET /public/issues/{issue}
IssuePage: title, summary, published contributions (text, attribution, publishedAt, pub reference, authorErased absent, requested or dormancy), counts of contributions by author status, questions with hypotheses, evidence links with state and provenance, options, investigations with their state label, actions (state, authority, owner, date, summary, completed), decisions, outcomes, chronology, the aggregate with its caveat, limitations text
GET /public/issues/{issue}/evidence/{evidence}
EvidenceCard: source, locator, passage, publishedAt, recordedAt, scope, authority, limitations, state
GET /public/issues/{issue}/claims/{claim}
ClaimExplanation: statement, stances, the evidence cards it derives from with their source cards, the contradicting and qualifying claims with their cards, revisedBy; or { "abstained": true, "reason": … }
GET /public/issues/{issue}/questions/{question}
the question with its hypotheses and the claims on each; or { "abstained": true, "reason": … }
GET /public/issues/{issue}/chronology
Entry[]: state (one of the seven labels), actor kind, at, ref
GET /public/search
q, limit, after
Hit[]: kind (issue, source, option), record id, version or date, title, snippet from public text only, link
GET /public/sources/{source}
SourceCard: name, licence, attribution, link
No public route creates anything; reading an issue creates no interest. Snippets are built from the
public view's columns only. A private, restricted, withdrawn or unpublished record answers 404public.not-found, the same as one that never existed.
The session token is a bearer token the endpoint resolves to a participant id. A route that names a
record checks it belongs to that participant and answers 404 otherwise.
Conversation with messages, proposals and confirmed concern ids
POST /me/conversations/{conversation}/messages
{ "text" }
202{ "turn": "cnv-…:turn-<seq>", "message": <the recorded message> }; the reply and any proposal appear on the conversation when the turn ends
GET /me/conversations/{conversation}/turns/{turn}
TurnStatus: Thinking, Replied with reply and an optional proposal, or CouldNotInterpret with message
GET /me/conversations/{conversation}/stream
SSE, after=<seq>
message, proposal and turn events as they are recorded
POST /me/conversations/{conversation}/proposals/{proposal}/edit
{ "summary", "elements": [ … ] }
ConcernProposal marked edited
POST /me/conversations/{conversation}/proposals/{proposal}/confirm
{}
Concern
POST /me/conversations/{conversation}/proposals/{proposal}/discard
{}
Conversation
GET /me/concerns
Concern[] with their contributions' states
A turn is the conversation-turn workflow: the route records the message and starts the workflow;
its step opens the sealed transcript for this call, calls the extraction agent, and records Voice's
reply and any proposal on the conversation. Nothing a browser waits on runs the model.
DataSummary: what Voice holds for this participant by class, what is public, what retention applies, the erasure status
POST /me/erasure
{}, or { "recoveryCredential": "<current credential>" }
202ErasureStatus: cause requested, state Verifying, HeldForOperator, InProgress or Completed, ruleerasure.unverified when held, what will be erased, what is retained and why, what remains public. With the current recovery credential the request is verified and starts at once; without it, or with one that does not match, it is held for an operator. A second request answers 200 with ruleerasure.already-requested
GET /me/erasure
ErasureStatus
After completion, every /me route for the participant answers 410participant.erased.
A person's token is verified against the issuer the service descriptor names; the subject is looked
up in staff-account for its name, authority and whether it is active. Each route states the role
it needs. Every staff read of a restricted record, and every refused staff request, appends an entry
to the access log.
WorkQueue: new issues, pending reviews, open review prompts, open investigations, tasks awaiting an external party, each with kind, state, age and link
GET /staff/issues/{issue}
IssueForStaff: the public page plus the restricted contributions this role may see, participant-derived measures with caveats, unresolved questions, contrary perspectives
POST /staff/issues/{issue}/research
{ "question" }
202{ "run": "inv-…" }; starts the research workflow
GET /staff/research/{run}
ResearchRun: state, and when done the suggestions: sources, questions and investigations, each labelled suggested with the records the agent read
each message, proposal and turn as it is recorded, from after
GET /me/inbox/stream
each notification as it is prepared
The platform's stream sends unnamed events with no id, so each event's data is { "type", "data" }
and a client keeps its own cursor. The gateway ends a response at fifteen seconds; a client
reconnects, and the conversation route also answers in full without the stream.
/me routes: Authorization: Bearer <session token>. The token is
base64url(participantId "." expiresAt) "." base64url(hmac-sha256) under VOICE_SESSION_KEY, valid
for VOICE_SESSION_HOURS, verified in the endpoint with no I/O. It is not a JWT and never reaches
an OIDC issuer. Erasure is checked in the handler against the participant.
The recovery credential is different: the participant id, ~, and 32 random bytes in base64url,
shown once by POST /me/sessions and by each recovery; Voice keeps its hash. It is sent only to the
two session routes and POST /me/erasure, never as a bearer header.
/staff routes: Authorization: Bearer <OIDC access token> from the issuer named staff in the
descriptor, with audience voice-staff; the role is the token's realm role.
Requests arrive through the web proxy with X-Ankka-Caller: internet and Authorization
unchanged; cookies are not used.
The issue and chronology routes send
Voice-State-Labels: submitted,ai-interpreted,under-moderation,staff-acknowledged,under-investigation,action-recorded,outcome-verified,
so an interface renders the seven states from the server's vocabulary.