RFC-0053: Faithful persistence — round-trip fidelity as the foundational contract

  • Status: Accepted — 2026-07-07
  • Author: Mark Truluck mark.truluck@cogiton.com
  • Created: 2026-07-06
  • Builds on: RFC-0012 (persist stress testing), RFC-0015 (restore bypasses construction), RFC-0016.1 (@@[no_persist]), RFC-0049 (one portable semantic, per-target mechanism)

Summary

This RFC establishes one foundational requirement for @@[persist] and nothing more: a saved system, restored, MUST reproduce its state faithfully, in every target language. Everything else a persistence feature eventually wants — a security posture for untrusted snapshots, a compile-time guarantee that unsupported programs are rejected before they run, coverage of pathological value shapes, an encoding framec owns end-to-end — is deliberately layered on top as a later requirement with its own decision, not baked into this foundation. The intent is to fix the base contract first (persistence that actually round-trips, uniformly) and add constraints deliberately, rather than specify a tower of coupled promises that collapses because the parts contradict each other or outrun what the compiler can deliver.

Motivation

@@[persist] promises that a saved snapshot restores the system’s state. Today that promise holds for scalars, lists, and dictionaries but breaks for a domain variable whose value is an instance of a user-defined type — and it breaks differently on each target. The same program that round-trips a counter will, with a one-line change to hold a small value type, either keep working, throw at save, or quietly restore a shape that has lost its type — depending only on which language you emitted. A feature that works under some circumstances, silently and inconsistently, is the single most surprising thing a user can hit, and it undermines trust in the whole feature.

The cause is precise. A snapshot in the common wire format (JSON) is a typeless document: it carries objects, arrays, strings, numbers, booleans, and null — never the identity of a user-defined type. Whether a restore can rebuild a typed value therefore depends entirely on where the type comes from at restore time. A statically typed target hands its deserializer the declared type as a schema and reconstructs transparently; a dynamically typed target hands its library nothing and gets back a bare map — so the strictest of them refuses to even save an unknown value and raises, while the more permissive ones save a best-effort shape and lose the type on the way back. The gap is a property of the format’s type erasure crossed with each library’s willingness to bridge it — not a fundamental limit of the language.

So the base problem to solve, before any other requirement, is uniformity of fidelity: a system’s state must survive a round trip intact on every target. That is what this RFC requires. What it does not yet require — and why — is spelled out in Layered requirements below, so the base contract stays deliverable.

The foundational requirement

The key words MUST, MUST NOT, and MAY are to be interpreted as described in RFC 2119.

Faithful round-trip. For any system carrying @@[persist], a save immediately followed by a restore into a fresh instance MUST reproduce the system’s persisted state — its domain: values (excepting those marked @@[no_persist]) and its live control state — such that all subsequent observations are indistinguishable from the original. The requirement covers any tree-shaped value with a reflectable field map: a value and the sub-values it owns, including instances of user-defined types. It holds identically across every target on which the program’s values are reconstructable — a program that round-trips on one such target round-trips on all of them, with no target quietly restoring a degraded or type-erased value in place of the original.

Two boundaries sit deliberately outside this floor. The pathological shapes a borrowed encoding cannot represent — reference cycles, shared object identity, types with no reflectable field map — are the coverage layer’s concern, excepted here rather than promised. And where a target cannot reconstruct a given program’s value at all — a user type on a target with no first-class type identity, e.g. Lua — that is a reachability limit surfaced by the diagnosability layer, not a silent erasure. Accordingly, a target “supports @@[persist]” for a program exactly when it can faithfully reconstruct that program’s values; a target may support persistence for one program (scalars, lists, dicts) and not another (a value type it has no way to reconstruct).

This requirement is the whole of the foundation. It is a fidelity requirement, not a safety or diagnosability requirement — those are separate layers. In particular, this RFC does not yet require any of the following, and a conforming foundational implementation MAY defer them:

  • the full behavior for snapshots from an untrusted source (the security layer) — beyond the minimal resolution floor that co-ships, noted below;
  • a compile-time guarantee that a program which cannot be persisted faithfully is rejected before it runs (the diagnosability layer);
  • faithful handling of value shapes a JSON encoding fundamentally cannot represent — reference cycles, shared identity, types with no reflectable field map (the coverage layer);
  • an encoding format framec defines itself rather than borrowing from each target’s serialization library (the owned-format layer).

One safety floor is not deferrable — it co-ships with the foundation. On a target that carries the type identity in the snapshot and resolves it on restore (the reflective route, below), that resolution is the very operation the security layer governs. A foundational implementation on such a target therefore MUST NOT resolve a snapshot-named type against the ambient global environment or imports; it resolves only against types the program itself defines — even before the full security layer (allocate-without-init, the identity-checked allow-set, the trust model) is specified. Put plainly: the full untrusted-snapshot posture is deferrable, but the minimal “do not resolve a blob-named type against ambient globals” floor is a co-requisite of the foundation wherever the type travels in the snapshot. Deferring it would ship an insecure deserializer, which no amount of later layering repairs for snapshots already trusted in the interim.

Each of the deferred requirements is real, to be added in its own section of a future revision or a follow-on RFC, with its own trade-off decided explicitly. Naming them here — and declining to specify them yet — is deliberate: it keeps the base contract honest about what it guarantees.

Approach

The foundational requirement constrains the outcome (a faithful round trip), not a single mechanism. Targets reach it by different routes, and the design does not lock one prematurely:

  • Targets whose serialization library reconstructs a declared type (the statically typed backends) already meet the requirement; the library rebuilds the value from the type it is handed. No new mechanism is needed beyond ensuring the declared type reaches the deserializer.
  • Targets whose library does not reconstruct have framec emit the reconstruction. The value’s type identity has to be available at restore — and, because a decoded snapshot is typeless, that identity is either fixed in the generated code (when the program declares the domain variable’s type) or travels in the snapshot (captured from the live value by the target’s runtime reflection on save). Which of these a backend uses is an implementation choice, not fixed here; a statically-typed-but-non-reconstructing target such as Dart already uses the first route (the type is fixed at codegen, and the generated restore rebuilds through it), and the mainstream reflective dynamic targets (Python, JavaScript, Ruby) are the natural home for the second.

Whatever the route, reconstruction MUST remain type-ignorant at the codegen level: framec emits one uniform mechanism, with no branch per user type. A uniform rule applied to every type (a single reflection pass, or one reviver convention) satisfies this; a match on specific type names does not.

Where the type travels in the snapshot, the round trip depends on telling a typed value apart from a plain container at restore. This disambiguation is a faithfulness obligation, not a loose detail: the mechanism MUST distinguish the two unambiguously, so that a plain user domain: value which happens to look like a tagged one — a dict carrying the reserved type marker as ordinary data — is never mis-restored as a typed instance, nor a typed value flattened to a container. The marker’s exact spelling and namespacing is an implementation detail (see Open questions); that the disambiguation is unambiguous is foundational.

The routes are not equally reachable on every target, and the foundation does not pretend otherwise:

  • The reflective dynamic targets (Python, JavaScript, Ruby; TypeScript is JavaScript at runtime) can capture a value’s type and fields and rebuild an instance — one shared design, expressed in each idiom.
  • A target whose “objects” are a base container plus a convention, with no first-class type identity (Lua, whose values are all tables), has no portable type to capture; the faithful form there is the native container, and a program that needs a reconstructed type is beyond what the target expresses.
  • A target with its own object-serialization facility (GDScript, via Godot’s reflection / resource machinery) reaches the requirement through that facility, within its constraints.

Reconciling these — including that not every persisting target even uses JSON (some emit a native serializer format) — is part of turning this approach into a per-target specification, and is left to the implementation work this RFC authorizes, not fixed as normative text here.

Layered requirements (to be added, not yet specified)

These are the requirements deliberately excluded from the foundation, each with the open decision that must be made when it is layered in. They are recorded so the sequence is explicit and none is forgotten.

  1. Untrusted-snapshot security posture. When reconstruction resolves a type that a snapshot named, a snapshot becomes able to influence what gets built. The preferred direction is closed-world reconstruction — resolve only against types the program itself defines, never imports or ambient globals, and rebuild without invoking user constructors — so a hostile snapshot’s blast radius is bounded and it cannot execute code. Open decisions before this is normative: how “the program’s own types” is enforced concretely per target (an explicit, identity-checked allow-set built from the program’s own type definitions — not a name lookup against a module namespace, which readmits imports), and whether the earlier “restore instantiates nothing a snapshot names” property is relaxed to this bounded form or preserved by other means.

  2. Compile-time diagnosability. Ideally a program that cannot be persisted faithfully on the chosen target is rejected at build time. But framec has no type system — a bare domain: variable carries only an initializer expression, not a declared type — so it cannot in general decide at compile time that a value is a non-reconstructable type without inspecting the initializer text, which is the kind of source-text oracle the compiler discipline forbids. Open decision: make the loud failure a deterministic first-save runtime error from the reflective save path (immediate and clear, but not build-time), or require an explicit type annotation on a class-valued persisted domain variable (which makes the check decidable at build time, at the cost of a small source obligation). This RFC does not pick one; it records that “faithful, or a clear failure” is the aim and that the timing of the failure is the layered decision.

  3. Coverage of pathological shapes. Reference cycles, shared object identity, and types with no reflectable field map (e.g. slot-based classes) cannot be round-tripped by a borrowed JSON library even with reflection. The foundation targets the common case (acyclic values with a reflectable field map); a later layer decides which of these to support and by what means (typically the owned format, below).

  4. framec-owned wire format. The reflection above is a language built-in, not a dependency, but the encoding is still each target’s library. Owning the format end-to-end would let framec represent what the borrowed libraries cannot (cycles, identity) and re-tighten the security story with strict load validation. Direction only; scope for a dedicated RFC.

Examples

A small value type held in a domain variable is expected to round-trip with no user boilerplate:

@@[persist(string)]
@@[save(save_state)]
@@[load(restore_state)]
@@system Body {
    interface:
        set_velocity(vx, vy)
        speed_x()
    machine:
        $Moving {
            set_velocity(vx, vy) { @@:self.velocity = Vec2(vx, vy) }
            speed_x()            { @@:(@@:self.velocity.x) }
        }
    domain:
        velocity = Vec2(0, 0)
}

After save_state() then restore_state(blob) into a fresh Body, @@:self.velocity is again a Vec2 with the saved x/y — the same observation on every target that supports @@[persist], not a Vec2 on some and a bare map on others.

Alternatives

  • Specify security, diagnosability, and coverage together in one contract (the shape this RFC started as). Rejected: the promises collide — a build-time rejection the type-system-free compiler cannot produce, a no-constructor security rule the shipped Dart path already breaks, and a faithfulness claim wider than any JSON library can honor. Layering them makes each deliverable and keeps the base contract true.

  • Reject non-native domain variables at compile time as the primary answer. Turns the silent runtime failure into a build-time one, but makes the feature honest rather than faithful and pushes the modelling burden onto the user. It is retained only as one option under the diagnosability layer, not as the base.

  • A code-executing serializer (store a class reference, re-import on load). Reconstructs anything, but by executing arbitrary code from the snapshot on load — the hazard the move to a typeless document removed. Rejected; it is target-locked and turns a snapshot into an execution vector.

  • Require user-supplied per-type encode/decode hooks as the baseline. Faithful and safe, but not the default experience — it puts boilerplate on the common case. Retained as a permitted override, not the base requirement.

Migration

Source-additive; no Frame source changes are required and existing persisted programs keep compiling. On the targets that gain framec-emitted reconstruction, a snapshot’s wire form changes on non-native values, so a snapshot written by a newer compiler is not guaranteed readable by an older runtime of the same program — an extension of the existing coupling between a snapshot and a program’s shape (topology is already validated on restore), not a new one. Requirements that change the security or format story carry their own migration notes when they are layered in.

Open questions

  • The security-layer enforcement of “the program’s own types” per target, and whether the older no-instantiation property is relaxed or preserved (layer 1).
  • The timing of the “clear failure”: first-save runtime error vs. a required annotation for build-time rejection (layer 2).
  • Which pathological shapes (cycles, shared identity, slot-based types) are in scope, and whether they force the owned format (layers 3–4).
  • The reserved, namespaced type-marker key for targets that carry the type in the snapshot (the spelling; that the typed-vs-plain-container disambiguation is unambiguous is already a foundational obligation, see Approach).
  • Reconciling the non-JSON persisting targets with a single cross-target story.

References

  • RFC-0012 — Persist stress testing (the @@[persist] surface this refines)
  • RFC-0015 — restore bypasses construction (the no-init restore the foundation relies on)
  • RFC-0016.1 — @@[no_persist] (the per-field opt-out that interacts with faithfulness)
  • RFC-0049 — one portable semantic, per-target mechanism (the framing this reuses)
  • Frame language reference
  • Glossary
  • CHANGELOG.md