Skip to content

ADR-0038: Tenant link and metrics ingest

Context

The Junior's settings live today in each tenant's n8n (mailboxes, settings, alert_rules, guardrail_words, and some still in code). It was decided on 10 October 2026 that the registry is their source of truth, with the n8n tables as a local copy (OI-095), but not how the copy is kept, how a change is pinned, or how this squares with ADR-0006's two writers only.

The Console also needs to see each tenant's health without holding its content (ADR-0037 §4), and to ask a tenant to run something, such as resetting what a mailbox has learned.

Tenants sit on shared and, later, dedicated hosts (ADR-0030). Their containers cannot reach the registry's network (ADR-0025 §2), and that must stay true.

Decision

flowchart LR
    subgraph T["Tenant n8n, one per client"]
        L["Tenant link<br/>workflow"]
        LT[("Local copy<br/>mailboxes, settings,<br/>alert_rules, word lists")]
        J["Junior workflows"]
        L -->|"writes"| LT
        J -->|"reads"| LT
    end
    L -->|"GET own settings and jobs<br/>tenant token"| API["Console API<br/>link endpoints"]
    L -->|"POST aggregates<br/>append-only"| MET["Metrics endpoint"]
    API --> REG[("Registry")]
    MET --> REG

1. Tenants call out; the Console never calls in

Every connection between a tenant and the centre starts in the tenant. The Console holds no credential for any tenant's n8n, Postgres or mailbox, and has no route to them. A compromised Console cannot reach into a tenant; a compromised tenant can reach only its own records.

A workflow in each tenant's n8n, credential-free apart from its own link token.

Does How often
Pulls its own settings and writes them into the local copy Every 5 minutes, and at start
Pulls its own queued tenant jobs, runs them, reports the result Every minute while a job is queued, otherwise with the settings pull
Pushes aggregates to the metrics endpoint Every 5 minutes for health; hourly totals for counts

The token is scoped to its own records: one token per tenant, issued at provisioning, stored in that tenant's vault (ADR-0027) and placed in its n8n by Ansible. With it, a tenant can read only its own settings and jobs, append only its own aggregates, and do nothing else.

3. The local copy stays the thing workflows read

Junior workflows keep reading their n8n tables, exactly as now. The link keeps those tables in step with the registry.

  • If the Console is unreachable, the copy is used as it stands. Sorting never waits on the centre.
  • Client-owned builds with no link use the local tables as their source, as already decided.
  • A setting changed in the n8n table by hand is overwritten at the next pull. The registry wins; the Console shows when a pull last succeeded.

4. Pinning: a change applies from the next poll

A Junior poll reads its settings once, at its start (Mailbox settings). A change written to the registry therefore reaches the copy within 5 minutes and applies from the next poll after that, never part-way through one. The copy carries the settings_version it was pulled at, and the version is recorded with each sorted email, so a decision can always be traced to the settings in force (ADR-0006's pinning, at the grain the Junior works at).

5. Which settings are per tenant and which per mailbox

Per mailbox Per tenant
poll_minutes, go_live, learn_noise_after, learn_action_after (OI-106), mark_read_after_days_*, minutes_saved_*, label_names business_type, min_confidence_list_mail, min_confidence_unknown_sender, known_sender_months, guardrail_words, alert_rules, alert_recipients

A tenant value may later gain a per-mailbox override; a mailbox value never moves up. The settings schema (ADR-0037 §5) carries the level of each.

6. Two writers only still holds for config

Config has two writers: Ansible, which creates the tenant record at provisioning, and the Console, which edits it. The tenant link writes no config:

The tenant link Writes to
Reads settings Nothing central. Its own local copy only
Pushes aggregates The metrics store: append-only, never config
Reports a job's result The job's own record, which only it and the Console touch

The brain remains read-only against the registry, unchanged.

7. The metrics endpoint is separate and append-only

  • Separate from the settings API: its own path and its own table, so a fault in ingest cannot touch config.
  • Append-only: the API offers insert and nothing else. No update, no delete.
  • Validated against a metric catalogue: a payload with any field not in the catalogue is refused whole. This is where ADR-0037 §4's boundary is enforced in code: the centre cannot store a sender address because it will not accept a field that could hold one.
  • Every value is typed: a number, a timestamp, or a value from a fixed list declared in the catalogue (a bucket, a rule, a correction type, an alert kind). The catalogue has no free-text fields. A payload whose values do not match their declared types is refused whole, so a string cannot slip into a field meant for a count, and nothing in the catalogue can carry an address or a subject.
  • Idempotent on tenant, mailbox, metric and period, so a retried push is harmless.

The v1 catalogue is the Junior's: emails sorted by bucket and by rule, corrections by type, the correction rate, last completed poll, poll gaps, history resets, and alert state.

8. Tenant jobs report counts, not detail

A tenant job, such as reset learning, reports what it did as counts: rows it would change, rows it changed. Its row-by-row report (which includes sender addresses) stays in the tenant's n8n execution, where the operator can open it. The same boundary as everything else.

9. What this makes possible

With aggregates central, two deferred items gain a home: correction alerts across every tenant (ADR-0034's global scope, OI-101), and the central notifier that removes per-host mail secrets (ADR-0036 §5, OI-104). Neither is built by this decision.

Consequences

Tenants keep working when the centre is down, and the centre never holds a way into a tenant. Settings changes reach a mailbox within one refresh and one poll, with a version on every decision.

The boundary is enforced twice, by what the link sends and by what the endpoint accepts, so a bug in one tenant's link cannot leak content into the registry.

The costs: a link workflow in every tenant to keep in step with the API; a token per tenant to issue and rotate; a lag of up to one refresh before a change applies; and the Console must show when a tenant's link has gone quiet, because a silent link looks the same as a healthy one with nothing to report.

Alternatives considered

  • The Console calls each tenant's n8n API is rejected: it puts a credential for every tenant in one place and needs a route into every tenant network.
  • Tenants read the registry database directly is rejected by ADR-0025 §2 and ADR-0006: no single enforcement point.
  • Workflows read settings live from the API on every poll is rejected: sorting would stop whenever the centre does.
  • Push detail and filter centrally is rejected: the content has already left the tenant by then.
  • One shared token for all tenants is rejected: one leak reads every tenant's settings.