Estimate: 38m · Depends on: 1.3.1
Add the WorkItem Prisma model with the verbatim shape from this Story's acceptance criteria, generate the migration, write the Postgres trigger functions that enforce the kind-parent rule + depth limit + cycle prevention at the DB layer, and ship the repository (single-Prisma-op leaves with required-tx on writes). No service layer in this Subtask — that's 1.4.4.
Why DB-level constraints, not service-layer-only: the kind-parent rule is a structural invariant of the data model. If it lives only in the service layer, then any future direct Prisma access (a maintenance script, an admin path, a future microservice) can violate it silently. A Postgres trigger is the durable shape — it fires on every INSERT/UPDATE regardless of where the call originated, mirroring how RLS guarantees workspace isolation regardless of application path. The triggers are written in prisma/sql/work_item_triggers.sql and applied via a raw-SQL Prisma migration (Prisma supports raw SQL migrations natively).
Trigger sketch:
CREATE FUNCTION enforce_work_item_kind_parent() RETURNS TRIGGER AS $$ BEGIN IF NEW.parent_id IS NULL THEN IF NEW.kind = 'subtask' THEN RAISE EXCEPTION 'WI_SUBTASK_NEEDS_PARENT' USING ERRCODE = '23514'; END IF; RETURN NEW; END IF; SELECT kind INTO parent_kind FROM work_item WHERE id = NEW.parent_id; IF NOT is_legal_parent_child(parent_kind, NEW.kind) THEN RAISE EXCEPTION 'WI_ILLEGAL_PARENT_TYPE' USING ERRCODE = '23514'; END IF; -- depth + cycle checks here too RETURN NEW; END; $$ LANGUAGE plpgsql;
The repository's create/update methods catch SQLSTATE 23514 with the specific MESSAGE markers and translate to typed errors from lib/workItems/errors.ts.
Fractional indexing for position: use the fractional-indexing library (LexoRank-style, the Linear/Notion/Figma standard). New items at the end of a parent get a key after the current last child; reorders compute a key between the two neighbors. Decimal(20,10) is plenty of headroom — the keys are short strings, but they sort lexically as decimals after parsing. Choosing this over an integer position with bulk-shift-on-insert because shifts are O(N) writes per reorder, and the bulk-shift pattern is what Jira's original design got wrong.
What you'll do: Extend prisma/schema.prisma with WorkItem + the three enums (WorkItemKind, WorkItemPriority, WorkItemExplanationSource). Write the trigger SQL in prisma/sql/work_item_triggers.sql. Generate the migration add_work_items, append the trigger SQL to the migration file. Add lib/repositories/workItemRepository.ts with the single-op methods listed in the AC. Add lib/workItems/errors.ts with the typed errors. Add lib/dto/workItems.ts + lib/mappers/workItemMappers.ts (Prisma row → DTO conversion). Install fractional-indexing as a runtime dependency. Install the Markdown render stack (react-markdown, remark-gfm, rehype-sanitize, rehype-highlight) as runtime dependencies — the renderer component itself is built in Epic 2's issue-detail Subtask, but the deps land here so the schema and a smoke-rendered /dev/markdown page can confirm GFM features render correctly before the UI Subtask consumes them. Add lib/markdown/render.tsx with a single renderMarkdown(md: string) helper that pipes through the standard sanitize + GFM + highlight chain — this is the canonical renderer Epic 2 + Epic 5 + Epic 7 all consume. No Markdown editor in this Subtask — that's Epic 2; for v1.4 verification the test endpoint writes raw Markdown strings.
WorkItem + WorkItemKind + WorkItemPriority + WorkItemExplanationSource in prisma/schema.prisma with the verbatim field set + relations + indexes from this Story's AC, including descriptionMd String? @db.Text, explanationMd String? @db.Text, explanationSource WorkItemExplanationSource @default(user_authored). pnpm prisma generate succeeds.react-markdown, remark-gfm, rehype-sanitize, rehype-highlight. lib/markdown/render.tsx exports a renderMarkdown(md: string) component that pipes through the sanitize + GFM + highlight chain. Smoke test (unit test) renders a fixture containing headings, lists, tables, task checkboxes, code blocks, links, images, and an inline <script> tag — asserts the script tag is stripped and every other element renders as expected semantic HTML.add_work_items applies cleanly. The migration includes the trigger functions and CREATE TRIGGER statements. Down-migration is reversible (drops triggers, then the table).workItemRepository exports findById, findByIdentifier, findByProject, findSubtree, findChildren, create(data, tx), update(id, data, tx), archive(id, tx). All writes require tx: Prisma.TransactionClient. Trigger errors are translated to IllegalParentTypeError / DepthLimitExceededError / ParentCycleError at the repository edge.findSubtree uses a recursive CTE inside $queryRaw and returns the full subtree (with depth info) in one round-trip. Verify on a tree of ~50 items: single query.lib/dto/workItems.ts exports WorkItemDto + WorkItemSummaryDto + WorkItemRevisionDto. lib/mappers/workItemMappers.ts converts. The repository never returns raw Prisma rows past the public boundary except to the service layer (kept internal).fractional-indexing installed; a helper lib/workItems/positioning.ts exposes keyForAppend(last), keyBetween(prev, next), keyForPrepend(first). Unit tests over each (deterministic outputs vs. known fixtures).create persists; create with illegal parent kind rejects with IllegalParentTypeError; create at depth 5 rejects with DepthLimitExceededError; update setting parentId to a descendant rejects with ParentCycleError; findSubtree returns the right shape. Tests use a real Postgres per the standing no-mocks rule.db.* / $transaction outside the repository layer (no service layer exists yet).pnpm prisma generate && typecheck && lint && format:check && build && test. Existing suite stays green.motir-core/CLAUDE.md — 4-layer rule (auto-loaded)prisma/schema.prisma — current Workspace / WorkspaceMembership / Project modelslib/repositories/projectRepository.ts — the single-op + required-tx pattern; allocateWorkItemNumber is the method this Story's service layer will consume (in 1.4.4)lib/repositories/workspaceRepository.ts — error-translation pattern (Prisma error → typed error at the repository edge)lib/workspaces/errors.ts + lib/projects/errors.ts — typed-error patternlib/dto/projects.ts + lib/mappers/projectMappers.ts — DTO/mapper pattern