Pet architecture for v0.4.0
| Document version | Date | Summary |
|---|---|---|
| v1 | 2026-08-02 | Initial architecture for the local persistence foundation release |
| v2 | 2026-08-03 | Accept the architecture for the local persistence foundation release |
| v3 | 2026-08-03 | Include the wall-clock stamp and the bounded absence transition |
| v4 | 2026-08-03 | Record the implemented loader, its identification order, and its error surface |
| v5 | 2026-08-03 | Add the storage dispatch calls and the implemented write strategy |
| v6 | 2026-08-03 | Record the executed embedded evidence for the storage contract |
| v7 | 2026-08-03 | Record the shared app persistence module and the implemented application options |
| v8 | 2026-08-04 | Record 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 contractpet_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
petprefix - 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
TODOcomment carrying the technical reason, following the existing convention intools/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
- keep the format, the version identity, and the validation inside the Core
- keep paths, streams, replacement, and platform errors inside the storage boundary
- encode field by field so the payload does not depend on how a compiler lays out a structure
- validate a complete candidate before any part of it becomes active state
- build the candidate in Core-owned automatic storage so a rejection cannot touch the destination
- keep the payload fixed in length so a reader knows what it needs before it reads
- carry a version number from the first release so a later format can be added rather than guessed
- keep the hosted implementation a build selection, exactly as the desktop asset reader already is
- keep the save decision in the host, because when to persist is a product surface question
- 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.hallocation 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 clockThe 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.cThe 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
- the Core depends on no app, frontend, component, platform, or storage target
components/storagedepends on nothing but the freestanding standard headers it needs for sizes- the hosted implementation depends on standard C file operations and is a build selection
- apps depend on the public Core API and on the storage contract, in that direction only
- no component, app, or frontend re-implements the format or reinterprets a payload
- 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 directoryA 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
| Environment | Evidence target | Required for release |
|---|---|---|
| Linux x86-64 desktop, GCC | Build tested and runtime tested, including a file round trip | Yes |
| Linux x86-64 desktop, Clang | Build tested and runtime tested, including a file round trip | Yes |
| Core round trip through the memory double | Build tested and runtime tested | Yes |
T-Display classic variant, ESP-IDF v6.0.2 for esp32 | Cross-compile tested, storage contract compiled with no implementation | Yes |
| Selected classic T-Display hardware | Runtime unverified, no storage implementation exists | No |
| MSVC on Windows | Portable by design, unverified, including the replacement fallback | No |
| Clang and Ninja on Windows | Portable by design, unverified | No |
| Any non-volatile storage target | Not implemented, unverified | No |
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/storageas a component target reached through the shared build interfaces recorded in ADR-010PET_STORAGE_STDIOas 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-compositions7.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:
| Condition | Absence |
|---|---|
either stamp is PET_WALL_CLOCK_UNKNOWN | zero |
loaded_at is not greater than saved_at | zero |
| otherwise | loaded_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.
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 4 | magic | the bytes P, E, T, S |
| 4 | 2 | format version | unsigned 16-bit, value 1 |
| 6 | 8 | elapsed time | unsigned 64-bit milliseconds |
| 14 | 8 | update count | unsigned 64-bit |
| 22 | 8 | saved at | unsigned 64-bit UTC milliseconds since the Unix epoch, 0 when unknown |
| 30 | 1 | activity | unsigned 8-bit PetActivity value |
| 31 | 1 | expression | unsigned 8-bit PetExpression value |
| 32 | 8 | activity since | unsigned 64-bit milliseconds |
| 40 | 8 | expression since | unsigned 64-bit milliseconds |
| 48 | 1 | name length | unsigned 8-bit, 1 to PET_NAME_CAPACITY - 1 |
| 49 | 31 | name bytes | the name, with every byte after the length set to zero |
| 80 | 4 | integrity check | unsigned 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.
| Outcome | Meaning | Typical host response |
|---|---|---|
PET_STORAGE_OK | the whole payload was delivered or written | continue |
PET_STORAGE_MISSING | the name does not exist | report a startup failure when a load was requested |
PET_STORAGE_TOO_LARGE | the buffer was filled and bytes remained | reject, because a valid payload is exactly 84 bytes |
PET_STORAGE_FAILED | the platform refused or failed | report 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.0is 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
PETSand a CRC-32 integrity check - the payload carries a host-supplied UTC save stamp, and
PET_WALL_CLOCK_UNKNOWNis 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_MSis 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 reportsPET_ERR_INVALID_ARGUMENT - a load reports the absence it applied through an optional output, because nothing outside the Core can derive it
- the
initialisedmarker 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_stdiois a build selection underPET_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_readandpet_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
| Decision | Owner |
|---|---|
| Non-volatile storage implementation, its layout, and its wear behaviour | Later hardware runtime release |
| Automatic save and load policy, including where a save belongs on a packaged system | Later product frontend release |
| What a return after a long absence should feel like, beyond the world’s clock having moved | Later behaviour release |
| Whether randomness becomes authoritative state and therefore part of the format | Later 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
| Requirement | v0.4.0 tactic | Evidence |
|---|---|---|
| SPEC-FR-037 | Core save and load operations with a hosted storage implementation | round-trip tests and a process-level round trip |
| SPEC-FR-038 | complete validation of a candidate before assignment | rejection tests asserting an untouched destination |
| SPEC-FR-039 | magic and format version in the first six bytes, read before the length rule of any version, with an unsupported version reported as its own status | decoder tests over unknown magic and version, including a version rejected ahead of a wrong length |
| SPEC-FR-015 | a bounded absence added to simulation time, with no update replayed | absence tests and a process-level round trip over a chosen wall clock |
| SPEC-FR-016 | a backwards, wrong, or absurd clock reduces to a clamped absence | absence tests over backwards and extreme stamps |
| SPEC-FR-040 | the payload carries a name, simulation time, and one save stamp | format table and review |
| SPEC-FR-042 | no device, path, display, or host identity is encoded | format table and review |
| SPEC-NFR-001 | field-by-field encoding and no platform header in the Core | Core symbol and include boundary checks |
| SPEC-NFR-002 | fixed 84-byte payload, caller-owned buffers, no allocation | allocation probe and its coverage check |
| SPEC-NFR-003 | storage is a replaceable contract with a memory double | tests that need no filesystem |
| SPEC-NFR-004 | every malformed payload class is rejected safely | rejection tests |
| SPEC-NFR-006 | format in the Core, platform access in storage, policy in the host | dependency and include boundary checks |
| SPEC-NFR-007 | a stated promise for format version 1 | section 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_MSclamped 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.
| Claim | Evidence |
|---|---|
| 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 unchanged | 20 save tests and 36 load tests, of which 20 reject a malformed payload with the destination untouched |
| a world survives a process boundary unchanged | pet_headless_round_trip, which saves and loads through a real file and asserts a chosen absence |
| save and load allocate nothing | the allocation probe reports 0 allocations, and its coverage check exercises 11 public Core operations |
| the Core names no file or allocation dependency | the symbol scan over libpet_core.a in both compositions and both toolchains |
| the new component holds its boundaries | 12 include boundaries over 268 sources and 12 project sources against the ESP-IDF compile database |
| the embedded build carries the contract without an implementation | ESP-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 asked | the desktop save, load, missing, and damaged runs on the real Wayland and X11 drivers |
| the release claims no session continuity and no embedded storage | the 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
| Risk | Impact | Mitigation |
|---|---|---|
| the format freezes a shape that identity work cannot extend | a later release breaks compatibility in its first content change | keep 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 world | authoritative state becomes invalid and every later rule is unsound | build and validate a candidate before assignment, and assert an untouched destination in every rejection test |
| structure copying creeps into the encoder for convenience | padding and endianness reach the file and portability is lost quietly | encode field by field and review the encoder against the format table |
| the temporary file and replacement path behaves differently on an unverified platform | a save is lost on a platform the project never ran | keep the fallback minimal, mark it with a TODO carrying the reason, and claim nothing for Windows |
| storage grows format knowledge to be helpful | two components decide what a payload means | keep the contract byte-transparent and keep truncation detection in the Core |
| the release drifts into session continuity work | the reference app gains a policy the real product will have to unpick | keep persistence operator-driven and record the gap as a limitation |
| the embedded contract without an implementation is read as embedded support | a support claim becomes false | state the gap in the support matrix and carry it as a limitation beside LIM-013 |
| the integrity check is read as tamper protection | a security property is claimed that does not exist | state in the architecture and the evidence that the check detects damage only |
| a wrong host clock produces an absence the user never had | the world’s clock moves for a reason the user cannot see | clamp every absence, accept a backwards clock as zero, and never let a stamp reject a payload |
| the absence is read as session continuity | the release claims a product behaviour it does not have | keep 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.