Pet architecture for v0.4.0

Document versionDateSummary
v12026-08-02Initial architecture for the local persistence foundation release
v22026-08-03Accept the architecture for the local persistence foundation release
v32026-08-03Include the wall-clock stamp and the bounded absence transition
v42026-08-03Record the implemented loader, its identification order, and its error surface
v52026-08-03Add the storage dispatch calls and the implemented write strategy
v62026-08-03Record the executed embedded evidence for the storage contract
v72026-08-03Record the shared app persistence module and the implemented application options
v82026-08-04Record the release evidence and the claims it supports

Status: Accepted

Product version: v0.4.0

Milestone: V0 software companion foundation

Release: Local persistence foundation

General architecture: Pet architecture

Template: arc42

Versioning: Semantic Versioning 2.0.0

1. Introduction and goals

1.1 Purpose

Version v0.4.0 gives a world a way to leave the process and return unchanged.

The release implements the save and restore boundary that the general architecture already describes in sections 5.6, 6.3, and 8.6 but that no release has built. Pet Core gains the format, the encoding, and the validation. A storage boundary outside the Core gains platform access, and a desktop implementation over the standard C file operations proves it.

The release deliberately settles the format while the authoritative state is still one pet, four scalar fields, and a name. Identity, maturity, and hardware work should extend a decided format rather than invent one while they are also inventing their own content.

flowchart LR
    C[Pet Core]
    P[save payload bytes]
    S[storage contract]
    D[desktop stdio storage]
    A[host app]

    C --> P
    A --> C
    A --> S
    S --> D
    P --> A

1.2 Architecture goals

The release must prove that:

  • the Core owns the save format, its version identity, and its validation
  • the Core names no file, path, handle, stream, or platform interface
  • a payload is validated completely before any part of it reaches an active world
  • a rejected payload leaves the destination world byte for byte unchanged
  • save and restore perform no dynamic allocation and remain bounded
  • a round trip is deterministic and reproduces the original world exactly
  • time spent outside the process reaches the world as a bounded absence rather than as a replay
  • a host with no absolute time source loses nothing but the absence, and gains it by supplying a stamp rather than by changing the Core
  • storage is a separate boundary with a hosted implementation and no embedded implementation
  • the format is small enough and plain enough for a later embedded storage path
  • the compatibility promise for the first format version is stated rather than implied

1.3 Included capabilities

  • a Core save operation producing a complete payload into caller-owned storage
  • a Core load operation validating a payload before it activates
  • an explicit little-endian binary format with a magic value, a format version, and an integrity check
  • a host-supplied wall-clock stamp in the payload and a bounded absence applied by a load
  • components/storage, a frontend-neutral storage contract
  • pet_storage_stdio, the desktop implementation over standard C file operations
  • explicit save and load options on the desktop and headless applications
  • deterministic round-trip and rejection tests
  • allocation probe coverage over the new public operations
  • a recorded compatibility promise for format version 1
  • continued headless and desktop SDL verification

1.4 Excluded capabilities

  • automatic load at startup, automatic save at exit, and periodic autosave
  • any absence behaviour beyond advancing simulation time, including a reunion acknowledgement
  • a migration framework and support for reading a second format version
  • an embedded storage implementation, a flash layout, and non-volatile storage usage
  • save file discovery relative to an executable, a bundle, or an install prefix
  • multiple save slots, profiles, worlds, and pets
  • pet export, device transfer, networking, and synchronisation
  • identity, traits, memories, maturity, and formative events in the saved state
  • encryption, signing, and tamper resistance beyond an integrity check
  • Windows build and runtime verification

Excluded capabilities must not leave placeholder dependencies inside the Core.

1.5 Stakeholders

Developer

Needs a save path that can be tested without a filesystem and a storage boundary that a test double can replace.

Future firmware developer

Depends on the format being small, fixed in length, and free of hosted assumptions, so that a later non-volatile storage implementation supplies bytes rather than reinterprets the format.

Future identity developer

Depends on the format version and the validation rules being decided now, so that adding a lasting characteristic is a format revision rather than a redesign.

Product reviewer

Uses this release as evidence that authoritative state survives a process, without believing that the desktop app has become a product with session continuity.

2. Architecture constraints

2.1 Product constraints

  • the release belongs to the V0 milestone
  • one world contains one active pet
  • a save carries authoritative state, not presentation state
  • restoring must not punish the user for the time the process was not running
  • the desktop app remains a reference application rather than the product surface
  • storage location policy is a host concern

2.2 Technical constraints

  • Pet Core remains portable ISO C99
  • Pet Core does not include stdio.h, filesystem, network, or platform headers
  • Pet Core performs no dynamic allocation, so payload storage is caller owned
  • the payload is encoded field by field and never by copying structure storage
  • the encoding is fixed and independent of endianness, padding, alignment, and enumeration width
  • the storage contract owns no format knowledge and no product rule
  • the hosted storage implementation is a build selection, not a component requirement
  • desktop SDL and headless builds remain supported compositions

2.3 Documentation and language constraints

  • code and developer documentation use British English
  • C domain identifiers use the pet prefix
  • version architecture documents follow the policy in arch README
  • the save format and its compatibility promise receive an ADR when the release commits to them
  • code that names an unverifiable platform path records it as a TODO comment carrying the technical reason, following the existing convention in tools/verify/symbols.py

2.4 Verification constraints

  • Linux desktop GCC and Clang verification continues to be required
  • round-trip and rejection evidence is required before release completion
  • the allocation probe must exercise the new public operations
  • storage implementations must be replaceable by a test double so tests need no filesystem
  • Windows evidence is not produced by this release and no Windows claim changes

3. System scope and context

3.1 Product context

The release adds no new pet behaviour and no new visible interaction. A user still meets the pet through the desktop app, and that app still starts a new world unless the operator passes a load option.

flowchart TD
    U[User]
    D[Desktop app]
    H[Headless app]
    C[Pet Core]
    F[Save file]

    U --> D
    D --> C
    H --> C
    D --> F
    H --> F

3.2 Technical context

Pet Core is the only owner of what a payload means. It decides the byte layout, the version number, the integrity check, and every rule that decides whether a candidate world may become the active world.

The storage boundary is the only owner of where bytes live. It opens, reads, writes, and replaces whatever its platform provides, and it never inspects the bytes it carries.

The host app is the only place where the two meet. It asks the Core for a payload, hands it to storage, and later hands storage bytes back to the Core. It owns the location policy, the failure reporting, and the decision of when either operation happens.

3.3 External interfaces

The release interfaces are:

  • the public Core save and load operations
  • the storage contract consumed by hosted apps and implemented by the desktop selection
  • explicit save and load options on the desktop and headless applications
  • the save payload itself, which is a file format that outlives the process that wrote it

No network, cloud, account, sensor, or device transfer interface is present.

4. Solution strategy

4.1 Functional core and imperative shell

Serialisation is a pure transformation, so it belongs to the Core. Storage is an effect, so it belongs to the shell.

flowchart LR
    W[PetWorld]
    E[encode]
    B[payload bytes]
    S[storage]
    P[platform]

    W --> E
    E --> B
    B --> S
    S --> P

The Core sees a buffer and a length. The shell sees a path and a platform error. Neither sees the other’s concern, which is what lets a device with no filesystem reuse the format unchanged.

4.2 Main strategies

  1. keep the format, the version identity, and the validation inside the Core
  2. keep paths, streams, replacement, and platform errors inside the storage boundary
  3. encode field by field so the payload does not depend on how a compiler lays out a structure
  4. validate a complete candidate before any part of it becomes active state
  5. build the candidate in Core-owned automatic storage so a rejection cannot touch the destination
  6. keep the payload fixed in length so a reader knows what it needs before it reads
  7. carry a version number from the first release so a later format can be added rather than guessed
  8. keep the hosted implementation a build selection, exactly as the desktop asset reader already is
  9. keep the save decision in the host, because when to persist is a product surface question
  10. record what the release cannot verify rather than writing platform code it cannot run

4.3 Technology strategy

The format is a plain binary encoding written by hand. No serialisation library, schema compiler, or text format is introduced, because the state is small, the reader must run on a microcontroller, and every dependency here would become a compatibility commitment.

The hosted storage implementation uses the standard C file operations only. It introduces no POSIX, Win32, or platform package dependency, which keeps it inside the portability contract the project already holds.

5. Building block view

5.1 Level 1 building blocks

flowchart TD
    subgraph Core["Pet Core"]
        SV[save encoder]
        LD[load decoder and validator]
        WD[world state]
    end

    subgraph Storage["components/storage"]
        SC[storage contract]
        SD[desktop stdio implementation]
        SM[memory double for tests]
    end

    subgraph Apps["apps"]
        AD[desktop app]
        AH[headless app]
    end

    AD --> SC
    AH --> SC
    AD --> SV
    AD --> LD
    AH --> SV
    AH --> LD
    SV --> WD
    LD --> WD
    SC --> SD
    SC --> SM

5.2 Pet Core

The Core gains two public operations and the encoding they share.

#define PET_SAVE_FORMAT_VERSION 1U
#define PET_SAVE_CAPACITY       84U
 
PetStatus pet_world_save(const PetWorld *world, PetWallClockMs saved_at, uint8_t *out_bytes,
                         size_t capacity, size_t *out_length);
 
PetStatus pet_world_load(PetWorld *world, PetWallClockMs loaded_at, const uint8_t *bytes,
                         size_t length, PetTimeMs *out_absence_ms);

out_absence_ms may be null and reports the absence the load applied. Nothing else can derive it. A host supplies loaded_at but never sees saved_at, because the payload’s meaning belongs to the Core and dependency rule 5 forbids an app from reading the format itself. The later behaviour work that decides what a return should feel like therefore has a number to act on without either widening the boundary or duplicating the clamping rule outside the Core.

PetWallClockMs is an unsigned 64-bit count of milliseconds since the Unix epoch in UTC, and PET_WALL_CLOCK_UNKNOWN is the value a host passes when it has no absolute time. The Core still reads no clock. It receives the value the same way it already receives an elapsed duration, which keeps the rule ADR-002 set.

pet_world_save validates the source world, encodes it together with saved_at into caller-owned storage, and reports the length it wrote. A capacity below PET_SAVE_CAPACITY is rejected before anything is written.

pet_world_load validates the payload completely, builds a candidate world in Core-owned automatic storage, applies the bounded absence described in section 8.1 to that candidate, checks it with the same rules pet_world_validate applies, and only then assigns it to the destination in one step. Every rejection returns before that assignment, which is what makes the destination byte for byte untouched rather than merely mostly untouched.

The Core must not:

  • include stdio.h, stdlib.h allocation functions, or any platform header
  • name a file, a path, a handle, a stream, or a storage error
  • read a clock, whether to stamp a payload or to measure an absence
  • retain a pointer to a caller buffer after an operation returns
  • accept a payload whose length is not exactly the length its format version defines

The header lives in include/pet/save.h and is included by include/pet/pet.h beside the existing public headers.

5.3 Storage contract

components/storage owns the boundary between a payload and a platform. It follows the shape components/assets already established for readers, so a reader of one is a reader of both.

typedef enum PetStorageStatus {
    PET_STORAGE_OK = 0,
    PET_STORAGE_MISSING,
    PET_STORAGE_TOO_LARGE,
    PET_STORAGE_FAILED,
} PetStorageStatus;
 
typedef struct PetStorage {
    PetStorageStatus (*read)(void *context, const char *name, uint8_t *buffer, size_t capacity,
                             size_t *length);
    PetStorageStatus (*write)(void *context, const char *name, const uint8_t *bytes, size_t length);
    void *context;
} PetStorage;
 
PetStorageStatus pet_storage_read(const PetStorage *storage, const char *name, uint8_t *buffer,
                                  size_t capacity, size_t *length);
PetStorageStatus pet_storage_write(const PetStorage *storage, const char *name,
                                   const uint8_t *bytes, size_t length);
 
PetStorage pet_storage_stdio(void);

The contract reports why a payload could not be delivered rather than how its platform failed, so a host decides between starting a new world and reporting an error without knowing what backs the storage. context is passed back unchanged and is never dereferenced by the contract itself. Names arrive already resolved, so an implementation joins nothing and interprets no separator.

A caller reaches an implementation through pet_storage_read and pet_storage_write in src/storage.c rather than through the structure. The two calls validate the storage, the function pointer, the name, the buffer, and the length, and return PET_STORAGE_FAILED without reaching an implementation when one of them is unusable. That gives every implementation the same argument contract instead of each repeating it, and it gives the component a translation unit that a cross build compiles when no implementation is selected. An empty payload is rejected there too, because a reader cannot tell an empty file from a payload that was never written.

The component owns no format knowledge. It never inspects a byte it carries, which is what lets the same implementation carry a later format version without being changed.

5.4 Desktop storage implementation

components/storage/src/desktop/storage_stdio.c implements the contract over fopen, fread, fwrite, rename, and remove. It is selected by PET_STORAGE_STDIO, which defaults on for a hosted build and off for a cross build, exactly as PET_ASSETS_STDIO_READER does.

A write is not performed in place. The implementation writes a temporary file beside the target and then replaces the target with it, so a failure during writing cannot destroy a payload that was already valid. Replacement is attempted with rename first, because that is a single atomic operation on the platforms this release verifies. When rename fails because the target exists, which is the documented behaviour on Windows rather than on POSIX, the implementation removes the target and renames again. That fallback is not atomic and is not verified, so it carries a TODO comment recording the technical reason rather than a claim. ADR-014 records the decision and its alternatives.

The temporary file is created beside the target rather than in a system temporary directory, because a rename across filesystems is not a rename. Its name is the target name with .tmp appended, and it is removed only when the write itself created it. A name that leaves no room for that suffix is rejected, which is the one length limit in the component and belongs to this implementation rather than to the contract.

5.5 Test double

components/storage/tests/doubles/storage_memory.c implements the contract over a fixed buffer. It mirrors components/assets/tests/doubles/reader_memory.c.

Every test that is not specifically about the desktop implementation uses the double, so the Core round trip, the app behaviour, and the failure paths are verified without touching a filesystem. This also keeps the tests runnable in an environment that has no writable working directory.

5.6 Applications

Both hosted apps gain explicit options and no implicit behaviour:

--load PATH   load a world from PATH before the run starts
--save PATH   save the world to PATH after the run ends
--now MS      supply the wall clock in UTC milliseconds instead of reading the host clock

The desktop app reads its wall clock from the standard C time facility and passes the value in. The headless app passes PET_WALL_CLOCK_UNKNOWN unless --now is given, because a diagnostic run must stay reproducible and a gate step that read a real clock could not assert its own output. --now is what lets the gate exercise a chosen absence deterministically, including a backwards one.

Reading the clock stays in the app for the same reason a path does. It is a platform effect, and the Core is given the result rather than the source.

Neither app reads or writes a payload when its option is absent. There is no default path, no implied file name, and no discovery rule, so the release makes no claim about where a save belongs on a packaged system.

--load failure is a startup failure and is reported through the existing app diagnostic ranges in apps/include/pet_app/status.h. A missing file is a failure when the operator asked for a load, because silently starting a new world would hide the operator’s intent.

The desktop app keeps its current lifecycle otherwise. It does not save on quit, does not save on a timer, and does not preserve the pet between launches. That is a deliberate exclusion recorded in section 8.5 rather than an oversight.

The headless app gains argument parsing, which it does not have today. Its fixed scenario stays the same, so a load replaces the starting world and a save records the finished one. That gives the gate a complete process-level round trip that does not depend on SDL.

The load and save sequence itself is shared rather than written twice. apps/src/persistence.c builds pet::app_persistence behind apps/include/pet_app/persistence.h, which owns the payload buffer, the order storage and the Core are asked in, and nothing else:

PetAppLoad pet_app_load(PetWorld *world, const PetStorage *storage, const char *name,
                        PetWallClockMs loaded_at);
 
PetAppSave pet_app_save(const PetWorld *world, const PetStorage *storage, const char *name,
                        PetWallClockMs saved_at);

Both report which stage decided the result, PET_APP_PERSIST_STORAGE or PET_APP_PERSIST_CORE, together with the status that stage returned. They know no diagnostic code and reach no stream, so each app still owns its own codes and its own reporting exactly as apps/include/pet_app/status.h requires. The module links the Core and storage and nothing else, which keeps it usable by the firmware app when that gains persistence.

A payload that could not be carried falls in the app range, because reaching a platform is a host’s work. A payload the Core refused falls in the Core range, because what a payload means is the Core’s. Both apps therefore add an app subgroup for a load and a save and a Core subgroup for each, and a missing file exits with the app status while a damaged one exits with the Core status.

The buffer a load reads into is exactly PET_SAVE_CAPACITY bytes. A payload longer than the format this Core reads is reported as PET_STORAGE_TOO_LARGE rather than reaching the Core, because an app cannot size a buffer for a format version it does not know. The version report the loader makes therefore covers a payload of this length that names another version, not a longer one.

The desktop app reads its wall clock at the moment it needs it rather than once at startup, so a save stamps the moment the session ended and the next absence does not count the time that session spent running. --now replaces both readings, which is what lets a test assert an absence it chose.

A desktop session ended by a host control quit still saves when --save was supplied, because the option asks for a save after the run ends and a quit is how a live run ends. Nothing is written on a timer, on a frame, or on any other implicit event.

5.7 Proposed source layout

include/
    pet/
        save.h
 
src/
    save.c
 
components/
    storage/
        CMakeLists.txt
        include/
            pet_storage/
                storage.h
        src/
            storage.c
            desktop/
                storage_stdio.c
        tests/
            doubles/
                storage_memory.c
                storage_memory.h
            test_runner.c
            test_suites.h
            test_pet_storage_dispatch.c
            test_pet_storage_stdio.c
            test_pet_storage_contract.c
 
apps/
    CMakeLists.txt
    include/
        pet_app/
            persistence.h
    src/
        persistence.c
    tests/
        test_runner.c
        test_suites.h
        test_persistence.c
    headless/
        include/
            pet_app_headless/
                app.h
                options.h
                diagnostics.h
        src/
            main.c
            app.c
            options.c
            diagnostics.c
        tests/
            test_runner.c
            test_suites.h
            test_options.c
            test_diagnostics.c
            test_lifecycle.c
        test_round_trip.cmake
 
tests/
    test_pet_save.c
    test_pet_load.c

The headless app becomes a host with sources and tests of its own rather than one main.c, because its options and its diagnostic mapping have to be asserted without starting a process. It follows the layout the desktop app already uses. Its lifecycle takes the storage it should use rather than choosing one, so its tests drive a whole run through the memory double and reach no filesystem, while test_round_trip.cmake covers the real implementation across a process boundary.

Core test files follow the test_pet_<area>.c convention the existing suite already uses, and each one registers a suite in tests/test_suites.h and tests/test_runner.c. The encoder tests live in test_pet_save.c and the decoder, absence, and rejection tests belong to test_pet_load.c.

Files are added by implementation PBIs only when they have real responsibility. The layout records ownership rather than a requirement to create empty placeholders.

5.8 Dependency rules

flowchart TD
    AD[apps/desktop]
    AH[apps/headless]
    ST[components/storage]
    SD[stdio implementation]
    API[Public Pet API]
    CORE[Pet Core]
    LIBC[standard C file operations]

    AD --> API
    AH --> API
    AD --> ST
    AH --> ST
    ST --> SD
    SD --> LIBC
    API --> CORE
  1. the Core depends on no app, frontend, component, platform, or storage target
  2. components/storage depends on nothing but the freestanding standard headers it needs for sizes
  3. the hosted implementation depends on standard C file operations and is a build selection
  4. apps depend on the public Core API and on the storage contract, in that direction only
  5. no component, app, or frontend re-implements the format or reinterprets a payload
  6. the firmware app gains no storage dependency in this release

5.9 Composition model

The v0.2.0 app and frontend composition model is unchanged. Storage is neither an app nor a frontend, so it introduces no new composition dimension and no new registry entry.

components/storage is a component in the same sense as components/assets. A composition links it when its app uses it.

6. Runtime view

6.1 Save

sequenceDiagram
    participant App as Host app
    participant Core as Pet Core
    participant Store as Storage
    participant Plat as Platform

    App->>Core: pet_world_save with a caller buffer
    Core->>Core: validate world and encode fields
    Core-->>App: status and payload length
    App->>Store: write name and payload
    Store->>Plat: write temporary and replace target
    Plat-->>Store: platform result
    Store-->>App: storage status

The Core never learns whether the write succeeded, and storage never learns what the payload means.

6.2 Load

sequenceDiagram
    participant App as Host app
    participant Store as Storage
    participant Core as Pet Core

    App->>Store: read name into a caller buffer
    Store-->>App: storage status and length
    App->>Core: pet_world_load with the current wall clock, bytes, and length
    Core->>Core: check magic, version, length, and integrity
    Core->>Core: decode into a candidate world
    Core->>Core: apply the bounded absence to the candidate
    Core->>Core: validate the candidate
    Core-->>App: accepted or rejected

The destination world is written once, at the end of an accepted load. Every failure path returns before that write.

6.3 Rejection

flowchart TD
    B[candidate payload]
    H{long enough to hold the header}
    M{magic known}
    V1{format version supported}
    L{length exactly the format length}
    I{integrity check matches}
    F{fields within their domains}
    X[apply the bounded absence]
    V{candidate world valid}
    A[assign to destination]
    R[reject and leave destination untouched]

    B --> H
    H -- no --> R
    H -- yes --> M
    M -- no --> R
    M -- yes --> V1
    V1 -- no --> R
    V1 -- yes --> L
    L -- no --> R
    L -- yes --> I
    I -- no --> R
    I -- yes --> F
    F -- no --> R
    F -- yes --> X
    X --> V
    V -- no --> R
    V -- yes --> A

The order matters. Cheap structural checks run before field decoding, and the world invariant check runs last, so a payload that is merely damaged is rejected without reaching the rules that describe a meaningful world.

Identification comes before the length rule, and that ordering carries weight. The format length is a property of format version 1, so a longer payload written by a later version would be reported as a wrong length if the length were checked first, which is exactly the confusion the format version exists to prevent. The decoder therefore reads only enough bytes to hold the header, checks the magic, checks the version, and only then applies the length rule of the version it recognised. A payload too short to hold the header cannot be identified at all and is rejected as a damaged one.

The absence is applied before the invariant check rather than after it, so the world that is validated is the world that will be assigned. The absence itself is never a rejection reason, because it is derived from two clocks rather than read from the payload.

Every rejection except an unsupported format version reports PET_ERR_INVALID_ARGUMENT, because a payload is an argument rather than the state of the destination. PET_ERR_INVALID_STATE is unreachable from a load, since the destination is never read and never has to be valid.

6.4 Application flow

sequenceDiagram
    participant Op as Operator
    participant App as Host app
    participant Core as Pet Core

    Op->>App: run with optional load and save paths
    alt load requested
        App->>Core: pet_world_load
    else no load
        App->>Core: pet_world_init
    end
    App->>Core: update, action, and snapshot as usual
    opt save requested
        App->>Core: pet_world_save
    end

A load replaces initialisation rather than following it, so a restored world is never a new world that was then overwritten.

7. Deployment view

7.1 Logical outputs

The release produces or preserves:

Pet Core library, now including the save encoder and loader
Storage component library
Desktop stdio storage selection
Headless diagnostic executable with save and load options
SDL frontend library
Desktop application executable with save and load options
Firmware app foundation source
ESP-IDF build integration
Test executables
Development runtime asset directory

A save file is user data rather than a build output. The project ships no save file, no template world, and no default location.

7.2 Platform verification matrix

EnvironmentEvidence targetRequired for release
Linux x86-64 desktop, GCCBuild tested and runtime tested, including a file round tripYes
Linux x86-64 desktop, ClangBuild tested and runtime tested, including a file round tripYes
Core round trip through the memory doubleBuild tested and runtime testedYes
T-Display classic variant, ESP-IDF v6.0.2 for esp32Cross-compile tested, storage contract compiled with no implementationYes
Selected classic T-Display hardwareRuntime unverified, no storage implementation existsNo
MSVC on WindowsPortable by design, unverified, including the replacement fallbackNo
Clang and Ninja on WindowsPortable by design, unverifiedNo
Any non-volatile storage targetNot implemented, unverifiedNo

Hosted file evidence, embedded cross-compile evidence, and device runtime evidence remain separate claims. This release adds hosted file evidence only.

The absence transition is verified on the hosted side over supplied wall-clock values rather than over a real elapsed absence, which is what makes it testable at all. No claim is made about any device clock. A board that gains an RTC or a network time source needs no Core, format, or storage change to produce a real absence, and it needs its own runtime evidence before that is claimed.

7.3 Build and gate interface

The CMake and Python tooling boundary from earlier releases is unchanged. The release adds:

  • components/storage as a component target reached through the shared build interfaces recorded in ADR-010
  • PET_STORAGE_STDIO as a hosted build selection, defaulting off when cross-compiling
  • storage component tests and Core round-trip tests registered with CTest
  • a process-level round trip through the headless app as a gate step

Daily and maintainer commands are unchanged:

./pet.py build
./pet.py test
./pet.py verify
./gate.py audit --all-compositions

7.4 Deployment constraints

  • no daemon is installed
  • no network port is opened
  • no account is required
  • application data is written only when an operator supplies a path
  • no save location is chosen on the user’s behalf
  • no save file is created during a build
  • production packaging remains deferred

8. Crosscutting concepts

8.1 Time

The Core continues to receive explicit elapsed deltas and reads no clock. A wall-clock value is one more explicit input, never a clock the Core reaches for.

A payload carries simulation time, which is elapsed_time_ms, update_count, and the two dwell stamps, and it carries one wall-clock value, saved_at. The two are different clocks and are never compared with each other.

A load turns the pair of wall-clock values into an absence and applies it to the candidate world:

ConditionAbsence
either stamp is PET_WALL_CLOCK_UNKNOWNzero
loaded_at is not greater than saved_atzero
otherwiseloaded_at minus saved_at, clamped to PET_ABSENCE_MAX_MS

The absence is added to elapsed_time_ms. update_count is not changed, because no update ran. That is precisely the bounded summary transition that general architecture section 6.4 describes and that SPEC-FR-015 requires, and it is the whole of it. The world holds no state that decays, so a returning pet finds its dwell stamps stale, settles through the existing transition rules, and loses nothing. Absence costs the user nothing, which the non-punitive principle in the specification requires.

PET_ABSENCE_MAX_MS is seven days and is a separate constant from PET_TIME_DELTA_MAX_MS, which bounds a single update at one day. One is a runtime safety limit and the other is a product judgement about how long a pet keeps counting while nobody is there. ADR-012 records the decision, the value, and the alternatives.

A clock that moves backwards, a clock that is simply wrong, and an absurd stamp all reduce to a clamped absence rather than to a failure, which is what SPEC-FR-016 requires. A payload is never rejected because its stamp is implausible. A save that became unreadable after a machine’s clock was corrected would punish a user for something the user did not do.

An unknown stamp is a property of one call rather than a claim about a platform. A host without an absolute time source passes PET_WALL_CLOCK_UNKNOWN and gets a world that resumes exactly where it stopped. When that host gains a clock, whether through an added RTC, a network time source, or an operator, it passes a real value and gets the absence, and nothing in the format, the Core, or the storage boundary changes. The same firmware image may pass an unknown stamp on one boot and a real one on the next.

8.2 Randomness

The Core random source remains caller-owned and explicit, and no random state is part of the world today. When a later release makes randomness part of authoritative state, its stream position becomes a format revision under the same rules as any other field.

8.3 Save format

The format is PETS, format version 1. Every multi-byte field is little-endian and is written byte by byte. No structure is copied into the payload, so padding, alignment, enumeration width, and host endianness cannot reach the file.

OffsetSizeFieldEncoding
04magicthe bytes P, E, T, S
42format versionunsigned 16-bit, value 1
68elapsed timeunsigned 64-bit milliseconds
148update countunsigned 64-bit
228saved atunsigned 64-bit UTC milliseconds since the Unix epoch, 0 when unknown
301activityunsigned 8-bit PetActivity value
311expressionunsigned 8-bit PetExpression value
328activity sinceunsigned 64-bit milliseconds
408expression sinceunsigned 64-bit milliseconds
481name lengthunsigned 8-bit, 1 to PET_NAME_CAPACITY - 1
4931name bytesthe name, with every byte after the length set to zero
804integrity checkunsigned 32-bit over bytes 0 to 79

The payload is 84 bytes and every payload of format version 1 is exactly that length. A fixed length is deliberate. A reader knows its buffer size at compile time, a truncated file is detected before it is parsed, and a device that stores the payload in a fixed region needs no length record of its own.

saved at is the only field that describes the world’s relationship to the outside rather than the world itself. It is written from the value the host supplied and is never derived, never corrected, and never compared against the simulation clock beside it.

The initialised marker is not encoded. It describes the storage rather than the pet, and a loader that copied it from a file would let a file assert that arbitrary bytes are a valid world. A load writes the marker itself, after the candidate has passed every check.

The integrity check is CRC-32 as used by IEEE 802.3, computed bitwise so the Core carries no lookup table. It detects damage. It is not a signature and provides no protection against a deliberately crafted payload, which section 8.6 states as a limit rather than leaving to assumption.

Decoding rejects a payload when it is too short to hold the header, the magic is wrong, the format version is not 1, the length is not 84, the integrity check does not match, the activity or expression is outside its declared enumeration, the name length is zero or above 31, a name byte inside the length is zero, a name byte after the length is not zero, or a dwell stamp or update count exceeds the elapsed time. The last group repeats the invariants pet_world_validate already holds, because a payload is an untrusted input rather than a trusted copy of memory.

saved at has no rejection rule of its own, because every 64-bit value is a possible answer from a host clock. A load that would overflow the world clock while adding the absence is the one exception, and it is rejected as an invalid candidate rather than as a damaged payload.

8.4 Compatibility promise

Format version 1 is the format this release writes and the only format it reads.

A payload whose version is not 1 is rejected rather than interpreted. That is the entire promise, and it is deliberately narrow. The project promises no forward compatibility, no downgrade path, and no automatic migration.

When a later release changes the state that must survive, it increments the format version. At that point the project decides whether the new reader also accepts version 1, and records that decision in its own version architecture. Because version 1 payloads are self-describing and length-checked, that decision stays available rather than being lost.

A save file written by a development build is not a supported artefact. The promise applies to released versions.

8.5 Session continuity

The desktop app does not save or load by itself. Persistence is available to it and is exercised only when an operator passes a path.

This is a scope decision rather than a technical limit. The desktop app is a reference application and is not expected to receive significant product work while the project’s attention moves to firmware. Deciding when a running product persists itself, what it does when a save is missing or damaged, and where the file belongs on a packaged system are questions that belong to the frontend that becomes the real product surface. Answering them now in the reference app would produce a policy that the actual product would then have to unpick.

The consequence is honest and recorded: after this release the pet still starts fresh every time a user launches the desktop app. LIM-017 carries it as a limitation with the future product frontend as its owner.

8.6 Trust and privacy

A save file contains a pet name, simulation time, and the wall-clock moment of the save. It contains no location, no account identity, and no activity history, which keeps it inside SPEC-FR-040.

The save stamp deserves naming rather than glossing. It is one timestamp that says when the operator last saved, it is written only when an operator asks for a save, and it is the minimum needed to know how long a pet was away. It is not a session log, and the format has no room to become one, because every payload is one fixed-length record that the next save replaces.

A payload is untrusted input. The integrity check detects accidental damage, and validation prevents a damaged or hostile payload from producing an invalid world. Neither prevents an operator from editing a payload and computing a new check, and this release makes no attempt to. A local file the user owns is the user’s to edit, which is consistent with the local-first ownership principle in the specification.

The format is not encrypted and not signed. Adding either would need a key, and a key would need a place to live, which is a platform question this release does not open.

8.7 Storage failure handling

Storage reports four outcomes and no platform detail. A host maps them to its own diagnostics.

OutcomeMeaningTypical host response
PET_STORAGE_OKthe whole payload was delivered or writtencontinue
PET_STORAGE_MISSINGthe name does not existreport a startup failure when a load was requested
PET_STORAGE_TOO_LARGEthe buffer was filled and bytes remainedreject, because a valid payload is exactly 84 bytes
PET_STORAGE_FAILEDthe platform refused or failedreport the failure

A short read is not a separate outcome. It returns PET_STORAGE_OK with a smaller length, and the Core rejects it because the length is not the format length. Keeping truncation detection in the Core rather than in storage means every storage implementation gets it for free.

8.8 Testing and verification

Unit tests remain owned by the component that owns the behaviour.

The Core owns round-trip and rejection tests. A round trip asserts that a saved and reloaded world compares byte for byte with the original, which is meaningful because a world holds no pointer. The rejection tests cover every branch in section 6.3, and each of them also asserts that the destination world is unchanged after the rejection.

The storage component owns contract tests over the memory double and implementation tests over the stdio selection, including replacement of an existing file and a failed write leaving the previous payload intact.

The apps own option parsing tests. The gate owns a process-level round trip: a headless run that saves, followed by a headless run that loads the same file and reports the same state.

The allocation probe must call both new operations, which tools/verify/probe.py enforces because it compares the public operations of the Core archive against the operations the probe object calls. The Core symbol scan must continue to find no file or allocation symbol in the Core archive.

8.9 Component embedded suitability

components/storage is selected for embedded build evidence. Its contract is a structure of function pointers over freestanding types, so the cross toolchain compiles it with no implementation selected.

That is now executed rather than expected. ESP-IDF v6.0.2 for esp32 compiles components/storage/src/storage.c and no implementation, the embedded boundary check holds over 12 project sources, and the firmware links without a storage dependency reaching the board or the app. LIM-015 records the gap the result leaves.

The embedded build therefore links the boundary and no implementation, which is the same honest gap that LIM-013 records for embedded assets. A firmware image that wants to persist a pet needs a non-volatile storage implementation of this contract, and that work belongs to a later release together with the hardware evidence that justifies it.

8.10 Language and documentation

Developer documentation uses British English. Format field names in this document match the identifiers used in the public headers.

8.11 Process diagnostics and exit status

The app diagnostic contract in apps/include/pet_app/status.h is unchanged. PetStatus gained PET_ERR_UNSUPPORTED_VERSION, so the desktop Core offset table and both status naming functions carry one more entry, and a payload from a later Pet is reported as unsupported rather than as damaged. Save and load failures are app-range diagnostics, because the operator asked for a platform operation and the platform or the file refused it. A Core rejection of a damaged payload is also reported in the app range, because the app is the component that chose to hand those bytes to the Core.

9. Architecture decisions

9.1 Accepted decisions

  • v0.4.0 is the local persistence foundation release
  • Pet Core owns the save format, the format version, and payload validation
  • the Core produces and consumes a byte payload in caller-owned storage and names no platform interface
  • the payload is an explicit little-endian binary encoding written field by field
  • format version 1 is 84 bytes, fixed in length, carrying magic PETS and a CRC-32 integrity check
  • the payload carries a host-supplied UTC save stamp, and PET_WALL_CLOCK_UNKNOWN is a valid value
  • a load adds a bounded absence to simulation time, replays no update, and never rejects a payload for an implausible stamp
  • PET_ABSENCE_MAX_MS is seven days and is separate from the one-day single-update bound
  • an unsupported format version reports PET_ERR_UNSUPPORTED_VERSION, which is a new public status value, and every other rejection reports PET_ERR_INVALID_ARGUMENT
  • a load reports the absence it applied through an optional output, because nothing outside the Core can derive it
  • the initialised marker is derived by the loader rather than encoded
  • a load builds and validates a candidate world before assigning it, so a rejection leaves the destination untouched
  • the compatibility promise for version 1 is that only version 1 is read, with no migration
  • storage is a separate frontend-neutral contract under components/storage
  • the hosted implementation pet_storage_stdio is a build selection under PET_STORAGE_STDIO, defaulting off for cross builds
  • a write is performed through a temporary file beside the target followed by replacement
  • a caller reaches storage through pet_storage_read and pet_storage_write, which own the argument validation every implementation would otherwise repeat
  • the storage contract gains no remove or exists operation, because no host in this release has a use for one
  • the desktop and headless apps persist only when an operator supplies a path, with no default location and no automatic session continuity
  • the desktop app remains a reference application, so session continuity belongs to the future product frontend
  • the embedded build compiles the storage contract with no implementation selected

The save format, its integrity check, and its compatibility promise receive an ADR when the format epic commits to them, because the alternatives are real and the decision outlives this release. The storage write and replacement strategy receives one for the same reason.

ADR-012 already records the wall-clock stamp, the bounded absence, and the value of PET_ABSENCE_MAX_MS, because that decision reversed a recorded exclusion and had to be justified before the format epic could start.

9.2 Open decisions

DecisionOwner
Non-volatile storage implementation, its layout, and its wear behaviourLater hardware runtime release
Automatic save and load policy, including where a save belongs on a packaged systemLater product frontend release
What a return after a long absence should feel like, beyond the world’s clock having movedLater behaviour release
Whether randomness becomes authoritative state and therefore part of the formatLater behaviour release

The release review closed none of these, because each one waits on a release that has the hardware, the product surface, or the behaviour to decide it. The first is carried by LIM-015, the second by LIM-008 and LIM-017, and the third by LIM-014. The fourth needs no limitation, because no random state exists to leave out of the format. The error surface for an unsupported format version was the one decision this release closed, and section 9.1 records that it reports PET_ERR_UNSUPPORTED_VERSION.

9.3 Architecture deviations

No deviation from the general architecture is accepted for this release.

Any later deviation must be recorded here with its reason, impact, and tracking PBI.

10. Quality requirements

10.1 Requirement mapping

Requirementv0.4.0 tacticEvidence
SPEC-FR-037Core save and load operations with a hosted storage implementationround-trip tests and a process-level round trip
SPEC-FR-038complete validation of a candidate before assignmentrejection tests asserting an untouched destination
SPEC-FR-039magic and format version in the first six bytes, read before the length rule of any version, with an unsupported version reported as its own statusdecoder tests over unknown magic and version, including a version rejected ahead of a wrong length
SPEC-FR-015a bounded absence added to simulation time, with no update replayedabsence tests and a process-level round trip over a chosen wall clock
SPEC-FR-016a backwards, wrong, or absurd clock reduces to a clamped absenceabsence tests over backwards and extreme stamps
SPEC-FR-040the payload carries a name, simulation time, and one save stampformat table and review
SPEC-FR-042no device, path, display, or host identity is encodedformat table and review
SPEC-NFR-001field-by-field encoding and no platform header in the CoreCore symbol and include boundary checks
SPEC-NFR-002fixed 84-byte payload, caller-owned buffers, no allocationallocation probe and its coverage check
SPEC-NFR-003storage is a replaceable contract with a memory doubletests that need no filesystem
SPEC-NFR-004every malformed payload class is rejected safelyrejection tests
SPEC-NFR-006format in the Core, platform access in storage, policy in the hostdependency and include boundary checks
SPEC-NFR-007a stated promise for format version 1section 8.4 and release evidence

10.2 Required test areas

  • deterministic round trip over the complete supported world state
  • round trip after actions and updates, not only after initialisation
  • an unknown stamp on either side leaving the restored world exactly as it was saved
  • an absence added to simulation time without changing the update count
  • a backwards or equal clock producing a zero absence rather than a failure
  • an absence beyond PET_ABSENCE_MAX_MS clamped to it
  • a candidate that would overflow the world clock rejected with the destination untouched
  • rejection of wrong magic, unknown version, wrong length, and failed integrity check
  • rejection of out-of-domain activity, expression, name length, and name padding
  • rejection of a payload whose dwell stamps or update count exceed its elapsed time
  • an unchanged destination world after every rejection
  • capacity and null argument handling on save
  • storage contract behaviour through the memory double
  • stdio implementation behaviour including replacement and a failed write
  • app option parsing and diagnostic mapping
  • a process-level round trip through the headless app
  • allocation probe coverage of both new operations
  • continued headless and desktop SDL gate coverage

10.3 Required development checks

The strict gate continues to apply to implementation PBIs.

The release gate must cover:

  • the existing strict gate for supported hosted compositions
  • the allocation probe and its coverage enforcement over the enlarged public surface
  • the Core symbol scan, which must still find no file or allocation dependency
  • include and embedded boundary checks over the new component
  • the audit gate before release completion

10.4 Release compliance

The release complies with this architecture when:

  • all v0.4.0 exit criteria are satisfied
  • all v0.4.0 PBIs are complete
  • support claims match executed evidence
  • no excluded capability is claimed
  • new limitations are recorded and linked to owners
  • this document reflects the implemented system

10.5 Release evidence

The release evidence is recorded in release v0.4.0 evidence, following the structure used by release v0.3.0 evidence.

ClaimEvidence
the supported compositions verify from a clean tree./gate.py audit --all-compositions passed over headless and desktop-sdl under GCC, Clang, ASan, and UBSan
a world survives a function boundary unchanged20 save tests and 36 load tests, of which 20 reject a malformed payload with the destination untouched
a world survives a process boundary unchangedpet_headless_round_trip, which saves and loads through a real file and asserts a chosen absence
save and load allocate nothingthe allocation probe reports 0 allocations, and its coverage check exercises 11 public Core operations
the Core names no file or allocation dependencythe symbol scan over libpet_core.a in both compositions and both toolchains
the new component holds its boundaries12 include boundaries over 268 sources and 12 project sources against the ESP-IDF compile database
the embedded build carries the contract without an implementationESP-IDF v6.0.2 for esp32 compiled libpet_storage.a with pet_storage_read and pet_storage_write and no implementation
the hosted applications persist only when askedthe desktop save, load, missing, and damaged runs on the real Wayland and X11 drivers
the release claims no session continuity and no embedded storagethe support status table, LIM-015, and LIM-017

Core line and branch coverage is 100% over src in both compositions. The manual desktop observation that LIM-011 carries is carried forward from v0.3.0, because nothing in this release touches rendering, the frontend, or input.

11. Risks and technical debt

RiskImpactMitigation
the format freezes a shape that identity work cannot extenda later release breaks compatibility in its first content changekeep the format minimal, versioned, and length-checked, and treat a revision as normal rather than exceptional
a partial validation lets a damaged payload reach an active worldauthoritative state becomes invalid and every later rule is unsoundbuild and validate a candidate before assignment, and assert an untouched destination in every rejection test
structure copying creeps into the encoder for conveniencepadding and endianness reach the file and portability is lost quietlyencode field by field and review the encoder against the format table
the temporary file and replacement path behaves differently on an unverified platforma save is lost on a platform the project never rankeep the fallback minimal, mark it with a TODO carrying the reason, and claim nothing for Windows
storage grows format knowledge to be helpfultwo components decide what a payload meanskeep the contract byte-transparent and keep truncation detection in the Core
the release drifts into session continuity workthe reference app gains a policy the real product will have to unpickkeep persistence operator-driven and record the gap as a limitation
the embedded contract without an implementation is read as embedded supporta support claim becomes falsestate the gap in the support matrix and carry it as a limitation beside LIM-013
the integrity check is read as tamper protectiona security property is claimed that does not existstate in the architecture and the evidence that the check detects damage only
a wrong host clock produces an absence the user never hadthe world’s clock moves for a reason the user cannot seeclamp every absence, accept a backwards clock as zero, and never let a stamp reject a payload
the absence is read as session continuitythe release claims a product behaviour it does not havekeep the transition to simulation time only and record the reunion question as an open decision

12. Glossary

12.1 Terms

Save payload

The complete byte sequence that represents one world. It is produced and consumed by the Core and carried unchanged by storage.

Format version

The number in the payload header that decides which rules apply to the remaining bytes.

Integrity check

The CRC-32 value over the preceding payload bytes. It detects damage and is not a signature.

Candidate world

The world a loader builds in its own storage while validating a payload. It becomes the destination world only after every check passes.

Storage contract

The function-pointer boundary that carries a payload to and from a platform without inspecting it.

Round trip

Saving a world and loading the payload back into a world that compares byte for byte with the original.

Absence

The wall-clock duration between the moment a payload was saved and the moment it was loaded, clamped to PET_ABSENCE_MAX_MS. It is zero when either side has no clock.

Session continuity

A product behaving as though it never stopped, by persisting and restoring without being asked. It is excluded from this release. Accounting for an absence is not session continuity, because the operator still decides when a save and a load happen.

12.2 References