issue-core/docs/uuid-external-id-mapping.md

288 lines
9.9 KiB
Markdown
Raw Permalink Normal View History

# 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: <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:
```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 <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:
```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)