bug(quick-tasks-append, audit acknowledge): two call sites bypass the clock seam and stamp operator-facing dates from a raw UTC instant
Summary
realClock.localToday() exists so that operator-facing, date-only fields name the day the
operator actually did the work (#2136). Two call sites still derive that day from a raw
new Date().toISOString().slice(0, 10).
That does two wrong things at once:
- It uses the UTC calendar day for a field an operator reads as a local date. Work done between local midnight and UTC midnight is stamped with yesterday. The window is one hour at UTC+1, two at UTC+2, and up to fourteen elsewhere.
- It bypasses the
GSD_NOW_MStime-pin seam (#474), so the value cannot be pinned and neither call site can be covered by a deterministic test.
Verified against the published @opengsd/[email protected] tarball AND against main, where
both call sites still carry the raw expression.
Affected call sites
| Source file | Line | Field it stamps |
|---|---|---|
gsd-core/bin/gsd-tools.cjs |
1285 on main (1274 in the v1.14.0 tarball) |
the Date column of the Quick Tasks Completed table in .planning/STATE.md |
src/audit.cts |
1656 | the acknowledgement date spliced into artifact frontmatter (has an --at override) |
The second row names src/audit.cts rather than bin/lib/audit.cjs:1406: per ADR-457 the
bin/lib/*.cjs files are generated at publish and are not in the repository.
Neither file imports the clock module at all. In the published tarball grep -c clock bin/lib/audit.cjs returns 0, and the five matches in bin/gsd-tools.cjs are all the phrase
"wall-clock" in timeout comments.
Why this is wrong by the project's own contract
The docstring on localToday() in bin/lib/clock.cjs states the rule:
Operator-facing date-only fields (last_activity, "completed <date>", etc.) must use the local calendar day: an operator reads them as "the day I did this", and they must never name a day ahead of
last_updated's local date.today()(UTC) stays the source for internal/cosmetic stamps.
A Quick Tasks Completed row is exactly that kind of field: a log of what the operator finished
and on which day. localToday() is already used in 24 places under bin/. These two are the
stragglers.
Reproduction
Deterministic, and does not require waiting for midnight. The time is pinned to
2026-07-01 00:30 in a UTC+2 zone, which is 2026-06-30 22:30 UTC.
mkdir -p /tmp/gsd-utc-repro/.planning && cd /tmp/gsd-utc-repro
git init -q . && git -c [email protected] -c user.name=t commit -q --allow-empty -m init
cat > .planning/STATE.md <<'EOF'
# Project State
### Quick Tasks Completed
| # | Description | Date | Commit | Directory |
|---|-------------|------|--------|-----------|
EOF
TZ=Europe/Copenhagen GSD_TEST_MODE=1 GSD_NOW_MS=1782858600000 \
gsd-tools quick-tasks-append --task "repro row"
grep -E '^\| 1 ' .planning/STATE.mdExpected (the pinned local day):
| 1 | repro row | 2026-07-01 | <sha> | — |Actual: the real UTC day at the moment you run it. The pin has no effect at all.
The clearest evidence is that the same pinned command gives a different answer depending on when you run it. I ran it twice, 55 minutes apart, with byte-identical arguments:
01:50 local (23:50 UTC, Sep 20) -> | 1 | repro row | 2026-09-20 | ... |
02:45 local (00:45 UTC, Sep 21) -> | 1 | repro row | 2026-09-21 | ... |Real UTC midnight fell between the two runs. GSD_NOW_MS was pinned to 2026-07-01 for both,
so a call site that honoured the seam would have printed 2026-07-01 twice.
The contrast between the three sources, under the same pin:
$ TZ=Europe/Copenhagen GSD_TEST_MODE=1 GSD_NOW_MS=1782858600000 node -e '
const { realClock: clock } = require("./bin/lib/clock.cjs");
console.log("clock.localToday() ->", clock.localToday());
console.log("clock.today() ->", clock.today());
console.log("raw toISOString ->", new Date().toISOString().slice(0,10));
'
clock.localToday() -> 2026-07-01 # correct: honours the pin, local day
clock.today() -> 2026-06-30 # honours the pin, UTC day
raw toISOString -> 2026-09-20 # ignores the pin entirelySuggested fix
Route both call sites through the existing seam. For gsd-core/bin/gsd-tools.cjs:
const { realClock: clock } = require('./lib/clock.cjs');
const date = clock.localToday();For src/audit.cts:1656 the --at override should keep precedence:
const at = atFlag || clock.localToday();Of the two, the Quick Tasks row is the one that matters: it has no override and is written
unattended, so it records the wrong day silently. Routing both through the seam also makes
them testable under the GSD_TEST_MODE + GSD_NOW_MS pin that #3367 and #474 put in place.
Related
- #2136 — same defect class, and the issue that introduced
localToday(). The fix did not reach these two call sites, because they never usedclockin the first place. - #3367 — TZ-dependent test failures from deriving local values while the subprocess is pinned to UTC.
- #474 — introduced the
GSD_NOW_MSsubprocess time-pin that both call sites bypass.
Source: open-gsd/gsd-core