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:

MechanismWhereCarries
Keyword (ADR-0080)head, before struct/enumposture (copy, linear)
@derive(…) (ADR-0058)directive listmethod-bearing interface implementations
@lang(…), @handledirective listcompiler-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:

  • copy had to be implemented as a contextual identifier (not a hard keyword) so the prelude's fn copy(self) -> Self and user methods named copy keep working. The parser carries a special-case posture_parser that filters Ident("copy") next to a hard Linear token.
  • The two markers occupy the same exclusive slot via grammar alone — any future markers that want to coexist with copy or linear cannot use the slot.
  • Anonymous struct / enum literals 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 struct BuiltinMarker {
    pub name: &'static str,
    pub kind: MarkerKind,           // Posture(Copy) | Posture(Linear) | …
    pub applicable_to: ItemKinds,   // struct | enum | both
}

pub static BUILTIN_MARKERS: &[BuiltinMarker] = &[
    BuiltinMarker { name: "copy",   kind: MarkerKind::Posture(Posture::Copy),   applicable_to: ItemKinds::STRUCT_OR_ENUM },
    BuiltinMarker { name: "affine", kind: MarkerKind::Posture(Posture::Affine), applicable_to: ItemKinds::STRUCT_OR_ENUM },
    BuiltinMarker { name: "linear", kind: MarkerKind::Posture(Posture::Linear), applicable_to: ItemKinds::STRUCT_OR_ENUM },
];

The registry serves three purposes:

  1. Closed taxonomy — sema rejects @mark(unknown) with a suggest-from-registry diagnostic (parallel to the directive diagnosis path that already exists for @derive).
  2. Single source of truthis_copy / is_linear flag-setting reads MarkerKind::Posture rather than string-matching directive args.
  3. 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. an i32 handle that's actually a kernel resource ID).
  • No @mark (or @mark with 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 drop cannot 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 / linear per 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:

TypePosture
(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:

  1. 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."
  2. Reconciliation pass. If a posture marker is present:
    • @mark(copy) + inferred ≠ Copy → reject with the offending member cited. (Subsumes ADR-0080's validate_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 from BUILTIN_MARKERS (Levenshtein, parallel to the directive diagnosis path).
  • @mark(copy) @mark(linear) struct … → existing LinearStructCopy diagnostic, 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 a copy keyword.
  • @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: Spur symbol on the parser state.
  • posture_parser in chumsky_parser.rs.
  • Linear token in gruel-lexer (both the logos and public token enums) and its parser uses (the head slot and the linear-interface parser at chumsky_parser.rs:3521).
  • The parser-time mutual-exclusion check (collapses into the directive-arg-list parser).
  • is_copy / is_linear field plumbing on StructDecl / EnumDecl AST 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.md and docs/spec/src/02-lexical-structure/05-builtins.md describing the posture keyword slot, replaced by directive-form prose.

What's added

  • @mark(...) directive recognition: name → registry lookup → flag population in register_type_names (struct path) and find_or_create_anon_struct / find_or_create_anon_enum (anonymous path).
  • BUILTIN_MARKERS table + MarkerKind, Posture enums in gruel-builtins.
  • mark_directive preview gate (in PreviewFeature), fired in register_type_names when an @mark(...) directive is seen on a type declaration.
  • Spec tests under cases/items/mark-directive.toml covering 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, and BUILTIN_MARKERS to gruel-builtins/src/lib.rs.
  • Add PreviewFeature::MarkDirective (mark_directive) to gruel-util/src/error.rs (enum + name() + adr()).
  • Recognize mark in validate_directive_names (crates/gruel-air/src/sema/declarations.rs) so @mark no longer falls through to UnknownDirective.
  • In register_type_names, when a type-decl directive is @mark, gate behind mark_directive, look each argument up in BUILTIN_MARKERS, and dispatch: - Unknown name → UnknownMarker { name, note } with Levenshtein suggestions from the registry. - MarkerKind::Posture(Copy) → set is_copy = true. - MarkerKind::Posture(Linear) → set is_linear = true. - MarkerKind::Posture(Affine) → tracked in mark_affine_decls side set on Sema. - Applicability mismatch → MarkerNotApplicable { marker, item_kind }.
  • Mutual exclusion (Copy + Linear, Copy + Affine, Affine + Linear): rejected at sema with the existing LinearStructCopy diagnostic, repointed to the @mark directive span.
  • Implement uniform structural inference inside validate_posture_consistency. For every named struct/enum: classify members, fold into MemberPosture::{Copy, Affine, Linear}, then write the final is_copy / is_linear flags. Drop ⊥ Copy carve-out: types with fn drop (inline) or drop 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_copy for [T; N] returns is_type_copy(T) (revives Copy posture for arrays of Copy elements, consciously reverting ADR-0080 — see Open Question 1). is_type_copy for Vec(T) continues to return false (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, plus mark_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_linear flags on StructDef / EnumDef. The validator (validate_posture_consistency) reads the flags directly — keyword and directive paths are indistinguishable downstream.
  • register_type_names OR-folds kw_is_copy || mark_outcome.copy and kw_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 existing LinearStructCopy mutual-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 X across crates/gruel-spec/cases/ and crates/gruel-air/src/sema/tests.rs. Script in scratch/rewrite_posture_keywords.py. Anonymous-form rewrites surveyed manually; the script handled the line-broken forms in the corpus. The compile_to_air and gather_declarations_for_testing helpers now enable mark_directive for 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 in scratch/cleanup_redundant_mark_copy.py. Tests genuinely asserting the directive form (e.g. mark_copy_struct_basic) keep @mark(copy); tests in cases/items/copy-keyword.toml likewise 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: String field 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 in scratch/add_mark_preview.py. Phase 5 strips these once the preview gate retires.
  • Update prelude/interfaces.gruel comments — no occurrences to update; the prelude doesn't reference the keyword form directly.
  • Regenerated docs/generated/builtins-reference.md to surface the BUILTIN_MARKERS registry 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_parser and its uses in struct/enum head parsers; the heads no longer accept the keyword form.
  • Dropped copy_name: Spur from ParserSyms and its initializer.
  • Deleted TokenKind::Linear and LogosTokenKind::Linear plus their display/conversion arms. Dropped the just(TokenKind::Linear) entry in item_start(). The TokenKind::Linear arm in the directive-arg parser is also gone — linear is now a regular identifier and falls through to ident_parser. linear_name: Spur retired with it.
  • Sema-side: register_type_names no longer reads kw_is_copy / kw_is_linear from StructDecl/EnumDecl. The @mark(...) recognizer is the sole writer of is_copy / is_linear flags on StructDef / EnumDef.
  • AST StructDecl.is_copy / is_linear and EnumDecl.is_copy / is_linear survive as fields, but the parser now always sets them to false — sema fills them from the directive list. Future cleanup may retire the AST fields entirely.
  • cases/items/copy-keyword.toml rewritten 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.md and docs/spec/src/02-lexical-structure/05-builtins.md rewritten 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.gruel off the keyword form — the structs have only primitive Copy fields, so uniform inference makes them Copy without a directive.

Phase 5: Stabilize

  • Removed PreviewFeature::MarkDirective from gruel-util/src/error.rs. --preview mark_directive is no longer recognised; the preview-required call site in process_mark_directives retired with it. The compile_to_air and gather_declarations_for_testing helpers no longer enable any preview features.
  • Stripped preview = "mark_directive" and preview_should_pass = true from every spec case still carrying them. Script: scratch/strip_mark_preview.py. Renamed the Phase 2 "redundant-with-keyword" tests to the more accurate mark_duplicate_* form now that the keyword path is gone. Dropped the mark_preview_gated test entirely.
  • Swept residual copy struct / linear struct / copy enum / linear enum strings: 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 in gruel-air/src/intern_pool.rs, gruel-air/src/sema/typeck.rs, gruel-air/src/sema/lang_items.rs, and a stale description field in cases/types/move-semantics.toml remain as historical breadcrumbs and are not load-bearing.
  • make test passes 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 copy retires; Linear frees up as an identifier.
  • BUILTIN_MARKERS becomes 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 of i32s infers Copy), so this only bites when the user wants the assertion form.
  • Breaking change to every copy / linear declaration 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_directive rollout 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

  1. 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's impl<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] }.

  2. @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 field x is affine" vs "type would be affine, but @mark(copy) requires Copy"). Pick the clearer wording during implementation.

  3. 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 @allow escape valve already exists for similar redundancy lints. Decision deferred to the implementation PR.

  4. Marker applicability beyond struct/enum. Today both markers are struct-or-enum only. The applicable_to field on BuiltinMarker is 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_MARKERS should 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.

References