From 74c91a71431c2b8658ea338cd684a72a013cc865 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 7 Nov 2026 01:57:54 +0100 Subject: [PATCH] docs: design and plan exact end times for Genshin and Miliastra Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-11-07-exact-end-times.md | 190 ++++++++++++++++++ .../2026-11-07-exact-end-times-design.md | 165 +++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-07-exact-end-times.md create mode 100644 docs/superpowers/specs/2026-11-07-exact-end-times-design.md diff --git a/docs/superpowers/plans/2026-11-07-exact-end-times.md b/docs/superpowers/plans/2026-11-07-exact-end-times.md new file mode 100644 index 0000000..21d9b59 --- /dev/null +++ b/docs/superpowers/plans/2026-11-07-exact-end-times.md @@ -0,0 +1,190 @@ +# Exact End Times Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A live Genshin or Miliastra row shows the end its Fandom article states, to the minute, and otherwise behaves as today. + +**Architecture:** The refresh stores the lead section of each listed event's article beside the other snapshots. The feed build parses those offline: `fandom.ts` uses them only to decide currency, and a pure step after `mergeEvents` writes the stated end onto the merged survivor. + +**Tech Stack:** Bun 1.3, TypeScript strict, `bun test`. No new dependency. + +**Spec:** `docs/superpowers/specs/2026-11-07-exact-end-times-design.md` + +## Global Constraints + +- `id`, `startsAt`, `sourceUrl`, `sourceId` and `classification` are never changed by this work. +- Only `genshin-fandom-events` rows are read. Other games are out of scope. +- The build and all tests never touch the network. Tests never need build output. +- No time is invented: only the `data-source="time_end"` infobox cell, in one of two printed shapes. +- Stored times are as printed (14:59, not 15:00). +- Article failures never affect a source's failure streak, health or broken-source count. +- Stage by path; a commit is its own command, never chained with an edit (AGENTS.md). + +## Review Focus + +- A percent-encoded title (`The_Forge_Realm%27s_Temper/2026-11-04`) must reach the API decoded and keep one stable file name. Test in Task 2. +- Two table rows linking the same article make one request per cycle. Test in Task 2. +- A row with no article link, whose `sourceUrl` is the API URL, makes no request and stays on fallback. Test in Task 2. +- An empty or unparsable source snapshot deletes no stored articles and throws nothing. Test in Task 2. +- A build with no `snapshots/articles` directory, as on a clean checkout, works and changes nothing. Test in Task 1. + +--- + +### Task 1: Read and apply a stated end, and keep a live row live + +**Files:** +- Create: `src/ingest/articleEnds.ts` +- Create: `fixtures/genshin/articles/hour-of-ruin-2026-11-07.html` (server time), `raiment-collection-2026-11-04-2026-11-07.html` (GMT+8): `section=0` API responses, captured once +- Modify: `src/ingest/adapters/types.ts` (`ParseContext`), `src/ingest/parsers/fandom.ts` (`parseGenshinEvents` currency check) +- Test: `test/article-ends.test.ts` + +**Interfaces:** +- Produces, from `src/ingest/articleEnds.ts`: + - `interface StatedEnd { day: string /* YYYY-MM-DD as printed */; endsAt: string; regionEnds: Record | null }` + - `parseArticleEnd(body: string): StatedEnd | null`: `body` is the API's JSON; anything malformed is `null` + - `articleTitle(sourceUrl: string): string | null`: decoded page title for a `genshin-impact.fandom.com/wiki/…` URL, else `null` + - `latestStatedMs(end: StatedEnd): number` + - `applyArticleEnds(merged: GachaEvent[], listed: GachaEvent[], ends: ReadonlyMap, warn: (line: string) => void): GachaEvent[]`, where `listed` is the Fandom parse before merging and `ends` is keyed by `articleTitle` +- Produces on `ParseContext`: optional `articleEnds?: ReadonlyMap` + +- [ ] **Step 1: Capture the two fixtures** with one request each, using the `section=0` URL from spec decision 5. + +- [ ] **Step 2: Write the failing tests** in `test/article-ends.test.ts` + +```ts +test("server time gives every region its own end", () => { + const end = parseArticleEnd(hourOfRuinFixture)!; + expect(end.day).toBe("2026-12-15"); + expect(end.regionEnds).toEqual({ + asia: "2026-12-15T06:59:00.000Z", + europe: "2026-12-15T13:59:00.000Z", + america: "2026-12-15T19:59:00.000Z", + }); + expect(end.endsAt).toBe("2026-12-15T06:59:00.000Z"); // earliest, as wikigg.ts does +}); +test("GMT+8 is one instant", () => { + const end = parseArticleEnd(raimentFixture)!; + expect(end).toEqual({ day: "2026-12-16", endsAt: "2026-12-15T21:59:00.000Z", regionEnds: null }); +}); +test.each(["December 15, 2026", "TBA", "December 15, 2026 14:59 UTC", "December 15, 2026 14:59 GMT+9", ""])( + "%s is no usable time", /* fixture with the cell text replaced → null */); +test("malformed JSON and a page with no infobox are null"); +test("the End header cell is not mistaken for the value"); // the carries data-source too +test("a merged Game8 survivor takes the end through mergedIds"); +// survivor id differs, mergedIds holds the Fandom id; regionScoped true, endPrecision "exact" +test("id, startsAt, sourceUrl, sourceId and classification never change"); +test("a table date that disagrees keeps the row and warns"); +test("a survivor date that disagrees with the table keeps the row and warns"); +test("a survivor already at exact precision is left alone"); +test("no articles means the same array contents as merged"); +test("a live row outlives the table's day on its stated end", () => { + // parse the Fandom fixture row for an event at now = just past latestBoundaryMs of its date: + // with articleEnds holding a later stated end it is emitted; without, it is not. +}); +``` + +Use `toEqual` against the input for the no-articles and unchanged-fields cases. The disagreeing-date cases use the real fixture with the End cell's date replaced. + +- [ ] **Step 3: Run:** `bun test test/article-ends.test.ts`. Expected: FAIL, module not found. + +- [ ] **Step 4: Implement `src/ingest/articleEnds.ts`.** Read `parse.text`, take `…`, flatten with `html.ts`'s `text`, then match only: + +```ts +/^([A-Z][a-z]+ \d{1,2}, \d{4}) (\d{1,2}):(\d{2}) (\(Server Time\)|GMT\+8)$/ +``` + +`(Server Time)` offsets come from `REGION_RESET_UTC_OFFSET`, not from `resetShiftMs`, whose game overrides concern resets. `applyArticleEnds` skips a survivor unless it has `endPrecision === "day"` (agent's call: an end another source stated exactly is not overridden) and both `listed.endsAt` and the survivor's `endsAt` start with `end.day`. It sets `endPrecision: "exact"`, `endsAt`, and `regionScoped`/`regionEnds` from the `StatedEnd`. + +- [ ] **Step 5: Add `articleEnds?` to `ParseContext`, and in `parseGenshinEvents`** compute the row's retirement on `latestStatedMs` when `ctx.articleEnds?.get(articleTitle(href))` exists and its `day` equals the table's date, else `latestBoundaryMs` as now. Emit the same row either way; the parser never writes the stated end. + +- [ ] **Step 6: Run:** `bun test test/article-ends.test.ts test/adapters/game8.test.ts`. Expected: PASS, including unchanged `.expected.json` rows. + +- [ ] **Step 7: Commit** `src/ingest/articleEnds.ts`, `src/ingest/adapters/types.ts`, `src/ingest/parsers/fandom.ts`, `test/article-ends.test.ts`, `fixtures/genshin/articles/` as `feat(ingest): read a stated end from a Genshin article`. + +### Task 2: Keep the articles fresh during a refresh + +**Files:** +- Create: `src/ingest/articleStore.ts`, `scripts/refresh-articles.ts` +- Modify: `src/ingest/snapshots.ts` (export `writeAtomic`), `scripts/refresh-sources.ts` (`RefreshOptions`, `runRefresh`, `main`), `.gitignore`, `snapshots/README.md` +- Test: `test/article-refresh.test.ts` + +**Interfaces:** +- Consumes: `articleTitle`, `parseArticleEnd` (Task 1). +- Produces, from `src/ingest/articleStore.ts`: + - `interface ArticleMeta { title: string; lastAttemptAt: string; lastReadAt: string | null; statesTime: boolean }` + - `class ArticleStore { constructor(root: string) /* /articles/genshin-fandom */; readMeta(title); readBody(title); saveRead(title, body, at); recordAttempt(title, at); remove(title); listed(): Promise; readEnds(): Promise> }` + - `articleFileName(title: string): string`: unsafe characters as `_` plus the first 8 hex of the title's hash + - `articleDue(meta: ArticleMeta | null, nowMs: number): boolean` +- Produces, from `scripts/refresh-articles.ts`: + - `refreshArticles(input: { titles: string[]; store: ArticleStore; robots: RobotsGate; fetchImpl: FetchLike; userAgent: string; now: () => Date; sleep: (ms: number) => Promise; dryRun: boolean; timeoutMs: number; log: (line: string) => void; requestedHosts: Set }): Promise<{ changed: number; warnings: string[] }>` +- Produces on `RefreshOptions`: optional `articleSourceId?: string`; on `RefreshSummary`: `articlesChanged: number` + +- [ ] **Step 1: Write the failing tests** in `test/article-refresh.test.ts`, driving `refreshArticles` with a stub fetch and a temp root: + +```ts +test("due: never read"); // articleDue(null, now) === true +test("due: no time found, only after 6 hours"); +test("due: time found, only 7 days after the last read that worked"); +test("due: a failed attempt inside 6 hours is not due, even past 7 days"); +test.each([500, 403])("HTTP %i keeps the body and the time, and warns"); +test("a network error keeps the body and the time, and warns"); +test("robots refusal makes no request, keeps the body, and warns"); +test("404 removes the stored article and warns"); +test("a 200 with a missingtitle API error removes the stored article and warns"); +test("a 200 with no usable End overwrites the body, sets statesTime false, and logs without warning"); +test("lastReadAt moves only on a successful read; lastAttemptAt on every attempt"); +test("the requested URL carries the decoded title, section=0, and the runner User-Agent"); +test("the same title twice is one request"); +test("per-host spacing applies when the host was already asked this cycle"); +test("a dry run requests and writes nothing"); +test("a stored article no longer listed is removed"); +``` + +And driving `runRefresh`: + +```ts +test("article reads run only with articleSourceId set; existing callers are unaffected"); +test("a row with no article link makes no article request"); +test("an empty source snapshot removes nothing and does not throw"); +test("article failures leave broken, hardFailure and the source's streak untouched"); +test("a changed article triggers the rebuild even when no source page changed"); +``` + +- [ ] **Step 2: Run:** `bun test test/article-refresh.test.ts`. Expected: FAIL, module not found. + +- [ ] **Step 3: Implement `articleStore.ts`.** `articleDue` is true when `meta` is null, or when at least 6h have passed since `lastAttemptAt` and either `statesTime` is false or at least 7 days have passed since `lastReadAt`. Body before meta, each through `writeAtomic`. `readEnds` skips a file `parseArticleEnd` returns null for, and returns an empty map when the directory does not exist. + +- [ ] **Step 4: Implement `refresh-articles.ts`** to spec decision 12's table. Sends no conditional headers: the API does not offer validators worth the state. Uses the `api.php` URL from spec decision 5 and the same 200-only, read-body-in-a-try discipline as `refreshOne`. Removes stored articles not in `titles` only when `titles` is non-empty. + +- [ ] **Step 5: Wire `runRefresh`.** After the source loop and before the hard-failure returns, when `articleSourceId` is among `selected`, parse that source's stored snapshot with the adapter at `now`, map rows through `articleTitle`, drop nulls, and call `refreshArticles`, sharing `cycle.requestedHosts`. Wrap the whole step in `try`, turning a throw into one warning. Push its warnings to `summary.warnings`. Rebuild when `summary.changed > 0 || summary.articlesChanged > 0`. `main` passes `articleSourceId: "genshin-fandom-events"`. `--force` is not forwarded. A dry run lists what is due. + +- [ ] **Step 6: Add `snapshots/articles/**/*.tmp-*` to `.gitignore`** and document the directory in `snapshots/README.md`, including why it holds no `.state.json`. + +- [ ] **Step 7: Run:** `bun test test/article-refresh.test.ts test/refresh.test.ts test/snapshots.test.ts`. Expected: PASS. + +- [ ] **Step 8: Commit** those paths as `feat(refresh): keep Genshin event articles beside the snapshots`. + +### Task 3: Build it into the feed and record it + +**Files:** +- Modify: `scripts/build-feed.ts`, `src/shared/changelog.ts`, `AGENTS.md`, `docs/INGESTION.md`, `docs/PRD.md`, `docs/2026-11-01-genshin-event-audit.md`, the spec's status line +- Test: `test/article-feed.test.ts` + +**Interfaces:** +- Consumes: `ArticleStore.readEnds` (Task 2), `applyArticleEnds` and `ParseContext.articleEnds` (Task 1). + +- [ ] **Step 1: Write the failing test** `test/article-feed.test.ts`: run `scripts/build-feed.ts` as a subprocess against a temp `SNAPSHOT_DIR` holding `genshin-fandom-events.html` and `.meta.json`, copied from a pinned `fixtures/genshin/articles/event-table-2026-11-06.html`, plus an `articles/genshin-fandom` copy of the Hour of Ruin fixture. Add two environment overrides to `build-feed.ts`, each defaulting to today's behaviour: `EVENT_FEED_OUT` for the output path, and `EVENT_FEED_NOW` pinned to `2026-11-07T00:00:00.000Z`, without which this test would fail once the fixture's events end. Assert Hour of Ruin's `regionEnds.europe` is `2026-12-15T13:59:00.000Z`, its `id` is `genshin:hour-of-ruin:2026-11-04`, and the feed still passes `EventFeed.parse`. + +- [ ] **Step 2: Run:** `bun test test/article-feed.test.ts`. Expected: FAIL, row still at day precision. + +- [ ] **Step 3: Wire `build-feed.ts`.** Read `ArticleStore.readEnds` once. Pass `articleEnds` in the context for `genshin-fandom-events` only. Keep that source's parsed rows. After `mergeEvents` for its game, apply `applyArticleEnds`, logging warnings as ` ! article: …`. Sort after applying. + +- [ ] **Step 4: Run:** `bun test && bun run typecheck`. Expected: all pass. + +- [ ] **Step 5: Check it for real, without touching the repo's data.** Copy `snapshots/` to a scratch directory, run `bun run refresh --snapshots --only genshin-fandom-events`, then build from it with `SNAPSHOT_DIR=` from an export. Expected: Test Run 17:59 server, Hour of Ruin 14:59 server, Raiment 05:59 GMT+8, passes 03:59 server. In a real browser on an HTTP test hostname: same countdowns on Europe, Raiment 38d 21h-style, and the detail sheet's "gave a date but no time" note gone from those rows but still shown for a Game8-only row. + +- [ ] **Step 6: Update docs:** `AGENTS.md` § Domain rules (a Fandom article may state the end) and the Genshin § Fandom bullets; `docs/INGESTION.md` (article step, decision 12's table); PRD § Quality bar; the audit doc gets the Forge Realm reading and marks open item 1 done; the spec's status. + +- [ ] **Step 7: Review:** run `schema-guardian` and fix what it finds. + +- [ ] **Step 8: Commit** the feature paths as `feat(feed): show the end a Genshin article states`, then the changelog separately as `chore(changelog): note exact Genshin end times ()`, after `bun run scripts/check-changelog.ts`. diff --git a/docs/superpowers/specs/2026-11-07-exact-end-times-design.md b/docs/superpowers/specs/2026-11-07-exact-end-times-design.md new file mode 100644 index 0000000..edff6c6 --- /dev/null +++ b/docs/superpowers/specs/2026-11-07-exact-end-times-design.md @@ -0,0 +1,165 @@ +# Exact end times for Genshin and Miliastra — design + +Date: 2026-11-07. Status: **approved** by the owner 2026-11-07; plan at `../plans/2026-11-07-exact-end-times.md`. + +## Why + +The owner trusts a countdown right up to the last hour before something closes. +Fandom's `Event` table prints dates only, so `clockFor` reads each end as that +game-day's reset. That is right for most events and hours wrong for the rest. It is +this product's named worst failure: a confidently wrong end date (PRD § Quality bar +for dates). The evidence is in `../../2026-11-01-genshin-event-audit.md` § Follow-up. + +Every live row's article, read 2026-11-07, against what the app shows on Europe: + +| Article's end | Rows | App today | +|---|---|---| +| 03:59 server time | 9: To Temper Cycle 6, Sacred Spirit Chronicle, Chronicle of Polyphony, Grand Defense, Stygian Onslaught, Stress Block, Frames of Fierce Splendor, Ley Line Overflow, Starlight Voyage | Correct | +| 05:59 GMT+8 | Raiment Collection, Forge Realm's Temper | 5h late on the article's reading | +| 14:59 server time | Encounter: Interlude, Hour of Ruin, Where Kristall Roots Run Deep | 11h early | +| 17:59 server time | Test Run | 14h early | + +In-game readings by the owner agree with the articles on Sacred Spirit Chronicle, +Chronicle of Polyphony, Raiment Collection and Test Run. Encounter has indirect +support: `Wonderland Treasures - Polyphonic Harmony` read 38d 13h 47m, landing on +the same 14:59. **One disagrees:** the Cat's Tail timer for Forge Realm's Temper read +38d 22h at about 00:30 UTC on 2026-11-07, implying 15 Dec about 22:30–23:30 UTC. The +article implies 14 Dec 21:59 UTC. See § Accepted exception. + +The miss is not a pattern a rule can fix: the same printed date can mean any of three +times, so each event's own stated time has to be read. + +## Goals + +- A live Genshin or Miliastra row shows the end its Fandom article states, to the minute. +- Reading the wiki stays polite: small requests, not repeated for what is already known. +- With no usable article time, a row behaves exactly as it does today. + +## Non-goals + +- Other games. Each has its own article layout; measure one when it is shown wrong. +- Start times. **`startsAt` must not change: it is half of the event ID.** +- The undated selector row, Wonderland Treasures, banners, and lane labels on Game8 + rows (audit open items 2–4). +- New interface. Countdowns and printed end times become more accurate; nothing is added. + +## Anti-goals: a failure even if it works + +- An event ID changes, or a reader's marks are orphaned. +- A time is invented or inferred, including from an owner's rounded in-game reading. +- A row disappears from the feed while its stated end is still ahead. +- A refresh's failure on an article blocks or fails the page sources. +- The build touches the network. + +## Constraints + +No LLM, no new runtime dependency, no HTML parsing library. Deterministic parsing only, +per AGENTS.md § Three constraints and § Scraping conduct. + +## Decisions + +Approved with this document unless marked as the agent's call. + +1. **Scope:** Genshin and Miliastra only (owner, 2026-11-07). Both come from + `genshin-fandom-events`, so this one source's rows are what gets read. +2. **Read an article when a row first appears, and keep what it said**, rather than + re-reading every article each refresh (owner, 2026-11-07, as a stated guess not + corrected). +3. **No usable time falls back to today's behaviour** (owner, same). +4. **Forge Realm's Temper is accepted as an exception,** not special-cased (owner, + 2026-11-07, see below). +5. **Article reads:** `api.php?action=parse&page=&prop=text§ion=0&formatversion=2&format=json`. + Agent's call. `section=0` holds the infobox: 8 KB rather than about 18 KB, verified + on `Hour_of_Ruin_(Event)`. Same host, same `/api.php?action=` path the robots note + on `genshin-fandom-events` already clears, same runner User-Agent, robots gate and + per-host spacing. +6. **When to read.** Agent's call. Only rows the current table lists and that have not + ended. An article is due when it has never been read; when the last read found no + time, once the 6-hour floor has passed; or when the last read found a time at least + 7 days ago, because wiki editors correct these and a kept wrong time would be + permanent. `--force` does not apply to articles. +7. **Storage: `snapshots/articles/genshin-fandom/<article-slug>.html` plus `.meta.json`,** + committed with the other snapshots. Agent's call. Raw bytes as served, written like + every other snapshot, so re-parsing never re-fetches and a changed time is a + reviewable diff. Rows no longer listed have their files deleted by the refresh. +8. **Read the End value of the infobox only:** the cell with `data-source="time_end"`. + Not the "Ends in" lines, the body prose, or the image names. The value has one of two + usable shapes: + + | Printed | Stored | Precision | + |---|---|---| + | `December 15, 2026 14:59 (Server Time)` | `regionScoped: true`, `regionEnds` for each region at that server's clock: Asia UTC+8, Europe UTC+1, America UTC−5 | exact | + | `December 16, 2026 05:59 GMT+8` | one instant in `endsAt`, `regionScoped: false` | exact | + + Anything else, including a date with no time or another zone, is no usable time. + Stored times are as printed, 14:59 and not 15:00. +9. **The article must agree on the date.** Its printed calendar date must equal the + date the table printed. If not, the time is ignored, the row keeps the table's date, + and the build logs a warning. A stale or wrongly linked article must not move an + event by days. This also keeps a wiki-side disagreement from silently shifting a row. + Agent's call: on a merged survivor the survivor's own end date must match as well. If + Game8 and Fandom disagree on the date, the row stays as merged, at day precision, with + a warning: `mergeEvents` flags such a disagreement for a person rather than resolving + it, and an article must not settle it unseen. No live row is in this state today. +10. **Apply after merge, in the feed build.** `mergeEvents` keeps the higher-confidence + copy, so Game8's day-precision row wins for Grand Defense, Stress Block, Frames and + Ley Line, and it would discard an end set on the Fandom copy. So the exact end is + applied to the merged survivor, matched by event ID or `mergedIds`. The merge's end + comparison keeps seeing table dates, so no new conflicts appear. Applying it changes + only `endsAt`, `endPrecision`, `regionScoped` and `regionEnds`; never `id`, + `startsAt`, `sourceUrl`, `sourceId` or `classification`. The application is a pure + function a test can reach, as `parseGame` keeps fetching out of parsing. +11. **Currency must follow the stated end.** `fandom.ts` retires a row on + `latestBoundaryMs` of the table's date. For Hour of Ruin that is 09:00Z while the + stated end is 19:59Z in the Americas, which would delete a live row for 11 hours on + its last day (an anti-goal). A row with a usable article time is retired on the + latest of those stated instants instead. How the parser receives this is left to the + builder; the parser stays offline and pure. +12. **Article failures are warnings, and what happens to a kept time depends on what the + wiki said.** Agent's call. None of these contributes to `genshin-fandom-events'` + failure streak, health or broken-source count. + + | Result of a read | Stored article | Row shows | + |---|---|---| + | Network error, 5xx, refused, or robots-blocked: the wiki told us nothing about the article | Kept untouched | The last good time, if any. Due again after the 6-hour floor | + | 404 or a missing-page API error: the article is gone or renamed | Deleted | Fallback | + | `200` whose End value is absent or unusable: the article no longer states a time | Overwritten | Fallback. Due again under the no-time rule | + + `.meta.json` records the last attempt separately from the last successful read, so the + 7-day rule counts from a read that worked. + +## Accepted exception + +Forge Realm's Temper would go from about 20h early to about 25h early against the +Cat's Tail reading. It may be that the timer covers something beyond the event, or that +the article is wrong; neither is established. Both errors are on the safe side, so the +reader leaves the event done before it closes. It is one reading, so no special case. +Recheck when the next `The Forge Realm's Temper` run starts. Record the reading in the +audit doc in the same change. + +Raiment Collection is the only confirmed-late row today, and it is corrected by this. + +## Left to the builder + +- Where the due-check and fetch sit in `scripts/refresh-sources.ts`, and the budget of + reads per run if one is needed. A new version adds about 15. +- Which form the region offsets take; reuse what `daily.ts`/`time.ts` already hold. +- What `endsAt` holds on a region-scoped row. Follow the existing wiki.gg precedent + and check `EventDetail.tsx`'s read of it. +- Whether a stale `firstSeenAt` or a bumped `version` applies; no change is the default. + +## Verification + +- Fixtures in `fixtures/genshin/` for one article of each shape, one stub with no time, + and one with a different date. Captured once, then tests stay offline. +- Tests: both shapes; rejected shapes; date disagreement, with the table and with a + merged survivor; each row of decision 12's table; application to a merged Game8 + survivor via `mergedIds`; IDs and `startsAt` unchanged; a row still live on its last + day past the table's boundary; an article failure leaving source health untouched; the + due rule. +- `schema-guardian` reviews the change, since it writes `regionEnds` for this source. +- After a real refresh: on Europe, the table above reproduces at Test Run 17:59, + Hour of Ruin 14:59, Raiment 05:59 GMT+8 and passes at 03:59, in a real browser. +- `bun test`, `bun run typecheck`, and the docs changed with it: `AGENTS.md` § Domain + rules and § Fandom, `docs/INGESTION.md`, PRD § Quality bar, `snapshots/README.md`. +- A reader sees this, so it gets a changelog entry per AGENTS.md.