Skip to content

fix(calendar-sync): back-catalog envelope, date-push indexing, reentrant guard (FM-CAL-BACKCATALOG-FIX)#77

Merged
keyxmakerx merged 1 commit into
mainfrom
claude/fm-cal-backcatalog-fix
Jul 11, 2026
Merged

fix(calendar-sync): back-catalog envelope, date-push indexing, reentrant guard (FM-CAL-BACKCATALOG-FIX)#77
keyxmakerx merged 1 commit into
mainfrom
claude/fm-cal-backcatalog-fix

Conversation

@keyxmakerx

Copy link
Copy Markdown
Owner

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 art FM-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/events envelope {"data":[…],"total":N} at Chronicle/internal/plugins/syncapi/calendar_api_handler.go:335-338; PUT /calendar/date is 1-indexed (SetDate, :585-611); GET /calendar returns the raw Calendar struct (GetCalendar, :67). Calendaria dateTimeChange payload pinned in source (below)
Foundry compatibility: date-shape pinned against Calendaria release-1.1.3 source (Sayshal/calendaria @ ee04e44); no Foundry-version-specific API used. Recommend a v14 live smoke of the real dateTimeChange payload (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 authored FM-CAL-BACKCATALOG-FIX to 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)

Bug Anchor Confirmed
BLOCKER calendar-sync.mjs:1315 if (!Array.isArray(events)) continue; where events = await this._api.get(path) api-client.get() (:290) returns the parsed body verbatim; Chronicle wraps events in an envelope → never an array → every window continues → 0 events
HIGH _onCalendariaDateTimeChange (:651-668) reads data.year / data.month / data.dayOfMonth ?? data.day off the top level The real payload nests under .current → top-level fields are all undefined → the PUT pushed a garbage date
MEDIUM 1 init (: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 loop A WS event's finally clears the guard the loop still depends on

In-repo envelope-unwrap precedent: getNotes() (api-client.mjs:349-357) already unwraps data.data. The SimpleCalendar push already sends (month??0)+1 (calendar-sync.mjs:841) — confirming /calendar/date is 1-indexed.

Calendaria 1.1.3 dateTimeChange payload — PINNED IN SOURCE

Ref: tag release-1.1.3 = commit ee04e441968b965132a137bdab774d84c7023949 (github.com/Sayshal/calendaria), read from the readable scripts/** source.

  • NESTED under .current. time-tracker.mjs#L97-L107: Hooks.callAll('calendaria.dateTimeChange', { previous, current: {...currentComponents, year: year+yearZero}, diff, calendar, worldTime }). Date fields live at payload.current.*, not top level.
  • RAW 0-indexed, field dayOfMonth. .current is a spread of game.time.components; api.mjs#L104-L112 toPublic (month=(month??0)+1; day=(dayOfMonth??0)+1; delete dayOfMonth) is the +1 conversion that is NOT applied in the hook.
  • current.year is already yearZero-adjusted (absolute) — must not be offset.
  • hour/minute/second ride in the .current spread; no index adjustment.
  • ⚠ day-of-YEAR day sibling. Because .current spreads game.time.components, it also carries Foundry core's day = day-of-year ordinal (0-indexed) — a different value from dayOfMonth (api.mjs#L190-L191 destructures dayOfMonth out separately, leaving day in ...rest).

Stop-and-flag verdict

  • Payload is 0-indexed AND nested → MATCHES the dispatch expectation. The explicit trigger ("1-indexed AND flat") is not met → proceed (no stop).
  • Envelope unwrap kept LOCAL to the one back-catalog call site → none of the other 25 _api.get() callers is touched → no other-caller breakage.
  • No third cause of "syncing to nothing" surfaced beyond bugs 1–2.

What changed & why (per item)

Item 1 — BLOCKER: unwrap the {data,total} envelope · calendar-sync.mjs:1396-1399

const events = Array.isArray(payload)
  ? payload
  : (payload && Array.isArray(payload.data) ? payload.data : null);
if (!events) continue;

Kept 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-740

const src = (data && data.current) ? data.current : data;
if (!src) return;
const startDate = (src.dayOfMonth !== undefined)
  ? { year: src.year, month: src.month, dayOfMonth: src.dayOfMonth }
  : src;
const date = chronicleDateFromCalendariaStartDate(startDate);
if (!date) return;
// PUT { year: date.year, month: date.month, day: date.day, hour: src.hour ?? 0, minute: src.minute ?? 0 }

The 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 .current verbatim would ship a bug: the helper keys on shape ("has a day field → already public → passthrough"), and .current's day-of-YEAR day sibling 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 the day sibling by building a clean {year,month,dayOfMonth} stub — only when the raw dayOfMonth is present; a genuinely public payload (has day, no dayOfMonth) still takes the helper's passthrough branch. The shared helper is unmodified except a doc CAUTION note (:178), so #76 note-path behavior is byte-identical.

Sibling raw-read sweep (dispatch requirement): _onLocalDateChange (legacy calendariaDateChange, :876) and _onLocalEventCreate read day (not dayOfMonth) — a different, already-1-indexed legacy shape, not the 0-indexed-dayOfMonth mis-read class; left unchanged (routing them through the helper would be a no-op for the day shape and risk the legacy assumption). _onSimpleCalendarDateChange already applies +1. Only _onCalendariaDateTimeChange reproduced the bug.

Item 3 — MEDIUM: reentrant _syncDepth guard · calendar-sync.mjs:315,354-356 + 5 pairs

The boolean is replaced with a depth counter read through a _syncing getter (get _syncing() { return this._syncDepth > 0; }). Constructor inits _syncDepth = 0; the 5 set/clear pairs do _syncDepth++ / _syncDepth-- in finally. The 12 if (this._syncing) return; read-sites are unchanged (they read the getter). A WS event firing mid-back-catalog increments to 2 and its finally returns to 1 — the loop stays suppressed. _syncing is never read/written outside the class, so the getter is safe.

Ride-alongs (LOW) · calendar-sync.mjs

  • Weekday-unreadable guard (compareCalendarStructures, :279 + doc :236): the weekday count is compared only when both sides expose a real count (> 0). A 0 means 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.
  • Leap-year documented exclusion (doc :236): Chronicle exposes leap rules, but the Foundry structure reader surfaces only base per-month days and pinning Calendaria's leap representation is out of scope (RC-9: no leap inference) — leap variance is a documented exclusion, not compared.
  • Banner (:528): appended "then reload the world" (the pause is unrecoverable in-session).

Files touched

File Change Load-bearing lines
scripts/calendar-sync.mjs all 3 fixes + 3 ride-alongs + 2 doc notes envelope 1396-1399; date-push 722-740; guard init 315, getter 354-356, _syncDepth++ at 634,649,664,1144,1375 / -- at 638,653,668,1181,1418; weekday 279; banner 528; helper CAUTION 178; compare doc 236
tools/test-calendar-backcatalog-fix.mjs new — 17 regression tests (BLOCKER×4, HIGH×7, MEDIUM×2, ride-along×4)
tools/test-calendar-sync-hotfix.mjs makeCalendarSync seeds _syncDepth:0; the back-catalog test stubs the real {data,total} envelope instead of a bare array 71-79, 154-171
CLAUDE.md convention line: calendar-sync uses a reentrant _syncDepth counter, not a boolean

Tests

node --test tools/test-calendar-backcatalog-fix.mjs   → 17/17 pass
node --test tools/test-calendar-sync-hotfix.mjs        → 17/17 pass
node --test tools/test-*.mjs   (full CI suite)         → 609/609 pass
node tools/check-package-descriptor.mjs                → OK (0 warnings)
node -c scripts/calendar-sync.mjs                      → parses

Genuine regression pins (verified by temporary revert, then restored):

  • Revert the unwrap → BLOCKER tests 1–2 fail (0 events created).
  • Revert the date-push to the top-level raw read → HIGH tests 5–9 fail (wrong/undefined dates).
  • Revert the guard to a boolean → MEDIUM tests 12–13 fail (guard unmasked mid-loop).

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

  1. The clean-stub deviation (item 2). Confirm the day-of-year day sibling makes the literal (data.current ?? data) unsafe and that stripping to {year,month,dayOfMonth} is right. Source-pinned; the day sibling is Foundry-core game.time.components behavior — worth a 30-second live console.log of the real payload on a v14 client before the release smoke.
  2. Weekday-unreadable ride-along. Confirm skipping the weekday check when a side reads 0 does not mask a divergence you care about (month structure still fail-closes; only the weekday dimension is skipped, and only when unreadable).
  3. Leap-year: accept the documented exclusion (no leap inference) vs. wanting a symmetric leap comparison later.
  4. /calendar vs /calendar/events envelope asymmetry (intentional, verified). /calendar/events returns {data,total} (unwrapped here); /calendar returns the raw struct (GetCalendarc.JSON(200, cal), calendar_api_handler.go:67), read unwrapped at calendar-sync.mjs:435. Correct today; the onInitialSync composed path has no headless test (every calendar-sync test drives the units directly).
  5. Out-of-scope observation (not chased, per stop-and-flag fix: add release workflow and fix module.json manifest URLs #3): map-sync.mjs:415 /maps and actor-sync.mjs:631 /entity-types list get() 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-*.mjs passes locally (609/609)
  • node tools/check-package-descriptor.mjs passes (OK, 0 warnings)
  • Manual verification in Foundry (v14): world with existing Chronicle events; confirm back-catalog creates N>0 notes; advance the Calendaria date and confirm the pushed Chronicle date is correct (see re-verify feat: strip Chronicle backend, keep only Foundry VTT module #1)
  • CI (descriptor check + node --test) will run on push

Tenet self-check

  • T-B1 security: n/a — sync-correctness only; no signed-URL / auth-token / trust-boundary surface touched
  • T-B2 plugin isolation: this module IS the foundry-vtt half; all changes stay within scripts/calendar-sync.mjs (+ its tests + CLAUDE.md)
  • T-B3 production UI: the only UI change is one warning-banner string; no state transitions involved
  • T-B4 dual-audience docs: the CAUTION and compareCalendarStructures doc comments + CLAUDE.md convention 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. .current verbatim) is a Step-0-driven correction the dispatch pre-authorized, documented in full above; passing .current verbatim would have shipped a mis-dating bug.

🤖 Generated with Claude Code

https://claude.ai/code/session_012vaBdb1cgA7pvf2mRR8Mu9


Generated by Claude Code

…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
@keyxmakerx keyxmakerx merged commit fc45423 into main Jul 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant