# 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`: ```text 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): ```yaml mapping: id: # optional row id work_record_uuid: # 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: ```yaml # 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: ```text 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: ```json { "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) ```text # Project an existing work record outward issue project [--backend gitea] [--title …] # Link an existing external issue to a work record (no create) issue map link # Resolve issue map show --uuid issue map show --external [--backend gitea] # Detach issue map detach ``` REST sketch: ```text 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 ```json { "title": "…", "triggering_event_id": "…", "activity_definition_id": "…", … } ``` ### Proposed extension (backward compatible) ```json { "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)