diff --git a/.agents/handoffs/3246.md b/.agents/handoffs/3246.md new file mode 100644 index 000000000..08594bb8a --- /dev/null +++ b/.agents/handoffs/3246.md @@ -0,0 +1,32 @@ +--- +schema_version: 1 +task_id: "3246" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts + - path: packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts + - path: packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts + - path: packages/sync/src/providers/provider-event-writer.port.ts + - path: packages/sync/src/providers/__contract__/microsoft-contract.factory.ts + - path: packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json + - path: packages/sync/src/providers/__contract__/microsoft.contract.test.ts +evidence: + - command: bun run verify --strict + result: "VERDICT: PASS (test:sync:fast, type-check, lint, knip)" +assumptions: + - "Meeting-provider cache is per writer instance, keyed by access token, TTL 5 minutes." + - "Graph /instances occurrence rows are rewritten to exception before M-04 so the delta reader can keep skipping expansions." + - "conference: null on createConference when no Teams provider is allowed; omitted when the write did not ask for a conference." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +M WP-06b: Graph writer creates Teams conferences when asked and resolves +recurring instances by originalStart. diff --git a/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json b/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json index 48a800256..9bbae2d1c 100644 --- a/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json +++ b/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json @@ -33,5 +33,22 @@ "timeZone": "UTC" }, "showAs": "busy" + }, + "instance": { + "id": "series-1_instance", + "@odata.etag": "W/\"graph-instance-etag\"", + "type": "occurrence", + "subject": "Contract instance", + "seriesMasterId": "series-1", + "originalStart": "2025-01-15T14:00:00.0000000Z", + "start": { + "dateTime": "2025-01-15T14:00:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2025-01-15T15:00:00.0000000", + "timeZone": "UTC" + }, + "showAs": "busy" } } diff --git a/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts b/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts index bf6c51679..fe98dc9e1 100644 --- a/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts +++ b/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts @@ -31,6 +31,7 @@ export interface MicrosoftReaderCorpus { interface WriterCorpus { readonly create: GraphEvent; readonly fetch: GraphEvent; + readonly instance: GraphEvent; } class CorpusEventListApi implements MicrosoftEventListApi { @@ -122,6 +123,25 @@ class CorpusEventWriteApi implements MicrosoftEventWriteApi { "@odata.etag": this.#etag, }; } + + async getCalendar() { + return { + allowedOnlineMeetingProviders: [] as const, + defaultOnlineMeetingProvider: "unknown", + }; + } + + async listInstances( + params: Parameters[0], + ): Promise { + if ( + params.seriesEventId !== + (this.corpus.instance.seriesMasterId ?? "series-1") + ) { + return []; + } + return [this.corpus.instance]; + } } /** Replay `fixtures/microsoft/reader.json` through the event reader adapter. */ diff --git a/packages/sync/src/providers/__contract__/microsoft.contract.test.ts b/packages/sync/src/providers/__contract__/microsoft.contract.test.ts index 66ffda56c..2613ba10b 100644 --- a/packages/sync/src/providers/__contract__/microsoft.contract.test.ts +++ b/packages/sync/src/providers/__contract__/microsoft.contract.test.ts @@ -33,7 +33,6 @@ import { type ProviderEventReadError, type ProviderEventReader, } from "@sync/providers/provider-event-reader.port"; -import { ProviderWriteError } from "@sync/providers/provider-event-writer.port"; const CLIENT_ID = "microsoft-client-id"; const CLIENT_SECRET = "microsoft-client-secret"; @@ -379,18 +378,16 @@ describe("microsoft writer contract", () => { }); }); - it("fetchInstanceAt remains unsupported until M-06b", async () => { - const error = await writerAdapter - .fetchInstanceAt({ - accessToken: "contract-access-token", - calendarId: "primary", - seriesProviderEventId: "series-1", - originalStartAt: "2025-01-15T14:00:00.000Z", - scheduleKind: "timed", - }) - .catch((caught) => caught); - expect(error).toBeInstanceOf(ProviderWriteError); - expect((error as ProviderWriteError).reason).toBe("unsupportedCapability"); + it("fetchInstanceAt resolves an occurrence by originalStart", async () => { + const instance = await writerAdapter.fetchInstanceAt({ + accessToken: "contract-access-token", + calendarId: "primary", + seriesProviderEventId: "series-1", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); + expect(instance).not.toBeNull(); + expect(instance?.providerEventId.length).toBeGreaterThan(0); }); }); diff --git a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts index 3073c8c31..6f57f8a80 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts @@ -1,7 +1,8 @@ import { type EventSchedule } from "@core/types/event.contracts"; import { type SyncEventContent } from "@core/types/sync/event.contracts"; +import { type GraphEvent } from "@sync/providers/microsoft/microsoft-event.normalizer"; import { - type GraphEvent, + type GraphCalendarMeetingInfo, type MicrosoftEventWriteApi, MicrosoftEventWriter, } from "@sync/providers/microsoft/microsoft-event-writer.adapter"; @@ -26,6 +27,8 @@ const scriptedEvent = (id: string, etag = 'W/"graph-v1"'): GraphEvent => ({ }); type Behavior = GraphEvent | Error | undefined; +type CalendarBehavior = GraphCalendarMeetingInfo | Error | undefined; +type InstancesBehavior = readonly GraphEvent[] | Error | undefined; class FakeWriteApi implements MicrosoftEventWriteApi { calls = { @@ -33,6 +36,10 @@ class FakeWriteApi implements MicrosoftEventWriteApi { patch: [] as Parameters[0][], delete: [] as Parameters[0][], get: [] as Parameters[0][], + getCalendar: 0, + listInstances: [] as Parameters< + MicrosoftEventWriteApi["listInstances"] + >[0][], }; #etag: string; @@ -45,6 +52,8 @@ class FakeWriteApi implements MicrosoftEventWriteApi { patch?: Behavior; delete?: Behavior; get?: Behavior; + getCalendar?: CalendarBehavior; + listInstances?: InstancesBehavior; } = {}, initialEtag = 'W/"graph-v1"', ) { @@ -97,6 +106,27 @@ class FakeWriteApi implements MicrosoftEventWriteApi { ); } + async getCalendar(): Promise { + this.calls.getCalendar += 1; + const scripted = this.behavior.getCalendar; + if (scripted instanceof Error) throw scripted; + return ( + scripted ?? { + allowedOnlineMeetingProviders: [], + defaultOnlineMeetingProvider: "unknown", + } + ); + } + + async listInstances( + params: Parameters[0], + ): Promise { + this.calls.listInstances.push(params); + const scripted = this.behavior.listInstances; + if (scripted instanceof Error) throw scripted; + return scripted ?? []; + } + #settle(method: "create" | "patch" | "get", fallback: () => GraphEvent) { const scripted = this.behavior[method]; if (scripted instanceof Error) throw scripted; @@ -432,20 +462,186 @@ describe("MicrosoftEventWriter", () => { expect(missing).toBeNull(); }); - it("fetchInstanceAt is unsupported until M-06b", async () => { - const { writer } = writerWith(new FakeWriteApi()); + it("does not read meeting providers unless createConference is set", async () => { + const api = new FakeWriteApi(); + await writerWith(api).writer.createEvent(baseCreate); + expect(api.calls.getCalendar).toBe(0); + expect(api.calls.create[0]?.body).not.toHaveProperty("isOnlineMeeting"); + }); - const error = (await writer - .fetchInstanceAt({ - accessToken: "at", - calendarId: "cal", - seriesProviderEventId: "series-1", - originalStartAt: "2025-01-15T14:00:00.000Z", - scheduleKind: "timed", - }) - .catch((e) => e)) as ProviderWriteError; + it("uses the default Teams provider when creating a conference", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["teamsForBusiness", "skypeForConsumer"], + defaultOnlineMeetingProvider: "teamsForBusiness", + }, + create: { + ...scriptedEvent("abc12deadbeef00000000000"), + onlineMeeting: { + joinUrl: "https://teams.microsoft.com/l/meetup-join/abc", + }, + onlineMeetingProvider: "teamsForBusiness", + }, + }); + + const result = await writerWith(api).writer.createEvent({ + ...baseCreate, + createConference: true, + }); + + expect(api.calls.create[0]?.body).toMatchObject({ + isOnlineMeeting: true, + onlineMeetingProvider: "teamsForBusiness", + }); + expect(result.conference).toEqual({ + url: "https://teams.microsoft.com/l/meetup-join/abc", + label: "Microsoft Teams", + }); + }); + + it("falls back to the first allowed Teams provider when the default is not Teams", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["skypeForBusiness", "teamsForConsumer"], + defaultOnlineMeetingProvider: "skypeForBusiness", + }, + create: { + ...scriptedEvent("abc12deadbeef00000000000"), + onlineMeeting: { + joinUrl: "https://teams.microsoft.com/l/meetup-join/consumer", + }, + onlineMeetingProvider: "teamsForConsumer", + }, + }); + + const result = await writerWith(api).writer.createEvent({ + ...baseCreate, + accessToken: "token-teams-allowed", + createConference: true, + }); + + expect(api.calls.create[0]?.body).toMatchObject({ + isOnlineMeeting: true, + onlineMeetingProvider: "teamsForConsumer", + }); + expect(result.conference).toEqual({ + url: "https://teams.microsoft.com/l/meetup-join/consumer", + label: "Microsoft Teams", + }); + }); + + it("creates without a conference when no Teams provider is allowed", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["skypeForConsumer"], + defaultOnlineMeetingProvider: "skypeForConsumer", + }, + }); + + const result = await writerWith(api).writer.createEvent({ + ...baseCreate, + accessToken: "token-no-teams", + createConference: true, + }); + + expect(api.calls.create[0]?.body).not.toHaveProperty("isOnlineMeeting"); + expect(api.calls.create[0]?.body).not.toHaveProperty( + "onlineMeetingProvider", + ); + expect(result.conference).toBeNull(); + }); + + it("reads meeting providers once per access token within the cache TTL", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["teamsForBusiness"], + defaultOnlineMeetingProvider: "teamsForBusiness", + }, + }); + const { writer } = writerWith(api); + + await writer.createEvent({ ...baseCreate, createConference: true }); + await writer.createEvent({ + ...baseCreate, + providerEventId: "def12deadbeef00000000000", + createConference: true, + }); + + expect(api.calls.getCalendar).toBe(1); + }); + + it("resolves an occurrence whose originalStart matches the requested instant", async () => { + const api = new FakeWriteApi({ + listInstances: [ + { + ...scriptedEvent("series-1_instance"), + type: "occurrence", + seriesMasterId: "series-1", + originalStart: "2025-01-15T14:00:00.0000000Z", + }, + ], + }); + const { writer } = writerWith(api); + + const read = await writer.fetchInstanceAt({ + accessToken: "at", + calendarId: "cal", + seriesProviderEventId: "series-1", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); + + expect(api.calls.listInstances[0]).toEqual({ + seriesEventId: "series-1", + startDateTime: "2025-01-15T00:00:00.000", + endDateTime: "2025-01-16T00:00:00.000", + }); + expect(read?.kind).toBe("event"); + expect(read?.providerEventId).toBe("series-1_instance"); + if (read?.kind === "event") { + expect(read.recurrence).toEqual({ + kind: "instance", + seriesProviderId: "series-1", + recurrenceId: "2025-01-15T14:00:00.000Z", + }); + } + }); + + it("returns null when no instance exists at that instant", async () => { + const api = new FakeWriteApi({ + listInstances: [ + { + ...scriptedEvent("other-instance"), + type: "occurrence", + seriesMasterId: "series-1", + originalStart: "2025-01-16T14:00:00.0000000Z", + }, + ], + }); + + const read = await writerWith(api).writer.fetchInstanceAt({ + accessToken: "at", + calendarId: "cal", + seriesProviderEventId: "series-1", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); + + expect(read).toBeNull(); + }); + + it("returns null when the series itself is gone", async () => { + const api = new FakeWriteApi({ listInstances: msError(404) }); + + const read = await writerWith(api).writer.fetchInstanceAt({ + accessToken: "at", + calendarId: "cal", + seriesProviderEventId: "gone", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); - expect(error.reason).toBe("unsupportedCapability"); + expect(read).toBeNull(); }); it("never leaks the bearer token onto a thrown error cause", async () => { diff --git a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts index 0affd35bc..218317cba 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts @@ -9,6 +9,7 @@ import { } from "@sync/providers/microsoft/microsoft-error"; import { type GraphEvent, + mapConference, normalizeMicrosoftEvent, } from "@sync/providers/microsoft/microsoft-event.normalizer"; import { @@ -73,8 +74,25 @@ export interface GraphEventWriteBody { readonly attendees?: readonly GraphAttendeeWrite[]; readonly responseRequested?: boolean; readonly transactionId?: string; + readonly isOnlineMeeting?: boolean; + readonly onlineMeetingProvider?: string; } +export interface GraphCalendarMeetingInfo { + readonly allowedOnlineMeetingProviders?: readonly string[]; + readonly defaultOnlineMeetingProvider?: string; +} + +const TEAMS_ONLINE_MEETING_PROVIDERS = new Set([ + "teamsForBusiness", + "teamsForConsumer", +]); + +const MEETING_PROVIDER_CACHE_TTL_MS = 5 * 60 * 1000; + +const CALENDAR_MEETING_SELECT = + "allowedOnlineMeetingProviders,defaultOnlineMeetingProvider"; + export interface MicrosoftEventWriteApi { create(params: { calendarId: string; @@ -87,6 +105,12 @@ export interface MicrosoftEventWriteApi { }): Promise; delete(params: { eventId: string; ifMatch: string | null }): Promise; get(params: { eventId: string }): Promise; + getCalendar(): Promise; + listInstances(params: { + seriesEventId: string; + startDateTime: string; + endDateTime: string; + }): Promise; } export type MicrosoftEventWriteApiFactory = ( @@ -99,31 +123,48 @@ const defaultApiFactory: MicrosoftEventWriteApiFactory = (accessToken) => // Microsoft Graph implementation of the event mutation port. Create is // idempotent via transactionId, patch and delete can be conditioned on etag, // and provider errors are classified into neutral, caller-actionable reasons. +// createConference reads the mailbox's allowed online-meeting providers and +// asks Graph for Teams when one is available; fetchInstanceAt lists one day +// of /instances and picks the occurrence whose originalStart matches. // // Named wart: Graph sends attendee mail on create and on attendee changes even // when responseRequested is false (invitation "none"). Compass honors "none" by // setting responseRequested false, but Outlook may still notify attendees. export class MicrosoftEventWriter implements ProviderEventWriter { #makeApi: MicrosoftEventWriteApiFactory; + #meetingProviders = new Map< + string, + { readonly expiresAt: number; readonly teamsProvider: string | null } + >(); constructor(makeApi: MicrosoftEventWriteApiFactory = defaultApiFactory) { this.#makeApi = makeApi; } async createEvent(input: ProviderCreateInput): Promise { - if (input.createConference) { - // Teams conference creation lands in M-06b; booking copy handles null URL. - } - const api = this.#makeApi(input.accessToken); - const body = toGraphCreateBody(input); - try { + const teamsProvider = input.createConference + ? await this.#resolveTeamsProvider(api, input.accessToken) + : null; + const body = { + ...toGraphCreateBody(input), + ...(teamsProvider + ? { + isOnlineMeeting: true, + onlineMeetingProvider: teamsProvider, + } + : {}), + }; const created = await api.create({ calendarId: input.calendarId, body, }); - return toResult(created); + const result = toResult(created); + if (input.createConference) { + return { ...result, conference: result.conference ?? null }; + } + return result; } catch (error) { throw classifyWriteError(error); } @@ -170,12 +211,46 @@ export class MicrosoftEventWriter implements ProviderEventWriter { } async fetchInstanceAt( - _input: ProviderInstanceFetchInput, + input: ProviderInstanceFetchInput, ): Promise { - throw new ProviderWriteError( - "unsupportedCapability", - "Microsoft instance resolution lands in M-06b", + const api = this.#makeApi(input.accessToken); + const { startDateTime, endDateTime } = instanceWindow( + input.originalStartAt, ); + try { + const instances = await api.listInstances({ + seriesEventId: input.seriesProviderEventId, + startDateTime, + endDateTime, + }); + const match = instances.find( + (item) => + item.originalStart !== undefined && + sameInstant(item.originalStart, input.originalStartAt), + ); + if (!match) return null; + return normalizeFetchedInstance(match, input); + } catch (error) { + if (isNotFound(error)) return null; + throw classifyWriteError(error); + } + } + + async #resolveTeamsProvider( + api: MicrosoftEventWriteApi, + accessToken: string, + ): Promise { + const now = Date.now(); + const cached = this.#meetingProviders.get(accessToken); + if (cached && cached.expiresAt > now) return cached.teamsProvider; + + const calendar = await api.getCalendar(); + const teamsProvider = pickTeamsProvider(calendar); + this.#meetingProviders.set(accessToken, { + expiresAt: now + MEETING_PROVIDER_CACHE_TTL_MS, + teamsProvider, + }); + return teamsProvider; } } @@ -323,13 +398,59 @@ function toResult(event: GraphEvent): ProviderWriteResult { "Microsoft returned an event without an id or etag", ); } + const conference = mapConference(event); return { providerEventId: event.id, providerVersion: event["@odata.etag"], ...(event.iCalUId ? { icalUid: event.iCalUId } : {}), + ...(conference ? { conference } : {}), }; } +function pickTeamsProvider(calendar: GraphCalendarMeetingInfo): string | null { + const defaultProvider = calendar.defaultOnlineMeetingProvider; + if (defaultProvider && TEAMS_ONLINE_MEETING_PROVIDERS.has(defaultProvider)) { + return defaultProvider; + } + return ( + calendar.allowedOnlineMeetingProviders?.find((provider) => + TEAMS_ONLINE_MEETING_PROVIDERS.has(provider), + ) ?? null + ); +} + +function instanceWindow(originalStartAt: string): { + startDateTime: string; + endDateTime: string; +} { + const dayStart = dayjs.utc(originalStartAt).startOf("day"); + return { + startDateTime: dayStart.format(GRAPH_DATETIME), + endDateTime: dayStart.add(1, "day").format(GRAPH_DATETIME), + }; +} + +function sameInstant(left: string, right: string): boolean { + const a = Date.parse(left); + const b = Date.parse(right); + return Number.isFinite(a) && a === b; +} + +// Graph /instances returns type "occurrence". The M-04 normalizer refuses +// those rows on the delta reader (they are expansions, not stored events). +// Rewrite to exception so the same mapper yields an addressable instance. +function normalizeFetchedInstance( + item: GraphEvent, + input: ProviderInstanceFetchInput, +): ProviderEventRead { + return normalizeMicrosoftEvent({ + ...item, + type: item.type === "occurrence" ? "exception" : item.type, + seriesMasterId: item.seriesMasterId ?? input.seriesProviderEventId, + originalStart: item.originalStart ?? input.originalStartAt, + }); +} + function classifyWriteError(error: unknown): ProviderWriteError { if (error instanceof ProviderWriteError) return error; if (error instanceof ProviderEventError) { @@ -438,12 +559,38 @@ class FetchMicrosoftEventWriteApi implements MicrosoftEventWriteApi { ); } - async #request( + getCalendar(): Promise { + const query = new URLSearchParams({ $select: CALENDAR_MEETING_SELECT }); + return this.#request( + "GET", + `${MICROSOFT_GRAPH_BASE_URL}/me/calendar?${query}`, + ); + } + + async listInstances(params: { + seriesEventId: string; + startDateTime: string; + endDateTime: string; + }): Promise { + const eventId = encodeURIComponent(params.seriesEventId); + const query = new URLSearchParams({ + startDateTime: params.startDateTime, + endDateTime: params.endDateTime, + $select: MICROSOFT_EVENT_SELECT, + }); + const data = await this.#request<{ value?: readonly GraphEvent[] }>( + "GET", + `${MICROSOFT_GRAPH_BASE_URL}/me/events/${eventId}/instances?${query}`, + ); + return data.value ?? []; + } + + async #request( method: "GET" | "POST" | "PATCH" | "DELETE", url: string, body?: GraphEventWriteBody, ifMatch: string | null = null, - ): Promise { + ): Promise { const headers: Record = { Authorization: `Bearer ${this.#accessToken}`, Prefer: 'outlook.timezone="UTC"', @@ -459,7 +606,7 @@ class FetchMicrosoftEventWriteApi implements MicrosoftEventWriteApi { }); if (method === "DELETE") { - if (response.ok) return {} as GraphEvent; + if (response.ok) return {} as T; const deleteError = await parseErrorBody(response); throw Object.assign( new Error(deleteError.message ?? "microsoft_event_write_failed"), @@ -467,7 +614,7 @@ class FetchMicrosoftEventWriteApi implements MicrosoftEventWriteApi { ); } - const data = (await response.json()) as GraphEvent & { + const data = (await response.json()) as T & { error?: { code?: string; message?: string }; }; diff --git a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts index b2710346d..f4c263850 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts @@ -25,6 +25,7 @@ const NO_CATEGORY_COLORS: ReadonlyMap = new Map(); const MEETING_PROVIDER_LABELS: Readonly> = { teamsForBusiness: "Microsoft Teams", + teamsForConsumer: "Microsoft Teams", skypeForBusiness: "Skype for Business", skypeForConsumer: "Skype", }; @@ -235,7 +236,8 @@ function mapAttendees(attendees: GraphEvent["attendees"]): Attendee[] { })); } -function mapConference(item: GraphEvent): Conference | null { +/** Read-reflected Teams URL. Writers reuse this on create responses. */ +export function mapConference(item: GraphEvent): Conference | null { const url = item.onlineMeeting?.joinUrl; if (!url) return null; diff --git a/packages/sync/src/providers/provider-event-writer.port.ts b/packages/sync/src/providers/provider-event-writer.port.ts index 3d080be1d..dac49083b 100644 --- a/packages/sync/src/providers/provider-event-writer.port.ts +++ b/packages/sync/src/providers/provider-event-writer.port.ts @@ -106,10 +106,10 @@ export interface ProviderWriteResult { // Google's cross-copy correlation key when the write response includes it. // Optional so non-Google writers and older fixtures stay valid. readonly icalUid?: string; - // Meet URL Google minted on create (or echoed on a later write). Omitted - // when the response has none. Create records overlay this onto command - // content, which sends `conference: null`. - readonly conference?: Conference; + // Meet/Teams URL the provider minted on create (or echoed on a later write). + // Omitted when the write did not ask for a conference. Null when the write + // asked for one but the provider could not mint a link (no Teams allowed). + readonly conference?: Conference | null; } // A provider-neutral event mutation port. Neutral inputs in, provider identity