#4905·gsd-core

bug(quick-tasks-append, audit acknowledge): two call sites bypass the clock seam and stamp operator-facing dates from a raw UTC instant

Author: allcounterCreated Sep 21, 2026Updated Sep 21, 2026
Labelsneeds-triage

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:

  1. 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.
  2. It bypasses the GSD_NOW_MS time-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.

bash
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.md

Expected (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:

bash
$ 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 entirely

Suggested fix

Route both call sites through the existing seam. For gsd-core/bin/gsd-tools.cjs:

javascript
const { realClock: clock } = require('./lib/clock.cjs');
const date = clock.localToday();

For src/audit.cts:1656 the --at override should keep precedence:

javascript
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 used clock in the first place.
  • #3367 — TZ-dependent test failures from deriving local values while the subprocess is pinned to UTC.
  • #474 — introduced the GSD_NOW_MS subprocess time-pin that both call sites bypass.