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.
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:
- Projections are fire-and-forget — no reverse lookup from issue → work record.
- Emitters invent ad hoc back-references (
task_spawn_log.external_id, labels, description footers). TaskSpec.triggering_event_idis overloaded as "some upstream id" and cannot represent work-record identity cleanly.- Two-way sync at the boundary has nothing durable to key on.
Goals
- One mapping row per projection: work-record UUID ↔ (backend, external id).
- 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. - Extend, do not overload,
triggering_event_id. - Zero load when no external integration / mapping is configured.
- 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)whilestatus=active - Unique on
(work_record_uuid, backend)whilestatus=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:
- Local SQLite table
work_record_issue_map(even when primary CRUD backend is Gitea) — mapping is fleet bookkeeping, not a Gitea concept. - 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" } } - 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
pendingif 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)
- Schema:
work_record_issue_mapin local backend (+ migration path). - Domain service:
MappingServiceindependent of HTTP. - Wire
project/mapCLI + REST. - Extend
TaskIngestionRequestwith optional work-record fields; tests for idempotent mapping. - Canon PR for optional back-reference field on work-record YAML.
- activity-core: only emit with UUID when intentional; default sink policy already fixed by its follow-up.
Open questions (for implementers)
- Exact work-record back-reference key names (canon vs issue-core-owned).
- Whether mapping table is only SQLite or also dual-written to state-hub.
- Inward sync writer: who may edit work-record files (agent harness vs fix-consistency-only).
- Whether
kind: intakeprojections are allowed before promotion, or only afterintake → task|workplan.
See also
INTENT.md— connector roleSCOPE.md— mapping in scope as target shapeissue_core/api/schemas.py— current TaskSpec fieldsissue_core/api/ingest.py— current ingestion metadata storage- ISSUE-WP-0004 — alignment workplan
- activity-core IssueSink follow-up (filed from ISSUE-WP-0004-T05)