ADR-012: A payload carries a host-supplied wall-clock stamp and a load applies a bounded absence
Date: 2026-08-03
Status: Accepted
Context
SPEC-FR-015 requires the product to account for elapsed time between sessions without simulating
every missed update, and general architecture section 6.4
answers it with a bounded summary transition rather than a replay. Neither had an implementation,
because no release had persistence to carry the missing information across a process boundary.
The first draft of the v0.4.0 architecture excluded a wall-clock stamp from format version 1 and
left the behaviour to a later release. That exclusion was reversed during E24, because the format
is the part that cannot be changed cheaply later. A payload written without a stamp can never be told
how long its world was away, so deferring the field would have deferred the requirement behind a
format revision rather than behind a product decision.
Three constraints shape the answer. Pet Core reads no clock and takes values rather than providers,
which ADR-002 established. A host may have no
absolute time source at all, which is true of an ESP32 with neither a battery-backed clock nor a
network, and may equally gain one later without any Core change. A clock can move backwards, jump, or
be wrong, which SPEC-FR-016 requires to cause no permanent harm.
Decision
Format version 1 carries saved_at, an unsigned 64-bit count of milliseconds since the Unix epoch in
UTC. The value 0 is PET_WALL_CLOCK_UNKNOWN and means that the host had no absolute time when it
saved.
The wall clock enters the Core as an explicit parameter, never as a callback and never as a clock read:
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);A load derives the absence from the two stamps and applies it to the candidate world before the candidate is validated and assigned:
- when either stamp is
PET_WALL_CLOCK_UNKNOWN, the absence is zero - when
loaded_atis not greater thansaved_at, the absence is zero - otherwise the absence is the difference, clamped to
PET_ABSENCE_MAX_MS - the absence is added to
elapsed_time_ms, andupdate_countis not changed
PET_ABSENCE_MAX_MS is seven days. It is a separate constant from PET_TIME_DELTA_MAX_MS, because
the two answer different questions. One day bounds what a single update may be told, which is a
runtime safety limit. Seven days bounds how much absence the product is willing to call real, which
is a product judgement about how long a pet keeps counting while nobody is there.
saved_at is never compared against elapsed_time_ms, and an implausible stamp is never a rejection
reason. The two are different clocks, and a payload that became unreadable because a host clock was
wrong would punish a user for a machine’s mistake.
Consequences
SPEC-FR-015 and general architecture section 6.4 are implemented rather than deferred, and
update_count still records only updates that actually ran, so nothing is replayed. The behaviour is
non-punitive by construction, because the world holds no state that decays. A returning pet finds its
dwell stamps stale, settles through the existing transition rules, and loses nothing.
A host without an absolute time source is not a second-class host. It passes
PET_WALL_CLOCK_UNKNOWN and gets exactly the behaviour the release would have had without this
decision. Nothing in the format, the Core, or the storage boundary has to change when that host later
gains a real clock, whether from an added RTC, from a network time source, or from an operator. The
same firmware image can pass an unknown stamp on one boot and a real one on the next.
A backwards clock, a wrong clock, and an absurd stamp all reduce to a clamped absence rather than to
a failure, which satisfies SPEC-FR-016.
The payload grows by eight bytes to 84, and the Core public surface grows by one parameter on each of the two new operations. Both costs are paid once, before any implementation exists.
What a long absence should feel like remains a product question. This decision gives that work a number to act on rather than answering it.
Alternatives considered
No stamp, and the exclusion recorded as a limitation. This was the accepted position until E24
reversed it. Rejected because the field is the irreversible part. Adding it later would need a format
revision, and every payload written in the meantime would be permanently unable to describe its own
absence.
A stamp, with the missed time replayed as updates. Rejected because SPEC-FR-015 explicitly
excludes simulating every missed update, and because an unbounded replay contradicts SPEC-NFR-005
and would make a load cost proportional to how long a user stayed away.
Let the host compute the absence from a file modification time. Rejected because it moves a product rule into the storage boundary, which is required to carry bytes without interpreting them, and because a modification time is a platform artefact that copying, syncing, or restoring a file changes for reasons that have nothing to do with the pet.
Reuse PET_TIME_DELTA_MAX_MS as the absence bound. Rejected in favour of a named constant,
because a single update delta and a session absence are different concepts that happen to be
measured in the same unit, and because tying them together would make a future change to either one
silently change the other.
Store the stamp as local time or with a timezone. Rejected because UTC ordering is all the rule needs. A timezone in the format would be a permanent commitment to a policy the release has no reason to hold.
References
- SPEC-FR-015, SPEC-FR-016
- General architecture section 6.4
- Architecture for v0.4.0
- ADR-002
PBI-102,PBI-088,PBI-090