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)
| Fact | Evidence |
|---|---|
netdocstips@hseny.com is a real North user in the HSE org | user.id = e682575e-3cbc-4f40-8faa-bcbddbe2762a |
| Its Outlook connector is authorized | conn_KEpQkOs84lSrd8D9, type=outlook, scope=user, status=authorized, created 2026-07-29 |
| HSE org | 3faddc1b-8dd2-4b5f-9108-6dccd2c131c6 ("Hirschen Singer & Epstein") |
| Gill's North user | gbenedek@hseny.com → HmJxMYgnXC9OAW2wlVQsY405f4StTj2L |
| Christophe's HSE user | cdumont@hseny.com → Yu52DhQbmfsg5FXKZrkULxUlACKdnwdK |
| No Graph subscription exists for the tips connector | connector_subscriptions has no row for conn_KEpQkOs84lSrd8D9 |
| Mail is not bulk-stored in Postgres | no mail_message table; only mail_classifications |
knowledge_sources.corpus_category already exists end to end | populated from ingest_sources.corpus_category via the OneDrive source-event translator |
heynorth.dev is a verified Resend domain | DEFAULT_NEWSLETTER_FROM = "North <news@heynorth.dev>" |
Two consequences that change the design from the spec:
D1 is already done, and needs no subscription.
search_emailreads live from Microsoft Graph viaresolveOutlookContext(the acting user's token) — it does not read a local mail table. So an automation running as thenetdocstipsNorth user sees the tips mailbox directly. We do not needworker-mailto 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.The
newsletter_*table names are already taken.packages/db/src/schema/newsletter.tsholds 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 thefirm_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.comandgbenedek@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.
packages/newsletters/src/allowlist.tsexports a hard-codedPILOT_RECIPIENT_ALLOWLIST(those two addresses, normalised) andassertAllowedRecipient(email), which throws on anything else.- Every outbound path — submitter ack, digest issue send, anything future — calls the single
sendGuarded()inpackages/newsletters/src/send.ts, which callsassertAllowedRecipientbefore touching a transport. There is no bypass parameter. - Audience resolution (
resolveAudience()) filters to the allowlist and records what it dropped on the issue, so a widened audience is visible rather than silent. - The guard is unconditional — not env-gated, not flag-gated. Widening the pilot is a deliberate code change under review, which is the point.
Tests that must exist and pass:
assertAllowedRecipientthrows forccoletta@hseny.com,rkivler@hseny.com, a distribution-list address, and an arbitrary external address.- Normalisation:
GBenedek@HSENY.com(case + trailing space) is allowed;gbenedek@hseny.com.evil.tldis not (suffix-match must not be substring-match). sendGuardednever reaches the transport for a disallowed recipient — asserted on a spy transport, proving the transport was not called, not merely that a throw occurred.- An audience of 5 addresses sends 2 and records 3 as dropped.
- A submitter ack for a non-allowlisted submitter sends nothing and is recorded as suppressed.
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)
packages/db/src/schema/wiki.tswiki_page:id,organization_id(FK, RLS),slug(unique per org),title,category,body(markdown),summary,status(published|archived),source(agent|human),created_by,updated_by, timestamps,knowledge_source_id(nullable — the projection link),last_ingested_at.wiki_media:id,organization_id(FK, RLS),page_id(FK cascade),r2_key,content_type,byte_size,alt_text,origin_message_id(the Graph message the attachment came from — provenance),created_at.- RLS via
pgPolicy()+.enableRLS()on both, org isolation oncurrent_setting('app.organization_id'), mirroringautomation.ts.
packages/db/src/schema/firm_newsletter.tsfirm_newsletter:id,organization_id,name,description,audience_kind(explicit_list),content_prompt,cadence_cron,timezone,status(active|paused),approval_mode(gated|autonomous),automation_id(FK to the hidden automation, nullable),sender_label,created_by, timestamps.firm_newsletter_recipient:id,organization_id,newsletter_id(FK cascade),email,user_id(nullable),status(subscribed|unsubscribed),created_at. Unique on (newsletter_id,email).firm_newsletter_issue:id,organization_id,newsletter_id(FK cascade),sequence(unique per newsletter),status(gathering|composed|awaiting_approval|approved|sending|sent|skipped|failed),subject,body_markdown,covered_from,covered_to,gathered(jsonb — the wiki page ids covered),approved_by,approved_at,sent_at,error, timestamps.firm_newsletter_send: per-recipient claim row, same claim/idempotence shape as the existingnewsletter_send(unique on issue+email,sending→sent/failed,attempts, heartbeatupdated_at) — plussuppressed_reasonfor allowlist drops.- RLS on all four.
- Export from
packages/db/src/schema/index.ts. bun db:generate→ one migration. Review the SQL; if wrong, fix the TS and regenerate — never patch the SQL. Watch the journalwhenwatermark.- GRANTs for
app_role/schedulerinpackages/db/scripts/provision-roles.ts(never in the migration).
W1-knowledge (omp — backend) — no DB changes, disjoint from W1-schema
- Add a
corpus_categoryfilter to retrieval (packages/knowledge/src/retrieval/filter.tsand the search entry point): optional include/exclude by category. - Expose the category parameter on
north__knowledge_search(apps/mcp-server/src/tools/knowledge-search.ts) and on the in-appsearch_knowledgetool. - Matter-context precedence: firm-procedural content (
corpus_category = firm-know-how) must never outrank matter documents inside a matter thread — down-rank or exclude it when amatter_idis in scope. Regression test required: a matter query with a competing firm-know-how page still ranks the matter doc first.
Wave 2 — the loop's core (2 omp + 1 claude, disjoint)
W2-wiki (omp) — packages/wiki + agent tools
packages/wiki/src/service.ts:createPage,updatePage,listPages,readPage,archivePage, slug generation/collision handling,storeMedia(R2 via@workspace/storage, key prefixwiki/<org>/<page>/<id>).- Agent tools in
packages/agent-runtime/src/tools/wiki.ts, registered inindex.tsdefaultTools:create_wiki_page,update_wiki_page,list_wiki_pages,read_wiki_page.- Deviation from spec D3 (three tools): re-synthesis is impossible without reading a page body back.
list_wiki_pagesreturns titles/slugs/summaries only (cheap);read_wiki_pagereturns full markdown. Four tools, deliberately.
- Deviation from spec D3 (three tools): re-synthesis is impossible without reading a page body back.
publish_wiki_page_to_knowledgetool wrappingpackages/knowledge/src/pipeline/ingest-single-file.ts— renders the page markdown to bytes,scope_default: 'firm_global',corpus_category: 'firm-know-how', and stores the resultingsource_idback onwiki_page.knowledge_source_idso re-publishing supersedes rather than duplicates. Synchronous is fine: this runs headless, nobody waits.
W2-newsletters (omp) — packages/newsletters
allowlist.ts+send.ts(sendGuarded) — section 2 above; write these first and test them first. Transport for the pilot is Resend, reusing thecreateDigestMailerladder (APP_DEV_MAIL_OUTBOX_DIR→ Resend → not-configured), mandatory unsubscribe footer, RFC 8058 headers, idempotency keyfirm-newsletter/<issue>/<email>.service.ts: newsletter CRUD, recipient management, issue mint/compose/approve/send,resolveAudience().- Automations facade:
createNewsletterprovisions a hiddenautomationrow (kind: recurring, the cadence cron, prompt = the compose prompt) and stores its id onfirm_newsletter.automation_id; pause/resume/delete propagate through the service so the two can never desync. The automation is not shown on the Automations board. - Agent tools
packages/agent-runtime/src/tools/newsletters.ts:create_newsletter,list_newsletters,update_newsletter(pause/resume/cadence/recipients),delete_newsletter. Mirrors theautomations.tstool-family pattern. send_tip_acknowledgmenttool →sendGuarded(submitter ack, D7).
W2-web-wiki (claude opus) — apps/web/app/(app)/wiki
- List page (grouped by category) + detail page (rendered markdown).
- Authenticated media route
apps/web/app/api/wiki/media/[id]/route.ts— org-scoped, streams from R2. Never public. - Video links render as format-agnostic link cards (no iframes — tenant frame policy).
DESIGN.mdcontract: semantic tokens only, radius pyramid, no palette literals, norounded-2xl. Sidebar entry "Firm knowledge".
Wave 3 — proof harness + newsletter surface
W3-harness (omp)
scripts/netdocs-tips/inject-test-tips.ts— repeatable Resend sender that posts the five fixture tips (below) intonetdocstips@hseny.comfrom aheynorth.devverified sender. Subjects carry a run tag so a re-run is distinguishable.scripts/netdocs-tips/run-loop.ts— local runner: executes the intake automation's agent turn against the isolated DB clone, reading the real preprod mailbox via the service user's connector. No shared ports. No deploy.
W3-newsletter-ui (claude opus) — apps/web/app/(app)/newsletters
- Newsletter list (next send, last send, recipient count, pause) and the per-issue step timeline (Gather → Compose → Approve → Send → Report) with the one-click Approve action. Failures surface as a red step.
- Scope call: this is the piece most likely to land thin. The gate itself must work (approval is a real state transition behind a real button); timeline polish is the first thing cut.
4. The fixture tips (what proves the loop)
Sent via Resend into the live intake mailbox. Each one tests a distinct claim:
| # | Tip | What it proves |
|---|---|---|
| 1 | Plain-text tip: workspace search filters in NetDocs | Baseline extraction → wiki page |
| 2 | Tip with a screenshot attachment | Attachment → R2 → authed media route → renders in the wiki, and never goes to web search (D8) |
| 3 | Tip with a video link (Loom-style URL) | Link card + agent-written step summary, no per-platform integration |
| 4 | Near-duplicate of tip #1, different wording | The wiki stays coherent — merged/updated page, not a second page. This is the openwiki synthesis claim and the one most likely to fail |
| 5 | Barely 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")
- Preprod DB: SELECT only, via
OWNER_DATABASE_URLfrom/home/kwiss/deploy/north-os-preprod/.env.local. Used to read the connector row and the credential material needed to reach the mailbox. Never written. - All writes go to an isolated clone: fresh DB on the same PG server →
bunx drizzle-kit migrate→bun run scripts/provision-roles.ts→ run with thatDATABASE_URL. - No deploy, ever. No
git push deploy, nosystemctl, no~/deploywrites, no Caddy. This branch never merges, so it can never reach the deploy path. - Never bind 5140/5141, 5160–5179, 8080–8084, 4002–4012. Never restart shared Postgres (5440) or Redis (6390).
- Reporting before the first send: the fully rendered digest — subject, body, links, exact recipient list — is printed to the pane before the first real send to Gill. That is a report, not a request for permission.
6. Gates
bun run lint+bun run typecheckfrom the worktree root, green.- Tests for every package touched:
db,wiki,newsletters,knowledge,agent-runtime,worker-notifications,web. - The allowlist tests of section 2 are the ones that must not be waved through.
- Dual adversarial review to convergence: a fresh omp (
gpt-5.6-sol, high) and a fresh claude Opus reviewer, neither of them the orchestrator, plus a deletion audit pass overgit diff main -U0 | grep '^-'.
7. Known risks and honest limitations
- 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.
- The migration will need regenerating at merge time.
feat/netdocuments-connectoris also unmerged and may also generate a migration. Both branches are offmain@c835e5b5, so this is expected: whichever lands second deletes its migration + snapshot + journal entry, rebases, and re-runsbun db:generate. Stated here so it is not discovered at merge. - 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.
- Autonomous send is a real posture change. Today
mail-send.tsis human-click only, deliberately. This introduces a code path that sends without a click. Every newsletter startsgated; promotion toautonomousis explicit; the sender is a service identity, never a lawyer's mailbox. If any send path is unclear, it stays gated. - Newsletter UI may land thin (Wave 3). The approval gate must function; the timeline polish is the first thing cut.
- 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:534 — payload.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
- Attachments.
read_email_threadrenders subject/From/To/Sent/body only — the agent cannot even see that an attachment exists, anddownloadMailAttachment's only call site isapps/worker-mail/src/loops/attachment-ingest.ts:47, the worker this design deliberately does not enrol. I verified the provider layer does support it (msgraph/email.ts:716,interface.ts:191-203), so the gap is one small net-new tool, not a redesign. Budgeted:read_email_attachment. - Incrementality.
msgraph/email.ts:274-283sends$searchonly when a query is present, because Graph rejects$search+$filtertogether; thesincewindow applies only in the query-less branch, andsearch_emailalways sends a query. Sosince_daysis decorative and every run would re-process the whole mailbox. Budgeted: a query-lesslist_recent_mailtool over the folder-listing branch, plus thetips_processed_messageledger below.
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):
- 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.
- Wiki writes stay headless but are bounded mechanically. In a headless run the tool sets
categoryitself 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. publish_wiki_page_to_knowledgemay publish only pilot-category pages, always undercorpus_category='firm-know-how'atfirm_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.- 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
- Cut: the newsletter UI (former W3-newsletter-ui). The amendment requires the newsletter be created through the newsletter tools, not that a management surface exist. Approval for the pilot is an explicit service-level transition. The wiki UI stays — it is what Gill is shown.
- Cut: a second matter down-ranking mechanism.
retrieval/matter-boost.ts:20-35already partitions matter-provenance hits above everything else when a matter is active, and a firm-know-how page has no matter provenance. Keep the regression test proving matter documents still win; skip the implementation. - Added:
search_wiki_pagesover title + summary.list+readalone give the agent no similarity signal, and fixture #4 (the near-duplicate merge) would fail more often than not once there are more than a handful of pages. - Added: the pilot's tools are registered as deferred rather than dropped unconditionally into
defaultTools(~160 entries already) — otherwise every lawyer in every org carries pilot tools in every chat. - UI port: the wiki surface is rendered on a free high port (5193), never 5140/5141.
- Reworded: §2's "ALL outbound mail funnels through ONE seam" is true of this feature's paths only. Two live Resend senders (
worker-notifications/src/mail.ts,newsletter/mail.ts) and Graph calendar invitations sit outside it and are untouched.
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.dev → delivered. 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.