Ingestion tooling — the rapid framework-import loop (dev log)
Anchored to the implemented constructs — not ad-hoc
Section titled “Anchored to the implemented constructs — not ad-hoc”The harness must converge on the real, already-built ingestion constructs, not grow its own logic. The transformation/ETL design was settled in the 2026-05 design phase (schema-as-primitive, RML/YARRRML-retargeted recipe + JSONata expression layer, the closed 5-mechanism grammar) — and a code investigation (2026-06-03) confirmed what’s live in src/:
| Construct | Status | The harness should use it for |
|---|---|---|
spec/recipe.schema.json — import recipe (source.levels, target.layout[], also_emit, graph_edges reserved) | ✅ implemented + AJV-validated | the per-framework “config” is a recipe — not CLI flags |
render(recipe, identity) → Address (pure; folder/file/heading wired, tag/wikilink fail-fast in v0.2) | ✅ implemented (src/render/) | compute path / frontmatter / tags / aliases — instead of the harness’s own buildFrontmatter |
generateFromRecipe(app, data, recipe, …) | ✅ implemented (src/generation/) | the native recipe import path (Obsidian-bound — the headless harness calls the pure render() + writes via fs) |
Template filters lower | upper | title | slug | tagsafe | fs-safe | truncate(N) | trim | split(d,i) | regex(p) | ✅ closed set (src/render/template.ts) | recipe-expressed transforms (R2RML {var|filter}) |
| merged-cell forward-fill, the ~40-primitive catalog, JSONata-in-templates | ❌ design-only | the logic we build up as real frameworks demand it |
The retarget: the harness’s --map / --sheet / --header-row are an ad-hoc stand-in. The disciplined path is: parse source → build ConceptIdentity[] → feed a real Recipe (spec/recipe.schema.json) through the implemented render() → write Tier 1. Each snag then extends the real filter set / primitive catalog — building up reusable ETL logic against first-principles constructs, which is the entire point of running the corpus in practice.
The harness (the rapid loop)
Section titled “The harness (the rapid loop)”tools/generate-fixtures.ts — a headless bun CLI: source file → Tier 1 concept notes (schema-conformant), deterministic, drift-gated, no Obsidian. The fast iteration surface is: point at a real file → inspect the notes → tweak the config → rerun (seconds). It started CSV-only with hard-coded columns; this log tracks its growth into a multi-format, config-driven importer — driven entirely by what real frameworks demand.
Capabilities built
Section titled “Capabilities built”--map — config-driven column mapping · 2026-06-02
Section titled “--map — config-driven column mapping · 2026-06-02”- Snag: the harness hard-coded canonical column names (
id,description); real frameworks use their own (identifier,control_text) → 0 rows matched. - Fix:
--map "srcCol=role,…"aliases a framework’s real columns onto the canonical roles (id / name / title / family / parent / description). - Result: NIST 800-53 r5 (CSV) went 0 → 1,189 control notes.
XLSX reader — --sheet + --header-row · 2026-06-02
Section titled “XLSX reader — --sheet + --header-row · 2026-06-02”- Built workbook ingestion on the
xlsxlib: select a sheet (by name or 0-based index), treat a chosen row as the header (skips banner/preamble rows above it), coerce every cell to a trimmed string → feeds the same pipeline (so--mapstill applies). - Result: NIST CSF 2.0 (
csf2.xlsx, sheet"CSF 2.0", header row 1) → 185 subcategory concepts (the Function/Category group-header rows surface as empty-id and are skipped).
Recipe-driven render() path · 2026-06-03 — on the real rails
Section titled “Recipe-driven render() path · 2026-06-03 — on the real rails”- Refactored the harness to call the real
render(recipe, identity)engine (src/render, pure + obsidian-free) via--recipe <json>+--id <template>— dropping the bespokebuildFrontmatter. The per-framework config is now a Recipe (recipes/import/nist-800-53-flat.json), schema-shaped, withalso_emit.frontmatter.manageddriving fields. (Legacy--mappath retained → no fixture drift.) - Result: 800-53 → 1,189 notes rendered by the production engine; hyphens + parens preserved (
AC-2(1).md); provenance carriesrecipe.id+ hash. - Latent bug found + fixed (the payoff): running the engine headless exposed
src/render/template.ts’sfs-safefilter embedding literal control bytes (NUL0x00…0x1F) instead of\u0000-\u001fescapes — esbuild silently passed it; bun’s parser rejected it; and it had quietly broken the realistic-frameworks e2e (which expectsAC-2(1)filenames). Replaced with proper escapes; 37 render tests pass. This is exactly “build up real logic by running the corpus.”
split / regex / trim template filters · 2026-06-03 — field-split, on the rails
Section titled “split / regex / trim template filters · 2026-06-03 — field-split, on the rails”The first real-framework requirement became a real construct instead of harness glue. Many frameworks pack a code + name into one cell — CSF’s Subcategory column is "DE.AE-01: Adverse events are analyzed…". Rather than slice strings ad-hoc in the harness, this became three new entries in the closed filter set (src/render/template.ts), so any recipe — plugin, harness, or external producer — can express the split declaratively:
| Filter | Does | Example → result |
|---|---|---|
split(<delim>,<index>) | nth (0-based) segment, trimmed | {Subcategory|split(:,0)} → DE.AE-01 · {Subcategory|split(:,1)|trim} → Adverse events are analyzed… |
regex(<pattern>) | first match (or first capture group) | {Subcategory|regex([A-Z.]+-\d+)} → GV.OC-01 |
trim | strip surrounding whitespace | {name|trim} → unpadded name |
Filters chain ({Subcategory\|split(:,1)\|trim}) — the pipeline already ran filters left-to-right; these just join the set. Parser limits carry over: an arg can’t contain ) or \| (the template reserves them), so split handles the colon/dash cases and regex covers the rest.
- CSF recipe (
recipes/import/nist-csf-2.json) now usessplit(:,0)\|fs-safefor the filename andsplit(:,1)\|trimfor the title. - Result: CSF re-ingested through the real
render()→ cleanDE.AE-01.mdfilenames (was the messy full-text),subcategory_id: GV.OC-01, titles with the code stripped. 40 render tests pass (3 new: split / regex / trim, plus a regression test pinningfs-safehyphen/paren preservation). - Known edge → next filter: a description containing its own colon (
A: B: C) makessplit(:,1)return justB. Motivates a futureafter(<delim>)(“everything past the first delimiter”) filter — logged, not yet built.
JSON / nested-source reader — --iterator + --where · built 2026-06-12
Section titled “JSON / nested-source reader — --iterator + --where · built 2026-06-12”CSV + XLSX are flat row tables: one row → one concept, columns → fields. The next class of sources (STIX bundles, OSCAL catalogs, NIST CPRT exports — ~39 JSON files in the corpus) are nested: concepts live at a path inside a tree, not as top-level rows. The reader generalizes the existing pipeline rather than forking it:
- Logical source + iterator (the RML “logical source” idea): a config field names a JSONPath-style iterator — e.g.
$.objects[*]for a STIX bundle,$.catalog.groups[*].controls[*]for OSCAL — that yields one row-equivalent object per concept. Flat readers are the degenerate case (iterator = “each row”). - Field access stays dotted:
render()’s template scope already resolves dotted paths ({external_references.0.external_id}), so once the iterator yields an object, the same Recipe + same filters apply — no new expression language. Deeply nested or computed pulls are where JSONata-in-templates (design-only, deferred) would eventually slot in. - One reader interface, three formats:
readSource()already dispatches on extension (.csv/.xlsx);.jsonadds a third branch that runs the iterator and emits the sameRecord<string, unknown>[]the downstream pipeline expects.
This keeps the single coupling point (render(Recipe, ConceptIdentity) → Address) intact — JSON support is a new front-end reader, not a new engine.
Built 2026-06-12 as tools/lib/json-source.ts (importable + 19 Jest tests), wired into readSource() via two flags:
--iterator '$.objects[*]'— closed syntax, fail-fast likerender(): dotted keys +[*]fan-out only (multi-fan flattens, e.g.$.catalog.groups[*].controls[*]); indices/filters rejected with a pointed error. A top-level-array document needs no iterator; an object root without one errors listing the available keys.--where 'type=attack-pattern,revoked!=true'— comma-ANDed=/!=row filters on dotted paths; a missing field never=-matches and always!=-matches (sorevoked!=truekeeps unmarked rows).
Top-level scalars coerce to trimmed strings (the CSV/XLSX contract — --map and the id roles behave identically across formats); nested objects/arrays survive so recipe templates reach into them via the dotted paths render() already resolves.
Smoke results (real corpus files, real render()):
| Source | Invocation | Result |
|---|---|---|
MITRE ATT&CK STIX (enterprise-attack.json, 25,843 objects) | --iterator '$.objects[*]' --where 'type=attack-pattern,revoked!=true,x_mitre_deprecated!=true' + recipe mitre-attack-technique.json (--id '{external_references.0.external_id}') | ✅ 697 active techniques — clean T1055.011.md sub-technique filenames via the dotted-path template; full Tier 1 provenance |
| NIST CPRT CSF 2.0 export | --iterator '$.response.elements.elements[*]' --where 'element_type=subcategory' + --map 'element_identifier=id,text=description' | ✅ 185 subcategories — exactly matching the XLSX-derived count |
Finding: the CPRT JSON is a cleaner CSF source than csf2.xlsx — id and text arrive pre-split (no split(:,0) filters needed) and withdrawn entries are a separate element_type, so the XLSX path’s [Withdrawn title artifact can’t occur. Candidate: re-point the CSF recipe at CPRT and retire the XLSX snags.
Mapping-column melt — the SCF hub becomes 5,675 edges · 2026-06-12
Section titled “Mapping-column melt — the SCF hub becomes 5,675 edges · 2026-06-12”tools/crosswalk-from-melt.ts — the third extractor (concepts → OLIR edges → melt edges), with the shared edge-emission path factored into tools/lib/crosswalk-shared.ts. It unpivots a wide mapping-column sheet: one subject row × one framework column × each \r\n-listed id in the cell → one crosswalk-edge note, through the same render() + AJV gate.
- One pass over the SCF 2026.1 sheet (
--melt 'CIS CSC 8.1=cis-v8;NIST CSF 2.0=nist-csf-2;…') → 5,675 edges into 7 frameworks: CIS 429 · CSF 611 · 800-53 1,117 · ISO 27001 316 · ISO 27002 506 · SOC 2/TSC 1,478 · PCI DSS 1,218. - The STRM proxy, realized: the ISO / SOC 2 / PCI edges reference those frameworks’ control ids without carrying a word of their copyrighted text — coverage analysis into frameworks we can’t ingest.
- Predicates are honest: the flat columns carry no per-pair relationship type (SCF publishes that only in 185 per-framework STRM PDFs — below the input floor), so every melt edge is
skos:relatedMatch→intersects_with. If a structured STRM source lands, regenerate with real predicates. - Snag → construct: SCF writes CIS control-level refs as
N.0(1.0= Control 1) — 88 dead links until (a)--object-id-sub 'cis-v8=\.0$='normalization and (b) ingesting the 18 CIS control-level notes via the new format-agnostic--where(--where 'CIS Safeguard='selects the group-header rows —--wherenow applies to CSV/XLSX, not just JSON). - Link integrity, whole corpus: 23,396 wikilinks · 42 dead · 99.82% resolve. Every dead link is a genuinely-absent concept (IA-13, withdrawn enhancements, family-level 800-53 refs, CRI extension ids).
Navigable edges — --depad + folder-qualified wikilinks · 2026-06-12
Section titled “Navigable edges — --depad + folder-qualified wikilinks · 2026-06-12”The “edges are dead bare CURIEs in Bases” gap, closed at generation level (per the backlog memory):
--depad subject|object|both— canonicalizes OLIR’s zero-padded ids to NIST’s unpadded form (AC-01→AC-1,IR-04(02)→IR-4(2)). Only the 800-53 side depads — CSF/CRI zeros ARE canonical (GV.OC-01,DE.AE-02.01).--subject-note-folder/--object-note-folder— edge notes emitsubject_note/object_notefrontmatter wikilinks + a linked body. Folder-qualified ([[Frameworks/_licensed/NIST-800-53/AC-1|AC-1]]) because the synthetic NIST-mini fixtures share 8 basenames with the licensed set — bare basename links are ambiguous.- Hierarchy back-fill: OLIR maps at function/category/family level too; CSF function (6) + category (34) notes were generated from the CPRT JSON (
recipes/import/nist-csf-2-cprt.json) so group-level edges resolve. - Result: 7,732 wikilinks across the three edge sets, 99.5% resolve. The 16 residual unresolved ids are genuinely absent concepts — 3 family-level 800-53 refs (PT/CP/IR),
IA-13(added in r5.1.1, newer than our source), and ~11 CRI v2.2 extension ids that masquerade as CSF ids (EX,GV.AU-*,GV.RM-08/09,PR.PS-07). Don’t stub those as CSF notes — they’re CRI concepts wearing CSF-style names (a nice find: extension detection falls out of link-integrity checking).
Formatted-text fidelity (raw: false) — the CIS “4.10” trap · 2026-06-12
Section titled “Formatted-text fidelity (raw: false) — the CIS “4.10” trap · 2026-06-12”- Snag: CIS stores safeguard 4.10 as the number
4.1with display text"4.10". The XLSX reader’sString(value)produced"4.1"— silently colliding with safeguard 4.1 (second note overwrites the first; 152 files where there should be 153, one of them wrong). - Fix: the XLSX reader now reads formatted text (
sheet_to_jsonraw: false) — every cell is what Excel displays, which is the contract the string-coercion already promised. Regression-verified: CSF (185) and CRI (472) re-renders are byte-identical (modulo timestamp). - Result: CIS v8.1.2 ingests clean —
4.1.mdand4.10.mdboth exist.
Header-key normalization · 2026-06-03
Section titled “Header-key normalization · 2026-06-03”- Snag: real workbooks bake line breaks into header cells — NIST’s crosswalk has
Focal Document\r\nElement; CRI’s structure sheet hasProfile\r\nId. A recipe referencing{Profile Id}silently matched nothing (the actual key wasProfile\r\nId). - Fix: the XLSX reader now normalizes header keys — collapse internal whitespace + trim — so recipes reference clean single-space names. CSV path unaffected (no fixture drift).
- Result: one small fix unblocks three frameworks — CRI ingestion and both NIST-OLIR crosswalks share the same newline-in-header layout.
End-to-end slice (depth-first): CSF 2.0 ↔ 800-53 ↔ CRI
Section titled “End-to-end slice (depth-first): CSF 2.0 ↔ 800-53 ↔ CRI”Decision (2026-06-03): stop accumulating import recipes that dead-end at Tier 1; drive a small set all the way to a rendered Bases pivot instead. One real crosswalk between real frameworks proves the entire product thesis — expansion afterward is just “add a recipe.” (Per the operational landscape: depth-first maximizes the Tier 1–2 leverage before fanning out Tier 3 configs.)
Why these three: CRI Profile is a financial-sector (FI) profile — not a generally-applicable framework — and its workbook is itself a crosswalk hub: it carries an inline NIST CSF mapping column and ships a companion CRI→800-53 mapping. Three nodes therefore yield three real edges, two of which share the identical NIST OLIR layout.
| Tier | Node (concepts) | Edge (crosswalk) |
|---|---|---|
| Public (commit-safe) | NIST 800-53 r5 · NIST CSF 2.0 | CSF → 800-53 (NIST official crosswalk) |
| FI / licensed (local-only) | CRI Profile v2.2 (F·C·S·DS) | CRI → 800-53 · CRI → CSF |
Copyright handling: CRI’s source workbook + every rendered note stay in a gitignored vault folder (test-vault/Frameworks/_licensed/). What’s committed = the constructs (CRI recipe, header-norm fix, the OLIR→SSSOM reader, tests) — no copyrighted prose. CRI notes also stamp sector: financial-services, so the FI-scoping travels with the data.
Stage A — nodes → Tier 1 (done 2026-06-03): all three through the production render(), into the gitignored slice folder.
| Node | Source | Notes |
|---|---|---|
| NIST 800-53 r5 | …catalog_load.csv | 1,189 |
| NIST CSF 2.0 | csf2.xlsx | 185 |
| CRI Profile v2.2 | CRI structure sheet (header row 2) | 472 (7 F · 28 C · 119 S · 318 DS) |
Stage B — the thesis (done 2026-06-03): one reusable OLIR→SSSOM crosswalk reader. Both the public CSF→800-53 crosswalk and the CRI→800-53 crosswalk use the identical NIST OLIR layout (Focal Document Element / Reference Document Element / Relationship / Strength of Relationship), so tools/crosswalk-from-olir.ts serves them all. It maps the OLIR layout → crosswalk-edge Tier 1 notes via the real render() (kind: 'crosswalk-edge', wired in v0.1.6) + the generic recipes/import/crosswalk-edge.json, and emits a standard SSSOM TSV as a shareable artifact. Every note is gated by the same validateTier1Frontmatter (AJV) the plugin uses — schema-valid or not written.
The predicate chain is OLIR → SKOS → STRM (reuse, not new logic):
OLIR Relationship | → SKOS (in the SSSOM TSV) | → STRM predicate_id (in the note) |
|---|---|---|
equal | skos:exactMatch | is_equivalent_to |
subset of | skos:broadMatch | is_narrower_than (fixed 2026-06-12; was is_broader_than ⚠️) |
superset of | skos:narrowMatch | is_broader_than (fixed 2026-06-12; was is_narrower_than ⚠️) |
intersects with (+ blank) | skos:relatedMatch | intersects_with |
Strength (0–10) → match_confidence (÷10, 0–1). SKOS_TO_STRM mirrors the (module-private) map in src/import/sssom-importer.ts.
Results (Stage B):
| Edge | Source | Edges | Predicates |
|---|---|---|---|
| CSF 2.0 → 800-53 (public, commit-safe) | NIST official crosswalk | 740 | all intersects_with (no rel-type in source) |
| CRI → 800-53 (FI, local-only) | CRI→800-53 OLIR (20 family sheets) | 1,039 | 71 equivalent · 179 broader · 278 narrower · 511 intersects |
| CSF 2.0 → CRI (FI, local-only) · added 2026-06-12 | CRI workbook’s own “NIST CSF v2 Mapping” sheet (--header-row 3 + --subject-col/--object-col aliases — the layout is OLIR-shaped, just differently labeled) | 154 (0 skipped) | 49 exact · 92 broader · 13 related — the third edge of the proving-ground triangle; same direction-snag caveat as CRI→800-53 |
✅ Direction snag — resolved 2026-06-12. It WAS a bug: the original SKOS_TO_STRM inverted standard SKOS. Fixed in both src/import/sssom-importer.ts and the tool mirror — skos:broadMatch → is_narrower_than (per SKOS, A broadMatch B ⇒ B is broader ⇒ A ⊂ B), skos:narrowMatch → is_broader_than. The convention is pinned by a unit test (“SKOS→STRM direction convention” in tests/sssom-importer.test.ts); all three edge sets regenerated; committed SSSOM TSVs unaffected (they store the SKOS wire format — exactly why sssom_predicate is preserved). Sanity anchor: nist-csf-2:DE is_narrower_than cri-profile:DE (CRI extends CSF’s DETECT).
Stages C/D (manual — the part headless can’t reach): open the slice in Obsidian → Tier 2 sidecar indexes → the crosswalkerPivot coverage matrix renders. Step-by-step (with regen commands, since the slice is gitignored): TEST_CROSSWALK_PIVOT.md.
Snags surfaced (the live backlog)
Section titled “Snags surfaced (the live backlog)”Running real frameworks is the requirements process. What’s surfaced so far:
| Snag | Seen on | What’s needed |
|---|---|---|
✅ Combined id + text cells — Subcategory = "DE.AE-01: Adverse events…" (code + name in one cell) | CSF 2.0 | Resolved → split / regex filters (above): split(:,0) → id, split(:,1)|trim → title |
✅ Newline-in-header keys — Profile\r\nId, Focal Document\r\nElement → recipe {Profile Id} matched nothing | CRI, NIST OLIR crosswalks | Resolved → header-key normalization in the XLSX reader (above) |
Merged-cell forward-fill — Function/Category sit only on group-header rows; empty on the child rows → family blank | CSF 2.0, CRI (Tier-1..4) | forward-fill hierarchy columns down into child rows (then group-headers can become parent concepts too) |
| Empty-id rows skipped silently | 800-53, CSF | acceptable default; consider a --strict skipped-row report |
⚠️ SKOS→STRM direction inverts standard SKOS — broadMatch → is_broader_than reads “subject broader” but standard SKOS A broadMatch B ⇒ B broader | CRI→800-53 (directional edges) | ✅ resolved 2026-06-12 — map flipped in src + tool, unit-test pinned, edge sets regenerated (see above) |
Per-sheet banner rows — OLIR family sheets (AT/AU/… after the first Relationships-AC) shift the header row → 803/1842 CRI→800-53 rows skipped | CRI→800-53 | 🟡 partial 2026-06-12 — --header-row added to crosswalk-from-olir.ts (fixes uniform banner offsets, e.g. the CSF→CRI sheet); the per-sheet varying case still needs detection |
| Per-framework invocation is a long CLI string | all | promote --map / --sheet / --header-row into a config file — the Tier-3 “as-code” unit |
Ingestion scoreboard
Section titled “Ingestion scoreboard”| Framework | Format | Invocation (abridged) | Result |
|---|---|---|---|
| NIST 800-53 r5 | CSV | recipe nist-800-53-flat.json via real render() | ✅ 1,189 controls |
| NIST CSF 2.0 | XLSX | recipe nist-csf-2.json (split filters) via real render() | ✅ 185 subcategories — clean DE.AE-01.md ids + split titles; hierarchy still needs forward-fill |
| CRI Profile v2.2 | XLSX | recipe cri-profile-v2-2.json (header row 2; FI-sector, local-only) | ✅ 472 (F·C·S·DS); sector: financial-services stamped |
| CSF 2.0 → 800-53 (edges) | XLSX (OLIR) | crosswalk-from-olir.ts → render() kind: crosswalk-edge + SSSOM TSV | ✅ 740 schema-valid edges (public; pivot-ready) |
| CRI → 800-53 (edges) | XLSX (OLIR ×20 sheets) | crosswalk-from-olir.ts (local-only) | ✅ 1,039 edges; predicate variety + confidence; direction snag flagged |
| MITRE ATT&CK (techniques) | JSON (STIX) | recipe mitre-attack-technique.json (--iterator '$.objects[*]' + --where) | ✅ 697 active techniques (smoke; sub-technique ids via dotted paths) |
| NIST CSF 2.0 (via CPRT) | JSON (CPRT) | --iterator '$.response.elements.elements[*]' + --where element_type=subcategory | ✅ 185 (smoke) — cleaner source than csf2.xlsx; candidate to replace it |
| CIS Controls v8.1.2 | XLSX | recipe cis-controls-v8.json (safeguard level; IG1-3 in frontmatter; local-only) | ✅ 153 safeguards — exactly the official count; 4.10 collision cured by raw: false |
| SCF 2026.1.1 | XLSX | recipe scf-2026-flat.json (1,468 controls; local-only) | ✅ 1,468 controls; the ~250 framework-mapping columns await the mapping-column melt → crosswalk edges extractor (named next step) |
Next — retarget onto the implemented constructs
Section titled “Next — retarget onto the implemented constructs”- ✅ Refactor the harness onto the real
render()+ aRecipe— done 2026-06-03 (800-53 renders through the production engine; legacy path retained). - ✅ field-split →
split/regex/trimfilters added to the closed set — done 2026-06-03 (CSF ids + titles clean throughrender(); 40 render tests). Follow-on: anafter(<delim>)filter for multi-colon descriptions. - ✅ Header-key normalization → XLSX reader collapses newline-laden header keys — done 2026-06-03 (unblocks CRI + both OLIR crosswalks).
- ✅ Stage B — OLIR→SSSOM crosswalk reader (the thesis) — done 2026-06-03:
crosswalk-from-olir.ts→render()crosswalk-edge notes + SSSOM TSV; CSF→800-53 (740, public) + CRI→800-53 (1,039, local), all AJV-validated. Now: (a) Stages C/D — render the pivot in Obsidian perTEST_CROSSWALK_PIVOT.md; (b) ✅ SKOS→STRM direction convention — resolved 2026-06-12 (map was inverted; fixed + test-pinned); (c) ✅ CSF→CRI — done 2026-06-12: the workbook’s “NIST CSF v2 Mapping” sheet turned out to be OLIR-shaped (not a per-row column extract) —--header-row 3+ column aliases on the existing tool → 154 edges, 0 skipped, completing the triangle; (d) per-sheet header detection to recover the 803 skipped CRI rows (the new global--header-rowcovers single-sheet banner offsets; the multi-sheet case still needs detection). - merged-cell forward-fill → a row pre-transform (the cross-structural forward-fill primitive) so hierarchy columns (CSF Function/Category, CRI Tier-1..4) populate child rows — then group-headers become parent concepts too.
- AJV-validate recipes inside the harness → run each
--recipeJSON through AJV againstspec/recipe.schema.jsonbefore rendering (see the callout above), so a malformed recipe fails fast with a precise error instead of a confusing render. - ✅ JSON reader (logical source + iterator path) — done 2026-06-12:
--iterator+--wherein the harness (tools/lib/json-source.ts, 19 tests); smoke-verified on ATT&CK STIX (697 techniques viarender()) + CPRT CSF (185). Unblocks the ~39-file JSON corpus (STIX / OSCAL / CPRT / Navigator layers). Follow-ons: YAML branch (same iterator idea), re-point CSF at CPRT. - SCF mapping-column melt → crosswalk edges — the SCF sheet’s ~250 per-framework mapping columns are the STRM proxy into copyrighted frameworks (ISO / SOC 2 / PCI / COBIT / HITRUST) without their text. Shape: unpivot — one column × one non-empty cell → N crosswalk-edge notes (
scf:GOV-01 → iso-27001:A.5.1). A new extractor in thecrosswalk-from-olir.tsfamily, not an OLIR layout.
Related
Section titled “Related”- GRC corpus end-to-end (use-case map) — the high-level pipeline this builds stages 1–2 of
- URM ingestion readiness (per-framework checklist) — the scoreboard generalized to the whole corpus: every framework → floor status → named path to Tier 1
- Building the unified model — the destination ontology
- ETL & import · Hierarchy primitives · Framework data sources · Operational landscape (the work tiers)