# GRCITSM ticketing doctrine

Plugin-owned GRCITSM domain doctrine for ticket operations: template-driven creation, the create→update chain, ticket-type routing, hierarchies, the KSI Validation tree, and proven multi-tool compositions. Platform-level write shapes, null-handling, lookup rules, and verification doctrine live in the vendored corpus — `reference-docs/halo-platform/write-semantics.md` and siblings — and are cited, not restated, here.

## Template-driven creation — check the catalog before any net-new create

Halo stores recurring ticket/project structures as native **ticket templates**: server-side definitions carrying pre-filled field defaults (team, agent, category, SLA, status), the full to-do checklist, and any baked-in child tickets. Two tools cover the surface. **If `ticket_templates` is absent from the tenant's tool catalog, skip this section silently and use the create→update chain below** — template-aware creation degrades gracefully.

- **`ticket_templates`** (no params) — lists the tenant's template catalog: `[Template ID]`, `[Template Name]`, `[Template Type]`, `[Ticket Type]`, `[Creates]` (whether applying creates a Project), `[Active]`, `[To-Do Count]`, `[Recurrence]`. Only `Ticket Template` (parent) rows are apply-targets; `Child Template` rows are sub-ticket definitions inside a parent — never apply them directly.
- **`apply_ticket_template`** — creates a real ticket or project FROM a template in one call; Halo expands the template server-side, replacing the whole create → set-fields → to-dos → child-creates chain. Params: `template_id` (required), `site_id` (required), `user_email` (optional requester override — defaults to the site's designated General User), `summary` (optional — defaults to the template name).

### The check → ask → apply sequence

1. **CHECK.** Before composing any net-new ticket/project create that plausibly matches a recurring or structured intent (illustrative examples only: onboarding, ConMon package, user access request, offboarding, privileged access, a training project — always enumerate the live catalog via `ticket_templates`; never assume an intent list), call `ticket_templates` first.
2. **ASK — never silently substitute.** Present the matching parent template(s) — name, ticket type, to-do count, whether it Creates a Project — and ask whether to apply. Templates carry team/agent/category/SLA/status defaults the user may not want; applying one is the user's decision, not the agent's.
3. **On yes → `apply_ticket_template`**, then verify per the mechanics below.
4. **On no, or no catalog match → `create_new_ticket`** and the chain below, exactly as today. The template check is an added branch, not a replacement.

### Template-path mechanics

- **Fail-loud lives INSIDE the backing runbook; the MCP transport reports success regardless.** An unknown `template_id` / `site_id` / `user_email` errors the run with no ticket created — yet the tool call returns transport-successful. Never trust transport success: **confirm the returned ticket id via a `get_ticket` read-back.** Debug path when the read-back fails: `get_runbooks` (version / active flag) → `get_runbook_logs` (GUID from its `[Runbook ID]` column) — an optional introspection pair (see `required-mcp-tools.md` § Runbook introspection); if your tenant does not provision it, report the failed apply to your platform operator.
- **Verify agent + category on the read-back.** Templates carry team/agent/category defaults, but the defaults are per-template — the "every created ticket needs agent + category" rule still binds. Check both landed on the `get_ticket` read-back and fill whatever the template left unset.
- **The cross-client redirect chain is NOT needed on the template path.** `apply_ticket_template` derives the client from `site_id` server-side, and the requester defaults to the site's designated General User natively — do NOT chain `set_ticket_client_site` or the General-User re-point after a template apply.
- **Never hardcode template ids or child/to-do counts** — both are tenant- and template-specific; enumerate via `ticket_templates` every time.
- **A template apply is ONE operation.** Server-side child fan-out does not multiply the row count — for routing and bulk-threshold purposes (e.g. a ≤5-row operational cap), one `apply_ticket_template` call counts as a single create; never escalate to the bulk action pipeline just because the template creates children.

## The mandatory create → update chain

`create_new_ticket` inherits status, team, SLA, priority, and **all** custom fields from the chosen ticket type's defaults. It cannot set CFs, status, priority, or dates. To set any of those at creation time, follow up with `update_ticket_fields` (or generic `agent_note`) against the returned `ticket_id` — there is no other write path.

```
1. create_new_ticket(user_email, summary, description, ticket_type)
     → returns { ticket_id, link }

2. get_ticket(ticket_id)
     → learn the inherited ticket_type, sla_id (needed by the validators)

3. (parallel) ticket_type_allowed_statuses, ticket_type_allowed_categories, ticket_type_fields, slas_and_priorities
     → discover valid status IDs, category paths, CF schema, and SLA-scoped priority labels for THIS ticket type

4. update_ticket_fields(ticket_id, [status_id, priority_id, dates, type-scoped CFs])  -- or agent_note for the generic envelope
     → commits the change
```

The same 2-4 sequence applies when updating an EXISTING ticket — skip step 1.

### Type-defining CFs are functionally required even when `Mandatory: No`

`create_new_ticket` leaves a type's classifying lookup CF unset (default `-1`/blank), and `ticket_type_fields` reports many of these as `Mandatory: No` — Halo enforces mandatory-ness only in the UI, never on the `/api/Tickets` write path. So the create chain will happily land a ticket with its defining classification empty, and nothing errors. **When you create a ticket of a type whose meaning depends on a classifier CF, you MUST set that CF in step 4 — treat it as required regardless of the schema's `Mandatory` flag.** The one that bites in practice:

- **Change Request → `CFchangeType` (id 151, lookup 122).** Every CR must carry a change type — `Routine Recurring | Adaptive | Transformative | Impact Categorization | Interim Requirement | Emergency` (exact strings; see `grcitsm-tenant-catalog.md` § CF id catalog). A CR created without it is unclassified for CAB routing and SCN/CM reporting. Pick the type from the change's own nature (a routine recurring patch window is `Routine Recurring`; a re-architecture is `Transformative`); never leave it blank.

## Cross-client ticket creation (`create_new_ticket` clientid inheritance)

`create_new_ticket` sets `Faults.clientid` from the `user_email` parameter's organization. The tool has NO `client_id` or `site_id` parameter. To log a ticket against a client other than the user's home org, **chain `set_ticket_client_site` immediately after creation, BEFORE setting assets or parent links.**

```
1. create_new_ticket(user_email="<any user>", ticket_type, summary, description="<≤200 chars; payload cliff — `halo-platform/write-semantics.md` § 8>")
     → ticket lands on user_email's org (the WRONG client for cross-tenant flows)
     → returns { ticket_id, link }

2. set_ticket_client_site(ticket_id, client_id=<target>, site_id=<target>)
     → writes Faults.clientid + Faults.sitenumber to redirect the ticket to the intended tenant
     → MUST run BEFORE step 3 (the redirect re-scopes SLA and may orphan asset/parent links if applied later)

3. update_ticket_fields(ticket_id, [CFs, dates, category, ...])
     → populate type-specific CFs under the now-correct client (payload cliff and null-handling rules: `halo-platform/write-semantics.md` §§ 2, 8)

4. (optional) set_ticket_assets / set_ticket_parent
     → safe only AFTER the client redirect — running these before step 2 risks orphaned asset/parent links
```

**Why the order matters:**

- Re-scopes SLA — new client/site may have different SLA; setting client after assets/parent linked leaves SLA mismatched.
- May orphan linked assets — assets belong to clients; reassignment without re-linking leaves the asset list stale.
- Can break a parent link — parent-child relationships may not survive a client reassignment.

**Alternative considered (and rejected):** pre-creating a service-account user at each target client and using that as `user_email`. Pros: single-call create. Cons: pollutes user catalog with bot accounts per tenant, requires a one-time create per client, still requires `set_ticket_client_site` if the target site is not the user's primary site. The redirect-chain pattern is simpler and tenant-agnostic.

**Illustration:** a ticket created with `user_email="analyst@acme.com"` lands on ACME (the analyst's home org) even when the intended target is a different client; the same create immediately followed by `set_ticket_client_site(<ticket_id>, <target client_id>, <target site_id>)` is correctly redirected.

**The derivation also picks a DEFAULT SITE — so this trap fires even for the caller's own client.** `create_new_ticket` derives both `clientid` AND the site from `user_email`'s home org, and the derived site may not be the one you want (e.g. a Gov/primary site instead of the intended internal site). Do not assume "same client = right site."

- **Always pass explicit `client_id` AND `site_id` to `set_ticket_client_site`** — not just when redirecting to a different client. Passing only `client_id` (or matching the current client with `site_id` omitted) **no-ops on the site**: the tool leaves the derived/default site in place rather than correcting it. Send both ids every time you care about the site.
- **`get_ticket` returns STALE denormalized `client_name` / `site_name` after a correct write.** The id-level `clientid` / `sitenumber` update lands, but the denormalized name echoes lag — do not treat the echoed names as proof the write failed. Trust the id fields; re-read if you need the resolved names. (Platform read semantics: `halo-platform/write-semantics.md` § 11.)

### Requester convention — the designated General User (canonical 7-step create)

Every client/site carries a designated "General User" end-user account (Display Name "General User", no email, `Is General User = Yes`); tickets logged for a client/site carry it as the requester — not the agent who logged them. `create_new_ticket` derives the requester from `user_email`, so a ticket created via an agent email always needs a post-create re-point. The canonical create recipe:

```
1. create_new_ticket(user_email=<any valid agent>, summary, description, ticket_type)
2. set_ticket_client_site(ticket_id, client_id=<target>, site_id=<target>)
     → explicit site_id required — the explicit-site_id and stale-echo bullets above apply verbatim
3. resolve the site's designated General User via the `users` end-user catalog
     (filter Client ID + Site ID + Is General User = Yes) — never hardcode user ids
4. update_ticket_fields({id: ticket_id, user_id: <General User id>})
     → sets the requester (user_id is a native Update Ticket who-field — `halo-platform/write-semantics.md` § 9)
5. assign_agent(ticket_id, agent_id)
     → agent must be on the ticket's team or the assignment silently reverts (`halo-platform/write-semantics.md` § 12)
     → steps 5-6 are order-independent (corpus § 9 runs the CF update before assignment; either order is valid)
6. update_ticket_fields(ticket_id, [category_1, dates, type-scoped CFs])
     → category change + manual framework CFs = TWO sequential calls, category first
       (`grcitsm-tenant-catalog.md` § Re-categorize + manual override recipe)
7. get_ticket(ticket_id) — trust clientid / sitenumber ids over name echoes; confirm user_name = "General User"
```

## Ticket-type → tool routing

Pick the write tool by ticket type and what you're updating. All CF writes go through `update_ticket_fields` (Update Ticket POST); `agent_note` stays for billable-note semantics + Project/Task strategic value.

**Framework CFs are NOT in this table's scope.** `category-control-lookup` is their single writer on every type — set `category2` and let it derive (the sole exception is `CFFedRAMPKSIs` / `CFFedRAMPRules` on Validation tickets, type 49). Where a row below says "framework attribution", read it as *set by the category*, not by you. See § Framework-CF write discipline.

| Ticket type | Use | What it sends |
|---|---|---|
| Issue | `update_ticket_fields` | Issue POA&M CFs by id: CFPOAMID (337), CFDeviationRationale (353), CFTypeofDR (267), CFRequestedRiskRatingImpact (268), plus free-form POA&M template fields (weakness narrative, milestones, vendor dependency, BOD, risk rating, CVE / CVSS / EPSS, point of contact). Resolve other ids via `ticket_type_fields(146)`. |
| Vulnerability Deviation (FP / OR / RA / OR RA) | `update_ticket_fields` | All Deviation CFs: type_of_dr, requested_risk_rating_impact, remediation_level, CVSS env metrics (CFAttackVector etc.), narratives (CFDeviationRationale, CFEvidenceDescription, CFAdditionalInformation), identifiers (CFDRNumber=338, CFPOAMID=337), framework attribution. |
| Validation (KSI / Rule) | `update_ticket_fields` | Check-definition CFs: CFValidationID (635), CFValidationTest (636), CFValidationMethod (483 — Automated \| Manual), CFEvidenceType (637), CFEvidenceDescription (341), CFValidationStatement (638, KSI ticket — copy-to-child). **Framework CFs: on this type (49) ONLY `CFFedRAMPKSIs` (381) and `CFFedRAMPRules` are yours to set** — the derive reads those two off the ticket itself. Controls / Requirements / Drata still come from the category (§ Framework-CF write discipline). Multi-select needs `[{id,name},...]` array shape (NOT CSV string). Run rows go through `create_validations`, never here. |
| Change Request | `update_ticket_fields` | **CFchangeType (id 151, lookup 122) — REQUIRED on every CR even though the schema says `Mandatory: No`; see § Type-defining CFs.** CFoutageRequired (id 158, **boolean — pass `true`/`false`, NOT `"Yes"`/`"No"`**), CFjustification (114), CFImpact (424), CFRisk (425, "Low RIsk" with capital I), the four memo CFs (CFChangePlanMemo=187, …), CFoutageDuration (159), framework attribution. |
| Project / Project Task | `agent_note` | Generic envelope + `strategic_value` (CFStrategicValue is only registered on these two ticket types). |
| Alert / Incident / Information Request / Problem / Service Request / User Access Request | `agent_note` for billable-note attribution; `update_ticket_fields` for status/priority/dates/category/CFs | `agent_note`: action history entry, time tracking, charge rate. `update_ticket_fields`: any field on the Faults row. `strategic_value` no-ops on these types (CFStrategicValue is registered only on Project / Project Task). |

The live `ticket_type_fields` catalog returns rows for every ticket type with registered custom fields — currently Alert, Change Request, Incident, Issue, Problem, Project, Project Task, User Access Request, Validation, Vulnerability Deviation. Types with no registered CFs (e.g. Data Request post-migration, Information Request, Service Request) return no rows. The **exact** parameter names and allowed values per type come from `ticket_type_fields` filtered to the target type — do not hardcode them.

### Priority / SLA write-path selection

Three tools can touch priority or SLA — pick by how much else you're writing in the same call:

| Tool | Scope | When to use |
|---|---|---|
| `agent_note` | One call that adds a note AND bumps priority/SLA/dates. Requires `ticket_id` + `note`. | You want the priority/SLA change recorded WITH billable-note attribution (action history entry, time tracking, start/target dates) in a single Action POST. |
| `set_ticket_sla_and_priority` | Narrow SLA/priority-only write (`Faults.slaid` + `Faults.seriousness`; priority required, sla_id optional). | You need only to adjust priority (and optionally rebind SLA) — nothing else, no note. Discoverable narrow surface. |
| `update_ticket_fields` | Broad custom-field / native-field write (any Faults column + `customfields[]`, omitted-PRESERVED). | You're writing priority/SLA alongside other fields (status, dates, category, CFs) in one payload. |

## Multi-level ticket hierarchies & single-parent ownership

`set_ticket_parent` writes `Faults.parentid` — a single FK column. **Every ticket has at most ONE parent.** When building rich hierarchies (umbrella issues + change requests + deviations + incidents all referencing the same vulnerabilities), this means parent slots must be **pre-allocated** before you start firing `set_ticket_parent` calls. The constraint trips up the natural mental model where you imagine a CVE umbrella as "covered by" a change request AND a deviation AND an incident simultaneously.

### The allocation rule

**Issue umbrellas group by ONE Weakness Name/CVE.** The parent (umbrella) Issue represents exactly one distinct weakness — one CVE id or one named weakness; children are its per-asset instances. Never blanket-group by scanner or theme ("all registry-scan findings"). A distinct CVE on a single asset is a standalone Issue. A Vulnerability Deviation MAY parent multiple Issues (each issue's parent_id → the VD).

For each umbrella issue, exactly one of these can be its parent:

| Parent type | When it owns the umbrella |
|---|---|
| Change Request | Patch-window-deliverable findings (CVE patches, version upgrades, planned deploys). |
| Deviation (FP / OR / RA) | Findings the customer is formally not fixing this cycle (false positive, operational requirement, risk-adjusted). |
| Incident | Almost never — incidents *correlate to* findings via `parent_id`, but incidents are usually children of an investigation thread, not parents of umbrellas. Reverse the direction. |

When a single umbrella is BOTH in-scope for a patch window AND has a deviation filed against part of it, the deviation wins (it's the customer-state-of-truth record); reference the CR from notes or a custom field instead.

### The observed hierarchy depth

Three-level chains work cleanly:

```
Change Request ("May patch window")
├── Umbrella Issue (Log4Shell CVE)
│   ├── Asset-bearing child Issue (EC2 instance)
│   ├── Asset-bearing child Issue (K8s pod)
│   └── Asset-bearing child Issue (worker node)
└── Umbrella Issue (OpenSSL CVE)
    └── Asset-bearing child Issues (per affected asset)

Deviation (OR)
└── Umbrella Issue (legacy TLS)
    └── Asset-bearing child Issues (per affected asset)

Incident (privilege-escalation alert)       ← parent = the CVE umbrella (correlation)
├── Alert (sudo privesc)
└── Alert (outbound C2)
```

No tenant-config depth limit has been observed. The umbrella → child relationship is preserved; the CR/Deviation simply replaces the umbrella's null `parent_id`.

### Implications for bulk-create workflows

1. **Map parent ownership before any `set_ticket_parent` call.** A spreadsheet column or a JSON `umbrella_to_parent` map prevents the "I'll fix it later" trap where you parent an umbrella to the CR and then have to detach it to reparent to a deviation.
2. **Create Deviations and CRs AFTER umbrellas exist**, then issue `set_ticket_parent` against the umbrella (the child) targeting the Deviation/CR (the parent) — Halo allows arbitrary direction; the parent-side relationship doesn't have to be created first.
3. **Standalone umbrellas (no upper parent) are valid** — leave `parent_id` null for findings that don't roll up under a CR or deviation. Halo's reporting handles unparented umbrellas natively.

### Incident & Alert as first-class ticket types

`ticket_type="Incident"` and `ticket_type="Alert"` are both valid for `create_new_ticket`. They produce real ticket rows on the standard `Faults` table.

**No type-specific update runbook exists for them** — the type-specific CF surface is empty. To populate post-create:

| What you want to write | How |
|---|---|
| Top-level Faults columns (status, priority, category, dates, summary) | Pass at `create_new_ticket` time, or via the generic envelope tools (`agent_note`, `assign_agent`, `set_ticket_parent`, `update_ticket_fields`). |
| Long-form description / investigation notes | `agent_note(ticket_id, note=…)` — subject to the payload cliff (`halo-platform/write-semantics.md` § 8). |
| Parent correlation (link incident to umbrella, link alert to incident) | `set_ticket_parent(ticket_id, parent_ticket_id=…)` |
| Asset linkage (which device fired the alert) | `set_ticket_assets(ticket_id, assets=[…])` |
| Owner | `assign_agent(ticket_id, agent_id=…)` |

**Auto-routing:** Halo's ticket-type-to-team mapping fires immediately on create. On the standard GRCITSM tenant configuration, `ticket_type="Alert"` routes to the **Continuous Monitoring** team; `ticket_type="Incident"` routes to **1st Line Support**. The default `priority` differs too — Alert lands at priority 4, Incident at priority 3. Override at create time or via `slas_and_priorities` + `update_ticket_fields` if you need a different routing.

## KSI ticket organization (Validation tree)

Every KSI ticket is a **Validation** (`tickettype_id: 49`) with `category_1: "Validation>KSI"` (421). The type's full allowed-category set is Validation (420), Validation>Control (423), Validation>KSI (421), Validation>Requirement (424), Validation>Rule (422) — Rule validations (VDR-*/VER-*) ride the same type under `Validation>Rule`. The migration off the old Data Request (type 47) tree (2026-06-28) also **dropped the site-wide L1 container** — KSI tickets are now top-level (`fxrefto IS NULL`), flattening the tree to two tiers. Standard fields on all levels (live-verified 2026-07-02): team **Governance, Risk and Compliance**, requester = the system's end user, agent always assigned. SLAs split by tier: KSI tickets carry the class-matched **KSI Validation SLA** (A=13 / B=9 / C=8 / D=12) with **Machine Based** priority; check children carry the **Data Request SLA** (6).

### Two-level hierarchy

```
L1  KSI ticket             "KSI-CMT-LMC - Logging Changes"         (top-level; one per catalog KSI per system)
└─ L2  Query/validation                                            (one per validation check under its KSI)
       └─ CTValidations rows = run history for that one check
```

- **L1 KSI tickets** — top-level; one per KSI in the current catalog (46 under the 2026 Consolidated Rules; Class A systems get their 6 — enumerate via `control_details_summary` → Framework=FedRAMP, Entity Type=ksi). Summary convention: `KSI-XXX-YYY - <Name>` with Name from `lookup_control_details(control_id)`. Framework CFs: `CFFedRAMPKSIs` = exactly its own KSI `[{name: "KSI-XXX-YYY"}]` (set by the scaffolder), `CFFedRAMPControls`/`CFFedRAMPRequirements` empty. The KSI **Statement** lives here in `CFValidationStatement` (638, copy-to-child — it propagates to the check children). Child-run status rolls up to this ticket via the `rollup-validation-parent-status-*` runbooks.
- **L2 query/validation tickets** — one child per validation check (e.g. CIS check `3.1.8`) under its KSI ticket; same type/category, summary convention `KSI-XXX-YYY | Check <name>`. Each carries the **check definition as ticket CFs** — `CFValidationID` (635), `CFValidationTest` (636), `CFValidationMethod` (483 — Automated | Manual), `CFEvidenceType` (637), `CFEvidenceDescription` (341) — set via `update_ticket_fields`, plus its parent's KSI in `CFFedRAMPKSIs`. Its `CTValidations` rows are the **run history of that single check** (runs-only: counts, status, datetime, note, reference — no definition columns on rows).

**Why one KSI per ticket matters:** the per-ticket KSI CF drives IR asset auto-linking, the validation rows aggregate per-KSI evidence, and `get_validations(ticket_id)` is the per-check read (call it on the L2 check ticket — rows attach there).

### Framework-CF write discipline (critical)

**`category-control-lookup` is the SINGLE WRITER of a ticket's framework CFs, on every ticket type.** It is event-triggered on `New Ticket Logged` + `Ticket Updated`, reads the ticket's `category2` against `CTCategoriesControlsRequirements`, and writes all six dimensions (378 FedRAMP Controls, 391 FedRAMP Requirements, 381 FedRAMP KSIs, 380 CMMC Controls, 392 CMMC Requirements, 462 Drata Controls, plus `CFFedRAMPRules`) — then recomputes `assets[]` from them. So **set the ticket's CATEGORY; that is the whole input.** Hand-writing framework CFs fights the single writer and loses on the next ticket update.

**The Validation-ticket exception (type 49).** For Validation tickets the runbook reads KSIs and Rules from the ticket's OWN `CFFedRAMPKSIs` / `CFFedRAMPRules` rather than from the category — so on type 49 those two CFs *are* the writer's job, which is what makes the two-phase KSI setup below both necessary and safe. No other type has this carve-out.

The remaining detail is specific to KSI tickets. The runbook **auto-derives the framework CFs from the category mapping on ticket creation** (six dimensions — FedRAMP Controls / Requirements / KSIs / **Rules** + CMMC Controls / Requirements; `CFFedRAMPRules` is derived-only and deliberately excluded from `ticket_type_fields` — never write it manually) — a new ticket in the `Validation>KSI` category lands with the category-mapped values, NOT its own KSI. KSI-ticket setup is therefore always two-phase (the scaffolder does both phases for you):

1. Create the ticket (auto-derive fires, mapping values land).
2. Follow-up `update_ticket_fields` that does NOT touch category: `customfields: [{name: CFFedRAMPControls, value: []}, {name: CFFedRAMPRequirements, value: []}, {name: CFFedRAMPKSIs, value: [{name: "<own KSI>"}]}]`.

**Persistence caveat:** manual CF persistence depends on the runbook's event configuration. Touching category ALWAYS re-derives and clobbers manual CFs. A CF-only write may *also* be reverted IF a loosely-conditioned "Ticket Updated" subscription re-fires the derive — but that is the worst-case misconfiguration, NOT guaranteed: live-validated 2026-06-14 on this tenant, a CF-only `CFFedRAMPControls` write (AC-02/AC-03 on ticket 1862) was stable on re-read after 2+ minutes and did not revert. **Rule: before any bulk framework-CF write, pilot ONE ticket and re-read after ~90s before fanning out** — cheap insurance against a misconfigured subscription (see `[[halo-bulk-update]]` Phase 4). Never combine a category change and manual CFs in one payload.

**IR asset links are derived, never manual:** the `relate-ticket-to-irs` reconciler computes ticket↔IR links from the stored framework CFs on **4 dimensions** — FedRAMP Controls, CMMC Controls, KSIs, and **FedRAMP Rules** (ticket CF `CFFedRAMPRules` 596 ↔ asset CF `CFAFedRAMPRules` 597). A KSI ticket with `CFFedRAMPKSIs = [KSI-CMT-LMC]` auto-links the matching IRs (e.g. `IR-ACME-FR5CC-KSI-CMT-LMC` + `IR-ACME-FR20XCC-KSI-CMT-LMC`) and drops stale ones. The single-ticket on-demand relater and the all-tickets diff-based sweep are separate reconcilers (the sweep is the backstop for tickets predating the trigger); automatic per-ticket relating also fires on category change.

**What that means for `assets[]`, precisely.** The reconciler recomputes `assets[]` as *existing non-IR attachments* UNION *every IR in the site whose framework CFs intersect the ticket's*. So:

- **Never attach Implemented Requirements (dtype 190) by hand** — `assets[]` is recomputed on every ticket update and your IR additions are replaced.
- **DO attach non-IR assets (Components, Cloud Assets) explicitly** — the UNION preserves existing attachments where `dtype != 190`, so those are yours to write and they survive. This is the positive half of the rule: "don't write `assets[]`" applies to IRs, not to the whole array.

### KSI processes

**A. Stand up KSI tickets for a new system (per site) — use the bulk scaffolder:**

**`create_validation_tickets`** builds the KSI tickets in one zero-param idempotent sweep across every 20x Site missing them: one top-level class-aware KSI ticket per baseline-scoped KSI, each with the class-matched KSI Validation SLA, Machine Based priority, and its own-KSI CF already set. **This is the standard path — do not hand-create KSI tickets one by one.** Re-runs only create what's missing; a settled system is a no-op. Sites without a resolvable requester are skipped. Verify with `search_tickets` / `get_ticket`; IR↔ticket links are wired by the `relate-ticket-to-irs` reconciler from the CFs (do nothing). The per-check L2 query leaves and their validation rows are added separately (see process C).

Backfill note: the sweep also fires on a Site's event trigger when it gains a 20x baseline — call `create_validation_tickets` to build the tickets without re-saving the Site.

Manual fallback (only if the sweep can't run — e.g. a bespoke single-ticket fix), reproducing what the sweep does under the hood:
1. Enumerate the catalog: `control_details_summary` (Framework=FedRAMP, Entity Type=ksi) → names via `lookup_control_details`.
2. Per KSI: create top-level ticket (`create_new_ticket`, Validation type, `category_1: "Validation>KSI"`) → structural update (site, user_id, team_id, agent_id) → **separate** CF-fix call (own KSI only, Controls/Requirements empty). Pilot one + 90s re-read, then fan out (`[[halo-bulk-update]]` for >20).
3. IR links: do nothing — the reconciler wires them from the CFs.

**B. Catalog refresh (when FedRAMP changes the KSI set):** diff top-level KSI ticket summaries' KSI prefixes against the current catalog list. Stale KSIs: a user deletes the tickets in the UI (MCP can't hard-delete). Missing KSIs: re-run `create_validation_tickets` — the diff-based sweep creates only the KSI tickets that are absent and no-ops the rest (manual process A fallback step 2 if the sweep can't run).

**C. Record a monitoring run (ConMon cycle) — per check:**
1. `create_validations(ticket_id, rows = [new run rows only])` — target the L2 check ticket; each row carries the Key (627, `{ticket_id}|{validation_id}|{datetime}`), counts, datetime, Status int (332=Pass, 333=Fail), note, reference. No read-first, no stubs: the upsert inserts new keys and leaves every existing row alone. Check-definition values go on the ticket via `update_ticket_fields`, never on rows.
2. `get_validations` to verify (write responses are not proof — read back).

Same run re-sent = same keys = idempotent in-place update. Correcting a bad value = re-send that row's key with just the changed columns. Row mechanics, column-id map, and delete/repair paths: § Validation rows.

**D. Read evidence:** per-check history / latest result via `get_validations` (first row = latest); one exact record via `record_id`; one cycle via `date_from`/`date_to`. Tenant-wide rollup: the `ksi_report` MCP tool / the repointed KSI Halo reports (Item = top-level KSI ticket, Check = child leaf).

### KSI gotchas checklist

- Stand up the KSI tickets with `create_validation_tickets` (zero-param idempotent sweep), not hand-created tickets — it sets the own-KSI CF, SLA, and priority class-aware at creation. The manual two-phase create is a fallback only.
- Auto-derive fires on CREATE: never expect a fresh KSI ticket to carry its own KSI — always the follow-up CF call (the bulk scaffolder handles this for you).
- Never category + manual CFs in one payload; never write `CFValidations` (the type-7 table CF) as a scalar; never `{name,value}` inside table rows.
- `create_validations` is an UPSERT keyed on column 627 — every row MUST carry a well-formed unique Validation Key (`{ticket}|{validation_id}|{datetime}`); a reused key silently updates the wrong row. Omission never deletes; deletes are UI-only (or ad-hoc `update_ticket_fields` full-replace for bulk repair — THAT path deletes omitted rows).
- `get_validations`: only `ticket_id` is required — `record_id`, `validation_id`, `date_from`, `date_to` may be OMITTED or passed `''`, both meaning "skip this filter" (`date_to` is EXCLUSIVE; date-only values compare as midnight). Contract fixed in runbook v0.4.8 + MCP config, full live matrix verified 2026-07-03 (supersedes the 2026-07-02 "send all five params" warning).
- Every created ticket needs agent + category (`assign_agent`; verify via `get_ticket`).
- `user_email` on create sets the requester, not the agent.
- Legacy data note: older rows may store numeric KSI ids (`KSI-CMT-01`); the current catalog uses descriptive ids (`KSI-CMT-LMC`). Exact-match filters care.

## Generic ticket writes — `update_ticket_fields` vs `agent_note`

`update_ticket_fields` rides Halo's Update Ticket endpoint (omit-preserved; the primary path for CF writes, native FK changes, multi-select framework CFs, and bulk updates). `agent_note` rides the Action POST path with billable attribution (action history, time tracking, charge rate) and the `new_*` parent-field convention. Payload contracts, null-handling, lookup shapes, the to-do-list diff, and the verify doctrine: `reference-docs/halo-platform/write-semantics.md` §§ 1-4, 11. Pre-update discovery (the lookup quintet): `write-semantics.md` § 10.

### When to use which

Use `agent_note` when:

- You want a billable note attribution (action history entry, time tracking, charge rate, start/target dates).
- You're setting `strategic_value` on a Project / Project Task ticket.

Use `update_ticket_fields` for everything else:

- Issue POA&M, Change Request, Validation check-definition, Vulnerability Deviation CF writes.
- Any native FK change (sla_id, priority_id, status_id, parent_id, client_id, site_id, agent_id, dateoccurred, summary, details).
- Multi-select framework CF writes on any ticket type (CFFedRAMPControls/Requirements, CFCMMCControls/Requirements, CFFedRAMPKSIs, CFDrataControls) — array-shape via Update Ticket only — `halo-platform/write-semantics.md` § 4.
- Cross-type scripted bulk updates.

When both are needed (billable POA&M update on an Issue), chain them: `update_ticket_fields` first to land the CFs, then `agent_note` to attach the billable rationale and time entry.

### Validation rows — `create_validations` / `get_validations`

Validation **pass/fail counts, run status, and run date** live in the `CTValidations` child table (custom table 1025, linked type-7 ticket CF `CFValidations` id 626 — live-verified name), not on `Faults` — scalar `customfields[]` writes never reach them. Rows are **runs-only**: the check definition (Validation ID, Statement, Test, Method, Evidence Type/Description) lives on the type-49 Validation ticket as ordinary CFs, set via `update_ticket_fields` (see § KSI ticket organization). The dedicated tool pair covers the row surface:

- **`get_validations(ticket_id, record_id, validation_id, date_from, date_to)`** — a ticket's run rows, newest first, Method/Status resolved and check-definition columns joined from the ticket. Only `ticket_id` is required; the four filters may be omitted or passed `''` — both skip the filter (`date_to` is EXCLUSIVE — date-only values compare as midnight). Contract live-verified 2026-07-03 (runbook v0.4.8).
- **`create_validations(ticket_id, rows)`** — **UPSERT** via Halo's Update Custom Table action (body `key` = the ticket id). Rows match on the Validation Key column (627): new key value INSERTS, existing key value UPDATES that row in place with **unsent columns preserved**; rows not mentioned are **never touched or deleted** — no stubs, no read-before-write, idempotent retries. Row shape `{"customfields": [{"id": <column FIid>, "value": ...}]}` — **columns map by FIELDINFO id only (`{name, value}` inserts the row but silently drops every value), and every row MUST carry the Key (627)** (`{ticket}|{validation_id}|{datetime}` convention; a reused key updates the wrong row instead of appending). Run columns: 627 Key, 620 PassCount, 621 FailCount, 614 DateTime (`YYYY-MM-DDTHH:MM:SS`), 622 Status (int: 332=Pass, 333=Fail), 623 Note, 624 Reference. **Do NOT send check-definition columns on rows** — those values belong on the ticket. Deletes are UI-only; bulk row repair rides an ad-hoc `update_ticket_fields` full-replace on the type-7 CF (`{"id": <ticket>, "customfields": [{"id": 626, "type": 7, "usage": 1025, "value": [...]}]}` — THAT path deletes omitted rows; `{id, fkid}` stubs preserve). Discipline: write only the new/changed rows → `get_validations` to verify.

`CFFedRAMPKSIs` (the Faults-level association list) is a separate, ordinary multi-select — writable via `update_ticket_fields` with `{id: 381, value: [{name: "KSI-…"}, ...]}`.

## Common compositions

### 1. Open a new POA&M Issue

```
create_new_ticket(user_email, summary, description, ticket_type="Issue") → ticket_id
get_ticket(ticket_id)                    # confirm type + inherited SLA
parallel: ticket_type_allowed_statuses, ticket_type_allowed_categories, ticket_type_fields(146), slas_and_priorities
update_ticket_fields(payload=JSON.stringify({
  "id": ticket_id,
  "priority_id": <resolved>,
  "dateoccurred": "<ISO>",                                            // SLA-trigger date for the POA&M
  "customfields": [
    { "id": 337, "value": "POAM-2026-001" },                          // CFPOAMID
    { "id": 378, "value": [{"id":"<lookup_id>","name":"AC-02"}] },    // CFFedRAMPControls (write Requirements in a FOLLOW-UP call)
    { "name": "CFWeaknessDescription", "value": "<narrative>" },
    { "name": "CFOverallRemediationPlan", "value": "<plan>" },
    { "name": "CFPointOfContact", "value": "<owner>" },
    { "name": "CFPlannedMilestones", "value": "<milestones>" },
    { "name": "CFOriginalRiskRating", "value": "High" }
  ]
}))
agent_note(ticket_id, note="<billable rationale>", time_taken=0.5, charge_rate_id=<resolved>)
```

### 2. Submit a Deviation Request (Risk Adjustment)

All Deviation CF writes go through `update_ticket_fields`:

```
create_new_ticket(user_email, summary, description, ticket_type="Vulnerability Deviation") → ticket_id
get_ticket + parallel lookups (ticket_type_fields filtered to type 45, slas_and_priorities)
update_ticket_fields(payload=JSON.stringify({
  "id": ticket_id,
  "status_id": <resolved>,
  "priority_id": <resolved>,
  "customfields": [
    { "id": 267, "value": "RA" },                                      // CFTypeofDR
    { "name": "CFAdjustedCVSSScore", "value": "5.4" },
    { "id": 353, "value": "<p>...</p>" },                              // CFDeviationRationale
    { "id": 341, "value": "<p>...</p>" },                              // CFEvidenceDescription
    { "id": 277, "value": "Network (MAV:N)" },                         // CFAttackVector
    { "id": 281, "value": "Low (MPR:L)" },                             // CFPrivilegesRequired
    { "id": 283, "value": "None (MUI:N)" },                            // CFUserInteraction
    { "id": 285, "value": "High (MC:H)" },                             // CFImpactMetricsConfidentiality
    { "id": 291, "value": "Workaround" },                              // CFRemediationLevel
    { "id": 378, "value": [{"id":"<lookup_id>","name":"AC-02"}, ...] } // CFFedRAMPControls (write first;
                                                                       //   write CFFedRAMPRequirements
                                                                       //   in a FOLLOW-UP call)
  ]
}))
```

CF allowed values (e.g. `attack_vector` ∈ {Network, Adjacent, Local, Physical}) come from `ticket_type_fields` filtered to ticket type 45 — don't guess. Multi-select framework Controls + Requirements must be written in separate calls (`halo-platform/write-semantics.md` §§ 4-5).

### 3. Update an existing ticket's priority + target date + Strategic Value

```
get_ticket(ticket_id)                    # learn current SLA + type
parallel: ticket_type_allowed_statuses, slas_and_priorities
agent_note(                                  # generic — billable envelope
  ticket_id,
  note="<billable rationale>",
  priority_id=<resolved>,
  target_date=<ISO>,
  strategic_value="High"                     # → CFStrategicValue (Project / Project Task only)
)
```

### 4. Triage open Issues by age

```
open_issues()                                # dedupe on [Issue Ticket ID] — fan-out row per linked asset
→ pick ticket_id
get_ticket(ticket_id)                        # single-row read (echo of input args = not-found)
update_ticket_fields(payload=JSON.stringify({
  "id": ticket_id,
  "status_id": <resolved>,
  "priority_id": <resolved>,
  "customfields": [ /* only the CFs being changed — others preserved */ ]
}))
agent_note(ticket_id, note="<billable triage rationale>", time_taken=0.25)
```

### 5. Create a new Implementation Statement (per-(Component × control) leaf) under the SSP

Library-first: when the IS acronym has a `CTImplementationStatements` definition, source the payload from `lookup_implementation_statement_definition` (Statement narrative + six pre-split framework lists, used verbatim) per [[ssp]] § Library-first sourcing — the chain below shows the mechanics either way.

```
# Discover the type
asset_types()
  → assettype_id = 191 for Implementation Statement
  → assettype_id = 190 for Implemented Requirement (per-control row, optional parent)

# Land the shell asset
create_asset(
  inventory_number="IS-ENTRAID-MFA",                       # AFUUID convention per [[halo-ssp-asset-write-conventions]]
  assettype_id=191,
  client_id=<resolved via client_details>,
  site_id=<resolved via system_information>
)
  → returns asset_id (e.g. 9101)

# Discover valid fields + statuses for Implementation Statement
parallel: asset_type_fields(assettype_id=191), asset_type_allowed_statuses(assettype_id=191)

# Populate the IS — pass 1 (identity + Controls/KSIs; omit Requirements)
update_asset(payload=JSON.stringify({
  "id": 9101,
  "fields": [
    {"id": 209, "value": "IS-ENTRAID-MFA"},                                 # UUID
    {"id": 213, "value": "Entra ID – Conditional access enforces MFA on privileged sessions"},  # Title (framework-agnostic)
    {"id": 225, "value": 1},                                                    # Implementation State = Implemented
    {"id": 222, "value": [{"value":"1","label":"Organization"}]},               # Control Origination (validate-W array)
    {"id": 224, "value": 1},                                                    # Responsible Role = Self
    {"id": 229, "value": "<Implementation Statement narrative>"}
  ],
  "customfields": [
    {"id": 382, "value": [{"id":"0","name":"AC-02"}]},                          # FedRAMP Controls
    {"id": 384, "value": [{"id":"0","name":"KSI-IAM-AAM"}]}                     # FedRAMP KSIs
  ]
}))

# Pass 2 — re-send pass 1's fields PLUS Requirements (lookup is now filterable)
update_asset(payload=JSON.stringify({
  "id": 9101,
  "fields": [ ...pass 1 fields... ],
  "customfields": [
    ...pass 1 customfields...,
    {"id": 399, "value": [{"id":"0","name":"AC-02a.[01][02]"}]}                 # FedRAMP Requirements
  ]
}))

# Wire IS into the spine — Component parent (rel 8) AND IR parents (rel 7, one per catalog)
set_asset_relationships(asset_id=<CMPN-ENTRAID asset_id>, relationships_json='[{"id":9101,"relationship_id":8}]')   # Component → IS
set_asset_relationships(asset_id=<IR-ACME-FR5CC-AC-02 asset_id>, relationships_json='[{"id":9101,"relationship_id":7}]')   # Rev5 IR → IS
set_asset_relationships(asset_id=<IR-ACME-FR20XCC-IAM-AAM asset_id>, relationships_json='[{"id":9101,"relationship_id":7}]') # KSI IR → IS

# Verify
get_asset(asset_id=9101)
```

### 6. Update an Implementation Statement's narrative + validation procedure

```
# Read existing state (full-replace on fields[]/customfields[] requires read-merge-write)
get_asset(asset_id=<IS asset_id>)                              # full Device row + all fields
  → assettype_id = 191 (Implementation Statement)
  → captures current Title, Implementation Statement, Responsible Role, framework CFs

# Discover valid fields for THIS type
parallel: asset_types, asset_type_fields(assettype_id=191), asset_type_allowed_statuses(assettype_id=191)

# Compose merged payload — preserve every field NOT being changed, change only what's needed
update_asset(payload=JSON.stringify({
  "id": <IS asset_id>,
  "fields": [
    {"id": 209, "value": "<existing UUID>"},
    {"id": 213, "value": "<existing Title>"},
    {"id": 225, "value": 1},                                   # Implementation State unchanged
    {"id": 222, "value": [{"value":"1","label":"Organization"}]},
    {"id": 224, "value": 1},
    {"id": 229, "value": "<new long-form narrative>"},         # ← the change
    {"id": 232, "value": 1},                                   # Validation Method = Machine Based
    {"id": 233, "value": "<validation process>"},
    {"id": 234, "value": "<pass-fail criteria>"}
  ],
  "customfields": [
    ...preserved framework CFs from existing state...
  ]
}))

# Verify the write landed
get_asset(asset_id=<IS asset_id>)
```

If you pass `ipv4_address` here, Halo silently drops it — IPv4 is FIid 29 (Cloud/Hardware Asset only). That's the point of step 2.

### 7. Partial CF update on an existing Issue (omitted-PRESERVED — single call)

Partial CF updates on Issue tickets don't need a read-first merge — `update_ticket_fields` preserves omitted CFs:

```
# Send only the CFs being changed; all other CFs preserved
update_ticket_fields(payload=JSON.stringify({
  "id": <ticket_id>,
  "customfields": [
    { "id": 378, "value": [                                          # CFFedRAMPControls — the only intended change
      {"id":"<lookup_id>","name":"AC-02"},
      {"id":"<lookup_id>","name":"AC-03"},
      {"id":"<lookup_id>","name":"AC-05"}
    ]}
  ]
}))

# In a FOLLOW-UP call, update CFFedRAMPRequirements once CFFedRAMPControls is settled
# (Requirements lookup is filtered by current Controls)

agent_note(ticket_id=<ticket_id>, note="<billable rationale for the controls change>", time_taken=0.25)

# Verify
get_ticket(<ticket_id>)                       # confirm the multi-select landed
```

On the asset side, `update_asset` is merge-on-omit (live-verified 2026-06-14) — the read-first merge step is required only when editing a MULTI-SELECT value (a sent entry replaces its whole array); see `halo-platform/write-semantics.md` § 2.1, including the param-templated-runbook caveat. On the ticket side, `update_ticket_fields`'s omitted-PRESERVED semantics never needed it.
