Repository navigation
Description
Summary
validateNewAction (pm-stack/aggregator/src/actions.ts) is the only validation gate for dashboard actions written through the actions API. For the two date fields it checks nothing but Number.isNaN(Date.parse(x)):
payload.until— actions.ts:229-232 (payload.until must be an ISO date/time)payload.proposalUpdatedAt— actions.ts:292-296 (same pattern)
V8's Date.parse does not refuse impossible calendar days — it normalizes them. Verified on the laptop (node v24 / bun, same engine family as the aggregator runtime):
Date.parse("2026-02-30") = 1772409600000 = 2026-03-02T00:00:00.000Z
Date.parse("2026-02-29") = finite (2026-03-01; 2026 is not a leap year)
Date.parse("2026-04-31T00:00:00Z") = finite (2026-05-01)
Date.parse("2026-13-01") = NaN (only the month range is checked)
So the check catches a bad month and garbage, but an impossible day passes, and the string is stored verbatim (payload.until = until, actions.ts:231).
Verified repro (this run)
Laptop probe ~/git/fleet/probe-2026-11-01-laneA-actionuntil/probe.ts (bun, importing the real validateNewAction from src/actions.ts and cardState from src/card-state.ts; laptop clone ~/git/code at main e6577a96, all five involved files md5-identical to origin/main):
validate until=2026-02-28 -> OK (stored as "2026-02-28")
validate until=2026-02-29 -> OK (stored as "2026-02-29") ← 2026 has no Feb 29
validate until=2026-02-30 -> OK (stored as "2026-02-30") ← impossible day
validate until=2026-04-31 -> OK (stored as "2026-04-31") ← April has 30 days
validate until=2026-13-01 -> ERR payload.until must be an ISO date/time
validate until=not-a-date -> ERR payload.until must be an ISO date/time
cardState at 2026-03-01T12:00Z with until=2026-02-28 -> suppressed: null (control: snooze over)
cardState at 2026-03-01T12:00Z with until=2026-02-30 -> suppressed: "snoozed" (still hidden on Mar 1)
The consumer side is cardState (src/card-state.ts:75): Date.parse(snooze.payload.until) > now decides suppression, re-parsing the stored string with the same normalizing parser — so the impossible date silently becomes Mar 2 there. A snooze written "until Feb 30" (or a UI/typo producing Feb 29 in a non-leap year) keeps the card hidden ~2 days longer than the chosen horizon, the write returned success, and the stored record names a day that never exists, so no later reader can recover the intent. proposalUpdatedAt has the same shape: an impossible stamp is accepted and stored, and ordering/freshness comparisons against it compare against the normalized instant, not the written one.
Why it matters
- This is the write-path gate for user-facing dashboard actions; a wrong-but-plausible date is exactly the input a strict validator exists to catch, and the failure mode is silent (success response, wrong behavior days later).
- The codebase already has the strict primitives and the precedent:
validate.tsparseIsoDate/isIsoDate(real-calendar-date check, aggregator(snapshot-records): shape-only dates win freshestDate — a 2026-13-01 'Last checked' silently drops a pool's snapshot history, 2026-02-30 is stored as a phantom March date #2264/aggregator: Phase 1 of #2576 — validate.ts primitives, the ingest contract in the README, and the malformed-input probe matrix #2577) are used byfeed.tsandsnapshot-records.ts(freshestDate: "an impossible date like 2026-02-30 … must not win").actions.tsis the remaining Date.parse-only gate on this surface. - Sibling snapshot-gate findings (aggregator(snapshot-records): recordsFromDeepseekBalance takes the ¥ line's first date-shaped string unchecked — an impossible date (2026-02-30) shadows the section's valid Last-checked date, the record fails isSnapshotRecord and is silently dropped as 'already present' #3379, aggregator(snapshots): isSnapshotRollup re-admits the shapes isSnapshotRecord was hardened to refuse — impossible dates, negative/string limit, unparseable resetsAt, inverted pairs expand into the reading path unchecked #3365) cover the snapshot pipeline; this is the distinct actions/card-state path — the persisted action log and live card suppression, not snapshot history.
Acceptance criteria
-
validateNewActionrefuses apayload.untilorpayload.proposalUpdatedAtwhose calendar day does not exist (e.g.2026-02-29in 2026,2026-02-30,2026-04-31), with the existing "must be an ISO date/time" error; real dates (incl.2024-02-29) still pass, as do full ISO datetimes for real days. - The validation uses a real-calendar check consistent with
validate.ts(parseIsoDatesemantics for the date part), notDate.parsealone. - Tests: actions validation tests gain impossible-day cases for both fields (refused) plus leap-year control (accepted); existing actions/card-state tests still pass.
- No change to
cardStatecomparison semantics for valid stored dates; already-stored impossible strings are out of scope (note in the fix if a defensive read-side guard is added anyway).
Filed by new-issue-creator-code-a (lane A), run 2026-11-01. Evidence probe: ~/git/fleet/probe-2026-11-01-laneA-actionuntil/ on the laptop.
Activity
developing-today commentedon Nov 1, 2026
CODE-1292
dezren39 commentedon Nov 1, 2026
🤖 Agent note · filed
90 remaining items
dezren39 commentedon Nov 4, 2026
🤖 Agent note · handed off
dezren39 commentedon Nov 4, 2026
🤖 Agent note · blocked (escalated to Drew)
c8b9355e-319a-4640-8096-7ad19480d151small_fixfor this issue)dezren39) — a Drew-owned PR is a hard stop for queue workers, and merging it is his decision.Verified 2026-11-04T14:16Z
2d6335c9125968f3e64cf1b1c3fe2bf080b2a08d(unchanged since 2026-11-04T11:30Z), assigneedezren39.basic: OpenCode metered-provider guardSUCCESS, CodeRabbit NEUTRAL. The policy-listedbasic: proxy port fallbacksis not triggered by this PR at all —.github/workflows/nix-ci.ymltriggers on push to main, schedule and workflow_dispatch, notpull_request/merge_group(same coverage gap recorded in the prior run).queue/6024-3393-calendar-dates; targetedbun test test/actions.test.tswas 20 pass / 0 fail (prior run, unchanged head).Why block instead of another 30-minute park: this task has been claimed 21 times and re-parked each time for the same Drew-owned PR, which is a queue churn loop, not progress. Nothing a worker may do remains.
Unblock condition: Drew merges or closes PR #4466, or asks for changes. Then re-run the removal/merge checks and complete.