fix(calendar-sync): back-catalog envelope, date-push indexing, reentrant guard (FM-CAL-BACKCATALOG-FIX)#77
Merged
Conversation
…entrant guard Post-merge review of #76 (FM-CAL-BACKCATALOG-FIX) found the back-catalog sync was a silent no-op and the Foundry->Chronicle date push mis-dated. - BLOCKER: the back-catalog now unwraps Chronicle's {data,total} envelope. api-client.get() returns the parsed body verbatim and GET /calendar/events wraps events in an envelope, so the old `if (!Array.isArray(events)) continue` skipped every month window -> 0 events. The unwrap is kept local to the call site, so get()'s contract for the other 25 callers is unchanged. - HIGH: _onCalendariaDateTimeChange now reads the RAW 0-indexed components nested under `.current` (Calendaria 1.1.3 dateTimeChange payload, verified in source) and corrects them through the shared helper, ignoring the day-of-YEAR `day` sibling that rides in game.time.components. The old top-level read sent undefined fields (nesting) with no 0->1 correction. - MEDIUM: the echo-suppression guard is now a reentrant _syncDepth counter, read through a _syncing getter, so a WebSocket event resolving mid-back-catalog no longer clears the guard the loop depends on -> no duplicate re-POST. - Ride-alongs: skip the weekday structure check when either side is unreadable (count 0); document the leap-year comparison exclusion; append "then reload the world" to the mismatch banner. New regression tests (tools/test-calendar-backcatalog-fix.mjs) fail on each pre-fix behavior, verified by temporary revert. Full suite 609/609 green; descriptor check green. Cites: 2026-05-21-core-tenets §T-O2 (consumer-verified wire contract), §T-B4 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vaBdb1cgA7pvf2mRR8Mu9
This was referenced Jul 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cites:
2026-05-21-core-tenets §T-O2(consumer-verified wire contract),§T-B4(footgun-catalog code comments);reports/coordinator/2026-07-11-r13-post-merge-review.md §1(RC-8/RC-9);dispatches/foundry/FM-CAL-BACKCATALOG-FIX.md; conventions RC-7 (this body is the review artifact); prior artFM-CAL-SYNC-HOTFIX(#76, the PR under repair)Security implication: none — sync-correctness fixes; no auth/token/trust-boundary surface touched
Consumer-verified:
GET /calendar/eventsenvelope{"data":[…],"total":N}atChronicle/internal/plugins/syncapi/calendar_api_handler.go:335-338;PUT /calendar/dateis 1-indexed (SetDate,:585-611);GET /calendarreturns the rawCalendarstruct (GetCalendar,:67). CalendariadateTimeChangepayload pinned in source (below)Foundry compatibility: date-shape pinned against Calendaria
release-1.1.3source (Sayshal/calendaria@ee04e44); no Foundry-version-specific API used. Recommend a v14 live smoke of the realdateTimeChangepayload (re-verify #1)Mockup: n/a (one banner wording change only)
What this changes
Executes the post-merge review of #76 (r13 §1). Repairs the calendar back-catalog sync (a silent no-op that created zero events), the Foundry→Chronicle date push (pushed a garbage date because it read the wrong payload shape), and the echo-suppression guard (a boolean that a mid-loop WebSocket event could unmask → duplicate re-POSTs). One PR, branch off fresh
main@4523bff.Why
The r13 post-merge adversarial review (
reports/coordinator/2026-07-11-r13-post-merge-review.md §1) found one BLOCKER, one HIGH, and one MEDIUM in #76, and authoredFM-CAL-BACKCATALOG-FIXto fix them. FM release 0.1.28 + the operator live-smoke are ON HOLD behind this PR (RC-9).Step-0 — verified before any code
Anchors (all held on fresh
main@4523bff)calendar-sync.mjs:1315if (!Array.isArray(events)) continue;whereevents = await this._api.get(path)api-client.get()(:290) returns the parsed body verbatim; Chronicle wraps events in an envelope → never an array → every windowcontinues → 0 events_onCalendariaDateTimeChange(:651-668) readsdata.year / data.month / data.dayOfMonth ?? data.dayoff the top level.current→ top-level fields are allundefined→ the PUT pushed a garbage date:285) + 5 set/clear pairs (:586/590,:601/605,:616/620,:1071/1108,:1302/1334) share ONE boolean between WS handlers and the back-catalog loopfinallyclears the guard the loop still depends onIn-repo envelope-unwrap precedent:
getNotes()(api-client.mjs:349-357) already unwrapsdata.data. The SimpleCalendar push already sends(month??0)+1(calendar-sync.mjs:841) — confirming/calendar/dateis 1-indexed.Calendaria 1.1.3
dateTimeChangepayload — PINNED IN SOURCERef: tag
release-1.1.3= commitee04e441968b965132a137bdab774d84c7023949(github.com/Sayshal/calendaria), read from the readablescripts/**source..current.time-tracker.mjs#L97-L107:Hooks.callAll('calendaria.dateTimeChange', { previous, current: {...currentComponents, year: year+yearZero}, diff, calendar, worldTime }). Date fields live atpayload.current.*, not top level.dayOfMonth..currentis a spread ofgame.time.components;api.mjs#L104-L112toPublic(month=(month??0)+1; day=(dayOfMonth??0)+1; delete dayOfMonth) is the +1 conversion that is NOT applied in the hook.current.yearis already yearZero-adjusted (absolute) — must not be offset..currentspread; no index adjustment.daysibling. Because.currentspreadsgame.time.components, it also carries Foundry core'sday= day-of-year ordinal (0-indexed) — a different value fromdayOfMonth(api.mjs#L190-L191destructuresdayOfMonthout separately, leavingdayin...rest).Stop-and-flag verdict
_api.get()callers is touched → no other-caller breakage.What changed & why (per item)
Item 1 — BLOCKER: unwrap the
{data,total}envelope ·calendar-sync.mjs:1396-1399Kept inline at the single call site (not in
get()) so the contract for the other 25_api.get()callers is unchanged. An empty-but-valid envelope ({data:[],total:0}) creates nothing; a malformed body ({},null,{data:"x"}) skips the window.Item 2 — HIGH: route the date push through the correction helper ·
calendar-sync.mjs:722-740The 0→1 correction math stays in ONE place (the helper), exactly like the note paths after #76.
⚠ Honest deviation from the dispatch (pre-authorized). The dispatch suggested
chronicleDateFromCalendariaStartDate(data.current ?? data)— "(adjusted to whatever Step-0 pins)". Passing.currentverbatim would ship a bug: the helper keys on shape ("has adayfield → already public → passthrough"), and.current's day-of-YEARdaysibling trips that branch → it would use day-of-year as day-of-month and skip the month+1(for March 15 it returns{month:2, day:73}). So the handler strips thedaysibling by building a clean{year,month,dayOfMonth}stub — only when the rawdayOfMonthis present; a genuinely public payload (hasday, nodayOfMonth) still takes the helper's passthrough branch. The shared helper is unmodified except a docCAUTIONnote (:178), so #76 note-path behavior is byte-identical.Sibling raw-read sweep (dispatch requirement):
_onLocalDateChange(legacycalendariaDateChange,:876) and_onLocalEventCreatereadday(notdayOfMonth) — a different, already-1-indexed legacy shape, not the 0-indexed-dayOfMonthmis-read class; left unchanged (routing them through the helper would be a no-op for thedayshape and risk the legacy assumption)._onSimpleCalendarDateChangealready applies+1. Only_onCalendariaDateTimeChangereproduced the bug.Item 3 — MEDIUM: reentrant
_syncDepthguard ·calendar-sync.mjs:315,354-356+ 5 pairsThe boolean is replaced with a depth counter read through a
_syncinggetter (get _syncing() { return this._syncDepth > 0; }). Constructor inits_syncDepth = 0; the 5 set/clear pairs do_syncDepth++/_syncDepth--infinally. The 12if (this._syncing) return;read-sites are unchanged (they read the getter). A WS event firing mid-back-catalog increments to 2 and itsfinallyreturns to 1 — the loop stays suppressed._syncingis never read/written outside the class, so the getter is safe.Ride-alongs (LOW) ·
calendar-sync.mjscompareCalendarStructures,:279+ doc:236): the weekday count is compared only when both sides expose a real count (> 0). A0means the list was unreadable (no real calendar has 0 weekdays); pausing on that while the month structure matches was a false positive. The month structure stays fail-closed.:236): Chronicle exposes leap rules, but the Foundry structure reader surfaces only base per-monthdaysand pinning Calendaria's leap representation is out of scope (RC-9: no leap inference) — leap variance is a documented exclusion, not compared.:528): appended "then reload the world" (the pause is unrecoverable in-session).Files touched
scripts/calendar-sync.mjs1396-1399; date-push722-740; guard init315, getter354-356,_syncDepth++at634,649,664,1144,1375/--at638,653,668,1181,1418; weekday279; banner528; helper CAUTION178; compare doc236tools/test-calendar-backcatalog-fix.mjstools/test-calendar-sync-hotfix.mjsmakeCalendarSyncseeds_syncDepth:0; the back-catalog test stubs the real{data,total}envelope instead of a bare array71-79,154-171CLAUDE.md_syncDepthcounter, not a booleanTests
Genuine regression pins (verified by temporary revert, then restored):
Adversarial pass. 3 independent skeptic lenses (date-push correctness / guard+envelope / dispatch-compliance) ran over the committed diff — zero real defects. One lens independently reconstructed the
{month:2, day:73}failure of the dispatch's literal and confirmed the clean-stub deviation is necessary and the shared helper byte-identical (note paths unregressed).What the coordinator should re-verify
daysibling makes the literal(data.current ?? data)unsafe and that stripping to{year,month,dayOfMonth}is right. Source-pinned; thedaysibling is Foundry-coregame.time.componentsbehavior — worth a 30-second liveconsole.logof the real payload on a v14 client before the release smoke./calendarvs/calendar/eventsenvelope asymmetry (intentional, verified)./calendar/eventsreturns{data,total}(unwrapped here);/calendarreturns the raw struct (GetCalendar→c.JSON(200, cal),calendar_api_handler.go:67), read unwrapped atcalendar-sync.mjs:435. Correct today; theonInitialSynccomposed path has no headless test (every calendar-sync test drives the units directly).map-sync.mjs:415/mapsandactor-sync.mjs:631/entity-typeslistget()callers may also receive{data:[…]}envelopes — not part of the calendar symptom; possible separate audit.After merge: operator hand-creates release
0.1.28(tag on main, no version input) and updates the package in Chronicle admin, then live-smokes with the #76 checklist PLUS "back-catalog creates N>0 notes on a world with existing Chronicle events".Test plan
node --test tools/test-*.mjspasses locally (609/609)node tools/check-package-descriptor.mjspasses (OK, 0 warnings)node --test) will run on pushTenet self-check
scripts/calendar-sync.mjs(+ its tests +CLAUDE.md)CAUTIONandcompareCalendarStructuresdoc comments +CLAUDE.mdconvention line serve both humans and AI sessions (footgun catalog)Stop-and-flag
No tenet violation found. The one deviation from the dispatch's literal suggestion (clean stub vs.
.currentverbatim) is a Step-0-driven correction the dispatch pre-authorized, documented in full above; passing.currentverbatim would have shipped a mis-dating bug.🤖 Generated with Claude Code
https://claude.ai/code/session_012vaBdb1cgA7pvf2mRR8Mu9
Generated by Claude Code