chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m52s

This commit is contained in:
2026-09-10 09:18:55 +00:00
parent b36a696d0e
commit 6ee203f5e9
172 changed files with 860 additions and 856 deletions
@@ -54,7 +54,7 @@ agent/internal/updates/
updates.go # PackageUpdate; CheckAvailable/ApplyAll declared once
updates_linux.go # existing detectPM, checkApt/DnfYum/Pacman/Zypper/Apk, ApplyAll
updates_windows.go # Windows Update COM, driven through PowerShell
updates_other.go # //go:build !linux && !windows no-ops
updates_other.go # //go:build !linux && !windows - no-ops
```
`updates_other.go` carries the build constraint for the same reason
@@ -116,7 +116,7 @@ A new field `reboot_required` on `InventoryReport`, added to
It travels on the inventory report rather than the update report because it is a
host property like the kernel version, and it is set on the **static** snapshot
only every 15 minutes rather than every 30 seconds. A host rebooted by hand
only - every 15 minutes rather than every 30 seconds. A host rebooted by hand
clears the flag in a quarter of an hour instead of showing it for up to a full
one, and the detection costs a PowerShell process on Windows, which is not
something to spawn twice a minute forever.
@@ -142,7 +142,7 @@ Both platforms set it, since parity is free here:
```
agent/internal/workloads/
workloads.go # Result, Collect, Hash Collect calls collectUnits
workloads.go # Result, Collect, Hash - Collect calls collectUnits
docker.go # unchanged, shared: shells to the docker binary
systemd_linux.go # was systemd.go
services_windows.go # new: Win32_Service collection
@@ -167,7 +167,7 @@ not responding, and running nothing.
### Collecting Windows services
`Get-CimInstance Win32_Service` converted to JSON not `Get-Service`, which
`Get-CimInstance Win32_Service` converted to JSON - not `Get-Service`, which
exposes neither `PathName` nor `StartMode`, and the filter needs both.
A service is reported when its executable does **not** resolve under
@@ -193,7 +193,7 @@ Field mapping:
`Kind: "unit"` and the existing `systemd_ok` / `systemd_error` fields are reused
rather than a `service` kind and `services_ok` fields being added. That would
cost a proto change, both pb copies, the server model, the service layer and the
web client, and would teach every existing consumer a second kind to describe
web client, and would teach every existing consumer a second kind - to describe
the same thing. The naming is corrected where it is read, in the UI, which knows
the server's OS.
@@ -206,8 +206,8 @@ The protected set stays computed and enforced agent-side, as it is on Linux: the
control plane may name a target, but the agent decides what it will do to
itself.
On Windows the protected workload is the `VantageAgent` service the NSSM
service name written by `installer/setup.ps1` matched case-insensitively,
On Windows the protected workload is the `VantageAgent` service - the NSSM
service name written by `installer/setup.ps1` - matched case-insensitively,
because Windows service names are. `detectOwnContainer` and its
`/proc/self/cgroup` read move to `control_linux.go`; the Windows build returns
no own-container ID.
@@ -257,13 +257,13 @@ rather than on `os_type`. `os_type` is stored and serialised but unread by
the two come to disagree. `WorkloadList` takes the result as a prop, since it
receives only a `serverId`:
1. `web/components/workloads/WorkloadList.tsx` takes an `isWindows` prop from
1. `web/components/workloads/WorkloadList.tsx` - takes an `isWindows` prop from
the server detail page, and the systemd status lines become
platform-worded. On Windows the error line reads "Windows services could not
be read" and the "systemd is not in use on this server" line is not rendered
at all. The empty-state line drops "on Linux only". The Docker lines are
unchanged.
2. Server detail a `Reboot required` pill beside the update count when the
2. Server detail - a `Reboot required` pill beside the update count when the
flag is set, placed with the update panel because that is what caused it.
3. The Updates panel's Windows copy describes a list of KB articles rather than
package upgrades, since `current_version` is empty on that platform.
@@ -272,7 +272,7 @@ receives only a `serverId`:
The Windows collectors are, in substance, parsers of PowerShell output. Parsing
is separated from invocation and table-tested against captured real output. The
`agent` module has no tests at all today, so these are the first they live
`agent` module has no tests at all today, so these are the first - they live
beside the parsers as ordinary `_test.go` files, run with `go test ./...` from
`agent/`, and need no new dependency:
@@ -292,6 +292,6 @@ service start/stop/restart, a protected refusal on `VantageAgent`, and logs on
both a chatty service and a silent one.
`GOOS=windows go build ./...` and `GOOS=linux go build ./...` both belong in the
implementation plan as explicit steps a build-tag split is exactly the change
implementation plan as explicit steps - a build-tag split is exactly the change
that compiles on the machine you are sitting at and nowhere else. CI already
cross-builds the agent on release, so no workflow change is needed.
@@ -36,7 +36,7 @@ instance:
Three things do not exist: any concept of a page, any operator-authored
incident, and any unauthenticated read path. The third is the constraint that
shapes the rest every route under `/api` carries `auth.Middleware`,
shapes the rest - every route under `/api` carries `auth.Middleware`,
`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where
it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with
no scope entry.
@@ -77,7 +77,7 @@ pages; a random identifier would be unguessable and unmemorable in equal
measure.
`published` exists so a page can be composed before anyone sees it. An
unpublished page answers the same 404 as a page that does not exist a
unpublished page answers the same 404 as a page that does not exist - a
distinct 403 would confirm it exists.
Sections are page-local and unrelated to `Monitor.Group`, which is a display
@@ -128,7 +128,7 @@ end and duration.
refused` lives.
Copying auto-incidents into `status_incidents` would be a second writer for the
same fact, arriving by a different route with its own opportunity to disagree
same fact, arriving by a different route with its own opportunity to disagree -
the same argument that keeps `RefreshWorkloadsCmd` from returning workloads
inline.
@@ -155,7 +155,7 @@ is private by default rather than published by accident.
What the snapshot contains, per entry: display name, current status, uptime
percentage over the last 90 days, and a 90-day history bar of one cell per day.
A cell is up, down, under maintenance, or no-data `no-data` for days before
A cell is up, down, under maintenance, or no-data - `no-data` for days before
the monitor existed, which is a distinct thing from a day it was down. No
latency, no addresses, no failure text.
@@ -167,7 +167,7 @@ GET /public/status/:pageId
Mounted on the gin root, not under `apiGroup`. Putting it under `/api` would
require exempting it from authentication, scope enforcement, token rate
limiting and the licence gate four holes, each one something a later change
limiting and the licence gate - four holes, each one something a later change
can widen. Outside `/api` it needs none of them.
The instance is resolved from the request host through `auth.InstanceFromHost`.
@@ -208,7 +208,7 @@ cache separately and two visitors would see different states during an incident.
### Rate limit
Per client address, one-minute fixed window, 120 requests, 429 with
`Retry-After` the same shape as `RateLimitTokens`, including its most
`Retry-After` - the same shape as `RateLimitTokens`, including its most
important property: **when Redis is unavailable, allow rather than deny.** A
status page must survive the outage it exists to report.
@@ -240,7 +240,7 @@ are required, not optional: `AssertScopeMapComplete` fails boot on an `/api`
route with no scope entry, which is exactly the safeguard working.
Handlers need `@…` annotations and `openapi.json` must be regenerated and
committed `server-deploy.yml` runs `git diff --exit-code` against the
committed - `server-deploy.yml` runs `git diff --exit-code` against the
committed copy, so a handler whose annotation drifted fails CI.
## Frontend
@@ -252,7 +252,7 @@ against the Go endpoint, with a client refresh every 60 seconds.
`web/next.config.ts` gains a `/public/:path*` rewrite so that client refresh
reaches the server.
The page stays dark, like the rest of `web/`, and carries no hex values the
The page stays dark, like the rest of `web/`, and carries no hex values - the
existing token palette covers every state it needs.
Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar
@@ -277,7 +277,7 @@ boundary made executable:
## Migration and rollout
No migration is needed both collections are new and absent means empty. Index
No migration is needed - both collections are new and absent means empty. Index
builders follow the `EnsureWorkflowIndexes` precedent and warn rather than being
fatal: a missing index on a small collection degrades to a scan, which is no
reason to refuse to serve the fleet.
@@ -6,8 +6,8 @@ Status: approved, ready for implementation planning
## Problem
Vantage has no backup story. A self-hosted deployment holds its entire state in
MongoDB and encrypts the sensitive half of it SSH private keys, key
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials with
MongoDB and encrypts the sensitive half of it - SSH private keys, key
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials - with
AES-256-GCM under a single 32-byte key supplied as the `KEY_ENCRYPTION_KEY`
environment variable.
@@ -48,7 +48,7 @@ such assertion available, so a second hand-maintained registry would drift
silently and the first symptom would be a restore missing a collection nobody
noticed was added.
`--exclude` accepts collection names for the volume-heavy ones
`--exclude` accepts collection names for the volume-heavy ones -
`workflow_log_lines`, `monitor_samples`, `audit_logs`. Whatever is excluded is
recorded in the manifest, so an archive can never claim to be complete when it
is not.
@@ -72,7 +72,7 @@ into the server binary.
cobra command tree and nothing else. A separate module rather than a package
under `shared/` because adding cobra to `shared/go.mod` would put cobra and
pflag into the module graph of `server`, `admin` and `sitesvc`, none of which
use them. Binaries are unaffected Go links only what is imported but three
use them. Binaries are unaffected - Go links only what is imported - but three
`go.sum` files would grow and three CI builds would fetch a dependency they do
not need. `agent/` is already a separate module for the same reason.
@@ -80,7 +80,7 @@ The tool imports nothing from `server/`. No `db.Col()`, no `services`, no config
loader, and it never dials the REST or gRPC API. It needs only network reach to
MongoDB, a database name, and `KEY_ENCRYPTION_KEY` in its own environment. This
is what lets it run against a control plane that is down, half-migrated, or was
deleted an hour ago which is the only condition under which anyone runs a
deleted an hour ago - which is the only condition under which anyone runs a
restore.
### Dump implementation
@@ -149,7 +149,7 @@ environment:
- Fingerprints differ: refuse, printing both.
- Archive has a fingerprint, environment has no key: refuse.
- `--ignore-key-mismatch`: proceed, having first printed exactly which
collections hold ciphertext that will be undecryptable `keys`, `secrets`,
collections hold ciphertext that will be undecryptable - `keys`, `secrets`,
`auth_providers`, `console_sessions`, `settings`.
### Restore semantics
@@ -169,7 +169,7 @@ The order is fixed:
Restore is not idempotent, and says so. A second run without `--force` is
refused because step 4 now finds data. A restore interrupted during step 5
leaves a partial database that the next run refuses to touch correct, because
leaves a partial database that the next run refuses to touch - correct, because
the alternative is a silent merge. There are no merge or upsert semantics at
all: merging two control planes reconciles nothing and produces a fleet that
half works, and upserting by `_id` resurrects rows deleted since the backup,
@@ -178,8 +178,8 @@ costume of a convenience.
Index replay is fatal per collection when a unique index fails to build, and a
warning when a non-unique one does. A unique index that cannot be created means
the restored data violates it, and the unique indexes here `(instance_id,
email)`, instance slug, settings instance, the ESO token hash are
the restored data violates it, and the unique indexes here - `(instance_id,
email)`, instance slug, settings instance, the ESO token hash - are
tenant-isolation properties rather than optimisations. The failure names the
offending index.
@@ -187,7 +187,7 @@ offending index.
Restore under `--force` requires a typed confirmation when stdin is a TTY.
When stdin is not a TTY a Kubernetes Job, a CI step, a cron entry the
When stdin is not a TTY - a Kubernetes Job, a CI step, a cron entry - the
confirmation comes from `--confirm-db <name>`, whose value must equal the
resolved target database name or restore refuses. Naming the database in the
argument means a copy-pasted restore command carries its intended target with
@@ -219,13 +219,13 @@ system; this tool reads no configuration file, and pulling it in to call
`os.Getenv` would make the largest dependency in the binary the one doing the
smallest job.
`inspect` prints the manifest when the archive was made, by what version,
`inspect` prints the manifest - when the archive was made, by what version,
which collections it holds, how many documents, what was excluded, and the key
fingerprint and contacts no database. It is what an operator runs to find out
fingerprint - and contacts no database. It is what an operator runs to find out
whether an archive they have found is worth anything.
`verify` adds a live check: whether the archive's fingerprint matches the key in
the current environment, and when `--mongo-uri` is given whether that key
the current environment, and - when `--mongo-uri` is given - whether that key
actually decrypts the target database. The second half is a probe: read one
ciphertext field from `secrets`, `keys` or `auth_providers` and attempt to open
it. A fingerprint comparison proves two archives agree; only a probe proves the
@@ -235,8 +235,8 @@ the documentation recommends running it on a schedule.
The probe needs AES-256-GCM open, which today lives in
`server/internal/services/crypto.go` and cannot be imported from another module.
Rather than copy it the exact hazard `CLAUDE.md` names around mirrored token
blocks and `web/lib/targets.ts` the primitives move to a new `shared/cryptobox`
Rather than copy it - the exact hazard `CLAUDE.md` names around mirrored token
blocks and `web/lib/targets.ts` - the primitives move to a new `shared/cryptobox`
package, and `services/crypto.go` becomes a thin delegation that keeps its
existing unexported function names and its `KEY_ENCRYPTION_KEY` lookup. One
implementation of the cipher, two callers.
@@ -255,11 +255,11 @@ Kubernetes, or neither.
`linux/arm64`, `darwin/arm64` and `windows/amd64` with `CGO_ENABLED=0`, writes
`checksums.txt`, and creates a Gitea release.
**Container image.** `vantagectl/Dockerfile` the repo's convention is a
**Container image.** `vantagectl/Dockerfile` - the repo's convention is a
Dockerfile per module built from the repository root, because every Go module
depends on `shared` through a replace directive produces a `scratch`
depends on `shared` through a replace directive - produces a `scratch`
image holding the static binary and an explicitly copied `/tmp`, which the
archive is staged in before compression the same omission that silently
archive is staged in before compression - the same omission that silently
disabled `vulnsched` on a scratch image. Pushed by `server-deploy.yml` as an
eighth image.
@@ -281,8 +281,8 @@ Restore in Kubernetes is the same image run as a one-shot `Job`. The chart ships
no restore manifest: a restore is an operator decision with a confirmation
attached to it, and must never be something a `helm upgrade` can trigger.
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row
`vantagectl/`, `shared/`, `go.work` which makes `shared/` fan out to four Go
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row -
`vantagectl/`, `shared/`, `go.work` - which makes `shared/` fan out to four Go
images rather than three. That table is already called out in `CLAUDE.md` as a
place where a missed entry ships a stale image.
@@ -294,8 +294,8 @@ variable that skips when unset.
Required cases:
- Round trip: seed one document of every awkward BSON type `ObjectId`,
`Decimal128`, `DateTime`, binary, null, nested arrays back up, restore into
- Round trip: seed one document of every awkward BSON type - `ObjectId`,
`Decimal128`, `DateTime`, binary, null, nested arrays - back up, restore into
a second database, assert byte-equal BSON.
- A single corrupted byte in a `.bson` member causes restore to refuse before
writing anything.
@@ -317,7 +317,7 @@ Fingerprint computation is a pure function and is tested without a database.
- A restore drill: restore into a scratch database and run `verify`, because an
untested backup is a hypothesis.
- What is not covered: Redis sessions, the vulnerability database (re-pulled
automatically), and agent state on managed servers agents reconnect on their
automatically), and agent state on managed servers - agents reconnect on their
own and `servers.agent_token_hash` is in the backup, so no re-enrolment is
needed.
@@ -5,8 +5,8 @@ Date: 2026-09-08
## Goal
Expose Vantage to LLM agents as a first-class tool surface, so that an agent
acting for a user can answer questions about the fleet and when explicitly
permitted act on it, under the same identity, scopes, licence and audit trail
acting for a user can answer questions about the fleet and - when explicitly
permitted - act on it, under the same identity, scopes, licence and audit trail
as every other API caller.
Concretely: a user mints a Vantage API token, points Claude (or any MCP client)
@@ -67,8 +67,8 @@ unchanged.
The design principle throughout: **MCP is a presentation layer over the service
layer, and introduces no new authority.** It calls the same service functions
the REST handlers call, and every decision about who may do what is made by
machinery that already exists. Where MCP needs something new tag-scoped
tokens that thing is built as a general capability of the API, not as an MCP
machinery that already exists. Where MCP needs something new - tag-scoped
tokens - that thing is built as a general capability of the API, not as an MCP
feature.
Three independent gates gate every tool call, and all three must pass:
@@ -84,16 +84,16 @@ Three independent gates gate every tool call, and all three must pass:
them, `ScopeSatisfied` already implements write-implies-read, and the token
creation UI advertises them without modification.
A bespoke `mcp:use` scope was rejected. The vocabulary is deliberately uniform
every resource has exactly `:read` and `:write` and one special-cased action
A bespoke `mcp:use` scope was rejected. The vocabulary is deliberately uniform -
every resource has exactly `:read` and `:write` - and one special-cased action
verb would be the first exception in a table whose value is having none.
The meanings:
- **`mcp:read`** the token may reach `/api/mcp` at all. A token without it is
- **`mcp:read`** - the token may reach `/api/mcp` at all. A token without it is
not an agent token, whatever else it holds. Read tools are listed and callable
subject to their own resource scopes.
- **`mcp:write`** write tools are listed and callable, again subject to their
- **`mcp:write`** - write tools are listed and callable, again subject to their
own resource scopes. Implied by the existing rule when a token holds
`mcp:write`, so `mcp:read` need not be requested separately.
@@ -120,7 +120,7 @@ TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,
Validated on creation by the existing `services.ValidateTags`, so a token
selector cannot express a tag a server could never carry. A caller may only
create a token whose selector is at least as narrow as their own the same
create a token whose selector is at least as narrow as their own - the same
rule `ScopeSatisfied` already enforces for scopes, applied to tags.
`auth.Session` carries `TagSelector`, populated in `sessionFromToken` and always
@@ -166,13 +166,13 @@ console and OIDC being opt-in per customer.
Enforced in three places:
1. **Route** `RequireFeature(license.FeatureMCP)` on the `/api/mcp` group,
1. **Route** - `RequireFeature(license.FeatureMCP)` on the `/api/mcp` group,
answering the standard `feature_unavailable` 403.
2. **Token minting** creating a token with `mcp:read` or `mcp:write` is
2. **Token minting** - creating a token with `mcp:read` or `mcp:write` is
refused without the feature. A licence downgrade should not leave live agent
credentials that fail confusingly mid-conversation, and the same
guard-at-source thinking is already in `services/packages.go`.
3. **UI** the token form's MCP scopes and the MCP connection panel are hidden
3. **UI** - the token form's MCP scopes and the MCP connection panel are hidden
when the licence does not grant it, as console is today.
Existing tokens are unaffected: absent the new scopes, no token can reach the
@@ -185,7 +185,7 @@ returns either a JSON response or an SSE stream. The transport is stateless
rather than session-resuming precisely so each request can stand alone and
sit behind ordinary request middleware with no special-casing, and that
stateless mode leaves no session for a server-to-client stream to resume
against so `GET /api/mcp` is registered but answers the protocol's 405
against - so `GET /api/mcp` is registered but answers the protocol's 405
rather than opening a stream. A client probing the endpoint therefore learns
"POST-only here" rather than seeing a bare 404, which is what the MCP spec
expects from a server that does not offer the GET/SSE leg.
@@ -197,7 +197,7 @@ duplicate auth and double every request's cost for no benefit.
`routeScopes` gains `POST /api/mcp` and `GET /api/mcp`, both mapped to
`mcp:read`, satisfying `AssertScopeMapComplete`. Per-tool scope enforcement
happens inside the handler, because one route serves many operations this is
happens inside the handler, because one route serves many operations - this is
the first route where the route-level scope is a floor rather than the whole
answer, and the map entry's comment says so.
@@ -275,7 +275,7 @@ someone, so they carry extra rules on top of the ordinary write gates:
tell at a glance what a model wrote. Workflows and monitors get the same
treatment through their audit event rather than a new field.
- **Script validation.** `create_step` runs the same parse and scan the existing
step-create route runs (`services.CreateStep` already does this) an agent
step-create route runs (`services.CreateStep` already does this) - an agent
gets no laxer a path than the UI.
## Audit
@@ -292,14 +292,14 @@ instance".
Event type `mcp.tool_call`; actor is the token name, as REST token actions
already record; detail is the tool name, a compact argument summary, and the
number of servers affected. Failures record `mcp.tool_denied` with the gate that
refused licence, MCP scope, resource scope, or tag selector which is what
refused - licence, MCP scope, resource scope, or tag selector - which is what
turns "the agent said it couldn't" into a diagnosable event.
Arguments are summarised, never dumped verbatim: an argument could carry
arbitrary text from a model, and the audit log is read by humans in a UI.
A chatty agent can produce many events. If that becomes a problem the throttle
pattern already used for `token.expired_use` applies, but v1 records everything
pattern already used for `token.expired_use` applies, but v1 records everything -
under-recording a new and sensitive surface is the worse failure.
## Errors
@@ -310,7 +310,7 @@ workflows:write". The agent must be able to read the refusal and adapt or tell
its user, and a transport-level failure is invisible to the model.
Out-of-scope hosts are not-found, matching the REST rule. Upstream service
errors are summarised a raw Mongo error is neither useful to a model nor safe
errors are summarised - a raw Mongo error is neither useful to a model nor safe
to expose.
## HQ, catalogue and Paddle
@@ -321,7 +321,7 @@ to expose.
more `KindFeature` row at `ScopeShared`, sold by every paid plan at one price.
`SeedCatalogue` is `$setOnInsert` only, so the row appears empty on deploy and
staff-entered price IDs are never blanked. The comment naming the row count
("nine rows") is updated the file explicitly asks the next person to keep that
("nine rows") is updated - the file explicitly asks the next person to keep that
number deliberate.
`catalogue.LineItems` needs no change: a `KindFeature` row the customer selected
@@ -349,7 +349,7 @@ One product, two prices, created in the sandbox environment first:
| Field | Value |
| --- | --- |
| Product name | Vantage Agent Access (MCP) |
| Product name | Vantage - Agent Access (MCP) |
| Description | AI agent access to a Vantage instance over the Model Context Protocol |
| Tax category | `standard` |
| Currency | GBP |
@@ -362,7 +362,7 @@ rows use.
Creation runs through the connected `paddle-sandbox` MCP server during
implementation, with the exact payload confirmed before each call. The resulting
price IDs are recorded in the catalogue row's `price_ids.sandbox` map through
the existing staff pricing page not by a migration, because that page is the
the existing staff pricing page - not by a migration, because that page is the
only place price IDs are meant to be entered and a migration writing them would
be a second source of truth.
@@ -379,8 +379,8 @@ the licence grants the feature:
- A short client configuration snippet, again copyable.
- A link to the docs page.
The token creation form gains the two MCP scopes in its scope list no special
UI, they are ordinary scopes and a **tag restriction** field, which is shown
The token creation form gains the two MCP scopes in its scope list - no special
UI, they are ordinary scopes - and a **tag restriction** field, which is shown
for every token regardless of licence because tag scoping is not gated. The
field offers the tag keys and values already in use on servers, as the workflow
target selector does.
@@ -418,7 +418,7 @@ is the thing that must stay correct as tools are added:
- **Audit.** A successful call and a refused call each write exactly one event
of the expected type.
- **Response size.** `list_servers` over a seeded fleet stays under a stated
byte budget a regression here degrades every agent interaction and is
byte budget - a regression here degrades every agent interaction and is
otherwise invisible.
`services/statuspages_test.go` is the style model.