feat(calendar): pause date-push for real-time calendars (RC-4)#79
Merged
Merged
Conversation
Chronicle #529 now serves tracks_real_time on GET /calendar/date (the composed UsesRealTime() predicate). Gate all four PUT /calendar/date sites through a shared scripts/_realtime-date-guard.mjs: fetch-before-push check re-run on every push (self-heals a mid-session flag flip), plus treating a 422 W3 rejection as the same condition rather than a retryable error. One GM notice per session, shared across calendar-sync.mjs and sync-dashboard.mjs via a module-level singleton. Pull/event sync untouched. Cites: 2026-05-21-core-tenets §T-O2, §T-O5 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BgwKzUUUY26C1oFjxY4Hfu
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-O5 (tenet citation); decisions/2026-07-11-r13-rulings.md RC-4; dispatches/foundry/FM-REALTIME-DATE-SIGNAL.md
Security implication: none — this only suppresses an outbound write under a server-declared read-only condition; no new attack surface, no new trust boundary.
Consumer-verified:
internal/plugins/syncapi/calendar_api_handler.go:95("tracks_real_time": cal.UsesRealTime()onGET /api/v1/campaigns/:id/calendar/date, bare object, verified directly against Chronicle's mergedorigin/main);internal/plugins/calendar/model.go:143(UsesRealTime() bool { return c.Mode == ModeRealLife && c.TracksRealTime });internal/plugins/calendar/service.go:2074(SetDate→guardManualDateChange→apperror.NewValidation,internal/apperror/errors.go:152confirms 422);internal/plugins/syncapi/routes.go:155,194(route wiring for both verbs).GET /calendar(GetCalendar, handler line 58) does not emit the field — confirmed by reading the handler body.Foundry compatibility: n/a — no Foundry-runtime API surface touched (no ApplicationV2/Handlebars/Hooks changes), pure REST-client gating logic. Verified via unit tests only (
node --test), consistent with how the equivalent FM-ENVELOPE-AUDIT / FM-CAL-BACKCATALOG-FIX PRs were verified.Mockup: n/a — no UI change.
What this changes
Chronicle #529 (merged) now serves
tracks_real_timeonGET /calendar/date— the composedUsesRealTime()predicate. This PR makes the module respect it: when a calendar tracks real-world time, Foundry's own date pushes are paused (pull and event sync keep working normally), because Chronicle is now wall-clock-authoritative for that calendar's date.Why
Without this, every GM date-advance/Calendaria hook keeps firing
PUT /calendar/dateat a calendar Chronicle will reject (422, its W3 guard) or — on an older deploy without the guard — could otherwise fight the live clock. RC-4 (decisions/2026-07-11-r13-rulings.md) calls for the module to stop making those doomed writes rather than relying solely on the server-side rejection.Step-0: re-grep for
put('/calendar/date'(line-number drift check)The dispatch cited
calendar-sync.mjs:731/:882/:911andsync-dashboard.mjs:2057. Re-grepped against freshmain(deb818b, post-#78) before editing:No drift — all four dispatch-cited lines matched exactly. All four (and only four)
put('/calendar/date'call sites in the repo were gated; no fifth site was found.Design
New pure/session-singleton helper
scripts/_realtime-date-guard.mjs(mirrors the existing_calendar-probe-state.mjspattern of extracting testable logic out of the stateful sync classes), socalendar-sync.mjs's three hook-triggered pushes andsync-dashboard.mjs's manual push button share one implementation instead of forking the guard twice (the dispatch's own stop-and-flag concern):shouldSkipDatePush(api)— fetch-before-push:GET /calendar/date, readpayload?.tracks_real_time === truedefensively (FM-ENVELOPE-AUDIT convention), show the notice + returntrue(skip) if set. Re-run on every push attempt (pushes are rare; a probe failure just lets the push proceed and fail/succeed on its own terms — never blocks on a network hiccup).isRealTimeRejection(err)— classifies aPUT422 as the W3 backstop. Mirrors_calendar-probe-state.mjs'scalendarStateFromErrorstatus-anchoring: prefers expliciterr.status, else parses the api-client's authoritative"Chronicle API error <status>:"prefix — never a bare digit run (so an entity literally named "Room 422" can't misclassify).notifyRealTimePushPaused()— one-time-per-sessionui.notifications.warn, backed by a module-level singleton (not a class field) so the "shown once" state is genuinely shared across the two independent classes without either reaching into the other's instance.All four call sites got the identical two-line change: a
shouldSkipDatePushcheck before thePUT, and anisRealTimeRejectionbranch in the existingcatchthat callsnotifyRealTimePushPaused()and returns — instead of falling through to the genericconsole.error.Files changed
scripts/_realtime-date-guard.mjsscripts/calendar-sync.mjs_onCalendariaDateTimeChange(:731→:733),_onLocalDateChange(:882→:886),_onSimpleCalendarDateChange(:911→:917 post-edit).scripts/sync-dashboard.mjs_onPushDate(:2057→:2059 post-edit).tools/test-realtime-date-signal.mjsAPI-CONTRACT.mdGET /calendar/dateresponse gainstracks_real_time; new prose on the read-only-dates contract and the 422 backstop underPUT /calendar/date.CLAUDE.md.ai.mdDeviations from the dispatch (honest-deviation rule)
_realTimeNoticeShownclass field mirroring the structure guard (calendar-sync.mjs:~327, which is a per-CalendarSync-instance boolean). I used a module-level singleton in the new_realtime-date-guard.mjsinstead, specifically because the "once per session" requirement has to hold across two independent classes (CalendarSyncandSyncDashboard) — a class field onCalendarSyncalone can't dedupe a notice triggered fromsync-dashboard.mjs's_onPushDatewithout one class reaching into the other's private state, which felt worse than a small shared pure-helper module (the repo already has four of these:_calendar-probe-state.mjs,_settings-validation.mjs,_overview-model.mjs,_member-mapping.mjs). Behaviorally identical to the dispatch's intent (one notice, once per session, everywhere); pinned by the last two tests in the new suite (the one-time notice is shared across calendar-sync.mjs AND sync-dashboard.mjs call sites)._onPushDate's existing try/catch shape took the identical two-line change as the other three sites. No fork was needed, so no flag.Test plan
node --test tools/test-realtime-date-signal.mjs— 29/29 pass. Covers: pure-helper unit tests fortracksRealTime/isRealTimeRejection/shouldSkipDatePush/notifyRealTimePushPaused(including the "bare digit run" 422 misclassification guard and the probe-failure-doesn't-block case); all threecalendar-sync.mjssites × (skip-on-true / proceed-on-false / proceed-on-absent / 422-sets-guard-without-throwing / non-422-still-logs);SyncDashboard._onPushDate× (skip / proceed / 422-backstop); cross-module once-per-session sharing.node --test tools/test-*.mjs— 646/646 pass (617 pre-existing + 29 new; zero regressions).node tools/check-package-descriptor.mjs—chronicle-package.json: OK (0 warnings).Tenet self-check
ui.notifications.warncall, matching the existing structure-guard notice's shape exactly..ai.mdandCLAUDE.mdboth updated with the same convention-bullet style used for FM-ENVELOPE-AUDIT.Stop-and-flag
None encountered — Step-0 found zero line drift, and the dashboard-push-awkwardness concern the dispatch flagged as a possible stop-and-flag didn't materialize (see Deviations above).
Generated by Claude Code