Skip to content

BOS Console

The operator surface El and Lachlan run ABOS from. Nothing here is built yet. The decisions are in four ADRs, accepted 11 October 2026:

ADR Decides
ADR-0037 Scope, the operating principle, and the data boundary
ADR-0038 The tenant link and the metrics ingest
ADR-0039 The runner and the change to secrets custody
ADR-0040 Access: Cloudflare Tunnel, Access, and the tailnet break-glass

The principle: nobody should need to remember SSH or Ansible commands for routine work. SSH and the octo-ctl command line are break-glass only. Anything done by hand more than once becomes a Console job. Break-glass runbooks stay in these docs, linked from the Console, so they can be found when the Console itself is the problem.

The four parts

flowchart LR
    OPS(["El, Lachlan"]) -->|"Access, Entra SSO + MFA"| CON["<b>Console</b><br/>FastAPI + React"]
    CON <--> REG[("<b>Registry</b><br/>control-plane Postgres")]
    TL["<b>Tenant link</b><br/>in each tenant's n8n"] -->|"pull settings and jobs<br/>push aggregates"| CON
    RUN["<b>Runner</b><br/>octo-ctl"] -->|"pull approved jobs<br/>report output"| CON
    RUN -->|"playbooks from main"| HOSTS["Hosts"]
Part Where Does Holds
Console The core stack on the host, reached through a Cloudflare Tunnel (ADR-0040) Shows the fleet and changes it: settings, tenant jobs, provisioning jobs No tenant credentials. Calls nothing in a tenant
Registry Control-plane Postgres, its own container and network (ADR-0025) Tenant records, settings and their schema, aggregates, alerts, the job queue, the history Settings and aggregates only. Never content, addresses or IDs from a mailbox (ADR-0037 §4)
Tenant link A workflow in each tenant's n8n (ADR-0038) Pulls its own settings into the local copy, runs its own tenant jobs, pushes aggregates One token, scoped to its own records
Runner A service on octo-ctl, its own user (ADR-0039) Pulls approved jobs, runs the named playbook from main, reports commit and output Its own SSH key, vault password files and API token

Every arrow starts outside the Console. Tenants and the runner call out; the Console never calls in.

The v1 screens

v1 serves the Junior. The model underneath is generic (tenant, product, mailbox), so Senior adds screens, not a new model.

Fleet

Redesigned 13 October 2026 (El). Wide screens (1024px and up): two columns, full width, Needs you on the left (about 35%), How it's doing and Tenants on the right (about 65%). Each column scrolls on its own under fixed headings, so the top bar and the headings stay in view. Below 1024px: one column, in the order Needs you, How it's doing, Tenants, with Needs you items and tenants as cards, the status first, and touch targets of at least 44px. Times are Australia/Melbourne, 24-hour. It refreshes every 60 seconds.

Top bar, full width on Deep Indigo: the Octopodia mark (the dark-ground file, Logo_DBG: the clear-ground Logo_CBG is dark-inked and all but disappears on Deep Indigo, per the brand guide's correction of 11 August 2026), BOS Console, the signed-in operator's email from their Access token, and Updated HH:MM. A compact app bar, El's decision of 13 October 2026, overriding the guide's 120px digital logo minimum for the Console app bar: 64px tall with the mark 40px tall on wide screens, email over the update time on the right; below 640px, 56px with the mark 32px, on one row with no wrapping at 375px. On phones the email does not fit beside the time, so it moves to a Signed in as … line at the foot of the page. Measured 13 October 2026: 64px and 56px. Provisioning and History are links in the top bar, after the mark, on every page (El, 15 October 2026: at the foot they were too easy to miss); the current page is underlined in Strong Cyan. Below 480px the BOS Console name is hidden and the mark alone links to Fleet, so both links and the update time still fit on one row.

flowchart LR
    subgraph wide["1024px and up"]
        direction LR
        L["Needs you<br/>(about 35%)"] --- R["How it's doing<br/>Tenants<br/>(about 65%)"]
    end
    subgraph narrow["Below 1024px"]
        direction TB
        N1["Needs you"] --> N2["How it's doing"] --> N3["Tenants"]
    end

1. Needs you, in two levels. Act now always above look today. One card per item: urgency, what (the alert in plain words), tenant and mailbox, since (HH:MM and how long ago), and why (the number against its limit, for example 16.7% over 7 days, limit 15%). When it is empty, a single All quiet line.

Level Means Shows
1, act now Something is broken Any incident alert (Gmail sign-in failing arrives as one); a mailbox not polling for longer than the larger of 15 minutes and four polls (the Junior's own rule, OI-120); a quiet link (no push for 15 minutes, OI-116)
2, look today Something is drifting Any advisory alert, such as corrections creeping; poll gaps today that have since recovered

An alert's level comes from its kind (incident is 1, advisory is 2), never from its rule name, so a new rule needs no Console change. The why for an alert comes from the value, limit and window the link sends with it (catalogue below); for not polling, a quiet link and poll gaps the Console works it out itself. An alert sent without figures says No figures sent yet, never a made-up number. Until the watchdog writes back the figures of an alert that stays open, the why shows the value from when it opened (OI-122).

2. How it's doing: four figures across the top of the right column.

Figure Shows
Sorted today Today's total, against the daily average of the last 7 full days
Correction rate today Corrections over emails sorted today. Below 20 sorted, "Too few emails" instead of a percentage (CONSOLE_CORRECTION_RATE_MIN_SORTED, agreed with OI-100's thresholds)
Mailboxes healthy Live mailboxes with nothing to report, of the live mailboxes. The caption says why the rest are not (1 needs action now, 1 needs a look); Live, nothing to report only when all are
Links reporting Tenants with a live mailbox whose link has pushed in the last 15 minutes, of those tenants

3. Tenants, a table sorted worst first: act now, look today, fine, not live, then name. Status comes first and is the verdict (no separate verdict column, 13 October 2026); numeric columns are right-aligned, headers and values alike. Click or tap a row to open its mailboxes underneath, in the same order and with the same columns; a mailbox's address opens Mailbox health.

Column Tenant row Mailbox row
Status The worst of its mailboxes Its own
Tenant Name Address
Mailboxes OK Live mailboxes that are fine, x of y, and why the rest are not Yes, No or Not live
Last poll The oldest last good poll of its live mailboxes, HH:MM and how long ago Its own
Sorted today, Corrections today, Open alerts Totals Its own
Status When
Act now Any level 1 condition
Look today Any level 2 condition
Fine Neither
Not live No push has ever arrived

A quiet link also emails (OI-119): every 5 minutes the Console checks each tenant's link, and sends one email from alerts@ when it goes quiet and one when it is heard again (runbook). It cannot report its own host being down; a watcher on octo-ctl does that.

Never on Fleet: time saved (the figures are placeholders, OI-089), charts (Mailbox only), and anything from inside a mailbox: no sender, subject or message.

Look and feel

Octopodia Branding Guidelines V2.0 (approved 10 August 2026), as recorded in the website repository's docs/brand.md; it supersedes the V1.3 design system (El, 13 October 2026). Bricolage Grotesque for headings, DM Sans for body, never under 14px or 400 weight. The V2.0 palette as CSS tokens named as in the guide, with no raw hex in components, and spacing only on the 4, 8, 12, 16, 24 and 32px scale.

Theme Grounds Text Links Focus ring
Light Pure White surfaces on 1 Light Greyish Blue; borders 3 and 4 Light Greyish Blue Darkest Black Blue #080224; secondary 2 Desaturated Dark Blue #575382; captions 1 Desaturated Dark Blue #726E9B, on white only Strong Blue #2B2DB5 Strong Blue
Dark (follows the device) Deep Indigo surfaces on Very Dark Black Blue #13112F; raised and borders 2 and 1 Very Dark Desaturated Blue Pure White; muted Greyish Blue #9895BA Soft Magenta #D9A6E4 (6.7:1 or better on every dark ground; Soft Blue drops to 4.1:1 on the raised ground) Strong Cyan, an accent, never text

The top bar is Deep Indigo in both themes, so its focus ring is always Strong Cyan. Cyan never carries text and never sits on white.

Status uses V2.0's Portal status tokens, always an icon and the word, never colour alone (octagon Act now, triangle Look today, tick Fine, dashed circle Not live). On light they are used as they are. On dark they fail AA (2.1 to 3.4:1 on the dark grounds), so the dark theme uses lifted variants of the same hues; only the dark variants differ. Not live is not a status colour: it uses the secondary text tone. Contrast, measured 13 October 2026:

Status Token, light On white On 1 Light Greyish Blue Dark variant On Deep Indigo On Very Dark Black Blue On 2 Very Dark Desaturated Blue
Act now Strong Red #B3261E 6.54:1 6.12:1 #FF8A80 7.00:1 8.01:1 5.89:1
Look today Burnt Orange #9A5B00 5.43:1 5.08:1 #F2B84B 8.93:1 10.21:1 7.51:1
Fine Bold Green #0E7A55 5.34:1 5.00:1 #5DD39E 8.58:1 9.82:1 7.22:1
Not live 2 Desaturated Dark Blue #575382 7.08:1 6.63:1 Greyish Blue #9895BA 5.59:1 6.39:1 4.70:1

Tenant

Shows Never shows
The tenant record (slug, region, host, products), its mailboxes with health, tenant-level settings with an edit form, recent jobs, when the link last pulled and pushed. Links to the tenant's n8n (tailnet) and to its runbooks Credentials or vault values, even masked. Any list of senders

Mailbox

Shows Never shows
Mailbox settings with an edit form, validated, each with its history. Counts per day and week: emails sorted by bucket and by rule, corrections by type, the correction rate, poll gaps, history resets. The number of learned rules. Tenant actions: reset learning (dry run first, reported as counts), run setup Sender addresses, subjects, message IDs, which senders were learned. The email itself is one click to the tenant's n8n, never copied. The corrections behind a count are shown in place, masked: row number, sorted and corrected times, rule, from and to bucket, fetched live and never stored, every look recorded in History (ADR-0041)

Phase 1, read-only, built 13 October 2026: the corrections section (#corrections), answering what did people correct?. Today and the last 7 days, by type (bucket swap, moved to Inbox, label removed) and by from and to bucket (Marketing → Action 5), counts only. Under it, See the emails in n8n opens the tenant's sort_log in its own n8n over the tailnet (the tenant's sort_log_url in the registry, seeded from the inventory). A corrections creeping or corrections spiking card on Fleet, and the same alert on the Mailbox page, link straight to this section, scrolling to it and moving focus there. 7 days means today so far plus the 6 Melbourne days before it, for every count on the Mailbox page (sorted, corrections and the correction rate): today comes from the latest snapshot, so the 7-day figure is never less than today's. Until a tenant's link sends correction_paths, the section says From → to isn't reported by this tenant's link yet; once it does, the 7-day column reads Since until the counts cover the whole window.

Alerts

Shows Never shows
Open and resolved alerts across the fleet: rule, kind (incident or advisory), value against threshold, when opened and resolved, whether the email went. The alert rules and recipients, editable as settings The emails behind a correction count

Provisioning

Shows Never shows
The job menu with its forms, the queue, Approve (needs a passkey step-up bound to the job, ADR-0040 §4), each job's live and final output and the commit it ran, and the runner's pause toggle Secrets in inputs or output. Any destructive job: there are none in v1

History

Shows Never shows
Every setting change, tenant action and job: who, when, before and after, and whether it came through the main path or break-glass. Filterable by tenant and mailbox. Append-only: nothing on this screen can be edited An edit button

The metric catalogue (junior.v1)

The tenant link sends only what is in this list; the Console refuses a payload with any other field or value, whole (ADR-0038). One definition, in console/api/app/catalogue.py; the link's builder (workflows/email-assistant-junior/tests/link_core.js) leaves out any value it does not know rather than send it.

Payload Sent Fields
Health snapshot Every 5 minutes. Stored by 5-minute slot; screens read the latest Per mailbox: the address, last good poll, last poll attempt, last history reset, poll_minutes (sets when it counts as not polling), pending count and oldest pending minutes, today so far (Melbourne day) as counts, open alerts
Hourly counts Every completed UTC hour after the Console's cursor, so an outage leaves no gap. At most 7 days back Per mailbox and hour: counts

Counts, in both: emails sorted by bucket (action, marketing_subscriptions, receipts), by deciding rule (legal_risk, safety, reply_to_us, thread_action, learned_action, known_sender, unsubscribe_header, ai, below_threshold, unclear_category, parse_failure), and corrections by type (bucket_swap, moved_to_inbox, label_removed) and, optionally, by from and to bucket (correction_paths: each from_bucket, to_bucket and count, the same three buckets, never the same bucket twice; added 13 October 2026). Open alerts: a rule (not_polling, sign_in_rejected, ai_unreachable, ai_quota_or_key, corrections_creeping, corrections_spiking, poll_gap_recovered), its kind (incident, advisory), when it opened, and, optionally, the value that tripped it, the rule's threshold and its window in minutes (numbers only, for the Fleet's why; added 13 October 2026, optional so an older link is still accepted). A Console catalogue change deploys before any link that uses it: the Console refuses a payload with a field it does not know, so a link sending the new figures to an older Console has every snapshot refused (13 October 2026, 02:50 to the republish). Link changes that need a newer Console wait on a Junior: tenant link (next) copy until that Console is live (AGENTS.md).

Never sent: a sender, recipient, subject, message or thread ID, label name or any content. The only address in a payload is the mailbox's own. Bucket names are not label names: action, marketing_subscriptions and receipts are the Junior's three fixed buckets, the same fixed list by_bucket has always sent, and correction_paths uses only those. A mailbox's own Gmail label names (such as Assistant/Receipts) and label IDs are never sent; a path naming anything outside the three buckets is refused whole. How long aggregates are kept is still open (OI-115).

The job menu

Two kinds of job, one set of rules: named jobs only, validated inputs, recorded with requester, approver, inputs and result.

Kind Run by v1 jobs
Provisioning jobs The runner on octo-ctl, Ansible from main Create tenant, add mailbox, redeploy tenant
Tenant jobs The tenant link, inside that tenant's n8n Reset learning (dry run, then real), run setup

How the menu grows: anything done by hand on octo-ctl, or by hand in a tenant's n8n, more than once becomes a job. A job is a definition in this repository (name, what it runs, input schema), so adding one is a reviewed commit to main, and the Console builds its form from the same definition.

Never on the menu in v1: delete tenant, rotate secrets, rebuild host. They stay as break-glass runbooks until a stronger gate exists (OI-107).

Build phases

Each phase ends with something proved before the next starts. Order, changed 15 October 2026 (El): 1, then 4, then 2, then 3. Phase 4 comes next, with tenant zero on a new production host as its acceptance test; its step-by-step plan is Console phase 4. The phase numbers stay as they were, so references elsewhere still hold.

The order keeps the ADRs' guarantees: phase 4 brings the part of phase 2 it needs before it writes anything (the append-only History for jobs and the records they create, and job inputs validated in the Console and again by the runner), writes no settings, and lands the passkey step-up before the runner exists, so no job runs unapproved.

Phase Order Builds Proves Closes
1. First light (read-only) Done The registry with tenants, mailboxes, the append-only metrics table and the typed v1 catalogue. The Console API: metrics ingest for the tenant link, read endpoints for the screens. The tenant link, push only. Fleet and Mailbox health. Tunnel, Access and the token check. Ansible roles for the registry, Console and cloudflared. lite-dev seeded as tenant zero, admin.octopodia as its mailbox Only El and Lachlan get in, with MFA, from a laptop or a phone. admin.octopodia's health and counts are visible without opening n8n and match its tables. A payload with an unknown field or a mistyped value is refused whole. A tenant link that stops pushing shows as link quiet within 15 minutes OI-116 (the quiet link, in its first form)
2. Change Third, after 4 Settings schema with validation and history for settings, before any settings write (the History record itself and the settings schema's definition arrive with phase 4). Settings pull into the local copy, with settings_version on each sorted email. Editable settings on Tenant and Mailbox, Alerts, History An invalid setting is refused through the API. Change poll_minutes in the Console: it applies from the next poll, History shows before and after, and the version is on the next sorted email. With the Console stopped, sorting carries on from the copy OI-062 and OI-063 (Console side), OI-095, OI-121 (alert responses: seen, snooze, not a real problem), ADR-0041 (masked in-place view of the corrections behind a count, built 13 October 2026, waiting on its token and the webhook's publish)
3. Actions Fourth Tenant jobs through the link: reset learning (dry run first), run setup, each recorded in History Reset learning from the Console, dry run first, reported as counts; the row detail stays in the tenant's n8n
4. Provision Next (plan) First, from phase 2: the append-only History for jobs and the records they create, job definitions with input schemas validated in the Console and by the runner, and the settings schema defined but written by nobody. Then the runner, the job menu and its wizards (which take no settings), the Provisioning screen, passkey enrolment, the step-up at approval and the passkey recovery command (ADR-0040 §4). Tailnet break-glass once OI-086 is closed An approval without a valid passkey step-up is refused, and so is a job whose approval record lacks one. Acceptance test: tenant zero on a new production host: a new tenant, then a fresh Gmail mailbox, then the Junior sorting, all started from the production Console. The one human step is the Google sign-in for the new mailbox's credential (OI-111). Each step before it is tested on lite-dev OI-063 (for jobs and records), OI-108, OI-109, OI-111, OI-112, OI-114, OI-124, OI-126, OI-127, OI-128, OI-129. Before production: OI-086, OI-100, OI-110

Routine tasks today, and where they move

What is done by hand now, on octo-ctl or in a tenant's n8n, and the phase that brings each into the Console.

Task Done today Moves in
Is a mailbox polling? When did a poll last complete? mailboxes table, n8n executions 1
Round check: emails sorted by rule, corrections by type Reading sort_log and corrections 1, as counts. Row detail stays in n8n
Open and resolved alerts alerts table, alert emails 1
How many senders a mailbox has learned known_senders table 1, as a count. Which senders stays in n8n
Checking n8n's version and execution-saving settings on a host SSH and grep (runbook) 1, reported by the link as health
Change a mailbox setting (poll_minutes, mark-read days, learn_noise_after, minutes saved) Editing the mailboxes table 2
Change alert rules or recipients alert_rules and settings tables 2
Edit the legal and safety word lists guardrail_words table 2
Reset learning before and after a test round Junior: reset learning form in n8n 3, tenant job
Run setup for a mailbox Run setup trigger in the mailbox workflow 3, tenant job; part of add mailbox in 4
Stop sorting for a test, then start it again Switching the mailbox workflow off and on in n8n 2, as a mailbox enabled setting
Publish workflow changes The n8n editor 4, redeploy tenant imports and publishes from main (OI-114)
Run the fixtures after a change Junior: sorter tests by hand 4, as the check at the end of redeploy tenant
Provision a host's n8n ansible-playbook playbooks/n8n.yml --limit … on octo-ctl 4, create tenant and redeploy tenant
Upgrade n8n Edit the pinned version, merge, run the playbook 4, redeploy tenant after the merge
Add a mailbox: duplicate the workflow, pick the credential in 15 nodes, set the error workflow, run setup The n8n editor 4, add mailbox
Delete parked test rows, delete a tenant, rotate the alert-sending secret, rebuild a host By hand Stays manual: destructive (OI-107)
Guardrail review of a round's emails Junior: guardrail review in n8n Stays in the tenant: it reads subjects
Create a test round's GitHub Project tools/junior_test_round.py Out of scope: GitHub, not ABOS

What stays outside the Console

  • Client content, always. The tenant's own n8n is where an email is looked at.
  • Break-glass: SSH over the tailnet, ansible-playbook by hand, and the runbooks in these docs. Each linked from the Console.
  • Destructive operations, until OI-107 gives them a two-person gate.
  • Uptime checking of the Console itself, which must be external (OI-048).

Open items

Raised by this design: OI-107 (destructive jobs), OI-108 (runner custody), OI-109 (link route and tokens), OI-110 (Cloudflare account), OI-111 (Google sign-in in add mailbox), OI-112 (job output redaction), OI-113 (break-glass identity), OI-114 (publishing workflows from the repository), OI-115 (metric catalogue and retention), OI-116 (a quiet tenant link), OI-117 (the Entra fallback for the approval gate), OI-118 (the passkey recovery runbook). Closed by its phases: OI-062, OI-063, OI-095. Fixed in part by the Console: OI-119 (nobody notices when a tenant's n8n is down; link quiet plus an alert email) and OI-120 (one threshold for a mailbox that has stopped polling).