5.8.3 Schema + service — parse refs on write → auto-create `relates_to` link
Type: code · Executor: coding_agent · Repo: motir-core. Blocked by 5.8.2 (the parse/resolve helper).
The core of the feature: on every write that can contain a reference, parse it and record the relationship. Hooks the SAME write paths that already re-parse @user mentions.
Schema (small migration). Add WorkItemLink.source enum (manual | mention, default manual) — provenance, mirroring the attachments link-on-write source column (5.2.3). It marks auto-created edges (for render/affordance + so we never need a separate work_item_mention table — the relates_to row IS the record). FK/relations unchanged; @@unique([fromId, toId, kind]) already backstops dedup.
Service logic (4-layer; all DB work inside the existing $transactions). In workItemsService.createWorkItem (~L667) and updateWorkItem (~L989), for the descriptionMd, explanationMd, AND title fields (today only descriptionMd is parsed, for the notification event; explanation/title are NOT parsed at all — extend them), and in commentsService.addComment (~L187) / editComment (~L284):
parseWorkItemRefs(text)(5.8.2) -> token ids + bare keys.- Resolve to work items in the SAME workspace the author can VIEW (reuse the browsable-project / viewable scope already used by
quickSearch/resolveMentionableIds); resolve bare keys -> ids; drop unresolved/forbidden silently (the Jira mention rule). Drop self-references. - For each resolved target, check any existing link between the pair in ANY kind, EITHER direction via a new
workItemLinkRepository.findAnyBetween(aId, bId, tx)(queriesis_blocked_by/relates_to/duplicates/clonesboth ways). If none, createrelates_to(source: mention) — reusing thelinkWorkItemscreate path (reciprocal row + revision) so all invariants hold. If a link already exists in ANY relationship, do nothing (the user's directive — never downgrade a blocked_by into an extra relates_to). - Idempotent + concurrency-safe: the create races (two saves, or save + manual link).
SELECT ... FOR UPDATEthe contended rows / rely on@@unique([fromId,toId,kind])and catchP2002-> treat as already-linked (the lock-before-read-derived-update + typed-race rule). The auto-link is a DB write -> it belongs INSIDE the write tx (no external side effect). - Non-destructive: removing a reference from text NEVER deletes the link (a relates_to the user may now rely on). Decided over diff-and-delete to avoid surprising relationship loss; re-adding is idempotent.
Acceptance criteria
- Saving a description/explanation/title/comment containing a
motir:token or a bareMOTIR-Nkey creates exactly onerelates_toWorkItemLink(source: mention) per distinct, viewable, non-self target with no pre-existing link. - A pair already linked in another kind (blocked_by / blocks / duplicates / clones) is left untouched — no relates_to added.
- Re-saving the same text adds no duplicate link; removing the reference leaves the link intact.
- Concurrent writes that both reference the same target produce exactly one link (a real-concurrency test with a warm pool, accepting either winner — the TOCTOU guard).
- Forbidden/cross-project/unresolved refs are dropped silently, never error.
- Unit tests for the resolve+gate+dedup logic; the user-mention notification path is unchanged.
Context refs: lib/services/workItemsService.ts (createWorkItem ~629/667, updateWorkItem ~945/989, linkWorkItems ~2166, findReciprocal), lib/repositories/workItemLinkRepository.ts (create, findReciprocal, add findAnyBetween), lib/services/commentsService.ts (addComment ~181, editComment ~278), prisma/schema.prisma (WorkItemLink, WorkItemLinkKind), lib/mentions/workItemRefs.ts (5.8.2).