I generate Swift types with facet_generate 0.19.0, via crux_core's typegen.swift(), into a SwiftPM package that also receives bindings from a second generator (BoltFFI). Under Swift 6's complete concurrency checking, none of the facet_generate-emitted types can cross an actor isolation boundary.
Reproduction
Generate any struct or enum with the Swift target and use it inside a Task or across an async boundary in a Swift 6 project:
error: sending 'myValue' risks causing data races
Measured over my generated output — 1,929 lines, 30 generated type declarations:
| Conformance |
Occurrences |
Hashable |
30 |
Equatable |
30 |
Sendable |
0 |
So Hashable and Equatable are emitted, correctly and on every type. Sendable is the only one missing. The string Sendable does not appear anywhere in facet_generate 0.19.0's source outside test fixtures — the emitter has no code path that produces it.
The types are structurally sendable: the emitter produces only structs and enums over String, Bool, the fixed-width integers, UUID, Optional, and Array of the same, with no class declarations in the output.
Why this is awkward in practice
The same SwiftPM package also holds BoltFFI-generated types, which do carry Sendable. One package directory ends up with two classes of generated Swift type, and there is no way to tell which is which except by trying to use one across an isolation boundary. The compiler error names the call site that crossed the boundary rather than the type missing the conformance, so there is no path from the diagnostic to the cause.
Where the gap is
Hashable and Equatable are computed deliberately: the emitter documents the rule at src/generation/swift/emitter/mod.rs:45-51 — types whose fields are all Hashable declare the conformance so they can serve as Set elements or Dictionary keys — and it raises a generation-time error when a non-Hashable type is used as a Set element or Map key. That machinery works. It simply has no Sendable equivalent.
There is an extension point that could supply one. EmitterPlugin::type_conformances (src/generation/plugin.rs:261) appends arbitrary conformances to a type declaration and defaults to empty. crux_core passes only BincodePlugin and does not implement the hook, and its Config does not expose the Installer to callers — so a downstream consumer cannot reach it from the typegen.swift() path.
A default in this emitter looks like the root fix: every type it can produce is structurally sendable, so emitting the conformance mirrors how Hashable and Equatable are already computed here. If you would rather keep default output unchanged and treat Sendable as opt-in — via the decorator or the plugin hook — then crux_core additionally needs to expose plugin configuration before any consumer on that path could opt in, which would be a second, separate issue.
The decorator fixtures
src/tests/test_default_decorators/mod.rs carries a commented-out // TODO: #[facet(swift = "Equatable")], and that fixture's output.swift shows struct EmptyType: Codable, Equatable, Identifiable, Sendable. Two sibling fixtures (struct_decorator, adjacently_tagged_enum_decorator) carry the same shape of TODO.
These read as the expected output of a per-type decorator feature that is not implemented yet — the current emitter produces none of Codable, Identifiable, or Sendable for real input.
Suggestion
Either default to emitting Sendable wherever it is derivable — which appears to be everywhere for the field types the emitter already supports, and would match how Hashable and Equatable are already handled — or finish the per-type decorator so consumers can opt in.
Workaround: hand-write retroactive conformances in a separate file, which is exactly the drift generated types are supposed to eliminate: every type added to the source arrives in Swift without Sendable, and nothing regenerates the workaround.
LLM Disclosure
I was using claude-code to explore making a Crux app. I noticed code I didn't like, and pushed back against the agent and investigated more deeply. The above ticket description was authored with the agent across several rounds, but I'm happy to trim it down entirely to content that I write by hand.
Please let me know if you need more input from me! I'd be happy to make a stab at fixing this issue, but I'd like a maintainer's input for which suggested approach (if any) is appropriate.
I generate Swift types with
facet_generate0.19.0, viacrux_core'stypegen.swift(), into a SwiftPM package that also receives bindings from a second generator (BoltFFI). Under Swift 6's complete concurrency checking, none of thefacet_generate-emitted types can cross an actor isolation boundary.Reproduction
Generate any struct or enum with the Swift target and use it inside a
Taskor across anasyncboundary in a Swift 6 project:Measured over my generated output — 1,929 lines, 30 generated type declarations:
HashableEquatableSendableSo
HashableandEquatableare emitted, correctly and on every type.Sendableis the only one missing. The stringSendabledoes not appear anywhere infacet_generate0.19.0's source outside test fixtures — the emitter has no code path that produces it.The types are structurally sendable: the emitter produces only structs and enums over
String,Bool, the fixed-width integers,UUID,Optional, andArrayof the same, with noclassdeclarations in the output.Why this is awkward in practice
The same SwiftPM package also holds BoltFFI-generated types, which do carry
Sendable. One package directory ends up with two classes of generated Swift type, and there is no way to tell which is which except by trying to use one across an isolation boundary. The compiler error names the call site that crossed the boundary rather than the type missing the conformance, so there is no path from the diagnostic to the cause.Where the gap is
HashableandEquatableare computed deliberately: the emitter documents the rule atsrc/generation/swift/emitter/mod.rs:45-51— types whose fields are allHashabledeclare the conformance so they can serve asSetelements orDictionarykeys — and it raises a generation-time error when a non-Hashabletype is used as aSetelement orMapkey. That machinery works. It simply has noSendableequivalent.There is an extension point that could supply one.
EmitterPlugin::type_conformances(src/generation/plugin.rs:261) appends arbitrary conformances to a type declaration and defaults to empty.crux_corepasses onlyBincodePluginand does not implement the hook, and itsConfigdoes not expose theInstallerto callers — so a downstream consumer cannot reach it from thetypegen.swift()path.A default in this emitter looks like the root fix: every type it can produce is structurally sendable, so emitting the conformance mirrors how
HashableandEquatableare already computed here. If you would rather keep default output unchanged and treatSendableas opt-in — via the decorator or the plugin hook — thencrux_coreadditionally needs to expose plugin configuration before any consumer on that path could opt in, which would be a second, separate issue.The decorator fixtures
src/tests/test_default_decorators/mod.rscarries a commented-out// TODO: #[facet(swift = "Equatable")], and that fixture'soutput.swiftshowsstruct EmptyType: Codable, Equatable, Identifiable, Sendable. Two sibling fixtures (struct_decorator,adjacently_tagged_enum_decorator) carry the same shape of TODO.These read as the expected output of a per-type decorator feature that is not implemented yet — the current emitter produces none of
Codable,Identifiable, orSendablefor real input.Suggestion
Either default to emitting
Sendablewherever it is derivable — which appears to be everywhere for the field types the emitter already supports, and would match howHashableandEquatableare already handled — or finish the per-type decorator so consumers can opt in.Workaround: hand-write retroactive conformances in a separate file, which is exactly the drift generated types are supposed to eliminate: every type added to the source arrives in Swift without
Sendable, and nothing regenerates the workaround.LLM Disclosure
I was using claude-code to explore making a Crux app. I noticed code I didn't like, and pushed back against the agent and investigated more deeply. The above ticket description was authored with the agent across several rounds, but I'm happy to trim it down entirely to content that I write by hand.
Please let me know if you need more input from me! I'd be happy to make a stab at fixing this issue, but I'd like a maintainer's input for which suggested approach (if any) is appropriate.