You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
'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
AI gate — EventUpsert::getDateTimeConfidence() (inc/Steps/Upsert/Events/EventUpsert.php ~L1239) checks presence only, never format. 2026-07-?? is non-empty → passes as date_only/full.
Block-content generation — generate_event_block_content() (~L1364) writes startDate/endDate verbatim into the event-details block JSON.
Storage chokepoint — inc/Core/event-dates-sync.php::data_machine_events_sync_datetime_meta() (hooked save_post, ~L209-222) concatenates $start_date . ' ' . $time → 2026-07-?? 00:00:00 and hands it to EventDatesTable::upsert(). Every event write (AI, manual, CLI, backfill) funnels through here and it validates nothing.
Table writer — EventDatesTable::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.
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:
PromoteMultiDayResolver::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.
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.
Resilience — render. Route DateGrouper and DisplayVarsnew 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.
Background
A published event with date
2026-07-??caused an uncaughtDateMalformedStringExceptionfatal inMultiDayResolver::is_multi_day()(fixed reactively in v0.44.1 by adding acheckdateguard 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:Core/DateTimeParserexists and fails safe (returns'', never throws)strtotime()/new DateTime()shortcutsevent_upserttool)2026-07-??. The model's raw string is stored verbatimDateGrouper,DisplayVars,MultiDayResolver)MultiDayResolver(the v0.44.1 patch)DateGrouperandDisplayVarsdo the same unguardednew DateTime()— undetonated landminesThe AI does not "choose" dates — but the schema lets it emit any string
inc/Core/EventSchemaProvider.phpL64-75 definesstartDate/endDateas:The JSON Schema handed to the model is
type: stringwith nopattern/format. When a source page says "July 2026, day TBA," the model transcribes the incomplete date as2026-07-??. ComparestartTime(L76-81), which explicitly instructs the model to reject via the rejection tool when it can't determine a value —startDatehas no such sanctioned fallback, so the model has no clean way to say "this date is incomplete."Where validation is missing, in flow order
EventUpsert::getDateTimeConfidence()(inc/Steps/Upsert/Events/EventUpsert.php~L1239) checks presence only, never format.2026-07-??is non-empty → passes asdate_only/full.generate_event_block_content()(~L1364) writesstartDate/endDateverbatim into theevent-detailsblock JSON.inc/Core/event-dates-sync.php::data_machine_events_sync_datetime_meta()(hookedsave_post, ~L209-222) concatenates$start_date . ' ' . $time→2026-07-?? 00:00:00and hands it toEventDatesTable::upsert(). Every event write (AI, manual, CLI, backfill) funnels through here and it validates nothing.EventDatesTable::upsert()$wpdb->replace()s the raw string into aDATETIME NOT NULLcolumn. On this host (non-strictsql_mode) MySQL silently coerced all 7 malformed rows to0000-00-00 00:00:00.new DateTime()inDateGrouper(~L46) andDisplayVars(~L44/51/104) — same fatal class as the patchedMultiDayResolver.Proposed refactor (high-leverage, does NOT rewrite the extractor subtree)
The extractor subtree already routes through
DateTimeParserand fails safe — leave it. Add a small shared helper and wire it into the two prevention points + one containment point:MultiDayResolver::is_valid_date()intoCore/DateTimeParseras a publicisValidYmd( string ): bool(regex^\d{4}-\d{2}-\d{2}$+checkdate) plus a non-throwingsafeCreate( string, ?DateTimeZone ): ?DateTimefactory. Single source of truth.EventUpsert::getDateTimeConfidence(), treat a non-empty-but-invalidstartDate/endDateas'none'so the existingerrorResponse/rejection path fires — mirroring the existing junk-title guard. Stops bad dates at the source.event-dates-sync.php(~L209), validate-or-bail before building theDATETIMEstring, so NO write path can land a malformed date in the table regardless of origin.DateGrouperandDisplayVarsnew DateTime()sites throughsafeCreate()(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
MultiDayResolvermigrates to the shared helper, its inlineis_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 even2026-??-??(year only).Options to decide before coding the AI gate:
eventStatus-style flag) so the calendar can show "Coming July 2026 — date TBA" instead of dropping it. Richer, but touches schema, block, render, andschema.orgmapping.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.Y-m-dmodel dates per the chosen product behavior.event-dates-sync.phpcannot write a malformedDATETIME.DateGrouper+DisplayVarsrender sites usesafeCreate()and cannot throw on a bad stored row.MultiDayResolvermigrated to the shared helper (inlineis_valid_date()removed).References
MultiDayResolver.php(PR fix: guard MultiDayResolver against malformed placeholder dates #393)