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
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-playbookby 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).