$maxLen) self::fail("{$key} exceeds {$maxLen} chars"); return $body[$key]; } public static function optionalString(array $body, string $key, int $maxLen = 1024): ?string { if (!isset($body[$key])) return null; if (!is_string($body[$key])) self::fail("{$key} must be a string"); if (strlen($body[$key]) > $maxLen) self::fail("{$key} exceeds {$maxLen} chars"); return $body[$key]; } public static function requireInt(array $body, string $key): int { if (!isset($body[$key]) || !is_int($body[$key])) { self::fail("missing or non-int field: {$key}"); } return $body[$key]; } public static function optionalInt(array $body, string $key): ?int { if (!isset($body[$key]) || $body[$key] === null) return null; if (!is_int($body[$key])) self::fail("{$key} must be an integer"); return $body[$key]; } /** * A calendar date as 'YYYY-MM-DD', or null. * * Absent, null, and '' all mean "no date" (the plugin sends '' when the tech * clears the field). Anything else must be a real date in exactly that * format — the round-trip comparison rejects both sloppy input ('2026-2-3') * and impossible dates ('2026-02-30', which createFromFormat would silently * roll forward to March 2nd). */ public static function optionalDate(array $body, string $key): ?string { if (!isset($body[$key])) return null; if (!is_string($body[$key])) self::fail("{$key} must be a YYYY-MM-DD date string or null"); $value = trim($body[$key]); if ($value === '') return null; $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); if ($date === false || $date->format('Y-m-d') !== $value) { self::fail("{$key} must be a valid calendar date in YYYY-MM-DD format"); } return $value; } public static function status(string $status): string { $valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a']; if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid)); return $status; } }