Contracts
bind-confirm.md
Contract: Bind Confirmation
Endpoint: POST /api/v1/tracker/bind-confirm/ Owner: spec-kitty-saas Consumer: spec-kitty CLI (SaaSTrackerClient.bind_confirm())
Request
POST /api/v1/tracker/bind-confirm/
Headers:
Authorization: Bearer <access_token>
X-Team-Slug: <team_slug>
Content-Type: application/json
Idempotency-Key: <uuid>
Body:
{
"provider": "linear",
"candidate_token": "cand_01HXYZ...",
"project_identity": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"slug": "my-project",
"node_id": "a1b2c3d4e5f6",
"repo_slug": null
}
}
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Normalized provider name |
candidate_token | string | Yes | Pre-bind token from resolution or inventory |
project_identity.uuid | string (UUID) | Yes | From ProjectIdentity |
project_identity.slug | string | Yes | From ProjectIdentity |
project_identity.node_id | string | Yes | From ProjectIdentity |
project_identity.repo_slug | string | No | User override from ProjectIdentity |
Idempotency-Key | header (UUID) | Yes | Prevents duplicate bindings on retry |
Response (200)
{
"binding_ref": "srm_01HXYZ...",
"display_label": "My Project (LINEAR-123)",
"provider": "linear",
"provider_context": {
"team_name": "Engineering",
"workspace_name": "Acme Corp"
},
"bound_at": "2026-04-04T08:32:00Z"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
binding_ref | string | No | Stable post-bind reference. Primary routing key. |
display_label | string | No | Human-readable label for display/caching |
provider | string | No | Normalized provider name |
provider_context | object | No | Provider-specific display metadata |
bound_at | string (ISO) | No | Timestamp of binding |
Error Responses
| Status | Error Code | Meaning |
|---|---|---|
| 400 | invalid_candidate_token | Token expired, invalid, or already consumed |
| 401 | unauthorized | Token expired/invalid (triggers refresh) |
| 409 | already_bound | Resource already bound to a different project |
| 429 | rate_limited | Rate limited (retry with backoff) |
Token expiry: If invalid_candidate_token is returned, the CLI should retry discovery once (re-call bind-resolve to get a fresh token) and re-attempt bind-confirm. If retry also fails, surface a clear error.
Client Method
def bind_confirm(
self,
provider: str,
candidate_token: str,
project_identity: dict[str, Any],
*,
idempotency_key: str | None = None,
) -> dict[str, Any]:
"""POST /api/v1/tracker/bind-confirm/"""
Contract Tests
- Verify POST method + path
/api/v1/tracker/bind-confirm/ - Verify request body includes
provider,candidate_token,project_identity - Verify
Idempotency-Keyheader is sent - Verify idempotency key is auto-generated (UUID4) if not provided
- Verify response parsed with
binding_ref,display_label,provider_context - Verify 400
invalid_candidate_tokenraises appropriate error - Verify 409
already_boundraises appropriate error
bind-resolve.md
Contract: Binding Resolution
Endpoint: POST /api/v1/tracker/bind-resolve/ Owner: spec-kitty-saas Consumer: spec-kitty CLI (SaaSTrackerClient.bind_resolve())
Request
POST /api/v1/tracker/bind-resolve/
Headers:
Authorization: Bearer <access_token>
X-Team-Slug: <team_slug>
Content-Type: application/json
Body:
{
"provider": "linear",
"project_identity": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"slug": "my-project",
"node_id": "a1b2c3d4e5f6",
"repo_slug": null
}
}
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Normalized provider name |
project_identity.uuid | string (UUID) | Yes | From ProjectIdentity |
project_identity.slug | string | Yes | From ProjectIdentity |
project_identity.node_id | string | Yes | From ProjectIdentity |
project_identity.repo_slug | string | No | User override from ProjectIdentity |
Response (200) — Exact Match
{
"match_type": "exact",
"candidate_token": "cand_01HXYZ...",
"binding_ref": "srm_01HXYZ...",
"candidates": [],
"display_label": "My Project (LINEAR-123)"
}
When match_type is "exact":
candidate_tokenis always presentbinding_refis non-null if a ServiceResourceMapping already exists (CLI can skip bind-confirm)binding_refis null if the match is confident but no mapping exists yet (CLI must call bind-confirm)
Response (200) — Multiple Candidates
{
"match_type": "candidates",
"candidate_token": null,
"binding_ref": null,
"candidates": [
{
"candidate_token": "cand_01HABC...",
"display_label": "My Project (LINEAR-123)",
"confidence": "high",
"match_reason": "project_slug matches existing mapping",
"sort_position": 0
},
{
"candidate_token": "cand_01HDEF...",
"display_label": "Backend API (LINEAR-456)",
"confidence": "medium",
"match_reason": "repo_slug partial match",
"sort_position": 1
}
],
"display_label": null
}
Ordering contract: candidates is sorted by sort_position (ascending). The host assigns sort_position deterministically: confidence descending, then display_label ascending within the same confidence tier. Ordering is stable for a given installation state.
Response (200) — No Match
{
"match_type": "none",
"candidate_token": null,
"binding_ref": null,
"candidates": [],
"display_label": null
}
Response Fields
| Field | Type | Nullable | Description |
|---|---|---|---|
match_type | string | No | "exact", "candidates", or "none" |
candidate_token | string | Yes | For exact match: the pre-bind token |
binding_ref | string | Yes | For exact match with existing mapping |
candidates | array | No | Empty for exact/none; populated for candidates |
candidates[].candidate_token | string | No | Pre-bind token for this candidate |
candidates[].display_label | string | No | Human-readable label |
candidates[].confidence | string | No | "high", "medium", or "low" |
candidates[].match_reason | string | No | Why this candidate matched |
candidates[].sort_position | integer | No | Zero-based stable ordinal |
display_label | string | Yes | For exact match: the display label |
Client Method
def bind_resolve(
self,
provider: str,
project_identity: dict[str, Any],
) -> dict[str, Any]:
"""POST /api/v1/tracker/bind-resolve/"""
Contract Tests
- Verify POST method + path
/api/v1/tracker/bind-resolve/ - Verify request body includes
providerandproject_identity - Verify exact match response parsing (with and without binding_ref)
- Verify candidates response parsing with sort_position ordering
- Verify none response parsing
- Verify candidates are sorted by sort_position in response
bind-validate.md
Contract: Binding Validation
Endpoint: POST /api/v1/tracker/bind-validate/ Owner: spec-kitty-saas Consumer: spec-kitty CLI (SaaSTrackerClient.bind_validate())
Request
POST /api/v1/tracker/bind-validate/
Headers:
Authorization: Bearer <access_token>
X-Team-Slug: <team_slug>
Content-Type: application/json
Body:
{
"provider": "linear",
"binding_ref": "srm_01HXYZ...",
"project_identity": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"slug": "my-project",
"node_id": "a1b2c3d4e5f6",
"repo_slug": null
}
}
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Normalized provider name |
binding_ref | string | Yes | The binding reference to validate |
project_identity.uuid | string (UUID) | Yes | From ProjectIdentity |
project_identity.slug | string | Yes | From ProjectIdentity |
project_identity.node_id | string | Yes | From ProjectIdentity |
project_identity.repo_slug | string | No | User override from ProjectIdentity |
Response (200 — Valid)
{
"valid": true,
"binding_ref": "srm_01HXYZ...",
"display_label": "My Project (LINEAR-123)",
"provider": "linear",
"provider_context": {
"team_name": "Engineering",
"workspace_name": "Acme Corp"
}
}
Response (200 — Invalid)
{
"valid": false,
"binding_ref": "srm_01HXYZ...",
"reason": "mapping_deleted",
"guidance": "The bound tracker resource no longer exists. Run `tracker bind --provider linear` to rebind."
}
| Field | Type | Nullable | Condition | Description |
|---|---|---|---|---|
valid | boolean | No | Always | Whether the binding_ref is still valid |
binding_ref | string | No | Always | Echo of the validated ref |
display_label | string | Yes | valid=true | Human-readable label |
provider | string | Yes | valid=true | Normalized provider name |
provider_context | object | Yes | valid=true | Provider-specific display metadata |
reason | string | Yes | valid=false | Machine-readable: mapping_deleted, mapping_disabled, project_mismatch |
guidance | string | Yes | valid=false | Human-readable guidance for CLI display |
Error Responses
| Status | Error Code | Meaning |
|---|---|---|
| 401 | unauthorized | Token expired/invalid (triggers refresh) |
| 429 | rate_limited | Rate limited (retry with backoff) |
Note: Invalid binding is not a 4xx error — it returns 200 with valid: false. The endpoint validates the ref's existence, not the request format.
Usage Contexts
1. tracker bind --bind-ref <ref>: Validates the CI-supplied ref before persisting to local config. 2. Error sharpening: When a real endpoint returns an ambiguous failure that might be a stale binding, the service layer may call bind-validate once to produce a clearer error message.
This endpoint is not called proactively on normal commands (status, push, pull, run). Stale binding detection for those paths is reactive from endpoint error responses.
Client Method
def bind_validate(
self,
provider: str,
binding_ref: str,
project_identity: dict[str, Any],
) -> dict[str, Any]:
"""POST /api/v1/tracker/bind-validate/"""
Contract Tests
- Verify POST method + path
/api/v1/tracker/bind-validate/ - Verify request body includes
provider,binding_ref,project_identity - Verify valid response parsed with display metadata
- Verify invalid response parsed with reason and guidance
- Verify both valid and invalid return 200 (not 4xx)
- Verify standard auth/rate-limit error handling
existing-endpoint-evolution.md
Contract: Existing Endpoint Evolution
Affected Endpoints: status, mappings, pull, push, run Owner: spec-kitty-saas (coordinated change) Consumer: spec-kitty CLI (SaaSTrackerClient existing methods)
Summary
All 5 existing tracker endpoints currently route by project_slug. After 062, they must also accept binding_ref as an alternative routing key. The SaaS host accepts either; the CLI sends whichever is available (binding_ref-first).
Wire Change
GET endpoints (status, mappings)
Current:
GET /api/v1/tracker/status/?provider=linear&project_slug=my-project
Updated (either key accepted):
GET /api/v1/tracker/status/?provider=linear&binding_ref=srm_01HXYZ
GET /api/v1/tracker/status/?provider=linear&project_slug=my-project
POST endpoints (pull, push, run)
Current:
{"provider": "linear", "project_slug": "my-project", ...}
Updated (either key accepted):
{"provider": "linear", "binding_ref": "srm_01HXYZ", ...}
{"provider": "linear", "project_slug": "my-project", ...}
Client Method Signature Changes
All methods change project_slug from required positional to optional, add keyword-only binding_ref:
# Before:
def status(self, provider: str, project_slug: str) -> dict[str, Any]:
# After:
def status(
self,
provider: str,
project_slug: str | None = None,
*,
binding_ref: str | None = None,
) -> dict[str, Any]:
At least one of project_slug or binding_ref must be provided. If both are provided, binding_ref takes precedence.
Stale Binding Error Response
When the host receives a binding_ref that maps to a deleted, disabled, or mismatched ServiceResourceMapping, it returns a PRI-12 error envelope with a specific error_code:
{
"error_code": "binding_not_found",
"message": "The binding reference srm_01HXYZ is no longer valid.",
"user_action_required": true
}
| Error code | Meaning |
|---|---|
binding_not_found | ServiceResourceMapping deleted |
mapping_disabled | Mapping exists but disabled |
project_mismatch | binding_ref doesn't match the authenticated project context |
The enriched SaaSTrackerClientError preserves these codes for the service layer to inspect.
Opportunistic Upgrade Response Enrichment
When the host routes a request by project_slug (legacy path) and can resolve the corresponding binding_ref, it includes binding_ref in the response alongside the normal payload:
{
"provider": "linear",
"connected": true,
"binding_ref": "srm_01HXYZ...",
"display_label": "My Project (LINEAR-123)",
...existing fields...
}
This is optional — the host returns these fields when available. The CLI's _maybe_upgrade_binding_ref() checks for their presence.
Contract Tests
For each of the 5 existing endpoints:
- Verify
binding_refquery param / body field is sent when provided - Verify
project_slugquery param / body field is sent when binding_ref is absent - Verify stale-binding error codes are preserved in
SaaSTrackerClientError - Verify normal response with
binding_refenrichment is parsed correctly
resources.md
Contract: Resource Inventory
Endpoint: GET /api/v1/tracker/resources/ Owner: spec-kitty-saas Consumer: spec-kitty CLI (SaaSTrackerClient.resources())
Request
GET /api/v1/tracker/resources/?provider=linear
Headers:
Authorization: Bearer <access_token>
X-Team-Slug: <team_slug>
| Param | Type | Required | Description |
|---|---|---|---|
provider | query string | Yes | Normalized provider name |
Response (200)
{
"resources": [
{
"candidate_token": "cand_01HXYZ...",
"display_label": "My Project (LINEAR-123)",
"provider": "linear",
"provider_context": {
"team_name": "Engineering",
"workspace_name": "Acme Corp"
},
"binding_ref": "srm_01HXYZ...",
"bound_project_slug": "my-project",
"bound_at": "2026-03-01T10:00:00Z"
}
],
"installation_id": "inst_01HXYZ...",
"provider": "linear"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
resources | array | No | List of bindable resources |
resources[].candidate_token | string | No | Pre-bind opaque token for bind-confirm |
resources[].display_label | string | No | Human-readable label for CLI display |
resources[].provider | string | No | Normalized provider name |
resources[].provider_context | object | No | Provider-specific display metadata |
resources[].binding_ref | string | Yes | Non-null if already bound |
resources[].bound_project_slug | string | Yes | Non-null if already bound |
resources[].bound_at | string (ISO) | Yes | Non-null if already bound |
installation_id | string | No | Installation identifier |
provider | string | No | Echo of requested provider |
Error Responses
| Status | Error Code | Meaning |
|---|---|---|
| 401 | unauthorized | Token expired/invalid (triggers refresh) |
| 403 | no_installation | No installation for this provider |
| 429 | rate_limited | Rate limited (retry with backoff) |
Client Method
def resources(self, provider: str) -> dict[str, Any]:
"""GET /api/v1/tracker/resources/?provider=<provider>"""
Contract Tests
- Verify GET method + path
/api/v1/tracker/resources/ - Verify
providerquery parameter is sent - Verify response parsed into resources list
- Verify 403
no_installationraises appropriate error - Verify empty resources list (valid response, not an error)