Data Model: Tracker Binding Context Discovery
Feature: 062-tracker-binding-context-discovery Date: 2026-04-04
Entities
TrackerProjectConfig (evolved)
Location: src/specify_cli/tracker/config.py Persistence: .kittify/config.yaml → tracker section
| Field | Type | Default | New? | Description |
|---|---|---|---|---|
provider | `str \ | None` | None | No |
binding_ref | `str \ | None` | None | Yes |
project_slug | `str \ | None` | None | No |
display_label | `str \ | None` | None | Yes |
provider_context | `dict[str, str] \ | None` | None | Yes |
workspace | `str \ | None` | None | No |
doctrine_mode | str | "external_authoritative" | No | Ownership mode. |
doctrine_field_owners | dict[str, str] | {} | No | Field → owner mappings. |
Validation rules:
is_configured(SaaS):provider is not None AND (binding_ref is not None OR project_slug is not None)is_configured(Local):provider is not None AND workspace is not Nonebinding_refandproject_slugcan coexist (post-upgrade state)display_labelandprovider_contextare cached; absence is not an error
Serialization (to_dict()):
tracker:
provider: linear
binding_ref: srm_01HXYZ...
project_slug: my-project
display_label: "My Project (LINEAR-123)"
provider_context:
team_name: Engineering
workspace_name: Acme Corp
workspace: null
doctrine:
mode: external_authoritative
field_owners: {}
Deserialization (from_dict()):
- Gracefully handles missing
binding_ref,display_label,provider_context(pre-062 configs) provider_contextparsed asdict[str, str]orNone
Unknown field passthrough (new behavior): The current from_dict()/to_dict() only materializes known fields — any unrecognized keys in the tracker YAML section are silently dropped on round-trip. This feature adds passthrough for unknown fields:
from_dict()captures unrecognized keys into a private_extra: dict[str, Any]attributeto_dict()merges_extraback into the output dict (known fields take precedence on conflict)- This prevents data loss when a newer CLI version writes fields that an older version doesn't recognize
save_tracker_config()already preserves non-trackerYAML sections; this extends that guarantee to within thetrackersection itself
BindableResource (new)
Location: src/specify_cli/tracker/discovery.py Persistence: Transient (returned by SaaS API, display fields cached in config after bind)
| Field | Type | Description |
|---|---|---|
candidate_token | str | Pre-bind opaque token. Identifies this resource for bind-confirm. Not persisted locally. |
display_label | str | Human-readable label for CLI display. |
provider | str | Normalized provider name. |
provider_context | dict[str, str] | Provider-specific display metadata (team_name, workspace_name, etc.). |
binding_ref | `str \ | None` |
bound_project_slug | `str \ | None` |
bound_at | `str \ | None` |
Factory method: BindableResource.from_api(data: dict[str, Any]) -> BindableResource
- Parses a single resource entry from the
GET /api/v1/tracker/resources/response.
Properties:
is_bound: bool→binding_ref is not None
BindCandidate (new)
Location: src/specify_cli/tracker/discovery.py Persistence: Transient (returned by SaaS API bind-resolve, never persisted)
| Field | Type | Description |
|---|---|---|
candidate_token | str | Pre-bind opaque token. Passed to bind-confirm. |
display_label | str | Human-readable label for selection display. |
confidence | str | "high", "medium", or "low". |
match_reason | str | Why this candidate matched (e.g., "project_slug matches existing mapping"). |
sort_position | int | Zero-based stable ordinal from host. --select N maps to sort_position = N - 1. |
Factory method: BindCandidate.from_api(data: dict[str, Any]) -> BindCandidate
- Parses a single candidate entry from the
POST /api/v1/tracker/bind-resolve/response.
BindResult (new)
Location: src/specify_cli/tracker/discovery.py Persistence: Transient (returned by bind-confirm or bind-validate, fields cached in config)
| Field | Type | Description |
|---|---|---|
binding_ref | str | Stable post-bind reference. Persisted in local config. |
display_label | str | Human-readable label. Cached in config. |
provider | str | Normalized provider name. |
provider_context | dict[str, str] | Provider-specific display metadata. Cached in config. |
bound_at | str | ISO timestamp. |
Factory method: BindResult.from_api(data: dict[str, Any]) -> BindResult
- Parses the response from
POST /api/v1/tracker/bind-confirm/or the valid case fromPOST /api/v1/tracker/bind-validate/.
ValidationResult (new)
Location: src/specify_cli/tracker/discovery.py Persistence: Transient
| Field | Type | Description |
|---|---|---|
valid | bool | Whether the binding_ref is still valid on the host. |
binding_ref | str | The ref that was validated. |
reason | `str \ | None` |
guidance | `str \ | None` |
display_label | `str \ | None` |
provider | `str \ | None` |
provider_context | `dict[str, str] \ | None` |
Factory method: ValidationResult.from_api(data: dict[str, Any]) -> ValidationResult
ResolutionResult (new)
Location: src/specify_cli/tracker/discovery.py Persistence: Transient
| Field | Type | Description |
|---|---|---|
match_type | str | "exact", "candidates", or "none". |
candidate_token | `str \ | None` |
binding_ref | `str \ | None` |
display_label | `str \ | None` |
candidates | list[BindCandidate] | Populated for match_type == "candidates". |
Factory method: ResolutionResult.from_api(data: dict[str, Any]) -> ResolutionResult
ProjectIdentity (unchanged)
Location: src/specify_cli/sync/project_identity.py Persistence: .kittify/config.yaml → project section
No changes. Consumed as-is. The uuid, slug, node_id, and repo_slug fields are sent to the bind-resolve and bind-confirm endpoints.
State Transitions
Binding Lifecycle
┌─────────┐
│ unbound │ (no tracker section, or provider-only)
└���───┬────┘
│
tracker bind
(discovery flow)
│
▼
┌──────────────────┐
│ legacy-bound │ (provider + project_slug only, pre-062)
│ (read compat) │
└────────┬─────────┘
│
opportunistic upgrade
(on any successful SaaS call)
│
▼
┌──────────────────┐
│ fully-bound │ (binding_ref + cached display metadata)
│ (primary state) │
└────────┬─────────┘
│
host-side deletion/disable
(detected reactively)
│
▼
┌──────────────────┐
│ stale-bound │ (binding_ref exists but host rejects it)
│ (error state) │ → user must run tracker bind to re-bind
└────────┬─────────┘
│
tracker bind
(re-bind flow)
│
▼
┌──────────────────┐
│ fully-bound │ (new binding_ref replaces old)
└──────────────────┘
Config Read Precedence (state machine)
has_binding_ref? ──yes──▶ use binding_ref for routing
│ │
no host rejects? ──yes──▶ StaleBindingError (exit 1)
│ │
▼ no
has_project_slug? ──yes──▶ use project_slug (legacy compat)
│ │
no response has binding_ref? ──yes──▶ write to config (opportunistic)
│ │
▼ no
not configured ▼
(error) return result unchanged
Relationships
ProjectIdentity ────────────────────────��────────────────┐
(uuid, slug, node_id, repo_slug) │
│ sent to
TrackerProjectConfig ────────────────────────────┐ │
(provider, binding_ref, project_slug, │ │
display_label, provider_context) │ │
│ │
persisted ◀──┘ │
in config │
│
SaaS API ◀───────────────────────────────────────────────┘
│
├─ resources/ ──▶ list[BindableResource]
├─ bind-resolve/ ──▶ ResolutionResult
│ ├─ exact: candidate_token + optional binding_ref
│ ├─ candidates: list[BindCandidate]
│ └─ none: error
├─ bind-confirm/ ──▶ BindResult (binding_ref created)
└─ bind-validate/ ──▶ ValidationResult (binding_ref still valid?)