Skip to content

Eight emitted A2UI Text instances are semantically blank: no gate refuses a Text carrying neither text nor children #50

Description

@ryandmonk

Eight emitted A2UI Text instances in the replay corpus carry neither text nor children. They render blank under both the shadcn and astryx registries, so the defect is upstream of every renderer. This report traces each one to its originating dsurface node, identifies the earliest point at which the content is absent, and names the layer that owns the fix.

Nothing was changed. This is an investigation report.

The list is correct — with two clarifications

The briefed set of eight is confirmed exactly. Folding every fixture's A2UI operations last-write-wins per (fixture, surfaceId, component id) — the same method PR #47 used for its corpus — yields 8 Text instances with no text key and no non-empty children:

# Fixture Emitted id Instance
1 fixture-001 text_3 {"id":"text_3","component":"Text","variant":"body"}
2 fixture-002 text_5 {"id":"text_5","component":"Text","variant":"body"}
3 fixture-002 text_6 {"id":"text_6","component":"Text","variant":"body"}
4 fixture-002 text_11 {"id":"text_11","component":"Text","variant":"body"}
5 fixture-004 title {"id":"title","component":"Text","variant":"h2"}
6 fixture-004 intro {"id":"intro","component":"Text","variant":"body"}
7 fixture-005 title {"id":"title","component":"Text","variant":"h2"}
8 fixture-005 intro {"id":"intro","component":"Text","variant":"body"}

Two things a naive scan gets wrong, both worth recording so the number is not re-litigated:

  • A raw (unfolded) scan reports 10. It additionally flags fixture-006 title and servings_label. Those are emitted blank in the first updateComponents op and then re-sent with text inside the same TOOL_CALL_RESULT ({"id":"title","component":"Text","variant":"h2","text":"Weeknight pasta"}). The recipe scenario's deterministic continuation hydrates them; they are never blank in the delivered state.
  • A scan keying on "no truthy text" reports 15. The extra 5 (fixture-005/006/007/008/009 status/status_line) have text as a data binding{"path":"/booking/status"}. All five resolve against the surface's updateDataModel ops to real strings (e.g. "Booked Mon 9:00 AM for Ada. See you then!"). They are not blank.

The correct predicate is: text key absent and children empty/absent.

Per-instance provenance

Every blank traces to a text node in the accepted attempt's dsurface IR that carries props but no text and no children. The IR is recorded in each fixture's dspack.gates CUSTOM event; the emitted A2UI rides in TOOL_CALL_RESULT.

Fixture Recorded Adapter Attempts / accepted Repairs Originating IR node → emitted id
fixture-001 "The interface argues back" 2026-07-10T04:08:20Z ollama:gemma4:e4b 3 / #2 2 $.root.children[0].children[0].children[0] props={"type":"body"} text_3
fixture-002 "Clean first pass" 2026-07-10T13:13:13Z ollama:gpt-oss:latest 1 / #0 0 $.root...children[0].children[0] (depth 5) ×2, $.root.children[1]...children[0] text_5, text_6, text_11
fixture-004 "Scheduling, generated live" 2026-07-10T14:32:51Z ollama:gpt-oss:latest 1 / #0 0 $.root.children[0] id="title", $.root.children[1] id="intro" title, intro
fixture-005 "Appointment booking (saved session)" 2026-07-10T15:40:17Z ollama:gpt-oss:latest 1 / #0 0 $.root.children[0] id="title", $.root.children[1] id="intro" title, intro

Contract component and profile plan, identical for all eight: contract component text (Astryx Text, "Body text content"), mapped by packages/contracts/src/astryx-profile.ts → A2UI Text with surfacePlan: { textProp: "text", childrenProp: "children" } and required: []. The emitter copies content only when it exists — dspack-emit/src/targets/a2ui/surface.ts:178:

if (sp.textProp && node.text !== undefined) instance[sp.textProp] = node.text;

Two details worth noting. fixture-001 ran the bounded repair loop twice (attempts 0→1→2, driven by rule.destructive-requires-alertdialog then rule.alertdialog-action-label-specific); the contentless text node survived all three attempts and both repairs untouched, because no rule names it. fixture-005 also emits a blank status node, but the scheduling scenario later rebinds it to /booking/status, so it does not survive into the delivered state.

Was the content ever there?

No. The earliest recorded artifact in the pipeline is dspack.audit.report.attempts[N].surface — the parsed model output, before any gate runs. In every case the node is already contentless there:

{"component":"text","id":"title","props":{"type":"display-3"},"children":[]}

versus the hand-authored reference surface for the very same scenario, packages/contracts/surfaces/appointment-booking.dsurface.json, which has the same ids and props and the text:

{ "component": "text", "id": "title", "props": { "type": "display-3" }, "text": "Book a consultation" }

The emitter did not drop it, replay did not lose it, and fixture recording did not truncate it — the string does not exist at any earlier stage. repairMessages is empty for fixtures 002/004/005, so repair did not introduce it either. I searched dspack-gen for any post-parse normalization that could strip empty text and found none.

What I could not determine

The raw pre-parse model completion is not preserved in the fixtures (the audit report stores the parsed surface, not the completion text). So I cannot prove from the artifacts alone whether the model emitted no text member at all, or emitted one that grammar-constrained decoding made unreachable. Stating that distinction as measured fact would be a guess.

What is measurable is an exact correlation with a known, already-fixed upstream defect. dspack-gen src/core/grammar-alignment.test.ts documents it:

text must be declared AFTER props (models and worked examples serialize props first; declared the other way round, node text becomes unreachable the moment props is emitted — measured as text-less nodes in every live generation before 0.1.1).

The fix is dspack-gen f987aed "fix(schema): align the generation schema with grammar-constrained decoders", 2026-07-10 19:57 EDT, shipped in 0.1.1 (published 2026-07-11T05:09:55Z). 0.1.0 was published 2026-07-10T03:05:23Z. Lining that up against the corpus:

Fixture Recorded dspack-gen in force Contentless text in IR Blanks surviving
001–005 2026-07-10, 04:08 → 15:40Z 0.1.0 (pre-fix) yes (all five) 8
006 2026-07-11T15:54Z 0.1.1 yes (3) 0 — hydrated by scenario
009–016 2026-07-14 → 07-21 0.1.1 / 0.1.2 none 0

Every surviving blank comes from a fixture recorded in the ~21-hour window when 0.1.0 was the only published dspack-gen. The regression suite that guards the ordering passes today (npx vitest run src/core/grammar-alignment.test.ts → 15/15). Honest caveat: fixture-006 was recorded under 0.1.1 and still produced three contentless text nodes, so the upstream fix reduced the behaviour rather than eliminating it — it is masked there only because the recipe scenario overwrites those nodes.

The gates all pass. That is the live defect.

The generation-side cause is historical and mitigated upstream. What is true today, on current code, is that nothing in the pipeline detects or refuses a semantically empty Text. Replaying the recorded IR through the real gate stack:

=== Astryx A2UI catalog: what does a `Text` instance have to carry? ===
   properties : [ 'component', 'text', 'children', 'variant' ]
   required   : ["component"]
   -> neither `text` nor `children` is required.

########## fixture-001 — "The interface argues back"  (accepted attempt #2, model gemma4:e4b)
  dspack-gen  S1=PASS  S2=PASS  S3=PASS   findings=0
  dspack-emit 0.9.1 schema-compile=PASS  catalog-shape=PASS  instance=PASS
  dspack-emit 1.0   schema-compile=PASS  catalog-shape=PASS  instance=PASS
  >> emitted Text instances with NEITHER text NOR children: 1
       {"id":"text_3","component":"Text","variant":"body"}

########## fixture-002 — "Clean first pass"  (accepted attempt #0, model gpt-oss:latest)
  dspack-gen  S1=PASS  S2=PASS  S3=PASS   findings=0
  dspack-emit 0.9.1 schema-compile=PASS  catalog-shape=PASS  instance=PASS
  dspack-emit 1.0   schema-compile=PASS  catalog-shape=PASS  instance=PASS
  >> emitted Text instances with NEITHER text NOR children: 3
       {"id":"text_5","component":"Text","variant":"body"}
       {"id":"text_6","component":"Text","variant":"body"}
       {"id":"text_11","component":"Text","variant":"body"}

########## fixture-004 — "Scheduling, generated live"  (accepted attempt #0, model gpt-oss:latest)
  dspack-gen  S1=PASS  S2=PASS  S3=PASS   findings=0
  dspack-emit 0.9.1 schema-compile=PASS  catalog-shape=PASS  instance=PASS
  dspack-emit 1.0   schema-compile=PASS  catalog-shape=PASS  instance=PASS
  >> emitted Text instances with NEITHER text NOR children: 2
       {"id":"title","component":"Text","variant":"h2"}
       {"id":"intro","component":"Text","variant":"body"}

########## fixture-005 — "Appointment booking (saved session)"  (accepted attempt #0, model gpt-oss:latest)
  dspack-gen  S1=PASS  S2=PASS  S3=PASS   findings=0
  dspack-emit 0.9.1 schema-compile=PASS  catalog-shape=PASS  instance=PASS
  dspack-emit 1.0   schema-compile=PASS  catalog-shape=PASS  instance=PASS
  >> emitted Text instances with NEITHER text NOR children: 3
       {"id":"title","component":"Text","variant":"h2"}
       {"id":"intro","component":"Text","variant":"body"}
       {"id":"status","component":"Text","variant":"caption"}

So, to answer the gate question directly: S1/S2/S3 all PASS with zero findings, and A1/A2/A3 all PASS against both A2UI versions, for every one of the eight.

Why each layer lets it through:

  • S1dspack.surface.v0_1.schema.json declares text as an optional node property. Correct: it is the portable "can this object exist" floor.

  • S2 — vocabulary only; text is a legal Astryx component and type/as are legal props.

  • S3astryx.dspack.json carries 14 rules and none of them constrains the text component. Measured: rules targeting 'text' = 0, rules with requiredText = (none).

  • A3 — validates each instance against #/$defs/anyComponent. The Astryx catalog's Text requires only ["component"], from astryx-profile.ts:394:

    // `text` is optional: container text nodes (children only) are valid.
    required: [],
    surfacePlan: { textProp: "text", childrenProp: "children" },

    The intent was to admit children-only container nodes. The constraint actually expressed admits neither — a Text with nothing at all satisfies it.

Note the contrast: dspack-emit's own synthesized text primitive uses required: ["text"] (src/transform/profiles.ts:465). The Astryx profile relaxed it to [] to make room for the nesting models produce, and the relaxation over-shot.

Root-cause classification

Per the requested taxonomy, for all eight instances:

model generated an empty text node

The content never existed at any recorded stage. Not an emitter drop, not a replay transformation, not a recording truncation. For fixture-001 text_3 there is a secondary note — bounded repair preserved it across two repair rounds — but repair only acts on findings, and no rule produces one here, so the primary cause stands.

The reason all eight survived into the shipped corpus and still render blank is the second, systemic cause, which is the one that is actionable today:

validation permits a semantically empty Text instance

Owning layer for the fix

The dspack contract / governance rules — aestheticfunction/dspack, examples/astryx.dspack.json (then byte-copied into dspack-studio/packages/contracts/astryx.dspack.json, per the protocol in packages/contracts/scripts/check-sync.mjs: "canonical change lands in dspack first; the consuming dspack-studio PR carries the byte-copied file").

Justification against the alternatives:

  • Not dspack-gen S3. The machinery already exists and needs no code change. required-props supports requiredText with textScope: "self" | "subtree", and it is live and exercised — the shadcn contract uses it in 20 rules (rule.button-carries-text, rule.field-label-carries-text, …). The Astryx contract simply never declares one. This is missing contract data, not missing engine capability.
  • Not dspack-emit instance validation / the A2UI catalog schema. The correct constraint is a disjunction — "carry text or carry children". Profile.required is string[] (src/transform/profiles.ts:62), so the disjunction is inexpressible without adding a new capability to a published external package's profile schema. Worse, A3 fires after generation: it would convert a silent blank into a hard emit failure with no repair path.
  • Not the recorded fixture corpus. The fixtures are honest recordings of real runs. Editing them to paste in text would fabricate model output and destroy their evidentiary value.

S3 is the right altitude because it runs during generation, inside the bounded repair loop, so a violation becomes a repair instruction the model can act on — exactly how rule.button-carries-label already works.

Proposed minimal correction (described, not implemented)

Add one rule to the Astryx contract. No new rule type, no engine change:

{
  "id": "rule.text-carries-content",
  "type": "required-props",
  "severity": "must",
  "component": "text",
  "requiredText": true,
  "textScope": "subtree",
  "rationale": "Astryx Text renders its children; a Text node whose entire subtree carries no string renders as empty space. Subtree scope admits the container composition the contract documents (\"Takes arbitrary children (ReactNode); surfaces express them as child nodes\") while refusing a subtree that never bottoms out in a string.",
  "tags": ["content"]
}

textScope: "subtree" is load-bearing: it permits the legitimate container nesting the profile was relaxed for, and refuses only trees that never bottom out in a string.

Measured effect across all 16 fixtures, linting each accepted attempt's IR with and without the rule:

fixture      accepted-attempt   S3 BEFORE   S3 AFTER   new findings
fixture-001  #2                 PASS        FAIL       3
fixture-002  #0                 PASS        FAIL       11
fixture-003  #1                 PASS        FAIL       10
fixture-004  #0                 PASS        FAIL       2
fixture-005  #0                 PASS        FAIL       3
fixture-006  #0                 PASS        FAIL       3
fixture-007  (no dspack.gates — scripted/refusal fixture)
fixture-008  (no dspack.gates — scripted/refusal fixture)
fixture-009  #0                 PASS        PASS       0
fixture-010  #1                 PASS        PASS       0
fixture-011  #0                 PASS        PASS       0
fixture-012  #0                 PASS        PASS       0
fixture-013  #1                 PASS        PASS       0
fixture-014  #0                 PASS        PASS       0
fixture-015  #0                 PASS        PASS       0
fixture-016  #1                 PASS        PASS       0

fixtures newly caught by the rule: 6
fixtures that passed before and still pass (no false positives): 8

Sample finding: $.root.children[0] 'text' must carry non-empty text somewhere in its subtree (none found).

It catches every fixture with contentless text (including 003 and 006, whose blanks are currently masked by an emitter refusal and by scenario hydration respectively) and produces zero false positives on the eight healthy fixtures. The three authored surfaces are unaffected — they already carry text:

appointment-booking.dsurface.json              Text instances= 3  blank=0
recipe-creator.dsurface.json                   Text instances= 4  blank=0
(contract worked example) ex.delete-project-confirmation Text instances= 2  blank=0

Two follow-ups this implies, listed for scoping, not prescribed here:

  1. Tighten the catalog constraint so A3 is a genuine backstop rather than a rubber stamp. This needs a way to express "text or children" in Profile.required — a dspack-emit change, and the reason it is a separate, lower-priority item.
  2. Decide the fate of the five pre-0.1.0-era fixtures. Once the rule exists, fixtures 001–005 record runs that today's governance would refuse. Re-recording them under current dspack-gen is the honest option; deleting or hand-editing them is not.

Should this block dspack-studio PR #47 (renderer parity)?

No.

Holding PR #47 would delay a measured renderer-fidelity fix for an unrelated contract-governance gap. Land it; track this separately.

Reproduction

All results above were produced against current main of dspack-studio, dspack-emit, dspack-gen and dspack, replaying the recorded IR from each fixture through the real gate stack (lintSurface for S1/S2/S3, emitSurface + transform(...).validation for A1/A2/A3). The probes are read-only and were run outside the repositories; no repository file was modified.


🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions