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:

  1. when either stamp is PET_WALL_CLOCK_UNKNOWN, the absence is zero
  2. when loaded_at is not greater than saved_at, the absence is zero
  3. otherwise the absence is the difference, clamped to PET_ABSENCE_MAX_MS
  4. the absence is added to elapsed_time_ms, and update_count is 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