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.
| Location | Role today |
|---|---|
apps/sdlc/lib/util/frontmatter.ts | Canonical 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.ts | Task-only resolveTaskDocPath (three ref spellings) + readTaskDoc (working tree only, raw {path, text}) + a DUPLICATE local parseFrontmatter. |
apps/sdlc/lib/model/ops/_update.ts | The 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.ts | readFrontmatter (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.ts | contractForType(type) → Contract whose .read(source, ctx) returns the typed Doc — unused on every read path today. |
apps/sdlc/lib/services/lease/ops/task/sweep.ts | statusAtOriginMain: 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.ts | Its 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.ts | Another local readFrontmatter wrapper indexing arbitrary string fields for inflight / probe-state. |
apps/sdlc/lib/services/lease/migration.ts | readTaskStatus: readFileSync + parseFrontmatter + fm['status']. |
apps/sdlc/lib/services/dashboard/server.ts | Hand-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.ts | readTaskDoc + 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. |
Proposed
Section titled “Proposed”A model-layer read API, generic over entity type, source-aware:
readEntity(type, ref, opts)core inlib/model/read.ts.refaccepts the three established spellings (absolute path / project-relative path / bare basename-or-id).opts: { projectRoot, at?: string, git?: CommandRunner }— working tree by default;atreadsgit show <at>:<relpath>through the injectedCommandRunner(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 }—docis the strongly-typedDocthe contracts already produce (frontmatter plane typed by the per-entity Zod, body plane navigable).markdown-contractalready ships the safe substrate:contract.validate()never throws and returns{ findings, doc?, tree }withtree.frontmatter.datathe 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;nullwhen 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’sstatusAtOriginMainmust still yieldstatusofffmfrom 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:) →nulloverall, distinct from both arms.
- Success arm:
- Per-entity typed wrapper co-located per the placement convention:
readTask(ref, opts)underlib/model/entities/task/returning the same wrapper with the success arm narrowed to the Task-typedDoc. 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);
statusAtOriginMaincollapses toreadTask(taskId, { at: 'origin/main', git: ctx.git })+ a field access (doc.frontmatter.statuson the success arm,fm['status']on the fail arm,undefinedonnull). - 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 ontask 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 thinapps/sdlc/lib/CLAUDE.mdpointing at it with the one hard rule — never hand-roll frontmatter parsing overdocs/planning/entities; use the read layer (same pattern asapps/sdlc/skills/CLAUDE.mdandapps/sdlc/desktop/CLAUDE.md).
Approach
Section titled “Approach”- Extract the generic read module
lib/model/read.ts: promoteresolveInstanceDocPath(from_update.ts) as the path resolver, add the source seam (working tree |{at, git}viagit show <at>:<relpath>), and return the wrapper result, deriving BOTH arms from onecontractForType(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,nullon absent source; the call itself never throws and never parses twice. Export fromlib/model/index.ts. - Add
readTaskinlib/model/entities/task/read.ts(same wrapper, success arm narrowed to the Task-typedDoc; task ref spellings via the generic resolver). Decide during implementation whetherreadTaskDocin_task_doc.tsbecomes a thin re-export or its callers move; the duplicate localparseFrontmatterin_task_doc.tsis deleted either way. - 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.tsrosters (readTasks/readSimpleEntities). - Point
_lint_state_origin_core.ts#fileAtRevat the shared rev-read seam (it keeps its own before/after diff logic; only the raw fetch converges). - Write
lib/model/README.mdandapps/sdlc/lib/CLAUDE.md(progressive disclosure; pointer + one hard rule, details in the README). - Tests in
lib/model/tests/read.test.ts: the three ref spellings; working tree vsat:rev through a fakeCommandRunner; missing file →null; malformed YAML → fail arm withfm: null, rawbody, and diagnostics; schema-drifted frontmatter (extra unknown key) → fail arm still yieldsstatusoff the hydratedfm; valid file → success arm with a typedreadTaskfield access narrowing onok.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
apps/sdlc/lib/model/read.ts | new | Generic 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.ts | modify | Export the read API. |
apps/sdlc/lib/model/entities/task/read.ts | new | readTask: same wrapper with the success arm narrowed to the Task-typed Doc. |
apps/sdlc/lib/model/ops/_update.ts | modify | Promote resolveInstanceDocPath / readInstanceDoc into the read module; import back. |
apps/sdlc/lib/model/entities/task/ops/_task_doc.ts | modify | Delete duplicate parseFrontmatter; thin readTaskDoc over the read layer (or retire, callers moving). |
apps/sdlc/lib/services/lease/ops/task/sweep.ts | modify | statusAtOriginMain → readTask(id, { at: 'origin/main', git: ctx.git }). |
apps/sdlc/lib/services/lease/migration.ts | modify | readTaskStatus → readRawFrontmatter (bulk walk over the tasks dir). |
apps/sdlc/lib/model/corpus/loader.ts | modify | readFrontmatter walk → readRawFrontmatter (bulk; keep DIR_TYPE as the plural map). |
apps/sdlc/lib/model/entities/task/ops/_probe_core.ts | modify | Local readFrontmatter → readRawFrontmatter. |
apps/sdlc/lib/model/entities/task/ops/check-claims.ts | modify | fm plane off the wrapper (via readTaskDoc’s new fm field); raw text still handed to resolvers. |
apps/sdlc/lib/services/dashboard/server.ts | modify | Roster/detail fm reads → readRawFrontmatter (bulk, polled per request). |
apps/sdlc/lib/model/entities/task/ops/_lint_state_origin_core.ts | modify | fileAtRev raw fetch → shared rev-read seam. |
apps/sdlc/lib/model/README.md | new | Model-access guide: read layer, contracts, registry, rev seam, how to consume the two result arms. |
apps/sdlc/lib/CLAUDE.md | new | Progressive-disclosure pointer at the README + the no-hand-rolled-frontmatter rule. |
apps/sdlc/lib/model/tests/read.test.ts | new | Unit tests per Approach step 6. |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
readEntity(generic) andreadTask(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 injectedCommandRunner, narrows onok, and asserts a typedstatuswith no caller-sideparseFrontmatter. - AC-2:
sweep.tshas noparseFrontmatterimport and no inlinegit showstring-building;statusAtOriginMainis a read-layer call and sweep’s behavior is preserved — including yieldingstatusoff the fail arm’s hydratedfmfor a schema-drifted file at a rev (regression test with a drifted fixture). - AC-3: the duplicate
parseFrontmatterin_task_doc.tsis 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) callparseFrontmatter/extractFrontmatterdirectly (grep-verifiable). - AC-4: the read never throws — missing source yields
null; malformed YAML yields the fail arm withfm: null+ rawbody+ diagnostics; schema-drifted frontmatter yields the fail arm with hydratedfm+ rawbody; a valid file yields the success arm’s typedDoc(tests cover all four). - AC-5:
lib/model/README.mddocuments the read layer andapps/sdlc/lib/CLAUDE.mdexists pointing at it with the use-the-read-layer rule. - AC-6:
bun testpasses forapps/sdlc, including the newread.test.tsand the existing frontmatter-convergence suite.
Out of scope
Section titled “Out of scope”- The byte-faithful WRITE engines (
_update.tsupdate flow,migrate.tstransforms,readPrsFrontmatter/writePrsFrontmatter, skill-sidewriteFrontmatter/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 overparseFrontmatterResultand 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-contractchanges (e.g. a native lenient read mode) — sdlc-side wrapper only this task. - Non-entity frontmatter parsers (
services/docs/site.tsSKILL.md reads,site/supplemental.ts).
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”- Captured as
B-N9PM(promoted to this task) while reviewing PR #905 (T-Z698 lease sweep) on 2026-07-19:statusAtOriginMainhand-rollsgit show+parseFrontmatter+ field indexing. - Grounded by a full call-site survey (2026-07-19): ~15 lib-side
frontmatter-parse sites over entity files, 5
readTaskDoccallers, 3 rev-read sites, zeroContract.read()consumers — sixvalidate()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.