Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions .ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,15 +238,72 @@ See API-CONTRACT.md for the full WebSocket protocol. Summary of handling status:
on first `connected`; emitted UNWRAPPED as `{status}`, read as
`msg.status ?? msg.payload?.status` — FM-SYNC-WIRE-FIX fix 1)

- `calendar.weather.changed` → CalendarSync (FM-SYNC-SUBRESOURCES-P1) — dashboard
world-state panel + Calendaria setter probe, GM-whisper fallback
- `calendar.season.changed` / `.era.changed` / `.moon.phase_changed` → CalendarSync
— panel + optional GM-whisper chat line, per-type world setting
- `calendar.worldstate.changed` → CalendarSync — wired + tested, but **dormant**:
Chronicle drops it before the bus (see below)
- `calendar.structure.updated` / `.cycle.changed` / `.festival.changed` →
CalendarSync `_onChronicleStructureUpdated` — re-compare + badge, **never
auto-applies the structure**

**Not yet handled (available per contract):**
- `calendar.season.changed` — Season boundary crossed
- `calendar.moon.phase_changed` — Moon phase changed
- `calendar.weather.changed` — Weather set or generated
- `calendar.structure.updated` — Calendar structure modified (months, weekdays, etc.)
- `calendar.era.changed` — Era boundary crossed
- `sync.error` — Synchronization error
- `sync.conflict` — Data conflict detected

Every other `calendar.*` type now hits a `default:` branch that logs one
`console.debug` line per type per session. Before FM-SYNC-SUBRESOURCES-P1 the
switch had no `default:` and seven of Chronicle's eleven calendar broadcasts
vanished with no console breadcrumb — the reason the operator's "celestial
events unfindable/unsyncable" report had nothing to trace.

### Calendar sub-resources (FM-SYNC-SUBRESOURCES-P1, 2026-07-25)

**Decision: P1 is display-level and non-destructive.** No sub-resource handler
writes into a Foundry document. Three specific abstentions, each deliberate:

1. **No structure auto-apply on `calendar.structure.updated`.** Rewriting the
Foundry calendar's months/weekdays from a broadcast is the most destructive
write available to this module: Calendaria stores notes against month/day
coordinates, so re-shaping the calendar silently re-dates every note in the
world. The handler re-compares and badges instead. Non-destructive
auto-merge is a later arc (calendar-remodel requirements §1 item 10).
2. **No auto-created celestial note on `calendar.worldstate.changed`.** The
payload carries no celestial detail and no stable event id, so a
re-broadcast or reconnect would create duplicate notes with nothing to
dedupe on. Announcing what the payload contains is honest; inventing a
"Meteor Shower" note from a mood tint is not.
3. **No speculative Calendaria weather setter.** Calendaria publishes weather
READS; no shipped build exposes a documented setter. The module probes
`setWeather` / `setCurrentWeather` / `setWeatherForDate` and degrades to a
GM chat line, with the probe result mirrored into the diagnostics bundle so
a build that does expose one shows up in an operator's bug report.

**Decision: announcements are GM whispers, never public chat.** Chronicle gates
dm_only traffic server-side (`Message.RequiresDM`, `internal/websocket/hub.go`),
but "cleared for the GM" is not "cleared for the table" — weather zones and
world state routinely encode what the DM is holding back. Re-broadcasting would
launder a server-side permission decision into a player-visible one, the leak
class cordinator#32 hardened against. `CalendarSync._announceToGM` whispers to
the GM user ids and is the module's only chat surface.

**Decision: structure signals are processed while paused.** `onMessage` routes
them ahead of the `_calendarSyncDisabled` guard, because fixing the calendar in
Chronicle — the documented remedy for a mismatch pause — is precisely what emits
the broadcast. Behind the guard, a pause would survive its own cause until a
world reload, the same dead-on-arrival shape FM-SYNC-WIRE-FIX removed from
initial sync. This is also the only path that can CLEAR a mismatch pause.

**Chronicle-side gap: `calendar.worldstate.changed` never reaches the wire.**
Three blockers, all Chronicle-side, verified against `main` 2026-07-25 — see
API-CONTRACT.md → "Gap: `calendar.worldstate.changed` never reaches the wire"
for the full trace. In short: the publisher adapter in `internal/app/routes.go`
has no `case` for it (so it hits `default: return` before the bus), the payload
carries no celestial detail, and `GET /calendar/world-state` is not on the
syncapi group. The module handler ships wired and tested; it lights up when
Chronicle closes blocker 1.

## Security posture

This section consolidates the security invariants this module relies on after FM-SEC Phase 4 (audit: `cordinator/reports/foundry/2026-05-22-fm-security-audit.md`; chunks #52, #53, #54, #55, #56, #57, #58).
Expand Down
55 changes: 53 additions & 2 deletions API-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1221,15 +1221,66 @@ If the token is invalid, the server rejects the upgrade.
| `calendar.event.updated` | Full event object | Calendar event modified |
| `calendar.event.deleted` | `{ id }` | Calendar event deleted |
| `calendar.date.advanced` | `{ year, month, day, hour, minute }` | Date/time changed |
| `calendar.season.changed` | `{ id, name, color }` | Season boundary crossed |
| `calendar.season.changed` | `{ id, name, color }` — **or `null`** when the date left a season without entering another | Season boundary crossed |
| `calendar.moon.phase_changed` | `{ moon_id, moon_name, phase_name, phase_position }` | Moon phase changed |
| `calendar.weather.changed` | Weather input object | Weather set or generated |
| `calendar.weather.changed` | merged `WeatherInput` (FLAT snake_case) — **or `null`** from the weather-zone paths, where it is a "refetch me" ping | Weather set or generated |
| `calendar.structure.updated` | `null` | Calendar structure modified |
| `calendar.cycle.changed` | `null` | Cycle edited (always fires alongside `structure.updated`) |
| `calendar.festival.changed` | `null` | Festival edited (always fires alongside `structure.updated`) |
| `calendar.era.changed` | `{ id, name, color }` | Era boundary crossed |
| `calendar.worldstate.changed` | `{ date: {year, month, day}, moodTint: {color, intensity} }` | World state changed — **see the gap note below; does not currently reach the wire** |
| `sync.status` | `{ connected: bool }` | Connection state change |
| `sync.error` | `{ message }` | Synchronization error |
| `sync.conflict` | Conflict details | Data conflict detected |

### What the module does with each `calendar.*` type

Wired by FM-SYNC-SUBRESOURCES-P1 (`scripts/calendar-sync.mjs` `onMessage` +
`scripts/_calendar-subresources.mjs`). Before that dispatch only the first four
rows were handled and the switch had no `default:`, so every other type was
dropped with no trace.

P1 is **display-level and non-destructive**: no branch below writes a Chronicle
value into the Foundry calendar's stored structure, and none creates a note.
Chat announcements are **GM whispers only** — never public chat, so a payload
Chronicle gated to the DM is not laundered into a player-visible one.

| Type | Module behavior | Calendaria | Simple Calendar |
|------|-----------------|-----------|-----------------|
| `calendar.date.advanced` | Applies the date, confirms it back | `CALENDARIA.api.setDateTime` | `SimpleCalendar.api` date set |
| `calendar.event.created/updated/deleted` | Mirrors to a calendar note | Full (notes API) | Full (journal-flag notes) |
| `calendar.weather.changed` | Updates the dashboard world-state panel; applies to the calendar module if it exposes a weather **setter**, else whispers a GM chat line. A `null` payload triggers one `GET /calendar/weather` refetch. | Reads only on shipped builds — the module probes `setWeather` / `setCurrentWeather` / `setWeatherForDate` and falls back to chat when absent (the probe result is reported in the diagnostics bundle) | No weather surface → chat fallback |
| `calendar.season.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | Display only | Display only |
| `calendar.era.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | Display only | Display only |
| `calendar.moon.phase_changed` | Panel + GM chat line (`calendarAnnounceMoon`, default **off** — moons change phase every few in-world days) | Display only | Display only |
| `calendar.worldstate.changed` | Panel + GM chat line (`calendarAnnounceWorldstate`, default **on**). Handler is wired and tested but **currently unreachable** — see the gap note. | Display only | Display only |
| `calendar.structure.updated`, `calendar.cycle.changed`, `calendar.festival.changed` | Refetches `GET /calendar`, re-runs the structure comparison, and sets the badge: pause if now incompatible, clear a prior mismatch pause if now compatible, otherwise raise the advisory `structure-changed` state. **Never auto-applies the structure** — rewriting months/weekdays would silently re-date every existing note. Processed even while sync is paused (the only recovery path). | Both | Both |
| any other `calendar.*` | `default:` branch logs one `console.debug` line **per type per session** — no more silent drops | — | — |

#### Gap: `calendar.worldstate.changed` never reaches the wire

Verified against Chronicle `main` on 2026-07-25 (FM-SYNC-SUBRESOURCES-P1
Step 0). Three independent blockers, all Chronicle-side:

1. **Not published.** `worldstate_service.go:265` calls
`PublishCalendarEvent("calendar.worldstate.changed", …)`, but the adapter
that translates internal event names to `ws.MessageType`
(`calendarEventPublisherAdapter.PublishCalendarEvent`,
`internal/app/routes.go`) has no `case` for it and hits `default: return`.
The event is dropped before the bus. `calendar.weather.zones.changed` is
dropped the same way.
2. **No celestial detail in the payload.** Even once published, the payload is
`{date, moodTint}` — the meteor/eclipse rows in `calendar_celestial_events`
that motivated the dispatch are not in it.
3. **No syncapi read path.** `GET /calendar/world-state` is registered on the
web plugin group, not the Bearer-token `syncapi` group, so the module cannot
fetch the detail either (`internal/plugins/calendar/routes.go:157` vs
`internal/plugins/syncapi/routes.go`).

The module-side handler ships wired and tested so the feature lights up the
moment Chronicle closes (1); until then it is dormant. Closing (2) and (3) is
Chronicle-side work.

### Reconnection

The API client automatically reconnects on WebSocket disconnection:
Expand Down
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ scripts/ # ES modules (.mjs)
map-sync.mjs # Chronicle map + sub-resources ↔ JournalEntry (image page); markers/drawings/tokens/fog/layers rendered as overlays via MapViewerSheet
map-viewer.mjs # MapViewerSheet (ApplicationV2): image + SVG overlay
calendar-sync.mjs # Calendar adapter (Calendaria/SimpleCalendar)
_calendar-subresources.mjs # Pure: weather/season/era/moon payload normalization, GM chat lines, dashboard snapshot reducer
actor-sync.mjs # Character entity ↔ Actor sync
item-sync.mjs # Item sync
note-sync.mjs # Chronicle Notes ↔ JournalEntry sync
Expand Down Expand Up @@ -89,6 +90,18 @@ Integration — Install & Updates".
422-from-`PUT`-is-the-same-condition backstop (never a retryable sync
error). The GM notice fires once per session, shared across both files via
a module-level singleton. See `tools/test-realtime-date-signal.mjs`.
- **Calendar sub-resources are display-only.** `calendar.weather/season/era/
moon/worldstate` land on the dashboard's world-state panel and (per-type world
setting) a **GM-whispered** chat line — never public chat, since Chronicle's
dm_only gating is server-side and re-broadcasting would launder it into a
player-visible decision. `calendar.structure.updated` (+ its `cycle`/`festival`
siblings) re-runs the structure comparison and badges the result but **never
auto-applies the structure** — that would silently re-date every Calendaria
note. It is routed AHEAD of the `_calendarSyncDisabled` guard because it is
the only signal that can clear a mismatch pause. Every other `calendar.*` type
hits a `default:` that logs once per type per session. See
`scripts/_calendar-subresources.mjs`, `tools/test-calendar-subresources.mjs`,
`tools/test-calendar-subresource-routing.mjs` (FM-SYNC-SUBRESOURCES-P1).
- WebSocket messages are routed by type through `SyncManager`.
- Chronicle-side serving rules live in `chronicle-package.json` at repo root; CI validates it against `module.json` via `tools/check-package-descriptor.mjs`.

Expand All @@ -103,6 +116,25 @@ 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 (FM-SYNC-SUBRESOURCES-P1 Step 0):**
`calendar.worldstate.changed` is published by
`internal/plugins/calendar/worldstate_service.go` but has no `case` in
`calendarEventPublisherAdapter.PublishCalendarEvent`
(`internal/app/routes.go`), so it hits `default: return` and never reaches
the bus (`calendar.weather.zones.changed` is dropped the same way). The
payload also carries no celestial detail (`{date, moodTint}`) and
`GET /calendar/world-state` isn't on the syncapi group. The module handler
is wired + tested and dormant until Chronicle closes these. See
API-CONTRACT.md → "Gap: `calendar.worldstate.changed` never reaches the wire".
- Recommended once on a live client after FM-SYNC-SUBRESOURCES-P1 (can't be
unit-tested): set weather / cross a season or era boundary in Chronicle and
confirm the GM whisper lands and the Calendar tab's "Chronicle world state"
panel fills; edit the calendar structure in Chronicle and confirm the badge
flips to "Structure Changed — Re-check" without the Foundry calendar being
modified; confirm a structure-mismatch pause CLEARS when the Chronicle
calendar is fixed (no world reload needed); check the diagnostics bundle's
`CALENDARIA.api methods available` block for whether the build exposes any of
`setWeather` / `setCurrentWeather` / `setWeatherForDate`.
- Recommended on a live client after FM-SYNC-WIRE-FIX (can't be unit-tested):
confirm initial sync now fires on a fresh world AND a world with pre-existing
synced data (console shows `_performInitialSync` / "Initial sync complete");
Expand Down
21 changes: 20 additions & 1 deletion lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@
"Name": "Sync Calendar",
"Hint": "Sync Chronicle calendar with Calendaria or Simple Calendar module"
},
"CalendarAnnounceWeather": {
"Name": "Announce Chronicle weather in chat",
"Hint": "Whisper a GM-only chat line when weather changes in Chronicle. Used when the active Foundry calendar module has no weather setter to apply the reading to (Simple Calendar, and Calendaria builds with read-only weather). Never posted to players."
},
"CalendarAnnounceWorldstate": {
"Name": "Announce Chronicle world-state changes in chat",
"Hint": "Whisper a GM-only chat line when Chronicle's world state changes (date + ambient mood tint). Never posted to players."
},
"CalendarAnnounceSeasonEra": {
"Name": "Announce season and era changes in chat",
"Hint": "Whisper a GM-only chat line when the Chronicle calendar crosses a season or era boundary. Never posted to players."
},
"CalendarAnnounceMoon": {
"Name": "Announce moon phase changes in chat",
"Hint": "Whisper a GM-only chat line each time a moon crosses a phase boundary. Off by default — moons change phase every few in-world days and can flood the chat log. The Calendar tab's world-state panel shows current phases regardless."
},
"SyncCharacters": {
"Name": "Sync Characters",
"Hint": "Sync Foundry actors with Chronicle character entities (requires matching game system)"
Expand Down Expand Up @@ -170,7 +186,10 @@
"NoModuleHint": "Install Calendaria or Simple Calendar to enable calendar sync.",
"UnableToRead": "Unable to read",
"OpenSyncCalendar": "Open Sync Calendar",
"OpenSyncCalendarHint": "Open the full Sync Calendar editor — import calendars, edit events, view moon phases."
"OpenSyncCalendarHint": "Open the full Sync Calendar editor — import calendars, edit events, view moon phases.",
"WorldStateTitle": "Chronicle world state",
"WorldStateHint": "Latest weather, season, era and moon phases received from Chronicle this session. Display only — the Foundry calendar is never modified from these.",
"WorldStateEmpty": "No world-state broadcasts received yet this session."
},
"Notes": {
"Synced": "Synced",
Expand Down
Loading