diff --git a/CLAUDE.md b/CLAUDE.md
index 20e1bcc..8cfd690 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -143,6 +143,28 @@ Integration — Install & Updates".
confirm) and the dashboard Calendar tab (Foundry local date now renders, the
four-state sync badge — in-sync / date-drift with direction /
incompatible-structures / paused, FM-SYNC-WIRE-FIX — and Push-date button).
+- **Blocked on Chronicle (calv4 fix R1, item 6) — THE MODULE CANNOT BE POINTED
+ AT A DIFFERENT CHRONICLE CALENDAR, and until it can, "author the matching
+ calendar in Chronicle" is not advice.** Measured against Chronicle's source:
+ `POST /api/v1/campaigns/:cid/calendar` answers a structured 409
+ `calendar_already_exists` whenever `GetCalendar(campaignID)` returns anything,
+ and that query is `… WHERE campaign_id = ? ORDER BY is_default DESC,
+ sort_order ASC LIMIT 1` — ANY calendar. A campaign showing a structure
+ mismatch has one by construction, so the import door is closed 100% of the
+ time. And `calendarService.CreateCalendar` sets `IsDefault: isFirst`, so a
+ calendar authored in the builder is never the default, while the module is
+ served the default by that same ordering; `SetDefaultCalendar` is on
+ Chronicle's service interface with **no route, no handler and no control on
+ any page**. The mismatch remedy therefore now says the reachable thing — edit
+ either calendar so the two agree — and the unreachable thing is booked here
+ rather than printed. **What Chronicle owes:** a way to mark a calendar as the
+ campaign default (wire `SetDefaultCalendar` to a route and a control), and/or
+ a `?calId=` the syncapi honours so the module can name the calendar it wants.
+ Either one turns "author the matching calendar" back into real advice.
+ Guarded by `tools/test-calendar-mismatch-remedy.mjs`, which fails if any of
+ the three mismatch prints starts recommending an import or a new calendar
+ again.
+
- **Blocked on Chronicle (FM-SYNC-SUBRESOURCES-P1 Step 0):**
`calendar.worldstate.changed` is published by
`internal/plugins/calendar/worldstate_service.go` but has no `case` in
diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs
index 340d598..25d25f2 100644
--- a/scripts/calendar-sync.mjs
+++ b/scripts/calendar-sync.mjs
@@ -733,8 +733,41 @@ export class CalendarSync {
const foundryShape = `${(foundryStruct?.monthDays || []).length}mo/${foundryStruct?.weekdayCount ?? 0}wd`;
this._calendarMismatchDetail =
`Chronicle: ${chronicleName} ${chronicleShape} · Foundry: ${foundryName} ${foundryShape} — ${detail}`;
+ // THE REMEDY IS AN EDIT, NOT AN IMPORT AND NOT A NEW CALENDAR.
+ //
+ // This line used to read "import or author the matching calendar in
+ // Chronicle" and NEITHER HALF WAS REACHABLE, measured against Chronicle's
+ // source:
+ //
+ // IMPORT — `POST /api/v1/campaigns/:cid/calendar` answers a structured
+ // 409 `calendar_already_exists` whenever the campaign has ANY calendar
+ // (`… ORDER BY is_default DESC, sort_order ASC LIMIT 1`). When this
+ // warning fires the campaign HAS one by construction: the mismatch was
+ // computed by comparing against it. The door is closed 100% of the time
+ // this advice appears.
+ //
+ // AUTHOR — Chronicle's `CreateCalendar` sets `IsDefault: isFirst`, so a
+ // newly authored calendar is not the default; this module is served the
+ // default (same ordering); and `SetDefaultCalendar` exists on Chronicle's
+ // service interface with NO route, handler or control anywhere. An
+ // authored calendar therefore never reaches the wire and the operator has
+ // nothing to click that would change that.
+ //
+ // What IS reachable is making the two structures agree by editing one of
+ // the calendars that already exist — Chronicle's Calendar Settings owns
+ // months and weekdays, which are exactly the facts compared here, and
+ // Calendaria / Simple Calendar own the Foundry side. Either edit clears it.
+ //
+ // Pointing this module at a DIFFERENT Chronicle calendar remains genuinely
+ // impossible and is BOOKED (CLAUDE.md → Blocked on Chronicle) rather than
+ // printed as an instruction. Advice that cannot be followed is worse than
+ // no advice: the operator spends the session believing the fix is theirs.
+ //
+ // Pinned by tools/test-calendar-mismatch-remedy.mjs, which holds this
+ // string and the dashboard's two banners to the same verdict.
const msg = `Chronicle Sync: calendar structures differ (${this._calendarMismatchDetail}). `
- + 'Calendar sync is paused for this session — import or author the matching calendar in Chronicle, '
+ + 'Calendar sync is paused for this session — edit either calendar so the two agree '
+ + '(Chronicle: Calendar Settings → Months / Weekdays; Foundry: your calendar module), '
+ 'then reload the world. '
+ '(Journals, characters, and maps still sync.)';
console.warn(msg);
diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs
index 4f280a7..ec11fa1 100644
--- a/templates/sync-dashboard.hbs
+++ b/templates/sync-dashboard.hbs
@@ -893,7 +893,14 @@
Calendar sync paused — structure mismatch.
{{calendar.syncStateDetail}}
-
Import or author the matching calendar in Chronicle. Journals, characters, and maps still sync.
+ {{!-- THE REMEDY IS AN EDIT. "Import or author the matching calendar
+ in Chronicle" was unreachable both ways: the create endpoint
+ 409s whenever the campaign has any calendar (always true here),
+ and an authored calendar is not is_default with no control
+ anywhere to make it one, so this module keeps being served the
+ old one. Editing either calendar's months/weekdays IS one page
+ away. See tools/test-calendar-mismatch-remedy.mjs. --}}
+
Edit either calendar so the two agree — in Chronicle, Calendar Settings → Months / Weekdays; in Foundry, your calendar module. Journals, characters, and maps still sync.
{{else if calendar.isIncompatible}}
@@ -902,7 +909,7 @@
Calendar structures don’t match.
{{calendar.syncStateDetail}}
-
The dates below aren’t comparable across the wire. Sync will pause on the next world reload — import or author the matching calendar in Chronicle.
+
The dates below aren’t comparable across the wire. Sync will pause on the next world reload — edit either calendar so the two agree, in Chronicle’s Calendar Settings (Months / Weekdays) or in your Foundry calendar module.
{{/if}}
diff --git a/tools/test-calendar-mismatch-remedy.mjs b/tools/test-calendar-mismatch-remedy.mjs
new file mode 100644
index 0000000..e725327
--- /dev/null
+++ b/tools/test-calendar-mismatch-remedy.mjs
@@ -0,0 +1,166 @@
+// test-calendar-mismatch-remedy.mjs — THE STRUCTURE-MISMATCH ADVICE HAS TO BE
+// SOMETHING THE OPERATOR CAN ACTUALLY DO.
+//
+// Run: node --test tools/test-calendar-mismatch-remedy.mjs
+//
+// WHAT WAS WRONG. All three places the module reports a calendar-structure
+// mismatch — the permanent `ui.notifications.warn` from
+// `_pauseCalendarSyncForMismatch`, and the dashboard's two Calendar-tab banners
+// (paused / incompatible) — printed the same remedy:
+//
+// "Import or author the matching calendar in Chronicle."
+//
+// NEITHER HALF OF THAT IS REACHABLE, and both were measured against Chronicle's
+// source rather than guessed:
+//
+// IMPORT. `POST /api/v1/campaigns/:cid/calendar` (api_handler.go's
+// CreateCalendar) answers a structured 409 `calendar_already_exists` whenever
+// `GetCalendar(campaignID)` returns anything, and that repo query is
+// `… WHERE campaign_id = ? ORDER BY is_default DESC, sort_order ASC LIMIT 1`
+// — i.e. ANY calendar at all. When this banner is on screen the campaign HAS
+// a calendar, by construction: the mismatch was computed by comparing against
+// it. So the import door is closed 100% of the time this advice is shown.
+//
+// AUTHOR. A calendar authored in Chronicle's builder is NOT the default:
+// `calendarService.CreateCalendar` sets `IsDefault: isFirst` — only the first
+// calendar in a campaign is ever marked. And the module reads Chronicle's
+// calendar through the same `is_default DESC, sort_order ASC LIMIT 1`
+// ordering, so it keeps being served the OLD calendar. `SetDefaultCalendar`
+// exists on Chronicle's service interface and HAS NO CALLER — no route, no
+// handler, no control on any page. So the authored calendar is invisible
+// across the wire and there is nothing the operator can click to change that.
+//
+// WHAT IS REACHABLE, and is what the advice now says: make the two structures
+// match by EDITING one of the calendars that already exist. Chronicle's
+// 10-tab structure editor (Calendar Settings → Months / Weekdays) is exactly
+// the surface for the month-count / month-length / weekday-count facts this
+// guard compares, and the Foundry side is editable in Calendaria / Simple
+// Calendar. Either edit closes the mismatch, and both are one page away.
+//
+// THE BOOKED GAP. "Point the module at a DIFFERENT Chronicle calendar" remains
+// genuinely impossible and is recorded as such (CLAUDE.md → Blocked on
+// Chronicle) rather than printed as an instruction. That is the difference this
+// file is guarding: advice that cannot be followed is worse than no advice,
+// because the operator spends the session believing the fix is theirs to make.
+//
+// THE THREE STRINGS ARE ALSO PINNED TO EACH OTHER. Three prints of one remedy
+// drift; this test reads all three from where they ship and requires the same
+// verdict from each.
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+// --- Foundry global stubs ---------------------------------------------------
+globalThis.foundry = globalThis.foundry || {
+ applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (b) => b } },
+};
+globalThis.game = globalThis.game || {
+ settings: { get: () => '', set: () => {}, register: () => {}, registerMenu: () => {} },
+ i18n: { localize: (k) => k, format: (k) => k },
+ modules: { get: () => null },
+ users: [],
+ user: { id: 'u1', isGM: true },
+ journal: { find: () => null },
+};
+globalThis.Hooks = globalThis.Hooks || { on: () => {}, once: () => {}, off: () => {} };
+
+const { CalendarSync } = await import('../scripts/calendar-sync.mjs');
+
+/** The permanent toast the pause path raises. */
+function toastText() {
+ const cs = new CalendarSync({}, {});
+ let msg = '';
+ const prevUI = globalThis.ui;
+ globalThis.ui = { notifications: { warn: (m) => { msg = m; } } };
+ const prevWarn = console.warn;
+ console.warn = () => {};
+ try {
+ cs._pauseCalendarSyncForMismatch(
+ { name: 'Harptos', months: new Array(12), weekdays: new Array(10) },
+ { name: 'Gregorian', monthDays: new Array(12), weekdayCount: 7 },
+ 'weekday count 10 vs 7',
+ );
+ } finally {
+ console.warn = prevWarn;
+ globalThis.ui = prevUI;
+ }
+ return msg;
+}
+
+/** The dashboard's two Calendar-tab mismatch hints, read from the template. */
+function bannerHints() {
+ const hbs = readFileSync(join(root, 'templates', 'sync-dashboard.hbs'), 'utf8');
+ const block = hbs.slice(hbs.indexOf('calendar-mismatch-banner'));
+ const hints = [...block.matchAll(/([\s\S]*?)<\/div>/g)].map((m) => m[1].trim());
+ assert.equal(hints.length, 2,
+ 'expected exactly two calendar-mismatch action hints (paused + incompatible) — '
+ + 'if the template grew a third, it has to be held to the same standard');
+ return hints;
+}
+
+/**
+ * The unreachable instructions, as patterns. Each is paired with the Chronicle
+ * source fact that closes it, so a future reader can re-verify rather than
+ * trust this file.
+ */
+const UNREACHABLE = [
+ {
+ re: /\bimport(ing)?\b[^.]*\bcalendar\b|\bcalendar\b[^.]*\bimport(ing)?\b/i,
+ why: 'POST /api/v1/campaigns/:cid/calendar returns 409 calendar_already_exists '
+ + 'whenever the campaign has ANY calendar, which is always true when a '
+ + 'structure mismatch has been computed against one',
+ },
+ {
+ re: /\bauthor\b|\bcreate a (new )?calendar\b|\bnew calendar\b/i,
+ why: 'calendarService.CreateCalendar sets IsDefault only for the FIRST '
+ + 'calendar in a campaign, the module is served '
+ + '`ORDER BY is_default DESC, sort_order ASC LIMIT 1`, and Chronicle\'s '
+ + 'SetDefaultCalendar has no route, handler or control — so an authored '
+ + 'calendar never reaches the wire',
+ },
+];
+
+/** The reachable remedy has to be named, not merely implied. */
+const REACHABLE = /calendar settings|months|weekdays|structure|edit/i;
+
+test('the mismatch remedy never tells the operator to import or author a calendar', () => {
+ const texts = [['the pause toast', toastText()], ...bannerHints().map((h, i) => [`banner hint ${i + 1}`, h])];
+ for (const [label, text] of texts) {
+ assert.ok(text && text.length > 0, `${label} is empty — there is nothing to check`);
+ for (const { re, why } of UNREACHABLE) {
+ assert.equal(re.test(text), false,
+ `${label} tells the operator to do something that cannot be done:\n`
+ + ` ${text}\n`
+ + ` ${why}`);
+ }
+ }
+});
+
+test('the mismatch remedy names a reachable action instead', () => {
+ const texts = [['the pause toast', toastText()], ...bannerHints().map((h, i) => [`banner hint ${i + 1}`, h])];
+ for (const [label, text] of texts) {
+ assert.ok(REACHABLE.test(text),
+ `${label} removes the impossible advice without offering the possible one:\n`
+ + ` ${text}\n`
+ + ' Editing either calendar so the two structures match IS reachable — '
+ + 'Chronicle\'s Calendar Settings owns months and weekdays, and Calendaria / '
+ + 'Simple Calendar own the Foundry side. An empty remedy is honest but useless.');
+ }
+});
+
+test('the pause toast still says sync is paused and what keeps working', () => {
+ // The two facts the remedy must not cost while it is being rewritten: the
+ // operator has to know calendar sync stopped, and that the rest did not.
+ const text = toastText();
+ assert.match(text, /paused/i, 'the toast must still say calendar sync is paused');
+ assert.match(text, /journals/i,
+ 'the toast must still say journals, characters and maps keep syncing — that '
+ + 'sentence is what stops a structure mismatch reading as a dead integration');
+ assert.match(text, /reload the world/i,
+ 'the pause is for the session, so the toast must still say a reload is what clears it');
+});