Skip to content

Admit string and nested-record payloads in variant cases (SPX-T215) #216

Closed

Description

@wsdt

Surfaced while implementing #109 (rich interaction schemas, commit 43a64ed).

The base compiler's Copy Variants v1 rule rejects string and nested-record fields inside a variant case unconditionally (SPX-T215). The rich interaction schema's derivation and decoder treat variant-case fields identically to record fields and are forward-compatible, but no compiling fixture can exercise a nested field inside a variant case, so that path is unproven.

This is a language-admission limit, not a defect in the schema work. It blocks:

  • proving nested payload round-trips through variant cases in the interaction schema;
  • any downstream consumer that wants a variant case carrying text.

Related unreachable-by-fixture guard: SPX-T223 forecloses generic-instantiated nested fields first, so the schema's type.field.generic_argument refusal is also unreachable from real source today.

Activity

  1. wsdt commented on Sep 12, 2026

    @wsdt
    MemberAuthor

    Partial landing, verified on main. Not closing — the issue asks for string and nested-record payloads; only the nested-record half shipped, and only its drop-free slice.

    Landed (77879d76): a monomorphic variant case field whose type is a Copy-only (drop-free) nested record is admitted — is_admitted_copy_aggregate_variant_field, variant_layout.rs's VariantFieldValueKind::Copy branch — with success, canonical round-trip and graph-revision coverage in copy_aggregate_variant_payload_is_admitted_and_round_trips_canonically (tests/language/variants_semantics.rs:156).

    Still refused, each with its own regression asserting the exact code:

    Shape Diagnostic Test
    direct string case field SPX-T215 string_variant_payload_is_rejected_distinctly_from_the_empty_variant_code — also asserts the message does not say SPX-T268, so the two refusals stay distinguishable
    nested record reaching an owned Bytes leaf SPX-T215 and SPX-T268 copy_aggregate_variant_payload_rejects_a_nested_owned_leaf
    class, and generic instance SPX-T215 copy_aggregate_variant_payload_rejects_a_class_and_a_generic_instance

    Why the rest is not a diagnostic relaxation. string has no cleanup-plan lifecycle of its own anywhere in the tree — only core.bytes.drop/vec/box/iter exist — and cleanup/iterator.rs::variant_leaf_lifecycle, with its two synchronized counterparts in cleanup_plan/build/type_shape.rs and cleanup.rs, emits only a flat Leaf shape for a variant case field, never recursing through shape_for_type the way an ordinary record field does. Separately, aggregate_layout.rs refuses ResolvedType::String any aggregate layout at all ("owned string values have no aggregate value layout in v1").

    Admitting string, or an owned nested record, inside a variant case therefore requires building recursive cleanup-shape capability across build/replay/execute, variant_layout.rs, native codegen and Wasm codegen simultaneously — the change protocol requires those to move together, and a widening admitted by one backend and refused by another would break the first non-negotiable invariant. That is a new subsystem, not a gap-fill, so no partial version was attempted.

    Remaining for closure: the string case field, and owned nested records, behind that recursive cleanup-shape work.

  2. wsdt commented on Sep 12, 2026

    @wsdt
    MemberAuthor

    No code change this session. Scoping the remaining work concretely, because the previous comment's "this is a cross-backend subsystem" was a conclusion without a map, and the next attempt needs one.

    I re-verified the prior tranche is intact on current main (5953e356, 85 commits since, none touching variants or strings). The shipped slice — direct Copy scalars, in-scope type parameters, and drop-free nested records — and its four tests in tests/language/variants_semantics.rs are present and unregressed.

    Why string is not a widening of the SPX-T215 type check. The gate itself is one place (src/source_verify/declaration/declarations.rs:469-518). The problem is everything downstream of admitting it:

    1. Owned Bytes in a variant case is admitted through a narrow special case, not a general mechanism. is_admitted_concrete_owned_byte_variant / is_admitted_owned_byte_variant (src/hir/type_reachability.rs:363, src/interpreter.rs:2194) have ~15–18 call sites across the resolver (resolve_pattern.rs, resolve_expr_reference.rs, generic_variant.rs, resolve_program.rs, declaration_index.rs), HIR validation (validation.rs, validation/type_profiles.rs), the interpreter (retained_call.rs, owned_try.rs), the type-checker (type_table.rs, declared_type.rs, declared_type/generic_variant.rs, oracle/matching.rs, iterative/matching.rs), native codegen and Wasm codegen. Every one hardcodes == ResolvedType::Bytes / Type::Bytes rather than delegating to a shared predicate. So there is no single place to widen.
    2. Runtime cleanup is dispatched by lifecycle ID. BYTES_DROP_LIFECYCLE_ID = "core.bytes.drop" (src/cleanup.rs:177) is synchronized across cleanup/iterator.rs::variant_leaf_lifecycle, cleanup_plan/build/type_shape.rs, cleanup_plan/execute.rs and cleanup_plan/replay/nested_shape.rs. None of them knows a string lifecycle.
    3. Native representation differs, so this is new memory-management codegen, not reuse. src/codegen/native_bytes/owned_leaf.rs has a closed OwnedLeafKind{Bytes,Vec,Box,Iter} with hardcoded spx_bytes_move/spx_bytes_drop against the spx_bytes_v1 struct. An ordinary owned string is a different representation entirely — char * / header-prefixed spx_string_v10, dropped via spx_string_drop (src/codegen/native_emit/mod.rs:1240-1330). Reusing spx_string_drop is plausible, but it has to be threaded as a genuinely new OwnedLeafKind::String through native_bytes.rs, variant_match.rs and nested_owned.rs, with an equivalent new path in src/wasm/aggregate.rs's own lifecycle dispatch (:400, :3437).
    4. A second, independent refusal exists. aggregate_layout.rs:275 refuses ResolvedType::String any layout at all ("owned string values have no aggregate value layout in v1") in the executable-aggregate ABI path used by generic records, UI-schema records and owned-data exports. Anything routing through that path needs this resolved too, separately from (1)–(3).

    Why I stopped rather than starting it. AGENTS.md requires parser, formatter, resolver/HIR, verifier, graph, native and Wasm to move together, and states that a partial cross-component change is worse than none. Items 2–3 are new runtime memory management in two backends; verifying them honestly needs cross-backend execution fixtures, not diagnostics. Landing half of that and reporting it as "admitted" is precisely how a backend disagreement gets introduced silently.

    Recommended scope for the next attempt, in order: start from the OwnedLeafKind/lifecycle-ID plumbing (items 2–3), since that is the part with no existing shape to copy; collapse the ~15–18 hardcoded == Bytes comparisons behind one shared predicate first, as a separate no-behaviour-change commit, so the widening then happens in one place; and make the acceptance test a cross-backend execution fixture that compares interpreter, native and Wasm results to each other — not one that asserts each independently succeeded, which is the vacuity pattern this repository has repeatedly found.

    Nested owned-record payloads remain blocked on the same recursive cleanup-shape work and should ride the same tranche.

  3. wsdt commented on Sep 13, 2026

    @wsdt
    MemberAuthor

    Pushed to main in 6a15535.

    The drop-free nested-record admission already present is now exercised through Agent interaction-schema derivation, canonical decoding, provider schema references, and a hostile unknown nested-field rejection. All 18 interaction-schema unit tests passed locally. This is coverage of the admitted Copy-only profile; strings and owned nested variant payloads remain outside it, so #216 stays open.

  4. wsdt commented on Sep 13, 2026

    @wsdt
    MemberAuthor

    Pushed to main in 8501433 (starting from 0eb2f3d).

    Direct monomorphic owned String variant payloads now execute through the interpreter, C11 and Core Wasm. The profile admits String plus existing Copy scalars, with constructor/case/field identities, own/borrow calls and matching. It introduces the independently reconstructed core.string.drop lifecycle and moves standalone String ownership into canonical cleanup-plan slots. String result traces use an opaque primitive marker, never pointers or text.

    The implementation also handles local String relays, scalar-match guard/arm cleanup, content equality and the existing String intrinsics in aggregate Wasm. The generated Web adapter uses the aggregate byte carrier and explicit private runtime imports; it does not reuse incompatible standalone arena handles or add source authority. Actual repeated execution caught and fixed one borrowed-place clone leak per invocation.

    Focused local evidence: all three cleanup_backends::owned_string_variant tests pass. The same program returns 16 in the interpreter, native Clang C11 at -O0/-O2 (three invocations with equal allocation/free counts), and one Node 24.3.0 Core-Wasm instance invoked 32 times under a 16-entry allocation limit. Canonical source/graph checks and stable profile refusals pass. The focused String unit run passed 58 tests; its remaining fixture was corrected to create a value requiring finalization, and that exact unit then passed. No full or hosted gates were run, per the requested execution workflow.

    The owning profile and executable gate are documented in docs/OWNED-STRING-VARIANTS-V1.md. Local logs: .agent-logs/0913-string-backends-final.log and 0913-string-native-unit-final.log.

    Still open: nested owned-record variant payloads and broader generic String substitutions remain refused. Direct owned variant-match expressions returning String also retain SPX-T216. This completes the direct-text payload tranche, not the remaining recursive owned-record scope of #216.

  5. wsdt commented on Sep 17, 2026

    @wsdt
    MemberAuthor

    Progress — b23972b8 — not closing

    Established empirically with fixtures and semaprax check what is actually admitted today, rather than trusting the issue's original framing (which predates two shipped features):

    Shape Status before this change
    Direct string case field, alone admitted — Owned String Variant v1, 85014337
    Drop-free Copy-closed nested-record case field, alone admitted — Copy Aggregate Variant Payload v1, 77879d76
    Both in the same variant declaration refused

    So the premise "SPX-T215 rejects string and nested-record fields unconditionally" has not held for some time. The real defect was narrower and is now fixed: two declaration-level gates in the same file disagreed with each other. check_byte_data_declarations's has_direct_string branch consulted only owned_byte_record_copy_field_is_admitted (direct Copy scalars) and never the newer is_admitted_copy_aggregate_variant_field predicate that the SPX-T215 check further down the same file already uses, producing:

    SPX-T268: owned-string variant field `Payload.value` must be direct `string` or a direct Copy scalar
    

    That was an inconsistency, not a rule anyone chose. b23972b8 makes SPX-T268 agree with SPX-T215. Nothing is weakened — a sibling field that itself needs a drop, or a class sibling, is still refused, and both have regressions. No golden or KAT pinned the old wording.

    Evidence: cargo test --locked -p semaprax --lib -- agent_lifecycle::rich_stage source_verify::declaration::declarations::tests → 9 passed, 0 failed (3 new regressions; 6 pre-existing rich_stage tests confirm that file is comments-only here, verified by diff).

    Also corrected the stale "Known limitation" rationale in src/agent_lifecycle/rich_stage.rs, which still claimed SPX-T215 forbids nested records in variant cases.

    Why this stays open — and exactly what is left

    Admission is not execution. Running either payload through authorize/reduce, or passing one as a Proposal parameter, is still refused by the interpreter's retained-call vocabulary:

    SPX-F102: interpreter admission failed (unsupported_result_type): ... outside the retained call vocabulary
    

    Prior comments scoped this at a high level. It is now pinned to three small functions:

    1. is_admitted_owned_variant in src/interpreter/variant_admission.rs — currently only is_admitted_owned_byte_variant OR is_admitted_owned_string_variant; needs a third arm for Copy Aggregate Variant Payload v1, mirroring hir::is_admitted_copy_aggregate_variant_field.
    2. harvest() in src/interpreter/retained_call.rs — no ResolvedType::String match arm.
    3. retained_leaf_is_admitted() in src/interpreter/retained_call.rs — omits String; this is what gates flat-record parameters such as Proposal.

    That work is in flight now. Until a fixture actually executes both payload shapes end to end, this issue should stay open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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