feat(integrations): shared-layer enrollment + target-collection plumbing (#505, #506) - #511
Conversation
…or layer (#505) Slice 1 of the accepted M365 ingestion design (#492): - EnrollmentRecord + optional enrollment/adapterData on ProviderState; "m365" joins ProviderName with an optional config block - optional ProviderAdapter.resolveEnrollment capability for selected-source providers - adapterData held provisionally with the change cursor: snapshotted before discovery, restored for intermediate writes, committed only when no source in the page failed - UnavailableSourceEvent.reason widens to no_longer_discovered | access_denied | deleted | unenrolled (backward-compatible JSONL) - optional maxCycleMs soft wall-time cap in ReconcileLimits: stop starting new fetches, leave the remainder retryable, cursor uncommitted - integration route matcher derives provider names from registered adapters instead of a hardcoded (google|notion) list - state validation covers the new fields; google/notion adapters unchanged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BgaRyc5Nji2GpMYwXEYpmk
…l path (#506) Slice 2 of the accepted M365 ingestion design (#492). Depends on #505's EnrollmentRecord/adapterData (already on this branch, PR #511). - DistillIds/DistillUpsertInput gain an optional collection override, defaulting to DISTILL_COLLECTION; propose.ts's derivePath and the frontmatter.collection field use it instead of the hardcoded constant. Every existing caller (no override passed) is unaffected. - DistillationInput gains targetCollection?: string. RemoteSource gains an optional enrolledRef so a folder-enrolled provider can attest which enrollment record owns a discovered descendant; the reconcile loop looks up the owning EnrollmentRecord (exact ref match, or enrolledRef for a folder descendant) and threads its targetCollection into deps.distill(). google/notion carry no enrollment, so they see no behavior change. - src/integrations/distill.ts forwards targetCollection into the distillUpsert collection field. - refuseRawDistillOutput needed no change: it is purely structural (path + frontmatter.tier), so it already fires identically against an overridden collection — added tests proving it. - New requireCollectionWriteAccess(role, targetCollection) reuses canWrite (the same stage-time write-gate rationale as vault_stage_action, docs/architecture.md): manage_integrations alone must not let an enrollment aim proposals at a collection the serve process cannot write. #509's enroll route will call this before persisting an EnrollmentRecord. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BgaRyc5Nji2GpMYwXEYpmk
|
CI red on — a filesystem-cleanup race in that test's tmp-git-repo teardown, in Generated by Claude Code |
The Security Review bot on PR #511 flagged that derivePath() joins the caller-supplied `collection` string into the staged file path without sanitizing it, even though the comment above it claims all join components are traversal-safe. Before #506, `collection` was always the hardcoded DISTILL_COLLECTION constant; now it can come from EnrollmentRecord.targetCollection, which state.ts only checked with typeof === "string" — no charset restriction. Add isValidCollectionName() (single path segment: [A-Za-z0-9_-]+, no separators or traversal) and enforce it in two places: proposeAllClaims rejects the whole batch with a clear error rather than silently sanitizing (sanitizing could make the written path diverge from the string requireCollectionWriteAccess checks against RBAC), and validEnrollmentRecord rejects an unsafe targetCollection at the persistence boundary so bad state can never be written or read back. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BgaRyc5Nji2GpMYwXEYpmk
Review summaryThe core plumbing for M365 selected-source ingestion (#505/#506) — enrollment records, However, the feature is currently unreachable end-to-end because three provider-enumeration sites outside this diff's touched files were not updated to include
None of these three files have any Recommend fixing 1 and 2 before merge (2 is unreachable without 1); 3 can be a fast-follow but should track |
…drift Three more findings from the Claude Code Review pass on PR #511, all confirmed against the actual code path: - runtime.ts's configuredProviders() and config.ts's RECOGNISED_INTEGRATIONS_KEYS / per-provider parse loop still hardcoded ["google", "notion"], even though this PR widened ProviderName to include "m365" specifically so an operator could configure it ahead of #508's adapter landing. An integrations.m365 block in config.yaml was rejected as an unknown key before ever reaching the "missing factory" error this PR added for exactly that case. - queue.ts's validQueueItem still only accepted provider "google" | "notion". Once an m365 webhook event reached the durable queue, the next read of the whole pending array would fail as malformed, taking every other provider's queued events down with it. Mirrors the isProviderName fix already in state.ts. - proposeAllClaims stamped the current run's `collection` into every claim's frontmatter uniformly, including update-in-place (pathOverrides) claims whose targetPath is pinned to wherever a PRIOR run landed them. If an enrollment's targetCollection changes between runs, the physical path stays under the old collection while frontmatter.collection would claim the new one — a metadata/location mismatch that matters because collection drives RBAC downstream. Now derived from the landed path's own leading segment for update claims. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BgaRyc5Nji2GpMYwXEYpmk
…provider-id m365) main advanced past #511 (shared-layer enrollment + target-collection plumbing) + RBAC, which overlaps this branch. main built the enrollment socket (EnrollmentRecord, ProviderState.enrollment[], RemoteSource.enrolledRef -> owningEnrollment -> targetCollection -> distill, requireCollectionWriteAccess, resolveEnrollment hook) but shipped no enroll route; this branch is that plug. Reconciliation: - Provider id standardized to "m365" (main released it in 3.13.0). Config key, routes, adapter name, factory, UI provider field, tests renamed; the Microsoft-named files/types/MSAL vendor and "Microsoft Graph" prose kept. - One EnrollmentRecord: main's spine (ref, kind file|folder, targetCollection, enrolledAt, enrolledBy) + the m365 operational/audit fields (driveId, remoteId, cursorKey, includeSpeakerNotes, webUrl?, audienceAckAt, readersAtEnrollment). Dropped the dead siteId/listId and the branch's id. - Storage: main's enrollment[] array (identity = ref); dropped the branch's enrollments Record and SourceState.enrollmentId (a read-but-never-written placeholder). - Dynamic ownership via enrolledRef: discover() now tags each source with its owning enrollment's ref (folder descendants -> folder ref; files -> own id), so the enrolled targetCollection actually reaches distillation — closing the gap main's #511 exists to fill. - m365 config is the rich MicrosoftProviderConfig (tenant_id + collections allowlist required); a bare block is now correctly rejected. - Kept main's provider-neutral providerFrom(adapters), targetCollection validation (isValidCollectionName confused-deputy guard), and canWrite gate. Verified: tsc + biome clean; full suite green on Node 22 (5092 passed); integration/distill/config/extract green on Node 20 (full Node 20 suite blocked locally by a better-sqlite3 ABI mismatch from an fnm node switch — CI rebuilds natives per version). Two obsolete tests removed with notes (m365-without-factory; bare m365 config block). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Stacked slices 1–2 of the accepted M365 ingestion design (#492, spec
docs/superpowers/specs/2026-08-31-m365-selected-ingestion-design.md). No M365-specific code in either — the shared layer that a selected-source adapter (#508) will build on.Closes #505 — provider-neutral enrollment in the shared connector layer
src/integrations/types.ts:"m365"joinsProviderName(with an optional config block soconfig[provider]indexing stays typed); newEnrollmentRecord(ref,kind: "file" | "folder",label,targetCollection,enrolledAt,enrolledBy);ProviderStategains optionalenrollmentand opaque adapter-ownedadapterData. Absent fields = existing google/notion behavior.src/integrations/engine.ts: optionalProviderAdapter.resolveEnrollment(candidates, state)capability; the provisional-cursor discipline now also snapshots/restoresadapterData(deep-copied) — intermediate writes keep the replayable values, commit only when no source in the page failed;UnavailableSourceEvent.reasonwidens tono_longer_discovered | access_denied | deleted | unenrolled;ReconcileLimitsgains an optionalmaxCycleMssoft wall-time cap.src/integrations/routes.ts: the provider route matcher derives names from the registered adapters instead of the hardcoded(google|notion)regex.src/integrations/state.ts/runtime.ts: validation for the new fields;DEFAULT_FACTORIESbecomesPartialwith a named error if a configured provider has no factory.Closes #506 — thread
targetCollectionthrough the integration distill pathsrc/distill/propose.ts/state.ts:DistillIds/DistillUpsertInputgain an optionalcollectionoverride, defaulting toDISTILL_COLLECTION;derivePathand thefrontmatter.collectionfield use it. Every existing caller (no override passed) is unaffected.src/integrations/engine.ts:DistillationInputgainstargetCollection?: string;RemoteSourcegains an optionalenrolledRefso a folder-enrolled provider can attest which enrollment record owns a discovered descendant. The reconcile loop looks up the owningEnrollmentRecord(exact ref match, orenrolledReffor a folder descendant) and threads itstargetCollectionintodeps.distill(). google/notion carry no enrollment, so they see no behavior change.src/integrations/distill.tsforwardstargetCollectioninto thedistillUpsertcollection field.refuseRawDistillOutputneeded no code change — it is purely structural (path +frontmatter.tier), so it already fires identically against an overridden collection; added tests proving it.requireCollectionWriteAccess(role, targetCollection)reusescanWrite— the same stage-time write-gate rationale asvault_stage_action(docs/architecture.md "Stage-time write gate"):manage_integrationsalone must not let an enrollment aim proposals at a collection the serve process cannot write. feat(m365): File Picker page, enrollment routes, cost preview, and status surfaces #509's enroll route will call this before persisting anEnrollmentRecord.Test plan
npm run lint— 0 errors/warnings (6 pre-existing infos)npm run build— cleannpm test— full suite shows only the 30 known environmental failures (Read-path fence: kill condition 1 fired — decide whether to accept the kill or fund a larger pre-registered run #314), identical tomaindiscover()= "expand the enrollment set"; adapterData replay (failed source keeps cursor+adapterData replayable, success commits both);maxCycleMs; route matcher; state round-trip + malformed-enrollment rejectionraw; engine threadstargetCollectionfrom the owning enrollment record (direct file match and folder-descendantenrolledRefmatch); google/notion unaffected;requireCollectionWriteAccessallows/denies by role, including the null-role deny-all fallbackInvariants
canWriteagainst the existing.daftari/config.yamlroles, no new permission modelResult<T, Error>; no classes; no throws from tool handlers🤖 Generated with Claude Code
https://claude.ai/code/session_01BgaRyc5Nji2GpMYwXEYpmk