-
-
Notifications
You must be signed in to change notification settings - Fork 28
Storing Every Date In UTC
Shipped as #1446, following issue #126
FreeITSM stores every moment in UTC and converts it when it draws it. That is the rule, and it was very nearly true: 570 statements said UTC_TIMESTAMP() in as many words.
The exception was everything nobody wrote down.
When a statement does not name a column, the database fills it in from that column's own default. Almost every datetime column in the schema is declared like this:
`created_datetime` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,CURRENT_TIMESTAMP is not UTC. MySQL evaluates it β along with NOW() and CURDATE() β in the connection's session time zone, which defaults to SYSTEM: the database server's own clock. So every statement that forgot a column wrote a local wall clock into a field the rest of the product reads as a UTC instant.
That is what issue #126 turned out to be. Sweeping the schema against every INSERT in the codebase found it was not one place:
220 tables carry 272 auto-stamped datetime columns, and 302 statements let one of them fire.
On a server whose clock is UTC, every one of those is accidentally correct β which is most installations, every sensible container image, and the reason this survived for years.
function dbConnectionOptions(): array
{
return [
PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00'",
];
}Pinned to UTC, CURRENT_TIMESTAMP and UTC_TIMESTAMP() are the same instant, so a column nobody remembered to fill in can no longer be wrong. All 302 are corrected at once, and so is every statement written from now on.
An INIT_COMMAND re-runs when the client silently reopens the socket. A SET issued once by hand is lost on reconnect, and nothing announces that it has been lost β you would get a connection quietly back on the server's clock with no way to tell from the outside.
connectToDatabase() is the front door, but ten other places build a PDO themselves: the two password-reset endpoints, three OAuth callbacks, the analyst sign-in page and five debug tools. A fix that only covered the front door would have left the sign-in page and the password reset writing local time, which is precisely the sort of gap the original bug was.
Not every date in FreeITSM is a moment in time, and the ones that are not must never be compared against one. There are three kinds, and the timezone reference sets them out:
| Kind | Examples | "Now" is |
|---|---|---|
| An instant | created, closed, notes, audit, sync metadata | UTC_TIMESTAMP() |
| A naive wall clock | change windows, scheduled work, calendar events, PIR actuals | the local clock |
| A bare date | contract end, warranty expiry, licence renewal, task due date, article review date | the local calendar date |
A change window stored as 14:00 means two o'clock, for everybody, wherever they are. Ask whether it is open by comparing it to a UTC instant and every window is judged the server's offset early. Likewise "is this contract expiring within 30 days" is a question about a calendar, and a calendar that rolls over at UTC midnight is an hour ahead of the one those dates were typed against β so "due today" would have changed its answer for an hour every night, which is exactly the sort of thing that gets reported as a ghost and never reproduced.
Those comparisons had been using bare NOW() and CURDATE(). They were right only by accident β MySQL happened to evaluate them in the server's zone, which nothing declared and nothing documented. Pinning the connection removed the accident, so they were made explicit first:
naive_now() // 'Y-m-d H:i:s' in the application's configured zone
naive_today_sql() // the same day as a quoted SQL literal, '2026-09-01'36 comparisons across Watchtower, the calendar and schedule feeds, the calendar-sync backfill, the four REST resources and the scheduled contract and warranty reminders now say which clock they mean.
naive_today_sql() hands back '2026-09-01', quotes included, to be interpolated straight into SQL. That is a deliberate exception to the parameter-binding rule and it is worth saying why: every caller builds its query by assembling fragments into a $where[] array and collecting bound parameters somewhere else. Threading an extra parameter through each one means getting its position right in a list built elsewhere β a much better way to introduce a bug than the one it prevents.
The value cannot come from a request. It is produced by PHP's own date() and the format is asserted before it is returned, so what comes back is always 'YYYY-MM-DD'; if the assertion could ever fail it falls back to CURDATE(), which is wrong by an hour rather than wrong by being a syntax error on somebody's dashboard.
The Intune dashboard's 90-day enrolment count compares a UTC column, so the UTC date is the right boundary and an hour is noise at that width. It is commented in place, so the next person sweeping for CURDATE() finds an answer rather than a puzzle.
π connection Β· π helper Β· π read/compare Β· βοΈ write Β· π§ͺ test
| π¨ | File | What changed |
|---|---|---|
| π | includes/db.php |
dbConnectionOptions() β the pin, and the explanation everything else points at. It lived in config.php at first, which broke every install (#129) β that file is the operator's, and upgrading left the definition behind.
|
| π | includes/functions.php |
connectToDatabase() passes it |
| π |
auth/login.php Β· auth/oauth_callback.php Β· auth/google_oauth_callback.php
|
the three sign-in paths |
| π |
api/auth/request_password_reset.php Β· api/auth/reset_password.php
|
password reset |
| π | api/system/debug-tools/D001β¦D012 |
five debug tools |
| π | includes/timezone.php |
naive_now() and naive_today_sql(), with the three kinds spelled out |
| π | includes/watchtower_queries.php |
change windows, calendar, contracts, warranties, due dates, review dates |
| π |
api/tickets/schedule_feed.php Β· api/calendar/feed.php Β· api/tickets/calendar_enrolment.php
|
the feeds and the sync backfill |
| π |
api/v1/resources/assets.php Β· api/v1/resources/contracts.php Β· api/v1/resources/software.php Β· api/v1/resources/tasks.php
|
nine date filters |
| π | includes/workflow_scheduled.php |
contract.expiring and asset.warranty_expiring β the reminder windows |
| βοΈ |
includes/calendar_sync/push.php Β· includes/calendar_sync/pull.php Β· api/system/calendar_sync.php
|
NOW() β UTC_TIMESTAMP() on sync metadata and audit |
| βοΈ |
includes/search/*.php Β· four others |
the same, on index and token timestamps |
| π§ͺ | tests/utc-connection.php |
new β eleven assertions |
1. The connection opens in UTC
PASS session time_zone is +00:00
PASS NOW() == UTC_TIMESTAMP()
PASS CURRENT_TIMESTAMP == UTC_TIMESTAMP()
2. Positive control: an unpinned connection is NOT the same clock
PASS unpinned connection differs from UTC offset -60 min
3. A column filled in by DEFAULT CURRENT_TIMESTAMP now stores UTC
PASS a row that names no date column lands on UTC
4. The wall-clock helpers still answer in local time
PASS naive_now() is the app zone, not UTC +60 min, expected +60 for Europe/London
PASS naive_today_sql() is a quoted literal date
PASS naive_today_sql() follows the LOCAL date Auckland 2026-09-02 vs CURDATE() 2026-09-01
5. The pin survives a reconnect
6. A naive window inside the offset hour is judged by the wall clock
PASS the wall clock sees it as started
PASS UTC does NOT see it as started the distinction is real
Section 2 is the one that matters. Every other assertion would also pass on a server that happens to run UTC with the fix removed β which is how the original bug hid. So the test opens a second, unpinned connection and proves the two clocks disagree; where they do not, it prints a SKIP saying the run cannot distinguish the fix from the bug, rather than reporting a false green.
Section 6 exists for the same reason. Comparing live Watchtower counts before and after gave identical on all twelve queries β but only because no row happened to sit in the shifted hour that evening. That is a coincidence, not a proof, and it would have passed just as happily on a build where the naive handling had been dropped entirely. So a change window is created that opened half an offset ago: the wall clock must call it in progress and UTC must call it not yet started. If those two ever agree, the distinction the whole change rests on is not being made.
- Twelve live queries run in both forms β the old text on an unpinned connection, the new text on the pinned one β against real data. All twelve identical.
- Nine REST date filters exercised through the real API with a throwaway read-only key, deleted by id afterwards. All returned rows without error.
- Seven pages driven with a signed-in session, checking the response body and not the status code, because a PHP fatal is served as HTTP 200.
- Ten existing test files re-run, 311 assertions, all green β including the calendar-sync suite, which is the one that exercises the naive columns.
- Existing rows are not corrected. Nothing records which of the 302 routes wrote a given row, and the tables hold rows from both, so a migration would have to guess β and every wrong guess would move a row that was already right. The same call as #116 and #126.
-
PHP's own clock is untouched.
config.phpstill pinsdate_default_timezone_set('Europe/London'), and that is what the wall-clock helpers deliberately read. It is also what produced the email template fault in the same report, where a value was parsed and formatted in the same zone and so came out converted for nobody. -
The naive comparisons use the installation's zone, not the viewer's. An analyst in Vienna reads a change window as "14:00" β because naive values are shown unconverted β while the dashboard decides whether that is in progress using the installation's clock. Making it per-viewer would mean two analysts seeing different counts on a shared dashboard, which is its own problem. Left as it was rather than changed on the way past, and flagged in
includes/timezone.phpso the question is on the record. - Setting the server's clock to UTC is still worth doing. It is what the rest of the product assumes, and it makes all of this moot.
- Notes were stamped with the server's clock β the report this came out of
- Time logged from the right-click menu was two hours out β the same reporter, a different cause
- Timezones and time handling β the three kinds of stored date
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96