Data Model: Ticket-First Mission Origin Binding

Feature: 061-ticket-first-mission-origin-binding Date: 2026-04-01

Entities

OriginCandidate

A candidate external issue returned by search. Immutable value object.

FieldTypeDescriptionValidation
external_issue_idstrProvider-native ID (e.g., Linear issue UUID, Jira issue ID)Non-empty
external_issue_keystrHuman-readable key (e.g., "WEB-123")Non-empty
titlestrIssue title / summaryNon-empty
statusstrCurrent issue status in the providerNon-empty
urlstrDeep link to the issue in the provider UINon-empty, valid URL
match_typestr"exact" or "text"Must be one of the two values

Implementation: @dataclass(frozen=True, slots=True) in tracker/origin.py

SearchOriginResult

Structured result from search_origin_candidates().

FieldTypeDescriptionValidation
candidateslist[OriginCandidate]Matching issues, ordered by relevanceMay be empty
providerstrResolved provider name"jira" or "linear"
resource_typestrResource type (e.g., "linear_team", "jira_project")Non-empty
resource_idstrResource identifier used for scopingNon-empty
query_usedstrThe query that was actually executedNon-empty

Implementation: @dataclass(frozen=True, slots=True) in tracker/origin.py

MissionFromTicketResult

Result of start_mission_from_ticket().

FieldTypeDescriptionValidation
feature_dirPathPath to the created feature directoryMust exist
feature_slugstrAssigned feature slug (e.g., "061-web-123")Non-empty, matches slug pattern
origin_ticketdict[str, str]The persisted origin_ticket metadata blockAll required keys present
event_emittedboolWhether MissionOriginBound event was emittedN/A

Implementation: @dataclass(slots=True) in tracker/origin.py (not frozen — feature_dir is a Path)

Metadata Extensions

origin_ticket block in meta.json

Additive metadata block. Written via set_origin_ticket()write_meta().

{
  "origin_ticket": {
    "provider": "linear",
    "resource_type": "linear_team",
    "resource_id": "team-uuid",
    "external_issue_id": "issue-uuid",
    "external_issue_key": "WEB-123",
    "external_issue_url": "https://linear.app/acme/issue/WEB-123/add-clerk-auth",
    "title": "Add Clerk auth"
  }
}

Required keys: provider, resource_type, resource_id, external_issue_id, external_issue_key, external_issue_url, title

All seven fields are required. resource_type and resource_id are routing context needed for offline intelligibility and possible future rebind/replay. They are always available from SearchOriginResult at bind time.

FeatureMetaOptional TypedDict extension

class FeatureMetaOptional(TypedDict, total=False):
    # ... existing fields ...
    origin_ticket: dict[str, Any]

Event Model

MissionOriginBound

FieldTypeRequiredValidation
feature_slugstrYesMatches ^\d{3}-[a-z0-9-]+$
providerstrYes"jira" or "linear"
external_issue_idstrYesNon-empty
external_issue_keystrYesNon-empty
external_issue_urlstrYesNon-empty
titlestrYesNon-empty

Aggregate: Feature (aggregate_id = feature_slug) Role: Observational telemetry only. Does NOT create the SaaS-side MissionOriginLink (that is done by the bind API call).

Relationships

TrackerProjectConfig (config.yaml)
  └──provides──▶ provider + project_slug
                    │
                    ▼
        SaaSTrackerClient.search_issues()
                    │
                    ▼
            SearchOriginResult
              └── candidates: [OriginCandidate, ...]
                                    │
                            (developer confirms one)
                                    │
                                    ▼
                        bind_mission_origin()
                          ├── meta.json ← origin_ticket block
                          ├── SaaS ← MissionOriginLink (authoritative write)
                          └── Event ← MissionOriginBound (telemetry)

State Transitions

The origin_ticket binding is a one-time write-once operation per mission (in v1):

(no origin) ──bind──▶ (origin bound)
                           │
                    (same-origin re-bind = no-op)
                    (different-origin re-bind = hard error)

No state machine beyond this. The origin_ticket block is immutable after binding (unless a future version adds unbind support).