Skip to content

ADR-0041: Masked in-place view of the corrections behind a count

  • Status: Accepted
  • Date: 2026-10-13
  • Deciders: El
  • Supersedes: ADR-0037 §4 in part: the paragraph "Detail means opening the tenant's own n8n … it never copies from it", for the masked fields below only. The rest of §4 stands: nothing from a tenant's tables is stored in the registry, and anything that identifies an email or a person is still seen only in the tenant's own n8n. Also the Console page's "Detail is one click to the tenant's n8n, never copied".
  • Relates to: ADR-0006, ADR-0008, ADR-0031, ADR-0038, ADR-0040; OI-121 (alert responses, the same phase)

Context

Phase 1 answers how many people corrected (the Mailbox page's corrections section, by type and by from and to bucket) but not which emails. To see them, an operator follows See the emails in n8n, signs in to the tenant's n8n over the tailnet, finds the sort_log and corrections tables and filters them by hand. That is slow, needs n8n access for every operator, and the filter has to be rebuilt for every count.

Most of what an operator wants from that list is not the email itself but the pattern: which rule keeps getting overruled, which bucket pair, how soon after sorting. That needs no sender, subject or message ID. ADR-0037 §4 keeps the registry to settings and aggregates, and ADR-0006 keeps client content out of the control plane. The aim here is to show the pattern behind a count, masked, through the Console's normal path, and keep the actual email behind the tailnet as today.

Decision

flowchart LR
    OP(["Operator<br/>either of us"]) -->|"click a count<br/>Cloudflare Access + MFA"| CON["Console"]
    CON -->|"1. record the view<br/>who, tenant, mailbox, which count, when"| REG[("registry<br/>view log, no content")]
    CON -->|"2. fetch live<br/>per-tenant detail token"| WH["Tenant n8n<br/>Junior: detail (webhook)"]
    WH -->|"reads, masks"| T[("sort_log, corrections")]
    WH -->|"masked rows, at most 50"| CON
    CON -->|"3. shown, never stored"| OP
    OP -.->|"Open row N in n8n<br/>tailnet only"| N8N["Tenant n8n<br/>sort_log"]

1. Masked rows, five fields only

When an operator clicks a count in the corrections section (for instance Marketing → Action, 7 days: 5), the Console shows one line per correction behind it, with only these fields:

Shown From
Row number The sort_log row's ID in the tenant's n8n data table
Sorted When the Junior sorted the email (sort_log)
Corrected When the poll saw the correction (corrections.detected_at)
Rule decided_by: one of the fixed names (known_sender, learned_action, ai, legal_risk, and the rest in the Junior's sort log)
From → to The bucket the Junior or the last correction left, and the one the person chose. Label removed when there is no to_bucket

Never returned, by the webhook or the Console: sender address or display name, sender domain, subject, body or snippet, recipients, message or thread ID, reason (it can hold a matched word from the email, guardrail word: tribunal), confidence, anything from the senders table. The masking happens in the tenant's n8n, before anything leaves it, so the Console never receives a field it must then hide.

Times show in Melbourne time, like the rest of the Console. At most 50 rows, newest first; more than that says so and points to n8n for the rest.

2. "Open row N in n8n" stays the only way to the email

Each line carries Open row N in n8n, which opens the tenant's sort_log in its own n8n (the tenant's sort_log_url) for the operator to find row N. That path is unchanged: tailnet only, the operator's own n8n sign-in, and the email itself (sender, the message in Gmail) is seen there and nowhere else. The row number is the only thing that joins the masked view to the email, and it means nothing without that access.

3. Through Cloudflare, for both operators, no tailnet for the operator

The masked view is part of the Console's normal pages, reached through Cloudflare Access with MFA (ADR-0040), so both operators can use it from anywhere without the tailnet. Because the rows hold no personal information (§6), nothing about them needs the tailnet break-glass path or a decision about Cloudflare as a processor.

4. Live fetch on click, nothing stored

The Console asks the tenant's own n8n for the rows at that moment, shows them, and keeps nothing: no registry rows, no file, no cache, no log line with row data. The response carries Cache-Control: no-store, and the rows live only in the page until it is closed or refreshed. The masked rows hold no personal information, but they are still the tenant's data, and a live fetch keeps the rule simple: the registry holds aggregates, settings and the view record, nothing per email.

A narrow, fixed query. A webhook workflow in each tenant's n8n, Junior: detail, answers only fixed questions, each one a count the Console already shows: corrections by type or by from and to bucket, for one mailbox, today or the last 7 days. The Console sends the count's reference (mailbox, measure, window), never a free filter, and the webhook checks a detail token issued per tenant. The request is server to server, from the Console's host to the tenant's n8n; operators never call it. Today n8n answers only on the tailnet (WEBHOOK_URL is the host's tailnet name), so the Console's host reaches it there, with the least-privilege grant OI-086 calls for (the Console's host to each tenant's n8n, nothing else). On lite-dev that is the same machine.

5. Every view is recorded, and both operators can see the record

Before the fetch, the Console writes an append-only view record: the operator (from the Access token), tenant, mailbox, which count (measure, window), the time, and afterwards the number of rows returned or why none came back. No row data. The record shows on the Console's History, readable by both operators, and the Console never edits or deletes it (ADR-0037 §5). It is kept although the rows are masked: it is cheap, and it answers "who has been looking at this client's corrections" without argument.

6. Why the masked rows hold no personal information

Each field, on its own and together:

  • The row number is an internal ID in the tenant's data table. It identifies nothing outside that table, and only someone signed in to the tenant's n8n over the tailnet can turn it into an email.
  • The rule and the buckets are fixed vocabularies chosen by Octopodia, the same for every client. They say what the Junior did, not who wrote to whom.
  • The two times say when an email was sorted and when someone moved it. That is the same kind of fact the Console already shows in phase 1 as hourly counts of sorted emails and corrections, at a finer grain. It names no sender, and nobody reading it can tell which email it was without the mailbox.

So a masked row identifies no sender, recipient or email, and adds no new kind of information about the mailbox's own users beyond the counts already shown. If a later field would change that (a domain, a subject, a sender's hash, a Gmail link), it is a change to this ADR, not a setting.

7. Phase 2, beside alert responses

It ships in Console phase 2 with alert responses (OI-121), read-only like the rest of phase 2's first step. Anything that changes a tenant stays in phase 3 behind the passkey step-up (ADR-0040 §4).

Risks and how they are met

Risk Meeting it
A field slips through the mask (a later change to Junior: detail returns from_address or reason) The webhook builds each row from the five named fields, never by passing a row through. The Console's schema for the response is strict (extra=forbid, as for the link's payloads) and refuses a response with any other field, showing an error rather than the rows. A test holds both
The Console's host cannot reach the tenant's n8n (tailnet or grant problem) The view fails closed: "Could not reach this tenant's n8n", with the n8n link as before. Counts and alerts never depend on it
The tenant is down (host off, n8n stopped, workflow unpublished) Timeout of 10 seconds, the same fail-closed message, and the view record notes the failure. Nothing is retried in the background
Response size 50 rows at most, five fields each, and a size cap on the response; anything larger is refused rather than shown in part
The webhook used as a general reader Fixed questions only (§4), the detail token, the tailnet grant, and the tenant's n8n execution log records every call
One more secret per tenant Held like the link token (vault and Console secrets), rotated with it
The row number used to find the email Only through the tenant's n8n, over the tailnet, with an n8n sign-in (§2). That is the access an operator needs for the email today

What we would tell clients

"Octopodia's operators can see, on demand, how the assistant's sorting was corrected: when an email was sorted, when it was moved, which rule sorted it and between which folders. They see no sender, subject or content in our console; that stays in your own system. Nothing is copied into or kept in our systems, and every look is recorded." No change to the client agreement or privacy notice is needed for the masked view, since it carries no personal information (§6); the sentence belongs in the service description.

Consequences

The corrections section shows the pattern behind a count in one click, for both operators, from anywhere, and the registry still holds no per-email data. The Console now displays row-level facts from a tenant, masked and never stored, so ADR-0037 §4 becomes "nothing stored, and nothing identifying shown" rather than "nothing shown". The email itself stays behind the tailnet.

The cost is a new path from the Console's host into every tenant's n8n, a token per tenant, the tailnet grant (OI-086), and the discipline of §6: any field added later is an ADR change.

Alternatives considered

  • Keep the n8n link only (today). Simple and safe, but slow, and every operator needs n8n access to every tenant for questions that need no email at all.
  • Show the sender address too, live and unstored (this ADR's first draft). Rejected: sender addresses are personal information, so they would cross Cloudflare's edge decrypted, need a decision on Cloudflare as a processor and a client notice, or be confined to the tailnet break-glass path. The masked view gives the pattern without any of that, and the sender is one click away in n8n.
  • Show the sender's domain only. Rejected: a domain can identify a person (a sole trader's own domain), so it carries the same questions.
  • Push the masked rows with the link's payloads and store them. Rejected for now: per-email rows in the registry, even masked, widen ADR-0037 §4 for little gain over a live fetch. Worth revisiting if the tailnet path from the Console's host proves fragile.
  • Cache the rows in the Console for a short time. Rejected: a cache is storage, with all its questions, for little gain over a live fetch.
  • Let the Console query n8n's API directly (the data table REST API) instead of a webhook. Rejected: an n8n API key reads every table and workflow on the instance, and the masking would happen in the Console, after the data has left the tenant.