ADR-0054 — HTMX swap / identity contract¶
Status: Accepted
Date: 2026-07-26
Related: ADR-0011 (SSR + HTMX), ADR-0053 (HM owns Hyperparts), ADR-0049 (typed Fragment path), card-safety invariants (docs/reference/card-safety-invariants.md), HM decisions 0005–0008 / 0012
Origin: Nested region-chrome regression (fleet smoke structure oracle; live DOM nested data-dz-region / id="region-*" on poll)
Context¶
ADR-0011 chose SSR + HTMX. ADR-0053 made HaTchi-MaXchi the owner of Hyperpart markup (dual-lock). Card-safety (INV-1) forbids nested card chrome (dz-card / rounded+border). HM decisions 0005–0006 prefer morph for stable surfaces and stable DOM identity.
None of those layers owned the host HTMX topology question:
Who may mint DOM identity for a persistent slot, and what may a fragment response re-emit when swapped into that slot?
In practice the dashboard card body owned id="region-{name}-{card_id}" with hx-swap="innerHTML", while the region GET re-wrapped every response in chrome with bare id="region-{name}". Polls nested wrappers (9-deep live). Dual-lock stayed green (queue rows were fine). Card-safety stayed green (not card chrome). Smoke flagged duplicate region-* ids as framework noise.
Agents were told dual-lock = HTML safety. That is false: dual-lock is part-local; swap identity is host/exchange-local.
Thesis¶
The swap / identity contract¶
For every hypermedia exchange that updates a persistent slot, the host must declare a single identity owner and a response envelope:
Slot (owner of id / data-dz-region hook)
← hx-get + hx-target + hx-swap
Fragment response (must not re-own the same identity under inner* swaps)
| Role | Owns | Must not |
|---|---|---|
Slot (card body, #{region}-body, list host) |
Stable id, optional data-dz-region / data-dz-region-name, hx-* on the slot |
Disappear and reappear with a new random id each poll |
| Fragment (innerHTML / innerMorph) | Interior content only (rows, queue body, chart) | Re-emit the slot’s id, or nest another data-dz-region chrome with the same region name |
| Fragment (outerHTML / outerMorph) | Full replacement of the target element | Silently change identity without a domain reason |
Normative rules¶
-
Sole identity owner. Exactly one element owns a given stable id for a given logical slot at rest. Duplicate ids after a swap are a contract violation (not “browser quirks”).
-
Inner swap ⇒ body-only response. When
hx-swapisinnerHTML,innerMorph, or equivalent into a slot that already carries identity (idand/ordata-dz-region), the HTMX response must not wrap content in a second chrome element that re-declares that identity (same bareid, or nesteddata-dz-regionfor the same region name). Prefer: return the typed interior only. -
Outer swap ⇒ replacement may carry identity. When
hx-swapisouterHTML/outerMorph, the response root may carry the target’s identity (it is the new element). Use outer swaps for poll-stop self-replace and whole-slot replacement, not as a default for every region refresh. -
No nested region hooks.
data-dz-regionmust not nest inside anotherdata-dz-regionthat names the same region (and should not nest arbitrarily). Nesting is the smoking gun of rule 2 violations under poll. -
Dual-lock is orthogonal. Dual-lock (
contracts/*.py) validates Hyperpart interiors (schema + DOM for a part). It does not validate host slot ownership or HTMX response envelopes. Region chrome / layout furniture remains host-owned (queue contract: “region chrome are layout furniture”). -
Card-safety is orthogonal. INV-1 forbids nested card chrome. Nested region hooks are this contract, not INV-1.
-
Morph policy still applies. ADR-0011 + HM 0005: prefer morph for stable surfaces; replacement for disposable. This ADR constrains identity under either strategy, not the morph-vs-replace choice itself.
Relationship to layers¶
┌──────────────────────────────────────────────────────────┐
│ dual-lock (HM contracts) — Hyperpart fragment shape │
├──────────────────────────────────────────────────────────┤
│ card-safety (INV-1) — no nested dz-card chrome │
├──────────────────────────────────────────────────────────┤
│ swap / identity (this ADR) — sole slot owner + envelope │
│ host SSR slot · exchange response · poll/filter │
└──────────────────────────────────────────────────────────┘
Decision¶
-
Adopt the swap / identity contract as a first-class architectural rule for Dazzle host emission and for HM exchange documentation.
-
Dazzle host (already partially fixed): HTMX region GETs (
HX-Request: true) return typed body only; card body SSR ownsid="region-{name}-{card_id}"anddata-dz-region/data-dz-region-name. Unit gate:tests/unit/test_region_chrome_id_policy.py. -
HaTchi-MaXchi: encode the contract as package decision 0012, extend morph-safe stem + template lint / morph gates so agents cannot ship exchange partials that re-own slot identity under inner swaps. Exchange tables and agent packs document response envelope (body-only vs outer replace), not only swap mode.
-
Promote detection: smoke’s duplicate-
region-*oracle remains defence-in-depth; ship-surface / composite tests should assert poll×2 (or synthetic double-swap) unique ids, not only first-stitch card-safety. -
Agent instruction: dual-lock green ≠ HTMX-safe. Agents must check sole identity owner + response envelope before shipping host or exchange changes.
Consequences¶
Positive¶
- Closes the dual-lock / card-safety blind spot that allowed nested region chrome.
- Gives agents a named checklist orthogonal to Hyperpart dual-lock expand.
- Aligns Dazzle host and HM gallery exchange language.
Negative / cost¶
- More rules for host authors and agent packs to keep in sync.
- Gallery mocks that use
innerHTMLinto#hm-*-bodywhile returning a full chrome root must be fixed or explicitly marked outer-replace demos.
Rejected¶
- Fold into dual-lock: dual-lock is part-local; folding host topology into every
QueueRowcontract confuses ownership and bloats part fixtures. - “Only Playwright / smoke will catch it”: already failed (fleet noise, late signal). Static / unit envelope checks are required (HM 0008 posture).
- Ban all region ids: slots still need stable targets; the bug is re-owning, not identity itself.
Implementation status¶
| Work | Status |
|---|---|
| Dazzle: HTMX body-only region response + card-body data-dz-region | Shipped (2026-07-26, nested-chrome fix) |
| ADR-0054 (this record) | Accepted |
HM decision 0012 + stem/lint/tests (contracts/swap_identity.py) |
Shipped with this ADR |
Composite poll×2 gate (apply_inner_swap + unit tests) |
Shipped (tests/unit/test_htmx_workspace_composite.py) |
HM Exchange.envelope + regenerated agent packs |
Shipped |
See also¶
docs/reference/card-safety-invariants.md— nested card chrome onlypackages/hatchi-maxchi/docs/decisions/0005-morphing-policy.md,0006-dom-identity-and-state.md,0012-swap-identity-contract.mdpackages/hatchi-maxchi/stems/morph-safe-hypermedia.mdsrc/dazzle/qa/smoke_crawl.py—evaluate_structure_oracles(duplicate region ids)src/dazzle/http/runtime/workspace_region_render.py— HTMX body-only wrap policy