RFC-0013: Annotation Syntax — @@[...]

Prompt Engineer: Mark Truluck mark@frame-lang.org Status: Wave 1 + Wave 2 shipped (2026-05-01); Wave 3 open Created: 2026-05-01


Motivation

Frame’s existing directive syntax has grown organically into two shapes: a bare keyword form (@@persist, @@target python_3) and an args form (@@persist(domain=[a, b]), @@persist(exclude=[c])). As more directives accumulate (per-target conditional emit, per- method deprecation, per-field secrets, custom user attributes), the surface needs a uniform shape that:

  • Reads as a self-contained attribute, not a free-floating keyword.
  • Stacks cleanly when an item carries more than one attribute.
  • Matches the typed-language convention users already know ([Foo] in C#, @Foo in Java/Kotlin, #[foo] in Rust).

The @@persist(domain=[a, b]) shape was the immediate motivator during the wave 8 nested-system rollout: persist gained more options, the bare-keyword grammar was bursting at the seams, and each new option had to invent its own ad-hoc syntax. A unified attribute syntax future-proofs this.

Design — locked

The four design questions raised during discussion are now locked:

1. Migration scope: hard cut

The four @@-prefixed syntactic categories split as follows:

Category Examples Migrates?
Directives @@persist, @@target python_3 Yes — to @@[...]
Declarations @@system Name { ... } No — different shape
Instantiation @@SystemName(args) No — different shape
Context accessors @@:return, @@:params.x, @@:self.method(), @@:system.state No — descent operator, different shape

Only the directives migrate. Declarations introduce names with bodies; instantiation creates values; context accessors use the descent operator (@@:). All three are syntactically distinct from attributes and stay unchanged.

Pre-1.0, hard cut. After Wave 1 lands, @@persist is a parse error. Test corpus migrates via mechanical sed pass.

2. Grammar: function-call (C#/Java/Kotlin)

Three forms:

@@[name]                       # boolean attribute
@@[name(arg)]                  # single positional arg
@@[name(arg=value)]            # named arg
@@[name(a, b, c=value)]        # multiple args, mixed positional and named

The function-call shape is the C#/Java/Kotlin annotation convention. Args inside the parens follow Frame’s existing expression grammar (lists, identifiers, strings, etc.).

Migration of existing @@persist(domain=[a, b]) is purely mechanical — wrap in @@[ ].

Single-value attributes use positional args, not the colon form: @@[target("java")], not @@[target: java]. Earlier discussion considered a colon-keyed form; rejected for consistency with the function-call grammar.

3. Multiple attributes: separate brackets

@@[deprecated("use tick2 instead")]
@@[target("java")]
tick()

Each attribute is its own @@[...] unit. No comma-separated form (@@[a, b]).

Java, Kotlin, and Rust all use the separate-brackets shape only. C# allows both; we follow the more-restrictive convention because:

  • Multi-line is the common case for multiple attributes (with args).
  • Comma-separated form has parser ambiguity with arg-list commas.
  • Parser is simpler — each @@[...] is one unit.

4. First attribute: @@[persist] (Wave 1)

@@persist was the directive that motivated this RFC. Wave 1 migrates exactly that one directive, proving the lexer + parser + AST-attach machinery without introducing new attribute kinds.

Migration

The three forms of @@persist map directly:

@@persist                              →  @@[persist]
@@persist(domain=[a, b])               →  @@[persist(domain=[a, b])]
@@persist(exclude=[c])                 →  @@[persist(exclude=[c])]

@@target python_3 migrates in Wave 2 (along with new attachment positions for conditional emit), since it touches a different attachment site and benefits from waiting until the per-target filter codegen is in place.

Wave plan

Wave 1 — @@[persist] migration

Scope:

  • Lexer: tokenize @@[, ], attribute identifier, arg-list parens.
  • Parser: parse attribute → attach to system declaration.
  • AST: attribute list field on SystemAst.
  • Validator codes:
    • E800: unknown attribute name.
    • E801: attribute disallowed at this attachment position.
    • E802: required arg missing / unknown arg name.
  • Codegen: existing persist codegen reads the new attribute representation; output is byte-identical to today.
  • Test corpus: sed -i 's/^@@persist/@@[persist]/' tests/**/*.f*, with the args form handled by a second regex.
  • Matrix verify: 17/17 clean post-migration.

Out of scope for Wave 1:

  • New attachment positions (only system-decl).
  • New attribute kinds.
  • @@target migration.

Effort estimate: ~1 day.

Wave 2 — @@[target(...)] + new attachment positions

Scope:

  • Migrate @@target python_3@@[target("python_3")] at module scope.
  • New attribute attachment positions:
    • Domain field
    • Interface method
    • Handler
  • Per-target conditional emit: @@[target("java")] on a domain field / method / handler causes that item to emit only when compiling for Java. Codegen gains a filter pass that walks the AST before emit.
  • Validator: extend E801 to per-position rules. New code if needed for target value validation.

Out of scope for Wave 2:

  • New attribute kinds beyond target.
  • Module-level @@[...] (TBD whether @@[target("java")] at top of file restricts the whole file or is illegal).

Effort estimate: ~2-3 days.

Wave 3 — Additional attributes

Scope (illustrative; pick what’s actually needed):

  • @@[deprecated("reason")] — emits warning on use.
  • @@[private] — replaces @@system private syntax for visibility.
  • @@[suppress(W501)] — per-item warning suppression.
  • User-defined attributes (TBD whether Frame supports user extension at all).

Each future attribute is a small additive change once the machinery from waves 1+2 is in place.

Open questions for later waves

Wave 2:

  • Module-level @@[target("java")] — does it restrict the whole file, or is it disallowed (only per-item)? I lean disallow at module scope; one file = one target stays in @@target.
  • Multi-target restriction: @@[target("java", "kotlin")] (positional list) vs separate attributes. Probably positional list — JVM family targets are one logical group.
  • Codegen filter: AST pass before emit, or per-backend-rs conditional? AST pass is simpler.

Wave 3:

  • User-defined attributes? (Out of scope for now; @@[unknown] errors with E800.)
  • Attribute composition / inheritance from imported systems?

Implementation status

  • 2026-05-01 — RFC drafted; design locked for Wave 1.
  • 2026-05-01 — Wave 1 (@@[persist]) shipped: pragma scanner + segmenter recognize the bracketed form, persist test corpus migrated, matrix 17/17 clean. Bare @@persist rejected with E803.
  • 2026-05-01 — Wave 2 Phase 1 (@@[target("lang")] at module scope) shipped: ~4,800 test fixtures + ~30 docs migrated, doc validator accepts both old and new form during transition. Bare @@target rejected with E804.
  • 2026-05-01 — Wave 2 Phase 2 (per-item attachment positions) shipped: lexer emits Token::Attribute { name, args } in structural mode for @@[name(args?)]; parser attaches to InterfaceMethod and HandlerAst; codegen filter pass (filter_by_target_attribute) prunes items whose target attributes don’t match the current target before emit. New matrix test 89 (Python + JavaScript) verifies per-item conditional emit. Matrix 17/17 clean (3,544 passing).
  • 2026-05-01 — Wave 2 Phase 2 follow-up: domain fields support @@[name(args?)] (both same-line and own-line forms) via the domain byte-walker. Validator now fires:
    • E800 on unknown attribute names at any per-item position
    • E801 on @@[persist] (module-scope-only) attached to a per-item position
    • E802 on @@[target(...)] with missing arg or unsupported language name Filter pass moved to run after validation so attribute-shape errors surface even on items the filter would otherwise prune.

References

  • docs/rfcs/frc-future.md@@ system context token foundation.
  • docs/rfcs/rfc-0012.md — RFC-0012 “Persist Stress Testing” (the wave-8 rollout that surfaced the persist directive complexity motivating this RFC).
  • C# attribute grammar: [Attribute(arg = value)] — https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/reflection-and-attributes/
  • Kotlin annotation grammar: @Foo(arg = value).
  • Rust attribute grammar: #[foo], #[foo(arg)].