issue-core/docs/uuid-external-id-mapping.md
tegwick d60175c354
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
docs: align issue-core with work-record connector role (ISSUE-WP-0004)
Retarget INTENT, SCOPE, README, ROADMAP, and agent docs from "task landing
zone" to external-tracker connector per work-record canon and architecture
draft §4.2. Add UUID↔external-id mapping design; mark ISSUE-WP-0004 finished
and cross-file activity-core ACTIVITY-WP-0022 for IssueSink policy.
2026-07-21 04:22:12 +02:00

9.9 KiB

Design: UUID ↔ external-id mapping

Status: design only (ISSUE-WP-0004-T04)
Date: 2026-07-21
Implementation: deferred to stage-3 work-record architecture in the-custodian (not this workplan).
Normative product role: INTENT.md, SCOPE.md; founder decision the-custodian/research/WorkOrchestrationArchitectureDraft.md §4.2; kinds in the-custodian/canon/standards/work-record-types_v0.1.md (task kind: issue-core issues become external projections only).

Problem

When collaboration requires a third-party tracker (Forgejo/Gitea, GitHub, Jira, …), the fleet still needs a stable link between:

Side Identity today Owner
Internal work record UUIDv7 written back as state_hub_*_id (workplan/task/intake/…) repo file + state-hub
External issue Backend issue id / number (+ URL) Gitea, GitHub, … via issue-core

Without an explicit mapping:

  1. Projections are fire-and-forget — no reverse lookup from issue → work record.
  2. Emitters invent ad hoc back-references (task_spawn_log.external_id, labels, description footers).
  3. TaskSpec.triggering_event_id is overloaded as "some upstream id" and cannot represent work-record identity cleanly.
  4. Two-way sync at the boundary has nothing durable to key on.

Goals

  1. One mapping row per projection: work-record UUID ↔ (backend, external id).
  2. Mirror the hub write-back pattern: optional field on the work-record source (or generated index) pointing at the external projection, similar to state_hub_task_id.
  3. Extend, do not overload, triggering_event_id.
  4. Zero load when no external integration / mapping is configured.
  5. Idempotent project: re-projecting the same UUID does not create duplicate issues.

Non-goals (this design)

  • Replacing work records as origin of work.
  • Making Forgejo the fleet task board.
  • Implementing the store, CLI, or API in ISSUE-WP-0004.
  • Full CRDT merge of comments/body (v1 can be status + simple comment push/pull; advanced merge is later).

Current traceability (as-is)

Ingestion metadata

POST /issues/ stores emitter fields under Issue.sync_metadata.ingestion:

triggering_event_id   # activity event UUID or stable key e.g. "scheduled"
source_type           # rule | instruction
source_id
activity_definition_id
target_repo

See issue_core/api/ingest.py and issue_core/api/schemas.py.

What triggering_event_id is

Property Value
Purpose Emitter-side traceability / idempotency key for activity spawns
Type non-empty string (UUID or stable key)
Scope Links to activity-core event or schedule, not to a work-record UUID
Stored on sync_metadata.ingestion of the created issue

What it is not

  • Not a work-record UUID
  • Not a substitute for state_hub_task_id / intake UUID
  • Not sufficient for "this Forgejo issue projects task X"

Decision: keep triggering_event_id for activity-core emit lineage. Add a separate identity channel for work records (work_record_uuid and durable mapping rows). Do not overload the existing field.

Target shape

Mapping record

Conceptual schema (storage TBD — local SQLite table is the natural first home; remote backends may only get labels/footers):

mapping:
  id: <mapping-uuid>                 # optional row id
  work_record_uuid: <uuidv7>         # primary internal key
  work_record_id: "ISSUE-WP-0004-T01"  # canonical name, optional denorm
  work_record_kind: task             # task | intake | workplan | …
  backend: gitea                     # gitea | github | gitlab | jira | sqlite
  external_id: "176"                 # backend-native issue id/number
  external_url: "https://…/issues/176"
  target_repo: "coulomb/example"     # optional routing hint
  direction: outward                 # outward | linked (pre-existing issue)
  status: active                     # active | detached | superseded
  created_at: 
  updated_at: 
  # optional sync cursors
  last_pushed_at: 
  last_pulled_at: 

Uniqueness:

  • Unique on (backend, external_id) while status=active
  • Unique on (work_record_uuid, backend) while status=active (one active projection per tracker per work record; multi-tracker later can relax to one per backend type)

Work-record side back-reference (optional denorm)

Mirror hub write-back style so humans/agents see the link without querying issue-core only:

# on a task / intake YAML block (illustrative — exact key names TBD with canon)
external_tracker:
  backend: gitea
  issue_id: "176"
  issue_url: "https://…"
  mapped_by: issue-core

Alternatively a single string field if canon prefers flat keys:

issue_core_external_ref: "gitea:coulomb/example#176"

Canon PR owns the field name; issue-core owns the mapping authority.

issue-core side storage

Recommended v1:

  1. Local SQLite table work_record_issue_map (even when primary CRUD backend is Gitea) — mapping is fleet bookkeeping, not a Gitea concept.
  2. Issue.sync_metadata.mapping (or top-level) echo for convenience when the issue is loaded:
    {
      "mapping": {
        "work_record_uuid": "…",
        "work_record_id": "…",
        "work_record_kind": "task"
      }
    }
    
  3. Optional Gitea label / body footer for human visibility in the tracker UI (non-authoritative).

API / CLI (sketch only)

# Project an existing work record outward
issue project <work_record_uuid> [--backend gitea] [--title …]

# Link an existing external issue to a work record (no create)
issue map link <work_record_uuid> <external_id>

# Resolve
issue map show --uuid <work_record_uuid>
issue map show --external <external_id> [--backend gitea]

# Detach
issue map detach <work_record_uuid>

REST sketch:

POST   /mappings/                 { work_record_uuid, kind?, create_issue? | external_id? }
GET    /mappings/?work_record_uuid=…
GET    /mappings/?backend=gitea&external_id=…
DELETE /mappings/{id}             # or POST detach

POST /issues/ remains for intentional issue creation without a work record. When a work record is known, prefer POST /mappings/ (or POST /issues/ with required work_record_uuid in a later schema version).

Intersection with TaskSpec / ingestion

Today

{
  "title": "…",
  "triggering_event_id": "…",
  "activity_definition_id": "…",
  
}

Proposed extension (backward compatible)

{
  "title": "…",
  "triggering_event_id": "…",
  "work_record_uuid": "optional-uuidv7",
  "work_record_id": "optional-canonical-name",
  "work_record_kind": "optional-kind",
  
}

Rules:

Case Behavior
No work_record_uuid Current behavior: create issue; store ingestion meta; no mapping row
With work_record_uuid Create or reuse issue; upsert mapping row; echo UUID in sync_metadata
Same UUID + active mapping Idempotent: return existing issue_id, do not create duplicate
triggering_event_id only Unchanged meaning (activity lineage)

Supersede vs extend: extend. triggering_event_id stays for activity-core. Work-record identity is additive. Long term, emitters that only need fleet work should not call issue-core at all; emitters that project a known work record pass work_record_uuid.

activity-core implications (out of band)

  • Default sink must not always POST for internal findings (see follow-up workplan filed under ISSUE-WP-0004-T05).
  • When a rule does intentionally project, pass the work-record UUID once intake/task promotion exists — not only the activity event id.

Sync semantics (boundary only)

Direction Trigger Writes
Outward project / status change on work record when sync enabled Update external issue state/labels/comment
Inward Tracker webhook or poll when enabled Update mapping cursors; optionally suggest work-record status change via hub/file write tools — never silent mutation of ADR-001 files without a defined writer

v1 can implement outward-only + mapping durability; inward sync is a later increment. Until enabled, connector load is zero beyond idle process cost.

Failure and edge cases

  • Unknown UUID: reject project with 4xx; do not create orphan mapping.
  • Backend down: no mapping row until create succeeds (or store pending if a queue is introduced later — not required for v1).
  • Issue deleted remotely: mark mapping detached; leave work record intact.
  • Multiple backends: one active mapping per (uuid, backend); documenting multi-tracker projection is future work.
  • SQLite-only offline: mapping table lives with local DB; sync of issues and mappings to remote is coordinated (mapping rows are not pushed as Gitea native objects).

Implementation sketch (later workplan)

  1. Schema: work_record_issue_map in local backend (+ migration path).
  2. Domain service: MappingService independent of HTTP.
  3. Wire project / map CLI + REST.
  4. Extend TaskIngestionRequest with optional work-record fields; tests for idempotent mapping.
  5. Canon PR for optional back-reference field on work-record YAML.
  6. activity-core: only emit with UUID when intentional; default sink policy already fixed by its follow-up.

Open questions (for implementers)

  1. Exact work-record back-reference key names (canon vs issue-core-owned).
  2. Whether mapping table is only SQLite or also dual-written to state-hub.
  3. Inward sync writer: who may edit work-record files (agent harness vs fix-consistency-only).
  4. Whether kind: intake projections are allowed before promotion, or only after intake → task|workplan.

See also

  • INTENT.md — connector role
  • SCOPE.md — mapping in scope as target shape
  • issue_core/api/schemas.py — current TaskSpec fields
  • issue_core/api/ingest.py — current ingestion metadata storage
  • ISSUE-WP-0004 — alignment workplan
  • activity-core IssueSink follow-up (filed from ISSUE-WP-0004-T05)