SFEP-0023
Capsule-Defined Decorators
- Status
- Draft
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect
- Tracking
- #1557
Capsule-Defined Decorators
Status: proposal (design gate)
Author: compiler architecture
Related: docs/proposals/0008-effect-validation.md,
docs/proposals/0004-check-architecture.md,
docs/proposals/0020-compiler-decomposition.md §7 (stdlib dogfooding),
docs/proposals/0003-tooling.md (deprecated-api lint).
1. Goal
Let capsules define, export, and import decorators as ordinary library
functions, and use that mechanism to move the two compiler-built-in decorators
(@logExecution, @trace) out of the compiler + always-linked runtime and
into the sfn/log capsule — without breaking self-hosting and without
weakening end-to-end effect enforcement.
2. Current state (verified)
@logExecution / @trace are compiler built-ins, matched by literal string
in three passes:
| Pass | Location | Behavior |
|---|---|---|
| Effect inference (parse-time) | compiler/src/decorator_semantics.sfn:19-21 (infer_effects) |
name == "trace"/"logExecution"/"logexecution" → force ![io] into the signature |
| Effect checking | compiler/src/effect_checker.sfn:387-398 (analyze_routine) |
same string match → treat io as satisfied |
| Helper-use marking | compiler/src/llvm/effects.sfn:473-481 |
mark runtime_log_execution_fn used |
| Lowering | compiler/src/llvm/lowering/emission.sfn:473-517 |
emit emit_runtime_call("runtime_log_execution_fn", [fn_name]) at function entry |
The lowering target resolves through the always-linked runtime:
- helper registry row:
compiler/src/llvm/runtime_helpers.sfn:673-675(target: "runtime_log_execution_fn",symbol: "sfn_log_execution", ABI(i8*) -> i8*,effects: ["io"]). - prelude alias:
runtime/prelude.sfn:71(let runtime_log_execution_fn = sfn_log_execution;). - body:
runtime/sfn/io.sfn:447(sfn_log_execution(value: * u8) -> * u8 ![io]) — prints[info] <fn-name>and returns the value unchanged.
The decorator therefore receives only the function name string today. It
does not see args, return value, or timing. The @trace name lowers to the
identical hook (no distinct behavior).
Independently, capsules/sfn/log/src/mod.sfn is a real library capsule
(Logger, log_info/warn/error/debug, info/warn/error/debug, timed,
timed_end) with no connection to the decorator. capsule.toml declares
required = ["io", "clock"].
The AST already models decorators with arguments:
compiler/src/ast.sfn:209-217 (Decorator { name, arguments: DecoratorArgument[] }).
Parsing lives in compiler/src/parser/declarations.sfn:124-217 and is applied to
functions, struct methods, structs, enums, interfaces.
Critical self-hosting fact (verified): the compiler and runtime sources use
no decorators (grep for @log*/@trace in compiler/src and runtime
returns only string-match logic, no usage sites). So the dogfooding migration
never has to route the compiler’s own build through a capsule-resolved decorator.
This removes the hardest self-hosting risk up front.
There is already a clean precedent for the cross-module effect plumbing we need:
ImportSymbolTable / ImportedFunctionSignature
(compiler/src/effect_imports.sfn:44-123) carries (name, effects) for every
imported function, localized per-program with alias rewrite
(localize_import_symbols_for_program). The effect checker already looks up
imported call targets there. Decorator-implied effects can ride the same
table instead of a hardcoded string list.
3. Constraints
- Self-hosting invariant.
make compilemust pass at every step. The seed binary still string-matches@logExecution/@traceand still emits theruntime_log_execution_fncall, so any change to the meaning of those names must keep the old shapes alive until a post-flip seed is pinned. - Three pillars unchanged. Decorators are a library/convenience mechanism, not a fourth pillar. No new keyword if a marker can do the job.
- No unenforced safety claims. Decorator-implied effects must be enforced
(not just inferred-and-forgotten). A decorator carrying
![io]must force its target to satisfyioend-to-end, or we do not ship it. - Boring syntax wins / libraries over keywords. A decorator is just a
function you import and apply with
@. No bespoke decorator-declaration grammar if an attribute marker suffices. - Opt-in linking. A capsule is dependency-resolved; the runtime helper is always linked. A capsule-resolved decorator must only link when its capsule is an actual dependency, and must produce a clean diagnostic when it is not.
4. Design
4.1 What a decorator is (the contract)
A decorator is an ordinary exported function, marked as decorator-eligible. The
“boring” framing: @foo on a function means “instrument foo_target with the
imported function foo.” We deliberately pick the simplest contract that
covers the current behavior and the sfn/log library shape, and leave the
richer wrapping contract as an explicit open question (§9).
Two contract tiers, shipped in order:
Tier 1 — entry hook (matches today’s behavior, ships first). A decorator is a function called at the decorated function’s entry, receiving the decorator’s own arguments followed by a descriptor of the call site. The descriptor in Tier 1 = the function name (string); decorator arguments precede it (resolved decision §9.5). Signature shape:
// in capsules/sfn/log/src/mod.sfn@decoratorfn logExecution(args: string[], fn_name: string) -> void ![io] { info(fn_name); // reuses the library's own info()}@decorator is a compiler-recognized marker attribute on a function
declaration, not a new keyword (it reuses the existing decorator/attribute
grammar — @decorator parses today as a Decorator { name: "decorator" } on
the function, and the compiler interprets that marker). This honors “libraries
over keywords”: no grammar change, the marker is just a recognized name.
The contract for Tier 1:
- Receives: the decorated function’s name as
string. - Returns:
void(the hook is fire-and-forget at entry). - Effects: whatever the decorator body declares (
![io]here). These propagate to the decorated function (§4.3).
This is intentionally narrower than a general wrapper. It reproduces
@logExecution exactly and unblocks the dogfooding. The signature is validated
((string[], string) -> void arity/shape — args then function-name descriptor)
so “decorator” is enforced, not just parsed.
Tier 2 — wrapper (post-dogfood, gated behind §9 resolution). A decorator that observes args/return/timing needs a wrapping contract (the function value
- a way to invoke it). That requires closures-with-capture and/or a reflective call ABI that the runtime does not yet expose. Tier 2 is explicitly out of scope for the dogfooding migration and tracked separately so we do not ship a half-built wrapper.
4.2 Resolution: @foo → an imported symbol
Today @logExecution is a free-floating name. Under this design a
capsule-defined decorator must be imported into scope exactly like any other
symbol, then applied:
import { logExecution } from "log";
@logExecutionfn handler() ![io] { ... }Resolution algorithm (new pass, runs after import resolution, before effect inference finalization):
- For each function/method, walk its
decorators: Decorator[]. - For each decorator name, look it up in the program’s localized import table
(
localize_import_symbols_for_programalready produces this), restricted to symbols whose definition carries the@decoratormarker. - Resolve to a
ResolvedDecorator { local_name, capsule, export_name, effects, tier }record stored alongside the AST node (a new sidecar list, mirroring howImportSymbolTableis threaded — no change to theDecoratorstruct layout, which is seed-layout-sensitive perdecorator_semantics.sfn:6-10). - A decorator name that resolves to a non-
@decoratorsymbol →E07xx“foois imported but not a decorator.” A decorator name that resolves to nothing and is not a recognized built-in →E07xx“unknown decoratorfoo(did you forget to import it?).”
Capsule resolver interaction. capsule_resolver.sfn already resolves
import … from "log" to the capsule’s exported symbols and their signatures
(this is how log_info’s ![io] already reaches the effect checker via the
import table). The decorator pass needs one extra bit per exported function:
is it @decorator-marked? That bit is carried in the same artifact metadata
that already carries each export’s effects (the .sfn-asm /
ImportedFunctionSignature channel). Concretely: extend the staged export
record with an is_decorator: boolean (and the validated arity/return shape) so
the resolver can populate ResolvedDecorator without re-parsing the capsule.
Built-in fallback during migration. The compiler keeps a tiny built-in
decorator registry containing exactly logExecution/trace so that source
which has not imported them still works until the deprecation completes (§4.5).
This registry is the seam we delete at the end.
4.3 Effect inference: principled, not hardcoded
The current behavior synthetically injects io for two names. Replace with:
a decorator contributes its own declared effects to the decorated function’s
required-effect set, exactly like a call to that function would.
- After resolution (§4.2), each
ResolvedDecoratorcarries the decorator function’seffects(from the import table — already populated for every exported function). infer_effects(parse-time,decorator_semantics.sfn) can no longer know effects, because parse-time has no import table. So move decorator-implied effect inference out of the parser and into the effect-checking stage, where the import table exists.infer_effectsstops special-casing names; it becomes a no-op for capsule decorators (it may keep injecting for the built-in-fallback names during migration only).- In
effect_checker.analyze_routine(and the function/method analogues), replace thename == "trace" || …block (effect_checker.sfn:386-398) with: for each resolved decorator on the routine, union itseffectsintodeclared-vs-requiredreconciliation. The diagnostic for a missing effect becomes the existingimported \` →E0402path (effect_imports.sfn:13-15`) — a decorator is just an implicit call, so it reuses the imported-effect diagnostic family rather than inventing one.
This is the key correctness move: decorator effects are now enforced
transitively through the same machinery that enforces imported-function
effects. There is no separate hardcoded io injection to drift out of sync.
Reconciliation with effect-validation.md / check-architecture.md: both docs
describe a “decorator-implied effects” gate as a TODO. This design is that
gate’s implementation — it makes decorator effects a function of the decorator’s
signature, satisfying the docs’ requirement that the gate not be name-hardcoded.
Update both docs to point at this proposal as the realization.
4.4 Lowering
Today: emit emit_runtime_call("runtime_log_execution_fn", [fn_name]).
New: when a decorator resolves to a capsule symbol, emit a normal call to that
imported function at function entry, passing the descriptor (Tier 1: the
function-name string constant — the exact getelementptr operand the current
code already builds at emission.sfn:499-508). The call target is the imported
function’s mangled symbol, resolved the same way any imported call is lowered —
no runtime-helper registry row, no always-linked dependency.
- Helper-use marking (
llvm/effects.sfn:473-481): remove theruntime_log_execution_fnspecial-case for capsule-resolved decorators. The capsule function is reached as an ordinary import, so the existing import-drivendeclareemission handles its forward declaration. - The string-constant + entry-call scaffolding in
emission.sfn:451-520is reused almost verbatim; only the call target changes from the fixed helper name to the resolved decorator symbol.
Linking / opt-in. Because the decorator lowers to a call into an imported
capsule, the capsule must be a real dependency. The resolution pass (§4.2)
already errors if @logExecution is used without import { logExecution } from "log". There is no path where we emit a call to a symbol whose capsule was not
linked, because resolution fails first. The diagnostic (“unknown decorator —
import it from log”) is the missing-dependency story.
Built-in fallback lowering. Until the deprecation completes, a
@logExecution that was not imported (legacy code) still lowers to the old
runtime_log_execution_fn helper. This is the seam removed in the final stage.
4.5 Dogfooding migration: @logExecution/@trace → sfn/log
End state:
logExecution(and a distincttrace) live incapsules/sfn/log/src/mod.sfnmarked@decorator, bodies built on the library’s owninfo/Logger.- The compiler no longer string-matches the names.
sfn_log_execution(runtime body), its helper-registry row, and theruntime_log_execution_fnprelude alias are deleted.
Path (ordered, each step self-hosts — see §5/§7):
- Ship the generic mechanism (resolution + effect-from-signature + capsule-call lowering) alongside the existing built-in fallback. New behavior is additive; old names still work via fallback. The compiler builds with the old seed because the old seed only sees old shapes (the compiler source uses no decorators).
- Add
@decorator-markedlogExecution/tracetosfn/log. The library is not yet wired into anything; this is pure addition. - Switch the built-in fallback to a deprecation: using
@logExecutionwithout importing it fromlogemits adeprecated-apilint (the lint surface already steersprint.*→sfn/log, pertooling.md:268), pointing users atimport { logExecution } from "log". Behavior unchanged; only a warning added. - Cut a seed that contains the generic mechanism.
/pin-seed. Now the compiler-under-build can rely on capsule-resolved decorators. - Delete the built-in string-match registry, the
runtime_log_execution_fnprelude alias (runtime/prelude.sfn:71), the helper-registry row (runtime_helpers.sfn:673-675), and thesfn_log_executionbody (runtime/sfn/io.sfn:447). The string-match blocks indecorator_semantics.sfn,effect_checker.sfn,llvm/effects.sfn, and the fixed-helper branch inemission.sfngo away. After this,@logExecutiononly works viaimport … from "log".
Seed-gating the runtime deletion. The pinned seed (pre-step-4) still emits
runtime_log_execution_fn references for any legacy @logExecution it compiles.
So the runtime body/alias/row cannot be deleted until the seed used by
make compile is the step-4 seed. This mirrors the @runtime magic-namespace
seed-gating documented at runtime/prelude.sfn:46-51 and tracked in
the Runtime Migration table in docs/status.md. Step 5 is therefore blocked on the step-4 seed pin
and carries the seed-blocker label until then. Add a docs/status.md Runtime Migration row
for sfn_log_execution so the deletion is tracked with the other seed-gated
runtime symbols.
4.6 Self-hosting impact
- The compiler/runtime sources use no decorators, so steps 1–5 never route the compiler’s own build through a capsule decorator. Self-hosting is decoupled from the feature’s correctness for the compiler binary itself.
- The only self-hosting risk is the
Decoratorstruct layout note (decorator_semantics.sfn:6-10): released seeds may treatDecoratoras opaque. The design does not change theDecoratorstruct (it adds a sidecarResolvedDecorator[]threaded separately, likeImportSymbolTable), so this hazard is avoided. - Steps that touch
runtime/prelude.sfn/runtime/sfn/io.sfnare guarded by the seed pin (step 4) before deletion (step 5).
5. Migration plan (each step a valid self-hosting compiler)
| Step | What | Self-host check |
|---|---|---|
| A | Carry is_decorator + validated shape in the export/import metadata channel; add ResolvedDecorator sidecar + resolution pass (no behavior change yet — built-in fallback still primary) |
make compile (old seed sees old shapes; new metadata is additive) |
| B | Move decorator-implied effects from infer_effects (parser) to the effect checker, sourcing effects from ResolvedDecorator; keep built-in names as a fallback effect source |
make check (effect enforcement unchanged for existing tests) |
| C | Capsule-resolved lowering: @imported_decorator lowers to a normal call into the capsule symbol; built-in @logExecution (un-imported) still uses the helper |
make check + new e2e: import-and-apply a user decorator from a fixture capsule |
| D | Add @decorator logExecution/trace to sfn/log; add deprecation lint for un-imported built-in use |
make check |
| E | Cut seed + /pin-seed |
seed contains A–D |
| F | Delete built-in string-match registry + runtime_log_execution_fn alias/row/body |
make clean-build && make check against the step-E seed |
6. Files affected (by pipeline stage)
Parser / AST
compiler/src/ast.sfn— no struct change toDecorator; possibly a helper to carry the@decoratormarker recognition. (Verify noDecoratorlayout edit.)compiler/src/parser/declarations.sfn:863,950,1276— stop callinginfer_effectsfor decorator-implied effects at parse time (step B); the parser still records decorator names.compiler/src/decorator_semantics.sfn—infer_effectsdrops thetrace/logExecutionstring match (kept only as fallback through migration, deleted in step F).
Resolution / capsule
- New module (e.g.
compiler/src/decorator_resolver.sfn) — theResolvedDecoratorsidecar + resolution pass (mirrorseffect_imports.sfn). compiler/src/capsule_resolver.sfn— carryis_decorator+ shape in resolved exports.compiler/src/effect_imports.sfn— extendImportedFunctionSignature(or a parallel decorator table) with the decorator marker bit; reuselocalize_import_symbols_for_program.
Type / effect checking
compiler/src/effect_checker.sfn:372-398(and the fn/method analogues) — replace the hardcodedioinjection with per-ResolvedDecoratoreffect union routed through theimported \` /E0402` diagnostic.- Diagnostic codes: new
E07xxfor “not a decorator” / “unknown decorator.”
Lowering
compiler/src/llvm/lowering/emission.sfn:451-520— branch: capsule-resolved decorator → call the imported symbol; built-in fallback → existing helper.compiler/src/llvm/effects.sfn:464-484— only markruntime_log_execution_fnfor the built-in fallback; capsule decorators use ordinary import declares.compiler/src/llvm/runtime_helpers.sfn:673-675— delete row in step F.
Runtime / stdlib
capsules/sfn/log/src/mod.sfn— add@decoratorlogExecution/trace.runtime/prelude.sfn:52,71— deletesfn_log_executionimport + alias (step F).runtime/sfn/io.sfn:447— deletesfn_log_executionbody (step F).docs/status.md(Runtime Migration table) — addsfn_log_executionseed-gated deletion row.
Tests
compiler/tests/unit/— resolution (import → resolve; unimported → error), effect-from-signature (decorator![io]forces targetio; missing → E0402).compiler/tests/e2e/—*_test.sfnthat compiles a fixture importing a user-defined@decoratorand asserts on its output (perno-bash-e2e.md).- Negative:
@logExecutionwithout import → deprecation lint (step D), then hard error (step F).
Docs
docs/status.md, a spec §N chapter (decorators),effect-validation.mdandcheck-architecture.md(point the decorator-implied-effects gate here),compiler-decomposition.md§7 (mark@logExecution/@tracedogfooded).
7. Staged issues (XS/S/M, dependency-ordered)
The unblocker is the resolution + effect-from-signature mechanism; the dogfooding is the payoff. Per decomposition discipline, the runtime deletion is bundled where it shares a seed gate, and the capsule-side decorator is bundled with the lowering consumer that exercises it.
- [M] Decorator resolution + capsule export metadata (steps A) —
is_decoratorbit through capsule_resolver + import table,ResolvedDecoratorsidecar, resolution pass,E07xxdiagnostics. Unit tests only. Blocks all. Unblocker. - [S] Decorator-implied effects from signature (step B) — move inference to
the effect checker, union
ResolvedDecoratoreffects via the imported-effect path. Depends on #1. - [M] Capsule-resolved decorator lowering +
sfn/logdecorators + e2e (steps C+D bundled) — lowering branch to call the imported symbol, add@decoratorlogExecution/tracetosfn/log, deprecation lint for un-imported built-in use, e2e fixture test. Bundled because the capsule decorator (capability consumer) and the lowering (capability) are exercised in one session; splitting forces a seed-cut between them for no benefit. Depends on #2. - [Seed task] Cut + pin seed (step E) — operational, after #3 merges. Carries the new mechanism into the pinned seed. Gates #5.
- [S] Delete built-in decorator string-match +
sfn_log_executionruntime (step F) — remove the string-match registry, prelude alias (runtime/prelude.sfn:71), helper row (runtime_helpers.sfn:673-675), and runtime body (runtime/sfn/io.sfn:447);docs/status.mdRuntime Migration row.seed-blockeruntil #4. Depends on #4.sfn_log_executionis Sailfin-defined (runtime/sfn/io.sfn) and reached only through the helper-registry emission, so this is a clean Sailfin-side deletion — the runtime is pure Sailfin, so there is no C body to retire.
8. Risks & mitigations
- Seed still emits the old helper. Mitigated by the explicit seed pin (#4)
gating the runtime deletion (#5);
seed-blockerlabel +docs/status.mdRuntime Migration row. Decoratorstruct layout / opaque-seed hazard. Mitigated by not editing theDecoratorstruct — all new data rides a sidecar likeImportSymbolTable.- Effect drift / unenforced claim. Mitigated by routing decorator effects
through the existing enforced imported-effect path (
E0402), not a parallel injector. A decorator carrying![io]whose target lacksiofails the build — that is the enforcement test in issue #2. @decoratormarker collides with a future user symbol nameddecorator. It is an attribute on a declaration, not a value namespace; low risk. If it becomes a concern, gate recognition on a reserved capsule/origin.- Two consumers of the mechanism (user convenience + dogfooding) tempt over- splitting. Mitigated by bundling #3.
9. Resolved decisions (design gate)
The design-gate review settled all five open questions:
- Tier 1 vs Tier 2 contract → Tier 1 only. Ship the entry-hook contract; defer the wrapping (return/timing exit-hook) contract to a post-dogfood issue once closures-with-capture / a call ABI land. Shipping a half-wrapper would violate “parsed but not enforced.”
- Marker vs keyword →
@decoratormarker attribute. No new keyword; reuse the existing decorator/attribute grammar (“libraries over keywords”). - Import strictness → require an import.
@foorequiresimport { foo } from "…"; no global decorator namespace. The built-in fallback for@logExecution/@traceis a temporary deprecation bridge only (deleted in step F). @tracevs@logExecution→ diverge now, within Tier 1 limits. They get distinct entry-hook bodies insfn/log:@logExecution→info(fn_name)([INFO] <fn>);@trace→ a distinct trace-level body (e.g. alogger("trace")entry line such as[TRACE] → entered <fn>). Full timing-style tracing (entry + exit) stays a Tier 2 follow-up — it needs the exit hook we are deferring in (1), so step D ships two entry-only bodies, not a timing wrapper. Step D therefore defines two bodies, not one.- Decorator arguments → wire now. Thread
DecoratorArgument[]into the Tier 1 call ahead of the function-name descriptor, so@trace("label")/@logExecution("label")work immediately. This enlarges issue #1 (the entry hook contract now carries(args, fn_name)and the lowering must marshal the parsed arguments).
Contract impact of (4) + (5)
The Tier 1 entry-hook signature becomes args-then-name, e.g.:
@decoratorfn logExecution(args: string[], fn_name: string) -> void ![io] { info(fn_name); // [INFO] <fn>}
@decoratorfn trace(args: string[], fn_name: string) -> void ![io] { let t = logger("trace"); log_info(t, "→ entered " + fn_name); // distinct trace-level entry line}(Exact arg-marshalling shape — string[] vs a typed descriptor — is an
implementation detail for issue #1; the contract is “decorator args precede the
function-name descriptor.”) Signature validation (§4.1) updates to
(string[], string) -> void arity/shape. Issue #1 absorbs the arg wiring;
issue #3 ships the two distinct bodies + the deprecation lint.