Skip to content

API Reference

Base URL: http://rig-conductor-api:8080 (internal ClusterIP)

Operator-token gate (rc#2369). Nine destructive write routes (POST /api/pods/{name}/start|stop, force-done, POST/DELETE /api/admin/tenants, POST /api/admin/overrides, DELETE /api/agents/{id}, /api/agents/offline, /api/repo-learnings) check X-Rig-Operator-Token. The default is report mode: logged, never refused. With OPERATOR_ROUTE_GATE=enforce, a missing or wrong token gets 403. Production has enforced since 2026-09-25 (rc#2435). Decision record: 2026-09-24-operator-route-gate.md.

Endpoints

Health

GET /health
{"status": "healthy", "timestamp": "2026-04-02T06:56:08Z"}

Deep Health (rc#1188)

GET /healthz/deep

Returns the status of every dependent system the conductor needs to function. Critical deps (Valkey, Marten, GitHub API) drive the readiness verdict; non-critical deps (Discord) can soft-degrade overall but cannot trip the 503.

Status-code mapping:

Overall HTTP Meaning
Ok 200 all critical deps reachable; non-critical deps Ok
Degraded 200 reachable but slow / rate-limited / a non-critical dep is non-Ok — pod stays in the load balancer
Unreachable 503 at least one critical dep is unreachable — readiness probe should pull the pod

Post-merge baseline (PR-B1 only):

{
  "overall": "Ok",
  "dependencies": [],
  "checkedAt": "2026-05-19T13:42:00Z"
}

Populated response (after PR-B2 wires production checkers):

{
  "overall": "Degraded",
  "dependencies": [
    { "name": "valkey",  "status": "Ok",         "critical": true,  "reason": null,         "latencyMs": 3,    "lastCheckAt": "..." },
    { "name": "marten",  "status": "Ok",         "critical": true,  "reason": null,         "latencyMs": 12,   "lastCheckAt": "..." },
    { "name": "github",  "status": "Degraded",   "critical": true,  "reason": "http 429",   "latencyMs": 88,   "lastCheckAt": "..." },
    { "name": "discord", "status": "Ok",         "critical": false, "reason": null,         "latencyMs": 41,   "lastCheckAt": "..." }
  ],
  "checkedAt": "2026-05-19T13:42:00Z"
}

reason is null on Ok and short on non-Ok ("high ping latency", "http 429", "ping failed: RedisConnectionException", etc.). latencyMs is the per-checker round-trip; the orchestrator runs checkers in parallel with a 2-second per-checker hard timeout that maps to Status=Degraded, reason="timeout".

Rollout slices: - PR-B1 (this slice): port + orchestrator + /healthz/deep endpoint; empty deps array - PR-B2 (rc#1204): four production checkers (Valkey / Marten / GitHub API / Discord) + DI - PR-D: Kubernetes readiness + liveness probes switched from /health to /healthz/deep + decision doc

See rc#1188 for the overall design and rc#1173 for the Valkey silent-degrade incident that motivated it.

Submit Event

POST /api/events

Submit any rig event. The stream ID is derived from repo#issueNumber for issue events, or agentId for heartbeats.

Request:

{
  "type": "ISSUE_APPROVED",
  "repo": "dashecorp/rig-conductor",
  "issueNumber": 42,
  "title": "feat: Add health check endpoint",
  "priority": "normal",
  "dependsOn": []
}

Response (200):

{
  "streamId": "dashecorp/rig-conductor#42",
  "type": "ISSUE_APPROVED",
  "timestamp": "2026-04-02T06:56:22Z"
}

Error (400):

{"error": "Unknown event type: INVALID"}

A body streamId or agentId that starts with cockpit-task: or release: (ignoring case) is also a 400. Those namespaces belong to the operator task store (rc#2104) and the release store (rc#2733).

An event without an int field that its type's mapping needs is a 400 too, and nothing is appended. For example, REVIEW_PASSED needs both issueNumber and prNumber (rc#2342):

{"error": "REVIEW_PASSED requires IssueNumber", "missing": ["IssueNumber"]}

missing lists every absent field and uses the C# property names, so they are PascalCase. The list of types and the fields each one needs is under Required fields. Before rc#2342 these requests threw inside SubmitEvent and came back as an unhandled 500.

See Event Store for all event types and their fields.

Get Issues

GET /api/issues
GET /api/issues?state=queued
GET /api/issues?view=full
GET /api/issues?view=full&include=body
GET /api/issues?limit=50&cursor=100

Returns tracked issues, optionally filtered by state. The response body is always a bare JSON array.

States: queued, assigned, in_progress, in_review, deploying, done, failed

Default: narrow, bounded summary (rc#2542). Agents (Rig-E, Dev-E, Planner) call this endpoint bare, and its output goes straight into a 16,384-token model context. By default each element is

{"id": "dashecorp/rig-conductor#2542", "title": "…", "state": "in_progress", "agentId": "dev-e-dotnet", "stuck": true}

agentId appears only when assigned; stuck only when the stuck flag is live (stuckSince >= lastUpdated). At most 100 rows come back, non-terminal issues first, then most recently updated. Measured against the live backlog on 2026-10-02: 145 issues, wide response 206,274 B → default response 16,957 B (~4,845 tokens).

Query Effect
view=summary (default) Narrow row above; default limit 100
view=full Full IssueStatus row (repo, issueNumber, prNumber, releaseSummary, …) minus body; no default limit (the operator dashboard uses this)
include=body Adds the markdown body (rc#2080). Without view, implies view=full; ignored by view=summary
limit=N Page size, 1–1000, either view
cursor=C Continue from a previous page; pass back X-Next-Cursor unchanged

Response headers: X-Total-Count (rows matching state before paging) and X-Next-Cursor (present only when more rows remain). The cursor is an offset into the ordered list — treat it as opaque. An unknown view, a limit outside 1–1000 or a malformed cursor returns 400.

History: rc#2080 dropped body (316 KB → 199 KB); rc#2542 narrowed the row and bounded the count. For a single issue (with body) use GET /api/issues/item?repo={repo}&issueNumber={n}.

Get Issue Trace (rc#951)

GET /api/issues/trace?repo={repo}&issueNumber={n}

Returns the ordered Marten event history for one issue stream, plus the current IssueStatus.State from the projection. Events are sorted by Marten's global Sequence (authoritative — not by timestamp, which can collide or be reported out of clock-skew order).

404 Not Found when the stream has zero events (issue never landed in conductor).

{
  "issueId": "dashecorp/rig-conductor#244",
  "currentState": "in_progress",
  "eventCount": 28,
  "events": [
    {"at": "2026-05-14T07:26:17Z", "type": "work_started", "data": {...}, "sequence": 364467},
    {"at": "2026-05-14T07:26:46Z", "type": "merged",       "data": {...}, "sequence": 364470}
  ]
}

Replaces the prior psql-into-Marten + log-tail workflow (~10 min per triage cycle). Also feeds the rc#947 SelfImprovementService a stable per-issue event-sequence API.

Get Priority Queue

GET /api/queue

Returns unassigned issues sorted by priority (critical > high > normal > oldest).

Get Next Assignment

GET /api/assignments/next?agentId=dev-e-1

Returns the top-priority unassigned issue, or 204 No Content if nothing available.

With LOCATION set, it also answers 204 when this location does not own invotek (the tenant whose rows this query reads) or the tenant map is missing or stale. See multi-location fencing (rig-conductor#2561).

Response (200):

{
  "streamId": "dashecorp/rig-conductor#42",
  "issue": {
    "number": 42,
    "repo": "dashecorp/rig-conductor",
    "title": "feat: Add health check endpoint",
    "milestone": null
  },
  "priority": "normal",
  "attempt": 1
}

Get Agent Status

GET /api/agents

Returns status of all known agents. The status and currentIssue/currentRepo fields are updated by both heartbeat events and assignment events (ISSUE_ASSIGNED, WORK_STARTED) so the dashboard reflects live work immediately without waiting for a heartbeat cycle.

Each item includes:

  • isOnline — computed from recent heartbeat freshness (last heartbeat within 2 minutes)
  • status — idle, working, or stuck. Set to working when ISSUE_ASSIGNED or WORK_STARTED is dispatched; cleared to idle on ISSUE_DONE, ISSUE_CANCELLED, or next idle heartbeat.
  • currentIssue — issue number the agent is currently working on (null if idle)
  • currentRepo — repo of the current issue (null if idle)
  • activeProvider
  • availableProviders
  • providers[] — provider health snapshot
  • integrations[] — integration health snapshot
  • lastModel — last LLM model the agent reported via TOKEN_USAGE (e.g. claude-opus-4-7). null until the agent emits its first TOKEN_USAGE event after deploy. Added in #530 (rc#529) so the dashboard can show which agents have switched models after a config change.

Response:

[
  {
    "id": "dev-e-1",
    "status": "working",
    "isOnline": true,
    "currentIssue": 136,
    "currentRepo": "dashecorp/rig-conductor",
    "activeProvider": "claude",
    "availableProviders": ["claude", "codex"],
    "providers": [
      { "name": "claude", "status": "ready", "details": "Claude Code auth configured", "active": true },
      { "name": "codex", "status": "authenticated", "details": "logged in using ChatGPT", "active": false }
    ],
    "integrations": [
      { "name": "github", "status": "ready", "details": "GitHub App configured" },
      { "name": "discord-webhook", "status": "ready", "details": "operator webhook configured" }
    ],
    "lastModel": "claude-opus-4-7",
    "lastHeartbeat": "2026-04-02T09:14:22Z",
    "issuesCompleted": 12,
    "issuesFailed": 1
  }
]

lastModel is null for agents that have not yet emitted a TOKEN_USAGE event after deploy.

Get Execution Logs

GET /api/execution-logs?limit=50&status=running

Returns recent execution log summaries (without full step/log payload). Used by the dashboard overview.

Query parameters:

Parameter Type Default Description
limit int 50 Max results to return
status string (all) Filter by status. Must be one of: running, completed, failed, stuck. Case-insensitive — RUNNING, Running, and running are all accepted and treated identically.

Status filter validation: If status is provided and is not one of the allowed values, the endpoint returns 400 Bad Request:

{"error": "Invalid status 'xyz'. Allowed values: running, completed, failed, stuck."}

Note: cancelled is not a valid status — the domain uses stuck to represent issues that have stalled. Sending ?status=cancelled returns 400.

Response (200 OK):

[
  {
    "id": "uuid",
    "repo": "dashecorp/rig-conductor",
    "issueNumber": 42,
    "prNumber": 99,
    "agentId": "dev-e-1",
    "status": "completed",
    "startedAt": "2026-04-23T10:00:00Z",
    "completedAt": "2026-04-23T10:12:00Z",
    "durationSeconds": 720,
    "totalCostUsd": 0.18,
    "totalTurns": 24,
    "model": "claude-sonnet-4-5",
    "stepCount": 7,
    "logCount": 130
  }
]

Get Execution Log Stats

GET /api/execution-logs/stats?agentId=review-e&days=14&status=completed

Aggregated turn / cost / token distribution for execution logs in a window — the instrument that sizes cost levers, e.g. choosing a safe --max-turns set above the p95 of real runs. Pure aggregation lives in ConductorE.Core.UseCases.ExecutionStatsPolicy; see the Review-E cost-reduction decision and Cost Attribution.

Query parameters:

Parameter Type Default Description
agentId string (all agents) Filter to a single agent. Omit to aggregate across all agents; echoed back as null when omitted.
days int 14 Look-back window from now. Must be 1–365; out-of-range returns 400 (a non-positive window would push the cutoff into the future and report zero rows, indistinguishable from "no runs").
status string completed Status to aggregate. One of running, completed, failed, stuck (case-insensitive). An unknown value returns 400 — it is not silently treated as "no data", since an empty result would size --max-turns from a zero p95.

Response (200 OK):

{
  "agentId": "review-e",
  "windowDays": 14,
  "status": "completed",
  "stats": {
    "runCount": 42,
    "turns": { "count": 42, "min": 3, "p50": 18, "p90": 47, "p95": 61, "max": 120, "mean": 21.4 },
    "totalCostUsd": 38.21,
    "meanCostUsd": 0.91,
    "totalInputTokens": 18450000,
    "totalOutputTokens": 412000,
    "totalCacheReadTokens": 15200000,
    "totalCacheCreationTokens": 980000
  }
}

turns percentiles are nearest-rank (rank = ceil(p/100 * N), 1-based). An agent with no runs in the window returns 200 with runCount: 0 and an all-zero distribution (never a 404 or error).

Get Event Stream

GET /api/events/stream?id=dashecorp/rig-conductor%2342

Returns all events for a specific stream. The id parameter must be URL-encoded (use %23 for #).

Response:

[
  {
    "id": "uuid",
    "type": "IssueApproved",
    "data": { "repo": "...", "issueNumber": 42, "title": "..." },
    "timestamp": "2026-04-02T06:56:22Z"
  }
]

Token Usage

GET /api/usage
GET /api/usage?agentId=dev-e-node
GET /api/usage?repo=dashecorp%2Frig-conductor
GET /api/usage?days=7

Returns per-agent token usage totals including cache token counts.

Query parameters:

Param Default Description
agentId (all) Filter to a single agent
repo (all repos) Filter to a specific repo
days (all time) Rolling window in days. Required to compare with /api/costs/summary — without it the endpoint returns all-time projection totals which are naturally larger than any windowed cost summary.

Response:

[
  {
    "agentId": "dev-e-node",
    "totalInputTokens": 1200000,
    "totalOutputTokens": 480000,
    "totalCacheReadTokens": 5600000,
    "totalCacheCreationTokens": 920000,
    "totalCostUsd": 39.591,
    "byRepo": [
      {
        "repo": "dashecorp/rig-conductor",
        "inputTokens": 800000,
        "outputTokens": 300000,
        "cacheReadTokens": 3200000,
        "cacheCreationTokens": 600000,
        "costUsd": 25.4
      }
    ]
  }
]

Cost Summary

GET /api/costs/summary
GET /api/costs/summary?days=7

Returns agent-level cost breakdown by category for the rolling window. Uses the same raw-event source as /api/usage?days=N — both endpoints agree for the same window.

Cost computation (fixed in #148):

  • Events with zero tokens (inputTokens=outputTokens=cacheReadTokens=cacheCreationTokens=0) contribute $0, regardless of any costUsd value in the event. This prevents phantom idle costs.
  • Known Anthropic models with cache tokens (cacheReadTokens > 0 || cacheCreationTokens > 0) are recomputed using the Anthropic pricing table in AnthropicPricing rather than trusting the agent-reported costUsd, which historically excluded cache costs.
  • Unknown models and non-Anthropic models retain the agent-reported costUsd, including when cache tokens are present.
  • Legacy events (no cache token fields) continue to use the agent-reported costUsd.

TOKEN_USAGE event now accepts optional cache token fields:

{
  "type": "TOKEN_USAGE",
  "agentId": "dev-e-node",
  "repo": "dashecorp/rig-conductor",
  "issueNumber": 148,
  "model": "claude-sonnet-4-5",
  "inputTokens": 10,
  "outputTokens": 4994,
  "cacheReadTokens": 160855,
  "cacheCreationTokens": 28927,
  "costUsd": 0.242194,
  "category": "work"
}

See cost attribution for the full pricing model and fix details.

Daily Costs

GET /api/costs/daily?days=7

Returns a per-agent, per-day breakdown for the rolling window. Add &by=repo to group by date, repository and agent, adding a repo field to each entry. Without by, the response shape is unchanged. /public/cost-summary never exposes repository names.

Response:

{
  "period": "7d",
  "entries": [
    { "date": "2026-04-23", "agentId": "dev-e-node", "costUsd": 12.30 }
  ]
}

Stream Status

GET /api/streams/status

Returns per-agent Valkey stream status. The primary field to watch is lag inside each consumer-group entry — it represents messages the consumer group has not yet delivered, i.e. the real backlog. xlen (total stream length) is included for diagnostics only; it does not decrease when messages are acknowledged and will accumulate until the stream is explicitly trimmed.

Response:

{
  "dev-e-dotnet": {
    "xlen": 17,
    "groups": {
      "agents": { "lag": 0, "pending": 0 }
    }
  },
  "dev-e-node": {
    "xlen": 27,
    "groups": {
      "agents": { "lag": 2, "pending": 1 }
    }
  }
}
  • lag — unread entries waiting to be delivered to the group (the real queue backlog)
  • pending — entries delivered but not yet acknowledged (in-flight)
  • xlen — total stream length (misleading as a backlog metric; stays high until trim)

When no consumer group has been created yet (stream never consumed), groups is empty and xlen reflects the raw message count.

List Tenants (Cockpit roster)

GET /api/admin/tenants

Operator-facing read of the rig_control tenant allowlist — the source the resolver consults — projected to a small DTO the Rig Cockpit (iOS app + web SPA via the rig-cockpit-worker /api/* proxy) renders as a Tenants tab (rc#1919). Fills the gap that GET /api/tenancy cannot: the census returns only a count + bare lower-cased tenant-ID strings, no name/status/type.

Auth: No in-app token. Same Cloudflare-Access posture as the sibling POST/DELETE /api/admin/tenants write paths and the rest of /api/admin/*. Do not expose via public ingress.

Query params: none. Returns the full roster (no owner-scoping today — effectively 1 active tenant = invotek; owner-scoping is future work if the roster grows).

Response (200):

{
  "tenants": [
    {
      "id": "invotek",
      "name": "Invotek",
      "githubOrg": "dashecorp",
      "status": "active",
      "type": "first-party",
      "installationId": 12345678
    }
  ]
}

Per-row fields:

Field Type Description
id string Stable lower-case tenant slug (Marten document identity)
name string Human-readable name
githubOrg string GitHub org that maps to this tenant (lower-case)
status string "active" or "suspended" — only "active" tenants resolve at the ingress boundary
type string Operator-policy classification, raw — "first-party" or "b2c" (rc#1873). Surfaced as-is; clients render their own label, no server-side B2B/B2C mapping
installationId long? GitHub App installation id when known; null until per-tenant App installs are mapped

The projection is closed — adding a field to the Tenant Marten document does NOT silently leak through this endpoint. Internal-only fields stay internal.

Force-Done (Epic override)

POST /api/admin/issues/force-done?repo=owner%2Frepo&issueNumber=N

Operator escape hatch for a parent epic that was blocked by the epic-completion guard (rc#459). Use when the team has deliberately abandoned remaining sub-issues and wants to declare the epic done without waiting for all subs to reach done/production.

Auth: the operator-token gate (rc#2369), enforced in production since 2026-09-25 (rc#2435): send X-Rig-Operator-Token, or get 403. Do not expose via public ingress.

Query params:

Param Type Required Description
repo string ✅ Repository slug, e.g. dashecorp/rig-conductor (URL-encode the /)
issueNumber int ✅ GitHub issue number of the parent epic

Responses:

Status Body Meaning
200 {"forcedDone": true, "previousState": "deploying", "repo": "...", "issueNumber": N} Transition emitted
200 {"alreadyDone": true, "state": "done", ...} Epic was already done/production — no-op
404 {"error": "Issue ... not found in conductor"} Issue not tracked in conductor

Example:

curl -X POST -H "X-Rig-Operator-Token: $RIG_OPERATOR_TOKEN" \
  "http://rig-conductor-api:8080/api/admin/issues/force-done?repo=dashecorp%2Frig-conductor&issueNumber=433"

Location fence status (rig-conductor#2561)

GET /api/location-fence

Read-only view of the multi-location fence (multi-location fencing). It never fetches the map; it reports what the conductor holds. With LOCATION unset the answer is {"enabled": false, ...} with empty lists.

Response (200):

{
  "enabled": true,
  "location": "gcp",
  "mapConfigured": true,
  "mapFetchedAt": "2026-10-02T12:00:00+00:00",
  "mapAgeSeconds": 12.4,
  "mapStale": false,
  "lastFetchError": null,
  "lastFetchFailedAt": null,
  "tenants": [
    { "tenantId": "invotek", "locationId": "gcp", "epoch": 3, "state": "active", "githubOrg": "dashecorp", "installationId": 12345678 }
  ],
  "recentBlocks": [
    { "at": "2026-10-02T12:00:05+00:00", "tenantId": "acme", "action": "assignment.publish", "target": "acme/app#5", "reason": "mapped_elsewhere" }
  ]
}

recentBlocks holds the last 100 refusals, newest first. reason is one of map_missing, map_unreachable, map_stale, tenant_unknown, tenant_not_in_map, tenant_ambiguous, mapped_elsewhere, tenant_not_active, fence_error.

While fencing is on, POST /api/merge answers 200 {"triggered": false, "fenced": true, "reason": ...} for a fenced tenant, GET /api/assignments/next and GET /api/pr-reviews/next answer 204, and the GitHub webhook answers 200 with "fenced": true once it has recorded the delivery.


Rig Cockpit task routes (rc#2104)

POST /api/cockpit/task/propose, GET /api/cockpit/task/status (the store state plus the projected stage, detail and timeline) and POST /api/cockpit/task/release (releases a MergeGate-held Rig-E task PR at exactly the signed head), signed by the Rig Cockpit Worker. They answer 503 not_configured until COCKPIT_TASK_SIGNING_KEY is provisioned. See Operator Task Routes.

Release promotion, shadow (rc#2733)

GET /api/promotions?repo=owner/name answers {repo, waiting, recent}: every waiting release and the ten newest, from the release streams. The conductor resolves the repo's tenant itself; a repo outside dashecorp, or one with no release, answers empty lists. It is not GET /api/releases, which is the dashboard's release-notes list (release notes).

POST /api/webhook/release is the release App's webhook. It answers 503 not_configured until RELEASE_APP_WEBHOOK_SECRET holds a usable secret, which must differ from GITHUB_WEBHOOK_SECRET. Past that: 413 over 512 KiB, 401 invalid_signature, 400 for a signed delivery that is not GitHub's shape, otherwise 200. It records each run's release (waiting, superseded, deployed, failed) and approves nothing. See Release Promotion.

Autonomy level (rc#2640)

GET /api/autonomy answers the rig-wide autonomy level and GET /api/autonomy/history?limit=50 (newest first, 1–500) the hash-chained log behind it. Both are read-only and need no credential. Nothing enforces the level yet: every point is in shadow mode. See the decision.

The body carries level, name, standing, override, seq, headHash, anchorFingerprint, pinnedAnchorFingerprint, health, detail and enforcement ({"merge": "shadow", "deploy": "shadow"}). level is a slug: full-speed, guarded, supervised or full-security. It is full-security whenever health is not ok: empty (no passkey pinned yet), chain_invalid, anchor_mismatch, seq_regression (the log ends before its Valkey high-water mark), tripped (the chain is whole again but has not recorded Full security since the latest tripwire incident) or store_unavailable. The first read after the operator's passkey is pinned in deploy/k8s/gates.yaml writes the genesis entry and posts its fingerprint to #admin.

Changing the level needs the operator token (X-Rig-Operator-Token). Each route answers {"outcome": …, "state": <the body above>}.

Route Body Outcomes
POST /api/autonomy/raise {"to": "supervised"} raised; no_op (200) when the level is already there or higher, which is always the case until the passkey is pinned
POST /api/autonomy/lower/challenge {"to", "durationMinutes": 30\|120\|480\|1440\|null, "expectedSeq"} 200 {challengeId, kind, params, origin, validUntil}; 409 not_configured (no passkey pinned), no_chain, stale, not_a_lowering; 400 bad_duration
POST /api/autonomy/lower {"challengeId", "assertion": {credentialId, authenticatorData, clientDataJson, signature}} lowered; 410 for an unknown, expired or used challenge; 403 when the passkey or the replay refuses it; 409 stale

A challenge is good once and for five minutes. Every refused lowering is posted to #admin, and so is every change. Where the location fence refuses the default tenant, a change answers 503 location_fenced and writes nothing. Once the passkey is pinned the conductor writes two entries itself: reverted by expiry when a time-box ends, and raised to Full security by tripwire once a tripwire's finding has cleared; each incident is posted to #admin once.

POST /api/autonomy/decision tells a deploy job which GitHub environment to use. It needs no operator token: the job sends its GitHub Actions OIDC token (Authorization: Bearer, audience rig-conductor-autonomy), which must be RS256, signed by GitHub, unexpired, and for a run on refs/heads/main in dashecorp. Body: {"point": "deploy", "class": "code|additive|destructive|…", "legacyEnvironment", "gatedEnvironment", "ungatedEnvironment"}. Answer: {environment, gated, wouldGate, outcome, level, enforced, decisionId}. While AUTONOMY_ENFORCE_DEPLOY is shadow, environment is always legacyEnvironment. Errors: 401 {error} with the token check's reason, 400 bad_request, 503 jwks_unavailable. Every answer is recorded with the run (AutonomyDecisionRecord).

Architecture

POST /api/events → SubmitEvent (Use Case) → IEventStore (Port) → MartenEventStore (Adapter) → PostgreSQL

GET /api/queue   → IIssueQuery (Port) → MartenIssueQuery (Adapter) → PostgreSQL (Marten projection)

Clean Architecture: endpoints delegate to use cases/ports, never touch Marten directly.