NetDocuments filing back + folder watch — design spec
Status: planned
Author: Claude (Fable, then Opus after the Fable credit stop), for Christophe
Date: 2026-09-07 (R2 the same day, after two adversarial design reviews)
Repo: north-os
Related: 2026-08-17-netdocuments-mcp-federation-design (§3.3 consent, §6 persistence, §7 inability to write, §9 scope), 2026-08-27-netdocs-firmwide-connection-design (§2.1 access_scope, §2.2 exclusivity ruling and grandfathered rows, §2.4 reconnect and ownership transfer), 2026-06-10-document-editing-design (in-tool confirmation, resume, personal deliver route, agent rows never promoted), docs/reviews/2026-09-07-partner-netdocs-gating-triage (lane L6), PR #216 (REST connector; its client package and its §9 ledger are the live-verified REST facts), docs/connections/netdocuments-api-access-request.md
Revision R2, 2026-09-07 — after the second dual review (48 findings, both readers NOT CONVERGED)
R1 fixed the first round's write-safety gaps. R2 fixes what the second round found underneath them: the parts where the design named a mechanism that does not exist in the shape the design assumed. The deltas, so a third reader checks these rather than re-deriving them:
- One REST seam, and the bearer never leaves the credential boundary. R1 had a new client in connectors-api and a poller calling NetDocuments directly from
apps/worker-connectors— a fourth call shape outside the guard, in the one process holding a write-capable bearer on a schedule. R2: the verified closed client from PR #216 (packages/connector-netdocuments) is extracted into a shared package, the probed upload primitive is added to it, its allow-list guard travels with it, and the poller calls a connectors-api read proxy that takes a watch id and performs only the approved listing call. The worker never holds a DMS token (§3.2, §4.2, §6). - The claim protocol is transactional, leased and namespaced. Claim commits in its own transaction; exactly one non-retrying upload runs outside any transaction; settlement commits in a second; a
pendingrow past its lease is swept tounknown; a caller that meets an in-flight claim gets a receipt saying so. The key is(organization_id, user_id, surface, thread_id, tool_call_id, binding_sha256)on product chat and a deterministic payload digest on MCP, where the host may retry an accepted elicitation and no durable tool-call id exists. A key whose stored payload differs is refused, never returned (§3.2 step 5, §3.3). - The write boundary recomputes what it is asked to trust: it decodes the bytes once, computes the digest and length itself, and compares them with the request and the binding before minting a token (§3.2 step 5).
- Destination freshness is re-resolved, not remembered. The container read at registration returns a child listing, not ancestry (REST A-22), so a stored path can go stale. The card and the write boundary both re-resolve the live container identity, and the ledger carries the ancestry question as its own probe (§3.1, §3.2, W-8).
- The source's origin is part of the confirmation.
resolveSourceBytesreturns agent-written rows, which the product deliberately refuses to promote to matter scope; filing is the first path by which one leaves the lawyer's personal space. The binding and the card carryorigin, and §3.5 and the plan record the invariant that retires (§3.2 step 2, §3.5). - Owner isolation follows
user_drafts_folder, notmatter_access. The owner GUC is set only when a caller passes it, and the credential boundary's transaction sets the organization alone, so amatter_access-shaped policy would make every filing fail at the boundary. R2: organization RLS plus an owner predicate in every query, on both roles (§3.1, §4.1, §6). - Enrolment is a row with a terms version, enforced server-side at target registration, watch registration, filing claim and watch proxy — not a UI acknowledgement a direct tool call bypasses (§4.4, §6).
- The 409-on-a-different-identity claim is withdrawn. Collaborative write confirmations are answerable by any thread participant by design, each acting as themselves. What actually protects filing is that the resume re-executes under the resumer: their credential, their targets, their receipt. Stated as the control instead (§3.2 step 4).
- MCP audit gets a prepared payload. The chassis computes the redaction before
consent.prepareruns, so the resolved destination and byte count cannot come fromredactArgs; prepared consent returns a closed audit payload the chassis merges (§3.3). - Ledger ownership moves to the credential boundary.
app_rolereads its own filing rows and manages targets; onlysync_rolewrites the delivery record (§6). - Connector generation is what invalidates. A same-owner firm reconnect reuses the same row with the same owner, so owner and connector id detect nothing. Targets and watches store the connector's consent generation and provider account, and a custody completion or severance invalidates and pauses them inside its own transaction (§3.1, §4.1, §4.4).
- Watch: listing only in v1, cadence promised at what the budget guarantees, cap derived per credential, lockout recovery in the scan predicate, poll-time revalidation of membership, account status and matter access, snapshot ageing only under full coverage, an event kind and an explicit transition behind every email, and a stored link for every document. The repository-audit-log feed becomes its own future spec rather than a branch inside one lane (§4.1–§4.4).
- The limiter reservation is two keys checked in one script, because the existing window counts undifferentiated members and a smaller capacity on the same key starves the poller rather than reserving anything (§4.2 step 4).
- Delivery uses the digest mailer that already takes an idempotency key (
apps/worker-notifications/src/newsletter/mail.ts), not the nudge mailer; P-7 is verified in-repo and leaves the shortcut list (§4.3). - Retention is a matrix, not one figure for one table, and the email processor's posture is in §8 (§4.3, §6, §8).
- Corrections: the mint route also requires
connectorId;consentrequiresrenderand takesprepareas an optional addition; the capability test ispackages/agent-runtime/src/tools/netdocs-capability.test.tsand the pin testpackages/agent-runtime/src/workflows/confirm-preapproved-pin.test.ts; the matter surface isapps/web/app/(app)/matters/; the existing pin test already forbids a filing tool from readingconfirm_preapproved(no amendment, and naming the literal would break the test on itself);WORKFLOW_TOOL_MAPabsence is already a type error; the retiring assertions arenetdocs-tools.test.ts's "nonorth__netdocs_*in the write catalogue", federation §7 layers 2 and 3 and its headline, and §9's two exclusions; "the partner alone" is a fact about data (grandfathered owner-reach rows exist by ruling), so the spec states the rule and the plan verifies the count; ledger regrades on W-4, W-5, W-6, P-5, P-7 and C-1 split in two.
Revision R1, 2026-09-07 — after the first dual review (49 findings, both readers NOT CONVERGED)
Kept for the record; every R1 delta stands unless R2 changes it above. Claim-before-upload instead of a unique index after it; confirm_preapproved never honoured; a strict review binding instead of a toolCallId match; thread-reachability source authorization; bytes on the wire so no R2 client enters the credential boundary; owner-only v1; the MCP write loop gated by one family registry; snapshot diff instead of a watermark (ordering ignored live, cursor unproven); owner-isolated watch tables; a poller sub-capacity against the real limiter; newsletter-style delivery claims; a watch lifecycle backend; the worker custody surface stated; phase 0 gated on the firm's answer; §8 and the ledger extended.
0. Why
The partner's session on 2026-09-06 ran into three things North cannot do with NetDocuments: browse, file a document back, and notice when something lands in a matter folder. Browse and ids are lane L5. This spec covers the other two, which the triage report left as "design spec only, consent decision with the firm before any code":
- Filing back. One write tool that uploads a document North holds into a NetDocuments folder the lawyer chose beforehand, behind a structural confirmation, fully audited.
- Folder watch. One polled watch per flagged matter folder (the real-estate "Title" folder is the motivating case; the mechanism is folder-agnostic), driven from
apps/worker-connectors, feeding an email to the lawyer who flagged it. NetDocuments publishes no webhook (REST spec §3.5), so a watch is a poll plus a snapshot, not an event.
Both cross the two lines the federation spec §9 drew on purpose: any write scope and background use of a lawyer's token. Those lines were consent decisions. §8 is the section kwiss takes to HSE; nothing in §3–§7 is built before that conversation closes.
1. What exists today (verified against main @ 0035bfb0, re-verified in two review rounds)
- Reads only, and no REST at all in
main.packages/connector-netdocs-mcpis compile-time closed tosearch+fetch(test/surface-guards.test.ts);find_in_documentis built on fetch. The federation spec describes a connect-time identity read; it was never built.connector_user_tokens.account_idfor NetDocuments is the syntheticconnector:<id>(apps/connectors-api/src/lib/netdocs-mcp.ts), so no provider subject is recorded today. Three tools on both surfaces, hidden when the feeder is disconnected (NETDOCS_MCP_TOOL_DEFS;netDocsToolsinpackages/agent-runtime/src/tools/netdocs.ts; guardsapps/mcp-server/test/netdocs-tools.test.tsandpackages/agent-runtime/src/tools/netdocs-capability.test.ts). - A verified REST client exists, parked. PR #216's
packages/connector-netdocumentscarriesclient.ts,auth.ts,cabinets.ts,folders.ts,walk.ts,download.ts,error-mapping.tsand their tests, built against the 2026-08-18 live probes. It has no upload primitive. - The token is already write-capable. NetDocuments' own consent screen grants
read organize editwhatever North asks for. The connections page says today: "The bearer is write-capable; North implements and allowlists read-only operations only" (apps/web/app/(settings)/connections/page.tsx:1146-1148). Filing makes that sentence false; §5 and §8 own the change. - One credential serves the firm; the exclusivity ruling has a grandfather clause. The firm-wide connection is
scope='user',access_scope='organization'.resolveNetDocsAccessprefers the actor's ownauthorizedrow, then the live firm row, and returnscredentialOwnerUserId,accessScopeandactorUserIdseparately. The ruling refuses new personal connections while a firm row is live, but an existing owner-reach row may be reopened (patchConnector reopenIfTerminal; the ruling is about new rows). Who at HSE holds such a row is a fact about data, not about the ruling. - Reconnect semantics. A same-owner firm reconnect happens in place, in the same row, so neither
owner_user_idnor the connector id changes; only a different administrator's transfer stages a second row and severs the incumbent inside the completion transaction (apps/web/app/(settings)/connections/actions.ts). Nothing in the schema records a consent generation today. - Token custody.
connector_user_tokens; minted throughPOST /connectors/netdocs-mcp/access-token, whose body requiresorganizationId,userIdandconnectorId(plus optionalactorUserId,expectedAccessScope), locking the connector rowFOR UPDATEwithowner_user_id = userId. The internal bearer authenticates the calling service, not the body's user (routes/deps.ts).apps/worker-connectorsholds no connectors-api credential today. - Rate limiter.
packages/connector-netdocs-mcp/src/redis-limiter.ts: one Redis ZSET per (org, credential owner, account), capacity 18 per 60 s, members untagged, and it denies rather than paces. With the firm-wide credential this is the whole firm's interactive budget. - RLS mechanics.
withRlsTransactionsetsapp.organization_idalways andapp.user_idonly when the caller passesoptions.userId(packages/db/src/rls.ts). The credential boundary's own helper sets the organization alone (packages/db/src/sync-tx.ts), andsync_roleis notBYPASSRLS.user_drafts_folderis therefore org-scoped RLS with an app-layer owner predicate;matter_accessandmatter_pinuse the user GUC and are only reachable from app-role callers that pass it. - The write pattern to copy. Document editing: the interrupt is raised inside the tool after validation and before any write; the resume guard accepts only the pending interrupt's
toolCallIdand treats write-confirm kinds as strict kinds carryingauthoringIdentity(model and agent ids only) and aServerReviewBinding, with unknown kinds falling through asnon_review; the per-thread lock is in-process and force-releases afterTHREAD_LOCK_MAX_HOLD_MS; any thread participant may answer a write confirmation, deliberately, because the tool re-executes under the resumer's identity (packages/chat-runtime/src/run.ts);confirm_preapprovedis set only by the workflow executor for the closedWORKFLOW_TOOL_MAPand read only byartifacts/confirm.ts, pinned bypackages/agent-runtime/src/workflows/confirm-preapproved-pin.test.ts, which asserts the literal appears in exactly those two files; the deliver route carriescontentBase64, a 15 MB cap and an allow-list sanitizer, and no target field; packaging tools get bytes only throughresolveSourceBytes(organizationId, currentThreadId, fileId), which enforces thread reachability and returns agent-written rows too —case_documentrows withorigin != 'upload'are refused promotion to matter scope by design. - MCP chassis.
NORTH_WRITE_TOOL_CATALOGUEregisters only formcp:write; a confirmed-tier def carriesconsentwith a requiredrenderand an optionalprepare; the chassis computestoolArgsRedactedbeforeprepareruns, so resolved state cannot reach the audit row throughredactArgs. Family postures live inaccess.ts(netdocs: "owner-read"). The NetDocuments pilot gate runs only in the read-catalogue loop, andsyncNetDocstoggles onlynetDocsTools.netdocs-tools.test.tsasserts today that nonorth__netdocs_*def appears in the write catalogue. - Federated content is stubbed out of context by tool name.
NETDOCS_TOOL_NAMES(packages/connector-netdocs-mcp/src/types.ts) is the closed list that keeps federated bodies out of inline cleaning and the digest. - Polling and delivery shapes to copy.
scheduled-rewalk.ts: Redis-mutexed, best-effort, never-fatal, cross-tenant underscheduler_role, already gated on connector status.apps/worker-notifications:src/mail.tsis the nudge mailer ({ kind, to, url }, no idempotency parameter);src/newsletter/mail.tsis the digest mailer and takes an idempotency key, in production use;weekly-digest.ts+newsletter_sendclaim before send withstatus/attempts/sent_at, stale reclaim, capped attempts, and re-check banned recipients inside the claim. - REST facts, live-verified 2026-08-18 (PR #216 §3.3 corrections and §9). US host;
GET /v1/user/infoidentity (two documented fields absent, A-5 graded contradicted);GET /v2/container/{id}?select=standardAttributes&top=…&skiptoken=…returns{Results, TotalFound, SkipToken}— a child listing, not ancestry (A-8/A-22);GET /v1/Document/{id}content;orderbyignored, field expressions 400 (A-23);skiptokenstability under concurrent mutation unproven (A-18); lockout near 25–30 calls per minute without a 429 (A-16); the repository audit log answered 200 with document ids and deletes, checkpointing, volume, naive dates and deletion mapping unbuilt, container attribution never checked (A-13); no client-credentials grant (A-19). No upload was ever exercised (W-1).
2. Design in five lines
- The agent never names a NetDocuments folder. It names a destination the lawyer registered; the write boundary re-resolves that destination live, under the lawyer's own token, and refuses if it is not what the lawyer confirmed.
- Every filing pauses on a card built from a persisted binding — folder, filename, size, source and how the source came to exist — claims its ledger row in a committed transaction before a single non-retrying upload, and never honours a pre-approval.
- Only a lawyer who owns an authorized NetDocuments connection can file or watch, through their own credential and their own registered destinations, after a one-time enrolment recorded server-side.
- A watch is a row per (matter, folder), polled on a fixed cadence through a connectors-api read proxy so the bearer never reaches the worker, diffed against a per-item snapshot, owner-isolated end to end, and paused the moment the credential, the membership or the matter access behind it changes.
- Everything the design cannot prove about the provider is a probe in §9, and no lane starts before the probe that gates it.
3. Filing back
3.1 Registered destinations, and who may register one
New table user_netdocs_filing_target (organization RLS in the shape of user_drafts_folder, with an owner predicate in every query on both roles — a matter_access-shaped policy would be invisible to the credential boundary, whose transaction sets only the organization): id, organization_id, user_id, connector_id, connector_generation (integer, copied from the connector row at registration), provider_account_id (from the identity read), container_id, container_kind (folder | workspace), container_name, container_parent_id, container_path_recorded (best-effort ancestry at registration, with path_recorded_at), label, matter_id (nullable), status (valid | invalidated), invalidated_reason (reauth | transfer | provider | user), validated_at, created_at.
Freshness. The registration read returns the container's children, not its ancestry (A-22), so the recorded path is a snapshot with a timestamp, not a live fact. W-8 probes whether a verified ancestry operation exists. Until it does, the card names the folder by its live name and id and shows the recorded path with its date, and the write boundary compares the live container_id, container_kind, container_name and container_parent_id against the binding; a mismatch refuses. If W-8 finds an ancestry call, the full path is re-resolved at both points and the recorded path disappears.
Invalidation. A same-owner reconnect keeps the row and the owner, so neither identifies a new consent: the connector row gains a consent_generation counter incremented on every successful token replacement or reconnect completion, and every target and watch carries the generation it was registered under. A custody completion or severance invalidates targets and pauses watches inside its own transaction, in connectors-api where custody actually changes, not in the web action that may be abandoned.
Who may register: an actor for whom resolveNetDocsAccess returns credentialOwnerUserId === actorUserId with status authorized, who has an enrolment row for the current terms version (§4.4). That is the rule. Whether anyone besides the firm-row owner satisfies it at HSE today is a data question the plan answers with a read-only query before §8 goes out, because grandfathered owner-reach rows are permitted by the firm-wide ruling. Everyone else gets owner_credential_required: "North can file into NetDocuments only through a connection in your own name."
Registration is on the connections page: paste a folder URL or id; connectors-api probes it under the actor's token (GET /v2/container/{id}, first page), records the identity fields above, refuses ids that resolve to nothing or to a document. Cap 25 targets per user. Lane L5's picker can feed the same form later.
3.2 The tool: netdocs_file_document
One implementation in packages/agent-runtime/src/tools/netdocs-file.ts, exposed as netdocs_file_document in product chat and north__netdocs_file_document on MCP. A read sibling netdocs_filing_targets / north__netdocs_filing_targets lists the caller's valid destinations by id and label on both surfaces — the model cannot otherwise learn the opaque id behind a label, and MCP descriptions are static.
Arguments: target_id, source ({ kind: "case_document", id }), filename (allow-list sanitizer + NFC, extension must match the source's). Nothing else. Out of scope in v1: mail attachments, new versions of existing NetDocuments documents, moves, renames, deletes, profile attributes.
Credential and enrolment.
resolveNetDocsAccess; requirecredentialOwnerUserId === actorUserId, statusauthorized, and a current enrolment row; elseowner_credential_requiredorenrolment_required, and no token is minted.Source authorization is reachability, and origin is part of the answer. Product chat:
resolveSourceBytes(organizationId, currentThreadId, id). MCP (no thread): the row must bescope='thread'on a thread the actor owns. The row'sorigintravels into the binding: an agent-written row (origin != 'upload') is one the product deliberately keeps in the lawyer's personal space and refuses to promote to matter scope, so filing it is the first path by which it becomes firm-visible. The card and the elicitation say so in words. Compute the digest and length from the bytes; cap 15 MB.Target and binding. Load the target (owner predicate,
status='valid', connector and generation still current), re-resolve the container live, and build the binding:{ authoringIdentity, actorUserId, targetId, connectorId, connectorGeneration, providerAccountId, containerId, containerKind, containerName, containerParentId, pathShown, sourceId, sourceOrigin, sourceSha256, filename, bytes }.binding_sha256is the digest of that object.Confirm against the binding. Product chat:
interrupt({ kind: "netdocs_file_confirm", toolCallId, binding }), a strict kind (a newServerReviewBindingvariant, so it cannot fall through asnon_review); resume"yes" | "no"for the pendingtoolCallIdonly. Any thread participant may answer, by design: the tool then re-executes under the resumer, so step 1 re-runs with their credential, step 3 with their targets, and a colleague without their own connection simply getsowner_credential_required. The binding is rebuilt on resume and compared field by field with the persisted one; any difference refuses.confirm_preapprovedis not read by this tool — the existing pin test already fails any file that reads the literal outside the two allowed ones, andWORKFLOW_TOOL_MAPis a closed record, so absence there is a type error. MCP:consent.prepareresolves the same binding under the actor's token and returns it for verbatim rendering plus a closed audit payload (§3.3); decline never reaches the handler.Claim, upload once, settle.
POST /connectors/netdocs-mcp/filewith{ organizationId, userId, connectorId, invocationKey, binding, contentBase64 }. The boundary, assync_role: decodes the bytes once, recomputes digest and length and compares them with the binding (mismatch ⇒ 409, no token), re-resolves the target by id and owner predicate and the live container, then:- transaction 1 inserts the ledger claim (
outcome='pending',claimed_at, the binding digest and the immutable payload fields) and commits. A key that already exists with the same payload returns the stored row (filed,failed,unknown, orpending⇒ the in-flight receipt); a key that exists with a different payload is refused (409), never returned. - the upload runs outside any database transaction, exactly once, with no retry.
- transaction 2 settles
filedwith the document id and URL, orfailedwhen the request provably never left, orunknownwhen the outcome is not knowable. - a sweep settles
pendingrows past a lease (15 minutes) tounknown, because process death runs no code.
invocationKeyissha256(organization_id, user_id, "chat", thread_id, tool_call_id, binding_sha256)in product chat, andsha256(organization_id, user_id, "mcp", binding_sha256)on MCP, where the host may retry an accepted elicitation andprepareruns once per call: a retry then collides with the existing claim instead of uploading twice.- transaction 1 inserts the ledger claim (
Receipt carries the document id, the URL, the destination as confirmed, the byte count, and what North did not do. An in-flight claim returns "a filing for this confirmation is already running"; an
unknownsays the upload may have landed and gives the folder link. Anowrites no ledger row.
The REST seam. PR #216's packages/connector-netdocuments is extracted as the single closed REST client for the whole repo; this spec adds the probed upload primitive and the identity read to it. Its allow-list guard test lives in that package and names every permitted call shape (identity, container listing, upload, plus the watch listing of §4.2 which is the same call); a shape not in the list fails. connectors-api is the only process that constructs it with a bearer. packages/connector-netdocs-mcp stays search + fetch; apps/mcp-server/src/mcp-app.ts is untouched.
3.3 Audit, everywhere the write can be seen
agent_tool_auditthrough the existing chassis on both surfaces:{ target_id, source_document_id, source_origin, filename_length, bytes, decision, outcome };outcomestays in its closed set (a refusal isokwithdecision: refused). Because the MCP chassis computes the redaction beforeprepare, prepared consent returns a closed audit payload (ids, enums, counts only, guarded against content fields) that the chassis merges after preparation; without it the destination and byte count cannot be audited on that surface.- New table
netdocs_filing(organization RLS + owner predicate), the external-delivery state machine:id,organization_id,user_id,invocation_keyUNIQUE,binding_sha256,target_id,connector_id,connector_generation,provider_account_id,container_id,source_document_id,source_origin,source_sha256,filename,bytes,outcomeCHECK (pending | filed | failed | unknown),nd_document_id,nd_url,error_class,claimed_at,settled_at,created_at. Provenance of how the source came to exist stays indocument_write_provenance; this table does not duplicate it. product_eventnetdocs.file_settled{ connector_id, target_id, bytes, source_origin, outcome, duration_ms }.- NetDocuments' own audit trail shows the upload under the lawyer's identity; whether it also records an application marker is unverified and the phase-0 probe captures those fields before §8.1 states anything as fact.
3.4 Where the tool is admitted, and where it hides
- One registry drives both catalogues: the NetDocuments family becomes the union of a read family and a
netdocs_writefamily (north__netdocs_file_document; postureowner-writeinaccess.ts, so the scope seam demandsmcp:write). The pilot and authorization gate that today runs only in the read loop runs for the write family too, andsyncNetDocsenables and disables the union. Guards on both surfaces derive the hidden set from the union; mutations (gate dropped from the write loop, def dropped from the union,mcp:writemissing) each fail. - Product chat: the tool joins
netDocsTools, sonetdocs-capability.test.tscovers it (absent without the feeder, absent from replay, automation and desktop surfaces), with a new assertion naming the write tool. - The tool is registered whenever the family is; a non-owner gets
owner_credential_requiredat call time rather than a silently missing tool. Non-negotiable 6 concerns the feeder being disconnected; unchanged. - Absent from
WORKFLOW_TOOL_MAP(type-enforced) and from every background toolbelt (test).
3.5 Blast radius of a wrong write, stated
A wrong filing is a document reachable from the lawyer's thread, uploaded under their NetDocuments identity into a folder they registered, named as the agent proposed and they confirmed.
- Worst case: the document lands in a registered folder belonging to another matter or client workspace, or is the wrong version of the right document. It becomes visible to everyone whose NetDocuments ACL covers that folder, appears in the DMS audit trail as the lawyer's, and is found by the firm's own search. Across client workspaces that is an ethical-wall breach by the lawyer's own hand, mediated by North.
- A new class, stated: filing an agent-written
case_documentrow puts in the firm's DMS something the product deliberately keeps in the lawyer's personal space and refuses to promote to matter scope. The confirmation says which kind of source it is; the lawyer decides. - Duplicates: a process killed between the claim and the response leaves
pendinguntil the lease sweeps it tounknown; the lawyer may find one document North cannot vouch for. Two documents for one confirmation require NetDocuments to have accepted an upload it did not acknowledge, or a provider-side retry; W-6 probes whether an idempotency contract exists. - What cannot happen, each with its control: overwriting or versioning (no such call in the allow-list); moving or deleting (no such call; those scopes are never requested); filing under another lawyer's identity (credential-owner check plus the connector, generation and provider account in the binding); filing into a folder nobody registered (no container id on the wire; live re-resolution compared with the binding at the boundary); filing something the lawyer cannot reach from their own thread (reachability seam); filing from a background run or a pre-approved workflow (type-enforced absence plus toolbelt tests); a stale confirmation (binding comparison); a stale destination (live container comparison); a replayed or retried confirmation (namespaced claim, payload-mismatch refusal); bytes other than those confirmed (digest and length recomputed at the boundary).
- What North cannot undo: anything. Remediation is the lawyer deleting or moving the document in NetDocuments; the receipt carries the URL for that, and the ledger lists every filing by user, target or outcome.
- The card is the last line: live folder name and id, the recorded path with its date (or the live path once W-8 lands), filename, size, source title and origin.
4. Folder watch
4.1 Model
matter_netdocs_watch (organization RLS + owner predicate): id, organization_id, matter_id (composite FK), owner_user_id, connector_id, connector_generation, provider_account_id, expected_access_scope, container_id, container_name, container_parent_id, container_path_recorded, label, consented_at NOT NULL, terms_version, status CHECK (active | paused), paused_reason CHECK (null | reauth | transfer | user | error | lockout | too_large | access_lost), next_poll_at, last_polled_at, last_full_listing_at, coverage CHECK (full | partial | unknown), consecutive_failures, last_error_class, created_at. UNIQUE (matter_id, container_id, owner_user_id).
netdocs_watch_item (same posture), the snapshot: watch_id, nd_document_id, name, extension, modified, nd_url, first_seen_at, last_seen_at; PK (watch_id, nd_document_id).
netdocs_watch_event (same posture): id, organization_id, watch_id, owner_user_id, kind CHECK (new | updated | paused | resumed | too_large), nd_document_id, name, nd_url, modified, paused_reason, seen_at, delivery_status CHECK (pending | sending | sent | failed), delivery_attempts, delivery_claimed_at, sent_at. UNIQUE (watch_id, nd_document_id, modified) for document kinds. Every email corresponds to an event kind, and every event to one atomic transition.
The watch is personal in v1: its owner's credential, its owner's reading, its owner's inbox. Direct children only, no subfolders, stated in the copy.
4.2 The poller: netdocs-watch-poller in apps/worker-connectors
Redis-mutexed, best-effort, never fatal, tick every 5 minutes; cross-tenant scan under scheduler_role of watches due by next_poll_at whose state is active, or paused with reason reauth or lockout (both recover; transfer, user, too_large and access_lost do not). Per watch, mutations run under withRlsTransaction(..., { userId: owner_user_id }) on the app-role pool so the owner predicate resolves; scheduler_role holds the scan grant only.
- Revalidate before anything else: the connector is
authorized, still owned byowner_user_id, with the recordedconsent_generationandexpected_access_scope; the owner is a current, non-banned member of the organization; the owner still has access to the linked matter. Any failure pauses with the matching reason, emits onepausedevent and stops — the newsletter loop rechecks banned recipients the same way before sending. - List through the credential boundary, never with a bearer in the worker.
POST /connectors/netdocs-mcp/watch/listtakes{ organizationId, watchId, skipToken? }, re-derives the watch and its credential server-side, mints internally, performs exactly the approved listing call from the shared client's allow-list, and returns items plus the next token. The worker holds the connectors-api internal token only, which by itself mints nothing (the route accepts a watch id, not a user). - Diff against the snapshot, full listings only: an empty snapshot is the first listing (insert everything, emit nothing); an id absent from the snapshot is
new; a latermodifiedisupdated; equal is nothing. Comparison is per item, so ties lose nothing. Absent ids stay (deletions are invisible to listings) and age out after 30 days only for watches whose coverage has beenfullsince the cut-off, so a long partial or paused stretch cannot silently empty a snapshot and re-announce the whole folder. - Coverage and budget. Up to 3 pages per watch per tick with no ordering available (A-23): a complete listing sets
coverage='full'; a truncated or failed one setspartial, writes no snapshot rows and no events, and reschedules. Three consecutive partials pause the watch with reasontoo_largeand onetoo_largeevent. The limiter counts undifferentiated members, so a smaller capacity on the same key would starve the poller rather than reserve anything: the proxy route reserves in one script against two keys — a poller key at 6 per minute and the shared key at 18 — so interactive reads keep at least 12 and the poller is never starved below its own reservation. A lockout-shaped response (A-16: errors without a 429) pauses with reasonlockoutand a persisted backoff of 15 → 30 → 60 minutes, capped at 6 h, innext_poll_at. - Cadence and cap, honestly. At 6 calls per minute reserved, one credential sustains 360 listing calls per hour. A watch costs up to 3 calls per round, so a 15-minute cadence costs 12 calls per watch per hour: 30 watches per credential is the cap, and that is what the consent copy promises. The organization-wide cap is 50 across credentials. The poller records per-watch lag; a watch overdue by more than an hour raises a metric.
- The repository audit log is not in v1. It would collapse N listings into one call per credential and add deletions and renames, but its checkpointing, volume handling, naive dates and deletion mapping are unbuilt, and container attribution was never checked. It gets its own design spec after the read-only probe, with its own event kinds and cursor protocol.
The poller reads listings, never content: nothing enters the corpus or any digest.
4.3 The notifier: netdocs-watch-digest in apps/worker-notifications
Every 5 minutes, per watch with pending events: claim under FOR UPDATE SKIP LOCKED (sending, delivery_claimed_at, attempts + 1), re-check the owner's membership and account status inside the claim, send one email through the digest mailer (apps/worker-notifications/src/newsletter/mail.ts, which already takes an idempotency key derived here from the claimed event ids), confirm sent. A crash between send and confirm is reclaimed after 15 minutes and resent under the same key; after 5 attempts the batch settles failed. The dev outbox counts as sent; an unconfigured mailer claims nothing.
Content: matter display number and name, watch label, up to 10 documents with their modified and the stored nd_url, "and N more"; paused, resumed and too_large each produce their own short email naming the reason. Retention and recipients are §6's matrix and §8's disclosure. The snapshot sweep runs in the poller, not the mail loop, so an unconfigured mailer cannot skip it.
In-product: the matter page shows a watch and its last 20 events to its owner only. Briefing, glance and desktop toasts are out of v1.
4.4 Enrolment, lifecycle backend, consent stamp and controls
Enrolment is a row, not a click: user_netdocs_enrolment (organization_id, user_id, terms_version, accepted_at), checked server-side at target registration, watch registration, the filing claim and the watch proxy. A UI acknowledgement alone is bypassable by a direct tool or route call. Raising the terms version re-asks.
Lifecycle backend: register a watch by URL or id (container probe under the owner's token, identity fields recorded, consent stamp, caps), pause, resume, delete. Two hooks live where custody actually changes, inside its transaction: a token replacement or reconnect completion invalidates that connector's targets and pauses its watches (reauth for the same owner, transfer for a new one); a disconnect or severance pauses with reason user. Web actions call these; they never own them, because a web action can be abandoned while custody has already changed.
Consent copy on the per-watch toggle: "North will check this folder every 15 minutes using your NetDocuments access, including when you are not using North. It reads the folder listing only, never document contents, and does not see subfolders. New and changed documents are emailed to you. You can pause or delete it here at any time." The enrolment screen carries the full §8 facts.
4.5 What the watch never does
Never polls with a credential its owner did not personally connect. Never puts a DMS bearer in the worker. Never reads content. Never writes to NetDocuments. Never notifies anyone but the owner, and never after they lose membership, standing or matter access. Never keeps polling a connection that changed hands or left authorized. Never announces a folder's history, and never emits from a partial listing.
5. Surfaces and copy
- Connections page, NetDocuments detail: for an actor who may write, "Filing destinations" and "Watched folders" with their controls; for everyone else one line: "Filing and folder watches run through a NetDocuments connection in the lawyer's own name."
- Connect-page copy replaces "North implements and allowlists read-only operations only" with: "North reads NetDocuments for you. Once filing is enabled for your account, North can also upload a document into a folder you registered, and only after you confirm each upload on a card that shows the folder. It never moves, replaces or deletes anything."
- Enrolment screen (once per terms version): what is stored and for how long, who receives it, what the DMS logs show, that a watch is incomplete by nature, that filing cannot be undone, and that the poller shares the credential's rate budget.
- Confirmation card and elicitation: identical fields from the binding, including the source's origin.
- Tool descriptions and server instructions: the filing description states the registered-destination model, one document per call, nothing replaced, the receipts, and that the tool always asks; the targets tool describes itself as the list to consult first.
- Retired-by notes on federation spec §3.3, §7 (headline, layer 2 and layer 3) and §9's two scope exclusions.
6. Security posture
- Authorization at the write boundary: connectors-api re-derives target ownership, connector ownership, generation, reach and enrolment, and recomputes the bytes' digest and length. The internal bearer authenticates the service, so the caller's
userIdis trusted exactly as the deliver route already trusts it; the watch proxy takes a watch id instead of a user, so naming a lawyer mints nothing. - The worker holds no DMS token. It holds the connectors-api internal token, whose reach through the watch route is one listing call for a watch that exists and is current. That is the whole new custody surface; the env and unit change is deploy machinery and needs kwiss's word.
- RLS and grants. Organization RLS on all five new tables with an owner predicate in every query (the credential boundary sets no user GUC, so a user-GUC policy would be invisible to it).
app_role: full DML on targets, watches, items and events under owner predicates, and SELECT only onnetdocs_filing.sync_role: SELECT on targets, watches and enrolment; INSERT and UPDATE onnetdocs_filing; no DELETE.scheduler_role: SELECT on the watch scan columns and the event delivery columns, UPDATE on delivery columns only. - Retention matrix: filing ledger rows and their filenames and digests, 7 years (they are the record of a firm-visible write) unless kwiss sets otherwise; targets and their recorded paths, life of the target plus 90 days; watch snapshots, life of the watch; watch events with document names, 90 days; audit and product events, existing policy; email bodies at the transactional processor, that processor's own retention, disclosed in §8. Deleting a watch cascades its items and events; deleting a target keeps the ledger rows that reference it (id retained, path redacted after 90 days).
- Prompt injection: the agent cannot name a folder, connector, identity or byte source outside the thread's reach; the worst an injected instruction achieves is proposing a filing the lawyer sees on a card with the live folder identity, or a filename the sanitizer bounds.
- Persistence posture: unchanged for federated content (the stubbing list keeps document bodies out of context and digests). Filing does persist federated metadata — the folder path shown, the document id and URL in the receipt — in
chat_messageand, unlike federated content, incontext_digest. That is deliberate: the receipt is the lawyer's proof of what was filed. It is stated here and in the enrolment facts, and the filing tool is not added to the stubbing list (which would erase the receipt).
7. Acceptance
- Registration: an eligible owner registers by URL; a member reading through the firm row sees the hint and no form; an actor without a current enrolment row is refused server-side, not only in the UI; the 26th target is refused.
- Product chat: the card shows the live folder identity, the source origin and the filename; "no" writes no ledger row; "yes" files once; a resume for another
toolCallIdis rejected; a resume by a different participant re-executes under them and, without their own connection, returnsowner_credential_requiredand mints nothing; a target invalidated between card and "yes" refuses; a folder renamed or re-parented between card and "yes" refuses. - Claim: two concurrent executions of one
toolCallIdproduce one claim and one upload (integration test against a real Postgres UNIQUE); a second call with the same key and a different payload is refused with 409; an MCP host retrying an accepted elicitation collides with the existing claim and uploads once. - Durability: a claim committed and then abandoned (no settlement) is swept to
unknownafter the lease, and a caller meeting the in-flight claim gets the in-flight receipt, not a second upload. - Boundary: bytes whose digest or length differ from the binding are refused before any token is minted.
- MCP: the targets tool lists destinations; the elicitation renders the binding; decline ⇒ no handler call; accept ⇒ one upload, one audit row carrying the resolved destination and byte count from the prepared payload, one ledger row, one product event.
- Hiding: the write tool is absent on both surfaces when the feeder is disconnected, when the principal is outside the pilot, and without
mcp:write; back on reconnect; the three named mutations each fail a test. - REST allow-list: a call shape outside the list fails the guard in the shared package, and no other package constructs the client with a bearer.
- Exclusions: no filing tool in
WORKFLOW_TOOL_MAP(type) or in the automation, replay and desktop toolbelts (test); the existing pin test still fails any new reader ofconfirm_preapproved. - Watch: the first full listing emits nothing; an added document produces one
newevent and one email within two poller ticks and one notifier tick; an edit producesupdated; a partial listing writes nothing; three partials pause withtoo_largeand one email; a lockout persists a backoff that survives a worker restart and later recovers;requires_reauth, a same-owner reconnect (generation bump), a transfer, a disconnect, a lost membership and a lost matter access each pause with the right reason and the right one-time email; recovery emitsresumed. - Budget: with the two-key reservation, 30 watches on one credential each poll within 15 minutes while interactive reads continue; the poller is not starved by interactive traffic; lag is exported.
- Isolation: a second user in the same org reads no watch, item, event or filing row through the app role; the matter page renders the section only for the owner; the watch proxy refuses a watch id that is not the caller's worker-scoped route contract.
- Delivery: the same event batch is never sent twice across a crash between send and confirm (idempotency key asserted on the mailer); a banned or removed owner is not emailed.
8. The consent point with the firm (for kwiss to raise with HSE)
Three asks, in the order they change the design, then the facts to state. Nothing in §3–§4 is built before the first two are answered; the phase-0 write probe is itself an instance of ask 1.
- North writes into the firm's DMS under lawyers' identities. The grant already includes
edit; what changes is that North uses it, one document per confirmed filing, never a replace, move or delete. What the DMS audit trail records about the actor and the application is captured by the probe before this is described to HSE as fact. North cannot undo a filing. Accept for the partner first, on a designated scratch folder, then in production? - A lawyer's NetDocuments access used in the background. A watch lists a folder every 15 minutes with the lawyer's own token, nights and weekends included, and those reads appear in NetDocuments' logs as theirs. The lawyer enrols once and consents per watch. Alternative: a NetDocuments service user for watches (no client-credentials grant exists, A-19, so it is a designated user who consents once). It removes the personal-token question and inherits one identity's ACL for every watch, the ethical-wall concern that killed it for ingestion.
- Who may write or watch. The rule is: a lawyer who owns an authorized NetDocuments connection. The firm-wide connection is read-only for everyone but its owner, and the exclusivity ruling forbids new personal connections while it is live, though existing owner-reach rows are grandfathered. The plan verifies how many such rows HSE holds before this question goes out, so the ask is exact: either it is the partner alone, or it is the partner plus N grandfathered lawyers. If HSE wants more, the ruling is reversed by its own lane.
Facts to state alongside: the consent screen is NetDocuments' own app and does not change; North's connect page and a one-time enrolment acknowledgement do; document names from watched folders leave the DMS by email through North's transactional email processor (retention and data-processing posture named in the ask) and are kept 90 days in North, with the retention matrix of §6 for everything else; a watch is incomplete by nature (no deletions, no subfolders, no renames, promotions unverified, partial listings skipped); the poller shares the credential's rate budget, so with the firm-wide credential a lockout affects everyone reading through it; an admin reconnect changes custody and pauses the previous owner's watches; the blast radius is §3.5 verbatim, including the agent-written-source case.
9. Assumption ledger
| # | Assumption | Confidence | What falsifies it, and the seam |
|---|---|---|---|
| W-1 | A document is created with one multipart POST to the document family carrying the destination container id, name and extension, returning the new id |
Unknown, probe-gated (no upload row in the endpoint map; every inferred sub-path was contradicted live) | Phase-0 probe; seam: the shared client's upload primitive and its allow-list |
| W-2 | A profile attribute (description) can be set on upload | Unknown | Same probe; matters only if HSE wants a North marker |
| W-3 | The edit grant covers creation in a folder the user can write to |
Unknown (token scope is null, A-5/A-6) |
403 on the probe ⇒ a re-consent lane before phase 1 |
| W-4 | A name collision creates a second document rather than overwriting | Inferred (ids are the identity; nothing documents upload collisions) | Probe uploads the same name twice and reads both ids |
| W-5a | The identity read returns a user id | Verified live (A-5) | — |
| W-5b | That id is stable across reconnects and usable as provider_account_id |
Unknown | Probe: re-consent in the scratch account and compare the id; if it moves, targets bind to the connector generation alone |
| W-6 | No upload idempotency contract exists (no key header, no dedupe) | Unknown, probe-gated | Probe: repeat one multipart body verbatim and look for a documented key header in the response and error shapes; two documents ⇒ confirmed absent |
| W-7 | A crash after the upload request cannot be reconciled automatically | Accepted by design | If the probe shows a new document is searchable by name within seconds, a reconciliation read can settle unknown later |
| W-8 | A verified operation returns a container's ancestry (cabinet › workspace › folder) | Unknown (the container GET returns children, A-22) | Read-only probe; until then the card shows the live folder identity plus a dated recorded path |
| P-1 | Container listings can be ordered | Contradicted (A-23) | Design assumes none |
| P-2 | skiptoken is a stable cursor under concurrent mutation |
Unknown (A-18) | Read-only probe with a concurrent edit; if unstable, one page per tick and a smaller folder cap |
| P-3 | Modified advances on every user-visible change |
Contradicted for official-version promotion (A-25) | Accepted; the copy says "new and changed documents" |
| P-4 | A two-key reservation of 6 per minute for the poller leaves interactive reads unaffected | Inferred from the limiter code and A-16 | Lockout or measured interactive denial during the pilot ⇒ lower the reservation and the watch cap together |
| P-5 | The container listing returns direct children only | Inferred (A-8/A-22 describe the envelope, not the recursion) | Read-only probe on a folder containing a subfolder with documents |
| P-6 | The repository audit log attributes activities to containers | Unknown (A-13 verified the call, not the fields) | Read-only probe; decides whether the log spec is worth writing |
| P-7 | The digest mailer's idempotency key prevents duplicate sends | Verified in repo (in production use by the weekly digest) | — |
| C-1a | HSE accepts filing under lawyers' identities (§8.1) | Unknown | No ⇒ phase 1 does not start; the watch may still proceed on C-1b |
| C-1b | HSE accepts background use of a lawyer's token (§8.2) | Unknown | No ⇒ phase 2 does not start, or becomes the service-user variant with its own spec |
10. Not in scope
Browse and ids (lane L5); mail attachments; new versions of existing NetDocuments documents; moves, renames, deletes, profile attributes; firm-shared watches; subfolders; briefing, glance and desktop surfaces; the repository-audit-log feed (its own spec after the probe); the exclusivity-ruling reversal (its own lane if §8.3 asks for it); ingestion of anything a watch sees; any change to apps/mcp-server/src/mcp-app.ts.