RFC-0016: Selective Domain Persist — @@[persist_fields([...])]
- Status: Draft — deferred; not shipped in any release
- Author: Mark Truluck mark@frame-lang.org
- Created: 2026-05-04
- Builds on: RFC-0012 (the persist contract), RFC-0015 (factory-only construction)
Summary
A persisted system currently round-trips every
domain field through save and
load (except fields tagged @@[no_persist]).
For most state machines that’s correct — the machine is the data. For larger
machines, some fields don’t belong in the snapshot: a memoization cache that’s a
pure function of other fields, a resource handle (socket, file, DB connection)
that’s meaningless across a restore, or a reference to a live system the parent
doesn’t own. This RFC proposes a system-level inclusion list —
@@[persist_fields([...])] — naming the domain fields that round-trip;
everything else is treated as if tagged @@[no_persist]. It’s the complement of
@@[no_persist]: use the inclusion list when most fields should be skipped,
@@[no_persist] when only a few should.
It is deferred — see Why deferred. This RFC records the design for when it’s ready to land.
Background — what @@[persist(<type>)] is
A persisted system declares three system-level attributes (RFC-0012, refined by RFC-0015):
@@[persist(<type>)]
@@[save(<save_method_name>)]
@@[load(<load_method_name>)]
@@system Foo { ... }
@@[persist(<type>)]— the host-language type of the serialized blob: the return type of the generatedsavemethod and the parameter type of the generatedloadmethod.@@[save(<name>)]/@@[load(<name>)]— the user-facing names of those two methods.
<type> is not a fixed set. You write whatever your target language uses
for “a string or a byte buffer” — that’s the natural shape for a serialized
snapshot:
| Backend | Typical @@[persist(<type>)] |
Generated save returns |
|---|---|---|
| Python | bytes |
bytes |
| Rust | String |
String |
| Java / Kotlin / Swift / Dart / PHP | String |
String |
| C# / Go | string |
string |
| JavaScript / TypeScript | string |
string |
| Ruby / Lua | String / string |
string |
| C | char* |
char* |
| C++ | std::string |
std::string |
| GDScript | String (or PackedByteArray) |
String |
| Erlang | binary |
a binary |
framec does not interpret <type> — it emits it verbatim into the save/load
signatures. The actual encoding inside the blob (JSON text, a binary encoding,
etc.) is chosen per backend by framec’s serialization layer (serde + serde_json
in Rust, Jackson in the JVM languages, nlohmann/json in C++, pickle in
Python, cJSON in C, …). So <type> answers “what does the blob look like to my
code?” — a String you can write to a file, a bytes you can put on a socket
— not “what serialization format is inside it?”. If your serializer can’t
handle a field’s type, that’s the serializer’s error, surfaced at compile time;
framec itself stays type-agnostic.
The per-field @@[no_persist] attribute (RFC-0012)
is the existing way to exclude one field from the blob. After restore
a @@[no_persist] field holds its domain: default — typically null for a
resource handle, which the user reattaches explicitly.
Motivation
Three classes of domain field don’t belong in a snapshot:
- Derived fields. A cache or memoization slot that’s 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.
- Resource handles. An open socket, file handle, database connection, or thread pool. Serializing it yields a value meaningless across the restore boundary; the receiver re-opens from scratch regardless.
- Cross-instance references. A reference to a live
@@systemthe parent doesn’t own — passed in at construction. A fresh process restoring the blob has no way to wire that reference back; the user always handles it externally.
@@[no_persist] per-field already handles cases 2 and 3 when there are only one
or two such fields. The inclusion-list form earns its place when most of a
system’s domain is the persistable part and only a handful round-trips — listing
the few inclusions is shorter, and clearer about intent, than tagging the many
exclusions.
The proposed contract
A second system-level attribute, separate from @@[persist(<type>)]:
@@[persist_fields([<field>, <field>, ...])]
- Optional. If absent, every domain field round-trips (the current default).
- If present, it lists the domain fields that round-trip. A field not in the
list is treated exactly as if it carried
@@[no_persist].
Two attributes, two jobs: @@[persist(<type>)] says what shape the blob is;
@@[persist_fields([...])] says which domain fields go in it. (Folding the
selector into @@[persist]’s argument — @@[persist(domain=[...])] — was
considered and rejected; see Alternatives.)
Save
For each domain field, in declaration order:
- tagged
@@[no_persist]→ skip; - else,
@@[persist_fields([...])]is present and the field is not listed → skip; - else → serialize.
The state-machine bookkeeping is always included and is not selectable: current state, 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 isn’t subject to the selector. Selection applies only to domain fields.
Load
Restore the always-included bookkeeping from the blob. Then, for each domain field:
- if it was serialized → deserialize it;
- if it was skipped → leave it at its
domain:default value (the value a no-initialization allocation produces; RFC-0015’s contract).
@@[persist_fields([...])] is static — it doesn’t read runtime state.
Serialization remains type-driven, exactly as today.
Examples
A mission-control system whose snapshot should carry the mission plan and progress, but not the live telemetry link or the recomputable distance cache:
@@[persist(bytes)]
@@[save(save_state)]
@@[load(restore_state)]
@@[persist_fields([mission_id, site_list, site_index, findings, inspection_retries])]
@@system MissionController {
interface:
advance()
record_finding(note: str)
attach_link(link: TelemetryLink)
operations:
next_site(): str
machine:
$Surveying {
advance() {
self.site_index = self.site_index + 1
self.distance_cache = -1
}
record_finding(note: str) { self.findings = append(self.findings, note) }
attach_link(link: TelemetryLink) { self.link = link }
next_site(): str { @@:(self.site_list[self.site_index]) }
}
domain:
mission_id: str = ""
site_list: list = []
site_index: int = 0
findings: list = []
inspection_retries: int = 0
distance_cache: int = -1 // derived; recomputed on demand
link: TelemetryLink = null // resource handle; re-attached after restore
}
save_state() writes mission_id, site_list, site_index, findings, and
inspection_retries (plus the always-included bookkeeping). distance_cache and
link are absent — they’re not in @@[persist_fields]. Round-trip:
blob = ctrl.save_state() // serialize the mission plan + progress
// ... later, fresh process ...
ctrl2 = @@!MissionController() // no-initialization allocation
ctrl2.restore_state(blob) // mission_id / site_list / site_index /
// findings / inspection_retries restored;
// distance_cache = -1, link = null (defaults)
ctrl2.attach_link(open_link()) // user re-attaches the live link
The same selection with @@[no_persist] instead — equivalent here, because only
two fields are excluded:
@@[persist(bytes)]
@@[save(save_state)]
@@[load(restore_state)]
@@system MissionController {
// ... interface / machine as above ...
domain:
mission_id: str = ""
site_list: list = []
site_index: int = 0
findings: list = []
inspection_retries: int = 0
@@[no_persist]
distance_cache: int = -1
@@[no_persist]
link: TelemetryLink = null
}
Use whichever list is shorter.
Prior art
Selective field serialization is a well-trodden problem; the design space has two axes — opt-in vs opt-out and per-field annotation vs per-type list — and most mature ecosystems support more than one point in it:
- Opt-out per field (the common default):
transient(JavaSerializable),[NonSerialized](.NET binary serialization),#[serde(skip)](Rust),json:"-"(Go),@JsonIgnore(Jackson),@JsonKey(ignore: true)(Dartjson_serializable),Field(exclude=True)(pydantic). — Frame’s existing@@[no_persist]. - Opt-in via a per-type list of the members that are serialized: Swift
Codable’sCodingKeysenum, PHP’s__sleep()(returns an array of property names),JSON.stringify(obj, ["a","b"])(the replacer-array form), marshmallowclass Meta: fields = (...), Elixir@derive {Jason.Encoder, only: [...]}, Railsserializable_hash(only: [...]), .NET[DataContract]+[DataMember]. — This is@@[persist_fields([...])]. - Per-type exclude list: Jackson
@JsonIgnoreProperties({...}), marshmallowMeta.exclude, Elixirexcept: [...]. — Frame doesn’t add this; it would duplicate the per-field@@[no_persist]. - Programmatic hook —
__getstate__/__setstate__(Pythonpickle),marshal_dump/marshal_load(Ruby),ISerializable.GetObjectData(.NET), customencode(to:)/init(from:)(Swift),serialize(ar)(Boost),toJSON()(JS). Frame deliberately offers no such hook: the design principle is that no user code runs in the lifecycle plumbing. A user who needs computed serialization models it in the state machine, or splits the offending fields into a sibling system that isn’t persisted. That’s the same answer Frame gives everywhere — a stance, not a gap.
The default — persist everything — matches serde, Jackson, Swift Codable, and
Go’s exported-fields rule, and is right for a state-machine-as-data model.
@@[persist_fields] doesn’t change that default; it’s an opt-in narrowing.
Alternatives
Collapse the selector into @@[persist]’s argument —
@@[persist(domain=[...])] or @@[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
established. (An early sketch — and an old version of the language reference’s
“Field Filtering” table — used this form; it never shipped.)
Named persist views / groups — several distinct field-sets per system, chosen
at save time (à la Jackson’s @JsonView, class-transformer groups, pydantic’s
model_dump(include=...)). Out of scope: this RFC gives each system a single
persist set. If a real use case for multiple views appears, it’s a follow-on RFC.
Validation
New error codes (numbers provisional — assigned when the feature lands; the shipped persist / lifecycle codes are E814–E821):
@@[persist_fields]attached to a domain field rather than a system header — it’s system-level.@@[persist_fields]on a system without@@[persist]— the selector has nothing to select for.@@[persist_fields([...])]whose argument isn’t an array of identifiers, or that names something other than a declared domain field (typos, non-existent fields).- a field both tagged
@@[no_persist]and listed in@@[persist_fields]— contradictory; pick one.
Migration
Purely additive:
- Existing
@@[persist(<type>)]systems keep round-tripping every domain field. - Adding
@@[persist_fields([...])]narrows the round-trip set; fields outside the list reset to theirdomain:default on restore. Authors opt in. - Until this lands, partial-persist needs are met by tagging the excluded fields
@@[no_persist], or by moving cache / handle fields into a sibling system that isn’t persisted.
Why deferred
- Demand isn’t proven. The feature was sketched in one cookbook recipe and never shipped; no concrete use case has come in since RFC-0012 landed.
@@[no_persist]covers the common case. Per-field opt-out handles “skip this one handle” cleanly; the inclusion list only wins when most fields would need@@[no_persist].- One persist change per release. RFC-0015 already reshaped persist (factory-only construction); stacking another semantic change risks avoidable migration churn.
Reopen this RFC when a real use case appears.
References
- RFC-0012 — the persist contract; the per-field
@@[no_persist]this RFC complements - RFC-0015 — factory-only construction; the no-initialization allocation a restored instance starts from
- Frame language reference —
@@[persist], persistence - Glossary — the persist contract, save / load, no-initialization