Collaborative document writes in shared chats — implementation plan
Status: In progress · Author: Christophe (with Claude) · Date: 2026-06-30 · Repo: north-os · Related: chat sharing (2026-06-27), document editing (2026-06-10)
Problem
In a shared chat only the thread owner could confirm an
agent document/email write (a owner_must_confirm 403 in
run.ts). Making it collaborative exposes a second issue: a
case_document version row stores one OneDrive
pointer (the confirmer's), so other participants can't open the doc — it
lives in someone else's drive.
Design
-
Remove the owner-only gate. Writes already execute under
the resumer's identity (
configurable.user_id = input.userId), so any participant who confirms acts as themselves — own North Drafts, own mailbox. Identity-safe. -
Per-user delivery ledger
case_document_delivery(one row per version × user). The confirmer still auto-delivers to their own drive (solo behaviour preserved); every other participant gets a "Save to my OneDrive" action that delivers the R2 bytes to their drive on demand and records their pointer.case_document.onedrive_*stays the confirmer's (load-bearing for the Word doc↔thread R1 lookup) — never overwritten.
Decisions
- Confirmer auto-delivers (preserve solo flow); others Save-on-demand.
- RLS ENABLE-only via
pgPolicy()/.enableRLS()(safe:app_roleis NOBYPASSRLS). Migration 0056, GRANT inprovision-roles.ts. - UI delivery-state via a batched
documentDeliveriesmap on/api/threads/:id/messages+ context (no per-card N+1 GET). - Card marker change is additive (
deliveredByadded; thewebUrlJSON key is never renamed — parsed in two places).
Deferred (open questions, defaulted)
- Word doc↔thread continuity for a collaborator's copy —
findDocumentByOnedriveItemIdmatchesuploaded_by + onedrive_item_id; a collaborator's copy lives only incase_document_delivery, so opening it in Word won't resolve to the thread. Out of scope for this cut. - Plain uploads (
origin='upload') stay non-deliverable (404). - Pre-feature cards have
deliveredBy=null→ the original confirmer sees "Save" (idempotent) until first click. Cosmetic, accepted.
Build order
case_document_deliverytable inpackages/db/src/schema/document.ts.- GRANT in
packages/db/scripts/provision-roles.ts(app_role: SELECT/INSERT/UPDATE). db:generate→ review →db:migrate→provision(0056).- Store (
apps/web/lib/documents/store.ts): relaxloadDeliverableVersionto thread-visibility (reusethreadVisible); addfindUserDelivery+recordUserDelivery(never touchcase_document.onedrive_*). - Deliver route (
router.ts): per-user idempotency + record. - Agent auto-deliver (
document-write-access.ts/document-write-shared.ts): confirmer also upserts a per-user delivery row. - Card marker attributable: add
deliveredBy(additive). - Remove the
owner_must_confirmgate (run.ts958-980 +pendingPayloadKind). - chat-runtime:
listMyDocumentDeliveriesbatched map + exportthreadVisible. - chat router: return
documentDeliverieson/threads/:id/messages. - chat-ui: viewer + deliveries context;
LiveChatprops. - chat-ui:
DocumentResultCardper-viewer Open/Save. - Wire
currentUserId+initialDocumentDeliveriesthrough render sites. - Tests (invert
§7.4; deliver per-user; card branches; map) + cleanup.
Risks (carry into review)
Ship gate+table together; scope relaxation exact (threadVisible, not
org-wide); never write case_document.onedrive_* on a collaborator
deliver; migration discipline (db:generate only, GRANT not in
migration, no db:push); every new query inside
withRlsTransaction; per-user write is ON CONFLICT … DO UPDATE;
keep threadCreatedBy + interruptPayloadToolCallId.