DONE evidence — task 2be5d4c0 (wire Google calendar broker so propose/invite creates real Google Proposal blockers)
Verified against origin/main ebf0fb18 (2026-11-05) in a dedicated worktree
(fix/calendar-broker-verify). This task's ask: calendar propose (the
propose command, Proposals in the CLI) should create a real Google Calendar
event marked as a Proposal blocker — the (Proposal)-titled tentative event —
via the calendar broker (backend/src/calendar/dapierBroker.ts, Dapier
connections).
Already implemented on origin/main — no code change needed. All three
"propose" paths write real Google events through the Dapier broker
(holdGoogleSync.syncEvents), and the (Proposal) blocker lands on the
Orin organizer calendar plus Alexey's personal calendar
(alexey.s.grigoriev@gmail.com, DEFAULT_HOLD_GOOGLE_CALENDARS,
backend/src/calendar/dapierBroker.ts:29-31). The broker credential is the
machine agent token (personal-scheduler.dapier-api-token, Dapier
POST /api/agent/token), and the calendar connection is the one Orin picks in
calendar settings (calendarInviteDeps, backend/src/db/calendarHolds.ts:32-53).
This PR is the verification record only.
There is no deprecated or orphaned CLI path: dataops calendar propose is
the hold propose endpoint — the same route the rest of the holds feature uses.
Help text documents this explicitly (Holds section,
cli/src/commands/calendar.mjs:57-58): "A hold is a tentative event
reservation: DataOps plus a Google (Proposal) event on the DTC and personal
calendars."
- CLI registration
propose: calendar.propose—cli/src/cli.mjs:115(command blockcli/src/cli.mjs:107-126); usage linecli/src/commands/calendar.mjs:19; arg parsingproposeArgscli/src/commands/calendar.mjs:727-755(window/duration/topic/expires validation,--card,--guest-ref,--document-url). proposeposts to/api/calendar-holds—cli/src/commands/calendar.mjs:768-778, and printsGoogle sync deferred: <reason>when the broker leg defers (:774-776).- Backend route
POST /api/calendar-holds→createRoute—backend/src/routes/calendarHolds.ts:479-482;createRoutebody:74-117. Card-backed proposals are rejected if the Card already owns an invitation (:77-82). buildHoldconstructs the record with the Proposal mirror title —backend/src/calendar/holds.ts:310-349:googleTitle: proposalTitle(title)(:332), whereHOLD_PROPOSAL_PREFIX = '(Proposal) '(:22) andproposalTitle(:222-224). The Google leg startsdeferred(:334,reason: 'not-synced-yet').- The Google write happens synchronously inside the propose request:
createHoldGoogleEvents(googlePayload(hold, proposalTitle(hold.title), hold.startIso, hold.endIso), await calendarInviteDeps(body, client))—backend/src/routes/calendarHolds.ts:105-108. The hold is then stored with the resultinggoogleEventIdsandgoogleSyncstate (:109-115). createHoldGoogleEvents→syncEvents—backend/src/calendar/holdGoogleSync.ts:290-292→:171-288:- mints a Google token via the broker:
POST {DAPIER_BASE_URL}/api/agent/tokenwith{ connection_id, agent: 'personal-scheduler' }(:92-97; constants inbackend/src/calendar/dapierBroker.ts:18-20); - verifies the token's calendar is the Orin organizer before any write
(
:202-205,HOLD_ORGANIZER = 'orin@datatalks.club',:115); - writes the
(Proposal)event to[HOLD_ORGANIZER, ...personal calendars](:214-252) — UID-import for silent personal copies, organizer PATCH last;eventBody(:156-169) setssummary: event.summary(the(Proposal) …title). - On any broker/Google failure the hold stays stored locally and the
Google leg is recorded
deferredwith an operator-actionable reason (:177-183,:283-287) — propose never fails because Google is down.
- mints a Google token via the broker:
- Connection resolution:
calendarInviteDepsreadsconnectionIdfrom DataOps calendar settings (Orin's pick) —backend/src/db/calendarHolds.ts:32-53; machine credential from the team store viareadDapierCredential—backend/src/calendar/dapierBroker.ts:103-113.
Same createRoute with a Card guard against double-invites
(backend/src/routes/calendarHolds.ts:77-82). Confirming such a hold
delegates to the Card invite path syncCardInvite
(backend/src/routes/calendarHolds.ts:231-249; implementation
backend/src/calendar/cardInvite.ts:244, which uses the same
createHoldGoogleEvents/updateHoldGoogleEvents primitives imported at
backend/src/calendar/cardInvite.ts:12). Prod holds show this exact shape:
Card-backed holds carry googleEventIds on
orin@datatalks.club + personal + public calendars with
googleSync.state = 'synced'.
- CLI
propose→POST /api/events/{target}/propose—cli/src/commands/event.mjs:107-116; help:7-27. - Route dispatch →
proposeEvent—backend/src/routes/events.ts:123-155(:144-145). proposeEvent→runTransition('propose')—backend/src/events/service.ts:857-870→:654-825; the planned operation'sdesired.summaryis rendered from the Card Template'seventPolicy.titles.proposed(backend/src/events/lifecycle.ts:165-179,:168), and policy validation requires the proposed title to contain(Proposal)(backend/src/events/policy.ts:62-65).- The Google write:
reconcileEventOperation→createHoldGoogleEvents/updateHoldGoogleEventswithextendedProperties.privatehold/card/generation metadata —backend/src/events/service.ts:379-457(:448-456). Same broker, samesyncEventsas (a).
backend/tests/calendar-holds.test.ts(14 pass) — POST/api/calendar-holdscreates the tentative hold withgoogleTitle === "(Proposal) GraphRAG workshop"(:86-105,:96); the broker-deferral contract: no connection selected / Dapierusegrant missing → hold stored, Google leg deferred with reason (:61-84);proposalTitle/confirmedTitleprefix lifecycle (:387-389); personal calendars only (:364).backend/tests/calendar-invite-sop.test.ts(10 pass) — the end-to-end SOP contract with a fake Google provider: "propose → confirm → release: Orin organizer, personal block, one normal invitation" (:50-102) assertsgoogleSync.state === 'synced'right after propose (:58), event ids onHOLD_ORGANIZER+ personal calendars (:59),(Proposal)summary on every written Google event (:60-73), token calls carryconnection_id: 'orin-selected'+agent: 'personal-scheduler'(:100-101); wrong Google identity rejected before any write (:104-112); partial-copy recovery (:114-134).backend/tests/hold-google-copies.test.ts(17 pass) — broker write semantics: imports for personal copies, silent PATCHes, organizer-only invitation, public-calendar refusal.backend/tests/event-lifecycle.test.ts(8 pass) andbackend/tests/event-service.test.ts(2 pass) — unified lifecycle, including the podcast full lifecycle "syncs Card, Episode, Google calendars, emails, tasks" (AC3/AC4/AC5/AC7/AC9 test).cli/test/calendar.test.mjs(32 pass) — CLIcalendar proposeposts the hold payload with evening/60/+7d defaults (:677-703), workshop flags with--card(:704-725), client-side validation (:726-739).
Test commands (repo root backend/):
NODE_ENV=test FRONTEND_ROOT=../frontend SKIP_AUTH=true \
node --import tsx --test --test-force-exit tests/calendar-holds.test.ts \
tests/calendar-invite-sop.test.ts tests/hold-google-copies.test.ts \
tests/event-lifecycle.test.ts tests/event-service.test.ts
node --test cli/test/calendar.test.mjs # from the repo root
dataops calendar holds --json (GET only, no writes): 19 holds total,
16 with googleSync.state = 'synced', 3 deferred. Six holds carry a
(Proposal) googleTitle together with real googleEventIds — e.g.
hold_109132fd3e13 (Card b4a90396-…, workshop, later confirmed) holds
Google event ids on orin@datatalks.club,
alexey.s.grigoriev@gmail.com and the public DTC events calendar.
Propose-created Proposal blockers exist in prod exactly as designed.
None for this ask. Two observations, both already by-design:
- When the broker credential/connection is unavailable, propose still
succeeds and stores the hold with
googleSync.state = 'deferred'(holdGoogleSync.ts:177-187, CLI prints the reason) — recovery is retry confirm/move, which PATCHes or re-creates the mirrors (routes/calendarHolds.ts:216-254). DataOps stays the source of truth on purpose. - The deferred-then-confirmed hold
hold_c3d15b7c-…in prod shows the deferred path leavesgoogleEventIdspresent but not synced until a retry — matching the documented behaviour above.