Skip to content

aggregator(tier-items): isIso accepts impossible calendar dates — delegation updatedAt/proposal.at/unable.at pass on bare Date.parse, are stored verbatim, and the proposal TTL is clocked from a day that never existed #3396

Closed

Description

@dezren39

aggregator(tier-items): isIso accepts impossible calendar dates — delegation timestamps (updatedAt, proposal.at, unable.at) pass on a bare Date.parse, are stored verbatim, and the proposal TTL is clocked from a day that never existed

Summary

sanitizeDelegation (pm-stack/aggregator/src/tier-items.ts) is the boundary sanitizer for tier-item delegation extensions — the fail-soft gate that decides which publisher-supplied delegation fields survive into the dashboard. Its only timestamp check is the local helper at tier-items.ts:68-70:

function isIso(v: unknown): v is string {
  return typeof v === "string" && v.includes("T") && !Number.isNaN(Date.parse(v));
}

Date.parse normalizes impossible calendar days instead of refusing them (2026-02-30 → 2026-03-02), so isIso admits them at all three call sites:

  • delegation.updatedAt — tier-items.ts:153
  • delegation.proposal.at — tier-items.ts:168
  • delegation.unable.at — tier-items.ts:185

The impossible string is then stored verbatim on the sanitized delegation (d.updatedAt = raw.updatedAt, d.proposal = { summary, at: p.at }, d.unable = { …, at: u.at }).

The repo already has the strict rule, one module over: validate.ts parseIsoDate refuses 2026-02-30 (returns null), and isTimestampNotFuture refuses 2026-02-30T00:00:00Z (pinned by test/validate.test.ts:197). The tier-item sanitizer just never uses it — the same asymmetry pattern as #3393 (actions.ts) and #3365 (snapshots.ts), in a different gate with different consumers.

Why it matters

proposal.at is not decorative: proposalExpired (tier-items.ts:308-313) clocks the 48-hour proposal TTL (#1293, PROPOSAL_TTL_MS) from it with the same normalizing Date.parse. A proposal stamped 2026-02-30T00:00:00Z is read as drafted on Mar 2, so at Mar 3 00:00Z it is exactly 48h old and not expired — while the Feb 28 control at the same instant is 72h old and expired. The impossible date silently extends the proposal's approvable life by ~2 days (and updatedAt/unable.at feed ordering and rendering consumers that re-parse the stored string the same way). A publisher typo or a non-leap-year Feb 29 passes every check, is stored naming a day that never existed, and no surface ever reports it.

Evidence (verified this run, 2026-11-01)

Laptop probe ~/git/fleet/probe-2026-11-01-laneA-tieriso/ (bun 1.4.2, importing the real sanitizeTierItemExtensions / proposalExpired from pm-stack/aggregator/src/tier-items.ts and parseIsoDate / isTimestampNotFuture from src/validate.ts; tier-items.ts md5 ab984916… and validate.ts md5 2078ddfd… identical in the sandbox clone at main d85e4098 and the laptop clone):

control valid 2026-02-27: KEPT
control garbage at: DROPPED
control no-T date 2026-02-27: DROPPED
impossible 2026-02-30T00:00:00Z: proposal.at KEPT stored=2026-02-30T00:00:00Z | updatedAt KEPT stored=2026-02-30T00:00:00Z
impossible 2026-02-29T00:00:00Z: proposal.at KEPT stored=2026-02-29T00:00:00Z | updatedAt KEPT stored=2026-02-29T00:00:00Z
impossible 2026-04-31T00:00:00Z: proposal.at KEPT stored=2026-04-31T00:00:00Z | updatedAt KEPT stored=2026-04-31T00:00:00Z
impossible 2026-02-30T25:00:00Z: proposal.at DROPPED | updatedAt DROPPED   (hour range IS checked by Date.parse)
unable.at 2026-02-30 / 2026-02-29 / 2026-04-31: KEPT (probe.ts run 1)

validate.parseIsoDate("2026-02-30") = null
validate.isTimestampNotFuture("2026-02-30T00:00:00Z", …) = false
Date.parse("2026-02-30T00:00:00Z") = 2026-03-02T00:00:00.000Z
proposalExpired(at=2026-02-30, now=2026-03-03T00:00Z) = false   (rolled to Mar 2 → exactly 48h, not > TTL)
proposalExpired(at=2026-02-28, now=2026-03-03T00:00Z) = true    (control: 72h > TTL, expired)

Dedup: open + recently closed search for tier-items / isIso / sanitizeTierItemExtensions / proposalExpired — no existing issue covers this gate. Distinct from #3393 (actions.ts validateNewAction payload.until / proposalUpdatedAt — different file, fields, and writer path) and from #3365/#3379 (snapshots).

Acceptance criteria

  • isIso (or its replacement) refuses impossible calendar dates — e.g. 2026-02-30T…, 2026-02-29T… in a non-leap year, 2026-04-31T… — at all three call sites (updatedAt, proposal.at, unable.at), reusing or matching the strictness of validate.ts (parseIsoDate / a round-trip component check), so a delegation carrying one is dropped by the sanitizer exactly as a garbage timestamp is today.
  • Timestamps that are valid today stay valid: the fix must not narrow the accepted formats beyond the impossible-date rule (existing published shapes with a T and a parseable, real date/time keep passing); tests pin at least one offset-bearing and one fractional-second valid timestamp.
  • New tests in the aggregator suite pin each refused shape for each of the three fields, plus the proposalExpired consequence (an impossible proposal.at can no longer extend the TTL window).
  • No change to the fail-soft contract: a refused timestamp drops the delegation field, never the item or the section.

Activity

  1. developing-today commented on Nov 1, 2026

    @developing-today
    Contributor
  2. added
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/lowNice to have; do when convenient
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    effort/sSmall: expected under 2 hours of work
    on Nov 1, 2026
  3. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a, run 2026-11-01 01:35 CST
    • Found via: filed this run
    • Task: Filed after verifying on the laptop that tier-items.ts isIso (bare Date.parse) admits impossible calendar dates at all three delegation timestamp call sites while validate.ts refuses the same strings; probe in ~/git/fleet/probe-2026-11-01-laneA-tieriso/ on the laptop.
    • Expectation: effort/s — triage packet enqueued to repo-health-code-pilot; done = strict-date refusal at the sanitizer with tests, per the acceptance criteria.
    • Links: queue packet packets/packet-3396-triage.json (hidden_files of the keep-the-four-repos-healthy goal)
  4. added
    app/pm-stackpm-stack (Pad/Grist) in developing-today/code
    work/assignedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  5. 19 remaining items

  6. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    queue/claimedQueue worker lifecycle state
    and removed
    queue/needs-reviewQueue task finished and is awaiting review
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 2, 2026
  7. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  8. added a commit that references this issue on Nov 2, 2026
  9. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/aggregatorPackage/app: aggregator (pm-stack in this repo)app/pm-stackpm-stack (Pad/Grist) in developing-today/codeeffort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/lowNice to have; do when convenientsource/creatorFiled by the new-issue-creator jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions