RFC-0016.1: @@[no_persist] — Transient Domain Fields

  • Status: Shipped (2026-05-15). Attribute parsed, validated, and honored by codegen on all 17 backends — save skips the tagged field, load leaves it at its domain: default. Fixture 100_no_persist_field exercises the contract across the matrix. See CHANGELOG.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:

  1. 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.
  2. 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 a domain: 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 its domain: 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’s domain: default, and load overwrites 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