Files
sx/issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.md
agra 45befed698 docs(issues): correct 0132 root cause; file 0133 and 0134
- 0132: rewrite to the verified root cause -- protocol method signature
  registration resolves type names via flat findByName and picks the wrong
  same-name author. Original payload-field hypothesis kept as superseded;
  repro switched to canonical `impl ... for` syntax. Still open (the
  protocol path is unchanged).
- 0133: assigning a struct literal to a union member panics ("unresolved
  type reached LLVM emission"); pre-existing, surfaced while testing.
- 0134: a same-name `error` set collapses into a namespaced import's set --
  error-set declarations lack per-decl nominal identity (E6a gap); this is
  what keeps the 0132-class error-ref resolution dormant.
2026-06-13 13:41:30 +03:00

7.3 KiB

0134 — a same-name error set collapses into a namespaced import's set (error sets lack per-decl nominal identity)

Symptom

One-line: a top-level error { ... } whose NAME matches an error set reachable through a (namespaced) import collapses into the imported set at registration — losing its own tags — because error-set declarations are NOT given per-decl nominal identity the way struct / enum / union are (E6a). So a local set's tags become "unknown".

  • Observed: error: error tag 'error.Boom' is not in error set 'EventErr' on raise error.Boom (and on r == error.Boom), where EventErr :: error { Boom } is declared locally but #import "modules/std.sx" also carries event.EventErr (tags Init / Register / Wait). The membership check sees the IMPORTED set, which has no Boom.
  • Expected: the local EventErr { Boom } is its OWN type; Boom is a member; the program prints own EventErr.Boom, exit 0 — exactly as a uniquely-named local error set already does.

This is the declaration-side twin of issue 0132's class. The reference-side is already visibility-aware: error_type_expr (!EventErr) resolves its name through Lowering.resolveNameresolveNominalLeaf (own-author-wins). But that fix is dormant for error sets: because the local declaration never gets its own TypeId (it collapses into the import's), there is only ONE EventErr in the type table for the reference to find. Fixing THIS issue is what makes the reference-side resolution observable.

Reproduction

Minimal, standalone (only modules/std.sx). The trigger is the name EventErr colliding with std/event.sx's EventErr error set:

#import "modules/std.sx";

EventErr :: error { Boom }                 // collides with std/event.sx `EventErr { Init, Register, Wait }`

fail :: () -> !EventErr {
    raise error.Boom;                      // Boom IS a member of the local set
}

main :: () -> i32 {
    r := fail();
    if r == error.Boom {
        print("own EventErr.Boom\n");
        return 0;
    }
    print("wrong set\n");
    return 1;
}

Run: ./zig-out/bin/sx run issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx

Actual (today):

error: error tag 'error.Boom' is not in error set 'EventErr'
  --> ...:NN:NN
   |
   | fail :: () -> !EventErr { raise error.Boom; }
   |                                 ^^^^^^^^^^

(and again on r == error.Boom). The fix should make it print own EventErr.Boom, exit 0.

Decisive bisection (verified)

Variant Result
Local EventErr (name collides with std/event.sx) FAILS — membership checked against the imported set
Rename the local set MyErr :: error { Boom } (no collision) OK — prints own EventErr.Boom-equivalent

So the trigger is purely the same-name collision; the local set's body ({ Boom }) is correct — it's simply never registered under its own identity.

Root cause

Error sets are excluded from the per-decl nominal identity system (E6a) that struct / enum / union use:

  • Lowering.registerErrorSetDecl (src/ir/lower/nominal.zig) registers via the FLAT type_bridge.resolveAstType(node, …)resolveInlineErrorSet (src/ir/type_bridge.zig), whose first line is if (table.findByName(name_id)) |existing| return existing; — so the SECOND author of a name (here the local EventErr, registered after the imported one) just gets the first author's TypeId. No distinct nominal slot, no own tags.
  • Contrast registerEnumDecl / registerStructDecl / registerUnionDecl, which intern through internNamedTypeDecl(decl_key, name_id, info, nominal_id) with nominal_id = shadowNominalId(name_id) — each author gets a distinct TypeId.
  • The E6a shadow-reservation scan only enumerates struct / enum / union: ShadowTypeDecl (src/ir/lower/nominal.zig) is union(enum) { @"struct", @"enum", @"union" }, topLevelTypeDecl maps only those, and there is reserveShadow{Struct,Enum,Union}Slot but no error-set equivalent. So a same-name error-set shadow is never reserved up-front.
  • The plumbing is half-there: nominalIdOf / stampNominalId already handle the .error_set arm — registration just never sets a nominal id.

Investigation prompt

A top-level error { ... } whose name collides with a same-name error set from a namespaced import collapses into the imported set, so its own tags are lost ("error tag 'X' is not in error set 'Name'"). Repro: issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx (expect it to FAIL today; the fix should make it print own EventErr.Boom, exit 0).

Root cause: error sets are excluded from the per-decl nominal identity system (E6a). Lowering.registerErrorSetDecl (src/ir/lower/nominal.zig) registers through the flat type_bridge.resolveAstTyperesolveInlineErrorSet (src/ir/type_bridge.zig), which short-circuits on findByName(name) and returns the first same-name author's TypeId — instead of interning under a per-decl nominal id like registerEnumDecl does via internNamedTypeDecl + shadowNominalId.

Fix direction (mirror E6a for error sets):

  1. Add an @"error_set" variant to ShadowTypeDecl, an arm in topLevelTypeDecl, and a reserveShadowErrorSetSlot (mirroring reserveShadowEnumSlot — reserve a .error_set placeholder under the computed shadowNominalId).
  2. Rewrite registerErrorSetDecl to build the .error_set TypeInfo (intern the tag ids — factor the body out of resolveInlineErrorSet if helpful, like buildEnumInfo) and intern it via internNamedTypeDecl(decl_key, name_id, info, nominal_id) with nominal_id from the reserved slot / shadowNominalId, instead of the flat resolveAstType.
  3. The reference side is ALREADY visibility-aware (issue 0132's broader fix): resolveErrorType (src/ir/type_bridge.zig) resolves a named set through inner.resolveName, which for *Lowering is resolveNominalLeaf (own-wins). Once the declaration has its own TypeId, the named reference !EventErr will resolve to it automatically — no further reference-side change needed.

Per CLAUDE.md "Silent fallback defaults": don't paper over with a findByName default — give error-set declarations real per-decl identity so the wrong-author resolution stops at the source.

Verification: the repro prints own EventErr.Boom exit 0; then zig build && zig build test green. When resolved, promote the repro to examples/10xx-errors-same-name-error-set-own-wins.sx (the example was drafted during the 0132 broader-latent work and removed because it could not pass until this lands).

Notes

  • Membership-check diagnostic site (where the symptom surfaces, not the root cause): src/ir/lower/expr.zig ("error tag '...' is not in error set '...'").
  • Root-cause sites: src/ir/lower/nominal.zig registerErrorSetDecl (flat registration, no nominal id) + the ShadowTypeDecl / topLevelTypeDecl / reserveShadow*Slot set (error sets excluded); src/ir/type_bridge.zig resolveInlineErrorSet (the findByName short-circuit).
  • Related: issue 0132 (same class, reference + payload/field side, fixed for struct/enum/union). This issue is the error-set declaration side; the 0132 reference-side error_type_expr fix stays in place and activates once this lands.