Skip to content

T-N9PM-typed-entity-read-layer

Status: closed/done · Impact: high · Complexity: medium

Reading an SDLC entity is un-abstracted: ~15 lib-side call sites (plus a skill-side tail) each resolve the path, read the file, parse frontmatter into an untyped Record, and index fields by hand — with two parseFrontmatter implementations and ad-hoc git show <rev>:<path> reads scattered through them. Meanwhile markdown-contract’s Contract.read() — the typed Doc<F, B> the contracts already produce — has ZERO read call sites; six validate sites compute that Doc and discard it. This task builds the one typed, rev-aware read path (readEntity core + readTask typed wrapper), converges the lib-side callers onto it, and makes it discoverable so the duplication stops recurring.

LocationRole today
apps/sdlc/lib/util/frontmatter.tsCanonical parse seam (extractFrontmatter / splitFrontmatter / parseFrontmatterResult / parseFrontmatter) plus the byte-faithful prs: read/write helpers. Parse only — no file read, no path resolution, no rev awareness.
apps/sdlc/lib/model/entities/task/ops/_task_doc.tsTask-only resolveTaskDocPath (three ref spellings) + readTaskDoc (working tree only, raw {path, text}) + a DUPLICATE local parseFrontmatter.
apps/sdlc/lib/model/ops/_update.tsThe generic, type-parameterized resolver resolveInstanceDocPath + readInstanceDoc — buried inside the frontmatter-update write engine instead of a shared read module.
apps/sdlc/lib/model/corpus/loader.tsreadFrontmatter (error-swallowing, untyped) + DIR_TYPE plural-dir map; walks all of docs/planning/ for the basename → {type, status} corpus index.
apps/sdlc/lib/model/entities/_contracts.tscontractForType(type)Contract whose .read(source, ctx) returns the typed Doc — unused on every read path today.
apps/sdlc/lib/services/lease/ops/task/sweep.tsstatusAtOriginMain: hand-rolled git show origin/main:<path> + parseFrontmatter + fm['status'] — the motivating example from PR #905 review.
apps/sdlc/lib/model/entities/task/ops/_lint_state_origin_core.tsIts own fileAtRev (git show <sha>:<path>, <sha>~1:<path>) + local splitFrontmatter wrapper for before/after state diffs.
apps/sdlc/lib/model/entities/task/ops/_probe_core.tsAnother local readFrontmatter wrapper indexing arbitrary string fields for inflight / probe-state.
apps/sdlc/lib/services/lease/migration.tsreadTaskStatus: readFileSync + parseFrontmatter + fm['status'].
apps/sdlc/lib/services/dashboard/server.tsHand-indexed task/entity rosters (id, title, status, impact, complexity, created, last_reviewed, prs picked off untyped records), plus its own id-to-file resolution.
apps/sdlc/lib/model/entities/task/ops/check-claims.tsreadTaskDoc + duplicate parseFrontmatter; passes untyped fm + raw text to claim resolvers.
apps/sdlc/lib/No README.md and no CLAUDE.md — the model-access story is undiscoverable, so each new op re-invents the read.

A model-layer read API, generic over entity type, source-aware:

  • readEntity(type, ref, opts) core in lib/model/read.ts. ref accepts the three established spellings (absolute path / project-relative path / bare basename-or-id). opts: { projectRoot, at?: string, git?: CommandRunner } — working tree by default; at reads git show <at>:<relpath> through the injected CommandRunner (the existing OpCtx seam, so tests fake it).
  • One read call, one wrapper result — a discriminated union, never a throw:
    • Success arm: { ok: true, doc, path }doc is the strongly-typed Doc the contracts already produce (frontmatter plane typed by the per-entity Zod, body plane navigable). markdown-contract already ships the safe substrate: contract.validate() never throws and returns { findings, doc?, tree } with tree.frontmatter.data the hydrated untyped fm — both arms below derive from that one pass.
    • Fail arm: { ok: false, fm, body, path, findings } — the degraded-but- usable view: frontmatter hydrated into a plain untyped object (raw YAML types, no schema applied; null when even the YAML is unparseable), the body as raw text, and the findings/parse diagnostics explaining why the typed arm was refused. CRITICAL: this arm is what keeps the read layer no MORE brittle than the hand-rolled reads it replaces — e.g. sweep’s statusAtOriginMain must still yield status off fm from a schema-drifted file at an old rev, where the strict .strict() schemas reject the typed arm.
    • Absent source (no file in the working tree / no blob at at:) → null overall, distinct from both arms.
  • Per-entity typed wrapper co-located per the placement convention: readTask(ref, opts) under lib/model/entities/task/ returning the same wrapper with the success arm narrowed to the Task-typed Doc. Other per-type wrappers minted only when a real caller converges (co-locate first, promote when shared).
  • Wave-1 convergence of the trivially-convergeable lib-side sites (see Files to touch); statusAtOriginMain collapses to readTask(taskId, { at: 'origin/main', git: ctx.git }) + a field access (doc.frontmatter.status on the success arm, fm['status'] on the fail arm, undefined on null).
  • Bulk escape hatch, same module: readRawFrontmatter(path) — contract-free fence+YAML read for many-file frontmatter walks (corpus loader, dashboard rosters). Measured on implementation: full-contract reads are ~4.5 ms/file vs ~0.1 ms; at 700+ corpus files the wrapper would be a ~30x regression on task next / dashboard polls, so bulk fm-only scans converge onto this shared primitive instead (three duplicate local parsers deleted).
  • Progressive-disclosure docs so the layer stays discovered: lib/model/README.md (the model-access guide: read layer, contracts, registry, rev seam) + a thin apps/sdlc/lib/CLAUDE.md pointing at it with the one hard rule — never hand-roll frontmatter parsing over docs/planning/ entities; use the read layer (same pattern as apps/sdlc/skills/CLAUDE.md and apps/sdlc/desktop/CLAUDE.md).
  1. Extract the generic read module lib/model/read.ts: promote resolveInstanceDocPath (from _update.ts) as the path resolver, add the source seam (working tree | {at, git} via git show <at>:<relpath>), and return the wrapper result, deriving BOTH arms from one contractForType(type).validate(source) pass (the library’s safe door): success arm = result.doc (present iff no error-level finding), fail arm = result.tree.frontmatter?.data (the already-hydrated untyped fm) + body text + result.findings, null on absent source; the call itself never throws and never parses twice. Export from lib/model/index.ts.
  2. Add readTask in lib/model/entities/task/read.ts (same wrapper, success arm narrowed to the Task-typed Doc; task ref spellings via the generic resolver). Decide during implementation whether readTaskDoc in _task_doc.ts becomes a thin re-export or its callers move; the duplicate local parseFrontmatter in _task_doc.ts is deleted either way.
  3. Converge wave-1 call sites, each its own commit where useful: sweep.ts#statusAtOriginMain, lease/migration.ts#readTaskStatus, corpus/loader.ts#readFrontmatter, _probe_core.ts#readFrontmatter, check-claims.ts (fm plane; resolvers keep raw text from the same read), dashboard/server.ts rosters (readTasks / readSimpleEntities).
  4. Point _lint_state_origin_core.ts#fileAtRev at the shared rev-read seam (it keeps its own before/after diff logic; only the raw fetch converges).
  5. Write lib/model/README.md and apps/sdlc/lib/CLAUDE.md (progressive disclosure; pointer + one hard rule, details in the README).
  6. Tests in lib/model/tests/read.test.ts: the three ref spellings; working tree vs at: rev through a fake CommandRunner; missing file → null; malformed YAML → fail arm with fm: null, raw body, and diagnostics; schema-drifted frontmatter (extra unknown key) → fail arm still yields status off the hydrated fm; valid file → success arm with a typed readTask field access narrowing on ok.
LocationKindChange
apps/sdlc/lib/model/read.tsnewGeneric readEntity core: ref resolution, working-tree/rev source seam, discriminated wrapper result (typed Doc success arm / hydrated-fm + raw-body fail arm).
apps/sdlc/lib/model/index.tsmodifyExport the read API.
apps/sdlc/lib/model/entities/task/read.tsnewreadTask: same wrapper with the success arm narrowed to the Task-typed Doc.
apps/sdlc/lib/model/ops/_update.tsmodifyPromote resolveInstanceDocPath / readInstanceDoc into the read module; import back.
apps/sdlc/lib/model/entities/task/ops/_task_doc.tsmodifyDelete duplicate parseFrontmatter; thin readTaskDoc over the read layer (or retire, callers moving).
apps/sdlc/lib/services/lease/ops/task/sweep.tsmodifystatusAtOriginMainreadTask(id, { at: 'origin/main', git: ctx.git }).
apps/sdlc/lib/services/lease/migration.tsmodifyreadTaskStatusreadRawFrontmatter (bulk walk over the tasks dir).
apps/sdlc/lib/model/corpus/loader.tsmodifyreadFrontmatter walk → readRawFrontmatter (bulk; keep DIR_TYPE as the plural map).
apps/sdlc/lib/model/entities/task/ops/_probe_core.tsmodifyLocal readFrontmatterreadRawFrontmatter.
apps/sdlc/lib/model/entities/task/ops/check-claims.tsmodifyfm plane off the wrapper (via readTaskDoc’s new fm field); raw text still handed to resolvers.
apps/sdlc/lib/services/dashboard/server.tsmodifyRoster/detail fm reads → readRawFrontmatter (bulk, polled per request).
apps/sdlc/lib/model/entities/task/ops/_lint_state_origin_core.tsmodifyfileAtRev raw fetch → shared rev-read seam.
apps/sdlc/lib/model/README.mdnewModel-access guide: read layer, contracts, registry, rev seam, how to consume the two result arms.
apps/sdlc/lib/CLAUDE.mdnewProgressive-disclosure pointer at the README + the no-hand-rolled-frontmatter rule.
apps/sdlc/lib/model/tests/read.test.tsnewUnit tests per Approach step 6.
  • AC-1: readEntity (generic) and readTask (typed) exist in the model layer with the { projectRoot, at?, git? } source seam, returning the discriminated wrapper; a test reads a task at a fake rev through an injected CommandRunner, narrows on ok, and asserts a typed status with no caller-side parseFrontmatter.
  • AC-2: sweep.ts has no parseFrontmatter import and no inline git show string-building; statusAtOriginMain is a read-layer call and sweep’s behavior is preserved — including yielding status off the fail arm’s hydrated fm for a schema-drifted file at a rev (regression test with a drifted fixture).
  • AC-3: the duplicate parseFrontmatter in _task_doc.ts is deleted, and none of the wave-1 files (sweep.ts, lease/migration.ts, corpus/loader.ts, _probe_core.ts, check-claims.ts, dashboard/server.ts) call parseFrontmatter/extractFrontmatter directly (grep-verifiable).
  • AC-4: the read never throws — missing source yields null; malformed YAML yields the fail arm with fm: null + raw body + diagnostics; schema-drifted frontmatter yields the fail arm with hydrated fm + raw body; a valid file yields the success arm’s typed Doc (tests cover all four).
  • AC-5: lib/model/README.md documents the read layer and apps/sdlc/lib/CLAUDE.md exists pointing at it with the use-the-read-layer rule.
  • AC-6: bun test passes for apps/sdlc, including the new read.test.ts and the existing frontmatter-convergence suite.
  • The byte-faithful WRITE engines (_update.ts update flow, migrate.ts transforms, readPrsFrontmatter/writePrsFrontmatter, skill-side writeFrontmatter / ensure_ready_mutate) — they parse-to-re-emit exact bytes and stay on their current seams; only their read-side path resolution may converge.
  • entities validate / entities audit — they need findings-as-data over parseFrontmatterResult and already share that seam; converging them is a follow-up once the read layer is proven.
  • Skill-side scripts (start_task.ts, ensure_ready_mutate.ts, dedup_search.ts, scan_validate_ac.ts, preflight_permissions.ts, scan_corpus_assumptions.ts) — wave-2 convergence after the lib-side layer lands.
  • Body-plane-only scanners (scan-placeholders, parse-touchpoints, resolve-touchpoints, gap-report) — candidates for the typed Doc body plane later; not blocked by this task.
  • Upstream markdown-contract changes (e.g. a native lenient read mode) — sdlc-side wrapper only this task.
  • Non-entity frontmatter parsers (services/docs/site.ts SKILL.md reads, site/supplemental.ts).
  • none
  • Captured as B-N9PM (promoted to this task) while reviewing PR #905 (T-Z698 lease sweep) on 2026-07-19: statusAtOriginMain hand-rolls git show + parseFrontmatter + field indexing.
  • Grounded by a full call-site survey (2026-07-19): ~15 lib-side frontmatter-parse sites over entity files, 5 readTaskDoc callers, 3 rev-read sites, zero Contract.read() consumers — six validate() sites produce the typed Doc and discard it.
  • The progressive-disclosure gap (no lib/-level CLAUDE.md/README chain) was called out in the promoting session as a root cause of recurrence.

← Back to Tasks