# Halo write semantics

> **Shared platform corpus** — `halo-platform/` is vendored byte-identical across the plugins that carry it; a consistency check enforces identity. Do not hand-edit a vendored copy — edit the canonical copy (see `halo-platform/README.md`) and re-run the corpus sync. Supersedes the former `halo-write-shapes.md`.

Consolidated payload-shape and behavior rules for writing to Halo via the grcitsm MCP server tools — null-handling semantics, lookup-value matching, framework-CF ordering, ticket vs asset endpoint differences, payload-size bounds, KB Article conventions, and write verification. Every rule here is anchored to a verified Halo failure mode; most failures are **silent** (the call returns success, the value never lands).

> **Live validation (2026-06-14, Site 18 "Demo FedRAMP System").** Confirmed end-to-end against the live tenant: `update_asset` is MERGE-on-omit for both `fields[]`/`customfields[]` and top-level columns — an omitted entry is preserved, while a **sent** entry replaces that field's whole value (arrays included, so read-merge-write is required only for multi-selects); `update_ticket_fields` omit-preserved; multi-select framework CFs land name-dispositive (`[{name:"AC-02"}]`) via the Update route; single-select resolves a scalar string to its fcode int; lookup values match verbatim incl. the live `"Low RIsk"` typo and zero-padded control IDs; `set_asset_relationships` stores a typed `relationship_id` on the upstream subject; KB create rejects a bare-string tag and silently drops phantom keys (`assets`, `archived`). Write tools return the full row when the tenant relay is configured — verify against the `fields[]` array, not the lagging header/`display` echoes.

## 1. Write-tool families — endpoint map

| Tool | Endpoint family | Notes |
|---|---|---|
| `create_new_ticket` | Create Ticket | Identity-scoped (user_email, type, summary, short description). No CFs, no agent. |
| `update_ticket_fields` | Update Ticket (`POST /api/Tickets`) | The primary ticket write path. Caller composes the full JSON body. |
| `agent_note` | Action POST (`POST /api/Actions`) | Billable attribution (action history, time tracking, charge rate); `new_*` parent-field convention. |
| `create_asset` / `update_asset` | Create Asset / Edit Asset | One tool pair for every asset type; `update_asset` is pass-through (single JSON `payload`). |
| `create_kb_article` / `update_kb_article` | KB (unified) | `id` present → update; absent → create. |
| `set_ticket_assets`, `set_ticket_parent`, `set_ticket_client_site`, `set_ticket_sla_and_priority`, `assign_agent`, `set_asset_relationships` | structural writes | Each is the ONLY path for its operation — see § 12. |

**API model naming — snake_case even where SQL has none.** Update Ticket / Edit Asset bodies use the API model's snake_case FK names (`parent_id`, `status_id`, `sla_id`, `priority_id`, `client_id`, `team_id`, `agent_id`, `dateoccurred`, `targetdate`, `categoryid_1`) even when the underlying SQL columns have no underscore (`fxrefto`, `statusid`, `slaid`, `seriousness`, `areaid`). Match the API model, not the SQL column — the bare SQL name (`slaid`) is silently dropped.

## 2. Null-handling — merge-on-omit vs full-replace vs guarded diff

Halo has two write semantics for the same JSON shapes — plus one special case, the ticket `todo_list[]`, which is a **guarded diff**. Knowing which tool uses which is the difference between a clean update and silent data destruction.

| Tool | `customfields[]` / `fields[]` arrays | Top-level fields |
|---|---|---|
| `update_asset` | **MERGE on omit** — an omitted `fields[]`/`customfields[]` entry is PRESERVED; a **sent** entry replaces that field's whole value (arrays included → read-merge-write only for MULTI-SELECTs) | **MERGE on omit** — omitted top-level Device columns (`inventory_number`, `status_id`, scalars) preserve |
| `agent_note` | **FULL-REPLACE** on `customfields[]` | Top-level `new_*` fields preserved when omitted |
| `update_ticket_fields` | **OMIT-PRESERVED** for `customfields[]`; **`todo_list[]` is a guarded diff** (§ 2.3); `assets[]` is full-replace | Omit-preserved |
| `update_kb_article` | Scalars omit-preserved; **arrays (`tags[]`, `devices[]`, `faqlists[]`) replace when present** | Omit-preserved |
| `update_system_information` | **Less well-characterized** — until a live probe confirms otherwise, read-merge-write the full row | (same caution) |

### 2.1 The asset-side rule

**`update_asset` is merge-on-omit, not full-replace** (live-verified 2026-06-14 — this corrected earlier doctrine that claimed full-replace). An omitted `fields[]`/`customfields[]` entry — and any omitted top-level Device column — is **PRESERVED**. A narrow update that sends only the changed field is safe.

> **Why older doctrine said full-replace — runbook style decides.** The null-handling semantics belong to the backing runbook, not to Halo's Edit Asset endpoint. A **param-templated** runbook renders every omitted tool parameter as JSON `null`, and Halo persists `null` as "clear this field" — that behavior was verified destructive on a populated asset (2026-05-18) and produced the old "read-merge-write everything" rule. The current standard catalog's `update_asset` is a **bare pass-through** (`<<request^payload>>`): the body contains only what the caller sends, so omitted fields never reach Halo and are preserved (verified 2026-06-14). **If you are on a tenant whose Edit Asset runbook is param-templated rather than pass-through, treat every write as full-replace and read-merge-write the entire row.** One probe on a disposable asset settles which style your tenant runs.

What a **sent** entry does: it replaces that field's WHOLE value, arrays included. So the only case that needs read-merge-write is a **multi-select** field where you want to ADD to (not replace) the existing selections — read the current array, union your additions, and send the full array. Scalars and single-selects need no pre-read.

**Clearing requires an explicit empty value, not omission.** Omitting a field preserves it; to clear, send it with `""` (scalars) or `[]` (arrays). **Exception — child-table arrays do not all accept empty-array clear:** a Project's `budgets[]` rejects `budgets:[]` with `System.Exception: Record not found` (observed) rather than clearing the rows. For such child-table arrays, read-merge-write the rows you want to keep; do not rely on `[]` to wipe them.

```
# SAFE — narrow update; every omitted field/column is preserved
update_asset(payload={"id": N, "customfields": [{"id": 382, "value": "..."}]})

# MULTI-SELECT ADD — read-merge-write so you don't drop existing selections
update_asset(payload={"id": N, "customfields": [{"id": 382, "value": [...existing + new...]}]})

# INTENTIONAL CLEAR — send an explicit empty value (omission would preserve)
update_asset(payload={"id": N, "customfields": [{"id": 382, "value": ""}]})
```

To read current values before a multi-select merge: `get_asset(asset_id)` (point read), `asset_summary` (bulk roll-up), or `active_inventory` (Cloud/Hardware only).

**Never two `update_asset` calls against the same asset in parallel** — a sent multi-select entry replaces its whole array, so concurrent writers clobber each other's members. Sequence per asset.

### 2.2 The ticket-side rule

`update_ticket_fields` uses omit-preserved semantics throughout: `{"id": N, "sla_id": 3, "customfields": [{"id": 353, "value": "..."}]}` updates exactly those fields and preserves the rest. No read-merge-write needed. Every key present is a write; every key absent is a no-op.

`agent_note` (Action POST) is the remaining ticket-side tool with a full-replace `customfields[]` array — avoid sending partial CF arrays through it.

### 2.3 The ticket to-do list — `todo_list[]` is a guarded diff

`update_ticket_fields` is omit-preserved for `customfields[]` and native fields, but its **`todo_list[]`** array — the ticket's checklist UI, the same array `get_ticket` returns — is neither omit-preserved nor full-replace. It is a **guarded, non-atomic diff**. The backing runbook is a bare `<<request^payload>>` passthrough to Update Ticket, so `todo_list` writes even though neither the MCP tool nor the runbook whitelists it. Verified live on UAT ticket 2387 (2026-06-29).

**Rule: `get_ticket` first, then POST the FULL union of existing items (each with its `id`) plus your changes. Never omit an item you don't intend to delete.**

```jsonc
// CORRECT — read-merge-write the whole list
{
  "id": 2387,
  "todo_list": [
    { "id": 1, "text": "Existing item",        "sequence": 10, "done": true  }, // kept + checked off
    { "id": 2, "text": "Another existing item", "sequence": 20, "done": false }, // kept verbatim
    {          "text": "Brand-new item",        "sequence": 30, "done": false }  // inserted (no id → Halo assigns next)
  ]
}
```

How Halo applies the posted array: item **with `id`** → update; item **without `id`** → insert; stored item **you omit** → **delete-attempt**, gated by ownership. The delete is the trap: Halo refuses to delete an item another agent created (`You don't have permission to delete to-do items added by other agents`); the MCP connection runs as its own agent (`addedby: 0`) so it cannot delete UI-user items. **Not atomic — the error does not roll back**: the write commits the updates and inserts it could apply, skips the blocked delete, and leaves the list half-applied with scrambled sequences. Sending the full union never triggers a delete, so the guard never fires.

Item shape: `text` (required), `sequence` (required — use spaced values 10/20/30), `id` (include to update/keep; omit for new), `done`, `group_name`, `addedby` (read-only — drives the delete guard).

## 3. Lookup-value rules

Halo lookup CFs match the supplied value against the lookup table **verbatim** — no fuzzy match, no whitespace tolerance. Mismatches are silently dropped at Halo's persistence layer; the API returns success, the customfield never updates. Applies to every write tool with lookup-valued CFs, ticket-side and asset-side.

- **3.1 — Comma-separated values use NO space between commas.** `"AC-2, AC-3, SC-7"` fails (the parser splits on `,` and the tokens carry leading spaces). Correct: `"AC-02,AC-03,SC-07"`.
- **3.2 — Control IDs are zero-padded to two digits.** `AC-2` fails; `AC-02` succeeds.
- **3.3 — Enhancements use ` (NN)` parenthesized two-digit suffix.** `AC-06 (01)` — zero-padded base + single space + parenthesized zero-padded enhancement. `AC-6 (1)`, `AC-6(1)`, `AC-06.1` all fail.
- **3.4 — Halo-side typos must be matched verbatim** (correcting them would invalidate historical data): `CFRemediationLevel` = `Offical Fix` (not `Official Fix`); `CFRisk` = `Low RIsk` (capital I). Historical note: `CFValidationMethod` formerly carried the `Machined Based` typo; the Validation ticket-type migration (2026-06-28) replaced its value set with `Automated` / `Manual` (live-verified 2026-07-02) — no typo remains on that CF.
- **3.5 — CSV vs single-value depends on the CF.** Framework mapping CFs (`CFFedRAMPControls`, `CFFedRAMPRequirements`, `CFFedRAMPKSIs`, `CFCMMCControls`, `CFCMMCRequirements`) are CSV in SQL-rendered form but MUST be written as object-arrays (§ 4-5). Rating/flag lookups (`CFRemediationLevel`, `CFValidationMethod`, `CFRisk`, `CFOriginalRiskRating`, `CFVendorDependency`, `CFIsPOAM`, `CFInternetReachable`, `CFLikelyExploitable`, `CFAutoApprove`, `CFBODTracking`) are single value. CVSS environmental metric lookups (`CFAttackVector`, `CFAttackComplexity`, `CFPrivilegesRequired`, `CFUserInteraction`, `CFScope`) take a single value with parenthesized vector code (`Network (N)`, `Local (L)`). `CFPotentialAdverseImpact` takes `N5`…`N1` (not High/Moderate/Low). `CFChangeType`/`CFImpact`/`CFRisk` on Change Requests take verbatim Halo allowed values, not textbook ITIL labels.
- **3.6 — KSI catalog stores families, not specifics.** `KSI-AFR-CCM`, never `KSI-AFR-CCM.10` — strip the `.NN` suffix before writing.
- **3.7 — Asset Field validate-W is array-shape:** `fields[].value = [{value:"<fcode>", label:"<display>"},...]` for multi-select Asset Fields — array required even for single-pick UI. **Validate-V accepts scalar:** `fields[].value = "<display>"` OR the `<fcode>` int for single-select Asset Fields; the server resolves either form.
- **3.8 — Status writes pass the integer.** Ticket/asset status display strings are not interchangeable with status IDs: pass `status_id` (resolve via `ticket_type_allowed_statuses` / `asset_type_allowed_statuses`); display strings at that level are silently ignored.
- **3.9 — Boolean CFs reject string `"Yes"`/`"No"` with `FormatException`.** Pass `true`/`false` JSON booleans for Boolean-backed CFs.
- **3.10 — Derived / computed CFs cannot be written as scalars** (server-resolved or table-CF-rendered values). Their backing rows may be writable via a dedicated tool; the rendered CF never is.
- **3.11 — Tool param descriptions can drift behind the live lookup** — and behind the live COLUMN SET: MCP tool registration is a manual step the report-sync path does not cover (§ 11), so a tool's description is evidence of neither its value set nor the columns it emits (live-verified 2026-08-08: a field-discovery tool's description still advertised a pipe-delimited value list while the tool demonstrably emitted `[Lookup Method]` / `[Lookup Source SQL]`). The only evidence of a tool's output shape is calling it and reading what comes back. Cross-check `ticket_type_fields` / `asset_type_fields` for the type before writing; the `[Allowed Values]` there is authoritative for static lookups. **Dynamic lookups resolve their value set at runtime**, so a field-discovery snapshot of their allowed values can be stale: never reject a dynamic-lookup value solely because it is absent from `[Allowed Values]`, and never source dynamic value text from it — take it from the per-family catalog tool. For baselines (Site `CFBaselines`, asset `CFBaseline` § 5.3) that is `baseline_catalog` `[Baseline Name]`, verbatim — prefer `Active == True` rows for NEW writes; framework CF values validate against `lookup_controls` / `lookup_requirements` for the Site's baselines (write mechanics: § 5). Inactive baselines remain valid values: Halo's baseline lookup intentionally applies no `Active` filter, so historical records on retired or archived assets keep their baseline values — never strip or reject an existing value because its baseline has since gone inactive. Caution — a lookup backed by a Custom Integration has no reliable discovery signal today and can present a stale, static-looking list; when a lookup's values are tenant data, verify against the live source. How to tell static from dynamic — the `[Lookup Method]` / `[Lookup Source SQL]` discovery columns, the sentinel, and the CFBaseline four-type scope rule: § 5.5.

### Diagnostic signal

When a write returns success and the CF on read-back is empty (or unchanged), the failure-mode rank-order is:

1. **Lookup-value mismatch** — re-check 3.1-3.4.
2. **Phantom CF** — the CF isn't registered on the target ticket/asset type. Consult `ticket_type_fields(ticket_type_id)` / `asset_type_fields(assettype_id)` BEFORE composing any `customfields[]` entry.
3. **Site-scope filter** — the value belongs to a baseline not on `Site.CFBaselines` (§ 5.2).
4. **Value-source dtype allow-list** — on a newly-added asset type (§ 6, Gotcha 1).
5. **`customfields[]` full-replace / array clobber** — an `agent_note` payload dropped omitted entries, or a multi-select was sent without unioning prior members (§ 2).

## 4. Ticket-side multi-select framework CFs — Update Ticket endpoint only

Multi-select lookup customfields with SQL-driven option sets (type 3, extratype 2 — `CFFedRAMPControls`, `CFFedRAMPRequirements`, `CFFedRAMPKSIs`, `CFCMMCControls`, `CFCMMCRequirements`, `CFDrataControls`) **cannot be written to tickets via the Action POST path**. The action succeeds and even echoes the value back, but Halo's action-to-ticket propagation renders them as CSV strings, which the multi-row `CFA<NN>` association table does not parse — the values silently drop.

The canonical path is `update_ticket_fields` with the id-keyed array shape:

```json
{"id": <ticket_id>, "customfields": [
  {"id": 378, "value": [{"id": "0", "name": "AC-02"}, {"id": "0", "name": "AC-03"}]}
]}
```

CSV strings on Update Ticket are accepted but stored as literal string blobs — downstream reports/filters won't read them. Always use the array form.

Ticket-side framework CF Field IDs (standard GRCITSM tenant schema — ticket-side `CF*` ids differ from asset-side `CFA*` ids):

| CF Name | FIid |
|---|---|
| `CFFedRAMPControls` | 378 |
| `CFFedRAMPRequirements` | 391 |
| `CFFedRAMPKSIs` | 381 |
| `CFCMMCControls` | 380 |
| `CFCMMCRequirements` | 392 |
| `CFDrataControls` | 462 |

Single-value lookup CFs (type 2, extratype 1) are NOT affected; those propagate correctly through `{name, value}` on Action POSTs, with Halo auto-resolving the display string to the underlying code on persist.

Note the Drata pluralization trap: the mapping table column is `CFDrataControl` (singular) while the ticket CF is `CFDrataControls` (plural, FIid 462). Only Drata has this mismatch.

**Controls before Requirements — two-pass on BOTH sides.** The Requirements lookup is filtered by the currently-persisted Controls value; sending both in one payload drops Requirements. Write Controls first (one call), then Requirements in a follow-up call. The asset-side version of this rule, with its SQL mechanics, is § 5.

## 5. Asset-side framework CFs — three-phase, CFBaseline, site-scope

The shared Custom Fields on the SSP asset types, with their Halo Field IDs (standard GRCITSM tenant schema): `CFAFedRAMPControls` **382**, `CFACMMCControls` **383**, `CFAFedRAMPKSIs` **384**, `CFAFedRAMPRules` **597** (FRR, 3-3-3 shape e.g. `VDR-TFR-KEV`; carried on IRs AND IS assets — 20x sites attribute by it), `CFAFedRAMPRequirements` **399**, `CFACMMCRequirements` **400**. All six are type-3 multi-select lookups (SQL-source). Omitting 597 from a write plan silently drops 20x Rules attribution (live-verified 2026-07-19: a 3-of-6 build lost Rules on every IS row). Write shape:

```json
"customfields": [
  {"id": 382, "value": [{"id": "0", "name": "AC-02"}, {"id": "0", "name": "AC-03"}]}
]
```

**Halo auto-resolves `id:"0"` from the field's Lookup SQL at write time** — submit `{id:"0", name:"<display>"}` and Halo fills in the real ID on persist. No pre-built ID map is needed. Bare strings (`"AC-02"`) or CSV (`"AC-02,AC-03"`) silently drop.

### 5.1 The three-phase rule (Baseline → controls tier → requirements tier)

The six CFs write in three ordered phases (historically called "two-pass" — phases 2-3 below; the compile layer's three-phase naming is canonical since 2026-07-19):

- **Phase 1 — baseline alone**: `CFBaseline` 503 (§ 5.3) lands first, by itself — the framework CFs' Lookup SQL is site/baseline-scoped (§ 5.2), so the controls tier resolves against a persisted baseline.
- **Phase 2 — controls tier**: `CFAFedRAMPControls` 382, `CFAFedRAMPKSIs` 384, `CFAFedRAMPRules` 597, `CFACMMCControls` 383.
- **Phase 3 — requirements tier**: `CFAFedRAMPRequirements` 399, `CFACMMCRequirements` 400.

Phase 3 MUST follow phase 2 because the Requirements field's Lookup SQL filters against the device's CURRENT `CFAFedRAMPControls` value:

```sql
WHERE (',' + device.CFAFedRAMPControls + ',') LIKE '%,' + CAST(CFRControlID AS VARCHAR) + ',%'
```

Phase 2 MUST follow phase 1 for the same mechanical reason (value-source SQLs read live, 2026-08-07): the controls-tier CFs source from `CTControlBaselines` JOINed on the DEVICE'S STORED baseline id (`asset_bl.CTControlBaselinesid = d.CFBaseline`) — an empty `d.CFBaseline` yields ZERO allowed values, so every controls-tier value silently drops. Stored multi-select values are `CTControlBaselinesid` rows routed by ID shape (`KSI-%` → KSIs; `[A-Z][A-Z]-[0-9]%` → Controls; `%.L%` → CMMC; 3-3-3 non-KSI → Rules). These are the value-source mechanics behind ordering rules this section previously asserted from observed behavior alone — an upgrade from asserted to grounded, not a change in the rules.

```
Call 1 (phase 2): update_asset(... "customfields":[{382: Controls}, {384: KSIs}, {597: Rules}, {383: CMMC Controls}])
        → controls tier lands. Requirements MUST be omitted in this call.
Call 2 (phase 3): update_asset(... controls tier re-sent verbatim ..., {399: Requirements}, {400: CMMC Requirements})
        → Requirements land cleanly against the now-stable Controls value.
```

Call 2 re-sends the Controls values verbatim. Because 382 and 399 are different fields, merge-on-omit would preserve an omitted 382 — but re-sending is the safe pattern and is mandatory when Call 2 edits the SAME multi-select it needs to keep prior members on. Never send an EMPTY 382 array in Call 2; that clears the Controls.

### 5.2 Site-scope filter

Each framework CF's Lookup SQL is scoped to `Site.CFBaselines`. Writing a value from a baseline NOT in the site's `CFBaselines` produces a silent drop — the lookup returns zero allowed values for that field. Pre-flight: `system_information(site_id)` → confirm `CFBaselines` includes the baseline whose values you're about to write; drop the `customfields[]` entry if not scoped. The ticket-side framework CFs follow the same ordering and site-scope filter.

### 5.3 CFBaseline (id 503) — scalar, name-string only

The global CFBaseline Custom Field (FIid 503; validate=V, type=2 single-select, `database_lookup_auto: false`) lives on every asset type. Unlike the multi-select framework CFs it takes a **scalar string** matching a verbatim entry on the parent Site's `CFBaselines` CSV:

```jsonc
"customfields": [
  {"id": 503, "value": "FedRAMP Rev5 Class C"}
]
```

**NEVER send the numeric lookup id for CFBaseline.** A single-select stores an integer *verbatim* with no name-resolution. Sending the **name string** lets Halo re-resolve it to the lookup's *current* fcode at write time, so the row survives a later lookup re-mint (e.g. a catalog refresh). Sending the **integer id** stores a dangling pointer the moment that id is retired — the Baseline then renders **blank** with no error. (Root cause of the 2026-06-24 incident: 199 Implementation Statements carried a retired id instead of the name and went blank after a catalog refresh re-minted the baseline lookup. The tiers written by name re-resolved cleanly.) **Every CFBaseline write — create, update, pass-2 re-send — uses the verbatim name string.**

### 5.4 The native asset importer cannot carry framework CFs

Halo's native asset import (Configuration → Asset Management → Import) was live-measured (2026-07-15 UAT): SQL-backed framework CF columns (`CFA*`) **never persist through an import file** — values drop whether supplied by display name or by lookup id — and **when those columns are present in the file, existing framework-CF values on matched assets are destructively wiped**. The importer also offers no status control (new assets take the tenant's "status of new assets" setting) and match-updates require the Imports unique-identifier configuration. Treat the native importer as a **fields-only fast path** (Asset Fields + scalar CFs like `CFBaseline`, which is itself a dynamic lookup — § 5.5); framework CFs always ride the API path (§ 5.1). Spreadsheets remain excellent **plan artifacts** — reviewable, diffable — applied via the MCP write tools, not the importer.

### 5.5 Lookup-method discovery — `[Lookup Method]` and `[Lookup Source SQL]`

The three field-discovery reports (`site_fields`, `ticket_type_fields`, `asset_type_fields`) carry two lookup-provenance columns alongside `[Allowed Values]` (live-verified 2026-08-08). `[Lookup Method]` reads `Static` (fixed Lookup rows — `[Allowed Values]` is authoritative, § 3.11), `Dynamic (SQL)` (value set resolved at runtime from the field's value-source SQL), or empty (free-form, no lookup). `[Lookup Source SQL]` carries the value-source SQL verbatim on dynamic rows. On a dynamic row `[Allowed Values]` is the literal sentinel string `(dynamic SQL lookup: values resolved at runtime, static Lookup rows are stale)` — never parse the sentinel as a value list.

- **SQL-text presence is the only reliable discriminator.** Halo's `filookupmethod` / `Ylookupmethod` flag columns read `0` on every row regardless of the actual method (measured live) and were removed from the reports. Classify by whether value-source SQL text is present, never by a flag column.
- **The SQL columns are `ntext`.** Any report or ad-hoc SQL that compares or filters on them must `CAST(... AS NVARCHAR(MAX))` first — `ntext` rejects direct comparison operators.
- **No third `[Lookup Method]` arm exists.** A lookup backed by a Custom Integration is unmapped and misclassifies as `Static` — the § 3.11 live-source caution.
- **Where the dynamic rows live** (standard GRCITSM tenant schema): Site `CFBaselines` (379 — its value source intentionally applies no `Active` filter: inactive baselines stay in the value set, § 3.11) and asset `CFBaseline` (503, § 5.3). `ticket_type_fields` reports zero dynamic rows today (all `Static` or free-form).
- **`CTBaselines` vs `CTControlBaselines` — two distinct tables; never generalize from the value-source SQL.** Both baseline value sources read `FROM CTControlBaselines` (the pure include-controls membership junction), which invites the false inference that every baseline reference should name `CTControlBaselines`. The tell against it sits in that same SQL: `GROUP BY CFBaselineName` only makes sense on a table with many rows per baseline name — a junction, not a dimension. The baseline DIMENSION — one row per baseline, the source of `baseline_catalog`'s 11 rows and of `CFBLShortCode` — is `CTBaselines`.

**CFBaseline (503) is settable on the four SSP-spine types ONLY — intended scope, not a defect.** `CFBaseline` is exposed on EIGHT asset types — Component (173), Capability (178), Cloud Asset (188), Hardware Asset (189), Implemented Requirement (190), Implementation Statement (191), Control Implementation (192), Set Parameter (193); dtype ids are tenant-standard defaults, resolve live when in doubt — all `Dynamic (SQL)`, all sharing one value source. That SQL's allow-list, visible in `[Lookup Source SQL]`, reads `d.dtype IN (190, 191, 192, 193)`: by design, only Implemented Requirement, Implementation Statement, Control Implementation, and Set Parameter carry a settable baseline. The `CFBaseline` exposure on **Component, Capability, Cloud Asset, and Hardware Asset is vestigial — do not write `CFBaseline` on those four types.** The observable consequence of trying: the lookup resolves to an EMPTY value set, the write is silently dropped with no error, and `[Allowed Values]` shows only the sentinel. Do not widen the allow-list to make those writes land. (Standing up a NEW asset type that is MEANT to carry the framework CFs is the different case — that is § 6 Gotcha 1.) Before writing a dynamic-lookup CF on a type you have not verified, read the field's `[Lookup Source SQL]` and confirm the target dtype is on the allow-list.

## 6. Standing up a NEW asset type — two mandatory gotchas

Both are **verified Halo failure modes** (surfaced standing up a new spine type) and fire for **any** new `dtype` that reuses the global CFs. Their defining symptom is **success-but-wrong**: `update_asset` returns `ok=true` and the display echoes the value, but `get_asset` shows the CF empty. There is no error — the only signal is the verify read.

### Gotcha 1 — CF value-source dtype allow-list (tenant Admin-UI fix, not a write-shape fix)

Global CFs appear on a new asset type automatically (`usage=5`) and show up in `asset_type_fields`, so the field looks wired. **But their values silently drop on write until the new `dtype` is added to the `d.dtype IN (...)` allow-list inside EACH CF's value-source SQL.** Per-type Asset Fields persist fine through this — only the global CFs drop, which is what makes it look like a write-shape bug.

```sql
... WHERE d.dtype IN (190,191,192)   -- new dtype missing → lookup returns zero allowed values
      AND ',' + s.CFBaselines + ',' LIKE '%,' + cb.CFBaselineName + ',%'
```

**Fix:** via the Admin-UI value-source edit, add the new dtype to `CFBaseline` (503) AND each framework CF the new type uses — the controls tier (382/383/384 + the Rules CF), plus the Requirements tier (399/400) if used. CF ids are tenant-standard defaults; resolve live by name when in doubt (the Rules CF has been observed at 597 where older docs pinned 385 — this document's own § 5 already carries 597). This is a **tenant configuration change** — no write shape will work around it.

### Gotcha 2 — two-phase CF write (still required after Gotcha 1)

Even with the dtype on every allow-list, a single combined `{503 + 382}` call still drops the framework value: the framework-CF value-lists filter on the asset's **persisted** `CFBaseline`, which is not yet committed when the combined call evaluates the lookup. Two transactions:

```
Call 1: update_asset(... {"id":503,"value":"FedRAMP Rev5 Class C"})   → baseline commits; framework CF omitted
Call 2: update_asset(... 503 re-sent verbatim, {"id":382,"value":[{"id":"0","name":"AU-11"}]})   → framework value resolves
```

This stacks with § 5.1 (baseline → controls → requirements). Never send an EMPTY 503 in Call 2 (clears the baseline).

**Scope of this rule — ordering on a FRESH asset, not co-presence.** Once the baseline HAS persisted, re-sending 503 alongside the framework CFs is verified harmless (2026-08-07, disposable-asset probes: a later co-send neither wipes nor perturbs the framework CFs) — Call 2 above re-sends it deliberately. Do not "fix" a payload or runbook by stripping an already-persisted baseline co-send; the combined-call prohibition constrains the FIRST write against an unpersisted baseline only. (One theory-driven removal of the co-send was made on the wipe assumption and had to be reverted after live disproof.)

## 7. Action POST field names — `new_X` for parent-ticket transitions

When an Action POST body (e.g. via `agent_note`) needs to mutate fields on the parent ticket, the field name follows the `new_X` convention. Names without the prefix are **silently dropped** — the action is created, the ticket-level change does not apply.

| Parent-ticket field | Action POST field | Underlying column |
|---|---|---|
| Status | `new_status` | `Faults.status` |
| Priority | `new_priority` | `Faults.seriousness` |
| Start date | `new_startdate` | `Faults.fprojectstartdate` |
| Target date | `new_targetdate` | `Faults.fopptargetdate` |
| Date Reported (SLA trigger) | `new_dateoccured` ¹ | `Faults.dateoccured` |
| Custom field (any) | `customfields: [{"name": "CFFoo", "value": ...}]` | `Faults.CF*` |

¹ The column is spelled `dateoccured` (single `r`) in Halo's schema; mirror that spelling in the POST key. Writing it moves "Date Reported" — the SLA-trigger anchor — and Halo auto-recomputes the SLA target date.

**Action-scoped fields** (`datestart`, `targetdate`) set the action's own record only; use the `new_*` variants when the parent ticket's dates must also move.

**Capabilities the Action POST does NOT have:**
- **SLA reassignment.** Every name shape (`new_slaid`, `new_sla`, `sla_id`, `slaid`) is silently dropped. SLA is settable only through Update Ticket: `update_ticket_fields(payload={"id": N, "sla_id": 3})` — or the dedicated `set_ticket_sla_and_priority` tool.
- **Ticket reassignment.** `agent_id` on an Action POST is action-attribution only. Use `assign_agent`.
- **Agent attribution under automation auth.** Calls executed by Halo's automation service authenticate as that principal, and Halo overrides every agent-attribution field on persist (`actionby_agent_id` → 0, `who` → `"System"`). There is no payload-driven way to attribute an Action to a human agent.

## 8. Payload cliff on write tools

A multi-paragraph payload on any runbook-backed Halo write tool can be silently rejected upstream of Halo with a generic denial ("user denied tool use") — no audit-log row, no MCP error. Observed calibration:

- `create_new_ticket` with ~400-char `description` — rejected; 20-char and ~130-char descriptions — succeed reliably (including 8-batch parallel waves).
- `update_ticket_fields` with three concurrent long-form CFs (~390 + ~310 + ~190 chars) — rejected. The cliff is endpoint-agnostic.
- SEVEN concurrent long-form fields each ~85 chars (total ~600) — landed cleanly. **Single-field length dominates**: any one long-form field over ~300-400 chars triggers the cliff; aggregate size matters secondarily.

**Rule:** keep every long-form text field in any single MCP write call ≤ ~200 chars where possible; split multi-field updates rather than batching several long fields into one payload.

**Workaround patterns:** (1) at create time keep `description` ≤ ~200 chars — a one-line context anchor, not a write-up — and append the long-form narrative after create via `update_ticket_fields` long-form CFs or `agent_note` (those paths are designed for long-form content); (2) on updates, write one long-form field per call when in doubt; (3) in bulk flows, plan-to-file first, create sequentially with short payloads, then update-pass long-form content one field at a time. Parallel batches with short payloads remain viable — the cliff is per-call payload size, not batch count.

**Diagnostic signature:** the harness reports a tool-use rejection for an apparently arbitrary subset of calls, and the same tool succeeds when the payload is shortened. If the rejection survives shortening, suspect a real permission denial; if it disappears, it's the cliff.

## 9. The two "who" fields — user vs agent

Every Halo ticket carries **two independent** people fields, populated through different tools; conflating them produces the "all my new tickets show as Unassigned" surprise.

| Field | Column | Set via | Identifier shape |
|---|---|---|---|
| **User** — the external person the ticket is logged for (the requester); drives `Faults.clientid` inheritance via the user's organization | `Faults.userid` | `create_new_ticket(user_email=…)` at create; re-point post-create via `update_ticket_fields({id, user_id})` — `user_id` is a native Update Ticket who-field | Email at create (resolved server-side to USERS row); integer user_id on update |
| **Agent** — the internal owner working the ticket | `Faults.assignedtoint` | `assign_agent(ticket_id, agent_id)` — the only working write path | Integer agent_id from the `agents` catalog (1 = Unassigned) |

`create_new_ticket` sets `userid` from `user_email` and **leaves `assignedtoint` = 1 (Unassigned)** — no auto-assignment, no team round-robin. The canonical bulk-create chain is therefore FIVE steps: `create_new_ticket` → `set_ticket_client_site` (if cross-client — must run before the rest) → `update_ticket_fields({id, user_id})` when the requester must differ from the creating `user_email` (the create derives `userid` from `user_email`; the native `user_id` field re-points it) → `update_ticket_fields` (CFs, dates, category) → **`assign_agent`** (easy to forget; most Halo workflows — auto-emails, SLA breach alerts, agent dashboards — key off `assignedtoint` and silently skip Unassigned rows).

Resolve the target agent_id via the `agents` catalog first — display names are not authoritative; only the integer ID is stable. Pass `agent_id=1` to explicitly unassign. `create_new_ticket` deliberately does not accept an `agent_id`: creation and assignment are separate lifecycle stages, and `assign_agent` produces the audit-trail action.

## 10. Create → update chains (both sides)

Halo's Create endpoints are identity-scoped by design; field population happens in the Edit/Update call that follows. Never try to land CFs in a create call.

**Ticket side:** `create_new_ticket(user_email, ticket_type, summary, short description)` → returns `ticket_id` → `update_ticket_fields` for CFs/dates/category → `assign_agent` (§ 9).

**The pre-update lookup quintet** — before any ticket update, run these in parallel:

| Tool | Resolves | Why scoped |
|---|---|---|
| `get_ticket(ticket_id)` | Current `ticket_type`, `sla_id`, existing CF values | Downstream validators key off `ticket_type`; SLA is needed for priority. |
| `ticket_type_allowed_statuses` | Valid `status_id` for the ticket's type | The global `statuses` catalog includes statuses not accepted by every type. |
| `ticket_type_allowed_categories` | Valid `category` path for the ticket's type | The global catalog is wider than the per-type allow-list. |
| `ticket_type_fields` | Live CF schema (existence, mandatory flag, allowed values, type) | Admins can add/rename CFs at any time — discover at call time. |
| `slas_and_priorities` | `priority_id` for the ticket's SLA | Priority labels are SLA-scoped (`api-mcp-semantics.md` § 7). |

`agents` and `charge_rates` are flat global lookups — resolve as needed.

**Asset side:**

```
1. asset_types                      → resolve assettype_id; cross-check the integer against the live
                                      catalog — runtime tool descriptions can drift
2. create_asset(inventory_number, assettype_id, client_id, [site_id])
                                    → returns asset_id; minimal valid Device row, no fields[]
3. asset_type_fields(assettype_id) [+ asset_type_allowed_statuses if setting status]
                                    → discover which Asset Fields + Custom Fields are valid for THIS type
4. update_asset(payload with only the params asset_type_fields said are valid)
```

Halo's Edit Asset API **does not enforce asset-type matching** — fields that don't belong to the target asset type are silently dropped without error. `asset_type_fields` UNIONs the per-type Asset Fields (FIELD table, `source: "asset_field"` → `fields[]` array) with the global Custom Fields (FIELDINFO, fiusage=5, `source: "custom_field"` → `customfields[]` array); each entry is `{id: <FIid>, value: ...}`.

**Restricted-status asset types create Active directly** (Create Asset runbook ≥ v0.2.1): a post-create "Activate Asset" Edit step lands the asset Active on every type — call `create_asset` on the real target `assettype_id` and trust the runbook. (The former workaround — create as another type then type-flip — is obsolete; do not use it.)

## 11. Verify every write

Whether a write returns a rich payload or the bare string `"Task queued."` / `"Completed successfully"` is **tenant-relay-dependent** (two-part requirement below). Where the relay is configured, `update_asset` returns the **full Device row synchronously** — that response *is* a read-back. Still verify, because:

- **Header/name echoes lag.** Live-confirmed 2026-06-14: after a narrow write, the denormalized `key_field`/`display` echoes showed the OLD value while the authoritative `fields[]` array was correct. Ticket-side: `status_name`/`sla_name` may lag `status_id` right after POST, and `client_name`/`site_name` may lag `clientid`/`sitenumber` right after a `set_ticket_client_site` write (the id fields are correct; the denormalized names echo stale). **Trust `fields[]`/`customfields[]` and the id fields, never the keyfield/`display`/name echoes.**
- Where the relay is NOT configured, tools return only `"Task queued."`, which never confirms anything — "success" means "Halo accepted the runbook task," not "Halo persisted the data."
- Lookup/multi-select values that fail exact-match drop silently under a success response.

| Wrote | Verify via |
|---|---|
| Asset (any type), single row | **`get_asset(asset_id)`** — full Device row + per-type Asset Fields + global CFs + assettype_config |
| Asset (Cloud/Hardware), bulk | `active_inventory` (EXCLUDES SSP-spine types) |
| Asset (any type), bulk enumeration | `asset_summary` → loop `get_asset` per id |
| Any ticket, single row | `get_ticket(ticket_id)` |
| Tickets, bulk enumeration | `ticket_summary` → loop `get_ticket` per id |

`get_asset` requires the "Pause all background services" setting UNCHECKED (below). **`get_ticket` on an unknown id ECHOES the input args — it does not error.** If the response mirrors your arguments with no populated Faults columns, treat it as not-found, not as a successful read.

For bulk writes, sampling verification is acceptable: verify ~10% via point reads and rely on Halo's change history for the rest; if any sample fails, full-verify the batch.

### What controls payload relay (two-part requirement + asymmetric end-step trap)

Two independent things must BOTH be in place for the MCP server to relay API-response payloads on a runbook-backed tool:

1. **Admin setting** — Halo → Configuration → Advanced Settings → **"Pause all background services"** must be **UNCHECKED**. When checked, ALL runbook responses short-circuit to the generic string.
2. **Runbook step config** — BOTH end steps of the backing runbook must relay the API response (`message3: <<apiresponse1>>` on Successful AND Unsuccessful end-steps). Halo's runbook designer can persist them inconsistently across re-imports.

**Asymmetric-relay trap:** if the failure path returns rich Halo exceptions but the success path returns `"Task queued."`, only the Unsuccessful branch's relay field is set. Diagnostic order: (1) admin setting, (2) Successful end-step relay field, (3) Unsuccessful end-step relay field, (4) the runbook's Halo API Action matches the intended operation.

### Deploys are writes too — `Sync complete` is not proof a report definition changed

Report definitions ride their own write path: a push to `main` touching `reports/**` fires the repo's `halo-sync` workflow, which forwards changed report files to the sync endpoint. **A green run answering `"Sync complete"` is a transport-level acknowledgement, exactly like `"Task queued."` — request accepted, nothing proven persisted.** Live-verified 2026-08-08: a merge forwarded 15 changed files including three field-discovery reports, the workflow ran green, the endpoint answered `Sync complete` — and read-back returned output byte-identical to before the merge (same values AND same column order). Nothing applied, no error anywhere.

- **The repo is not evidence of a deployed report's definition either.** Reports can be hand-edited directly in the tenant, producing a deployed definition NO commit reproduces (observed: a live column ordering that no candidate commit produces). Tool descriptions are no better a witness — registration is a manual step the sync does not cover (§ 3.11).
- **After any report-definition deploy, read back a column the change would alter** — call the tool and check the output shape, exactly as this section requires after a data write. (Free canary as of 2026-08-08 while live and `main` disagree: live output orders `[Lookup Source SQL]` immediately after `[Lookup Method]`, `main` puts it last — one call shows whether a sync actually applied.)

This is the deploy-path instance of this section's "echo ≠ proof" rule (the assign-side instance is `assign_agent`'s silent revert, § 12).

### Citation lives in Halo's native change history

Halo's asset UI exposes a built-in change history per asset — every field-level write with timestamp and writing principal. **This IS the audit trail.** Do not build parallel provenance layers (inline `_Source:_` footers, provenance JSONL files, per-write tracking tickets); keep the approved plan artifact with source citations, and cross-reference by timestamp.

## 12. Cross-cutting structural writes

Ticket-type-agnostic tools, each the **only** path for its operation:

| Tool | Writes | Notes |
|---|---|---|
| `assign_agent` | `Faults.assignedtoint` | Resolve via `agents`; pass `1` to clear. **Target agent must be a member of the ticket's team** — see silent-revert note below. |
| `set_ticket_parent` | `Faults.parentid` | Body field `parent_id`. |
| `set_ticket_assets` | FAULTDEVICE junction | Body `assets[]`, **full-replace** — union before writing. |
| `set_ticket_client_site` | `Faults.clientid` + `Faults.sitenumber` | Cross-client moves re-scope SLA and may orphan linked assets / break parent links. **Send `client_id` AND `site_id`** — omitting `site_id` (or matching the current client) no-ops on the site, leaving the derived/default site in place. |
| `set_ticket_sla_and_priority` | `Faults.slaid` + `Faults.seriousness` | `{sla_id, priority_id}`; priority required. |
| `set_asset_relationships` | typed asset↔asset links | **Replace-semantics on the parent subject** — re-send existing children + additions. Parallel writers must NEVER call it per row: accumulate ids and make ONE batch call per parent at end-of-run. |

**`assign_agent` silent revert-to-team-default (verified failure mode).** If the target agent is NOT a member of the ticket's team, `assign_agent` **silently reverts the owner to the team default** — it returns success, echoes the full ticket, and raises no error, but the owner is not who you set. The full-ticket echo is NOT proof the assignment took. Precondition: confirm the agent is on the ticket's team (Agent Membership role — see `halo-agent-role-model.md`) BEFORE assigning; add the agent to the team first if needed. Always **verify the owner via `get_ticket` after `assign_agent`** (read `assignedtoint`, not the echo). This is the assign-side instance of the § 11 "echo ≠ proof" rule.

## 13. KB Article API

### Endpoint behavior

`create_kb_article` and `update_kb_article` both target Halo's unified KB endpoint: **`id` present → update; `id` absent → create.** Both return the **full article row** — chainable for read-merge-write without a separate read.

### Write payload — confirmed fields

| Field | Type | Required (create) | Notes |
|---|---|---|---|
| `id` | int | NO (omit to create) | Required for update. |
| `name` | string | **YES** | Article title. |
| `tags` | array of `{"text":"..."}` | **YES (≥1)** | Tag is an OBJECT with a `text` key, NOT a string (string arrays raise a serialization error). |
| `description_html` | string | no | Rendered HTML body. For `editor_type:0` (rich-text): write HTML here. For `editor_type:1` (markdown): do NOT write here. |
| `description_markdown` | string | no | Markdown source — the canonical write path for markdown articles (`editor_type:1`). Halo auto-renders `description_html` and strips plain-text `description`. |
| `notes_html` | string | no | Internal notes. |
| `editor_type` | int | no | `0` = rich-text HTML (default); `1` = markdown. Halo does NOT auto-translate between modes. |
| `faqlists` | array of `{id:N}` | no | FAQ List categorization. |
| `devices` | array of `{id:N}` | no | Linked asset rows (any asset type). **The field is `devices`, NOT `assets`** (`assets` is silently dropped). |
| `inactive` | bool | no | **Archive flag.** `archived` is silently dropped — toggle `inactive:true` to archive. |

Server-derived fields (`kb_tags`, audit timestamps, view counters, `key_copy`, `latestversion`, `other_versions[]`) should not be in write payloads.

### Update semantics

| Field shape | Behavior |
|---|---|
| Scalars (`name`, `description_html`, `inactive`, `editor_type`, …) | **OMIT-PRESERVED.** |
| Arrays (`devices[]`, `faqlists[]`, `tags[]`) | **REPLACE when present.** Pre-union with prior state to add; omit entirely to preserve. |
| Phantom field names (`archived`, `assets`) | **SILENTLY DROPPED.** No error, no effect. |

### Read surfaces

| Tool | Returns | Use when |
|---|---|---|
| `get_one_article` | Curated 5-field view (`id, title, description, resolution, link`) — no tags/devices/HTML | Citation, chat surfacing, existence checks. |
| `search_knowledge_base` | Array of the same 5-field shape | Find-by-text. **Matches BODY content + tags, NOT the title.** Archived articles filtered out. Each result carries the FULL stripped body. |
| Chain off create/update response | Full row | Read-merge-write loops. |
| `get_asset(asset_id).kbs[]` | Article summaries under the linked asset | Discovery from the asset side. |

### Bidirectional asset linking

Article → assets is `devices: [{id:N}]`; asset → articles is `kbs[]` on `get_asset`. Same junction — writing from the article side updates the asset-side view. Article-side `devices[]` is replace-on-presence; link sparsely from the article side and use `kbs[]` for high-fan-out lookups.

### Size limits

The grcitsm MCP server has a **~262 KB / ~50-55K-token response-relay cap**. Halo stores bodies at NVARCHAR(MAX) — the binding constraint is the relay, not storage. KB responses include all three body representations (~3× amplification of the input body):

- Keep article bodies under **~80 KB markdown** (~250 KB response) to preserve the read-merge-write loop. Larger articles still write, but the response truncates on read-back.
- Tag freely — tag count is not a practical concern.
- Heavily-linked articles can exceed the cap on read-back even with a small body (Halo may hydrate each linked device with its full row).

### Versioning

KB versioning is **not writable via the API** — every candidate field probe is silently dropped or server-overwritten. The UI's "New Version" button is the only version-creation path (sets `inactive:true` on the original, groups rows via a server-allocated `key_copy`, surfaced read-side as `other_versions[]`). Recommended model for document-backed content: keep the linked asset stable and edit the article in place — Halo's change history captures the timeline natively; reserve UI versioning for side-by-side comparison needs.

## 14. Bulk-run shape rules

Orchestration methodology for write runs touching more than ~20 rows — partitioning, plan-to-file, approval gates, single-row pilots, bounded parallelism, audit logging — is owned by the consuming plugin's bulk-update skill; the shape rules in this document apply to every individual call inside such a run. Two bulk-critical restatements:

- **Per-row relationship writes race.** `set_asset_relationships` is replace-semantics on the parent subject — accumulate and batch per parent at end-of-run (§ 12).
- **Framework-CF ordering survives bulk scale.** When framework Controls values change, Requirements must land in a second call per row (§ 5.1); baseline-then-framework two-phase applies on new types (§ 6). When framework CFs are preserved verbatim, a single update per row suffices.

## 15. Anti-patterns quick reference

| Anti-pattern | Why it fails |
|---|---|
| Setting CFs in the `create_new_ticket` call | The tool only takes user_email/summary/description/ticket_type. CFs land via the follow-up `update_ticket_fields` (§ 10). |
| Using `agent_note` on an Issue / Deviation / Validation ticket and expecting type CFs to land | `agent_note` writes only its exposed CF params. Use `update_ticket_fields` for CF writes on any ticket type. |
| Passing a `priority_id` integer without first resolving `slas_and_priorities` against the ticket's SLA | The integer maps to different labels per SLA; some SLAs lack that level entirely (`api-mcp-semantics.md` § 7). |
| Passing a `status_id` / `category` from the global catalogs without the type-scoped allow-list tools | The global catalogs are wider than any type's allow-list — silent rejection (§ 10 quintet). |
| Hardcoding CF names / allowed values / mandatory-ness in client code | Admins change the CF schema at any time. Discover via `ticket_type_fields` / `asset_type_fields` at call time. |
| Sending raw `fcode` integers in lookup CFs | Halo resolves display strings server-side; `fcode` integers are tenant-specific and shift (§ 3, § 5.3). |
| Trusting the top `search_tickets` result | Recency-biased, not relevance-first — confirm by ID. |
| Omitting the `note` body on a billable/audited update | The note IS the audit record; GRC trails require explicit rationale per change. |
| Sending a partial `customfields[]` through `agent_note` against a populated ticket | `agent_note`'s CF array is full-replace (§ 2) — omitted entries clear. |
| Editing a multi-select on `update_asset` without unioning existing members | A sent entry replaces the whole array (§ 2.1) — un-resent members drop. (Scalars and omitted fields are safe under merge-on-omit; on param-templated legacy runbooks, treat ALL writes as full-replace — see § 2.1.) |
| Updating validation pass/fail/date via scalar `customfields[]` | Table-CF-backed rows live in a child table, not on `Faults` — use the dedicated row tools. |
| Calling `update_asset` without first running `asset_type_fields` for the target type | Halo silently drops fields that don't belong to the asset type (§ 10). |
| Packing Asset Fields / CFs into `create_asset` | Create tools are identity-scoped by design; field population is the follow-up `update_asset` (§ 10). |
| Trusting a write tool's success string to confirm persistence | Relay-dependent: rich payload where relay is configured (verify against `fields[]`, not echoes), bare `"Task queued."` where not — either way, verify by re-reading (§ 11). |
| Sending a `Device.dstatus` value not in the type's allow-list | Resolve via `asset_type_allowed_statuses` first (§ 3.8). |
| Writing multi-select framework CFs via an Action POST CSV body | Silently drops — Update Ticket endpoint + array shape only (§ 4). |
| Sending CSV strings for multi-select CFs on `update_ticket_fields` | Stored as a literal string blob unreadable downstream — array shape always (§ 4). |
| Writing framework Controls + Requirements in one payload | Requirements lookup filters on persisted Controls — two-pass, both sides (§ 4-5). |
| Sending a partial `todo_list[]` assuming full-replace or omit-preserve | It's a guarded, non-atomic diff — full union with ids, always (§ 2.3). |
| Passing `"Yes"`/`"No"` to a bit-backed Boolean CF the schema reports as `free-form` | `System.FormatException` — and it **rolls back the entire `customfields[]` write transactionally**, so the whole update no-ops. Pass `true`/`false` (§ 3.9). |
| Writing derived/computed CFs as scalars | Server-resolved or table-CF-rendered — never writable as scalar values (§ 3.10). |
| Using `FAULTDEVICE.FDdevnum` or `Device.ddevnum` as an asset ID | The asset PK is `Device.Did` (what every API surface exposes as `id`); `ddevnum` is a secondary identifier. |
| Treating `user_email` on `create_new_ticket` as the owner | It writes `Faults.userid`, never `assignedtoint` — every new ticket starts Unassigned (§ 9). |
| Skipping `assign_agent` on bulk-created tickets | SLA alerts don't fire, dashboards and "my tickets" views skip Unassigned rows (§ 9). |
