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#,@Fooin 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.
@@targetmigration.
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
targetvalue 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 privatesyntax 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@@persistrejected 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@@targetrejected 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 toInterfaceMethodandHandlerAst; codegen filter pass (filter_by_target_attribute) prunes items whosetargetattributes 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)].