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
  }
}
FieldTypeRequiredDescription
providerstringYesNormalized provider name
candidate_tokenstringYesPre-bind token from resolution or inventory
project_identity.uuidstring (UUID)YesFrom ProjectIdentity
project_identity.slugstringYesFrom ProjectIdentity
project_identity.node_idstringYesFrom ProjectIdentity
project_identity.repo_slugstringNoUser override from ProjectIdentity
Idempotency-Keyheader (UUID)YesPrevents 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"
}
FieldTypeNullableDescription
binding_refstringNoStable post-bind reference. Primary routing key.
display_labelstringNoHuman-readable label for display/caching
providerstringNoNormalized provider name
provider_contextobjectNoProvider-specific display metadata
bound_atstring (ISO)NoTimestamp of binding

Error Responses

StatusError CodeMeaning
400invalid_candidate_tokenToken expired, invalid, or already consumed
401unauthorizedToken expired/invalid (triggers refresh)
409already_boundResource already bound to a different project
429rate_limitedRate 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-Key header 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_token raises appropriate error
  • Verify 409 already_bound raises 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
  }
}
FieldTypeRequiredDescription
providerstringYesNormalized provider name
project_identity.uuidstring (UUID)YesFrom ProjectIdentity
project_identity.slugstringYesFrom ProjectIdentity
project_identity.node_idstringYesFrom ProjectIdentity
project_identity.repo_slugstringNoUser 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_token is always present
  • binding_ref is non-null if a ServiceResourceMapping already exists (CLI can skip bind-confirm)
  • binding_ref is 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

FieldTypeNullableDescription
match_typestringNo"exact", "candidates", or "none"
candidate_tokenstringYesFor exact match: the pre-bind token
binding_refstringYesFor exact match with existing mapping
candidatesarrayNoEmpty for exact/none; populated for candidates
candidates[].candidate_tokenstringNoPre-bind token for this candidate
candidates[].display_labelstringNoHuman-readable label
candidates[].confidencestringNo"high", "medium", or "low"
candidates[].match_reasonstringNoWhy this candidate matched
candidates[].sort_positionintegerNoZero-based stable ordinal
display_labelstringYesFor 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 provider and project_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
  }
}
FieldTypeRequiredDescription
providerstringYesNormalized provider name
binding_refstringYesThe binding reference to validate
project_identity.uuidstring (UUID)YesFrom ProjectIdentity
project_identity.slugstringYesFrom ProjectIdentity
project_identity.node_idstringYesFrom ProjectIdentity
project_identity.repo_slugstringNoUser 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."
}
FieldTypeNullableConditionDescription
validbooleanNoAlwaysWhether the binding_ref is still valid
binding_refstringNoAlwaysEcho of the validated ref
display_labelstringYesvalid=trueHuman-readable label
providerstringYesvalid=trueNormalized provider name
provider_contextobjectYesvalid=trueProvider-specific display metadata
reasonstringYesvalid=falseMachine-readable: mapping_deleted, mapping_disabled, project_mismatch
guidancestringYesvalid=falseHuman-readable guidance for CLI display

Error Responses

StatusError CodeMeaning
401unauthorizedToken expired/invalid (triggers refresh)
429rate_limitedRate 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 codeMeaning
binding_not_foundServiceResourceMapping deleted
mapping_disabledMapping exists but disabled
project_mismatchbinding_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_ref query param / body field is sent when provided
  • Verify project_slug query param / body field is sent when binding_ref is absent
  • Verify stale-binding error codes are preserved in SaaSTrackerClientError
  • Verify normal response with binding_ref enrichment 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>
ParamTypeRequiredDescription
providerquery stringYesNormalized 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"
}
FieldTypeNullableDescription
resourcesarrayNoList of bindable resources
resources[].candidate_tokenstringNoPre-bind opaque token for bind-confirm
resources[].display_labelstringNoHuman-readable label for CLI display
resources[].providerstringNoNormalized provider name
resources[].provider_contextobjectNoProvider-specific display metadata
resources[].binding_refstringYesNon-null if already bound
resources[].bound_project_slugstringYesNon-null if already bound
resources[].bound_atstring (ISO)YesNon-null if already bound
installation_idstringNoInstallation identifier
providerstringNoEcho of requested provider

Error Responses

StatusError CodeMeaning
401unauthorizedToken expired/invalid (triggers refresh)
403no_installationNo installation for this provider
429rate_limitedRate 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 provider query parameter is sent
  • Verify response parsed into resources list
  • Verify 403 no_installation raises appropriate error
  • Verify empty resources list (valid response, not an error)