0018. Store source datetimes as UTC instants¶
Date: 2026-09-04 Status: Accepted
Context¶
Conflux had no stated rule for what happens to a datetime that arrives from an external system. Every field was therefore decided on its own, at the point someone first needed it, and the decisions contradicted each other.
Five columns held a vendor datetime string as varchar(255) with no cast:
customers.date_of_birth, orders.date_paid, customer_consents.subscribed_at,
customer_consents.confirmed_at and customer_consents.unsubscribed_at. Whatever the shop wrote is
what the column held, and for orders.date_paid that was WooCommerce's date_paid, the shop-local
twin of a field the same payload also carries as date_paid_gmt. Every value in the column was
therefore displaced from UTC by the shop's own offset, with nothing recording what that offset was.
PrestaShopTransformer never set datePaid, and OrderData::$datePaid defaults to null, so for a
PrestaShop connection the column held NULL.
No column ever mixed two platforms' spellings, because none of the five had two writers: only
PrestaShop wrote date_of_birth, only WooCommerce wrote date_paid, and nothing wrote the three
consent columns at all. The defect was a missing zone and a missing type rather than a collision. A
varchar carrying an undeclared zone cannot be sorted, compared across connections, or resolved to
an instant, and the type is what let the raw string in unexamined.
AbstractTransformer discarded the source's own timestamps at the persistence boundary. All three
canonical DTOs carried createdAt and updatedAt, but none of the fill() arrays named a column
for either, so Eloquent stamped its own created_at and updated_at and the source's values went
no further than the mapper that read them.
What made that loss invisible is the outbound side. Each projector refilled the DTO's createdAt
and updatedAt from the row's own timestamps, so a projection came back with both fields populated
and a round trip compared equal. The payload said "created" and meant "first stored by Conflux",
and no test could tell the difference, because the value the projector put back was the only value
there had ever been to compare against.
The written contract said the same thing on purpose. Until now
reference/projection-contract.md specified the opposite
semantics as the intended design: that createdAt and updatedAt are the Conflux row's own
timestamps, that on the way out they mean when Conflux first stored the record, and that the
source's values are dropped on the way in. So this is not a case of documentation lagging behind
code. The semantics themselves were wrong, and they were wrong in writing.
Two further facts about the inbound path made the rule necessary rather than merely tidy.
WooCommerce sends a _gmt variant of every datetime field and the integration read the local
variant throughout, discarding the one unambiguous value on offer. PrestaShop resolved the shop's
timezone only when modifiedAfter was set, so a full sync, the run that writes the historical
values for the first time, was exactly the run that resolved nothing.
No outbound writer exists yet, which makes this the cheapest window there will ever be. Nothing has
to be unwound, and no timezone assumption has been committed to code that a writer would then have
to keep honouring. A writer built before this rule would have had to work out what each value it
reads actually means, and that depends on the source platform, so "a writer knows one target" would
have quietly become "a writer knows one target and every source". Each writer would also have
answered the zone question separately, and the answer would have been whatever Carbon::parse()
happened to do at that call site.
Decision¶
Six rules govern every datetime Conflux reads from an external system: what it stores, what it
passes through, and what it discards. The delta cursor is the only datetime Conflux sends outbound
today, and it is a CarbonImmutable wherever Conflux holds it, which removed the last exception to
the parse rule. It is rendered to a string in two places:
WooCommerceIntegration::deltaFilterParams() puts it in a query parameter, and BulkSyncJob
formats it for FetchDeletionLogJob, whose since is still a ?string. The second rendering is an
inconsistency rather than a design choice: the deletion log contract landed separately and still
takes the cursor as a string. That is worth naming as outstanding, not as though both were intended.
What these rules leave open about it is not its zone, which is UTC by construction, but which column
the remote platform compares it against. That is settled per integration; see
reference/woocommerce-integration.md
for WooCommerce's answer and why the query has to say so explicitly.
- A datetime Conflux stores in a column of its own is parsed at the integration boundary and stored as an instant in UTC.
- A calendar date is stored as a date and never converted, because it means the same day in every zone.
- The source zone is established per system connection, never assumed. Where the zone is a fact
about the platform, it is a constant in the integration's own code. Where it varies per
connection, it lives in
system_connections.source_timezone. Where it cannot be established at all, rule 4 applies. - A failed parse is an event, never a silent default.
- What a system connection passes through without storing it in a column of its own is not covered by rule 1, and must not be presented as though it were.
- A datetime an integration reads and Conflux does not store is recorded as discarded rather than quietly disappearing.
The canonical payload describes the record in the source. That is what settles the naming
question the rules raise: the DTO fields stay createdAt and updatedAt, because a canonical
record describes an entity as it exists in the shop, and there the source is the only authority for
when it was created. Conflux's row timestamps were never part of the payload; they are an artefact
of storing it. Conflux's own view of change is a separate signal and stays where it already was, on
the delivery envelope as contentChangedAt. The columns keep a source_ prefix while the fields do
not, which is the data layer's normal shape rather than an inconsistency: orders_count is already
orderCount and total_spent already totalSpent.
The type convention follows from the rules:
| What the value is | How it is declared |
|---|---|
| An instant | ?CarbonImmutable |
| A calendar date | ?CarbonImmutable with #[WithCast(DateTimeInterfaceCast::class, format: '!Y-m-d')] and #[WithTransformer(DateTimeInterfaceTransformer::class, format: 'Y-m-d')] |
| A value passed through under rule 5 | string, inside customAttributes, and nowhere else |
Typing the fields is what makes rule 1 checkable rather than merely stated. A string field accepts
$data['date_created'] unchanged, so assigning the raw string was the obvious thing to do and the
type permitted it. A CarbonImmutable field does not, so a mapper has to parse, and parsing forces
the zone question into the open. Rule 5 gains the same benefit from the other direction: leaving
passed-through values as strings puts the limit of the promise in the type instead of in a paragraph
of documentation.
The ! in the calendar-date cast format is load-bearing. Without it createFromFormat() fills the
unparsed time fields from the current wall clock, which would give every birthday the time of the
sync. The cast protects the hydration path only, not construction: a CarbonImmutable carrying a
time survives being passed to the constructor, and the mappers construct their DTOs directly with
named arguments rather than through from(), so the parse context has to force midnight as well.
The attribute and the parse context divide the work; neither replaces the other.
The zone is declared or resolved, never assumed. Where it is a platform fact it is a constant, which
is the WooCommerce case: WooCommerce sends its _gmt values as 2024-03-01T09:05:00, naive strings
with no offset whose zone is statically known, so WooCommerceIntegration::SOURCE_TIMEZONE is
'UTC'. That constant is the zone of the strings Conflux reads, not the zone the shop is in, and
that is exactly why it belongs in code rather than in a column. Where the zone varies per
connection it is resolved and written to system_connections.source_timezone, which is the
PrestaShop case: date_add and date_upd arrive in shop-local time with no offset, so
resolveSourceTimezone() reads PS_TIMEZONE on every run and falls back to the value already
stored on the connection, never to the application timezone. For PrestaShop, the only reader of that
column, a null therefore means exactly one thing: not established, so every instant parsed against
it is a failure under rule 4. A wrong zone produces a plausible-looking wrong instant, which is
worse than no instant at all.
Carbon::parse() on a source datetime string is banned. It reads an undeclared format by heuristic,
and the heuristic turns on the separator:
DateTimeImmutable::createFromFormat('!d.m.Y', '04.12.1985') -> 1985-12-04
DateTimeImmutable::createFromFormat('!m.d.Y', '04.12.1985') -> 1985-04-12
Carbon::parse('04.12.1985') -> 1985-12-04
Carbon::parse('04/12/1985') -> 1985-04-12
The same day, written two ways, eight months apart, with no warning. Every integration therefore
declares its date and datetime formats as constants and hands them to App\Services\SourceDateTimes,
which is the one place a source datetime is parsed. A value that does not match the declared format
is a parse failure under rule 4. A format list with an ordered fallback is not permitted, because the
order would silently decide the meaning of an ambiguous value. If a platform genuinely sends two
formats, that is written down as a fact and overridden per field, rather than built as a fallback
chain.
Two things are absent rather than broken, and are treated as such: a field the source did not send,
and the literal values 0000-00-00 and 0000-00-00 00:00:00. Both are how a MySQL-derived source
spells "no value", not a value it sent incorrectly, and PrestaShop's birthday sentinel for a
customer with no birthday is the case that motivates it. They yield null without recording a
failure. The check is an explicit list of those two literals rather than a general shape rule, so a
third platform spelling its own sentinel differently is not silently absorbed: it reaches the parser,
fails, and surfaces as a warning.
Consequences¶
Seven columns are added. customers, orders and products each gain source_created_at and
source_updated_at as nullable timestamps, cast immutable_datetime, and system_connections
gains source_timezone. The five varchar columns become real types: customers.date_of_birth a
date cast immutable_date, and the other four timestamp cast immutable_datetime. The cast
choice is itself a rule worth stating, because two kinds now sit in one casts() method and would
otherwise look like an oversight: a column that fills a typed DTO field is immutable, while
Conflux's own bookkeeping columns (content_changed_at, last_connected_at, disconnected_at)
keep 'datetime'.
source_created_at and source_updated_at join SYNC_NOISE_FIELDS in AbstractTransformer, so
neither counts as a meaningful change. A shop that re-saves a record without touching anything
Conflux mirrors still moves source_updated_at, and that must not move content_changed_at or
report the record as updated. Both columns are still written on every sync; they simply do not by
themselves make a record deliverable. Putting source_created_at on the list has one deliberate
side effect: a shop correcting a record's creation date after the fact does not count as a content
change either, which is rare enough to accept for the symmetry.
The outbound contract does not change. createdAt and updatedAt on the DTOs become nullable,
because the alternative is the '' fallback that rule 4 forbids, but the projectors prefer the
source columns and fall back to the row's own timestamps, so a null from a mapper does not reach a
consumer as a null. The nullability is a statement about the inbound path only. The fallback also
covers every record written before this decision, whose source columns are null and cannot be
recovered, so the payload carries a value there too.
The projectors' own return type stays nullable, and it means it: a row with neither a source column
nor a row timestamp projects to null. Nothing guards that case, because a persisted row cannot reach
it, Eloquent setting created_at and updated_at on insert. The fallback narrows the gap to a row
that does not exist rather than closing it in the type.
The wire format is unchanged. There is no config/data.php, so spatie's default DATE_ATOM applies
and an instant serialises as 2024-03-01T09:05:00+00:00, which is what the round-trip test already
asserted. A calendar date serialises as a bare 1985-04-12 with no zone at all, so no consumer can
shift the day by reading it as UTC midnight and converting westwards.
reference/projection-contract.md is corrected rather than
left standing. Its passage on the two fields that "do not survive a round trip" described the defect
as the contract. This ADR supersedes no existing ADR, because no ADR ever recorded the old
semantics: the only place they were written down was that reference page, and a reference page is
corrected in place rather than superseded.
ROUND_TRIP_DROPPED in tests/Integration/Services/ProjectionRoundTripTest.php is now empty, and
it stays there as a named constant for that reason. An empty list is the assertion: there is no
longer a field the inbound path fails to persist and no projection therefore can return.
This is what makes CFX-203's UTC labelling true. Labelling the admin panel's datetimes as UTC would
have been a false statement while orders.date_paid held WooCommerce's shop-local string for every
connection that wrote the column at all.
Historical accuracy is lost until a full sync rewrites the values. The migration reads each existing
varchar value as UTC and changes the column's type, which introduces no interpretation of its own,
but the original zone was never stored and cannot be recovered, so for a shop outside UTC the
historical date_paid values are off by the shop's offset until a full sync rewrites them from
date_paid_gmt. source_created_at and source_updated_at are not backfilled at all, for the same
reason.
The clean-up the migration runs first is destructive rather than merely imprecise. Before each type
change it sets to NULL every value MySQL cannot read in the target format, because strict mode would
otherwise reject the ->change() over a single malformed row. For the four columns becoming
timestamp that NULL-ing is preceded by rewriting a T separator to a space, which is what
WooCommerce writes into orders.date_paid; customers.date_of_birth takes no such step, because its
%Y-%m-%d target has no time component for a separator to sit in. A value nulled that way is gone
from the database rather than shifted by an offset, and only a full sync brings it back. There are no
production connections, so both this and the offset above reach local and staging data only.
The failure policy distinguishes a datetime that describes a record from one that is evidence of
something, and the distinction is recorded as policy rather than built as a second code path.
A datetime that describes a record (createdAt, updatedAt, datePaid, dateOfBirth) is nulled
on failure: the record is then less precise but still entirely usable, and skipping it would be
worse, because one systematically broken field would stop a whole shop from syncing while every
other field was fine. That systematic case is precisely what the aggregated log line makes visible.
A datetime that is the evidence of something (the three consent timestamps, of which confirmed_at
is the proof of a double opt-in) fails the record instead: nulling it silently turns a confirmed
recipient into one with no proof. Everything written today is of the first kind, so the skip path has
no caller yet and is deliberately not built ahead of one.
Parse failures are aggregated per run, per domain and per field, and flushed from the integration's
export entry point in a finally so a run that throws still reports what it already found. Logging
one line per record would put 200,000 identical lines in the log for one systematically broken field
on a full sync of 200,000 orders, which buries every other line while saying nothing the count does
not. Each line carries the driver, the domain, the field, the zone, the count and the first few
source ids. The domain and the field are separate keys rather than one composite value, so an
operator filtering a structured log on field=date_add finds the line.
A datetime read only to decide whether a record belongs in a batch is parsed the same way and
reported differently, which is why the parse context has a second entry point.
PrestaShopIntegration::wasModifiedAfter() compares date_upd against the delta cursor through
SourceDateTimes::instantForComparison(): the declared format and the established zone, and no
recorded failure. It fails open: a value it cannot read, a zone that was never established, and a
field the shop did not send at all each leave the record in the batch. The two errors are not
symmetrical here: a record wrongly kept is mapped and then reported as unchanged, whereas a record
wrongly dropped is invisible, and a shop whose zone cannot be resolved would look like an empty shop
rather than a broken one. An unreadable value and an unestablished zone still surface exactly once,
from the mapper that parses the same field of the same record moments later, which is why the
filter's entry point is handed no field, domain or source id to report against. Recording either one
there as well would inflate every aggregated line by the size of the delta batch. A field the shop
did not send at all is different: it is absent rather than broken, so it yields null without
recording a failure at either entry point, and nothing about it ever surfaces.
Several alternatives were considered and rejected.
A bespoke CalendarDate value object would model the one thing PHP has no type for, and it was
rejected on cost: four files plus tests, for a single field, to prevent an error that has no path
today. The two spatie attributes plus the parse context cover the same ground with no new type.
Heuristic parsing was rejected because it is not a shortcut, it is a coin flip. 04.12.1985 is
4 December read as d.m.Y and 12 April read as m.d.Y, and Carbon::parse() picks between them by
looking at the separator. A wrong date that parses cleanly is worse than a parse failure, because
nothing surfaces it.
Reusing Conflux's own updated_at as the source signal was rejected because the column already
means something else. It means "when Conflux last wrote this row", it is rewritten by every full
sync along with last_bulk_sync_batch_id, and it carries delta sync and the change signal. Giving
it a second meaning would break both readers rather than serve a third.
Storing the zone as a key in meta was rejected because meta is an editable KeyValue field in the
Filament form. A hand-set value there would be silently overwritten by the next sync, and a zone
that changes without anyone noticing is the failure mode rule 3 exists to prevent. A column of its
own is not writable from the form.
A parse-failure counter on TransformationResult was rejected because it sits on the wrong side of
the boundary. Parsing happens in the mappers, which the integration calls while fetching;
TransformationResult is only constructed afterwards, in AbstractTransformer::transform(). The
counter would need new plumbing threaded through the entire fetch path to reach the object that
reports it, and the aggregated log line already answers the question it would have answered.