IDKMesh

Control Tower Local API v0.1

Status: experimental, local-only, read-only
HTTP version prefix: /api/v1
Object schema version: 0.1
Issue: #572
Product surface: idkmesh control-tower

Purpose

The Control Tower Local API is the first machine-readable product boundary for the Human Control Tower. It exposes already-produced IDKMesh evidence for local human-facing clients without becoming a scheduler, verifier, decision maker, repository writer, or merge authority.

Its first canonical input contract is:

idkmesh-run-evidence-report / schema_version 0.1

Its primary output contract is:

idkmesh-control-tower-snapshot / schema_version 0.1

The corresponding JSON Schema is:

schemas/control-tower-snapshot-v0.1.schema.json

The API and browser UI consume the same Python service layer. Neither is a second source of project truth.

Versioning model

The HTTP prefix and object schema version are intentionally distinct:

HTTP API compatibility line: /api/v1
response schema version:      0.1

Additive compatible response fields may remain under /api/v1 with a new explicit object schema version only when clients can safely ignore them. Incompatible HTTP behavior requires a new URL version.

Unknown versions fail explicitly with:

unsupported_api_version

and return the supported version list.

Trust boundary

The service binds only to:

127.0.0.1

This is a local process boundary, not a claim that localhost is a complete sandbox against a compromised machine.

Every API request except GET /healthz, GET /readyz, and the HTML document itself requires:

X-IDKMesh-UI-Token: <session-token>

The default token is a random per-process value embedded only in the locally served browser page.

For intentional headless/local automation, callers may provide a stable token through:

IDKMESH_CONTROL_TOWER_TOKEN

The value must contain 32–4096 characters drawn only from ASCII letters, digits, -, ., _, and ~. Restricting the alphabet keeps the token safe for both HTTP-header use and embedding into the locally generated HTML/JS. It is never printed by the server. Environment injection exists so a local client can know the token without weakening the browser default.

The server also:

Enterprise service-runtime metadata

The Control Tower consumes the shared HTTP Service Runtime Baseline v0.1.

Every response carries bounded operational metadata:

X-Request-ID
X-IDKMesh-Service
X-IDKMesh-Service-Version
X-IDKMesh-API-Version
X-IDKMesh-Read-Only

A safe caller-provided X-Request-ID is echoed for correlation. Invalid or oversized values are replaced rather than reflected. Request IDs are not user identity, authorization, or evidence.

Optional access logging is enabled with:

IDKMESH_HTTP_ACCESS_LOG=1

Each log line is compact JSON containing request ID, method, path, response status/size, duration, service, and event time. Query strings are stripped and the logging API does not accept request bodies, authorization/session headers, prompts, or secret material. Logging failure does not turn an otherwise valid read-only response into an application failure.

Authority invariant

The Control Tower is presentation and inspection only.

A Run Evidence Report is refused unless its authority object is exactly:

{
  "canonical_state_write": false,
  "git_push": false,
  "merge": false,
  "automatic_candidate_selection": false
}

and its human decision remains exactly:

{
  "status": "pending",
  "selected_attempt_id": null,
  "integration_authority": "external_human_or_governance"
}

v0.1 therefore cannot:

Those capabilities require separately reviewed contracts and authority.

Media types

Requests to the inspection endpoint may use either:

application/json
application/vnd.idkmesh.control-tower.v1+json

Clients may request either through Accept.

If the vendor type is explicitly requested, the server responds with that vendor media type. Otherwise it returns ordinary application/json.

Unsupported response media types return HTTP 406 with not_acceptable.

Unsupported request media types return HTTP 415 with unsupported_media_type.

API responses include:

Vary: Accept
X-IDKMesh-API-Version: v1
X-IDKMesh-Read-Only: true
X-IDKMesh-Content-Digest: sha256:<64-hex>
ETag: "<64-hex>"

The content digest is computed from the canonical JSON value using the same sorted-key/minified UTF-8 SHA-256 rule used by repository provenance code.

The API still sends Cache-Control: no-store; ETag/content-digest headers are integrity and deterministic-equality signals, not permission to cache sensitive evidence.

Determinism

The inspection endpoint is read-only and deterministic.

For the same valid input document and API implementation:

same Run Evidence Report
 -> same Control Tower snapshot
 -> same response body bytes
 -> same snapshot_digest
 -> same X-IDKMesh-Content-Digest
 -> same ETag

No timestamp, random ID, host information, or mutable repository state is added to the snapshot.

Endpoint discovery

GET /healthz

Unauthenticated process-liveness check:

ok

It reveals no evidence/project state. It is exempt from the concurrent-request cap and keeps answering 200 while the server drains, so liveness stays cheap under saturation (see Service limits).

Supported methods:

GET, HEAD

GET /readyz

Unauthenticated service-readiness check. It returns only service/API runtime metadata and no project, WorkUnit, run, evidence, secret, prompt, or identity state.

Representative shape:

{
  "status": "ready",
  "service": "idkmesh-control-tower",
  "service_version": "...",
  "mode": "local-read-only",
  "api_version": "v1"
}

Liveness and readiness are intentionally separate: a future service may be alive while a required runtime dependency is not ready. Today the one concrete difference is service pressure: GET /readyz is counted against the concurrent-request cap and answers 503 overloaded at the cap or 503 shutting_down while draining, each with Retry-After, so an orchestrator stops routing to an instance that cannot take work.

Frozen by schemas/idkmesh-readiness-v0.1.schema.json. Built by the shared idkmesh/service_runtime.py:readiness_document(), so any future IDKMesh HTTP service reuses this same contract rather than defining its own.

Supported methods:

GET, HEAD

GET /api/v1/status

Authenticated discovery document.

The response includes:

Representative shape:

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-control-tower-status",
  "ok": true,
  "service": "idkmesh-control-tower",
  "mode": "local-read-only",
  "media_types": [
    "application/json",
    "application/vnd.idkmesh.control-tower.v1+json"
  ],
  "capabilities": {
    "run_evidence_inspection": true,
    "semantic_timeline": true,
    "provenance_chain": true,
    "human_decision_recording": false,
    "worker_execution": false,
    "canonical_state_write": false,
    "git_push": false,
    "merge": false,
    "automatic_candidate_selection": false
  }
}

Supported methods:

GET, HEAD

GET /api/v1/openapi.json

Authenticated OpenAPI 3.1 discovery document.

It describes:

This endpoint is generated from the same installed Python API constants as the server behavior so client discovery is not maintained as a detached static copy.

Supported methods:

GET, HEAD

POST /api/v1/run-evidence/inspect

Authenticated, read-only inspection endpoint.

Body:

one complete idkmesh-run-evidence-report v0.1 JSON document.

No query parameters are accepted in v1.

The service rejects:

Successful response envelope:

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-control-tower-inspection-response",
  "ok": true,
  "snapshot_digest": "sha256:...",
  "snapshot": {
    "kind": "idkmesh-control-tower-snapshot",
    "schema_version": "0.1"
  }
}

Supported method:

POST

Wrong methods return HTTP 405 with an Allow header. HEAD on a POST-only resource also returns no body.

GET /api/v1/runs

Authenticated, read-only Product Spine run listing (issue #739). Serves the same idkmesh/product_spine_run_store.py:ProductSpineRunStore.list() application service idkmesh run list already uses, deterministically ordered by run_id with keyset (not offset) pagination – API Conventions v0.1 sections 10-11.

This is one of two Control Tower endpoints that accept query parameters (the other is GET /api/v1/work-units):

Any other query parameter, or any of these repeated, is rejected with 400 unexpected_query_parameters (API Conventions v0.1 section 11: an unknown filter fails explicitly rather than being silently ignored). An out-of-range limit is rejected with 400 invalid_limit; an unrecognized state value is rejected with 400 invalid_state rather than silently matching zero rows. Like GET /api/v1/runs/{run_id}, this endpoint returns 503 product_spine_store_not_configured when this server instance was not started with --product-spine-store.

state and project_id combine with AND, not OR: passing both narrows to runs matching both.

The list contains Product Spine runs only, selected by an explicit set of stored row kinds (product-spine-cli-run, created by idkmesh run create, and product-spine-idempotency-result, the completed rows of the idempotent offline Product Spine). The shared runs table also holds admission-only, execution-error and GitHub dispatch/status rows; those are not Product Spine runs and are excluded deterministically, including from keyset pagination (ADR-0024). Before this rule a single non-Product-Spine row in the store made the whole page fail; that defect is fixed. A row of a listed kind that does not restore still fails loudly rather than being skipped.

Representative shape:

{
  "kind": "idkmesh-list",
  "schema_version": "0.1",
  "items": [
    {
      "schema_version": "0.1",
      "kind": "idkmesh-product-spine-run",
      "run_id": "...",
      "state": "proposed"
    }
  ],
  "page": {
    "next_cursor": null,
    "limit": 50
  }
}

Frozen by the cross-cutting schemas/idkmesh-list-v0.1.schema.json (API Conventions v0.1 section 10) wrapping schemas/idkmesh-product-spine-run-v0.1.schema.json items – no new envelope schema was needed for this endpoint.

Supported methods:

GET, HEAD

GET /api/v1/runs/{run_id}

Authenticated, read-only Product Spine run projection (issue #739, the first slice of the API-4 read model). Serves the same idkmesh/product_spine_run_store.py:ProductSpineRunStore.status() application service idkmesh run status already uses – the CLI and this endpoint read through one service, never two.

This server instance only exposes it when started with --product-spine-store PATH (or create_server(..., product_spine_store_path=...) programmatically); otherwise every request returns 503 with code product_spine_store_not_configured.

run_id may itself contain / (Product Spine run_ids share WorkUnit’s identifier grammar). ADR-0019 reserves attempts, evidence, and decisions as the only recognized trailing path segments: a path ending in /attempts, /evidence, or /decisions (with at least one character before it) always resolves as that sub-resource of the remaining prefix, regardless of whether that derived run_id exists; every other path is read as one literal run_id. The accepted cost: a run_id that itself ends in one of those three literal suffixes can no longer be read through this plain single-run GET – see GET /api/v1/runs/{run_id}/attempts below.

Representative shape:

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-control-tower-run-response",
  "ok": true,
  "run": {
    "schema_version": "0.1",
    "kind": "idkmesh-product-spine-run",
    "run_id": "...",
    "state": "proposed"
  },
  "idempotency_key": "...",
  "create_request_digest": "sha256:...",
  "created": false,
  "replayed": false,
  "provider_execution_terminated": false,
  "candidate_accepted": false,
  "merge_authority": false
}

created and replayed are always false here: they describe a create call’s outcome, carried over unchanged from the shared PersistedProductSpineRun projection rather than given a second response shape for reads.

Unknown run_id returns 404 with code run_not_found.

Supported methods:

GET, HEAD

GET /api/v1/runs/{run_id}/attempts

Authenticated, read-only listing of one run’s worker/verifier attempts (issue #739’s attempts read surface). attempts is a reserved trailing path segment; see ADR-0019 and the note on the plain single-run GET above. Serves the same ProductSpineRunStore.status() application service as the plain read, then projects run.attempts.

This server instance only exposes it when started with --product-spine-store PATH; otherwise every request returns 503 with code product_spine_store_not_configured.

Representative shape:

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-control-tower-run-attempts-response",
  "ok": true,
  "run_id": "run/example-1",
  "attempts": [
    {
      "attempt_id": "attempt-1",
      "order": 1,
      "connector_id": "connector.example",
      "state": "created",
      "provider_reference": null,
      "candidate_reference_digest": null,
      "result_manifest_digest": null,
      "verification_semantic_digest": null,
      "error_code": null
    }
  ]
}

Unknown run_id returns 404 with code run_not_found, including when the derived run_id (path minus the /attempts suffix) does not exist.

Supported methods:

GET, HEAD

GET /api/v1/runs/{run_id}/evidence

Authenticated, read-only retrieval of the Run Evidence Report retained in one run’s row (issue #739’s evidence read surface; ADR-0024). evidence is a reserved trailing path segment (ADR-0019). The idempotent offline Product Spine retains the full report in the run row next to the projection whose evidence_report_digest it must equal; this endpoint serves that report, so a client can reconstruct a run and its evidence without repository files and without supplying the evidence document.

The report is returned as retained, never synthesised. Before it is served the service verifies that the run projection is a valid Product Spine run and that canonical_digest(report) equals evidence_report_digest. On a mismatch the report is not served: the response is 500 evidence_integrity_error (fail closed).

This server instance only exposes it when started with --product-spine-store PATH; otherwise every request returns 503 with code product_spine_store_not_configured. The endpoint accepts no query parameters.

Representative shape (the report is abbreviated):

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-control-tower-run-evidence-response",
  "ok": true,
  "run_id": "run/example-1",
  "evidence_report_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
  "evidence_report": {
    "kind": "idkmesh-run-evidence-report",
    "schema_version": "0.1"
  }
}

Errors are distinct so a client can tell them apart:

Condition Status Code
no stored run has this run_id 404 run_not_found
the run exists but retains no evidence (a run created by idkmesh run create, or one that has not reached evidence_ready) 404 evidence_not_available
the retained report does not match the run’s evidence_report_digest, or the projection is not a valid Product Spine run 500 evidence_integrity_error

GET /api/v1/runs/{run_id}/decisions is still not built and answers the generic 404 not_found. It needs a retained decision store and an authenticated, accountable human or governance principal (issue #740 and the enterprise identity work, issue #670); a local session token is not an accountable person, so this is a governance gate and not an implementation detail.

Supported methods:

GET, HEAD

GET /api/v1/work-units

Authenticated, read-only WorkUnit listing (issue #739, the work-units read surface). A derived read model (ADR-0021): no WorkUnit store exists, so each item is computed on demand from the WorkUnit reference (id, version, digest, source_revision) that every retained Product Spine run already stores. Serves the same idkmesh/product_spine_run_store.py:ProductSpineRunStore.list_work_units() application service idkmesh work-unit list uses.

Items are one per distinct WorkUnit id, ordered by id ascending, with keyset (not offset) pagination – API Conventions v0.1 sections 10-11.

Query parameters (every other endpoint except GET /api/v1/runs still rejects any query string):

Any other query parameter, or any of these repeated, is rejected with 400 unexpected_query_parameters. An out-of-range or non-integer limit is 400 invalid_limit; a cursor this service did not issue is 400 invalid_cursor. Without --product-spine-store the endpoint returns 503 product_spine_store_not_configured.

What an item does and does not say:

Representative shape:

{
  "kind": "idkmesh-list",
  "schema_version": "0.1",
  "items": [
    {
      "id": "work/a",
      "run_count": 3,
      "revisions": [
        {
          "version": 1,
          "digest": "sha256:...",
          "source_revision": "0123456789abcdef0123456789abcdef01234567",
          "run_count": 2
        },
        {
          "version": 2,
          "digest": "sha256:...",
          "source_revision": "0123456789abcdef0123456789abcdef01234567",
          "run_count": 1
        }
      ]
    }
  ],
  "page": {
    "next_cursor": null,
    "limit": 50
  }
}

Frozen by schemas/idkmesh-list-v0.1.schema.json wrapping schemas/idkmesh-work-unit-resource-v0.1.schema.json items.

Counts are consistent per request only: a run created between two page reads can change them, the same caveat GET /api/v1/runs carries.

Supported methods:

GET, HEAD

GET /api/v1/work-units/{work_unit_id}

Authenticated, read-only read of one derived WorkUnit resource, through the same service as idkmesh work-unit status.

WorkUnit ids share the run-id grammar and may contain /, so the entire path remainder is one literal id (/api/v1/work-units/work/nested/b reads the id work/nested/b). v0.1 reserves no work-unit sub-resource suffix; adding one needs a new ADR extending ADR-0019’s path-only resolution rule. Query parameters are rejected with 400 unexpected_query_parameters.

Representative shape:

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-control-tower-work-unit-response",
  "ok": true,
  "work_unit": {
    "id": "work/a",
    "run_count": 1,
    "revisions": [
      {
        "version": 1,
        "digest": "sha256:...",
        "source_revision": "0123456789abcdef0123456789abcdef01234567",
        "run_count": 1
      }
    ]
  }
}

An id no stored run references returns 404 with code work_unit_not_found. That states only that the retained runs do not mention it, not that the WorkUnit does not exist elsewhere. Without --product-spine-store the endpoint returns 503 product_spine_store_not_configured.

Frozen by schemas/idkmesh-control-tower-work-unit-response-v0.1.schema.json, which references schemas/idkmesh-work-unit-resource-v0.1.schema.json.

Both work-unit endpoints answer 405 with Allow: GET, HEAD to any write method.

Supported methods:

GET, HEAD

GET /api/v1/projects/{project_id}

Authenticated, read-only project summary (issue #739, the projects read surface), through the same service as idkmesh project status. A derived read model (ADR-0021): no project record exists, so this is counts over the stored Product Spine runs that name the project, and nothing else.

project_id is not constrained beyond being non-empty and may contain /, so the entire path remainder is one literal id (/api/v1/projects/org/team/project reads org/team/project). Matching is exact. v0.1 reserves no project sub-resource suffix and defines no project list. Query parameters are rejected with 400 unexpected_query_parameters.

Representative shape (all 14 canonical states are always present; only two are shown):

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-control-tower-project-response",
  "ok": true,
  "project": {
    "project_id": "project.alpha",
    "run_count": 3,
    "runs_by_state": {
      "proposed": 2,
      "cancelled": 1
    },
    "work_unit_count": 2
  }
}

runs_by_state is zero-filled over every canonical run state, so the shape is identical for every project, and its values sum to run_count. work_unit_count is the number of distinct WorkUnit ids those runs reference; list them with GET /api/v1/work-units?project_id=. The summary carries no health, status, or score rollup and selects nothing.

A project no stored run names returns 404 with code project_not_found. That states only that the retained runs do not mention it, not that the project does not exist elsewhere. Without --product-spine-store the endpoint returns 503 product_spine_store_not_configured. Any write method answers 405 with Allow: GET, HEAD.

Frozen by schemas/idkmesh-control-tower-project-response-v0.1.schema.json, which references schemas/idkmesh-project-resource-v0.1.schema.json.

Supported methods:

GET, HEAD

GET /api/v1/events

Authenticated, read-only history of the canonical event source (ADR-0023, issue #741), through the same service as idkmesh events list. Events live in an append-only events table of the Product Spine store and are written in the same SQLite transaction as the run change they record, so a committed state change and its event either both exist or neither does.

Coverage is stated, not implied: in v0.1 only Product Spine run create and run cancel emit events (run.created, run.cancelled). The offline idempotent spine and the GitHub dispatch/status writers emit none yet, so this endpoint is canonical for the events that exist and makes no claim of completeness over every writer to the shared runs table. Nothing prunes the table.

Response: the idkmesh-list-v0.1 envelope, items ordered by sequence ascending, each item an idkmesh-event envelope. Query parameters (anything else, or a duplicate, is 400 unexpected_query_parameters):

Parameter Meaning
limit page size, 1-200 (default 50); otherwise 400 invalid_limit
cursor opaque token from the previous page’s page.next_cursor; a token this listing did not issue, including a forged one that does not carry a bounded ASCII-decimal sequence (at most 18 digits), is 400 invalid_cursor
project_id, run_id, work_unit_id exact match; a blank value is 400 invalid_filter rather than silently matching nothing
event_type exact match, one of run.created, run.cancelled; any other value is 400 invalid_event_type rather than silently matching nothing

Scoped run and work-unit queries are these filters. No new run sub-resource suffix is reserved, so ADR-0019 is unchanged.

Representative event:

{
  "schema_version": "0.1",
  "kind": "idkmesh-event",
  "event_id": "evt-000000000002",
  "sequence": 2,
  "occurred_at": "2026-10-02T09:15:00Z",
  "event_type": "run.cancelled",
  "principal": {"type": "unauthenticated_local", "id": "local-cli"},
  "authority_class": "local_control",
  "project_id": "project.alpha",
  "work_unit_id": "work/a",
  "run_id": "run/alpha-1",
  "attempt_id": null,
  "source_revision": "0123456789abcdef0123456789abcdef01234567",
  "evidence_reference": null,
  "payload": {"previous_state": "proposed", "state": "cancelled"},
  "payload_digest": "sha256:<64 hex of the canonical payload JSON>"
}

Field rules:

An idempotent replay of run create emits nothing, so a duplicate emission cannot occur at the source. The events table is append-only by construction: SQLite triggers abort any UPDATE or DELETE of an event row, and event_id is derived from sequence on read rather than stored, so it cannot drift from it.

Without --product-spine-store the endpoint returns 503 product_spine_store_not_configured. Any write method answers 405 with Allow: GET, HEAD. Frozen by schemas/idkmesh-event-v0.1.schema.json; the list envelope is schemas/idkmesh-list-v0.1.schema.json.

Supported methods:

GET, HEAD

GET /api/v1/events/stream

Authenticated, read-only Server-Sent Events stream of the same events. It is a GET only; WebSocket is deferred because nothing needs a bidirectional channel.

Each message:

id: 2
event: run.cancelled
data: {"schema_version":"0.1","kind":"idkmesh-event", ... }

Example:

curl -N \
  -H "X-IDKMesh-UI-Token: $IDKMESH_CONTROL_TOWER_TOKEN" \
  -H "Accept: text/event-stream" \
  -H "Last-Event-ID: 41" \
  "http://127.0.0.1:8770/api/v1/events/stream?run_id=run/alpha-1"

Retention: v0.1 keeps every event. If a pruner is ever added, a cursor or Last-Event-ID below the retained floor must answer 410 cursor_expired, never a silent gap. This is a declared contract, not a built feature.

Supported methods:

GET

Snapshot semantics

The snapshot is a deterministic projection, not a decision object.

Source binding

The snapshot retains:

source.evidence_report_digest is the canonical digest of the exact complete Run Evidence Report document presented to the API.

That field is intended to support the later human-decision slice, whose decision record must bind to immutable evidence rather than a filename.

Summary

The API independently recomputes from attempts[]:

The report’s own summary is not trusted merely because it is present. Any mismatch fails closed.

Human attention

The current projection can surface:

Attention items explain why a human should inspect something. They are not scores, rankings, or automatic selections.

Attempt layers

Every attempt keeps separate UI/API layers:

worker claim
  -> independent verifier evidence/recommendation
  -> human authority remains pending

The API deliberately avoids collapsing those meanings into one pass/fail field.

Provenance chain

The snapshot exposes deterministic provenance for:

WorkUnit digest
 -> worker identity
 -> ResultManifest ID/digest
 -> verifier identity
 -> VerificationResult semantic digest
 -> required check outcomes
 -> pending human/governance authority

The projection also retains:

Identity distinction and independence remain different concepts.

When worker/verifier identities differ:

identity_distinct_from_worker = true

but the API explicitly does not infer statistical, organizational, model-family, or execution independence from different names alone.

When the same identity appears as worker and verifier, the Control Tower raises a human-attention condition instead of calling the result independent.

Semantic timeline

Run Evidence Report v0.1 is not a complete wall-clock event stream.

The API therefore derives deterministic sequence events only:

It does not fabricate timestamps.

Published JSON Schema

The response snapshot is frozen by:

schemas/control-tower-snapshot-v0.1.schema.json

The schema constrains:

Focused tests validate the generated sample snapshot against this exact schema.

The status document (GET /api/v1/status) and the inspection success envelope (POST /api/v1/run-evidence/inspect) are frozen the same way:

Focused tests validate status_document()’s and success_document()’s real return values against these schemas, not only a hand-written example.

The run-read response (GET /api/v1/runs/{run_id}) is frozen by two schemas, since the wrapper envelope and the nested run projection are separately reusable contracts (the run projection is also what idkmesh run create/status/cancel --json prints):

tests/test_control_tower.py validates both against a real run created through the same ProductSpineRunStore the CLI uses, and separately asserts the HTTP response is byte-for-byte identical to what the CLI’s run status --json would print for the same run.

The run-attempts response (GET /api/v1/runs/{run_id}/attempts) is frozen by:

The run-evidence response (GET /api/v1/runs/{run_id}/evidence) is frozen by:

It references the existing schemas/run-evidence-report-v0.1.schema.json for the retained report.

The derived WorkUnit resource and its single-read response (GET /api/v1/work-units, GET /api/v1/work-units/{work_unit_id}) are frozen by:

The list endpoint reuses schemas/idkmesh-list-v0.1.schema.json.

The derived project summary and its single-read response (GET /api/v1/projects/{project_id}) are frozen by:

The canonical event envelope returned by GET /api/v1/events (as list items) and carried in the data: field of GET /api/v1/events/stream is frozen by:

Error envelope

All API JSON errors use the shape frozen by schemas/idkmesh-api-error-v0.1.schema.json (cross-cutting for every IDKMesh product API, not specific to Control Tower — see API Conventions v0.1 section 5):

{
  "api_version": "v1",
  "schema_version": "0.1",
  "kind": "idkmesh-api-error",
  "ok": false,
  "error": {
    "code": "invalid_run_evidence",
    "message": "human-readable explanation",
    "retryable": false
  }
}

Optional structured error.details may be included when a machine-readable recovery hint exists, such as supported API versions.

error.retryable is false for every code below except the three 503 capacity responses overloaded, too_many_streams and shutting_down, which carry retryable: true, a Retry-After header and error.details.retry_after_seconds.

Stable v0.1 codes include:

Service limits

The Control Tower is a stdlib development server bound to loopback. Its limits (ADR-0022, issue #742) are published at operations.limits on GET /api/v1/status, validated by schemas/idkmesh-control-tower-status-v0.1.schema.json, and each is proven by tests/test_control_tower_limits.py.

Limit Default Enforcement Proven by
Request timeout 10 s (--request-timeout, 0.1-300) socket timeout on every connection; a stalled request line or headers drop the connection, a stalled body answers 408 request_timeout SlowClientTests
Concurrent requests 16 (--max-concurrent-requests, 1-1024) non-blocking RequestLimiter; beyond the cap 503 overloaded + Retry-After, no application work, connection closed OverloadTests, RequestLimiterTests
Concurrent evidence inspection same general cap independent POST /api/v1/run-evidence/inspect requests execute concurrently inside the cap and remain deterministic for the same evidence ConcurrentInspectionTests
Queue listen backlog 16 request_queue_size; there is no application wait queue OverloadTests
Retry hint Retry-After: 1 on every 503 from the cap or a drain; the error body also carries retryable: true OverloadTests, DrainTests
Graceful drain 5 s ControlTowerServer.drain(): new requests 503 shutting_down, then waits for in-flight requests; serve_control_tower calls it on shutdown DrainTests
Request line 65536 bytes stdlib parser, 414 above ParserBoundTests
Header line 65536 bytes stdlib parser, 431 above ParserBoundTests
Header fields 99 stdlib parser, 431 at 100 (the stdlib allows 100 lines but counts the blank line ending the header block) ParserBoundTests
Request body 2 MiB 413 payload_too_large; one global cap, only POST /api/v1/run-evidence/inspect reads a body; an oversized declared length is rejected before body read RequestBodyBoundTests
Connections one request each stdlib HTTP/1.0 default; no keep-alive ConnectionPolicyTests
Client cancellation/disconnect n/a an admitted request always releases its limiter slot in finally; interrupted work is not retried, and a later request can immediately reuse the capacity CancellationTests
SSE clients 8 (--max-sse-clients, 1-64) a separate non-blocking RequestLimiter for GET /api/v1/events/stream; beyond it 503 too_many_streams + Retry-After EventStreamLimitTests (tests/test_control_tower_events.py)
SSE heartbeat 15 s : keepalive comment on an idle stream EventStreamLimitTests (tests/test_control_tower_events.py)
SSE stream lifetime 300 s the server ends the stream; the client resumes with Last-Event-ID EventStreamLimitTests (tests/test_control_tower_events.py)
Per-client rate limit not implemented 429 is reserved; see below status per_client_rate_limit

Semantics:

Representative operations.limits:

{
  "request_timeout_seconds": 10.0,
  "max_concurrent_requests": 16,
  "retry_after_seconds": 1,
  "drain_timeout_seconds": 5.0,
  "max_request_line_bytes": 65536,
  "max_header_line_bytes": 65536,
  "max_header_count": 99,
  "max_request_body_bytes": 2097152,
  "max_sse_clients": 8,
  "sse_heartbeat_seconds": 15,
  "sse_max_stream_seconds": 300,
  "overload_status": 503,
  "connection_policy": "close_after_response",
  "per_client_rate_limit": "not_implemented"
}

HTTP method behavior

Known endpoints return HTTP 405 for unsupported methods and publish the allowed method set in Allow.

Valid /api/v1/* routes are token-authenticated before normal route behavior, so unauthenticated local callers cannot use method differences as a substitute for API access.

Cross-origin OPTIONS preflight is deliberately rejected because v0.1 is a same-origin local API.

PUT, PATCH, DELETE, TRACE, and CONNECT have no write or tunneling meaning in v0.1 and are rejected.

CLI and headless use

Browser mode:

idkmesh control-tower

Open a specific report:

idkmesh control-tower path/to/evidence-report.json

Headless server with a caller-known token:

export IDKMESH_CONTROL_TOWER_TOKEN='replace-with-at-least-32-random-characters'
idkmesh control-tower --no-browser --port 8770

Headless server that also serves the run, WorkUnit, and project read endpoints (GET /api/v1/runs, GET /api/v1/runs/{run_id}, GET /api/v1/runs/{run_id}/attempts, GET /api/v1/work-units, GET /api/v1/work-units/{work_unit_id}, GET /api/v1/projects/{project_id}, GET /api/v1/events, GET /api/v1/events/stream) over an existing Product Spine store (the same file idkmesh run create/status/cancel already writes to):

idkmesh control-tower --no-browser --port 8770 \
  --product-spine-store path/to/product-spine.sqlite3

Tune the service limits (both are validated before the port is bound):

idkmesh control-tower --no-browser --port 8770 \
  --request-timeout 5 --max-concurrent-requests 8 --max-sse-clients 4

Read the same store’s derived WorkUnits and project summaries from the CLI:

idkmesh work-unit list --store path/to/product-spine.sqlite3
idkmesh work-unit status work/a --store path/to/product-spine.sqlite3
idkmesh project status project.alpha --store path/to/product-spine.sqlite3

Read the canonical event history from the CLI (the same service as GET /api/v1/events):

idkmesh events list --store path/to/product-spine.sqlite3
idkmesh events list --store path/to/product-spine.sqlite3 \
  --run-id run/alpha-1 --event-type run.cancelled --json

Example status request:

curl \
  -H "X-IDKMesh-UI-Token: $IDKMESH_CONTROL_TOWER_TOKEN" \
  -H "Accept: application/json" \
  http://127.0.0.1:8770/api/v1/status

The environment token is an ephemeral local API session credential. It is not a provider secret or repository credential and should not be committed.

Relationship to Gate Audit

gate-audit-ui and control-tower are complementary.

Gate Audit asks:

How much independent evidence does this verifier panel actually provide?

Control Tower asks:

What happened in this multi-attempt run, what evidence exists, where is there disagreement/failure, how is it bound by provenance, and what still needs a human decision?

Both local UIs share one browser-security helper while retaining separate domain contracts.

Interpretation rules

Every client must preserve:

worker success != verified correctness
verifier recommendation != human integration decision
multiple recommendations != majority truth
replay equality != correctness
identity distinction != independence

Next compatible extensions

Issue #739’s only unshipped read surface is GET /api/v1/runs/{run_id}/decisions. /evidence is shipped (ADR-0024): the idempotent offline spine already retains the digest-verified report in the run row. /decisions stays blocked on issue #740: no decision content is retained anywhere, and recording one needs an authenticated, accountable human or governance principal, which is a governance gate.

Issue #741’s canonical event source, history query and resumable SSE stream are shipped (ADR-0023). Not yet built: events for the other writers of the shared runs table (offline spine, GitHub dispatch and status), further event types (attempts, evidence, verification, human decisions), an enterprise principal on principal, a retention pruner with its 410 cursor_expired contract, and a production transport adapter that would replace the poll-based SSE loop.

Issue #572 defines later read-first slices:

  1. immutable human-decision recording;
  2. a Control Tower timeline built from the canonical event source (the event source itself exists; the UI still derives its timeline from the posted report);
  3. verification-debt/capacity;
  4. participant/resource/authority matrix;
  5. IDKGraph repository health;
  6. goal/uncertainty views;
  7. collaboration/scientific-evidence views.

Write/actuation endpoints are explicitly outside v0.1.