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.
287 lines
9.9 KiB
Markdown
287 lines
9.9 KiB
Markdown
# 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)
|