Skip to content
🚧 Early alpha — building the foundation. See the roadmap →

Ingestion tooling — the rapid framework-import loop (dev log)

Created Updated

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/:

ConstructStatusThe harness should use it for
spec/recipe.schema.json — import recipe (source.levels, target.layout[], also_emit, graph_edges reserved)✅ implemented + AJV-validatedthe 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-onlythe 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.

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.

--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 xlsx lib: 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 --map still 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 bespoke buildFrontmatter. The per-framework config is now a Recipe (recipes/import/nist-800-53-flat.json), schema-shaped, with also_emit.frontmatter.managed driving fields. (Legacy --map path retained → no fixture drift.)
  • Result: 800-53 → 1,189 notes rendered by the production engine; hyphens + parens preserved (AC-2(1).md); provenance carries recipe.id + hash.
  • Latent bug found + fixed (the payoff): running the engine headless exposed src/render/template.ts’s fs-safe filter embedding literal control bytes (NUL 0x00…0x1F) instead of \u0000-\u001f escapes — esbuild silently passed it; bun’s parser rejected it; and it had quietly broken the realistic-frameworks e2e (which expects AC-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:

FilterDoesExample → 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
trimstrip 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 uses split(:,0)\|fs-safe for the filename and split(:,1)\|trim for the title.
  • Result: CSF re-ingested through the real render() → clean DE.AE-01.md filenames (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 pinning fs-safe hyphen/paren preservation).
  • Known edge → next filter: a description containing its own colon (A: B: C) makes split(:,1) return just B. Motivates a future after(<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:

  1. 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”).
  2. 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.
  3. One reader interface, three formats: readSource() already dispatches on extension (.csv / .xlsx); .json adds a third branch that runs the iterator and emits the same Record<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 like render(): 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 (so revoked!=true keeps 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()):

SourceInvocationResult
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 — --where now 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).
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 emit subject_note/object_note frontmatter 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.1 with display text "4.10". The XLSX reader’s String(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_json raw: 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.md and 4.10.md both exist.
  • Snag: real workbooks bake line breaks into header cells — NIST’s crosswalk has Focal Document\r\nElement; CRI’s structure sheet has Profile\r\nId. A recipe referencing {Profile Id} silently matched nothing (the actual key was Profile\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.

TierNode (concepts)Edge (crosswalk)
Public (commit-safe)NIST 800-53 r5 · NIST CSF 2.0CSF → 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.

NodeSourceNotes
NIST 800-53 r5…catalog_load.csv1,189
NIST CSF 2.0csf2.xlsx185
CRI Profile v2.2CRI 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)
equalskos:exactMatchis_equivalent_to
subset ofskos:broadMatchis_narrower_than (fixed 2026-06-12; was is_broader_than ⚠️)
superset ofskos:narrowMatchis_broader_than (fixed 2026-06-12; was is_narrower_than ⚠️)
intersects with (+ blank)skos:relatedMatchintersects_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):

EdgeSourceEdgesPredicates
CSF 2.0 → 800-53 (public, commit-safe)NIST official crosswalk740all intersects_with (no rel-type in source)
CRI → 800-53 (FI, local-only)CRI→800-53 OLIR (20 family sheets)1,03971 equivalent · 179 broader · 278 narrower · 511 intersects
CSF 2.0 → CRI (FI, local-only) · added 2026-06-12CRI 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.

Running real frameworks is the requirements process. What’s surfaced so far:

SnagSeen onWhat’s needed
✅ Combined id + text cells — Subcategory = "DE.AE-01: Adverse events…" (code + name in one cell)CSF 2.0Resolved → 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 nothingCRI, NIST OLIR crosswalksResolved → 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 blankCSF 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 silently800-53, CSFacceptable 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 broaderCRI→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 skippedCRI→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 stringallpromote --map / --sheet / --header-row into a config file — the Tier-3 “as-code” unit
FrameworkFormatInvocation (abridged)Result
NIST 800-53 r5CSVrecipe nist-800-53-flat.json via real render()✅ 1,189 controls
NIST CSF 2.0XLSXrecipe 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.2XLSXrecipe 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.2XLSXrecipe 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.1XLSXrecipe 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”
  1. ✅ Refactor the harness onto the real render() + a Recipe — done 2026-06-03 (800-53 renders through the production engine; legacy path retained).
  2. ✅ field-split → split / regex / trim filters added to the closed set — done 2026-06-03 (CSF ids + titles clean through render(); 40 render tests). Follow-on: an after(<delim>) filter for multi-colon descriptions.
  3. ✅ Header-key normalization → XLSX reader collapses newline-laden header keys — done 2026-06-03 (unblocks CRI + both OLIR crosswalks).
  4. ✅ 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 per TEST_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-row covers single-sheet banner offsets; the multi-sheet case still needs detection).
  5. 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.
  6. AJV-validate recipes inside the harness → run each --recipe JSON through AJV against spec/recipe.schema.json before rendering (see the callout above), so a malformed recipe fails fast with a precise error instead of a confusing render.
  7. ✅ JSON reader (logical source + iterator path) — done 2026-06-12: --iterator + --where in the harness (tools/lib/json-source.ts, 19 tests); smoke-verified on ATT&CK STIX (697 techniques via render()) + 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.
  8. 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 the crosswalk-from-olir.ts family, not an OLIR layout.