11.1.1 Decision — the `/api/v1` contract: path versioning, bearer-PAT auth + scope mapping, `{ code, error }` errors, opaque-cursor pagination, rate limits, resource naming, deprecation policy
Repo: motir-core. One PR — a docs-only diff (docs/decisions/**), so branch as docs/MOTIR-<id>-<slug> (CI skips the E2E + integration lanes; the branch prefix follows the DIFF, not the card type).
Records the /api/v1 contract as an ADR before any route exists, because every endpoint in 11.2 / 11.3 inherits these choices and re-deciding them per-endpoint is how a public API becomes inconsistent. This is also the citable artifact later cards point at — a card that says "per the public-API conventions" must be able to resolve that to a real file.
The QUESTION
What exactly does Motir promise a third-party HTTP client: how is the API versioned, how does a caller authenticate and what may each token do, what does a collection response look like, what does a failure look like, what limits apply, how are resources named, and what does v1 guarantee over time?
The RECOMMENDATION, per axis, with its evidence
Each row is a decision to RECORD, not to re-open. Rung-1 evidence was gathered by reading both mirrors' published API docs during the planning pass (2026-07-29), and rung-2 by grepping origin/main — cite both in the ADR.
| Axis | Decision | Grounding |
|---|---|---|
| Versioning | Path-versioned: every route under /api/v1. A breaking change mints /api/v2; v1 then keeps working. | Rung 1: GitLab — "the path must start with /api/v4"; Plane — https://api.plane.so/api/v1/. Neither header- nor query-versions. |
| Auth | Authorization: Bearer motir_pat_…, resolved by the SHIPPED authenticateApiToken. No second auth mechanism — no X-API-Key, no query token, no cookie fallback. | Rung 2: lib/apiTokens/routeAuth.ts already does exactly this on a REST route, and /api/mcp uses the same header. Rung 1: Plane accepts Authorization: Bearer for OAuth; GitHub uses bearer PATs. A cookie fallback is rejected on purpose — it would make CSRF a concern on a surface that has none. |
| Scopes | Reuse TokenScope verbatim; map per operation, not per resource. Reads → read; work-item writes → work_items:write; archive/restore → work_items:archive; sprint writes + membership → sprints:write. work_items:delete stays unexposed in v1's first cut. | Rung 2: lib/mcp/scopes.ts — the set is shipped and typed total, and TOOL_SCOPES is the per-operation precedent. |
| Errors | { code, error } + the HTTP status. code is a stable machine identifier (never localized, never reworded); error is a human sentence. 401 undifferentiated for missing/malformed/unknown/revoked/expired; 403 for scope refusal; 404-not-403 for cross-tenant; 422 for validation. | Rung 2: app/api/work-items/[id]/route.ts returns { code: err.code, error: err.message } — the established convention; and the 404-not-403 cross-tenant rule is the product's existing behaviour, which the public API must not weaken into an existence oracle. |
| Pagination | Opaque cursor: ?cursor=&limit=, default 50, hard max 100; response carries the next cursor. No offset pagination. | Rung 1: Plane is cursor-based with a 100 max; GitLab's keyset mode returns X-NEXT-CURSOR. Rung 2: lib/mcp/searchCursor.ts already encodes opaque cursors. Offset is rejected because Motir's collections mutate under concurrent writes, where offset silently skips and duplicates rows. |
| Rate limits | Per TOKEN (not per IP, not per user), returning X-RateLimit-Limit / -Remaining / -Reset on every response and 429 when exceeded. Pick and record the initial budget. | Rung 1: Plane enforces 60 req/min per key with those headers. Per-token so one integration cannot exhaust another's budget. |
| Resource naming | Plural, hyphenated nouns, scoped by their parent: /api/v1/projects/{projectKey}/work-items, /api/v1/sprints/{sprintId}/work-items. work-items, never issues — the product noun is work item. Keys are the MOTIR-<n> identifier, not the internal cuid. | Rung 1: Plane scopes by /workspaces/{slug}/projects/… and calls them work items. Rung 2: the terminology rename already shipped product-wide. |
| Stability | Additive-only within v1: new fields and new endpoints may appear; a field is never removed, renamed or re-typed without a new major. Deprecation is documented + announced before removal. | Rung 1: GitLab's v4 has held for years on exactly this promise — the property that makes an API integrable. |
What it GATES
11.1.2 (the route wrapper) is blocked_by this card; every other endpoint story cites the ADR rather than re-deriving a convention. If a later story finds a convention here to be wrong, that is an ADR amendment card — not a per-endpoint deviation.
Scope BOUNDARY
A docs-only deliverable — one ADR file. It writes NO code, NO route, NO test, and does not create the /api/v1 directory. It does NOT decide anything about the OpenAPI spec's tooling (11.4 owns that), nor which resources exist (11.2 / 11.3), nor GraphQL (out of the epic entirely). It does NOT introduce a new auth mechanism or a new scope — if a v1 operation genuinely needs a scope the shipped set lacks, that is a separate card against lib/mcp/scopes.ts, flagged here, not decided here.
Acceptance criteria
- The ADR exists at
docs/decisions/public-api-conventions.md, following the format of the ADRs already in that directory. - Every axis in the table above is recorded as a DECISION with its rung-1 (mirror) and/or rung-2 (shipped-code) evidence cited by file path or doc URL — not asserted.
- The scope map is written as an explicit operation → scope table a route author can implement from without re-deciding.
- The error contract enumerates each status with the condition that produces it, and states that
codevalues are stable machine identifiers. - The rate-limit budget is a concrete number with its rationale, not "TBD".
- The stability section states what additive means and what forces a
v2. - Rejected alternatives are recorded with WHY: offset pagination,
X-API-Key, header/query versioning, a cookie fallback, and exposingwork_items:deletein the first cut. - No code, route or test file is touched by this PR.
Context refs
docs/decisions/— the ADR directory + its existing format.lib/apiTokens/routeAuth.ts—authenticateApiToken, the shipped bearer gate the auth decision ratifies.lib/mcp/scopes.ts—TOKEN_SCOPES/TOOL_SCOPES, the scope set and the per-operation mapping precedent.lib/mcp/searchCursor.ts— the opaque-cursor encoding the pagination decision adopts.app/api/work-items/[id]/route.ts— the{ code, error }envelope this ratifies.- Parent story: 11.1. Parent epic: the public REST API.