An Object's Time Is Carried, Never Minted by the Receiver
Context and Problem Statement
ActivityStreams 2.0 makes published and updated optional on every object,
and nested (inline) objects routinely omit them. Vultron's wire classes declared
both with default_factory=now_utc, so an omitted timestamp was filled with the
receiver's wall clock during validation. After model_validate returned, that
fabricated value was indistinguishable from one the sender had supplied.
The same classes author outbound activities and validate inbound ones, so the default was simultaneously right (for an object this process creates) and wrong (for an object it receives). Three consequences were measured:
- Genesis diverged per receiver. A case's
genesis_hashis derived from itspublished(CLP-08-002). Core recomputed it from the minted time, so each receiver derived a different genesis for the same case (ISSUE-3257). - Received statuses ordered by arrival.
current_statusorders byupdated or published. Minted values order by when the list happened to be rendered, not when the statuses were recorded (ISSUE-2553). - Ledger dedup was a race against the clock. Re-rendering one stored object twice yielded two different snapshots, so a retry landing in the next whole second appended a duplicate ledger index — the instability ADR-0041 forbids.
The question issue #2553 asked directly: should an object carry its own creation and modification time, and should the render carry it rather than mint it?
Decision Drivers
- A receiver-minted value is later indistinguishable from the sender's claim,
and
payloadSnapshotcomparisons (CLP-14-006/007/008) assumepublishedis the asserting participant's claim. - Rendering one stored object twice must produce equal dicts, or no comparison, hash or ordering over rendered objects is meaningful.
- Absence is common and mostly harmless: AS2 permits it everywhere, and most consumers only order by time.
- Fabricating data to satisfy a type is the failure mode ADR-0032 exists to prevent — validate at the edge, do not correct downstream.
- Outbound authoring genuinely needs a clock default; the two directions must be separated rather than one of them losing.
Considered Options
- Carry the time; refuse only where a decision needs it (chosen).
- Keep minting, and treat timestamps as non-semantic everywhere. Every consumer stops comparing, hashing or ordering by them.
- Keep minting, but record provenance. Add a "was this minted?" flag beside each timestamp so consumers can tell claim from fabrication.
Decision Outcome
Chosen option: carry the time; refuse only where a decision needs it.
An object's published/updated is taken as received at every boundary —
parsing, extraction, storage, rendering. A timestamp the sender omitted stays
absent (None); the receiver never substitutes its own clock. Absence is
refused only where a decision actually reads the value, and that refusal
happens at message validation, not deep in core:
- a case with no
genesisHashand nopublished(CLP-08-002); - an embargo with no
endTime— the value embargo decisions read; - a replicated ledger entry with no
published/receivedAt(CLP-14-002, CLP-02-008;received_atis hashed content, so a replica must not re-stamp it).
Locally authored objects keep the default_factory clock. The two directions
are told apart by validation context, not by a field default: the parser
passes an inbound marker, and as_Base.carry_absent_times_on_inbound reads
absent clock-defaulted timestamps as None for the class being validated.
Option 2 was rejected because the values are load-bearing in the protocol, not
merely informational: CLP-14-006/007/008 and CLP-15-003 read published as the
sender's claim, so making timestamps non-semantic would mean deleting protocol
requirements rather than satisfying them. It also keeps the fabrication in the
stored data, where every future consumer inherits the trap. Option 3 was
rejected because a provenance flag makes every consumer responsible for checking
it — the same guard-at-every-call-site pattern ADR-0032 replaced — and None
already carries exactly that information at no cost.
Consequences
- Good, because a timestamp on a stored object is now always somebody's claim, so comparing, hashing and ordering over rendered objects is meaningful.
- Good, because rendering one stored object twice produces equal dicts, which lets ledger dedup stop normalising timestamps away recursively — a nested time that differs is now a genuinely different assertion.
- Good, because each receiver derives the same genesis for the same case.
- Bad, because
published/updatedare nowdatetime | NoneonCoreObject, so consumers must handle absence.most_recent_statussortsNonelowest and breaks ties by append order;CaseLedgerEntry.publishedand an embargo'send_timestay required precisely because decisions read them. - Bad, because a sender that omits a time a decision needs now gets a 422 where it previously got silent acceptance. That is the intended trade: the message was never interpretable, and failing at the edge names the missing field.
- Neutral, because the inbound marker is a validation-context key. It is set in
exactly one place (
parse_activity); passing it while authoring an object would silently drop that object's time.
Validation
specs/case-ledger-processing.yamlCLP-15-007 states the rule normatively.test/wire/as2/test_nested_timestamps.pycovers every nested kind × omitted/blank/null, plus genesis, embargo and ledger-entry refusals.test/core/behaviors/sync/nodes/test_participant_status_effect.pypins that a replica rebuilding a status from a snapshot carries absence rather than stamping its own clock.test/adapters/driven/test_wire_render_adapter.pypins that rendering across a clock tick is deterministic.
More Information
Supersedes nothing, but narrows two earlier decisions in scope:
- ADR-0090 (a blank required field is absence) established that blank and
nullboth mean absence. This extends that reading to omission on the inbound path, so all three spellings arrive as the sameNone. - ADR-0032 (validate at the edge, promote to strict core types) is the
reason refusal happens at parse rather than being corrected downstream. Note
the direction: this ADR makes a core field looser (
datetime | None), which is consistent with ADR-0032 becauseNonehere is a real, distinct state — "the sender claimed no time" — not a silent failure to be guarded at every call site. - ADR-0041 (ledger index stability) is what the clock race violated.
Issues: #2553 (render mints instead of carrying), #3257 (nested objects get the receiver's clock).
Generated spec requirements: case-ledger-processing.yaml CLP-15-007.