Skip to content

refactor: V2: split the core model into machines, applications, and identities - #515

Open
passcod wants to merge 83 commits into
mainfrom
workhorse/v2
Open

refactor: V2: split the core model into machines, applications, and identities#515
passcod wants to merge 83 commits into
mainfrom
workhorse/v2

Conversation

@passcod

@passcod passcod commented Aug 26, 2026

Copy link
Copy Markdown
Member

No description provided.

@passcod passcod changed the title V2: Split the core model: machines, application servers, and identities refactor: V2: split the core model into machines, applications, and identities Aug 26, 2026
passcod and others added 14 commits August 27, 2026 01:06
The rename swept crates/ but not private-web/e2e/, so every Playwright
test failed in its fixture: the seed's TRUNCATE list and inserts still
named servers, server_names and server_certificates.

Also fixes two SPA call sites that were left sending the old key after
the issues endpoints' args became application_id. Typecheck did not
catch either — the useApi/useApiAction argument types are not bound to
the generated schema — so both only showed up under Playwright.

The seed's TRUNCATE now also clears machines, which is a parent of
applications and so is not reached by truncating applications alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the 7776 words in the EFF large wordlist are themselves hyphenated
(drop-down, felt-tip, t-shirt, yo-yo), so a 4-word hyphen-separated passphrase
hit one about one time in five hundred and read as five words. An operator
transcribing disband-retrace-drop-down-bodacious cannot tell which four were
meant, and the round-trip test asserting four words failed at that rate.

Skip hyphenated words when sampling, which costs about a thousandth of a bit,
and cover it with enough draws to make a regression certain rather than likely.

The sampler is now built once: constructing the list parses 7776 words out of a
static string and taking a sampler clones them again, which was costing ~1ms per
ticket and made the new test take 20s instead of 35ms.

Pre-existing; not caused by this card's changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A machine was a table nobody wrote: no update, no handlers, and nothing in
Rust ever setting registered_at or device_id. Scouting the two steps meant to
come next found both blocked on that, so it lands first.

Application.machine_id is now a real field, so every struct-literal insert
names its machine and the compiler enforces it.

Machine::update owns the group write. It propagates the new group onto the
applications on the machine, re-evaluates their open issues on an
ungrouped-to-grouped transition, and recomputes both groups' cached effective
version. A trigger would do the column and none of the three consequences,
which is why this is a model method.

MachineUpdate carries no device_id or registered_at: an identity is bound by
enrolment, not by editing a form. Machine::mark_registered does that, and
COALESCEs registered_at so a re-enrolment does not restart the clock a backup
deadline counts from.

The operator create flow creates the machine first and hangs the application
off it. Still 1:1 — a second workload on a box arrives by report.

Removed NewServer and its conversion: dead since the crate split, in neither
OpenAPI spec, and the only thing that would have needed a machine invented for
it. TEN_MINUTES went with its last non-test user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds /api/machines: list, get (with the applications on the box), create,
update, archive. Enrolment now marks the machine as well as the application;
the application is still marked because enrolment stays keyed by application
until that flow moves outright.

Drops the application_default_machine() scaffolding. Omitting a machine is now
a NOT NULL violation, which is what it should always have been: the hazard was
a caller that should attach to an existing machine silently getting a second
one, wrong for exactly the two-workload host this card serves.

That cost 137 raw-SQL fixture rewrites across 40 files. The uniform shape is a
data-modifying CTE, which keeps it one statement (sql_query cannot run two) and
preserves bind numbering. Where the application had an explicit id the machine
reuses it, the same 1:1 the backfill produced; otherwise the machine is minted
anonymously and selected from the CTE.

Fixture machines carry no group even where their application does. Nothing
reads machines.group_id on those paths so it is inert, but it is a disagreement
a future machine-scoped assertion would trip over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two triggers keep the denormalisation honest: a machine's group change
propagates to the applications on it, and an application's own group write is
corrected back to its machine's. Existing rows brought into agreement, a no-op
on anything the 1:1 backfill produced.

The triggers complement Machine::update rather than replacing it. The model
method does the three things a column write cannot — re-evaluating open issues
for anything that gains a group, and recomputing both groups' cached effective
version — while the triggers cover every other writer.

The application update endpoint keeps its contract and changes meaning: a group
change is applied to the machine, which propagates back down. Moving "the
server" to a group moves the box. No frontend change needed.

BEFORE INSERT was wrong and is deliberately absent. A data-modifying CTE's rows
are not visible to the rest of the same statement, so creating the machine and
the application together had the trigger find no machine and blank the group it
was just handed — silently, because the foreign key checks on a later snapshot.
The reason is in the migration so nobody adds INSERT back.

An empty changeset is no longer an error in Application::update: a group-only
edit leaves nothing to write. That turns update on a missing server from a 500
into a 404, the 500 having been an accident of diesel refusing the empty
changeset before anything checked existence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A many-to-many between devices and servers, trigger-maintained off every status
insert. The identity-to-machine link is a single column on the machine, so it
has nothing left to model.

Dropped in the order Postgres will not enforce: trigger, then function, then
table. A PL/pgSQL body is text, not a parsed dependency, so dropping the table
first succeeds and leaves the trigger to fail on the next status push — every
status push, for every reporter. statuses is partitioned and the trigger lived
on the parent, so one DROP covers every partition and any created later.

The whole slice goes with it: the model reader, the
/api/devices/get_past_server_associations endpoint and its OpenAPI path, the
generated TS, the "Past server associations" panel, the device-merge fix-up,
and the seeder's truncate entry.

The staleness anchor moves to machines.registered_at, joined in rather than
fetched separately. Anchored on the box because the box is what gets backed up:
anchoring on an application's registration would restart a machine's deadline
every time a workload was added to it. Both cases now have tests.

Behaviour change, not a like-for-like swap: min_first_seen was effectively
"first status this application ever pushed", and enrolment precedes first push,
so the anchor shifts earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CheckSubject names the 18 checks that describe the box rather than the workload
on it, and ingest grades and files each check at its own grain from one unified
payload. Whole names, never prefixes: caddy_version is the box's while
caddy_certs is the workload's, and ips is the box's addresses while ips_errors
is a Tamanu error stream. Anything unrecognised is the application's.

All five scouted traps were real:

- raise_machine_event_with_state now takes the source. Its group and
  canopy-wide siblings assume canopy because they file what canopy determines
  for itself; a machine's checks come from alertd, and recording them under
  canopy would break per-source silences, the check_severities a push answers
  with, source staleness, and same-source recovery.
- The debug assertion rejecting non-canopy filings outside application scope
  now admits a machine's.
- silenced_health_checks_for_server covers the machine grain, so a silence on a
  machine check reaches the agent instead of holding only on canopy's side.
- enqueue_incident_reeval keys on the application, so the machine path
  evaluates its incident inline.
- Recovery bookkeeping is per grain. One shared previously-active set would
  make a check that moves grain read as unmentioned on the grain it left,
  closing and reopening it every push.

The machine filing path carries device_id, so a machine issue records which
reporter filed it.

The detail-field split is now its own plan step: every figure read goes through
server_reported_detail, and osName/osVersion/munin/bestoolVersion are
machine-subject but read from the application's row, so the table split and the
figure reads have to move together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
server_reported_detail becomes application_reported_detail, and
machine_reported_detail joins it. Existing rows are split in place: the box's
fields move to its machine and come out of the application's body, so each fact
is stored once. A host running two workloads records its platform once rather
than once per workload.

Reads did not change, which is what made this tractable. for_server returns an
application's own detail merged with its machine's, so every figure consumer
sees the view it saw before; the storage is what moved. The plan's worry — that
every figure read goes through one table, with osName/osVersion/munin/
bestoolVersion machine-subject but read from the application's row — is
answered by merging on read rather than by moving the readers.

version stays with the application and has no machine counterpart: a version is
what the workload runs. The agent's own version is a detail field like any
other and goes to the box.

The field list lives beside the check-subject list in commons_types::subject,
since both answer the same question; the module is renamed from check_subject
to say so.

One regression introduced and fixed: merging on read made for_server error for
an application that no longer exists, where it used to return nothing. A
deleted application has no detail rather than being an error. An existing test
caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant