You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 8Text instances with no text key and no non-empty children:
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-006title and servings_label. Those are emitted blank in the firstupdateComponents 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/009status/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 andchildren 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.
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:
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:
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:
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:
S1 — dspack.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.
S3 — astryx.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-001text_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 textor 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:
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.
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)?
It does not cement the defect. The corpus assertion is a floor (expect(instances.length).toBeGreaterThan(100)), not an exact count, so fixing the fixtures later — which adds text rather than removing instances — will not break it.
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.
Eight emitted A2UI
Textinstances in the replay corpus carry neithertextnorchildren. 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 8Textinstances with notextkey and no non-emptychildren:text_3{"id":"text_3","component":"Text","variant":"body"}text_5{"id":"text_5","component":"Text","variant":"body"}text_6{"id":"text_6","component":"Text","variant":"body"}text_11{"id":"text_11","component":"Text","variant":"body"}title{"id":"title","component":"Text","variant":"h2"}intro{"id":"intro","component":"Text","variant":"body"}title{"id":"title","component":"Text","variant":"h2"}intro{"id":"intro","component":"Text","variant":"body"}Two things a naive scan gets wrong, both worth recording so the number is not re-litigated:
fixture-006titleandservings_label. Those are emitted blank in the firstupdateComponentsop and then re-sent with text inside the sameTOOL_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.text" reports 15. The extra 5 (fixture-005/006/007/008/009status/status_line) havetextas a data binding —{"path":"/booking/status"}. All five resolve against the surface'supdateDataModelops to real strings (e.g."Booked Mon 9:00 AM for Ada. See you then!"). They are not blank.The correct predicate is:
textkey absent andchildrenempty/absent.Per-instance provenance
Every blank traces to a
textnode in the accepted attempt's dsurface IR that carriespropsbut notextand no children. The IR is recorded in each fixture'sdspack.gatesCUSTOM event; the emitted A2UI rides inTOOL_CALL_RESULT.ollama:gemma4:e4b$.root.children[0].children[0].children[0]props={"type":"body"}text_3ollama:gpt-oss:latest$.root...children[0].children[0](depth 5) ×2,$.root.children[1]...children[0]text_5,text_6,text_11ollama:gpt-oss:latest$.root.children[0]id="title",$.root.children[1]id="intro"title,introollama:gpt-oss:latest$.root.children[0]id="title",$.root.children[1]id="intro"title,introContract component and profile plan, identical for all eight: contract component
text(AstryxText, "Body text content"), mapped bypackages/contracts/src/astryx-profile.ts→ A2UITextwithsurfacePlan: { textProp: "text", childrenProp: "children" }andrequired: []. The emitter copies content only when it exists —dspack-emit/src/targets/a2ui/surface.ts:178:Two details worth noting. fixture-001 ran the bounded repair loop twice (attempts 0→1→2, driven by
rule.destructive-requires-alertdialogthenrule.alertdialog-action-label-specific); the contentlesstextnode survived all three attempts and both repairs untouched, because no rule names it. fixture-005 also emits a blankstatusnode, 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.
repairMessagesis 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 notextmember 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.tsdocuments it: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:textin IREvery 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 contentlesstextnodes, 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: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:
S1 —
dspack.surface.v0_1.schema.jsondeclarestextas an optional node property. Correct: it is the portable "can this object exist" floor.S2 — vocabulary only;
textis a legal Astryx component andtype/asare legal props.S3 —
astryx.dspack.jsoncarries 14 rules and none of them constrains thetextcomponent. Measured:rules targeting 'text' = 0,rules with requiredText = (none).A3 — validates each instance against
#/$defs/anyComponent. The Astryx catalog'sTextrequires only["component"], fromastryx-profile.ts:394:The intent was to admit
children-only container nodes. The constraint actually expressed admits neither — aTextwith 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:
The content never existed at any recorded stage. Not an emitter drop, not a replay transformation, not a recording truncation. For
fixture-001text_3there 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:
Owning layer for the fix
The dspack contract / governance rules —
aestheticfunction/dspack,examples/astryx.dspack.json(then byte-copied intodspack-studio/packages/contracts/astryx.dspack.json, per the protocol inpackages/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:
required-propssupportsrequiredTextwithtextScope: "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.textor carrychildren".Profile.requiredisstring[](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.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-labelalready 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:
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:Two follow-ups this implies, listed for scoping, not prescribed here:
Profile.required— a dspack-emit change, and the reason it is a separate, lower-priority item.Should this block dspack-studio PR #47 (renderer parity)?
No.
The defect is provably upstream of every renderer: the blank is already in the emitted A2UI payload, which is why it draws blank under both registries. PR fix(shadcn): close renderer drift against the emitted A2UI contract, with props-level parity tests #47 changes only
packages/shadcn-renderers; it cannot fix or worsen it.PR fix(shadcn): close renderer drift against the emitted A2UI contract, with props-level parity tests #47 explicitly leaves the emitter, catalogs and contracts untouched, so it does not touch any layer named above as the owner.
Its corpus test already handles this case correctly and deliberately.
contentViolations()inemitted-prop-parity.test.tsxreturns early:A blank
Textpromises no string, so the renderer owes none. That is the right boundary: PR fix(shadcn): close renderer drift against the emitted A2UI contract, with props-level parity tests #47 asserts renderer fidelity to what the emitter promised, and the bug here is that the emitter promised nothing.It does not cement the defect. The corpus assertion is a floor (
expect(instances.length).toBeGreaterThan(100)), not an exact count, so fixing the fixtures later — which adds text rather than removing instances — will not break it.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
mainof dspack-studio, dspack-emit, dspack-gen and dspack, replaying the recorded IR from each fixture through the real gate stack (lintSurfacefor S1/S2/S3,emitSurface+transform(...).validationfor A1/A2/A3). The probes are read-only and were run outside the repositories; no repository file was modified.🤖 Generated with Claude Code