Status: Accepted Date: 2026-09-27
Issue #739 (API-4) targets, among other read surfaces, GET
/api/v1/runs/{run_id}/attempts, /evidence, and /decisions alongside the
already-shipped GET /api/v1/runs/{run_id} (PR #889) and GET /api/v1/runs
(PR #890, PR #891).
idkmesh/product_spine.py’s ProductSpineRun.run_id and the
idkmesh-product-spine-run-v0.1.schema.json schema place no constraint on
run_id beyond minLength: 1 – it is caller-supplied and, in practice,
shares WorkUnit’s identifier grammar, which legitimately contains / (the
shipped test fixtures and this session’s own smoke tests use ids such as
run/http-read-1). idkmesh/control_tower_ui.py’s _run_id_from_path
docstring already flagged this as an open ambiguity when it shipped the
single-run GET in PR #889: a path-nested sub-resource under an
arbitrary-content run_id cannot in general tell where the run_id ends
and the sub-resource name begins, and
tests/test_control_tower.py::ControlTowerRunReadTests
::test_run_id_containing_a_slash_is_read_as_one_literal_id pinned the
interim behavior (take everything after the prefix as one literal id, no
sub-resource) as a regression guard until this was resolved.
docs/specifications/CONNECTOR_CONTROL_API_V0_1.md (an aspirational,
not-yet-implemented specification for a different, future control plane)
already establishes the colon-suffix shape ({run_id}:cancel,
{connection_id}:probe) for this repository’s mutation/action verbs, but it
uses that shape only for POST custom actions – its own GET
/api/v1/runs/{run_id}/events sub-collection uses plain path nesting, the
same shape API-4 wants for attempts/evidence/decisions. Colon-suffix is
therefore not the right precedent to reuse here: it signals a mutation verb,
not a read sub-collection, and reusing it for a GET would blur that
distinction across the two control planes ADR-0016 already requires to
converge on shared semantics.
Percent-encoding the run_id segment (%2F for an embedded /) is the
general HTTP-correct answer, but it pushes real complexity onto every
client of a purely local, single-tenant development read API to protect
against a collision that the same caller who names runs can simply avoid.
Reserve attempts, evidence, and decisions as the only recognized
trailing path segments under /api/v1/runs/{run_id}/. Resolution rule,
applied deterministically and independent of what a store currently holds
(ADR-0016’s “no hidden winner selection” carried into this shape):
/api/v1/runs/./attempts, /evidence, /decisions
and has at least one character before that suffix, the run_id is
everything before the suffix and the sub-resource is the suffix name.This is a total function of the path string alone – it never depends on whether a run with that derived id exists, so routing is deterministic and “run not found” (404) is the only error a caller can hit from an unknown or mis-shaped id, never a silently different resource.
The cost this accepts: a run_id that itself ends in exactly /attempts,
/evidence, or /decisions can no longer be read via the plain
single-run GET – that path now always resolves to the sub-resource. This
is a documented, narrow, caller-avoidable naming collision (three reserved
literal suffixes, not a reserved character), not the general percent-encoding
problem.
GET /api/v1/runs/{run_id}/attempts (this decision’s first consumer) and
the still-unbuilt /evidence and /decisions slices can ship without
re-litigating URL shape per endpoint;run_id
that does not end in a reserved suffix, which is every id this repository
itself generates).run_id values ending in /attempts, /evidence, or /decisions are
now unreadable via the plain single-run GET; callers minting run ids
must avoid those three literal suffixes, and this restriction must be
documented wherever run_id is described as free-form;tests/test_control_tower.py’s interim regression guard
(test_run_id_containing_a_slash_is_read_as_one_literal_id) is superseded
by this decision and rewritten to assert the resolved routing instead of
the old “always one literal id” placeholder behavior.run_id in sub-resource URLs, require raw / only as a real path separatorRejected for this development-profile, single-tenant read API. It is the
theoretically clean answer and remains available to a future network/
multi-user deployment profile (ADR-0016) if that profile’s run_id grammar
still allows embedded /; nothing in this decision forecloses adopting it
later.
Rejected. That shape already carries a specific meaning in this
repository’s own aspirational connector-control specification –
POST custom actions, not GET sub-collections – and its own /events
sub-collection already avoids it. Reusing it for a read would create two
different conventions for the same syntax across the converging API
surfaces ADR-0016 requires.
Rejected. Issue #739 lists attempts, evidence, and decisions as target
read surfaces with real, already-shipped data behind them
(ProductSpineRun.attempts ships today); deferring indefinitely would block
real, grounded feature work over a narrow, documentable edge case.
attempts, evidence, and decisions slices.idkmesh/control_tower_ui.py’s _run_id_from_path (and any successor
resolver function) implements the rule in section “Decision” above.Revisit this ADR if:
run_id values that legitimately end in one of
the three reserved suffixes and cannot rename around it;Any revision must preserve ADR-0016’s authority invariant and issue #739’s “no hidden winner selection” requirement – routing must stay a pure function of the request path.