← plans · north_os · plan22 juil. 2026

NetDocs tips loop — implementation plan

Status: In progress · Author: Claude (with Christophe) · Date: 2026-07-22 · Repo: north-os
Related: docs/superpowers/specs/2026-07-22-netdocs-tips-loop-design.html (design spec) · Linear NOS-202

0. What this plan is for

The spec locks the architecture. This plan locks the build: what gets written, by whom, in what order, and — critically — how the loop is proven end to end against the real mailbox before Christophe leaves on Monday 2026-08-03.

The priority is a narrow, real, provable run, not a complete productised newsletter subsystem. Breadth is deliberately sacrificed for a demonstrable loop.

Ground truth verified on preprod (2026-07-31, read-only SELECT)

FactEvidence
netdocstips@hseny.com is a real North user in the HSE orguser.id = e682575e-3cbc-4f40-8faa-bcbddbe2762a
Its Outlook connector is authorizedconn_KEpQkOs84lSrd8D9, type=outlook, scope=user, status=authorized, created 2026-07-29
HSE org3faddc1b-8dd2-4b5f-9108-6dccd2c131c6 ("Hirschen Singer & Epstein")
Gill's North usergbenedek@hseny.comHmJxMYgnXC9OAW2wlVQsY405f4StTj2L
Christophe's HSE usercdumont@hseny.comYu52DhQbmfsg5FXKZrkULxUlACKdnwdK
No Graph subscription exists for the tips connectorconnector_subscriptions has no row for conn_KEpQkOs84lSrd8D9
Mail is not bulk-stored in Postgresno mail_message table; only mail_classifications
knowledge_sources.corpus_category already exists end to endpopulated from ingest_sources.corpus_category via the OneDrive source-event translator
heynorth.dev is a verified Resend domainDEFAULT_NEWSLETTER_FROM = "North <news@heynorth.dev>"

Two consequences that change the design from the spec:

  1. D1 is already done, and needs no subscription. search_email reads live from Microsoft Graph via resolveOutlookContext (the acting user's token) — it does not read a local mail table. So an automation running as the netdocstips North user sees the tips mailbox directly. We do not need worker-mail to subscribe to this mailbox, and we should not subscribe it — which is also the cleanest way to satisfy the spec's "exclude the service mailbox from mail-intelligence" follow-up: never enrol it in the realtime pipeline at all. MI exclusion is achieved by omission, and piece #6 of the spec's shortlist collapses to a verification step.

  2. The newsletter_* table names are already taken. packages/db/src/schema/newsletter.ts holds the product-ops "What's new" digest (newsletter_issue, newsletter_send, newsletter_opt_out) — system-level, no org scoping, no RLS. Our newsletter entity is a different thing (org-scoped, agent-managed, firm-facing) and therefore uses the firm_newsletter* prefix. Conflating them would have put un-RLS'd rows in a tenant surface.

1. Architecture as built

  Resend (test fixture)  ──mail──▶  netdocstips@hseny.com  (licensed acct, enrolled)
  or a real HSE staffer                     │
                                            │  live Graph read (search_email as the
                                            │  netdocstips North user) — NO subscription,
                                            ▼  NO mail-intelligence enrolment
                        automation "NetDocs tips intake"  (automation.run, headless)
                                            │
             ┌──────────────────────────────┼───────────────────────────┐
             ▼                              ▼                           ▼
      submitter ack email          create/update wiki pages       publish to knowledge
      (guarded sender)             (canonical store)              (ingestSingleFile,
             │                              │                      corpus_category=
             │                              │                      'firm-know-how')
             │                              ▼                           │
             │                     (app)/wiki in apps/web               ▼
             │                              │                   chat + MCP answers
             │                              ▼
             └──────────▶  firm_newsletter "NetDocs tips digest"
                           (entity → hidden automation → compose issue
                            → GATED approve → send via guarded sender)

                    ALL outbound mail funnels through ONE seam:
                    packages/newsletters/src/send.ts → assertAllowedRecipient()

Single source of truth: wiki pages. The knowledge index, digest issues and any future doc export are projections.

Single outbound choke point. Both the submitter acknowledgment and the digest send go through one function. That is what makes the recipient allowlist a real control rather than a hope.

2. The safety control (non-negotiable)

During this pilot, North sends to exactly two addresses: cdumont@hseny.com and gbenedek@hseny.com.

This is enforced mechanically in the code path, never as a prompt instruction to the agent. An agent instruction is not a safety control.

Tests that must exist and pass:

3. Build order

One agent owns packages/db for the whole build — schema is the serialization point, and parallel bun db:generate corrupts the journal. One migration for the whole feature.

Wave 1 — foundations (2 workers, parallel, disjoint files)

W1-schema (omp — backend)

W1-knowledge (omp — backend) — no DB changes, disjoint from W1-schema

Wave 2 — the loop's core (2 omp + 1 claude, disjoint)

W2-wiki (omp) — packages/wiki + agent tools

W2-newsletters (omp) — packages/newsletters

W2-web-wiki (claude opus) — apps/web/app/(app)/wiki

Wave 3 — proof harness + newsletter surface

W3-harness (omp)

W3-newsletter-ui (claude opus) — apps/web/app/(app)/newsletters

4. The fixture tips (what proves the loop)

Sent via Resend into the live intake mailbox. Each one tests a distinct claim:

#TipWhat it proves
1Plain-text tip: workspace search filters in NetDocsBaseline extraction → wiki page
2Tip with a screenshot attachmentAttachment → R2 → authed media route → renders in the wiki, and never goes to web search (D8)
3Tip with a video link (Loom-style URL)Link card + agent-written step summary, no per-platform integration
4Near-duplicate of tip #1, different wordingThe wiki stays coherent — merged/updated page, not a second page. This is the openwiki synthesis claim and the one most likely to fail
5Barely a tip ("thanks!" / vague)Junk handling — the agent should decline to create a page rather than manufacture one

Success is judged on the hard cases: #4 must not create a duplicate page, and #5 must not create a page at all.

5. Isolated environment ("its own corner")

6. Gates

7. Known risks and honest limitations

  1. Wiki coherence is the hard part. Merging a near-duplicate into an existing page is an LLM judgement, not a deterministic operation. Fixture tip #4 is the probe. If it fails, the honest report is "intake and publication work; synthesis needs another pass" — not a claim of success.
  2. The migration will need regenerating at merge time. feat/netdocuments-connector is also unmerged and may also generate a migration. Both branches are off main@c835e5b5, so this is expected: whichever lands second deletes its migration + snapshot + journal entry, rebases, and re-runs bun db:generate. Stated here so it is not discovered at merge.
  3. The pilot allowlist is hard-coded. Correct for a two-person pilot; it must become org-level configuration before the audience widens. Called out in the PR.
  4. Autonomous send is a real posture change. Today mail-send.ts is human-click only, deliberately. This introduces a code path that sends without a click. Every newsletter starts gated; promotion to autonomous is explicit; the sender is a service identity, never a lawyer's mailbox. If any send path is unclear, it stays gated.
  5. Newsletter UI may land thin (Wave 3). The approval gate must function; the timeline polish is the first thing cut.
  6. Screenshots are never sent to web search (D8). Enrichment searches tip text only. This is a code-path property to be verified in review, not a prompt promise.

8. Deferred (unchanged from the spec)

.Shared / app-only shared-mailbox support · Word/doc export of the wiki · per-platform video transcript fetching · a separate structured tips table · newsletters generalised to per-matter/per-client audiences.


Revision 2 — after dual adversarial plan review (2026-07-31)

Two independent reviewers (omp gpt-5.6-sol high, and a fresh Claude Opus session) reviewed this plan against the code. Four claims in Revision 1 were factually wrong. I verified each finding myself before acting on it; the corrections below supersede the sections above where they conflict.

R2-1 — corpus_category is NOT threadable through the ingest path (blocks the "askable in chat" leg)

Revision 1 claimed knowledge_sources.corpus_category "exists end to end". It exists — but is reachable only via the OneDrive connector path. Verified: ingestSingleFile(appDb, tenantId, file, bytes, scope, ctx) (ingest-single-file.ts:520-527) carries no category on either ScopeDescriptor (:57-62) or PipelineContext (:64-97); the sole writer is commit.ts:534payload.external?.corpus_category ?? null — where external is typed provider: "onedrive" with five required external_* columns under an all-or-none CHECK plus an ingest_source_id FK into ingest_sources. The direct-commit path never constructs external (:795-810), so a wiki-page ingest would write NULL unconditionally.

Correction: threading an explicit corpus_category through the non-external commit branch moves into Wave 1 (W1-knowledge). It is not free inside W2-wiki. The external_* CHECK must not be weakened to achieve it.

R2-2 — no agent tool can read an email attachment; and "new since last run" is inexpressible

R2-3 — the new tips_processed_message table (idempotence)

Added to the same single migration. Unique on (organization_id, mailbox_user_id, provider_message_id) — that uniqueness is the idempotence claim; a second run conflicts instead of re-deriving. Also carries submitter_email + acknowledged_at (so a submitter is acknowledged at most once), wiki_page_id + outcome (created|merged|skipped|failed) so "the agent deliberately produced nothing" is a recorded outcome rather than an absence, and gives the digest its "what is new since the last issue" watermark.

R2-4 — prompt injection: the intake mailbox is the exact threat the repo already guards against

This is the most serious finding and it changes the design.

packages/agent-runtime/src/tools/automations.ts:64-77 carries an explicit, documented invariant: a scheduled prompt that reads attacker-controlled content must not be able to create persistence, and every write tool reachable headless today either pauses on a confirmation interrupt or hard-refuses. Anyone on the internet can email netdocstips@hseny.com. Revision 1 proposed five headless write tools over exactly that input, and treated the recipient allowlist as the safety control — but the allowlist bounds recipients, not content or persistence. An injected tip could write firm-wide wiki pages and poison the corpus that answers in chat and over MCP.

Resolution (built, not just stated):

  1. Newsletter CRUD tools take the same hard headless refusal as automations. The digest newsletter is created once, by a human, from a live chat — which is what "created through the newsletter tools from within North" means. This removes the headless-persistence surface at zero cost.
  2. Wiki writes stay headless but are bounded mechanically. In a headless run the tool sets category itself from the run's configured pilot category — never from a model-supplied argument — and may not update a page outside it. Body and attachment size caps. Every page traces to its origin message through the ledger, so a poisoned page is findable and revocable.
  3. publish_wiki_page_to_knowledge may publish only pilot-category pages, always under corpus_category='firm-know-how' at firm_global. This remains an accepted, written-down risk: content originating in unsolicited email becomes answerable firm-wide. It is bounded to one labelled category, kept out of matter contexts, on a branch that never merges.
  4. Prompt-level defence is required but is explicitly not the control. Every guarantee above must hold even if the model is fully persuaded by an injected instruction.

R2-5 — "preprod is SELECT-only" was not achievable as written

Token retrieval does not read the database directly: email-access.ts:75-131 POSTs to connectors-api, which refreshes and persists the rotated token. And the refresh token is rotated at Azure AD, not in our database — so any path that reads this mailbox rotates it somewhere.

Decision: point the runner's CONNECTORS_API_URL at preprod's connectors-api. Counter-intuitively this is the safer option: standing up an isolated connectors-api against the clone would rotate the token at Microsoft and leave preprod holding a stale one, silently breaking the live tips connector later. Pointing at preprod persists the rotation where it belongs and keeps the live connector consistent. Everything the loop writes (wiki pages, knowledge, ledger, issues) still goes to the clone.

So the honest constraint is: preprod is read-only except its own Graph token row, which preprod's own service rotates exactly as it would for any normal mailbox read. Reported rather than discovered.

R2-6 — the isolated clone cannot read the mailbox without seeding

resolveOutlookContext (email-access.ts:137-175) reads connectors from the runtime's own database under RLS. A fresh clone has no organization, no user and no connectors row, so search_email would return "Outlook is not connected." The clone-setup step must seed the HSE organization, the netdocstips user + member row, the connectors row, and the two allowlist users — and assert the seed before running, so a mis-seeded clone fails loudly instead of looking like a product bug.

R2-7 — automation ownership decides which mailbox is read

Automations are private to their creator (service.ts:109; every read scoped eq(automation.createdBy, userId)) and the headless executor runs as that owner (run-executor.ts:594). An intake automation created as cdumont would read Christophe's own mailbox. run-loop.ts asserts automation.created_by === e682575e-… before executing and aborts otherwise.

R2-8 — the unsubscribe footer is cut for the pilot

The existing route (apps/web/app/api/newsletter/unsubscribe) writes newsletter_opt_out keyed by North user id, for the product-ops "What's new" digest. firm_newsletter_recipient is keyed by email. Clicking unsubscribe on the tips digest would opt the reader out of the wrong newsletter and leave them subscribed to this one. Spec D5 inherited "the route already exists" uncritically.

Decision: for a two-person pilot, cut the unsubscribe footer and the RFC 8058 headers rather than ship a control that silently does the wrong thing. A correct token + route against firm_newsletter_recipient is required before the audience widens beyond the two pilot addresses — recorded as a release blocker for widening, not for the pilot.

R2-9 — the acknowledgment leg needs one human action to be proven for real

Fixtures are injected from a heynorth.dev Resend sender, so every synthetic submitter is non-allowlisted and send_tip_acknowledgment correctly suppresses — meaning the harness as designed guarantees the ack never actually sends. We cannot spoof an @hseny.com From through Resend, and we should not want to.

Resolution: the suppression path is unit-tested, and the real round-trip is proven by Christophe sending one tip email from his own Outlook to netdocstips@hseny.com — a 30-second human action. Called out as the single human step the demo needs, rather than papered over.

R2-10 — scope corrections

R2-11 — deliverability: falsified cheaply, and it passed

The reviewers flagged that Resend → an external M365 tenant could be quarantined, which would invalidate the entire injection approach. I sent one probe immediately rather than building on the assumption: Resend id 2f81f1a8-… to netdocstips@hseny.com from news@heynorth.devdelivered. The remaining check is Inbox vs Junk, confirmed on the first mailbox read.

Honest verdict on scope

Both reviewers judged Revision 1 unachievable by Monday. With the newsletter UI cut, the duplicate ranking work cut, and the three newly-budgeted tools added, the target is the narrow run this plan always said it wanted: inbound mail → coherent wiki page → answerable in chat → one gated digest to two people. Anything beyond that is explicitly not promised for Monday.