# SSP write surfaces — per-surface detail, payload examples, routing, and failure modes

Detail companion to `skills/ssp/SKILL.md` (the workflow primer). Field schemas live in `reference-docs/halo-ssp-asset-schemas.md`; platform write shapes in `reference-docs/halo-platform/write-semantics.md`.

**Tenant ids are examples, never constants** — every numeric CF/field/dtype/relationship id below follows the `halo-ssp-asset-schemas.md` resolve-live rule: resolve names → ids at run start (`asset_type_fields`, catalog reads) and fail loudly on a miss.

## The SSP write surfaces

The spine has **six asset types** (Capability, Component, Control Implementation, Implemented Requirement, Implementation Statement, Set Parameter) plus the Site-level `system_information` surface.

| Surface | Tools | Used for |
|---|---|---|
| **Capability** (dtype 178) | `create_asset` → `update_asset` → `set_asset_relationships` | The 15 seeded top-level groupings + tenant additions. Children: Components. AFUUID: `CAP-<SystemID>-<Acronym>` (e.g. `CAP-ACME-IAM`). |
| **Component** (dtype 173) | `create_asset` → `update_asset` → `set_asset_relationships` | Specific tools / services / policies / documents. Children: Implementation Statements. AFUUID: `CMPN-<NAME>`. |
| **Control Implementation** (dtype 192) | `create_asset` → `update_asset` → `set_asset_relationships` | The OSCAL `control-implementation` envelope. One per (Site × Catalog) — one per entry on `Site.CFBaselines`. Children: Implemented Requirements via rel 10, system-wide Set Parameters via rel 11. AFUUID: `CI-<SystemName>-<CATALOGSHORT>` (e.g. `CI-ACME-FR5CC`). CFBaseline (id 503, single-select scalar) carries the structured catalog binding. |
| **Implemented Requirement** (dtype 190) | `create_asset` → `update_asset` → `set_asset_relationships` | The per-control row. One per (Catalog, Control-ID, Enhancement). Parent: one Control Implementation (rel 10). Children: Implementation Statements via rel 7, per-control Set Parameters via rel 12. AFUUID: `IR-<CATALOG>-<CONTROL>`. |
| **Implementation Statement** (dtype 191) | `create_asset` → `update_asset` → `set_asset_relationships` | The atomic narrative leaf. One per (Component, requirement-intent). Two parents: one Component (rel 8), N Implemented Requirements (rel 7, one per catalog). AFUUID: `IS-<CMPN>-<ACRONYM>`. |
| **Set Parameter** (dtype 193) | `create_asset` → `update_asset` → `set_asset_relationships` | The OSCAL `set-parameter` leaf — the organization's CHOSEN VALUE for a control's `[Assignment: organization-defined ...]` (ODP) slot. Parent is the IR (rel 12, Scope=`Control`, per-control ODP) OR the CI (rel 11, Scope=`System`, system-wide ODP) — never orphaned on the Site. AFUUID: `SP-<SystemName>-<CATALOGSHORT>-<CONTROL>[-<REQSUFFIX>]-<IDX>`. |
| **system_information** (Site-level, NOT a Device) | `update_system_information` | SSP front-matter: System Name, FIPS-199, baselines, narratives. One row per Site. |

Null-handling on all these surfaces: `update_asset` is merge-on-omit (an omitted `fields[]`/`customfields[]` entry is preserved; a sent entry replaces that field's whole value, so read-merge-write applies only to multi-select adds) — `reference-docs/halo-platform/write-semantics.md` §2.

## Library-first sourcing (CTComponents + CTImplementationStatements) — mechanics

**Components — CTComponents library:**
1. `component_definitions_summary` → browse by (Provider, Offering), get row ids.
2. `lookup_component_definition(component_definition_id)` → full definition (Acronym, Title, concept-only Description/Purpose, Component Type, Provider, Offering, FedRAMP ID).
3. Create the Component shell (`create_asset`, inventory_number `CMPN-{Acronym}`, dtype 173) → `update_component_from_library(asset_id, component_definition_id)` stamps the definition fields onto the asset. **The library stamp's internal Edit-Asset is full-replace on fields[]** — run it BEFORE writing the non-definition fields (CFBaseline, CMMC Asset Type), and re-apply those after any later library re-sync. The subsequent MCP `update_asset` itself is omit-preserves per [[halo-ssp-asset-write-conventions]] Rule 1 — send only the additions.

**Implementation Statements — CTImplementationStatements library:**
1. `implementation_statement_definitions_summary` → one row per Component acronym with definition row ids + statement acronyms (coverage check).
2. `lookup_implementation_statement_definition(id-or-acronym)` → Title, Statement narrative, owning Component, [Mapped Control IDs] (the unified comma-list), AND six **pre-split per-framework lists** that map 1:1 onto the IS asset CFs: [FedRAMP Controls]→382, [FedRAMP KSIs]→384, [CMMC Controls]→383, [FedRAMP Rules]→597, [FedRAMP Requirements]→399, [CMMC Requirements]→400. The library already routed each ID to its framework by shape — do NOT re-parse [Mapped Control IDs] yourself.
3. Populate the live IS asset (dtype 191). **Preferred — `update_implementation_statement_from_library(asset_id, definition_id, baseline)`** where the tenant carries it: one call stamps UUID/Title/Statement plus the baseline-gated framework CFs (one baseline per call). Precondition: confirm the baseline appears in `Site.CFBaselines` first — only site-legal baselines are usable (or testable; an ordering experiment against a non-legal baseline is unfalsifiable, not passing). The tool guards this itself at v1.0.5+: an illegal baseline or unknown acronym FAILS the run at step 1 and leaves the asset untouched. It is idempotent: **populate → `get_asset` read-back → re-run once if the framework CFs came back empty** (first-write lookup-CF race). It deliberately does NOT touch Implementation State (225), Control Origination (222), or Responsible Role (224) — per-instance policy, set via `update_asset` after. Fallback (tool absent from the tenant): instantiate manually from the Statement text — **verbatim by default**; site-specific tailoring is a reviewed deviation recorded in the CHANGE-PLAN, never a silent rewrite (the library row stays the source of truth — propose library edits out-of-band). Copy each split list straight onto its CF, **filtered to the Site's baselines** (on this raw `update_asset` path, out-of-baseline names silently drop — the underlying CF write has no guard), in the standard two-pass: Controls/KSIs/Rules (382/384/383/597) in pass 1, then Requirements (399/400) in pass 2.

Library definitions are templates, NOT live assets — instantiating one does not link it; all relationship wiring (Capability↔Component rel 6, Component↔IS rel 8, IR↔IS rel 7) still follows the canonical chain.

## The canonical new-asset write chain

```
For each NEW SSP asset (Capability / Component / IR / IS / SP):

  1. asset_types
       → resolve assettype_id (178 / 173 / 190 / 191 / 193)
  2. create_asset(inventory_number=<AFUUID>, assettype_id, client_id, site_id)
       → returns new asset_id
  3. asset_type_fields(assettype_id) [+ asset_type_allowed_statuses if setting status]
       → confirm field set for this asset type
  4. update_asset(payload={ ...full body... })
       → identity + narrative + framework Controls/KSIs land (Controls+KSIs in pass 1 if IS)
  5. update_asset(payload={ ...preserved + Requirements... })   ← Implementation Statement only
       → Requirements land against the now-stable Controls
  6. set_asset_relationships(...)
       → (after ALL IS rows of a run land) IR Description rollup: rewrite each touched
         IR's Description as a 2-3 sentence summary of its child IS narratives — the
         control-level "how the system meets the objective". Narrow update — send only
         the Description field (merge-on-omit, write-semantics.md §2).
       → wire into the SSP spine
            Capability → Component        rel 6
            Component  → IS               rel 8
            IR         → IS               rel 7   (one per catalog the IS satisfies)
            IR         → SP               rel 12  (per-control ODP; SP Scope=Control)
            CI         → SP               rel 11  (system-wide ODP; SP Scope=System)
            Cloud Asset → Component       rel 9
  7. get_asset(asset_id=<new>)
       → verify every field landed
```

## Payload examples per type

For an Implementation Statement specifically, the field set in chain steps 4/5 looks like:

```jsonc
update_asset(payload={
  "id": <new>,
  "status_id": <status>,
  "fields": [
    {"id": 209, "value": "IS-KMS-ROT"},                                        // UUID
    {"id": 213, "value": "AWS KMS – Customer-managed keys with annual rotation"}, // Title
    {"id": 225, "value": 1},                                                    // Implementation State (validate-V)
    {"id": 222, "value": [{"value":"1","label":"Organization"}]},               // Control Origination (validate-W array)
    {"id": 224, "value": 1},                                                    // Responsible Role (validate-V)
    {"id": 228, "value": "AWS KMS"},                                            // Related Component (free-text mirror)
    {"id": 229, "value": "<Implementation Statement narrative>"}                // The narrative payload
  ],
  "customfields": [
    {"id": 503, "value": "FedRAMP 20x Class C"},                                // CFBaseline — REQUIRED, every IS. Verbatim Site.CFBaselines name as a scalar STRING. NEVER the numeric lookup id (see [[halo-ssp-asset-write-conventions]] § CFBaseline write shape).
    {"id": 382, "value": [{"id":"0","name":"SC-12"},{"id":"0","name":"SC-13"}]}, // FedRAMP Controls
    {"id": 384, "value": [{"id":"0","name":"KSI-SVC-SNT"}]},                     // FedRAMP KSIs
    {"id": 597, "value": [{"name":"VDR-TFR-KEV"}]},                              // FedRAMP Rules (FRR; only if site has a FedRAMP baseline)
    {"id": 383, "value": [{"id":"0","name":"SC.L2-3.13.10"}]}                    // CMMC Controls (only if site has CMMC baseline)
  ]
})
// Two-phase CF discipline ([[halo-ssp-asset-write-conventions]]): CFBaseline (503, by name) commits first; pass 2 re-sends 503 + Controls/KSIs/Rules; pass 3 re-sends all + Requirements (399, 400). The IS MUST carry 503 — omitting it leaves the Baseline blank in the SSP render.
```

For an Implemented Requirement (dtype 190), the field set is much thinner — 3 Asset Fields only (UUID, Title, Description) plus the framework CFs (Controls 382, Requirements 399, KSIs 384, **FedRAMP Rules 597**, CMMC Controls 383, CMMC Requirements 400). An IR typically carries exactly ONE dimension matching its control's shape (e.g. an FRR-rule IR like `IR-…-VDR-TFR-KEV` carries only `CFAFedRAMPRules`). The Title typically is `{Control-ID}: {Control Name}` (e.g. `AC-02: Account Management`).

For a Set Parameter (dtype 193), the field set is the five per-type Asset Fields — UUID (209), Title (213, a value-free stable handle, NOT the value), Parameter Index (237), Parameter Label (238), Parameter Scope (239, `Control` | `System`) — plus Parameter Value (236, the long free-form field that carries the OSCAL `set-parameter.value`). The global framework CFs (CFBaseline 503 + 382/384/383) follow the same two-pass discipline when populated. After the values land, the SP is wired to its single parent: rel 12 on the IR when Scope=`Control` (per-control ODP), or rel 11 on the CI when Scope=`System` (system-wide ODP). System-wide SPs parent to the CI, never left orphaned on the Site. See [[halo-ssp-asset-model]] for the full SP schema and [[halo-ssp-asset-write-conventions]] for the SP write order.

## Set Parameter write sequence (dtype 193)

An SP is hand-written one at a time against an already-existing parent (IR or CI):

```
1. create_asset(assettype_id=193, inventory_number=<AFUUID>)   → returns asset_id, lands Active
2. update_asset (Asset Fields)   → UUID (209), Title (213), Parameter Index (237),
                                    Parameter Label (238), Parameter Scope (239), Parameter Value (236)
3. two-phase CF write (write-semantics.md § 6 Gotcha 2)   → CFBaseline (503) alone, THEN the framework control
4. set_asset_relationships on the PARENT (IR for Scope=Control rel 12, CI for Scope=System rel 11)
       → full-replace: echo the parent's existing children + add this SP
5. get_asset(asset_id)   → verify Asset Fields AND CFs landed
```

**Restricted-status asset types create Active directly** (Create Asset runbook ≥ v0.2.1 runs a post-create Activate step on every type). The former create-as-193-then-type-flip workaround is obsolete — do not use it.

The relationship is written on the **parent's** child set — full-replace, so re-send the parent's existing children or you orphan them. The **bulk / idempotent path** is `reconcile_ssp_relationships_ir_sp` (rel 12, Scope=`Control`) and `reconcile_ssp_relationships_ci_sp` (rel 11, Scope=`System`) — diff-and-create sweeps like the other reconcilers. Use the single-asset sequence only when standing up or correcting one SP.

## Existing-asset update chain

For each EXISTING SSP asset touched by ingest:

```
1. get_asset(asset_id=N)
     → READ full state including UUID, every Asset Field, every Custom Field
2. compute merged payload: existing values + ingest's intended changes
3. update_asset(payload={...changed fields; re-send the full union for any multi-select being appended to...})
     → merge-on-omit: omitted entries preserve, a sent entry replaces that field's
       whole value (write-semantics.md §2.1) — the pre-read is REQUIRED only when
       appending to a multi-select; ingest reads anyway to compute the diff
4. If framework CFs changed: split into Controls-then-Requirements two-pass
5. If parenting changed: set_asset_relationships(...)
6. get_asset(asset_id=N) → VERIFY
```

## Routing — when to write at which layer

Decisions you'll commonly face during ingest:

| Evidence describes... | Write where |
|---|---|
| A new functional area not in the 15 (e.g. "DLP program") | Stage as new Capability via the Proposed-New-Capability path. Get approval in CHANGE-PLAN. |
| A specific tool or service | Component. If the tool maps to multiple Capabilities (e.g. Defender XDR covers Endpoint + Logging), pick primary; see [[ssp-capability-seed-list]] § Cross-cutting Component note. |
| A new baseline added to `Site.CFBaselines` | Control Implementation (dtype 192). One per (Site × Catalog). AFUUID `CI-<SystemName>-<CATALOGSHORT>` (e.g. `CI-ACME-FR5CC`), short code resolved from `CTBaselines.CFBLShortCode`. CFBaseline=verbatim baseline name. |
| A specific control or KSI being in scope (no Component yet) | Implemented Requirement (dtype 190). One per (Catalog, Control-ID). Title `{Control-ID}: {Control Name}`. Parent CI via rel 10. |
| "How {Component} implements a specific requirement (Control statement, KSI outcome, CMMC practice)" | Implementation Statement (dtype 191). One per (Component, requirement-intent). Framework IDs (Control / KSI / CMMC) attach via the framework CFs AND the parent IR rel-7 links, NOT the IS Title or AFUUID. |
| FIPS-199 categorization, service/deployment model, baselines, narratives | `system_information` only. Never replicate to Capability/Component/IR/IS. |
| A control's framework attribution alone (no implementation narrative) | Implemented Requirement (dtype 190) with the framework CFs populated. The IS layer is for narrative; the IR layer is for control-row scaffolding. |
| An organization-defined parameter (ODP) VALUE chosen for a control's `[Assignment: organization-defined ...]` slot | Set Parameter (dtype 193). One per ODP slot. Parent the IR via rel 12 (Scope=`Control`) for a per-control ODP, or the CI via rel 11 (Scope=`System`) for a system-wide ODP. The value is the Parameter Value field (236), NOT IS prose. Do NOT fold ODP values into Implementation Statement narrative — model them as first-class SP assets. |
| User roles / personnel responsibilities | `system_pocs` (existing MCP tool). NOT a Capability and NOT a Component. v1 reuses `system_pocs` as-is. |
| Authorization boundary / network diagram | The corresponding narrative field on `system_information` (Authorization Boundary Description / Network Architecture Description / Data Flow Description). **Diagrams as files are out of v1 scope.** |

## Failure modes to recognize

| Symptom | Cause | Fix |
|---|---|---|
| `update_asset` returned "Task queued" but `get_asset` shows fields cleared | Explicit empty values were sent for fields meant to be preserved, or the tenant's Edit Asset runbook is param-templated (renders omitted params as JSON null → destructive) — see `reference-docs/halo-platform/write-semantics.md` §2.1 | Re-run with the correct payload (omit fields to preserve them on a pass-through tenant; read-merge-write the full row on a param-templated tenant). The original values are in Halo's change history if needed for recovery. |
| Requirements CF empty after a combined Controls+Requirements write | Rule 3 violation | Re-write Requirements alone (after confirming Controls are still set) |
| IS Title embeds a Control ID / KSI ID | Legacy format leaked into a new write | Reformat to `{Component} – {Descriptive phrase}` per Rule 4. Framework attribution belongs in the framework CFs + parent IR links, not the Title. |
| New Capability shows up with duplicate of an existing one | AFUUID lookup missed; identity-collision wasn't staged as merge-candidate | Resolve via merge-candidate workflow; never silently merge |
| Multi-select framework CF stored as empty `value:[]` despite landing in response | Sent CSV-string or bare display string instead of object-array | Both ticket-side `update_ticket_fields` and asset-side `update_asset` require object-array shape: `customfields[].value=[{id,name},...]`. CSV silently drops. |
| Lookup value silently dropped (field stays empty) on a SQL-source Lookup CF | The `name` doesn't appear in the field's Lookup SQL result for the current site / device context | Check the Lookup query's WHERE clauses (e.g. `Site.CFBaselines LIKE '%,' + CFBaselineName + ',%'`). Common cause: writing a CMMC value to a site whose `CFBaselines` doesn't include any CMMC baseline → zero allowed values → silently dropped. **Per-asset prereq**: framework Controls/Requirements/KSIs are also lookup-filtered by the asset's own `CFBaseline` (FIid 503) — set CFBaseline first or the write drops silently. |
| `update_asset` response echoes `value:[{"name":"X"}]` with `display:""` (empty) | Lookup-filter silently dropped the write — Halo echoes the request payload back without resolving | NEVER trust update_asset's response for lookup-CF verification. The persistence signature requires `display:"<resolved label>"` AND the value array enriched with `id` (e.g. `[{"id":"1024","name":"AC-02"}]`). If `display:""` or no `id` enrichment, the write was dropped. Verify via `get_asset` — it returns the resolved/persisted state. |
| Asset Field lookup stays empty after write (validate-W) | Sent bare string instead of `[{value, label}]` array | Validate-W fields (e.g. Control Origination) require the multi-element array shape even for single-pick UI. Validate-V (e.g. Implementation State) accepts scalar string OR fcode int. |
| "user denied tool use" on an `update_asset` with long Implementation Statement | Runtime payload cliff (>300 chars in one field) | Split into multiple `update_asset` calls each with shorter long-form values, or move the long content into multiple short paragraph-fields |
| IS shows up with framework CFs but isn't claimed by any IR at OSCAL export time | Missing IR→IS rel-7 link | Find / create the matching IR (one per catalog) and set the relationship. Framework CFs alone don't wire the IS to the per-control OSCAL row. |
| FedRAMP-Rules (FRR) IR has no downstream IS even after running the IR→IS reconciler | The IS side is missing `CFAFedRAMPRules` (597) — the reconciler matches `ir.CFAFedRAMPRules ↔ ist.CFAFedRAMPRules` (same Site + CFBaseline), so BOTH ends must carry the rule. IRs get it from `reconcile_ssp_assets`; IS assets must also be stamped with the FRR rule (whatever creates/populates the IS). | Populate `CFAFedRAMPRules` (597) on the IS (set CFBaseline first, then the rule — value-source is baseline-scoped), then run `reconcile_ssp_relationships_ir_is`. |

## KB articles and Validations — write/read conventions (live-verified 2026-07-01)

Three tool-behavior facts that write planners get wrong. All verified against the live Halo MCP integration.

### KB association — one documentary Component, matching Titles, never an IR/IS

Before composing any KB write, resolve **which Component the article belongs to**. The binding rules (model rationale: [[halo-ssp-asset-model]] § KB articles reach the spine through a Component):

- **Exactly one Component per article.** Not several, not zero.
- **`Component Type` must be documentary** — `Policy`, `Process/Procedure`, `Plan`, `Guidance`, `Standard`, or `Document`. Refuse the write if the target Component is any of the other eight lookup values (`System`, `Interconnection`, `Software`, `Hardware`, `Service`, `Physical`, `Validation`, `Network`); that is a modeling error upstream, not something to work around by attaching anyway.
- **`Component.Title` must equal the KB article title exactly.** The title is the only correlation key between the two — there is no foreign key. If a title changes, change both in the same action file; a one-sided rename silently breaks correlation and nothing will fail loudly.
- **Never associate a KB article with an Implemented Requirement or an Implementation Statement.** The article reaches the spine only through its Component and that Component's relationships. A direct IR/IS association asserts control coverage a second time, and the two paths drift with no reconciler owning the conflict.

Composition order when the document is new: create the documentary Component first (Title set to the article title), then create the KB article and associate it. Do not create the article first and retrofit a Component — the Title match is easier to guarantee in the other direction.

Verification after the write: read back `get_asset(<component-id>).kbs[]` and confirm (a) the article is present, (b) it is the only one whose title matches, and (c) no IR or IS in the same Site carries the same article.

### KB write — `faqlists` is REQUIRED

`create_kb_article` requires a `faqlists: [{id: N}]` entry — Halo rejects a KB write with no faqlist ("You must select an FAQ list"). **Faqlist id `1` is the default.** A KB article created without a faqlist will not surface (it has no list to appear in), so always send `faqlists: [{id: 1}]` unless a specific list is intended:

```jsonc
create_kb_article({
  ...,
  "faqlists": [{"id": 1}]   // REQUIRED — id 1 is the default list; omitting it rejects the write
})
```

### KB read — read via `get_asset(...).kbs[]`, not the KB search tools

`get_one_article` and `search_knowledge_base` currently come back **blind** — they report "not found" even for a just-created article (faqlist 1, showforallroles=true) that is plainly visible via `get_asset(...).kbs[]`. These are native Halo MCP tool types (not runbooks), so the cause is a tenant-side MCP permission/visibility gap (the integration application/agent lacks KB/FAQ-list visibility), **not a runbook bug**. Until the MCP integration's KB/FAQ visibility is fixed, **read KB content through the owning asset's `get_asset(...).kbs[]` array**, not through `get_one_article` / `search_knowledge_base`.

### Validations write — `create_validations` is RUNS-ONLY

`create_validations` writes a validation **run**, not a validation **definition**. The definition columns (615 / 616 / 618 / 625 / 619) silently **vanish at write time** — sending them accomplishes nothing. The check *definition* lives on the **type-49 ticket**; runs land in `CTValidations` (custom table **1025**). When composing a validation write:

- To read/confirm the check definition: inspect the **type-49 ticket**, not the run row.
- `create_validations` payloads should carry only run fields — do NOT target definition columns 615/616/618/625/619.
- Runs are queryable in `CTValidations` (1025) — read back via `get_validations(ticket_id, ...)` on the check ticket.
- Standing up the KSI validation tickets themselves is the `create_validation_tickets` zero-param sweep (see `[[grcitsm]]` § KSI ticket organization), not per-ticket creates.

## Pre-write validation with point-lookups (optional)

For asset framework-CF writes, Halo auto-resolves `id:"0"` server-side via the field's Lookup SQL, so no caller-side ID resolution is needed. But if you want a defensive pre-flight that a Control/Requirement actually exists in scope before composing the payload, the catalog point-lookup tools return one row each. The control catalog is normalized across five tables: `CTControlDetails` (canonical per-ControlID detail — Family / Name / Description / Discussion / 800-53 mapping / Score / POAM), `CTControlBaselines` (pure baseline×control membership), `CTControlParameters` (sparse per-class parameter overrides), `CTControlRequirements` (sub-parts), and `CTBaselines` (the baseline dimension / OSCAL profile header).

- `lookup_control_details(control_id)` — e.g. `lookup_control_details("AC-02")` → returns the `CTControlDetails` row (Family/Name/Description/Discussion/800-53/Score/POAM). Empty response means the Control ID isn't in the catalog.
- `lookup_control_parameters(control_id, baseline_name)` — e.g. `lookup_control_parameters("AC-02", "FedRAMP Rev5 Class C")` → returns any per-class parameter override from `CTControlParameters`. Empty is normal (sparse table — most controls have no override).
- `lookup_requirements(requirement_id, baseline_name)` — e.g. `lookup_requirements("AC-02a.[01][02]", "FedRAMP Rev5 Class C")` → returns the matching `CTControlRequirements` row (Requirement Text + sub-part).
- `lookup_controls(...)` — the combined view across the catalog tables; use when you want detail + membership in one call.
- `control_details_summary`, `control_parameters_summary`, `baseline_catalog` — bulk/dimension views; `baseline_catalog` is also the source of truth for `CFBLShortCode`.

Use cases:
- **Sanity-check IS plans before writing** — confirm every (Control ID, Baseline) in the change-plan resolves via `lookup_controls` / membership.
- **Fetch verbatim text** for inclusion in an IR row's Title / Description (from `CTControlDetails`).
- **Cross-baseline mapping** — call the lookup twice (once per Class baseline) to confirm an enhancement number translates.

Skip the pre-flight if the source plan already drew Control / Requirement IDs from `category_control_mappings` — those are pre-validated against the catalog.
