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 generated save method and the parameter type of the generated load method.
  • @@[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:

  1. 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.
  2. 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.
  3. Cross-instance references. A reference to a live @@system the 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 (Java Serializable), [NonSerialized] (.NET binary serialization), #[serde(skip)] (Rust), json:"-" (Go), @JsonIgnore (Jackson), @JsonKey(ignore: true) (Dart json_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’s CodingKeys enum, PHP’s __sleep() (returns an array of property names), JSON.stringify(obj, ["a","b"]) (the replacer-array form), marshmallow class Meta: fields = (...), Elixir @derive {Jason.Encoder, only: [...]}, Rails serializable_hash(only: [...]), .NET [DataContract] + [DataMember]. — This is @@[persist_fields([...])].
  • Per-type exclude list: Jackson @JsonIgnoreProperties({...}), marshmallow Meta.exclude, Elixir except: [...]. — Frame doesn’t add this; it would duplicate the per-field @@[no_persist].
  • Programmatic hook__getstate__/__setstate__ (Python pickle), marshal_dump/marshal_load (Ruby), ISerializable.GetObjectData (.NET), custom encode(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 their domain: 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

  1. 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.
  2. @@[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].
  3. 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