RFC-0016.1: @@[no_persist] — Transient Domain Fields
- Status: Shipped (2026-05-15). Attribute parsed, validated, and honored by
codegen on all 17 backends —
saveskips the tagged field,loadleaves it at itsdomain:default. Fixture100_no_persist_fieldexercises the contract across the matrix. SeeCHANGELOG.md. - Author: Mark Truluck mark@frame-lang.org
- Created: 2026-05-12
- Builds on: RFC-0012 (the persist contract), RFC-0015 (factory-only construction)
- Companion to: RFC-0016 (the
@@[persist_fields([...])]inclusion-list form)
Summary
@@[no_persist] is a per-field attribute on a domain
field of a persisted system. It marks the field
transient: save skips it, and after a
load the field holds its domain: default — the same
value a no-initialization allocation
produces. It’s the opt-out half of Frame’s persist-selection design;
@@[persist_fields([...])] (RFC-0016, deferred) is the opt-in
half. This RFC defines @@[no_persist] on its own — it was introduced inside
RFC-0012’s persist-stress-testing document, where its definition was easy to
miss; here it stands alone.
Motivation
Two classes of domain field don’t belong in a serialized snapshot:
- Resource handles. An open socket, file handle, database connection, UI reference, or thread pool. Serializing it yields a value that’s meaningless across the restore boundary — a fresh process re-opens the resource from scratch regardless. Worse, attempting to serialize one is usually a type error in the target’s serializer (a socket is not JSON-encodable), surfaced at compile time, which would otherwise block persisting the whole system.
- Derived fields. A memoization slot or cache that is a pure function of other fields. Persisting it bloats the blob for no information gain — and risks the cache and its inputs disagreeing if the blob is ever hand-edited or migrated.
Without an opt-out, a persisted system must either round-trip such a field
(impossible for #1, wasteful for #2) or push it into a sibling system that isn’t
persisted (heavy-handed for one field). @@[no_persist] is the lightweight
answer: tag the field, and it’s simply absent from the snapshot.
The contract
The key words MUST, MUST NOT, SHOULD, MAY are to be interpreted as in RFC 2119.
Syntax
@@[no_persist] is an annotation (@@[...] form,
RFC-0013) written on its own line immediately above the domain-field
declaration it applies to:
domain:
counter: int = 0
@@[no_persist]
socket: Socket = null
It takes no arguments. It applies to exactly the field declared on the next line.
Where it’s valid
@@[no_persist]MUST appear on adomain:field of a system that carries@@[persist(<type>)]. On a non-persisted system the attribute has nothing to act on; anywhere other than a domain field it’s a misplaced attribute. Either is a validation error (E801).- It MUST NOT be combined, on the same field, with that field being listed
in
@@[persist_fields([...])](RFC-0016) — that’s a direct contradiction (“don’t persist this” vs. “persist exactly these”). A validator rejects it.
Scope: domain fields only
@@[no_persist] applies only to domain: fields. It does not apply to
the compartment bookkeeping — current
state, the state stack,
state-args, enter-args,
exit-args, state variables,
and the forwarded-event slot. That bookkeeping is the machine’s resumable
identity — there is no meaningful restore without it — so it is always persisted
and is not subject to the attribute. A author who wants a transient per-state
slot should lift it to a domain field and tag that @@[no_persist].
Save
When generating the save body, framec iterates the domain fields in declaration order and, for each:
- tagged
@@[no_persist]→ skip (not written to the blob); - (if
@@[persist_fields([...])]is present and the field isn’t listed → skip; RFC-0016); - else → serialize, using the target’s own serialization library — framec emits
the field’s declared type spelled the target way and lets serde / Jackson /
nlohmann/json/ pickle / cJSON / … do the encoding (the type-ignorant codegen contract; RFC-0012).
A @@[no_persist] field’s type is therefore never handed to the serializer —
so a field whose type the serializer can’t encode (a Socket, a FileHandle)
is fine to keep on a persisted system, as long as it’s tagged.
Load
When generating the load body, framec restores the always-included bookkeeping from the blob, then, for each domain field:
- if it was serialized → deserialize it from the blob;
- if it was skipped (
@@[no_persist], or excluded by@@[persist_fields]) → leave it at itsdomain:default — the value it was given by the no-initialization allocation the restored instance starts from (RFC-0015’s contract: a@@!Foo()instance holds every domain field’sdomain:default, andloadoverwrites only the fields the blob carries).
So after inst.load(blob), a @@[no_persist] socket: Socket = null field is
null; a @@[no_persist] cache: int = -1 field is -1. The host re-attaches
the live resource (or lets the cache recompute lazily) explicitly, after the
restore.
@@[no_persist] is static — it’s resolved at compile time and reads no
runtime state.
Examples
A connection-pool wrapper that should snapshot its retry/backoff state but not the live socket or the recomputable RTT estimate:
@@[persist(bytes)]
@@[save(save_state)]
@@[load(restore_state)]
@@system Link {
interface:
attach(s: Socket)
record_round_trip(ms: int)
note_failure()
operations:
rtt_estimate(): int
machine:
$Connected {
record_round_trip(ms: int) {
self.rtt_samples = append(self.rtt_samples, ms)
self.rtt_cache = -1 // invalidate; recompute on demand
}
note_failure() {
self.failures = self.failures + 1
self.backoff_ms = self.backoff_ms * 2
}
attach(s: Socket) { self.socket = s }
rtt_estimate(): int { @@:(self.rtt_cache) }
}
domain:
rtt_samples: list = []
failures: int = 0
backoff_ms: int = 100
@@[no_persist]
rtt_cache: int = -1 // derived from rtt_samples
@@[no_persist]
socket: Socket = null // live handle; re-attached after restore
}
save_state() writes rtt_samples, failures, backoff_ms, and the
always-included compartment bookkeeping — rtt_cache and socket are absent.
Round-trip:
blob = link.save_state()
// ... later, fresh process ...
link2 = @@!Link() // no-initialization allocation
link2.restore_state(blob) // rtt_samples / failures / backoff_ms restored;
// rtt_cache = -1, socket = null (domain: defaults)
link2.attach(open_socket()) // host re-wires the live resource
If only one or two fields need excluding, @@[no_persist] per field is the
right tool. If most of a large domain should be skipped, the inclusion list
@@[persist_fields([...])] (RFC-0016) is shorter — the two are complementary.
Alternatives
Fold the exclusion into @@[persist(...)]’s argument —
@@[persist(exclude=[...])]. Rejected: it overloads one attribute with two
unrelated concerns (the blob’s host type and the field set), against the “one
attribute, one responsibility” rule (RFC-0015). Per-field @@[no_persist] keeps
the exclusion next to the field it applies to.
@@[no_persist] on state variables / enter-args / state-args. Rejected: those
are compartment fields — the machine’s resumable identity. A “transient state
var” that vanishes across a restore would mean the restored machine isn’t the
machine that was saved. If a author genuinely needs a per-state scratch slot that
shouldn’t persist, the recommended pattern is to lift it to a domain field and
tag that. (Left as a deliberate, documented limitation; could be revisited if a
real use case appears.)
A programmatic serialization hook — __getstate__/__setstate__-style. Rejected
for the same reason RFC-0016 rejects it: no user code runs in the lifecycle
plumbing. A author who needs computed serialization models it in the state
machine, or splits the offending fields into a sibling system that isn’t
persisted. A stance, not a gap.
Validation
@@[no_persist]on a non-domain item, or on a system without@@[persist]— E801 (misplaced attribute).- A field both tagged
@@[no_persist]and named in@@[persist_fields([...])](RFC-0016) — contradictory; the validator rejects it.
(Other persist / lifecycle codes are E814–E821; see RFC-0012 and RFC-0015.)
Migration
Source-additive; no breaking change. Existing persisted systems that don’t use
@@[no_persist] round-trip every domain field exactly as before. Tagging a field
narrows the snapshot — that field resets to its domain: default on restore;
authors opt in.
References
- RFC-0012 — the persist contract; where
@@[no_persist]was first introduced - RFC-0015 — factory-only construction; the no-initialization
allocation a restored instance starts from, which supplies the
domain:defaults a skipped field keeps - RFC-0016 — the complementary
@@[persist_fields([...])]inclusion-list form (deferred) - Frame language reference —
@@[persist], persistence, field filtering - Glossary —
@@[no_persist], persist contract, save / load, no-initialization CHANGELOG.md