Skip to content

Stabilize event date handling: validate at the AI gate + storage chokepoint, route render through a non-throwing factory #394

Description

@chubes4

Background

A published event with date 2026-07-?? caused an uncaught DateMalformedStringException fatal in MultiDayResolver::is_multi_day() (fixed reactively in v0.44.1 by adding a checkdate guard at that one render site). That fix stops the crash but does not address the root cause: malformed/incomplete date strings can enter storage and get published with zero validation. This issue tracks the stabilization refactor.

What's actually going on (investigation summary)

There is no single canonical date path. Dates flow through three structurally independent regimes, and the one good safe parser (Core/DateTimeParser) is only used by one of them:

Regime Validation today Notes
Extractor path (~30 extractors: Squarespace, JsonLd, ICS, DiceFm, Ticketmaster, …) Partial — Core/DateTimeParser exists and fails safe (returns '', never throws) Many extractors bypass it with bare strtotime() / new DateTime() shortcuts
AI path (WebScraper HTML/vision fallback → event_upsert tool) None Source of 2026-07-??. The model's raw string is stored verbatim
Render path (DateGrouper, DisplayVars, MultiDayResolver) Only MultiDayResolver (the v0.44.1 patch) Siblings DateGrouper and DisplayVars do the same unguarded new DateTime() — undetonated landmines

The AI does not "choose" dates — but the schema lets it emit any string

inc/Core/EventSchemaProvider.php L64-75 defines startDate/endDate as:

'startDate' => array(
    'type'        => 'string',          // only constraint
    'required'    => false,
    'description' => 'Event start date (YYYY-MM-DD format)',  // a hope, not a rule
),

The JSON Schema handed to the model is type: string with no pattern/format. When a source page says "July 2026, day TBA," the model transcribes the incomplete date as 2026-07-??. Compare startTime (L76-81), which explicitly instructs the model to reject via the rejection tool when it can't determine a value — startDate has no such sanctioned fallback, so the model has no clean way to say "this date is incomplete."

Where validation is missing, in flow order

  1. AI gateEventUpsert::getDateTimeConfidence() (inc/Steps/Upsert/Events/EventUpsert.php ~L1239) checks presence only, never format. 2026-07-?? is non-empty → passes as date_only/full.
  2. Block-content generationgenerate_event_block_content() (~L1364) writes startDate/endDate verbatim into the event-details block JSON.
  3. Storage chokepointinc/Core/event-dates-sync.php::data_machine_events_sync_datetime_meta() (hooked save_post, ~L209-222) concatenates $start_date . ' ' . $time2026-07-?? 00:00:00 and hands it to EventDatesTable::upsert(). Every event write (AI, manual, CLI, backfill) funnels through here and it validates nothing.
  4. Table writerEventDatesTable::upsert() $wpdb->replace()s the raw string into a DATETIME NOT NULL column. On this host (non-strict sql_mode) MySQL silently coerced all 7 malformed rows to 0000-00-00 00:00:00.
  5. Render — unguarded new DateTime() in DateGrouper (~L46) and DisplayVars (~L44/51/104) — same fatal class as the patched MultiDayResolver.

Proposed refactor (high-leverage, does NOT rewrite the extractor subtree)

The extractor subtree already routes through DateTimeParser and fails safe — leave it. Add a small shared helper and wire it into the two prevention points + one containment point:

  1. Promote MultiDayResolver::is_valid_date() into Core/DateTimeParser as a public isValidYmd( string ): bool (regex ^\d{4}-\d{2}-\d{2}$ + checkdate) plus a non-throwing safeCreate( string, ?DateTimeZone ): ?DateTime factory. Single source of truth.
  2. Prevention fix(calendar): unique SVG borders & per-group badges for repeated weekday groups #1 — AI gate. In EventUpsert::getDateTimeConfidence(), treat a non-empty-but-invalid startDate/endDate as 'none' so the existing errorResponse/rejection path fires — mirroring the existing junk-title guard. Stops bad dates at the source.
  3. Defense — storage chokepoint. In event-dates-sync.php (~L209), validate-or-bail before building the DATETIME string, so NO write path can land a malformed date in the table regardless of origin.
  4. Resilience — render. Route DateGrouper and DisplayVars new DateTime() sites through safeCreate() (skip/degrade gracefully on null) so a single bad row can never again take down the calendar.

Net: ~30 ad-hoc parsing sites collapse into a 2-prevention + 1-containment + 1-resilience model. After MultiDayResolver migrates to the shared helper, its inline is_valid_date() can be removed.

Open product question — how should "month known, day TBA" be represented?

This is the real upstream question. Today there is no representation for an incomplete date, so the model is forced to either fabricate a day or emit ??. Observed in the wild (see cleanup issue): 2026-07-??, 2026-09-??, 2026-04-??, and even 2026-??-?? (year only).

Options to decide before coding the AI gate:

  • (a) Hard reject incomplete dates via the rejection tool (simplest; loses the event entirely — bad for "announced, date pending" shows we'd want to list).
  • (b) TBA state — add an explicit "date TBA" concept (e.g. store the known precision + an eventStatus-style flag) so the calendar can show "Coming July 2026 — date TBA" instead of dropping it. Richer, but touches schema, block, render, and schema.org mapping.
  • (c) Defer to draft — accept the partial date but force post_status=draft + flag for human completion, never auto-publish an incomplete date.

Recommend deciding (a) vs (b) vs (c) first, since it determines what the AI gate does on an incomplete date. My lean: (c) as the immediate safety behavior (never auto-publish an unparseable date), with (b) as a follow-up product enhancement if "date TBA" listings are worth surfacing.

Acceptance criteria

  • Core/DateTimeParser::isValidYmd() + safeCreate() added and unit-tested.
  • AI gate rejects/handles non-Y-m-d model dates per the chosen product behavior.
  • event-dates-sync.php cannot write a malformed DATETIME.
  • DateGrouper + DisplayVars render sites use safeCreate() and cannot throw on a bad stored row.
  • MultiDayResolver migrated to the shared helper (inline is_valid_date() removed).
  • Product decision on TBA handling documented in this issue before the AI-gate change lands.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions