ADR-0083: @mark(...) directive for marker traits
Status
Implemented
Summary
Replace the copy / linear declaration-site keywords introduced in ADR-0080 with a @mark(...) directive: @mark(copy) struct Foo { … }, @mark(linear) enum Bar { … }. Markers live in a small open registry in gruel-builtins (initially copy and linear) so future declaration-time-only markers — Gruel's equivalent of marker traits — plug in by adding one row. Semantics, consistency rules, and codegen are unchanged from ADR-0080; only the surface syntax moves from a contextual keyword slot into the directive system, where it sits next to @derive and @lang instead of in front of struct / enum.
Context
ADR-0080 carved out a posture slot in struct/enum heads to carry copy and linear. That slot solves the immediate problem (Copy and Linear are postures, not method-bearing interfaces) but introduces a third syntactic mechanism for type-level metadata:
| Mechanism | Where | Carries |
|---|---|---|
| Keyword (ADR-0080) | head, before struct/enum | posture (copy, linear) |
@derive(…) (ADR-0058) | directive list | method-bearing interface implementations |
@lang(…), @handle | directive list | compiler-recognized roles |
The keyword slot pays for itself only as long as posture is the only marker-style attribute we ever want. As soon as a second marker appears (an obvious near-term candidate is a marker indicating that an enum is intended for use as a discriminated capability tag, but other examples exist), the keyword pattern doesn't extend — a third reserved word in the head, a fourth, etc., quickly becomes worse than a directive list. Even the existing two keywords already hit awkward edges:
copyhad to be implemented as a contextual identifier (not a hard keyword) so the prelude'sfn copy(self) -> Selfand user methods namedcopykeep working. The parser carries a special-caseposture_parserthat filtersIdent("copy")next to a hardLineartoken.- The two markers occupy the same exclusive slot via grammar alone — any future markers that want to coexist with
copyorlinearcannot use the slot. - Anonymous
struct/enumliterals duplicate the keyword logic at every literal site.
Moving these into the directive system unifies how all declaration-time markers are spelled, removes the contextual-keyword hack, frees the Linear reserved word, and gives a single extension point — BUILTIN_MARKERS in gruel-builtins — for future markers.
Decision
Syntax
struct Point { x: i32, y: i32 } // inferred Copy (all members Copy)
struct Mixed { x: i32, y: String } // inferred Affine
struct Held { fd: FileHandle } // inferred Linear (FileHandle is linear)
@mark(copy) struct Point { x: i32, y: i32 } // assertion: errors if not Copy
@mark(affine) struct Token { x: i32 } // suppresses Copy inference
@mark(linear) struct Pin { x: i32 } // override: Linear despite Copy members
@mark(copy) struct { x: i32 } // anonymous Copy struct (assertion)
@mark(linear) enum { A, B } // anonymous Linear enum
@derive(Clone) @mark(copy) struct Pair { x: i32, y: i32 }
@mark(copy, future_marker) struct … // multiple markers in one directive
@mark lives in the same directive list as @derive, @lang, and @allow. Order between directives is irrelevant. A type can carry multiple @mark(…) directives; the marker set is the union. Mutual exclusion (Copy ⊥ Linear) is enforced regardless of whether the two markers appear in one directive or two.
Marker registry
A new BUILTIN_MARKERS table in gruel-builtins/src/lib.rs lists the recognized marker names and their semantics in one place:
pub static BUILTIN_MARKERS: & = &;
The registry serves three purposes:
- Closed taxonomy — sema rejects
@mark(unknown)with a suggest-from-registry diagnostic (parallel to the directive diagnosis path that already exists for@derive). - Single source of truth —
is_copy/is_linearflag-setting readsMarkerKind::Posturerather than string-matching directive args. - Mechanical extension point — adding a future marker is one row in the registry plus the sema arm that consumes its
MarkerKind.
The registry is intentionally small. New markers must still go through an ADR; the registry documents what exists, not what's permissible.
Semantics: uniform structural inference
ADR-0080 split posture into "declared" (named types — keyword required for Copy) versus "inferred" (tuples and the anonymous-enum carve-out). This ADR collapses the split. Every unmarked type — named struct, named enum, anonymous struct, anonymous enum, tuple, array — infers posture from its members using one rule:
- If any member is Linear → the type is Linear.
- Else if every member is Copy → the type is Copy.
- Else → the type is Affine.
The marker overlay sits on top:
@mark(copy) struct/enum X { … }— Copy assertion. The type is declared Copy, and the inference rule must agree (every member Copy). If a member isn't Copy, the directive is rejected with the field cited. Useful for documenting intent and turning a silent posture downgrade (adding a non-Copy field later) into an error at the declaration site.@mark(affine) struct/enum X { … }— Copy suppressor. The type is declared Affine regardless of whether inference would produce Copy. Use when a type's members all happen to be Copy but its semantics demand move-on-use (a non-Clone-able token, a single-use builder, a value whose duplication would be semantically wrong even though it's bitwise-fine). Has no effect on Linear inference: if any member is Linear, the directive is rejected — Linear is contagious and cannot be hidden behind an affine declaration.@mark(linear) struct/enum X { … }— Linear override. The type is declared Linear regardless of member postures. Linear can hold anything (ADR-0080), so the only thing this precludes is structural inference picking Copy or Affine. Use when the type has linear semantics that aren't visible from its fields (e.g. ani32handle that's actually a kernel resource ID).- No
@mark(or@markwith no posture marker) → posture is whatever inference produces. No diagnostic, even if the resulting posture changes when a field changes. - Copy ⊥ Drop is unchanged — a type with
fn dropcannot be Copy (whether declared or inferred).Vec(T)and any other type with a Drop impl is therefore never Copy regardless of its members. This is the only carve-out in the model. - Mutual exclusion: at most one of
copy/affine/linearper item. Any combination (one directive with multiple posture args, or two directives carrying conflicting markers) is rejected.
The arithmetic of "if any field is linear, the type is linear" remains strictly enforced even with @mark(copy): declaring Copy on a type with a Linear field is an error, not a silent override. Linear is contagious upward and cannot be hidden.
Consequences for built-ins under the uniform rule:
| Type | Posture |
|---|---|
(i32, i32) | Copy (all members Copy) |
(i32, String) | Affine (one affine member, no linear) |
(i32, FileHandle) | Linear (one linear member) |
[i32; 3] | Copy — changes back from ADR-0080's "always affine" |
[String; 3] | Affine |
[FileHandle; 3] | Linear |
Vec(i32) | Affine (Vec has Drop, so never Copy; no linear members) |
Vec(FileHandle) | Linear (Vec is linear iff T is linear, ADR-0067) |
Option(i32) | Copy (anonymous-enum payload is Copy) |
Option(String) | Affine |
Option(FileHandle) | Linear |
Result(i32, i32) | Copy |
Result(String, FileHandle) | Linear |
Option / Result and other generic prelude wrappers track their type arguments' posture automatically — no comptime predicate, no double declaration, no anonymous-type carve-out. Tuples behave as today. Arrays of Copy regain Copy posture (reverting ADR-0080's "arrays are containers, not value types" stance — this ADR judges that the consistency win outweighs the container/value-type distinction, and arrays of large Copy types already had clone() for explicit deep copies under that distinction's intent).
@ownership(T) remains the comptime query for posture.
Mutual exclusion
The validator collects all markers from all @mark directives on an item, deduplicates by name, then validates pairwise constraints. Copy + Linear is the only constraint today. Duplicate markers (@mark(copy) @mark(copy)) are a soft error (warning under @allow, hard error otherwise — TBD in Open Questions).
Validator and inference entry points
Two passes on StructDef / EnumDef:
- Inference pass. Compute structural posture from members: classify each member, fold into a posture using the rule above. This pass writes the type's inferred posture into a new field (
inferred_posture: Posture) — separate from the declared bits so we can distinguish "user said Copy" from "I figured out Copy." - Reconciliation pass. If a posture marker is present:
@mark(copy)+ inferred ≠ Copy → reject with the offending member cited. (Subsumes ADR-0080'svalidate_posture_consistency.)@mark(affine)+ inferred = Copy → accept; the declared posture wins, and the type is Affine.@mark(affine)+ inferred = Affine → accept (redundant but harmless).@mark(affine)+ inferred = Linear → reject; Linear members cannot be hidden by an affine declaration.@mark(linear)+ any inferred → accept; the declared posture wins, and the type is Linear.- No posture marker → declared posture is inferred posture.
After this pass StructDef.is_copy / is_linear (and the enum counterparts) carry the final posture, which is what codegen and the rest of sema see. The flags on StructDecl / EnumDecl AST nodes survive only as the directive-derived "user asserted" bits; the final posture is computed in sema.
Diagnostic surface
@mark(unknown_marker)→unknown marker 'unknown_marker'with suggestion fromBUILTIN_MARKERS(Levenshtein, parallel to the directive diagnosis path).@mark(copy) @mark(linear) struct …→ existingLinearStructCopydiagnostic, repointed to the offending directive span.@mark(copy) enum … { A(FileHandle) }→ existing copy-with-affine-payload diagnostic, span on the@mark(copy)directive instead of on acopykeyword.@mark(copy)on a non-struct/non-enum item (fn,const,interface) →marker 'copy' is not applicable to functions.
What retires (after stabilization)
copy_name: Spursymbol on the parser state.posture_parserinchumsky_parser.rs.Lineartoken ingruel-lexer(both the logos and public token enums) and its parser uses (the head slot and the linear-interface parser atchumsky_parser.rs:3521).- The parser-time mutual-exclusion check (collapses into the directive-arg-list parser).
is_copy/is_linearfield plumbing onStructDecl/EnumDeclAST nodes survives — sema still needs the bits — but is filled exclusively from@mark(...)after Phase 4.- Spec text under
docs/spec/src/03-types/08-move-semantics.mdanddocs/spec/src/02-lexical-structure/05-builtins.mddescribing the posture keyword slot, replaced by directive-form prose.
What's added
@mark(...)directive recognition: name → registry lookup → flag population inregister_type_names(struct path) andfind_or_create_anon_struct/find_or_create_anon_enum(anonymous path).BUILTIN_MARKERStable +MarkerKind,Postureenums ingruel-builtins.mark_directivepreview gate (inPreviewFeature), fired inregister_type_nameswhen an@mark(...)directive is seen on a type declaration.- Spec tests under
cases/items/mark-directive.tomlcovering the new surface (parse, gating, mutual exclusion, unknown-marker diagnostic, applicability check).
Implementation Phases
Each phase ships behind --preview mark_directive, ends green, quotes its LOC delta in the commit message.
Phase 1: Marker registry + directive recognition
- Add
MarkerKind,Posture,ItemKinds,BuiltinMarker, andBUILTIN_MARKERStogruel-builtins/src/lib.rs. - Add
PreviewFeature::MarkDirective(mark_directive) togruel-util/src/error.rs(enum +name()+adr()). - Recognize
markinvalidate_directive_names(crates/gruel-air/src/sema/declarations.rs) so@markno longer falls through toUnknownDirective. - In
register_type_names, when a type-decl directive is@mark, gate behindmark_directive, look each argument up inBUILTIN_MARKERS, and dispatch: - Unknown name →UnknownMarker { name, note }with Levenshtein suggestions from the registry. -MarkerKind::Posture(Copy)→ setis_copy = true. -MarkerKind::Posture(Linear)→ setis_linear = true. -MarkerKind::Posture(Affine)→ tracked inmark_affine_declsside set onSema. - Applicability mismatch →MarkerNotApplicable { marker, item_kind }. - Mutual exclusion (Copy + Linear, Copy + Affine, Affine + Linear): rejected at sema with the existing
LinearStructCopydiagnostic, repointed to the@markdirective span. - Implement uniform structural inference inside
validate_posture_consistency. For every named struct/enum: classify members, fold intoMemberPosture::{Copy, Affine, Linear}, then write the finalis_copy/is_linearflags. Drop ⊥ Copy carve-out: types withfn drop(inline) ordrop fn TypeName(self)(top-level) downgrade Copy → Affine. - Reconciliation pass:
@mark(copy)requires inferred Copy (errors on Affine/Linear members).@mark(affine)forbids Linear members but suppresses Copy.@mark(linear)forces Linear regardless. Unmarked → final posture is inferred posture. - Anonymous struct/enum literals continue to use the existing structural inference in
find_or_create_anon_struct/find_or_create_anon_enum.@mark(copy)on an anonymous type literal flows through the directive list and is processed by sema. -
is_type_copyfor[T; N]returnsis_type_copy(T)(revives Copy posture for arrays of Copy elements, consciously reverting ADR-0080 — see Open Question 1).is_type_copyforVec(T)continues to returnfalse(Vec has Drop, so Copy ⊥ Drop forbids it). - Spec tests under
cases/items/mark-directive.toml:mark_copy_struct_basic,mark_linear_enum_basic,mark_copy_struct_anon,mark_unknown_marker_diagnostic,mark_copy_and_linear_rejected,mark_combines_with_derive,mark_multi_arg_form,mark_two_directives_form,mark_preview_gated, plusmark_linear_struct_basic,mark_copy_enum_basic,mark_affine_suppresses_copy_inference,mark_affine_with_linear_field_rejected,mark_unmarked_struct_of_copy_infers_copy.
Phase 2: Coexistence with the keyword path
- Both pathways write to the same
is_copy/is_linearflags onStructDef/EnumDef. The validator (validate_posture_consistency) reads the flags directly — keyword and directive paths are indistinguishable downstream. -
register_type_namesOR-foldskw_is_copy || mark_outcome.copyandkw_is_linear || mark_outcome.linear, so a redundant combination (@mark(linear) linear struct …) is accepted. Conflicts (@mark(copy) linear struct …,@mark(linear) copy struct …) hit the existingLinearStructCopymutual-exclusion path. - Spec tests in
cases/items/mark-directive.toml:mark_redundant_with_keyword_copy_ok,mark_redundant_with_keyword_linear_ok,mark_copy_with_linear_keyword_rejected,mark_linear_with_copy_keyword_rejected.
Phase 3: Migrate the corpus
- First pass (mechanical translation). Rewrote
copy struct X→@mark(copy) struct X,copy enum X→@mark(copy) enum X,linear struct X→@mark(linear) struct X,linear enum X→@mark(linear) enum Xacrosscrates/gruel-spec/cases/andcrates/gruel-air/src/sema/tests.rs. Script inscratch/rewrite_posture_keywords.py. Anonymous-form rewrites surveyed manually; the script handled the line-broken forms in the corpus. Thecompile_to_airandgather_declarations_for_testinghelpers now enablemark_directivefor the migration window. - Second pass (cleanup). Stripped redundant
@mark(copy)declarations on structs whose fields are all primitive Copy types — uniform inference produces Copy without the directive. Script inscratch/cleanup_redundant_mark_copy.py. Tests genuinely asserting the directive form (e.g.mark_copy_struct_basic) keep@mark(copy); tests incases/items/copy-keyword.tomllikewise keep the directive because they are about the directive form. Tests that previously asserted "named types must declare to be Copy" now reflect inferred Copy. - Audited named affine types: structs of all-Copy fields asserted as Affine were given a
name: Stringfield to preserve move-on-use semantics; otherwise accepted the Copy posture. Phase 1's spec-fix pass already handled the bulk of these. - Add a
preview = "mark_directive"line to every spec test whose source still contains@mark(...). Script inscratch/add_mark_preview.py. Phase 5 strips these once the preview gate retires. - Update
prelude/interfaces.gruelcomments — no occurrences to update; the prelude doesn't reference the keyword form directly. - Regenerated
docs/generated/builtins-reference.mdto surface theBUILTIN_MARKERSregistry alongside the type constructors and interfaces. - Run
make test— all 2158 spec tests, 92 UI tests, and 20 examples tests pass on the new surface.
Spec text rewrites (
docs/spec/src/03-types/08-move-semantics.md,docs/spec/src/03-types/09-destructors.md,docs/spec/src/02-lexical-structure/05-builtins.md,docs/spec/src/04-expressions/13-intrinsics.md) and grammar production updates are deferred to Phase 4's "spec text final pass" since both phases touch the same files; the corpus tests already use@mark(...)form.
Phase 4: Retire the keyword path
- Deleted
posture_parserand its uses in struct/enum head parsers; the heads no longer accept the keyword form. - Dropped
copy_name: SpurfromParserSymsand its initializer. - Deleted
TokenKind::LinearandLogosTokenKind::Linearplus their display/conversion arms. Dropped thejust(TokenKind::Linear)entry initem_start(). TheTokenKind::Lineararm in the directive-arg parser is also gone —linearis now a regular identifier and falls through toident_parser.linear_name: Spurretired with it. - Sema-side:
register_type_namesno longer readskw_is_copy/kw_is_linearfromStructDecl/EnumDecl. The@mark(...)recognizer is the sole writer ofis_copy/is_linearflags onStructDef/EnumDef. - AST
StructDecl.is_copy/is_linearandEnumDecl.is_copy/is_linearsurvive as fields, but the parser now always sets them tofalse— sema fills them from the directive list. Future cleanup may retire the AST fields entirely. -
cases/items/copy-keyword.tomlrewritten to the directive form (@mark(copy) struct …,@mark(linear) enum …). Posture consistency, mutual exclusion, and drop interaction coverage retained. Section description updated to reflect the directive surface. - Spec text final pass:
docs/spec/src/03-types/08-move-semantics.mdanddocs/spec/src/02-lexical-structure/05-builtins.mdrewritten to describe@mark(copy)/@mark(linear)and uniform structural inference in place of the keyword form. Grammar appendix had no posture-keyword production so no change needed there. - Migrated
examples/closures.gruel,examples/methods.gruel,examples/shapes.grueloff the keyword form — the structs have only primitive Copy fields, so uniform inference makes them Copy without a directive.
Phase 5: Stabilize
- Removed
PreviewFeature::MarkDirectivefromgruel-util/src/error.rs.--preview mark_directiveis no longer recognised; the preview-required call site inprocess_mark_directivesretired with it. Thecompile_to_airandgather_declarations_for_testinghelpers no longer enable any preview features. - Stripped
preview = "mark_directive"andpreview_should_pass = truefrom every spec case still carrying them. Script:scratch/strip_mark_preview.py. Renamed the Phase 2 "redundant-with-keyword" tests to the more accuratemark_duplicate_*form now that the keyword path is gone. Dropped themark_preview_gatedtest entirely. - Swept residual
copy struct/linear struct/copy enum/linear enumstrings: the only remaining occurrences are in historical ADR bodies (0008, 0059, 0065, 0067, 0075, 0078, 0079, 0080) — those are protected by the "no rewriting old ADRs" rule. Comments ingruel-air/src/intern_pool.rs,gruel-air/src/sema/typeck.rs,gruel-air/src/sema/lang_items.rs, and a staledescriptionfield incases/types/move-semantics.tomlremain as historical breadcrumbs and are not load-bearing. -
make testpasses on the final state (2157 spec, 92 UI, 20 examples, 100% normative spec coverage). - ADR status →
implemented; frontmatter updated (accepted: 2026-05-10,implemented: 2026-05-10,feature-flag:cleared).
Consequences
Positive
- One mechanism (
@-prefixed directives) for all declaration-time type-level metadata. Posture, derives, lang items, and future markers all share a uniform spelling. - The contextual-keyword hack for
copyretires;Linearfrees up as an identifier. BUILTIN_MARKERSbecomes the obvious place to add future markers — one row, one ADR, no parser surgery.- Spans on diagnostics improve marginally: errors now point at the
@mark(…)directive rather than at a keyword that may sit far from the offending field. - Anonymous struct/enum literal handling collapses into the existing expression-directive path; no parallel keyword logic at literal sites.
Negative
- Visual cost:
copy struct Point { … }(16 chars of head) becomes@mark(copy) struct Point { … }(22 chars). Marginal but real. In practice many@mark(copy)declarations can simply retire under uniform inference (a struct ofi32s infers Copy), so this only bites when the user wants the assertion form. - Breaking change to every
copy/lineardeclaration in the corpus, plus a behavioural change to every named struct/enum whose member set produces Copy: those types silently change from Affine (today, post-ADR-0080) to Copy. The migration is one-way safe — code that worked under the old "must declare to be Copy" rule continues to work; code that move-then-uses such types now compiles where it previously errored. No silent miscompiles, but spec tests asserting the old "this struct is Affine" behaviour will need updating to either accept the new inference or add@mark(affine)to preserve the move-on-use semantics. Mass-rewrite is mechanical, and a--preview mark_directiverollout overlaps the keyword path during the migration phase, but every spec test, ADR-0080 test, and prelude doc comment touching the surface needs editing. - Two ways to spell posture exist during Phases 1–3 (keyword and directive), with Phase 2 explicitly defining their interaction. The window is short (one ADR's worth of phases) but adds review surface.
- One more directive name (
mark) in the recognized set. Negligible cost, but noted for completeness.
Neutral
- Codegen unchanged. Sema validator unchanged in body.
- Posture semantics, Copy ⊥ Drop, tuple/array/Vec carve-outs, anonymous-enum structural inference: all unchanged from ADR-0080.
@ownership(T),@implements(T, Iface), and the Copy-related intrinsics: unchanged.
Open Questions
Arrays of Copy regain Copy posture. ADR-0080 made arrays non-Copy as a deliberate "containers aren't value types" stance. The uniform-inference rule reverts that;
[i32; 3]is Copy again, matching Rust'simpl<T: Copy, const N: usize> Copy for [T; N]. Treating this as decided (consistency + Rust parity outweighs the original justification); user code that wants move-only array semantics can wrap with@mark(affine) struct ArrayWrapper { inner: [T; N] }.@mark(copy)as an assertion vs a declaration. The model above says@mark(copy)is an assertion that errors when inference disagrees. An alternative is for@mark(copy)to force Copy and emit field-level errors (current ADR-0080 shape). Functionally identical — both reject the same programs — but the diagnostic phrasing differs ("declared@mark(copy)but fieldxis affine" vs "type would be affine, but@mark(copy)requires Copy"). Pick the clearer wording during implementation.Duplicate markers within one item (
@mark(copy) @mark(copy)or@mark(copy, copy)): warn under@allow(redundant_marker), or hard error? Leaning warn — the "no semantic effect" case is harmless and an@allowescape valve already exists for similar redundancy lints. Decision deferred to the implementation PR.Marker applicability beyond struct/enum. Today both markers are struct-or-enum only. The
applicable_tofield onBuiltinMarkeris forward-looking; if no future marker ever needs it, the field could retire. Keeping it costs one bitfield-shaped enum entry per registry row, which seems worth it for the design clarity.
Future Work
- Additional markers added to
BUILTIN_MARKERSshould be motivated by their own ADR. Examples worth considering:- A marker indicating an enum is a tag for a discriminated capability (related to Handle).
- A marker for "exhaustive" vs "non-exhaustive" enums (cross-file pattern-match obligations).
- A marker for "no-niche" / "niche-required" structs in the layout abstraction (ADR-0069).
- User-defined markers (analogous to user-defined derives in ADR-0058) are explicitly out of scope. The registry is closed.