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.yamltracker section

FieldTypeDefaultNew?Description
provider`str \None`NoneNo
binding_ref`str \None`NoneYes
project_slug`str \None`NoneNo
display_label`str \None`NoneYes
provider_context`dict[str, str] \None`NoneYes
workspace`str \None`NoneNo
doctrine_modestr"external_authoritative"NoOwnership mode.
doctrine_field_ownersdict[str, str]{}NoField → 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 None
  • binding_ref and project_slug can coexist (post-upgrade state)
  • display_label and provider_context are 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_context parsed as dict[str, str] or None

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] attribute
  • to_dict() merges _extra back 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-tracker YAML sections; this extends that guarantee to within the tracker section itself

BindableResource (new)

Location: src/specify_cli/tracker/discovery.py Persistence: Transient (returned by SaaS API, display fields cached in config after bind)

FieldTypeDescription
candidate_tokenstrPre-bind opaque token. Identifies this resource for bind-confirm. Not persisted locally.
display_labelstrHuman-readable label for CLI display.
providerstrNormalized provider name.
provider_contextdict[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: boolbinding_ref is not None

BindCandidate (new)

Location: src/specify_cli/tracker/discovery.py Persistence: Transient (returned by SaaS API bind-resolve, never persisted)

FieldTypeDescription
candidate_tokenstrPre-bind opaque token. Passed to bind-confirm.
display_labelstrHuman-readable label for selection display.
confidencestr"high", "medium", or "low".
match_reasonstrWhy this candidate matched (e.g., "project_slug matches existing mapping").
sort_positionintZero-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)

FieldTypeDescription
binding_refstrStable post-bind reference. Persisted in local config.
display_labelstrHuman-readable label. Cached in config.
providerstrNormalized provider name.
provider_contextdict[str, str]Provider-specific display metadata. Cached in config.
bound_atstrISO 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 from POST /api/v1/tracker/bind-validate/.

ValidationResult (new)

Location: src/specify_cli/tracker/discovery.py Persistence: Transient

FieldTypeDescription
validboolWhether the binding_ref is still valid on the host.
binding_refstrThe 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

FieldTypeDescription
match_typestr"exact", "candidates", or "none".
candidate_token`str \None`
binding_ref`str \None`
display_label`str \None`
candidateslist[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.yamlproject 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?)