Compare commits

...

486 commits

Author SHA1 Message Date
rcourtman
47f2bdaed4 Add Go branch-coverage tests for pure config and helper packages
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Adds table-driven branch-coverage unit tests for previously untested pure
helper functions across internal/alerts/config, internal/config,
internal/ai/safety, internal/ai/modelresolution, internal/operationreceipt,
internal/models, internal/securityutil and pkg/securityutil. New test files
only, with no source changes.

Covers alert-config normalization and validation, sensitive-path and
redaction classifiers, URL normalizers, provider model resolution,
operation-receipt decoding, credential masking, and account-to-org role
mapping. 57 TestBranchCov functions in 12 files, all vet and gofmt clean.
2026-07-16 10:19:51 +01:00
rcourtman
4dcba3a1d8 Name the actual OIDC token verification failure on the login page
Every ID token verification failure rendered the issuer-mismatch
advice, sending users with audience or clock problems down the wrong
path and telling users with a genuine issuer mismatch nothing they had
not already checked (#1533). Map the distinct verification failures
(issuer, audience, expiry) to their own error codes and give each
accurate login-page copy pointing at the server log's got/want detail.
2026-07-16 10:15:42 +01:00
rcourtman
506d2e5b7e Serve agent config from continuity state during reload windows
A full monitor reload tears the state down and rebuilds it empty, so
agent-reported host rows vanish until each agent's next report lands.
Config fetches in that window 404ed with a perfectly valid token, which
showed up as rare correlated agent_config_fetch failures across
unrelated agents (#1570).

When the live snapshot has no match, resolve the host from the
persisted continuity store with the same semantics as the live path. A
report-scoped token resolves only its bound host and manage-scoped
tokens resolve by host ID. Deliberately removed hosts stay 404 because
removal deletes their continuity entry.
2026-07-16 10:13:14 +01:00
rcourtman
cdae56df0f Align qualification wait with subscription preflight 2026-07-16 10:05:20 +01:00
rcourtman
b84f650e77 Allow bounded subscription-agent preflights 2026-07-16 09:54:34 +01:00
rcourtman
2bc48774c2 Let deleted hosts re-enroll with a freshly generated token
Deleting a host writes a machine-id-keyed removal block that rejected
every future report with HTTP 400, and the error pointed at an Allow
reconnect control that is not wired into the UI, leaving the machine
permanently unable to enroll without changing its machine-id (#1581).

Three holes made the block effectively immortal:
- The 24h TTL sweep only iterated the in-memory removal maps, which
  reset on every restart, so persisted blocks never expired. Sweep the
  persisted entries by their own RemovedAt for host agents, Docker
  hosts, and Kubernetes clusters.
- AllowHostAgentReenroll (and the Docker and Kubernetes equivalents)
  bailed out when the ID was missing from the in-memory map, so even
  the API escape hatch stopped clearing persisted blocks after a
  restart. Check and clear the persisted store independently.
- A report presenting an API token created after the removal is
  explicit re-add intent (the user generated a fresh install command),
  so clear the block and accept it. A still-running old agent keeps
  presenting its pre-removal token and stays blocked.

Also reword the rejection to describe the two working recovery paths
instead of the unwired Settings control.
2026-07-16 09:50:04 +01:00
rcourtman
5867d439a3 Reconcile recovery points against source enumerations
The recovery store was upsert-only: backups and snapshots deleted at
the source lingered as recovery_points rows until the 90-day retention
prune. ListRollups kept returning a rollup with a frozen LastSuccessAt,
so the backup-age alert for a deleted guest re-raised every poll cycle
and acknowledging or clearing it could never stick (#1580).

Each backup poll already publishes a complete per-instance enumeration
(partial failures early-return or carry previous entries forward), so
attach a reconcile scope to that ingest batch. After the upsert, points
in the scope (provider + id class + instance) that were not part of the
enumeration are deleted, which lets the existing per-cycle alert sweep
resolve the alert. An empty enumeration is meaningful and clears the
scope, covering the delete-all-backups case from the report.

Also make the async ingest queue batches instead of overwriting the
single pending slot, which silently dropped a full poll cycle whenever
two sources coalesced behind an active batch.
2026-07-16 09:38:53 +01:00
rcourtman
99cb04fb89 Reserve Patrol subscription timeout qualification 2026-07-16 09:35:30 +01:00
rcourtman
4bd9b0c079 Show timezone on alert email start times
Alert start times are stored in UTC and the email templates rendered
them with no zone conversion or label, so the Started line read as a
local clock while showing UTC (#1582). Convert to the server's local
zone and include the zone name.
2026-07-16 09:29:01 +01:00
rcourtman
c70431caaf Honor configured availability poll interval in the scheduler
An availability target's configured poll interval only seeded the
adaptive scheduler: BuildPlan derived every instance's cadence from the
global adaptive bounds, and a failing probe raised the staleness score
and error penalty, collapsing the probe interval toward the global
5-second minimum. With interval 120s and failure threshold 4 the alert
was promised after ~8 minutes of downtime but fired within the first
minute because the four consecutive failures accumulated at the
collapsed cadence (#1582).

Availability checks promise pollInterval x failureThreshold as the
detection window, so the cadence is a user contract, not a scheduling
hint. Add a FixedIntervalPollProvider extension that pins an instance
to its configured interval, implement it for availability targets, and
bypass adaptive selection wherever the next run is computed (plan
building, rescheduling, and the non-adaptive fallback paths).
2026-07-16 09:26:53 +01:00
rcourtman
4421945e9b Normalize v prefix in install.ps1 agent version check
The Windows installer compared the downloaded agent's --version output
(v6.0.5) against the server's /api/version value (6.0.5) literally, so
every matching install still warned about a version mismatch (#1527).
Strip the leading v from both sides before comparing, matching what
install.sh already does.
2026-07-16 09:16:51 +01:00
rcourtman
84d43e16e4 Release Patrol reporting reliability claim 2026-07-16 07:27:26 +01:00
rcourtman
362b96dbca Repair rejected Patrol finding batch siblings 2026-07-16 07:22:13 +01:00
rcourtman
b397896be3 Harden Patrol multi-finding report guidance 2026-07-16 06:42:53 +01:00
rcourtman
7a8a090d37 Harden Patrol multi-finding report guidance 2026-07-16 06:40:17 +01:00
rcourtman
2614c39270 Reserve Patrol reporting reliability work 2026-07-16 06:32:42 +01:00
rcourtman
513243314a Release repeated restart evidence claim 2026-07-15 21:22:30 +01:00
rcourtman
c527405f16 Reject invalid Patrol reconfirmation prerequisites 2026-07-15 21:20:56 +01:00
rcourtman
f8aac166b6 Treat repeated container restarts as actionable evidence 2026-07-15 20:42:17 +01:00
rcourtman
6921ea40bc Reserve repeated restart evidence claim 2026-07-15 20:36:38 +01:00
rcourtman
15a6990da4 Release grounded Patrol finding claim 2026-07-15 19:35:39 +01:00
rcourtman
e9b23455ba Prevent Patrol from echoing untrusted instructions 2026-07-15 18:59:40 +01:00
rcourtman
473d5ed1b5 Require grounded actionable Patrol findings 2026-07-15 18:44:33 +01:00
rcourtman
960892efa3 Reserve actionable Patrol finding claim 2026-07-15 18:36:38 +01:00
rcourtman
7c21b670ac Release authoritative Docker OOM alert claim 2026-07-15 17:57:15 +01:00
rcourtman
e3382c8bcb Use authoritative Docker OOM evidence 2026-07-15 17:55:26 +01:00
rcourtman
e499f92360 Reserve authoritative Docker OOM alert claim 2026-07-15 17:38:35 +01:00
rcourtman
2b44f2b0b8 Release Patrol qualification confidence claim 2026-07-15 17:36:40 +01:00
rcourtman
a39150ba4c Make qualification profiles statistically passable 2026-07-15 17:34:57 +01:00
rcourtman
bb12f45ffb Reserve Patrol qualification confidence claim 2026-07-15 17:29:54 +01:00
rcourtman
b27a97491b Release Patrol evidence continuity claim 2026-07-15 17:23:56 +01:00
rcourtman
3bf6d7d8f8 Renew Patrol evidence continuity claim 2026-07-15 15:51:46 +01:00
rcourtman
d307fe8869 Preserve Patrol investigation evidence continuity 2026-07-15 15:43:18 +01:00
rcourtman
9e3765e19d Reserve Patrol evidence continuity claim 2026-07-15 15:32:01 +01:00
rcourtman
1eb736e359 Add branch-coverage tests for provider tool-artifact leak detectors
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
New table-driven tests raise branch coverage on
SplitTrailingProviderToolNamePrefix and the JSON and plain function
tool-call leak-index helpers, covering empty content, no-alnum tails,
prefix-hold versus pass-through and regex no-match paths. Test-only.
2026-07-15 15:11:02 +01:00
rcourtman
b6ed899ad5 Add branch-coverage tests for provider context and tool-choice helpers
New table-driven tests raise branch coverage on ContextWindowTokens,
extractModelName, isDigits, rateLimitInfo, normalizeOpenAICompatibleChatURL,
stop-reason normalization and the OpenAI, Anthropic and Gemini tool-choice
converters, covering date-suffix stripping, malformed URLs and default arms.
Test-only, no source changes.
2026-07-15 15:08:59 +01:00
rcourtman
c775eed739 Add branch-coverage tests for qualification scorer and report helpers
New table-driven tests raise branch coverage on applyGates, ApplyProTrackGates,
findFault, bestFindingMatch, validatePredicates, ApplyQualificationGates,
canonicalToolInput, sanitizeArtifactText and allObservationsPassed, covering
previously uncovered gate arms, grounding misses, redaction and JSON edge
cases. Test-only, no source changes.
2026-07-15 15:04:26 +01:00
rcourtman
7da4890ce9 Add branch-coverage tests for AI tools command classifiers
New table-driven tests raise branch coverage on the timeout, curl/wget,
env and recovery-point canonicalization helpers in internal/ai/tools,
exercising previously uncovered flag-parsing arms, mutation-method
detection and numeric-detail type cases. Test-only, no source changes.
2026-07-15 15:02:19 +01:00
rcourtman
6901aae881 Release Patrol remediation qualification claim 2026-07-15 12:26:04 +01:00
rcourtman
580093155f Resolve canonical Patrol discovery targets 2026-07-15 12:07:42 +01:00
rcourtman
87699dfff3 Align remediation qualification with advertised actions 2026-07-15 11:31:56 +01:00
rcourtman
dda79547b6 Reserve governed Patrol remediation qualification claim 2026-07-15 11:22:53 +01:00
rcourtman
2eef85e437 Release model-led Patrol investigation claim 2026-07-15 11:08:07 +01:00
rcourtman
03ae7d2b58 Score investigation facts without magic words 2026-07-15 10:56:12 +01:00
rcourtman
cdc109d408 Publish Patrol investigation limits live 2026-07-15 10:49:16 +01:00
rcourtman
a95edafcf1 Qualify model-led Patrol investigations 2026-07-15 10:32:16 +01:00
rcourtman
c1bd9876a2 Reserve model-led Patrol investigation claim 2026-07-15 10:13:46 +01:00
rcourtman
23bd39990c Release Patrol qualification suite route claim 2026-07-15 10:07:17 +01:00
rcourtman
a717790e90 Pin Patrol qualification routes per suite 2026-07-15 09:46:53 +01:00
rcourtman
80e75e8c9d Reserve Patrol qualification suite route claim 2026-07-15 09:30:43 +01:00
rcourtman
fdd7b7082a Release production Stripe remediation claim 2026-07-15 09:26:53 +01:00
rcourtman
0fda77b008 Record passing production commercial audit 2026-07-15 09:24:39 +01:00
rcourtman
ede7aa1249 Release Patrol canonical resource identity claim 2026-07-15 09:21:56 +01:00
rcourtman
2774744b11 Preserve canonical resource IDs in Patrol queries 2026-07-15 09:16:39 +01:00
rcourtman
d407bec47f Record passing production Stripe remediation 2026-07-15 09:14:24 +01:00
rcourtman
b9ead8c7fe Reserve Patrol canonical resource identity fix 2026-07-15 09:07:44 +01:00
rcourtman
5d162305d8 Claim production Stripe commercial remediation 2026-07-15 08:39:52 +01:00
rcourtman
f09b897e01 Release Patrol subscription preflight work claim 2026-07-15 01:28:18 +01:00
rcourtman
a8e42fc7fe Preserve coding-plan provenance in qualification 2026-07-15 00:06:56 +01:00
rcourtman
61ef640a51 Normalize subscription models before CLI execution 2026-07-14 23:45:10 +01:00
rcourtman
c07b8a0ef4 Reserve Patrol subscription preflight normalization 2026-07-14 23:40:37 +01:00
rcourtman
f03b0c01e7 Add branch-coverage tests for tools canonical query helpers
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
New white-box table tests over the pure resource-resolution helpers in
internal/ai/tools/current_resource.go, taking canonicalQueryTypeForResolvedResource,
canonicalQueryIDForResolvedResource and resolvedResourceKindMatchesLocation from
partial to full branch coverage. Every kind switch arm, the provider-uid then
resource-id then alias fallback chain, and the nil guards are pinned to exact
output.

One file, tests only, no source changes. Gates green with go test, gofmt and go
vet, plus an adversarial GLM review scoring three KEEP and zero reject.
2026-07-14 23:38:09 +01:00
rcourtman
63442b7fe4 Add branch-coverage tests for patrol runtime failure classifiers
New white-box table tests over the pure error classifiers in
internal/ai/patrol_runtime_failure.go, taking ClassifyProviderConnectionFailure
from 18 percent to full coverage and closing the residual branches in
patrolRuntimeFailureFromError and summarizePatrolRuntimeFailureDetail. Every
reachable switch arm is pinned to its exact diagnostic output, and the four
grouped causes that only preflight and readiness state can set are documented
as out of reach from a plain error input rather than faked.

One file, tests only, no source changes. Gates green with go test, gofmt and
go vet, plus an adversarial GLM review scoring two KEEP, one WEAK and zero
reject.
2026-07-14 23:23:14 +01:00
rcourtman
ab4b29b9ee Release subscription model route work claim 2026-07-14 23:16:34 +01:00
rcourtman
89a7b88093 Distinguish subscription allowances from API spend 2026-07-14 23:12:37 +01:00
rcourtman
99615c18e3 Support Patrol streaming on subscription routes 2026-07-14 23:05:28 +01:00
rcourtman
917a9e5421 Add local subscription model routes 2026-07-14 22:57:38 +01:00
rcourtman
d0d8426cb9 Add branch-coverage tests for qualification contribution helpers
New white-box table tests over the pure validators, predicates and readme
renderer in internal/ai/qualification/contribution.go, covering
ValidateContributionChallenge, validateContributionIdentity,
everyRunChallengeBound, observationsPassedOrEmpty, reportPhasePassed,
ContributionBundle.Validate and renderContributionReadme.

One file, tests only, no source changes. Gates green with go test, gofmt
and go vet, plus an adversarial GLM review scoring seven KEEP and zero
reject.
2026-07-14 22:50:13 +01:00
rcourtman
280a44ef08 Add branch-coverage tests for AI patrol, qualification, tools and agentcapability helpers
New white-box table tests over previously-untested pure functions in
internal/ai (patrol run recency, patrol findings JSON validation),
internal/ai/tools (read-only violation hints, VM config parsing, node
target matching), internal/ai/qualification (compare predicate,
percentile and model summary, runner helpers), internal/ai/chat
(investigation run error, session compaction formatting) and
internal/agentcapabilities (path parameter substitution, markdown
helpers).

Twelve files, tests only, no source changes. All gates green with go
test, gofmt and go vet, plus an adversarial GLM review scoring twelve
KEEP and zero reject.
2026-07-14 22:33:40 +01:00
rcourtman
3be3809dfc Claim Patrol subscription-agent bridge work 2026-07-14 22:21:00 +01:00
rcourtman
cbe2b66915 Release Patrol community qualification claim 2026-07-14 22:14:46 +01:00
rcourtman
538c1baaef Add community Patrol qualification exports 2026-07-14 22:08:32 +01:00
rcourtman
4ee570bd54 Release Stripe commercial remediation claim 2026-07-14 21:51:01 +01:00
rcourtman
051a3c1822 Claim Patrol community qualification work 2026-07-14 21:48:58 +01:00
rcourtman
d2bcfac2f6 Govern Stripe commercial remediation preparation 2026-07-14 21:46:32 +01:00
rcourtman
c11bf5c757 Claim Stripe commercial remediation preparation 2026-07-14 21:37:29 +01:00
rcourtman
5d506d69a0 Release Patrol final lifecycle turn work 2026-07-14 21:32:30 +01:00
rcourtman
b540025ef1 Reserve Patrol final finding decision turn 2026-07-14 21:07:49 +01:00
rcourtman
f80439501b Claim Patrol final lifecycle turn work 2026-07-14 20:59:27 +01:00
rcourtman
4efcf8b218 Release production Stripe proof claim 2026-07-14 20:54:41 +01:00
rcourtman
f2ac5f63f8 Record blocked production commercial audit 2026-07-14 20:52:31 +01:00
rcourtman
5ac8357da3 Claim production Stripe read-only proof 2026-07-14 20:41:27 +01:00
rcourtman
7b520abbaa Release Patrol Watch evidence work claim 2026-07-14 20:34:30 +01:00
rcourtman
3487b9a98e Require collected negative control convergence 2026-07-14 20:14:35 +01:00
rcourtman
960e9f5e89 Bound Patrol finding verbosity 2026-07-14 19:58:03 +01:00
rcourtman
12f317364a Separate Watch restart detection from investigation 2026-07-14 19:51:53 +01:00
rcourtman
3f94a8f302 Preserve restart evidence in Patrol scope 2026-07-14 19:47:16 +01:00
rcourtman
6e71bcb151 Preserve resolved provider in qualification scoring 2026-07-14 19:38:25 +01:00
rcourtman
739b0c92d4 Align Patrol quiet-run tool contracts 2026-07-14 19:30:45 +01:00
rcourtman
9ea8cb2aa1 Clarify Patrol scope identities and finding reads 2026-07-14 19:23:36 +01:00
rcourtman
e008343b7a Align Docker host query schema and executor 2026-07-14 19:13:11 +01:00
rcourtman
3b1bc65b43 Separate Watch symptoms from injected fault targets 2026-07-14 19:02:32 +01:00
rcourtman
aa0f9ea5a7 Require collected fault convergence before Patrol 2026-07-14 18:56:06 +01:00
rcourtman
2b271e3e20 Repair disposable dependency qualification fixtures 2026-07-14 18:49:25 +01:00
rcourtman
44d8614330 Bound Patrol finding summary turns 2026-07-14 18:35:17 +01:00
rcourtman
d1e8aece7d Price reviewed OpenRouter qualification routes 2026-07-14 18:24:33 +01:00
rcourtman
090e6fde64 Separate finding writes from infrastructure verification 2026-07-14 18:19:03 +01:00
rcourtman
ee9d4b1071 Reserve Patrol reporting turn for confirmed symptoms 2026-07-14 18:10:00 +01:00
rcourtman
e0edc520b7 Release Stripe proof audit work claim 2026-07-14 18:07:53 +01:00
rcourtman
f6a289f72d Make Patrol report confirmed health failures 2026-07-14 18:04:09 +01:00
rcourtman
7f2c0380c6 Claim read-only Stripe proof audit 2026-07-14 17:59:51 +01:00
rcourtman
dc416dd9be Claim Patrol Watch evidence contract work 2026-07-14 17:49:52 +01:00
rcourtman
1f9410a10b Release commercial coherence work claim 2026-07-14 17:39:49 +01:00
rcourtman
f63a58b9a8 Release Patrol provider resilience work claim 2026-07-14 17:36:58 +01:00
rcourtman
728d09769c Preserve qualification artifacts for bounded transcripts 2026-07-14 17:31:05 +01:00
rcourtman
f1adb00d90 Govern license runtime commercial config 2026-07-14 17:29:05 +01:00
rcourtman
7deaf60b37 Harden Patrol provider streams and runtime scoring 2026-07-14 17:20:56 +01:00
rcourtman
206b68cc94 Clarify commercial offer boundaries 2026-07-14 17:18:14 +01:00
rcourtman
a8341b013f Open the shipped security guide outside the SPA router
Some checks failed
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Build and Test / Frontend & Backend (push) Waiting to run
Build and Test / Secret Scan (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Unified Agent Native Verification / Linux ARM64 (push) Has been cancelled
Unified Agent Native Verification / Linux x64 (push) Has been cancelled
Unified Agent Native Verification / Windows x64 (push) Has been cancelled
Unified Agent Native Verification / macOS ARM64 (push) Has been cancelled
Unified Agent Native Verification / macOS Intel (push) Has been cancelled
Unified Agent Native Verification / FreeBSD cross-build contract (push) Has been cancelled
The Security Overview configure-https action linked /docs/SECURITY.md
with external: false, so Solid Router intercepted the same-origin
anchor click and rendered the SPA NotFound page instead of letting the
browser fetch the embedded doc. Mark the link external like every
other shipped-doc consumer.

Refs discussion #1505
2026-07-14 17:13:50 +01:00
rcourtman
085f5abe0d Claim Patrol provider stream resilience work 2026-07-14 17:11:45 +01:00
rcourtman
8f31fec341 Release Patrol headless projection work claim 2026-07-14 17:08:43 +01:00
rcourtman
e56561b76a Refresh canonical resources after headless agent reports 2026-07-14 17:03:14 +01:00
rcourtman
a05e905381 Govern commercial transition convergence 2026-07-14 16:59:28 +01:00
rcourtman
e98eaebf43 Claim commercial transition coherence work 2026-07-14 16:47:56 +01:00
rcourtman
630481aeca Release Patrol qualification evidence work claim 2026-07-14 16:45:33 +01:00
rcourtman
93fff4f1d8 Add an explicit operator override for plaintext HTTP to non-local Pulse hosts
Fleets on networks numbered from nominally public IP space (issue
#1522: an AD estate on 192.20.0.0/16) cannot pass the agent's
local-network plaintext heuristic, and the only workaround was pointing
a .internal DNS alias at the server, which bypasses the same control
less visibly than a flag would. --allow-plaintext-http
(PULSE_AGENT_ALLOW_PLAINTEXT_HTTP) records process-wide consent once at
agent startup before any module validates a URL, covers every agent
transport including the websocket command channel, warns at startup
that the API token travels in cleartext, defaults closed, and is never
emitted by generated install commands or settable by the server.
2026-07-14 16:42:02 +01:00
rcourtman
84eff17578 Preserve Patrol evidence on provider errors 2026-07-14 16:34:19 +01:00
rcourtman
8e417010eb Release commercial invalidation work claim 2026-07-14 16:20:23 +01:00
rcourtman
ae4162f8f2 Enforce installation-scoped license invalidation 2026-07-14 16:18:21 +01:00
rcourtman
623000b933 Release Patrol runtime work claim 2026-07-14 15:37:36 +01:00
rcourtman
3f45953866 Complete Patrol autonomous qualification loop 2026-07-14 15:35:48 +01:00
rcourtman
e18b28a780 test(coverage): unifiedresources adapters and agentexec server-receipt branches
Add purely-additive branch-coverage tests for two 0%-covered backend surfaces
from the recent feature drop:
- internal/unifiedresources: unraidDiskMountTotal (role/mount-path resolution,
  trim-normalized mountpoint match, zero-total skip) and dockerStorageUsageMeta
  (four-operand nil guard, full field copy).
- internal/agentexec: Server.matchesPendingDockerUpdateOperation (identity /
  subject-id normalization / absent-key arms) and AgentOperationReceiptVersion
  (nil-receiver, not-found, stored-version arms).
No source changed.
2026-07-14 14:56:47 +01:00
rcourtman
4dcc18fbd4 Match nvme-eui zpool member references to physical disks
A pool built from /dev/disk/by-id/nvme-eui.<hex> references (the
installer's device naming when identical NVMe models share a box)
produced no serial key in the disk-to-pool matcher, and the WWN lookup
never stripped the eui. prefix smartctl reports, so the disk fell back
to the generic Proxmox usage string and showed 'ZFS' instead of its
pool name while an identically-built node showed 'local-zfs' (issue
#1540).
2026-07-14 14:48:30 +01:00
rcourtman
d7a73ee521 test(coverage): patrol investigation context model branch coverage
Add a purely-additive branch-coverage suite for the residual uncovered arms of
patrolInvestigationContextModel builders (assistant briefing/handoff inputs,
configuration-failure handoff secret redaction, investigation context summary
numeric clamping, proposed-fix keep/drop gating, approval-policy checks). The
existing dev test is left untouched; this exercises previously-unexecuted
exports and branch arms only. No source changed.
2026-07-14 14:48:01 +01:00
rcourtman
f5aeac590b Survive NAS installer environments without od and with Synology systemd
Two agent-install failures from stock NAS shells: QNAP ships no od, so
the ELF header sniff read empty and rejected a valid download (issue
#1572); Synology DSM 7's patched systemd cannot apply the unit's
sandbox directives and killed the service with
status=227/NO_NEW_PRIVILEGES before exec (issue #1578). The header
sniff now falls back od -> hexdump -> xxd and skips with a warning when
none exist (checksum verification still guards integrity), and the
systemd unit omits the sandbox hardening block on DSM.
2026-07-14 14:47:45 +01:00
rcourtman
e1985528d9 test(coverage): agentexec codecs and licensing service branch coverage
Add purely-additive branch-coverage tests for freshly-landed, previously-
untested backend surface:
- internal/agentexec: docker container update codec (decode/validate/bind/
  digest/result-cross-validation), residual host apt codec validation arms
  (host update + storage cleanup decode/bind/result/operation-query identity),
  and NormalizeDeployMaxParallel clamping.
- pkg/licensing: service helpers (unionFeatures, safeIntFromInt64,
  remainingDaysCeil, ensureGracePeriodEnd, Set/Get Evaluator+StateMachine,
  IsValid, CurrentUnsafeForTesting, dev-mode) and activation GrantEnvelope
  ParseExpiresAt + uncapped-core-monitoring tier classification.
No source changed.
2026-07-14 14:45:55 +01:00
rcourtman
f50bcce2dc Govern Relay commercial invalidation 2026-07-14 14:38:58 +01:00
rcourtman
acd5637485 Drive executing-action restart recovery at startup and agent registration
RecoverExecutingActions existed with full test coverage but had no
production caller, so any typed action mid-dispatch across a server
restart (container update, start/stop/restart, host update, storage
cleanup) stayed in the executing state forever and sat in the Actions
inbox as live work, even after the agent persisted its terminal durable
receipt. Reproduced live on the dev instance with a Docker container
update (act_bf77dfe860ad3d8e4e0a91dc8eb83b44).

The router now runs a bounded, serialized recovery pass per organization
from a startup background worker, and again whenever an agent
(re)registers on the agentexec command server via a new registration
notifier, because a receipt-pending attempt can only be reconciled while
the owning agent is connected. Both triggers reuse the existing
query-only reconciliation semantics; nothing gains a resend authority.

Task 07 owns this residual; the api-contracts and agent-lifecycle
subsystem contracts now record the production trigger. The
rg-07-durable-delivery gate suite stays green, and a new router-level
test pins that a receipt-pending executing action completes from the
agent receipt without a second dispatch.
2026-07-14 14:19:19 +01:00
rcourtman
1a2215c9b4 Refresh the action-plan contract snapshot for identity-hashed resource versions
098ba4eaa changed plan resource versions to hash relationship identity
instead of observation stamps, and updated the planner unit tests, but
missed the pinned API contract snapshot: its fixture carries a vm-to-node
relationship, so the resourceVersion (and the actionId, decisionId, and
planHash derived from it) legitimately changed. TestContract_ActionPlanJSONSnapshot
has been red on main since that commit; this re-pins the snapshot to the
deterministic values the new hashing produces.
2026-07-14 14:15:52 +01:00
rcourtman
68dd64e768 Drive the container Update button through the governed action review flow
The Update button on the Docker containers table and workloads rows now
plans an audited update action and opens the same governed review
dialog the lifecycle controls use (approve, run, per-action audit),
instead of calling the retired direct-update endpoint. The host
drawer's bulk update-all reports honestly that updates run as reviewed
per-container actions until a reviewed bulk flow exists, and the dead
updateDockerContainer/updateAllDockerContainers API clients are
removed. Update state still tracks through the containerUpdates store,
flipping to success when the refreshed report clears updateAvailable.
2026-07-14 12:23:43 +01:00
rcourtman
098ba4eaa9 Hash relationship identity, not observation stamps, into plan resource versions
Docker adapters restamp relationship ObservedAt/LastSeenAt on every
~15s report, and the action planner folded those stamps into the plan's
resource version, so any reviewed action against a relationship-bearing
container (start, stop, restart, and the restored update) drifted to a
409 action_plan_drift before a human could read the review dialog and
click approve. Relationship edges now count by identity (source,
target, type, active, discoverer, metadata), the same
identity-versus-timestamp boundary change emission drew for issue
#1496. Found live: the UI update journey failed with plan drift on
every attempt slower than one report cycle.
2026-07-14 12:22:02 +01:00
rcourtman
3c778e2b26 Restore one-click Docker container updates through the typed action plane
v6.1.0-rc.1 retired the legacy update endpoints before a replacement
existed, so the UI's Update button failed with an internal-jargon 410
(issue #1564). This lands the replacement end to end: update_container
is a typed agentexec operation with its own strict codec, durable
receipts, and a request digest bound to the image digest the plan
observed; the unified agent bridges execution to the Docker module's
existing pull/backup/recreate/verify/rollback implementation (which now
reports rollback attempt and outcome); and the container action
executor plans, dispatches, and reconciles the operation with declared
backup/rollback compensation truth. Containers advertise an
admin-approval update capability while an image update with a stated
current digest is detected. The legacy endpoints stay retired but
return actionable copy.

Proven live against a Colima daemon: single-container update, the
issue-1564 shared-network-namespace update, and the full UI journey
(Update button, governed review, approve, run) all completed with the
namespace preserved and the backup retained.
2026-07-14 12:19:04 +01:00
rcourtman
8f475cbf58 Fold runtime-key Docker URL metadata into the stable guest key
URLs saved through the resource drawer historically landed in the docker
store under the runtime container key, which any stable record (including
an intentionally empty cleared one) outranks in the unified customUrl
projection, and which orphans on container recreation. On report ingest,
copy those records into the stable app-container guest key when it is
missing (cleared links stay cleared), healing saves stranded before the
drawer moved to the stable identity. Also read the runtime key before its
copy-if-missing container-name snapshot so the freshest write wins among
the docker-store fallbacks.

Refs #1556
2026-07-14 12:00:57 +01:00
rcourtman
7a1e41bcf9 Let flowing AI streams outlive the configured request timeout
Thinking models served through OpenAI-compatible endpoints (qwen3 via
Ollama >=0.31, DeepSeek) spend most of a turn streaming per-token
reasoning deltas before any content. The stream reader wrapped the whole
turn in a wall-clock deadline at the configured request timeout, so a
live, visibly-thinking stream was killed mid-thought with 'AI response
timed out before completion' (#1576, second symptom).

The timeout's job is stall detection, not turn budgeting. It now bounds
only how long Pulse waits for the stream to start: the response-header
wait (Ollama holds headers while a cold model loads) and the first-chunk
wait both honor the configured timeout, completing what 1c0648451
started. Once deltas flow, the 12s inter-chunk stall bound and caller
cancellation are the only limits, matching the native Ollama provider's
long-standing design.

Verified live against Ollama 0.31.1 / qwen3:8b: with a 15s configured
timeout, a turn streaming 10k chars of reasoning over 4m17s completes
and delivers the answer; previously it died at the timeout.
2026-07-14 11:57:26 +01:00
rcourtman
99c2ef22a6 Govern commercial offer and lifecycle 2026-07-14 11:54:22 +01:00
rcourtman
5d4f51c027 Key TrueNAS systems by configured connection, not reported hostname
Two TrueNAS systems that report the same hostname collapsed into one
flapping resource (#1573, #1575): systemSourceID keyed the system by the
snapshot-reported hostname, every child pool/dataset/app/VM/share/disk
was scoped under it, and the client minted the system's machine key from
the DMI serial with a hostname fallback, so serial-less systems sharing
a hostname (and DR clones sharing a serial) also fully merged in the
identity matcher.

The system source ID now scopes to the connection ID the poller passes
through NewLiveProviderForConnection; the hostname arm survives only for
fixture snapshots, which carry no connection. The ingest identity drops
the machine key entirely (DR clones share DMI serials, and vendor
placeholder serials collide across unrelated machines), the client no
longer falls back to the hostname for MachineID, and ingest skips
identity-pin completion for SourceTrueNAS so a stale pre-fix pin or a
same-named agent host's pin cannot lend the system a machine key and
re-merge what connection scoping keeps apart. Agent.AgentID and the
native metric history keys follow the source ID minus its system:
prefix, so BuildMetricsTarget keeps resolving one series.

Rows minted under the retired hostname-keyed derivation re-key once via
record-declared succession: records name their old canonical IDs in
IngestRecord.SupersededCanonicalIDs and IngestRecords applies the
existing ApplyCanonicalIDSuccessions semantics (operator state and
action audits re-key, the superseded pin drops, never while the old ID
still belongs to a live resource, journal rows are never rewritten).
Alert identities and persisted metric series under old child IDs are
not re-keyed: active alerts re-arm under the new IDs and TrueNAS host
charts are backed by native read-through history.
2026-07-14 11:51:55 +01:00
rcourtman
52469f5cbc Surface native Ollama thinking tokens instead of silently dropping them
Ollama's /api/chat returns reasoning in message.thinking, but the native
provider had no field for it: thinking models like qwen3 (the quickstart
default) showed dead air in the Assistant drawer for the whole reasoning
phase. Decode the field, stream it as thinking events (matching the
OpenAI provider's reasoning handling), carry it on the non-streaming
response as ReasoningContent, round-trip prior-turn reasoning on
assistant history messages, and make SupportsThinking tell the truth.

Verified live against Ollama 0.31.1 with qwen3:8b: first stream event is
now thinking (1137 thinking chunks before content), and non-streaming
Chat returns the reasoning text.
2026-07-14 11:50:01 +01:00
rcourtman
848b4d5038 Preserve customer data across plan downgrades 2026-07-14 11:47:25 +01:00
rcourtman
caa822ed92 Keep the drawer Access section in one place with an always-visible open link
Some checks are pending
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
In row drawers the Access disclosure sat collapsed at the very bottom, but
expanding it re-rendered the section at the top — clicking "Show access"
teleported the content away from the user, and reaching a container's web
interface meant scrolling the whole drawer down and back up (#1556).
Promote the section whenever it exists so it renders in one stable spot at
the top, and surface the saved URL as a clickable link in the disclosure
header so opening the web interface requires no expansion at all.

Refs #1556
2026-07-14 10:51:13 +01:00
rcourtman
2c6f96513b Key the resource drawer's container web-interface URL by stable identity
A URL saved from the Docker tab's container drawer went to the docker
store under the runtime container key — the lowest-priority key in the
unified customUrl projection, and one that goes stale the moment the
container is recreated (e.g. by an update). Any stable record, including
an intentionally empty cleared one, masked it forever, so the saved URL
never turned the container name into a table link even after a refresh
(#1556). The Workloads drawer was already fixed to use the stable
host-plus-container-name guest key; align the resource drawer on the
same identity.

Refs #1556
2026-07-14 10:46:07 +01:00
rcourtman
18b5b1dd58 Make web-interface name links look like links everywhere
The Proxmox nodes table styled its PVE UI link in the plain text color, so
a working link was indistinguishable from a label (#1556). The workloads
node group headers and both alerts-surface name links had the same drift.
Extract the canonical blue link classes into WEB_INTERFACE_LINK_COLOR_CLASS
on WebInterfaceNameLink and compose it at every call site.

Refs #1556
2026-07-14 10:45:13 +01:00
rcourtman
12e6b74ae2 Document the -1 disk usage sentinel in the API reference
Guest disk percentages report -1 when a VM is stopped or its guest
agent is unavailable (issue #1569); consumers were treating it as a
real percentage.
2026-07-14 09:46:12 +01:00
rcourtman
1c06484512 Honor the configured AI timeout while waiting for the first stream chunk
The OpenAI-compatible stream reader bounded every chunk wait at 12s.
Local backends (LM Studio, llama.cpp) legitimately spend minutes on
prompt processing before the first SSE chunk, so raising the provider
timeout in Settings changed nothing and Pulse dropped the stream with
'AI response timed out' (discussion #1571). The wait for first bytes now
uses the configured request timeout; the 12s bound still applies to
inter-chunk gaps once the stream is flowing.
2026-07-14 09:44:22 +01:00
rcourtman
b3ae6f72b6 Recognize OIDC sessions that carry no refresh token
A provider that issues no refresh token (offline_access not requested,
e.g. default Authelia) left session.OIDCIssuer unset, so
/api/security/status reported an empty ssoSessionUsername and
X-Auth-Method fell back to plain session. The frontend then skipped the
SSO fast path, and the pre-auth bootstrap short-circuit pinned the user
to the login page after ?oidc=success before the /api/state probe could
run (issue #1574). Stamp issuer/client on every OIDC session (refresh
stays gated on the token being present) and let a completed SSO
callback bypass the bootstrap short-circuit.
2026-07-14 09:42:27 +01:00
rcourtman
58d533a524 Stop emitting no-op docker.updateStatus change rows
dockerUpdateStatusChanged compared the whole DockerUpdateStatusMeta with
reflect.DeepEqual, so the LastChecked stamp refreshed by every periodic
update check emitted a resource_changes row per container per cycle and
flooded unified_resources.db (discussion #1577, same class as #1496).
Only availability, digests, and error text count as change now.
2026-07-14 09:38:13 +01:00
rcourtman
d746731c75 Dispatch relay proxy requests to the local API in-process
The relay client's HTTP proxy dialed http://127.0.0.1:<FrontendPort> for
every proxied mobile request. With HTTPS_ENABLED the main listener serves
TLS on that port, so Go answered each plaintext dial with a bare
"Client sent an HTTP request to an HTTPS server" 400 - breaking Remote
Access backlog sync (alerts/approvals) on every HTTPS-enabled instance.
A non-loopback BIND_ADDRESS broke the same dial outright.

Route proxied requests through the router's own handler chain in-process
instead, via a streaming-capable RoundTripper (pipe-backed, SSE flush,
panic recovery, loopback RemoteAddr for address-keyed middleware). The
listener's scheme and bind address no longer matter, and the request
traverses exactly the middleware the real listener serves.

Reported by Johannes Strasser (Remote Access thread, 2026-07-14).
2026-07-14 09:26:41 +01:00
rcourtman
1054f28fe8 test(coverage): action audit canonicalization branch coverage
Cover the uncovered branches of migrateActionAuditCanonicalization (store.go):
idempotent no-op skip + len(updates)==0 early return, terminal-state exclusion
(expired not in the WHERE clause), mixed-batch skip-vs-update partition, and the
un-normalizable strict-error WARN branch (with an in-test honesty guard that
fails if the fixture stops reaching the strict path). The shipped repro test
covers only the happy path. New _test.go only; no source or existing tests
touched. Adds 4 tests; go test/vet/gofmt green.
2026-07-14 05:55:50 +01:00
rcourtman
208c77d932 Canonicalize pre-upgrade action audit rows at store open
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Lifecycle transitions compare stored request/plan/origin JSON byte-for-byte
as their concurrent-writer guard. Rows written by v6.0.x re-marshal
differently after read-time normalization (backfilled plan expiry,
legacy-unknown policy decision, approval requirement), so every transition
on them matched zero rows and was swallowed as already-final. The visible
symptom: pre-upgrade pending approvals sit in the Actions inbox forever
with no way to clear them - the review dialog blocks approve/reject by
design for records without policy provenance, and the expiry sweep
silently failed to retire them.

Rewrite non-terminal audit rows to the current canonical JSON shape once
at store open; the existing expiry sweep then moves stale pre-upgrade
approvals to History on the next inbox load. Rows that cannot be expressed
in the current shape are left as stored and logged.

Reported by Johannes Strasser on v6.1.0-rc.1.
2026-07-14 00:19:32 +01:00
rcourtman
027185b097 Cover action-resource presentation and localStorage signal branches
Add branch-coverage tests for the fresh Actions-inbox and localStorage code:
- actionPresentation.branchcov2130.test.ts (17 cases): getActionResourcePresentation
  label/detail resolution — unknown/null resource types, canonical-kind fallback,
  opaque-suffix compaction, dashed-prefix handling, regex boundaries.
- localStorage.branchcov2130.test.ts (47 cases): createLocalStorage*Signal parse
  and stringify ternary arms (Number hex/scientific/whitespace, strict boolean
  parse, String exponential notation) across initial-read, sync-event, and write paths.

All gates green (vitest+type-check+eslint); adversarial substance review KEEP 2/2.
2026-07-13 22:10:12 +01:00
rcourtman
71a3b6ebcd Restore release-blocking backend contracts 2026-07-13 21:51:33 +01:00
rcourtman
252754ee4f Refresh Pulse 6.1 RC1 mobile coordinates 2026-07-13 20:27:42 +01:00
rcourtman
310ee94b0d Refresh Pulse 6.1 RC1 mobile evidence 2026-07-13 19:41:41 +01:00
rcourtman
e2ec59a840 Prepare v6.1.0 RC1 release
Some checks failed
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Helm CI / Lint and Render Chart (push) Has been cancelled
2026-07-13 18:35:08 +01:00
rcourtman
b428fd2662 Sync shipped ICMP probe documentation 2026-07-13 18:33:02 +01:00
rcourtman
1fd930e71d Document RG-06 autonomy proof runner 2026-07-13 18:32:56 +01:00
rcourtman
a393744894 Add in-app release highlights 2026-07-13 18:30:29 +01:00
rcourtman
2d954b24ca Badge the Actions tab with the pending-approval count
Actions awaiting a decision are time-boxed, but the nav gave no signal
unless the approval happened to be Patrol-origin and the user was
already on Patrol. Poll the canonical decision queue alongside the
existing 30s open-work refresh and surface the count on the Actions
tab, matching the Alerts/Patrol badge pattern. Sessions without the
action-approve capability stop polling after the first terminal
response.
2026-07-13 17:48:33 +01:00
rcourtman
7b114f4d5a Connect Patrol action handoffs to Actions 2026-07-13 17:34:12 +01:00
rcourtman
66dc5fd7df Polish Actions layout and review details 2026-07-13 17:00:36 +01:00
rcourtman
c471d8d201 Re-home guest alerts of every kind when a guest moves nodes
Guest alerts are keyed by node-scoped resource IDs, and only metric
threshold alerts were re-homed after a live migration. Lifecycle alerts
(powered-state and future guest kinds) stayed keyed to the old node
forever because their resolvers only look up the new-node key, node
existence GC never fires while the source node is still online, and
nothing else consumes the move.

Generalize the migration helper to match lifecycle spec IDs (which
embed the node-scoped resource ID) as well as node-independent metric
spec IDs, and call it from the canonical lifecycle and stateful
evaluators, the powered-off resolver, and the disabled-threshold early
returns in both metric paths. Guest-wide sweep clears (metric clear on
stop, per-disk cleanup, tag suppression) now match the stable
instance+vmid identity across nodes too.

Reported by Johannes Strasser: a VM live-migrated between Proxmox
nodes stranded its pre-existing active alert with no way to clear it.
2026-07-13 16:54:33 +01:00
rcourtman
d6387b8241 Redesign the Actions inbox 2026-07-13 16:38:16 +01:00
rcourtman
4c3e0756e3 Guard the allowed_signers format in the install verification docs 2026-07-13 16:27:04 +01:00
rcourtman
4c073d6b17 Add mock action lifecycle data 2026-07-13 16:24:06 +01:00
rcourtman
95001df0bb Publish the sshsig key as an allowed_signers line so the documented verify command works
ssh-keygen -Y verify -f expects the principal in the FIRST field; the
docs published the key in authorized_keys order, so the documented
verification failed against a valid signature (customer-reported against
v6.0.5). Verified the new command against the live v6.0.5 release
artifacts.
2026-07-13 16:17:25 +01:00
rcourtman
c5bf3adebf Fail closed instead of auto-downgrading, and back up config to a writable path under the hardened update unit
Customer report against v6.0.5 (2026-07-13):
- resolve_target_release fell back to hardcoded v4.5.1 when the GitHub API
  and the /releases/latest redirect both failed, silently downgrading a
  v5.0.17 install two major versions. Now: fail with --version guidance,
  and refuse any auto-resolved target older than the installed version.
- backup_existing wrote /etc/pulse.backup.<ts>, which is read-only under
  pulse-update.service (ProtectSystem=strict, ReadWritePaths=/opt/pulse
  /etc/pulse /tmp), so every unattended update on systemd/LXC failed at
  the backup step. Now: fall back to $INSTALL_DIR/config-backups when the
  config parent is not writable.
2026-07-13 16:13:29 +01:00
rcourtman
f9f90a1a27 Redesign Patrol open work queue 2026-07-13 16:12:37 +01:00
rcourtman
2e9520da81 Bind governed action intent across Patrol and web 2026-07-13 16:05:44 +01:00
rcourtman
9286422263 Bind mobile actions to reviewed plans 2026-07-13 15:27:39 +01:00
rcourtman
a10f309c95 Add Proxmox lifecycle Patrol detectors 2026-07-13 14:34:27 +01:00
rcourtman
5b4365853d Correct stale Settings/Alerts nav labels in user docs
Rename doc references to settings/alerts items that were renamed in the
shipped v6 IA, verified against current settingsNavCatalog.ts and
i18n/messages.ts:
  Settings > Relay             -> Settings > Remote Access
  Settings > Plans             -> Settings > Plans & Billing
  Settings > Security > Webhooks -> Settings > Security > Audit Webhooks
  Alerts > Notification Destinations -> Alerts > Notifications
  Settings > Reports           -> Settings > Data & Reports

Derived from the docs-rot audit; pure label renames only. Flow/route
rewrites (retired standalone pages, TrueNAS/Nodes relocation, i18n
copies) held for a supervised pass.
2026-07-13 13:47:13 +01:00
rcourtman
f095da2fdb Wire production Proxmox action verification 2026-07-13 11:04:09 +01:00
rcourtman
5cde383b98 Adopt a member agent's connection address on cluster re-registration
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
The v6.0.5 host-adoption fix (f85009913) only applies when a
re-registering agent matches a top-level instance host, so an agent on a
non-primary cluster member never benefited: its registration created a
standalone instance that ConsolidatePVEInstances folded back into the
cluster, and the fill-empty endpoint merge silently discarded the agent's
fresh address. The member row kept showing the corosync short-DNS host
rebuilt on every re-discovery (the "Install issues with V6" support
thread; a reinstall on v6.0.5 still showed the stale name).

Canonical auto-register now matches cluster member endpoints directly:
address identity against the agent's candidate list first, then an
unambiguous corosync node-name match. The Pulse-verified selected host is
adopted as the member's IPOverride, the durable field re-discovery
preserves and polling prefers, plus the fingerprint captured from that
address. An admin-managed override absent from the candidate list is
preserved, mirroring shouldPreserveExistingAutoRegisterHost. Credential
writes stay restricted to a same-token-identity secret refresh (reinstall
rotates the agent's token in place, so the stored secret is already
invalid) and full promotion onto a credential-less cluster; a member's
distinct per-node token never replaces working cluster credentials, and
no standalone instance is created for consolidation to discard.
2026-07-13 09:59:18 +01:00
rcourtman
e1720ca219 Use unavailable sentinel when VM guest agent disk query fails
When fetchVMFSInfo fails (agent not running, timeout, permission
denied, no filesystems), the builder passed the cluster/resources
numbers through — and PVE always reports 0 used for QEMU — so the UI
rendered a confident "0% (0 B/<allocated>)" for every affected VM
instead of the unavailable dash plus diskStatusReason tooltip. The
agent-disabled path already used the -1 sentinel for exactly this;
apply it on the error path too. The stabilizer can still replace the
sentinel with the previous good sample when recent agent evidence
exists.

Reported by Massimo Simoni (support, 2026-07-13): screenshot showed
every VM at 0% disk with only containers reporting real usage.
2026-07-13 09:41:11 +01:00
rcourtman
06a6028b29 test(coverage): pulse PURE branch-cov (15 presentation/model modules) 2026-07-13 08:25:53 +01:00
rcourtman
533c3392bd test(coverage): pulse PURE branch-cov (16 presentation/model modules) 2026-07-13 08:24:29 +01:00
rcourtman
8ffb56946e chore: seal Pulse Intelligence release gate 2026-07-13 01:14:19 +01:00
rcourtman
a63b3eae2b Require independent evidence for verified findings 2026-07-12 23:15:54 +01:00
rcourtman
7d772acff3 feat(assistant): mid-turn steering of the running response
Some checks are pending
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
A follow-up sent during an active run now offers itself to the running
agentic loop via POST /api/ai/sessions/{id}/steer. Accepted steers join
the loop at its next turn boundary (the abort-check site) as plain user
messages, are announced with a steer_applied stream event so the drawer
settles the pending row, and persist through the end-of-run save. A
steer carries prompt text only: no route, control-level, or autonomy
changes, no turn-budget extension, system sessions rejected, and the
per-session inbox is bounded (steer_backlog overflow). Delivery is not
guaranteed by acceptance: a run that ends first discards the inbox and
the row drains as an ordinary queued turn, so pre-steering queue
semantics remain the fallback. Steering rows lose edit/remove once
accepted.
2026-07-12 23:01:40 +01:00
rcourtman
8676e5d5b9 Keep RG09 cache fixture across restart 2026-07-12 22:31:29 +01:00
rcourtman
42fe61493c feat(assistant): show estimated session cost in the last-turn summary
Chat turns, compaction, and title calls now stamp session_id on their
cost.UsageEvent, and the done event carries session_cost_usd summed from
the operator ledger (cost.Store.SessionCostUSD). The drawer's last-turn
summary appends '$0.12 session' with a sub-cent floor. The figure is
omitted whenever any of the session's models has unknown pricing, and
free local models price known-at-zero, so no figure is ever partial.
2026-07-12 22:13:23 +01:00
rcourtman
329039354d Use Development SSD for RG09 Go cache 2026-07-12 22:13:04 +01:00
rcourtman
373b491484 Fix durable APT drift receipts 2026-07-12 21:53:52 +01:00
rcourtman
abcf7ec6ba test(coverage): harvest 20 stranded branch-coverage tests from 13:30 wave
Harvests the 13:30 c-wave's completed-but-unlanded branch-coverage tests
(315 cases across 20 PURE modules): url, settingsFeatureGates, alertDestinations,
storageAlertState, commandPalette, guestDrawer, nodeDrawer, navigation,
actionAuditPresentation, workloadTypePresentation, upgradeNavigation, and others.
Verified against current HEAD: vitest 20/20 green, full type-check clean, eslint
clean, GLM adversarial review 20 KEEP / 0 REJECT (no source edits).
2026-07-12 21:40:32 +01:00
rcourtman
8a0c6fc9a8 feat(assistant): collapse long pastes into composer chips
Pasting a log or config into the composer flooded the textarea. Pastes
over 8 lines or 800 chars now collapse into a removable chip; the full
text rejoins the prompt after the typed question at send time. Clicking
the chip body expands it back into the textarea for inline editing, and
a chip-only send is allowed.
2026-07-12 21:27:08 +01:00
rcourtman
1ff4c29cf7 Add Debian and Ubuntu APT certification 2026-07-12 21:25:48 +01:00
rcourtman
120c80239d feat(assistant): edit-and-resend pencil on the latest user prompt
The undo flow already restores the removed prompt into the composer for
editing, but the only entry points were the header undo button and /undo.
Surface it where users look for it: a hover pencil on the latest
non-queued user prompt that undoes the turn and prefills the composer,
riding the same last-turn undo boundary as regenerate.
2026-07-12 21:15:38 +01:00
rcourtman
6d1dced727 docs(readme): surface the Pulse MCP bring-your-own-agent path
The MCP server and its Settings setup panel existed but the README never
mentioned them, so users who prefer their own agent (Claude Code,
OpenCode) had no pointer to the supported path.
2026-07-12 21:05:25 +01:00
rcourtman
44e29e8e98 Merge branch 'main' of https://github.com/rcourtman/Pulse 2026-07-12 21:02:34 +01:00
rcourtman
60ce8924bb feat(assistant): retry re-runs the turn in place and the last answer gains regenerate
Retrying a failed turn re-sent the prompt without removing the persisted
turn, so session history double-recorded the prompt. Session undo now
accepts an expected-prompt guard (a stale retry can never remove a
different turn); retry drops the replaced turn server-side before
re-sending. The latest settled assistant answer gains a hover-revealed
Regenerate button that reuses the same path.
2026-07-12 20:58:20 +01:00
rcourtman
b8df758ede Preserve RG06 evidence trust boundaries 2026-07-12 20:49:18 +01:00
rcourtman
8b2eef10b4 Normalize bounded APT agent clock skew 2026-07-12 20:44:21 +01:00
rcourtman
9981e370bb Align RG06 operator lock reason 2026-07-12 20:39:14 +01:00
rcourtman
399745fde5 Plan RG06 stale barrier explicitly 2026-07-12 20:35:47 +01:00
rcourtman
03cb85d85a Measure RG06 stale plan at dispatch 2026-07-12 20:30:28 +01:00
rcourtman
43accc722e Project RG06 resource staleness fully 2026-07-12 20:26:19 +01:00
rcourtman
4334535291 Invalidate RG06 registry on staleness 2026-07-12 20:22:53 +01:00
rcourtman
912f131cd0 Measure RG06 barriers before teardown 2026-07-12 20:19:03 +01:00
rcourtman
1ad813c2e7 Align RG06 emergency barrier reason 2026-07-12 20:15:25 +01:00
rcourtman
322f4fc50d Bind RG06 agent and host identity 2026-07-12 20:12:00 +01:00
rcourtman
d518e7d33d Use canonical default tenant in RG06 2026-07-12 20:08:10 +01:00
rcourtman
fedc77ef62 Route RG06 through canonical registry 2026-07-12 20:04:40 +01:00
rcourtman
06dcd59121 Bind RG06 report projection identity 2026-07-12 19:59:35 +01:00
rcourtman
e0e6842cb5 Fix RG06 ext4 pressure fixture 2026-07-12 19:56:47 +01:00
rcourtman
7fd7e2b160 fix(frontend): key drawer guest web-interface URLs by the canonical workload id
The infrastructure resource drawer saved guest metadata (web interface
URLs) under the unified resource hash or the discovery resource id, a
keyspace no workloads table reads: the tables and the workloads drawer
resolve metadata by the canonical id (instance:node:vmid for PVE guests,
resource id otherwise), which is also the key v5 upgrades carry over in
guest_metadata.json. A URL saved from that drawer was stranded where
only the same drawer could read it back.

Route the drawer's guest metadata id through
getCanonicalWorkloadIdForResource, and canonicalize the resource type
inside that helper so lxc/oci-container/qemu spellings build the same
node-scoped id the workloads surfaces use.

Refs #1556
2026-07-12 19:52:21 +01:00
rcourtman
4eb7715171 fix(frontend): key drawer guest web-interface URLs by the canonical workload id
The infrastructure resource drawer saved guest metadata (web interface
URLs) under the unified resource hash or the discovery resource id, a
keyspace no workloads table reads: the tables and the workloads drawer
resolve metadata by the canonical id (instance:node:vmid for PVE guests,
resource id otherwise), which is also the key v5 upgrades carry over in
guest_metadata.json. A URL saved from that drawer was stranded where
only the same drawer could read it back.

Route the drawer's guest metadata id through
getCanonicalWorkloadIdForResource, and canonicalize the resource type
inside that helper so lxc/oci-container/qemu spellings build the same
node-scoped id the workloads surfaces use.

Refs #1556
2026-07-12 19:50:09 +01:00
rcourtman
10b73e1964 fix(docker-agent): preserve shared network namespaces through container updates
One-click updates recreated containers from the raw inspected config, so
Docker rejected the create for any container using network_mode:
container:<id> (compose service:<x>) or host with "conflicting options:
hostname and the network mode", leaving the workload stopped under its
_pulse_backup_ name until the rollback rename restored it. Docker fills
Config.Hostname with the namespace owner's ID on such containers, so the
verbatim copy always tripped the daemon validation (verified against a
real Docker 29.5.2 daemon: verbatim create 409s, sanitized create
succeeds and the replacement starts in the shared namespace).

Strip the namespace-owned settings before ContainerCreate: hostname and
domainname for container:/host modes, plus exposed/published ports,
links, DNS options, and extra hosts for container: mode, all of which
the daemon derives from the owning namespace. Bridge and user-defined
networks are untouched.

Refs #1564
2026-07-12 19:48:18 +01:00
rcourtman
99aa2bd6eb Add Colima autonomy release proof 2026-07-12 19:47:56 +01:00
rcourtman
2d47c12e6d Normalize Pulse Intelligence release gates 2026-07-12 14:42:07 +01:00
rcourtman
c07927adbd Classify action routes and operation transports 2026-07-12 13:55:19 +01:00
rcourtman
e6d40f3c11 fix: bound denied model tool loops 2026-07-12 13:29:21 +01:00
rcourtman
90edcfe017 Fix strict APT lifecycle fixture 2026-07-12 13:00:02 +01:00
rcourtman
0062128414 Add durable Docker restart lifecycle proof 2026-07-12 12:11:16 +01:00
rcourtman
906d4823aa test(frontend): branch coverage for 7 pure modules (GLM wave 0712 tier 2d)
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
2026-07-12 10:49:05 +01:00
rcourtman
48ecb93848 test(frontend): branch coverage for 8 pure modules (GLM wave 0712 tier 2c)
Some checks are pending
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
2026-07-12 10:07:06 +01:00
rcourtman
353c5c63a4 test(frontend): branch coverage for 12 pure modules (GLM wave 0712 tier 2b) 2026-07-12 09:49:55 +01:00
rcourtman
00ff2e805c test(frontend): branch coverage for 6 pure modules (GLM wave 0712 tier 2a) 2026-07-12 09:19:35 +01:00
rcourtman
243f10f3c0 test(frontend): branch coverage for 5 pure modules (GLM wave 0712 batch 3b) 2026-07-12 08:19:49 +01:00
rcourtman
8f6888b7a4 test(frontend): branch coverage for 9 pure modules (GLM wave 0712 batch 3) 2026-07-12 08:05:51 +01:00
rcourtman
512606c40b test(frontend): branch coverage for 16 pure modules (GLM wave 0712 batch 2) 2026-07-12 07:34:44 +01:00
rcourtman
8321ed55c5 test(frontend): branch coverage for 7 pure modules (GLM wave 0712) 2026-07-12 06:36:22 +01:00
rcourtman
b0ee11e162 Add trusted APT action review
Refs #1491
2026-07-12 06:33:42 +01:00
rcourtman
d6838d3a25 Complete durable APT workflow continuity 2026-07-12 05:23:02 +01:00
rcourtman
71ad92ef71 test(frontend): branch-coverage tests for 10 more pure presentation/model modules 2026-07-12 04:39:10 +01:00
rcourtman
4aac79dc72 Add durable agent operation receipts 2026-07-12 04:16:22 +01:00
rcourtman
516179e5fa test(frontend): branch-coverage tests for 16 more pure model/presentation modules 2026-07-12 03:30:26 +01:00
rcourtman
1968ba210c test(frontend): de-brittle actionAudit verified-outcome assertion
The 'trusted action review workspace' change (e13abf84f) reworked the verified
verification-outcome copy, breaking a branchcov2 assertion that hard-coded the old
label/detail. Assert 'VERIFIED' normalises to the canonical 'verified' result
instead of exact copy, so the test stays focused on the toLowerCase branch.
2026-07-12 03:18:07 +01:00
rcourtman
e77ab9518d Add safe APT workflow foundations 2026-07-12 02:53:01 +01:00
rcourtman
e13abf84fa Add trusted action review workspace 2026-07-12 01:43:00 +01:00
rcourtman
1294695d6a test(frontend): branch-coverage tests for 28 pure model/presentation modules
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
2026-07-12 01:04:46 +01:00
rcourtman
d8066ace0b Merge remote-tracking branch 'origin/main' 2026-07-12 00:21:36 +01:00
rcourtman
82c7c52727 Expose server-authored action policy provenance 2026-07-12 00:18:43 +01:00
mz
010d6435b8
Harden dotted hostname identity pin compatibility
Refs #1559
2026-07-11 23:41:07 +01:00
rcourtman
76f1084c9b Enforce server-owned Patrol Autopilot acknowledgement 2026-07-11 23:12:00 +01:00
rcourtman
b7b897651b Add canonical action result truth 2026-07-11 21:27:00 +01:00
rcourtman
cd7886aa44 Enforce server-owned action approval authority 2026-07-11 19:49:17 +01:00
rcourtman
e5dbbb3145 Derive cluster-scoped canonical host IDs from full hostnames
Sibling of #1559: canonicalIDFromIdentity hashed the short hostname in
its cluster arm, so two machine-keyless Docker Swarm members with FQDN
hostnames sharing a first label (cloud.a, cloud.b) derived the same
cluster:<swarm>:cloud canonical ID and fully merged in the registry.
PVE is unaffected (single-label node names hash identically).

Both hostname-derived arms now hash NormalizeFullHostname. Derivation
stays a pure function of identity so store-less registries keep deriving
the same IDs as the durable one. Compatibility for hosts minted under
the short era: journal reads already merge both eras via
ResourceIdentityPin.EraIDs, and new canonical-ID succession re-keys
resource_operator_state and action_audits rows to the successor ID at
pin persist (never across a contradicting machine key, never while the
old ID is still live, never rewriting journal rows) so operator intent
like never-auto-remediate survives the era change. Already-merged
keyless pairs cannot be retroactively split; the merged rows succeed to
whichever member persists first.
2026-07-11 19:05:14 +01:00
rcourtman
a23a8fad9a Addresses #1558 2026-07-11 19:02:14 +01:00
rcourtman
aefc631374 Addresses #1559
The code fix (preserve full dotted hostnames in identity pins and the
presentation host coalescer, with short/FQDN equivalence kept for
matching) rode commit 1d3b8e194 through a shared-index race. This
commit carries the unified-resources subsystem contract update and
moves the end-to-end regression test into registry_test.go.
2026-07-11 18:31:45 +01:00
rcourtman
0b6b97115b Addresses #1557 2026-07-11 18:30:43 +01:00
rcourtman
76dc690840 Suppress recovery notifications when the firing never left the queue
An alert that resolved while its firing notification was still in the
grouping window or waiting in the persistent queue (alert delay pushes
activation close to resolution; quiet-hours replay defers delivery)
produced a recovery-only notification: CancelAlert dropped the queued
firing, but LastNotified had been set optimistically at dispatch, so
the resolved-notification gate believed the firing had been sent.

CancelAlert now reports whether it cancelled a firing notification that
had not been delivered (grouping window entries and pending queue rows;
mid-send rows are excluded because their delivery may still complete),
and handleAlertResolved suppresses the recovery in that case. This also
covers the quiet-hours replay bypass: a recovery only follows a deferred
firing if the replay was actually delivered.

Addresses #1553
2026-07-11 18:28:28 +01:00
rcourtman
1d3b8e1949 Addresses #1555 2026-07-11 18:25:17 +01:00
rcourtman
175309b8f7 Thread alert emails with their resolved notifications
Every email covering a single alert now carries In-Reply-To and
References headers set to a deterministic incident thread ID derived
from the alert ID and firing start time, so mail clients thread the
firing, re-notification, and resolved emails of one incident together.
Message-ID stays unique per send because re-notified incidents send
multiple emails and some providers de-duplicate on Message-ID. Grouped
emails skip threading since firing and resolved batches rarely contain
the same alert set.

Addresses #1543 (discussion)
2026-07-11 18:22:07 +01:00
rcourtman
5a6871813f Grant CAP_NET_RAW to the installed systemd unit for ICMP probes
The installer's unit hardens with NoNewPrivileges=true, which strips the
ping binary's setuid/file capabilities, so ICMP availability probes could
never work on a systemd install. Grant the capability ambiently instead,
document the systemctl edit override for existing units, and pin the
hardening block with an install test and contract clause.

Addresses #1554 (discussion)
2026-07-11 18:15:22 +01:00
rcourtman
a44e5513ca Register template funcs on the enhanced ntfy payload path
The ntfy branch of prepareEnhancedWebhookExecution parsed the payload
template without templateFuncMap(), so the built-in ntfy preset (which
uses {{.Type | title}}) failed to parse and Test sends returned HTTP
400 with "function \"title\" not defined". Register the func map on
that parse, matching the generic webhook path, and add a regression
test that renders the actual built-in ntfy preset template.

Addresses #1549
2026-07-11 18:11:22 +01:00
rcourtman
71ea14e4dd Add durable action dispatch continuity 2026-07-11 16:08:44 +01:00
rcourtman
f510b99095 Enforce the canonical mutation plane 2026-07-11 15:19:41 +01:00
rcourtman
c74c88fb86 Fix dispatch-time policy revocation 2026-07-11 14:29:26 +01:00
rcourtman
e65bef8e84 Fix atomic action lifecycle replay 2026-07-11 14:02:32 +01:00
rcourtman
c0bbed1c59 test(frontend): branch-coverage tests for 19 more pure frontend modules
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Wave 2 of coverage_backlog_2026-07-11 (items 17-35): new *.branchcov.test.ts
covering top uncovered-branch functions in patrolRunPresentation,
infrastructureSelectors, resourceDetailDiscoveryModel, thresholdsResourceModel,
infrastructureSummaryCache, monitoredSystemPresentation, connectionsTableModel,
activeTurnStatus, resourceStoragePresentation, storagePoolDetailPresentation,
cephSummaryPresentation, settingsNavigationModel, workloadMetricHistoryModel,
resourceCorrelationPresentation, recoveryItemTypePresentation,
setupCompletionModel, storagePageState, discoveryPresentation, searchQuery.
GLM-authored, verify-gated: vitest (888 tests) + full type-check + eslint +
adversarial GLM review (KEEP 19/19). Tests only, no source changes.
2026-07-11 13:05:13 +01:00
rcourtman
ffd1ea8127 Enforce Assistant read-only scope integrity 2026-07-11 12:43:55 +01:00
rcourtman
c17e3814ee Rename the demo Patrol snapshot parameter so the SRC-04b guard passes
synthesizeDemoPatrolFindings took its patrolRuntimeState parameter as
'state', which trips the textual direct-state-access ban in
TestNoDirectStateAccessForMigratedResources even though the struct is
the sanctioned snapshot wrapper. The rest of the Patrol code names
these parameters 'snap' for exactly this reason, so the demo function
now follows suit. No behavior change.
2026-07-11 12:02:05 +01:00
rcourtman
4edf5f8265 fix: close chat command authority boundary 2026-07-11 11:54:56 +01:00
rcourtman
91b10b19b5 Simulate Patrol end to end in demo/mock mode
Mock mode faked assistant chat but left Patrol on the real provider
path. The public demo therefore ran real patrol cycles that failed with
"no patrol model configured", raised the Provider analysis error
finding, and rendered /patrol as an unconfigured setup surface with no
findings, which also cost the landing page its Patrol screenshot.

Demo mode now simulates the whole surface without ever contacting a
provider:

- runDemoPatrolCycle synthesizes findings from the live mock state
  (offline Docker edge host, degraded PBS and PMG, unhealthy or
  restart-looping containers, the fullest storage pool, the hungriest
  guest), quoting each resource's actual observed values so the patrol
  page stays consistent with the rest of the demo UI. Cycles refresh
  findings as heartbeats, auto-resolve ones whose mock condition
  cleared, and append matching run records plus a one-time backfilled
  history.
- Readiness, the API readiness checks, and the model-check preflight
  report a simulated pass under a "demo" provider identity, and the
  stale provider-error finding is auto-resolved.
- Release demo ordering is handled: mock fixtures only enable after the
  demo_fixtures license sync, so the patrol loop now starts for
  demo-intended runtimes (PULSE_MOCK_MODE requested in env) and every
  entry point re-checks IsDemoMode() live. Dev installs with the
  background-AI guard get a warmup goroutine instead of the scheduler.
- Run-history evidence filtering is now symmetric (demo runtimes hide
  real records, matching the existing inverse), scoped/targeted patrol
  runs are skipped in demo mode, and demo findings are excluded from
  ai_findings.json so fixtures never leak into a real install.
2026-07-11 11:25:52 +01:00
rcourtman
6b18652812 Point the portal operations-guide link at the repository root
The MSP portal support row deep-linked docs/MSP.md at blob/main, which
the repo-docs link-drift guard rightly rejects: a released portal build
must not reference branch-tip docs that can move out from under it.
The row keeps the docs/MSP.md path as text and links the repository
root instead, matching every other runtime surface. Portal dist rebuilt
alongside.
2026-07-11 11:11:58 +01:00
rcourtman
32f0fd4244 Keep Patrol background sessions out of the Assistant quick-resume list
Patrol detection reuses the durable patrol-main session (and investigations
use investigation-* sessions) as forensic logs. Those surfaced in the
Assistant empty-state Recent sessions list as resumable chats, titled with
the raw triage seed ("# Deterministic Triage Results Scanned ..."), which
reads as a broken user session (reported by a 6.0.5 user via support).
Session summaries now carry a server-derived system flag for Pulse-owned
background runs and the quick-resume list filters them out. The drawer
session picker and the Settings sessions panel still list them for
inspection.
2026-07-11 11:09:28 +01:00
rcourtman
704e738913 test(frontend): branch-coverage tests for 16 pure frontend modules
New *.branchcov.test.ts covering the top uncovered-branch functions from
coverage_backlog_2026-07-11 across models/presentation/selectors/mappers
(infrastructureOperationsModel, resourceBadgePresentation, kubernetesPageModel,
agentCapabilities, alertResourceTableModel, alertHistoryModel, storageAdapters,
resourceDetailDrawerServiceModel, proxmoxBackupRecoveryModel, transcriptExport,
resourceDetailMappers, diskPresentation, infrastructureImportPlanModel,
storageAdapterCore, resourceIdentity, workloadSelectors). GLM-authored,
verify-gated: vitest (711 tests) + full type-check + eslint + adversarial GLM
review (KEEP 16/16). Tests only, no source changes.
2026-07-11 11:04:54 +01:00
rcourtman
a63c3b1045 Route service-context discovery through the Patrol model by default
Discovery is high-fan-out background work (one model call per container or
service), but GetDiscoveryModel fell back straight to the shared default
model. An operator who picked a cheap Patrol model to keep background AI
inexpensive still had scheduled service-context refreshes burning the
expensive shared model, dozens of calls per run, with no chat activity
(reported by a 6.0.5 user via support). Discovery now follows the same
fallback chain as auto-fix: DiscoveryModel, then PatrolModel, then the
shared default. Settings copy and the ai-runtime contract state the chain.
2026-07-11 10:55:10 +01:00
rcourtman
8980ae1f5f Raise the bundle size baseline to the shipped platform features
The Docker, Kubernetes and TrueNAS chunks grew with the shared
platform-table sorting fabric and the grouped-by-host restoration, and
UpdatesSettingsPanel grew with the digest-pinned Pro Docker update
commands. All deliberate landed work, so the budget baseline moves to
match. The violations were hidden behind the failing frontend unit
tests and only surfaced once those were re-pinned.
2026-07-11 10:51:20 +01:00
rcourtman
d5609a2dc3 Re-pin frontend tests to the Patrol typed-action vocabulary
The Patrol lifecycle work renamed the queued-fix copy (Review fix ->
Recover action, proposed fix -> typed action, and the waiting-for-
approval summaries) and reshaped ApprovalSection's badge constant, but
the unit tests still asserted the old strings and identifiers, leaving
build-and-test red on main since last night. Expectations now match the
shipped source. ApprovalSection kept MetadataBadge and shared Button
variants, so the primitive guardrails re-pin to its current shape
(BADGE_PROPS, no warningSolid variant) rather than flagging a real
regression.
2026-07-11 10:40:20 +01:00
rcourtman
d4d12f3289 Let SSO sessions carry admin privileges through the settings gate
The privileged-session gate (ensureAdminSession) and the settings
capabilities snapshot only accepted a session whose username equals the
configured local admin identity. SSO users are keyed by their
provider-scoped principal (sso:oidc:...), which can never match, so an
SSO session was locked out of every settings-scoped route no matter what
roles it held. That made group role mappings (group=admin) appear
broken (#1535) and surfaced as 'Unable to load SSO providers' when an
SSO user opened the SSO settings panel (#1533).

A session user now passes the gate when their effective RBAC
permissions include the admin action on all resources, which is the
shape the built-in Administrator role assigns through group role
mappings. On an instance with no local admin identity configured at all
(the v5 OIDC-only pattern), SSO sessions pass as before the rewrite,
since they are the only administrators the instance has. Org-scoped
tenant sessions keep their own management rules.

Refs #1533, #1535
2026-07-11 10:27:26 +01:00
rcourtman
f2b732721e Fix physical disks vanishing on wide nodes and standby-misreporting SSDs
Two root causes behind #1516's remaining reports:

- A node whose Proxmox disks/list query fails (PVE probes SMART per disk
  inside that call, so dozens of disks can exceed the API window) now
  falls back to the linked host agent's smartctl inventory instead of
  leaving the Physical Disks view empty. Each node also gets its own
  attempt window so one slow node no longer starves the rest of the
  cluster, and a poll that runs out of budget saves partial results.

- The -n standby probe guard is dropped for positively confirmed
  non-rotational devices in both the host agent and the node sensor
  wrapper. The guard exists to avoid spinning up sleeping HDDs; an SSD
  has nothing to spin up, and some SATA SSDs answer CHECK POWER MODE
  with a bogus standby state that permanently hid their temperature,
  attributes and history.

Refs #1516
2026-07-11 10:24:45 +01:00
rcourtman
eb9954618a Add governed storage pressure cleanup 2026-07-11 10:11:35 +01:00
rcourtman
500d3be2a7 Align Pro feature catalog names with the new Patrol vocabulary
Some checks failed
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Helm CI / Lint and Render Chart (push) Has been cancelled
The public pricing surface and in-app plan-selling copy now say
"Patrol investigates issues and explains the root cause" and
"Patrol applies safe fixes and verifies the result". Rename the
licensing catalog DisplayName/ComparisonName pairs to the title-cased
forms, align the ai_autofix UpgradeReason verbs (apply/verify the
result), regenerate the frontend catalog, and update the Go, vitest,
and Playwright pins plus README and PULSE_PRO docs.
2026-07-11 09:33:47 +01:00
rcourtman
1312da3acb Add governed host update autonomy
Some checks are pending
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
2026-07-11 01:25:14 +01:00
rcourtman
0b15855de3 Align in-app Plans & Billing copy with the new public pricing vocabulary
The public pricing surface (pulserelay.pro and the license-server pricing
model, pulse-pro 3d9e365/2da1290) now sells Pro with the real Patrol mode
names and plain-English job framing. The in-app plan cards still used the
old taxonomy (Choose Patrol mode, Patrol investigates issues, Patrol
handles safe fixes) and 'admin controls' phrasing. Pro plan-selling copy
now matches the public wording: mode names spelled out (Ask first, Safe
auto-fix, Autopilot), investigation explains the root cause, safe fixes
are verified, and RBAC/audit/reporting/agent profiles are 'team controls'.
Functional CTAs (the Choose Patrol mode link) and canonical mode labels
are unchanged. Test pins updated.

The Go-side feature catalog names (Patrol Investigates Issues / Patrol
Handles Safe Fixes) still carry the old phrasing and are a separate
follow-up since they regenerate from pkg/licensing.
2026-07-11 01:16:00 +01:00
rcourtman
b57fb95e20 Fix click-dead zone on the Assistant launcher tab
The document root kept a forced scrollbar (html { overflow-y: scroll })
from before the app-scroll-shell owned scrolling. The root never
scrolls, so the rule only reserved a dead 11px gutter at the right
viewport edge and pulled every fixed right-anchored element off the
true screen edge - parking the desktop Assistant launcher tab under
the scroll shell's own scrollbar, which hit-tests above descendants.
Net effect: the tab's right quarter plus the whole screen-edge strip
silently swallowed clicks, exactly where an edge-docked control gets
aimed at.

Drop the legacy root-scrollbar rule and portal the launcher out of the
scroll shell so its full face receives clicks, flush to the real edge.

Verified live: elementFromPoint returns the button across its entire
width including the last pixel column, and an extreme-edge click opens
the drawer.
2026-07-11 01:02:01 +01:00
rcourtman
2b028d31bd Refresh v6.0.6 RC1 release packet 2026-07-11 00:42:20 +01:00
rcourtman
69b2028513 Add policy-scoped Patrol autonomy 2026-07-11 00:37:34 +01:00
rcourtman
1ae2c6726d Re-pin vSphere activity date-time guardrail to sortable head
The TrueNAS/vSphere sorting adoption (cf0f027b6) moved the activity
table's When header onto PlatformSortableTableHead, so the guardrail's
literal getPlatformTableHeadClassForKind('numeric-value') pin no longer
matches. The alignment intent survives as the sortable head's
kind="numeric-value" prop; pin that form instead. The metadata-badge
and Button guardrail failures predate the sorting commits and are left
for their owner.
2026-07-11 00:36:28 +01:00
rcourtman
4572f83bc0 Document platform-table sort fabric in subsystem contracts
The three sorting commits (20cccc1a9, f66c4b46b, cf0f027b6) added the
shared sortable-header fabric and adopted it across Docker, Kubernetes,
TrueNAS, vSphere, and Proxmox nodes tables, but landed frontend-only,
which skips the local governance audit; Canonical Governance CI then
flagged frontend-primitives and unified-resources for missing contract
deltas. Record the fabric's interaction contract (shared sort state,
persistence, missing-values-sink, aria-sort, canonical alignment) and
the two-layer ordering rule (status-first default, user sort on top).
2026-07-11 00:24:29 +01:00
rcourtman
cf0f027b64 Adopt shared platform-table sorting on TrueNAS, vSphere, and Proxmox nodes tables
v5 parity follow-up to 20cccc1a9, completing the adoption sweep after the
Kubernetes pass (f66c4b46b). Adopts createPlatformTableSortState +
PlatformSortableTableHead across the TrueNAS systems / apps / services /
network shares / protection / storage topology / VMs tables and the vSphere
hosts / datastores / networks / activity tables: per-table storage keys,
descendingFirst on metric, count, and timestamp columns, the built-in order
preserved until the user sorts, and canonical kind-based alignment via the
sortable head. Composite summary columns (inventory, ports, images, access,
clients, devices, flags, hosts, consumers) stay non-sortable.

The TrueNAS storage topology sorts sibling groups and re-emits the tree
depth-first, so pools re-order against pools and every dataset or disk stays
under its parent; a new sort test pins that plus descending-first metrics and
persistence.

ProxmoxNodesTable drops its bespoke non-persisted createSignal sort for the
same fabric, so the nodes sort now survives reloads like every other platform
table. Its existing sort assertions pass unchanged; the suite now clears
localStorage between tests since the sort persists by design.

Also repairs the platformOverviewLayout guardrails at HEAD: f66c4b46b landed
with the TrueNAS/vSphere head assertions already flipped to the kind form and
the Kubernetes assertions still on the legacy form (a staging race between
parallel agents); this commit carries the version with both sets on the kind
form, which is green against the migrated tables.
2026-07-11 00:15:42 +01:00
rcourtman
f66c4b46b3 Restore user column sorting on Kubernetes platform tables
v5 parity follow-up to 20cccc1a9 (v5 KubernetesClusters.tsx persisted
k8s-sort-key/k8s-sort-dir sorting; v6 shipped fixed ordering). Adopts the
shared platform-table sort fabric (createPlatformTableSortState +
PlatformSortableTableHead) across the clusters, nodes, pods, deployments,
controllers, services, storage, config, autoscaling, and networking tables:
per-table storage keys, descendingFirst on metric/count columns (pods Ready
stays ascending-first so the least-ready pods surface), the status-first
compare preserved as the base order until the user sorts, and canonical
kind-based alignment via the sortable head's kind prop. Composite summary
columns (ports, selectors, labels, access/policy, bounds, detail) stay
non-sortable. TrueNAS/vSphere adoption is landing separately.
2026-07-11 00:11:04 +01:00
rcourtman
20cccc1a91 Restore user column sorting on Docker platform tables
v5 parity: v6 platform tables shipped with fixed status-first ordering and
no sortable headers. Adds opt-in sortable-header support to the shared
platform table fabric (createPlatformTableSortState + PlatformSortableTableHead
in sharedPlatformPage.tsx): click cycles first-direction -> flipped -> built-in
order, state persists per table via usePersistentSignal, missing values sink
to the bottom, aria-sort and arrow indicator match the workloads table, and
alignment stays on the canonical kind helpers.

Adopted across the Docker tables (containers, hosts, images, services,
volumes, tasks, swarm nodes, secrets, configs). Container sorting applies
within host groups and stays orthogonal to grouping, unlike v5's
sort-by-host-equals-grouping design. Kubernetes and TrueNAS/vSphere adoption
are follow-ups.
2026-07-10 23:43:45 +01:00
rcourtman
d5437a9353 Allow RCs while Windows signing is pending 2026-07-10 23:12:12 +01:00
rcourtman
d5c4f9adf3 Restore grouped-by-host view and web links on Docker containers
v5 parity regressions reported by a Pro customer after the v6.0.5
upgrade:

- The Docker containers table lost the grouped view: add a
  Grouped/List segmented control (same fabric as the workloads
  table), grouped by host with count badges and host web links,
  persisted locally and defaulting to grouped for multi-host fleets.
- Container web-interface links were buried in the detail drawer's
  access section: the container name now renders through
  WebInterfaceNameLink, linking straight to the resource customUrl
  like VM/LXC guest names do. Applies to all docker native tables
  via the shared name cell.
2026-07-10 22:50:42 +01:00
rcourtman
cb310d9932 Hydrate container customUrl in REST resource snapshot
The websocket broadcast path applies docker metadata (container
customUrl) via applyDockerMetadataToUnifiedResources, but the REST
/api/resources registry seed read the raw unified state view, so the
two payloads drifted: a container web-interface URL saved in the
drawer never appeared in REST-hydrated tables. Apply the same
hydration at the UnifiedResourceSnapshot provider boundary.
2026-07-10 22:48:49 +01:00
rcourtman
4883235936 Add public code signing policy 2026-07-10 22:46:35 +01:00
rcourtman
92ea590000 Prepare v6.0.6 RC1 release packet 2026-07-10 21:39:03 +01:00
rcourtman
5bbfce956d Complete Patrol action lifecycle continuity 2026-07-10 21:22:06 +01:00
rcourtman
3bea52b1b5 Harden native Windows agent lifecycle 2026-07-10 19:16:23 +01:00
rcourtman
c89918d78e fix(setup): make onboarding actions explicit and truthful 2026-07-10 18:21:56 +01:00
rcourtman
e5aebb4ab2 docs(release-control): add home status wall spec for agent handoff 2026-07-10 18:18:27 +01:00
rcourtman
a1ba51630d Commit B (pulse): typed lifecycle is the only Patrol route; delete the command side doors
OrchestratorChatService is replaced, not extended: it exposes only
ExecuteInvestigationStream (returning a structured
OrchestratorInvestigationResult) and ListInvestigationTools. The generic
ExecuteStream, SetAutonomousMode, ListAvailableTools, and the
AutonomousMode request field are gone, along with
OrchestratorCommandExecutor, OrchestratorApprovalStore, and the
autonomy/fix-verifier/license dependency interfaces. OrchestratorDeps
now carries a REQUIRED ActionBroker and no command/autonomy deps.

The pulse investigation adapter is rewritten to drive
ExecuteInvestigationStream, injecting proposal/finding/investigation
identity from trusted context and feeding a per-org proposal catalog
resolved from the tenant-bound action lifecycle (so acceptance and
planning validate identically). The retired command-execution,
autonomy, and command-shaped approval adapters are deleted; the router
wires the broker and catalog factories onto the AI settings handler.

The sixth side door is removed: PatrolService.generateRemediation-
PlanFromInvestigation and its call, which copied Fix.Commands into an
executable enterprise remediation plan, are deleted, pinned closed by a
source-audit test.

ActionCapabilityParamInfo gains Pattern (mapped by the broker), so the
canonical planner's pattern validation survives the cross-repo
boundary.

L20 readiness assertion RA35 asserts every Patrol-initiated
infrastructure mutation originates as a typed proposal and reaches
execution only through the canonical action lifecycle, with unsupported
proposals failing closed. Contract prose updated across ai-runtime,
api-contracts, agent-lifecycle, performance-and-scalability,
security-privacy, and storage-recovery.

Lands together with the pulse-enterprise orchestrator migration (the
replace directive means both heads move as one window).
2026-07-10 18:05:25 +01:00
rcourtman
8fbbed4d05 Harden mobile organization settings 2026-07-10 17:37:13 +01:00
rcourtman
27bc1b356f test(frontend): second-pass branch-coverage for vmwarePageModel and dockerPageModel
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Add vmwarePageModel.coverage2.test.ts (130 cases; power-state/status mappers,
incident + activity row builders, search haystacks, activity resource
resolution) and dockerPageModel.coverage2.test.ts (108 cases; swarm node/service/
task status mappers, swarm evidence, storage-usage bucket, incident rows and
search). Cover functions disjoint from the existing coverage tests; new test
files only.
2026-07-10 17:11:03 +01:00
rcourtman
956f5749d6 A.1: close the proposal-channel contract breaks before Commit B
Privacy: provider-streamed RawInput overrides on tool_progress events
are unredacted model output; for exposure-restricted tools they are now
discarded instead of replacing the projected form (the override was
reintroducing exactly the values the projector removed). Proven with a
progress event carrying a secret in the raw override.

Schema validation: proposal acceptance now validates through the
planner's exported canonical rules - FindCapability's exact-name
matching (the capture previously matched case-insensitively while
planning matched exactly) and ValidateParams for declared, required,
typed, enum, pattern, and malformed-schema cases - so a proposal that
validates is exactly a proposal the planner will accept. The
sensitive-parameter rejection remains a proposal-specific ratchet on
top.

Fail-closed ratchets: params and evidence identity are deep-cloned on
capture and again on outcome, so caller-side mutation after validation
can never alter the actionable proposal; fingerprint serialization
failures return errors rather than a shared sentinel value; an
investigation run refuses to start without finding and investigation
identity (before any provider call or session exists); and any run
error nils the proposal while preserving simultaneous proposal errors
via errors.Join - a non-nil proposal exists only from a completely
successful run.
2026-07-10 17:08:25 +01:00
rcourtman
6230bc9584 Improve mobile layouts across product surfaces 2026-07-10 16:50:59 +01:00
rcourtman
9bc51a8a35 Build the narrow proposal channel: patrol_propose_action and its capture sink
Commit A of the coordinated proposal slice (pulse-internal; the
aicontracts interface replacement and enterprise migration follow as
one window because pulse-enterprise builds against this tree via a
replace directive).

patrol_propose_action is a side-effect-free, mutation-none capture
whose schema carries only resource_id, capability_name, params, and
reason. Registry policy rejects it outside the Patrol investigation
profile before the handler runs, and the same check keeps it out of
every other profile's projected manifest. Correlation identity
(proposal, finding, investigation, evidence) is injected from trusted
orchestration context through the request-local ProposalCapture sink,
which executor clones share so one run has exactly one capture.

Tool calls now carry an explicit invocation envelope (tool-use ID,
name, arguments) through ExecuteInvocation; the ID rides the context
because per-turn tool calls execute concurrently. The sink keys on call
identity plus payload fingerprint: idempotent replay re-succeeds, the
same ID with a different payload latches a terminal integrity error,
and a second distinct valid proposal latches terminal ambiguity - both
terminal states invalidate the captured proposal, since concurrency
makes first-wins nondeterministic. Proposals count only after catalog
validation: advertised capability, declared/required/enum parameters,
and sensitive parameters rejected before success with no value echo in
any output.

ExecuteInvestigationStream returns proposal cardinality as a structured
result with typed errors for ambiguity, integrity violations, and the
failed-attempts-only case (never collapsed into the valid zero-proposal
conclusion); ListInvestigationTools projects through the identical
profile path. Proposal parameter values exist only transiently for
provider continuation and validation: the canonical exposure projector
redacts them from the durable transcript and every
tool_start/progress/end stream event, proven end-to-end with a scripted
provider run that also verifies the provider continuation keeps raw
values.

Essential proof included: two concurrent valid proposal calls produce
ErrProposalAmbiguous and a nil proposal regardless of execution order.
2026-07-10 16:47:23 +01:00
rcourtman
b04aaf876f test(frontend): second-pass branch-coverage for aiFindingPresentation and resourceDetailDrawerTrueNASModel
Add aiFindingPresentation.coverage2.test.ts (176 cases; finding source/severity
presentation, patrol verification/workflow summaries, resolution reason,
attention-queue sort, clipboard formatting) and
resourceDetailDrawerTrueNASModel.coverage2.test.ts (175 cases; percent/duration
formatters, port/volume/network labels, storage-state tone, section builders).
Cover functions disjoint from the existing coverage tests; new test files only.
2026-07-10 16:41:23 +01:00
rcourtman
b380388d30 test(frontend): second-pass branch-coverage for truenasPageModel and agentMachineTableModel
Add truenasPageModel.coverage2.test.ts (93 cases; status mappers, incident/rollup
rows, topology cycle-termination, search tokenizers) and
agentMachineTableModel.coverage2.test.ts (55 cases; sortAgentMachines across every
sort key, RAID summary/detail builders, disk/network/thermal accessors). Target
still-uncovered functions from the v8 branch-coverage backlog; new test files only.
2026-07-10 16:29:09 +01:00
rcourtman
d274c93c58 Close the two profile residuals before the proposal slice
The blocked-question branch persisted its refusal only to provider
context, so the durable transcript retained a question call with no
matching result. The refusal is now appended to the transcript as well,
and the previously deferred end-to-end loop proof drives a scripted
provider turn carrying pulse_question plus a sibling pulse_query under
the detection profile, asserting: no waiting-for-answer event, the
persisted call/result pairing, sibling execution, and continued
inference to the model's final answer.

Execution profiles gain the closed-vocabulary ratchet: Valid() pins the
known set, ApplyExecutionProfile and the loop setter reject unknown
values outright, and NonInteractive() fails closed so an unclassifiable
posture can never inherit interactive permissions.
2026-07-10 16:14:38 +01:00
rcourtman
8e0a1fb290 Let users override cluster member connection addresses
Settings showed PVE cluster members as read-only rows, so a member whose
discovered address is unreachable from Pulse (internal cluster network IP,
stale DNS) had no manual fix; only agent re-registration could adopt a
better IP, which leaves agent-less members stranded. Surfaced in the
"Install issues with V6" support thread.

The node editor now lists cluster members with a per-member connection
address field that writes ClusterEndpoints[n].IPOverride, the field
re-discovery already preserves and EffectiveIP already prefers at poll
time (editing Host would be clobbered on the next cluster refresh).
PUT /api/config/nodes/{id} accepts write-only clusterEndpointOverrides
entries; only changed members ride the payload, an empty value clears
the override, and unknown members are rejected. The configured-nodes
cache mirrors saved overrides instead of spreading the write-only
payload field onto node config state.
2026-07-10 16:11:17 +01:00
rcourtman
9560fe6c07 feat(updates): surface digest-pinned Pro Docker update commands from the broker
A Docker install of the Pro runtime had no sane update path: the binary
self-updater refuses to run in a container, the customer compose file pins
the previous image digest (so `docker compose pull` never updates), and the
in-app guidance showed community `docker pull rcourtman/pulse:<tag>` commands
that silently downgrade the container to the community build - the same
failure mode 93bccaff1 fixed for binary installs.

The update check on the Pro binary already fetches the license server
download broker manifest (GET /v1/downloads/pulse-pro), which carries the
digest-pinned image ref plus ready-to-run compose commands. Relay that block
on UpdateInfo.dockerUpdate for Docker deployments, behind the existing
stable/rc channel guard, failing closed unless both compose commands
reference the digest-pinned ref (never a mutable tag).

Settings -> Updates now renders those commands as copy blocks when an update
is available, and the idle Docker box, the update banner, and the docker
update plan suppress every community-image command whenever the compiled
runtime identity is pro.

Contracts: api-contracts (update transport dockerUpdate block),
deployment-installability (Pro Docker update path), frontend-primitives
(install guide Pro-runtime guidance), with transport and settings-shell
proofs extended accordingly.
2026-07-10 16:03:59 +01:00
rcourtman
4908434f91 test(frontend): second-pass branch-coverage for resourceStateAdapters
Add resourceStateAdapters.coverage2.test.ts (87 cases) covering the canonical
resource/platform mappers (canonicalizeLegacyPlatformData, nodeFromResource,
buildMemory/buildDisk, PBS/PMG instance + job mappers, merge helpers) not
reached by the existing coverage test. Targets still-uncovered functions from
the v8 branch-coverage backlog; new test file only.
2026-07-10 15:57:42 +01:00
rcourtman
97a30b0600 Introduce the Patrol execution profiles: detection and investigation
Execution posture is now profile-owned through the core-only,
never-serialized tools.ExecutionProfile. Both Patrol profiles are
non-interactive, deny infrastructure mutations, and clear any inherited
autonomous mode. Detection restricts pulse-state mutations to an
explicit allowlist of the finding lifecycle tools - a blanket
pulse-state allowance would also permit alert dismissal and knowledge
writes - while investigation denies all pulse-state mutations, keeping
it structurally read-only. InvocationPolicy gains the allowlist and
Allows() is now tool-name-aware.

Chat turns build ONE effective request executor (control level,
autonomy, profile, resolved context) BEFORE provider projection and
clone that executor per provider attempt, reversing the previous
project-from-base-then-clone order so the offered schema and the
runtime boundary always agree. Scheduled Patrol (ExecutePatrolStream)
now runs under the detection profile instead of autonomous mode -
closing its direct view of Docker/Kubernetes mutation subactions - and
ListAvailableTools projects through the identical path.
toolsForExecutionMode's mode booleans are replaced by the profile on
the executor itself.

Non-interactive profiles independently hide pulse_question from the
manifest AND runtime-block it before the interactive-call-set special
case: a fabricated question call returns a non-interactive error
without emitting a waiting event, and sibling tool calls from the same
provider turn keep processing. Approval waits never block for
non-interactive profiles (they queue), and the tool-only-turn wrap-up
guardrail is interactive-profile-owned instead of keyed on autonomy.
The system prompt describes detection and investigation modes directly
rather than claiming controlled or autonomous execution; the
investigation prompt directs the model toward typed action proposals.

Proofs: detection allowlist enforcement (alerts resolve and knowledge
remember blocked, finding tools allowed, projection agrees),
investigation structural read-only (patrol mutation tools dropped from
the manifest and blocked at runtime), profile clone isolation, profile
prompt modes, and question-tool hiding across profiles.
2026-07-10 15:53:38 +01:00
rcourtman
391d3d0be0 Fix responsive navigation and mobile table layouts 2026-07-10 15:19:50 +01:00
rcourtman
8c8affc041 Split tool registration authority and make the registry append-only
Review of 3073a5061 found the remaining registration hole: Register
rejected canonical descriptor overrides but still accepted a canonical
NAME with a nil override, inheriting the canonical descriptor while
replacing the governed handler in the map - an extension could re-register
pulse_read and bypass its execution-intent enforcement.

Registration authority is now split. registerBuiltin is the unexported
construction-time path for canonical Pulse tools: shared descriptor
mandatory, overrides rejected. RegisterExtension - the only path exposed
through PulseToolExecutor.RegisterTool - rejects every canonical tool
name outright and requires the extension to declare its own descriptor.
Both paths are append-only: a name registers exactly once, so no later
registration can swap out an already-governed handler.

Proofs cover the exact bypass (canonical name, nil override), extension
and builtin duplicate rejection, builtin override rejection, and
descriptor-less extension rejection. Tests that previously swapped
handlers by re-registering now use fresh executors per scenario.
Contract prose and source pins updated.
2026-07-10 15:16:06 +01:00
rcourtman
fbf431fa15 Define product-led public language 2026-07-10 15:10:53 +01:00
rcourtman
ebd15b8c67 test(frontend): branch-coverage for alertsConfigurationModel and alertOverridesModel
Add alertsConfigurationModel.snapshot.test.ts (83 cases, exercising the
factory-fallback branches of readAlertsConfigurationSnapshot and
buildAlertsConfigurationPayload) and alertOverridesModel.projected.test.ts
(81 cases, covering buildProjectedOverrides across every resource-type switch
branch plus the ID-candidate builders). Second-pass coverage targeting still
uncovered functions from the v8 branch-coverage backlog; new test files only.
2026-07-10 14:57:42 +01:00
rcourtman
22534bfe71 Clarify self-hosted product roles 2026-07-10 14:49:20 +01:00
rcourtman
0eaa636eb5 test(frontend): branch-coverage for toolPresentation and resourceDetailDrawerVmwareModel
Add toolPresentation.coverage.test.ts (42 cases) covering chat tool-call
input/output parsing and command-preview formatting, and
resourceDetailDrawerVmwareModel.coverage.test.ts (91 cases) covering the VMware
detail-drawer section builders. Both target previously uncovered pure functions
from the v8 branch-coverage backlog. New test files only; no source changes.
2026-07-10 14:32:58 +01:00
rcourtman
3aca9d8f7a test(frontend): branch-coverage for licensePresentation and patrolSummaryPresentation
Add licensePresentation.coverage.test.ts (75 cases) and
patrolSummaryPresentation.coverage.test.ts (98 cases) covering previously
uncovered pure presentation/plan/verification functions flagged by the v8
branch-coverage backlog. New test files only; no source changes.
2026-07-10 14:22:57 +01:00
rcourtman
0ebdbbe07c feat(frontend): stripe the reclaimable-cache memory segment
At warning level the used segment turns threshold-yellow, which sat
next to the amber cache segment as one indistinguishable yellow mass.
Give the cache segment a vertical-stripe SVG pattern so the used/cache
split stays readable at every severity color. Stripes are vertical
because the bar's preserveAspectRatio=none scaling would shear a
diagonal texture.
2026-07-10 14:09:17 +01:00
rcourtman
3073a50614 Harden the invocation-descriptor contract before the investigation profile
Four classifier ratchets from review of 67c2534c0:

The classification vocabulary is closed: descriptor validation rejects
any class outside the known workflow kinds and mutation targets (an
empty Mutation no longer registers), and InvocationPolicy.Allows
independently denies unknown mutation targets outright, so a class
that somehow bypassed validation still cannot execute. Descriptor
lookups and registration store deep copies, so callers can never
mutate the canonical table through shared case maps or static class
pointers. Registration rejects descriptor overrides for canonical tool
names; overrides exist only for genuinely non-canonical extension and
test names.

Projected governance now derives its action mode from mutation
targets, not workflow kinds, and recomputes it even when no enum value
was filtered; a projection whose remaining invocations mutate nothing
downgrades to scope-only approval metadata (registered scope-only
summaries are preserved). Docker check_updates reclassifies from
{write,none} to {read,none}: it queues a read-only scan, and the write
kind was driving the FSM into verification and making the read-only
Docker projection report mixed. Discovery consequently projects as
mode=read in governance manifests, which is the honest mutation-derived
mode. The pulse_file_edit governance summary no longer claims to read
files. The contract prose also names pulse_read's execution-intent
classifier as mandatory second-stage enforcement for exec, not merely
defense in depth: the static read/none descriptor cannot prove
arbitrary command text safe.

Proofs: open-vocabulary rejection, unknown-target policy denial,
descriptor copy isolation, canonical-override rejection, and the
read-only Docker read/scope-only projection.
2026-07-10 13:53:31 +01:00
rcourtman
1685358872 fix(frontend): memory tooltip Used label tracks bar severity color
The stacked memory bar colors its used segment by threshold (green
below warning, yellow/red above), but the tooltip legend hard-coded
Used as green. At 80% used the bar read as all-yellow while the
tooltip claimed a green Used section. Derive the legend color from
the same severity as the segment.
2026-07-10 13:45:27 +01:00
rcourtman
67c2534c08 Replace the hard-coded tool classifier with registry-owned invocation descriptors
Every registered Pulse tool now carries a canonical invocation
descriptor (internal/agentcapabilities/invocation.go): static or
discriminator-based, classifying each invocation with a workflow kind
plus a mutation target (none / pulse_state / infrastructure). Mixed
descriptors must exactly cover their schema enum and registration
panics otherwise, so an unclassifiable tool cannot exist. Missing,
malformed, unknown, or fabricated discriminator values classify
fail-closed as infrastructure writes.

Provider projection and runtime enforcement consume the same
descriptor under one InvocationPolicy (control level plus the
request-local, non-serializable deny_infrastructure_mutations
restriction, isolated across executor clones): ListTools and
ListToolGovernance remove forbidden enum values, drop empty tools, and
recompute the offered action mode, while ToolRegistry.Execute blocks
forbidden invocations before the handler runs. This closes the mixed
tool control-level bypass, most seriously Docker action:update, which
previously fell through to direct execution at read-only, and fixes
the Kubernetes misclassification: the retired switch read the action
argument while the schema discriminator is type, so type:scale
classified as read.

pulse_file_edit is now write-only (append/write); file inspection
routes through pulse_read action=file, whose exec path keeps its
structural read-only execution-intent enforcement. ClassifyToolCall
consults the descriptor table first and retains only genuinely
non-registry compatibility cases. The deny restriction is deliberately
separate from autonomous mode, which only suppresses interactive
questions and grants no mutation authority.

Proofs: descriptor validation and fail-closed classification unit
tests, plus the invocation-policy regression suite (scale classifies
write and never invokes at read-only or under deny; Docker update
queues nothing at read-only; autonomous plus deny cannot mutate;
fabricated enum values fail at runtime; filtered projection and
runtime enforcement agree; executor clones keep request policies
isolated). Contracts and registry ownership updated for the new
shared invocation descriptor boundary.

Slice 3a of the typed-lifecycle ratchet; the patrol_investigation
execution profile and patrol_propose_action tool build on this
substrate next.
2026-07-10 13:31:11 +01:00
rcourtman
99dad2b511 Add a guided Ollama quickstart blessing qwen3:8b for Patrol
Ollama is the zero-cost AI path but the setup row offered only a Server
URL, and Patrol then failed on models that cannot emit tool_calls
(#1463, #847, #1425, #1152, #880). Bless qwen3:8b, the model family
Ollama's own tool-calling docs are written against, verified locally
against Patrol's real preflight: qwen3:8b emitted the tool call on
every run; qwen3:4b never did (0/4), so no low-RAM tag is suggested.

- Registry: SuggestedModel/Note/Equivalents on AIProviderDefinition,
  projected on /api/settings/ai providers; Ollama default model goes
  llama3.2 -> qwen3:8b.
- Provider row: copyable 'ollama pull qwen3:8b' block with hardware
  note, and a next-step hint when a successful test resolves a model
  outside the blessing set.
- Model resolution: exact-ID blessed preference, so pulling qwen3:8b
  makes it the auto-resolved Patrol model with no manual selection.
- Readiness copy names the blessed model (its contract pins landed
  with 94ccc7a0c's staging; this commit restores green).
- manual_ollama_preflight_test.go is the env-gated re-blessing
  harness; contracts updated for ai-runtime, api-contracts,
  frontend-primitives, and the agent-lifecycle/storage-recovery
  dependent boundaries; subsystem_lookup_test line pins follow the
  api-contracts.md insertion.
2026-07-10 13:02:26 +01:00
rcourtman
a97774466e Update stale test-connection assertion to the Provider & Models rename 2026-07-10 12:45:27 +01:00
rcourtman
94ccc7a0c2 Close the 2b residuals on the typed Patrol proposal seam
Proposals now require their full correlation identity before anything
persists: a Submit without a finding ID or investigation ID is refused,
so a planned action can never lose the deterministic link back to its
Patrol finding. Pinned in the plan-only broker contract test.

The persisted-state transition callback becomes org-scoped
(OnActionTransition func(orgID, record)) and is wired through
ResourceHandlers.SetActionTransitionPublisher into the shared lifecycle
service, so a multi-tenant Patrol reconciler can key per-tenant stores
and can never apply a transition to the wrong tenant. Publication still
strictly follows persistence; code-standards pins guard the org-scoped
signature and wiring.

pkg/aicontracts/action_broker.go gains machine ownership: a shared
ai-runtime/api-contracts registry boundary (owned_files plus a sorted
shared_ownerships entry) with path policies proving through
pkg/aicontracts/contracts_test.go, and Shared Boundaries entries
inserted in canonical sorted order in both contracts.

Slice 2b-1 of the typed-lifecycle ratchet; the Patrol reconciler itself
lands with the orchestrator wiring now that ai_handlers.go is free.
2026-07-10 12:43:17 +01:00
rcourtman
8d07089eb3 Finish the Assistant & Patrol settings rename to Pulse Intelligence
Commit cc948b022 fixed the Patrol preflight strings that gated keyless
onboarding; this sweeps the rest. Error messages, readiness checks, and
guidance copy that still pointed at the retired Assistant & Patrol
settings page now name the real surfaces: Pulse Intelligence settings
for the area as a whole, and the Provider & Models settings page for
provider-credential guidance, matching the phrasing cc948b022
established. Pinning tests updated in step, including three
ai_handlers_test.go assertions cc948b022 had already left stale.
2026-07-10 12:04:03 +01:00
rcourtman
0db74c7eab Retheme the provider portal onto the product design tokens with dark mode
The portal shipped with a GitHub-Primer palette, light-only, while every
"Open client" hands the operator into the slate/blue Pulse product UI
(which most monitoring users run dark). The portal now uses the product's
Tailwind slate/blue semantic tokens so both halves of the MSP workflow
read as one product:

- Light theme mirrors the product exactly: slate-100 page, white surfaces,
  slate borders/inks, blue-600 solid buttons.
- Full dark theme (slate-900/800 surfaces, blue-400 links, product-style
  translucent status chip backgrounds with readable 300-series text),
  applied via prefers-color-scheme by default with a persisted header
  toggle (data-theme override in localStorage, nonce'd no-flash bootstrap
  in the page template).
- Hardcoded badge/status border literals, the toast, the danger-button
  hover, and border-only panels that relied on the old white page are all
  tokenized so every state renders in both themes.
- Dropped two dead Roboto @font-face blocks and their data-URI payloads
  (nothing referenced the family): portal_app.css shrinks 135KB -> 43KB.

Verified live in both themes: workspace roster, setup queue, client
onboarding panel chips, Access invite panel, signed-out instruction card,
and the error toast; theme choice survives reload and sign-out. Portal
vitest suite 103/103 incl. new theme tests; frontend/dist sync test green.
2026-07-10 11:59:06 +01:00
rcourtman
26718e70b6 Add the typed Patrol action-proposal seam over the shared lifecycle
pkg/aicontracts gains the plan-only OrchestratorActionBroker contract:
ActionProposal (typed capability reference, no command, host, risk, or
approval fields), a read-only ActionCapabilityCatalog with parameter
sensitivity, ActionDisposition over the existing safe ActionPlanInfo
projection, and an additive Action *ActionReference on
InvestigationSession and InvestigationRecord. ProposedFix/ApprovalID are
documented as migration-only; OrchestratorDeps gains the ActionBroker
seam while CmdExecutor/ApprovalStore are marked legacy pending removal.

internal/api/patrol_action_broker.go implements the seam tenant-bound
over ResourceHandlers.ActionLifecycle(): fixed pulse_patrol actor,
broker-owned ActionOrigin stamped through the service's internal
PlanWithOptions (the public plan endpoint cannot claim an origin),
plan-only submission even for ApprovalNone capabilities, and refusal of
proposals that populate IsSensitive parameters before any persistence.

The lifecycle service adds Capabilities (same registry resolution and
typed errors as planning) and an OnActionTransition persisted-state
callback covering plan, decision, and terminal execution transitions,
published only after the store write succeeds, so Patrol can reconcile
decisions and outcomes deterministically. ActionAuditRecord carries the
new broker-owned Origin, persisted in action_audits.origin_json with a
schema migration and round-trip normalization.

Contracts updated across api-contracts, ai-runtime, unified-resources,
agent-lifecycle, and storage-recovery; proofs added in
pkg/aicontracts/contracts_test.go (propose-only method set, command-free
wire shape, additive reference), internal/api/contract_test.go
(plan-only broker pins), broker behavior tests, lifecycle origin and
transition tests, and a SQLite origin round-trip test.

Slice 2a of the typed-lifecycle enforcement ratchet: additive core
fabric only; enterprise migration and side-door deletion follow.
2026-07-10 11:56:54 +01:00
rcourtman
cc948b022c Route keyless Patrol setup to Provider & Models instead of a dead-end model check
A fresh install with no AI provider looped: the Patrol zero state sent
users to the Patrol model check, the check told them to enable Pulse
Assistant in a settings page that does not exist by that name, and the
Patrol model field silently degraded to a bare text input with no hint
that a provider key or Ollama server was the missing step (#1463, #847).

- Preflight and readiness copy now names the real surfaces (Provider &
  Models, Patrol settings) and the real first step: add an API key or an
  Ollama server.
- The Patrol zero-state CTA, header Fix setup link, and readiness
  banners route config-level causes (assistant_disabled,
  provider_not_configured) to Provider & Models; model-level causes
  keep the Check Patrol model action.
- The Patrol model field shows a linked zero-provider notice instead of
  a bare text input when no provider is configured.
- The preflight result box no longer renders the same failure message
  twice.
2026-07-10 11:38:23 +01:00
rcourtman
0e40ec07cb Make the provider MSP portal honest and self-sufficient without an email provider
Exercised the full provider portal as a pilot MSP would and fixed what
made it feel broken:

- The signed-out portal promised "a sign-in link is on the way" even when
  the control plane has no email provider (the bundle default), and team
  invitations silently sent nothing. The portal bootstrap now carries
  email_sign_in_available and provider_hosted_mode; the sign-in page shows
  the host command that actually prints a link, and the invite panel says
  invitation emails are not sent and how to hand over a link instead.
- New "provider-msp portal-link --email" CLI mints a one-time portal link
  for an account member or pending invitee, so teammates can sign in at
  all on email-less installs (bootstrap only covers the owner).
- Portal sessions were fixed at 12h; CP_SESSION_TTL now configures them
  and provider-hosted MSP mode defaults to 7 days.
- Creating a client past the license cap showed a generic "Failed to
  create workspace." toast: the limit error is now a JSON payload with
  current/limit, the API client no longer drops non-JSON error bodies
  (double body read), and the toast explains the license limit.
- Copy polish: provider-mode sign-in intro (no refunds/privacy register),
  least-privilege default invite role, queue tile label matches "Client
  onboarding", softer Support tab with a docs/MSP.md pointer, setup.sh
  summary now prints the bootstrap next step and day-2 sign-in commands,
  .env.example and docs/MSP.md document portal sign-in and sessions.

Contracts updated (cloud-paid, api-contracts, deployment-installability,
security-privacy) with verification pins in tenant_handlers_test,
config_test, magiclink_test, and provider_msp_deploy_test.

Verified live against a dockerless control plane: portal-link for an
invitee redeems, promotes the invitation, and sets a 7-day session;
the at-cap toast shows the license copy; portal vitest suite and
cloudcp/auth/account/installtests Go suites pass.
2026-07-10 11:22:57 +01:00
rcourtman
ef19e64b28 Stop dev-server full reloads when test files are saved
Once anything requests a test module through the Vite dev server it joins
the client module graph, and every later save of that file dead-ends HMR
into a full page reload broadcast to all connected clients. With parallel
agents saving test files continuously, the dev UI reloaded out from under
interactive sessions every few seconds during bursts (31k+ reloads in the
hot-dev log), which read as "tabs sometimes don't load when clicked".

Ignore test files in the dev watcher, scoped off vitest's test mode so
watch-mode re-runs still see changes. Verified live: test-file touch no
longer reloads a connected page, source HMR unaffected, vitest run green.
2026-07-10 11:09:40 +01:00
rcourtman
f356994869 Extract the transport-independent action lifecycle service
Planning, approval decisions, and execution for typed resource actions
move out of the HTTP handlers in internal/api/actions.go into a new
internal/actionlifecycle.Service owned by api-contracts. The REST
handlers become thin decode/actor/error-mapping adapters over the one
shared service, and ResourceHandlers.ActionLifecycle() exposes the same
service for in-process consumers, so a future Patrol action broker
inherits identical resource lookup, availability checks, plan hashing,
audit persistence, remediation locks, plan-drift revalidation,
execution, and terminal publication instead of loopback HTTP or a
parallel lifecycle.

Behavior is preserved: same status codes, error codes, and audit/
lifecycle persistence ordering, backed by the existing api contract
tests plus new fail-closed proofs for the service itself (unknown
resource/capability, availability refusal, unapproved execution,
remediation lock, plan drift, missing executor, missing store).

Contract text in api-contracts, agent-lifecycle, and storage-recovery
now names the service alongside actions.go and planner.go; the
subsystem registry owns internal/actionlifecycle/ under api-contracts
with a dedicated path policy; the code-standards and contract source
pins follow the moved invariants; and the subsystem_lookup line-number
pin shifts with the api-contracts canonical-files list insertion.

This is the first slice of making the typed action lifecycle the only
autonomous execution route for Patrol, Assistant, and MCP.
2026-07-10 11:06:55 +01:00
rcourtman
3c252918b3 Add branch-coverage tests for eight more frontend models
Covers previously-uncovered branches in truenas page model, disk/storage
presentation, agent machine table, alert overview/config models, connections
table fleet-governance signals, and the TrueNAS detail drawer - 295 assertions
through public entry points, no source changes.
2026-07-10 10:39:47 +01:00
rcourtman
1f139771f7 governance: assign subsystem ownership for internal/agentexec and internal/actionplanner
The typed action planner and the agent command-execution server were
unowned in the subsystem registry even though the contracts already
claim them in prose: api-contracts declares the action plan contract
API-owned through internal/actionplanner/planner.go, and
agent-lifecycle documents internal/agentexec request normalization and
semaphore semantics as one shared protocol contract.

Encode that ownership: internal/agentexec/ joins agent-lifecycle with
a command-execution path policy pinned to the package's test files,
and internal/actionplanner/ joins api-contracts pinned to
planner_test.go. registry_audit and contract_audit pass; both test
packages are green.
2026-07-10 10:12:44 +01:00
rcourtman
67fa4c89c7 Recover the initiating provider on the legacy OIDC callback path
The legacy v5 callback path /api/oidc/callback carries no provider ID,
so extractOIDCProviderID hard-maps it to the legacy-oidc sentinel.
Providers created in the v6 UI get a UUID ID, and when their IdP is
still registered with the legacy redirect URI the callback missed the
provider lookup and returned provider_not_found with no log, breaking
SSO login (authentik and similar).

Peek the pending authorization state across providers to recover the
provider that actually initiated the flow, re-validate it, and proceed.
State stays single-use (peek never consumes), unknown/disabled/expired/
replayed all still fail closed, and the 4-part provider_mismatch guard
is unchanged. The fail-closed path now logs instead of failing silently.

Refs #1533, #1535
2026-07-10 09:53:09 +01:00
rcourtman
7da1f0ae99 Add branch-coverage tests for eight already-tested frontend models
Covers previously-uncovered branches in proxmox backup recovery, AI finding
presentation, chat tool/transcript formatting, docker page model, alert
history/overrides models, and storage page state - 275 assertions through
public entry points, no source changes.
2026-07-10 09:45:11 +01:00
rcourtman
f2b327b42d Cover PBS/PMG resource adapters and vSphere page-model sort ranks
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
2026-07-10 08:51:21 +01:00
rcourtman
0abeb1a041 Update stale Patrol readiness copy assertions to match shipped strings
Commit 20b310201 renamed the Patrol provider-setup action copy
('Check model' -> 'Fix setup', 'Open Provider & Models' ->
'Check Patrol model' with href /settings/pulse-intelligence/patrol)
and updated the sibling patrol tests, but missed these four, leaving
build-and-test.yml red on main. Point them at the shipped copy.
2026-07-10 08:50:44 +01:00
rcourtman
0c7796c0bf Record native lifecycle verification contracts 2026-07-10 01:57:09 +01:00
rcourtman
11bf0c9e74 Fix native agent release lifecycle verification 2026-07-10 01:49:38 +01:00
rcourtman
a0170d3252 Block silent downgrades on apply and give the updater a sanctioned rollback path
The in-app updater validated download URL and channel but never compared
the target against the running version, so any valid older release asset
URL installed silently while the UI presented it as an update. ApplyUpdate
now rejects targets at or below the running version on both the community
and Pro broker paths before any history entry or download, with an explicit
allowDowngrade opt-in on POST /api/updates/apply for sanctioned cases.

The rollback half already existed but nothing reached it: createBackup
retains three backups, restoreBackup works, and history records BackupPath,
yet no endpoint or UI called restoreBackup. RollbackToBackup restores the
retained backup recorded on a history entry after re-validating the path
against the managed backup roots, shares the update-in-flight slot with
ApplyUpdate, records an Action rollback history entry linked to the source
update, marks that update rolled_back, streams a restoring stage through
the existing status/SSE machinery, and restarts via the exit-for-systemd
path. POST /api/updates/rollback carries it with the same RequireAdmin plus
settings:write gating as apply. Rollback is purely local, so the Pro
edition gate never applies to it.

Settings now has the update history surface that was missing entirely:
nothing called /api/updates/history before. The Updates panel lists recent
updates with a Roll back action on successful entries whose backup is still
retained, behind a confirmation dialog naming the restore version, and the
rollback rides updateStore's shared pending-apply marker for the
post-restart toast. restoreBackup also honors PULSE_INSTALL_DIR now instead
of hardcoding /opt/pulse, matching createBackup.

Contract deltas ride along: api-contracts picks up the rollback transport
and downgrade-conflict semantics, agent-lifecycle and storage-recovery pin
rollback as server self-update plumbing, ai-runtime and cloud-paid pin the
update watcher stage vocabulary as non-assistant non-paid shell chrome, and
frontend-primitives adds UpdateHistorySection as the history/rollback
presentation owner with matching architecture proofs.
2026-07-10 01:46:08 +01:00
rcourtman
eb63de0b75 Harden first-run security boundaries 2026-07-10 01:28:33 +01:00
rcourtman
c3f7b4d5c9 Keep action completion contract aligned with remediation locks 2026-07-10 01:22:05 +01:00
rcourtman
33d318a29e Delete the unreachable adapter Execute/Rollback update path and the unused UpdateQueue
The only production consumer of the UpdaterRegistry is the plan endpoint
(GET /api/updates/plan); every real apply runs through the in-Go pipeline
in manager.go ApplyUpdate, with concurrency held by updateMu/updateInFlight.
The InstallShAdapter Execute/Rollback path (install.sh piping, rollback
binary download, health wait) and the UpdateQueue were never called outside
tests, so fixes made there silently did nothing. Adapters are now plan
providers only: the Updater interface keeps SupportsApply, PrepareUpdate,
and GetDeploymentType, pinned by the new registry proof in
internal/api/updates_test.go.

The orphaned fetchAndVerifyReleaseSignature helper goes with it; its
fail-closed sidecar tests now exercise the manager's
downloadAndVerifyReleaseSignature directly. The deployment-installability
sshsig invariant now names the manager pipeline as the only surface that
fetches release artifacts, and api-contracts, storage-recovery, and
agent-lifecycle pin the same plan-provider-only shape at their update
transport boundaries. Stale adapter references in build-release.sh,
validate-release.sh, and the installtests comments are updated to match.
2026-07-10 01:14:40 +01:00
rcourtman
26a4a062f6 Unify the update apply flow behind one store action with a post-update toast
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Both the update banner and the Settings update panel duplicated the
confirm/apply POST with divergent error handling; the banner's failure
path was a native alert(). updateStore.applyUpdate() now owns the POST,
the shared error toast, and a persisted pending-apply marker recording
the pre-update version. On the next version fetch after the post-update
reload, the store confirms the running version moved and shows a
one-time 'Updated to vX.Y.Z' toast, clearing the marker either way.
2026-07-10 01:12:54 +01:00
rcourtman
0c91799e0f Self-test the new binary before the in-app update swaps it in
Checksum and SSHSIG verification prove the downloaded artifact matches
what was published, not that it can run on this host or is the version
the user approved. The apply pipeline previously swapped the binary and
exited, relying on systemd to restart into an unproven executable; a
wrong-arch fallback asset or unstamped build would take Pulse down with
the backup left unused.

ApplyUpdate now locates the extracted binary and probes it with
--version before the backup and swap stages, failing the update with
zero changes applied when the probe fails or reports a version other
than the apply target. Same pattern the agent updater already uses.
Pinned in the deployment-installability contract with
internal/updates/selftest_test.go as the owned proof surface.
2026-07-10 00:44:01 +01:00
rcourtman
4c37a8c332 reporting: fit and wrap operator-controlled cover title and brand lines
Long custom titles on generate-multi covers overflowed the page edges;
the cover now shrinks to fit, wraps at the minimum size, and flows the
subtitle and scope blocks below a wrapped title. Also drop the stale
frontend-primitives contract and registry references to the
Settings/reportingResourceTypes.ts re-export removed in c862fb0ca; that
frontend-only commit skipped the governance audit, leaving the stale
references blocking every Go commit.
2026-07-10 00:42:06 +01:00
rcourtman
f6d9468a84 Lead README with the watching-not-showing story and demote platform-page navigation 2026-07-10 00:38:42 +01:00
rcourtman
c862fb0ca0 Remove dead frontend type module and re-export barrel
Dead-code audit batch 5: types/responsive.ts (all 8 exports had zero
non-definition references) and the one-line
components/Settings/reportingResourceTypes.ts barrel (all consumers
import @/utils/reportingResourceTypes directly). Both were referenced
only via ?raw text imports in guardrail tests; those imports and their
assertions are dropped here in the same commit. The TYPE_COLUMN_*
contract remains pinned against the live sources
(utils/typeColumnContract.ts, utils/typeColumnDefinition.ts).
2026-07-10 00:36:30 +01:00
rcourtman
51155a42c9 Remove uncalled test helpers
Dead-code audit batch 3: hasPrefix in internal/ai/service_test.go and
the staticExternalAgentActivityProvider test double in
internal/api/agent_resource_context_test.go have no callers in any
test or production code.

Skipped from the audit list after re-verification:
createTestEncryptionKey in cmd/pulse/test_helpers_test.go (called nine
times by commands_integration_test.go under the integration build tag).
2026-07-10 00:29:10 +01:00
rcourtman
f7aa9ee922 Remove unreachable exported functions from pkg/
Dead-code audit batch 1: five functions with zero references from main,
tests, or pulse-enterprise (deadcode -test, grep-verified per symbol,
enterprise compile-checked). Also renames the tlsutil test that
exercised the deleted compat alias's target to name the Unverified
function it actually calls.

Skipped from the audit list after re-verification: IsNilAlertPayload
(live pulse-enterprise consumer in internal/aialertanalysis).
2026-07-10 00:27:35 +01:00
rcourtman
3f4bc295d2 Record general release workflow proof 2026-07-10 00:23:28 +01:00
rcourtman
e5faca5035 Fix Docker update copy to recreate the container instead of docker restart
docker restart reuses the image the container was created with, so the
pulled update never takes effect. Point users at docker compose pull &&
docker compose up -d, with pull/stop/rm and re-run for non-compose
installs, matching docs/DOCKER.md.
2026-07-10 00:22:39 +01:00
rcourtman
4ac518a020 Replace reverted unified-nav copy in README with the shipped platform-first navigation 2026-07-10 00:19:48 +01:00
rcourtman
ef895abbd5 portal: replace raw white_label token in onboarding template copy 2026-07-10 00:16:40 +01:00
rcourtman
f4c2fd0c38 Fail closed on unknown remediation lock state for autonomous dispatches
The AI action broker treated an unreadable operator lock as unlocked:
isResourceRemediationLocked returned (false, nil) with no audit store
wired, and the caller logged store errors then dispatched anyway. An
operator's NeverAutoRemediate=true could be silently ignored whenever
the policy store was missing or erroring, which is unacceptable while
Patrol and Assistant run at assisted or full autonomy.

Posture change at the dispatch decision point:
- isResourceRemediationLocked now reports unknown state (nil store or
  lookup failure) as an ErrRemediationLockStateUnknown-wrapped error
  instead of silently defaulting to unlocked.
- New checkRemediationLockForDispatch gate: dispatches without an
  approved human decision fail CLOSED on unknown lock state and
  surface "remediation lock state unknown; operator approval
  required". Human-approved dispatches keep the historical fail-open
  behavior with a warning log. A confirmed lock still refuses even
  approved dispatches, as before.
- executeNativeActionWithAudit (TrueNAS app start/stop/restart) now
  enforces the lock too; it previously skipped the check entirely.
- Refusals persist Failed audit records with stable
  remediation_lock_state_unknown: / resource_remediation_locked:
  ErrorMessage prefixes.
- ai-runtime subsystem contract updated to pin the new posture.

Tests cover store-error and nil-store at both autonomy postures on
both dispatch paths; routing/control tests now wire an in-memory
audit store since autonomous dispatch without one is refused.
2026-07-10 00:14:01 +01:00
rcourtman
dc6b5c3197 Verify AI Kubernetes scale and Docker update actions after execution
Two AI write paths asserted unverified success while the Proxmox guest
and Docker start/stop/restart handlers already do read-after-write
checks. Bring both in line with that idiom:

- pulse_kubernetes scale: re-read the deployment's spec/ready replicas
  via kubectl through the same agent (bounded settle-and-retry window)
  and return a JSON response with a verification block instead of
  "Action complete - no verification needed".
- pulse_docker update: the docker agent already recreates the container,
  health-checks it, rolls back on failure, and acks a terminal command
  status; expose that status through a new Monitor lookup
  (GetDockerCommandStatus) plus UpdatesProvider.GetCommandStatus, and
  poll it within a bounded window. Responses now report verified
  success, verified failure (is_error), or an explicit inconclusive,
  never unverified success.

kubernetes_control_test.go also carries a small in-flight fix from the
parallel remediation-lock work (in-memory ActionAuditStore in the test
helper) that these tests require to run on this tree.
2026-07-10 00:05:22 +01:00
rcourtman
85b2bc4008 Improve platform attention workflows 2026-07-10 00:00:07 +01:00
rcourtman
44da863e1a Remove dead internal/license/subscription alias package
Pure re-export shim over pkg/licensing with zero importers in pulse or
pulse-enterprise (internal/ is unreachable from external modules anyway).
Canonical implementations stay in pkg/licensing.
2026-07-09 23:56:33 +01:00
rcourtman
1c11d0945d Skip deleted directories when deriving golangci-lint targets in pre-commit 2026-07-09 23:52:22 +01:00
rcourtman
1894d2e665 Make native agent tests Windows-safe 2026-07-09 23:51:50 +01:00
rcourtman
a3ef1226b7 Fail fast on missing native signing configuration 2026-07-09 23:44:22 +01:00
rcourtman
211b80717d Partition host agent tests by platform 2026-07-09 23:40:34 +01:00
rcourtman
51c60df3a4 Exercise native signing in release rehearsals 2026-07-09 23:36:06 +01:00
rcourtman
8a186e2c58 Reconcile concurrent archive validation commits 2026-07-09 23:34:14 +01:00
rcourtman
453b478ac9 Validate release archives in one pass 2026-07-09 23:33:22 +01:00
rcourtman
a27350378e Validate release archives in one pass 2026-07-09 23:28:02 +01:00
rcourtman
20b3102015 Improve monitor-first frontend workflows 2026-07-09 23:26:53 +01:00
rcourtman
255c7c23d4 Modernize Unified Agent lifecycle and platform support 2026-07-09 23:20:35 +01:00
rcourtman
a5b8d9a3ee Allow complete release candidate validation 2026-07-09 23:11:38 +01:00
rcourtman
54f700371e Add unit tests for 12 untested and weakly-tested frontend modules
Nightly GLM grunt wave: new Vitest coverage for pure logic that had no
test file or only shallow export coverage. 436 test cases, all green;
tsc and eslint clean. Tests characterize current behavior only; no
source was modified.

Untested modules now covered:
- api/responseUtils (19 coercion/guard fns)
- utils/workloads (8 predicates/builders)
- features/storageBackups/storageSearchQuery, storageMetricsIdentity
- utils/platformSupportManifest (12 manifest lookups)
- stores/sessionCapabilities, content/help registry

Weakly-tested modules strengthened (new *.coverage.test.ts):
- utils/format (5 formatters), features/alerts/helpers (apprise round-trip)
- utils/cloudPlans (parseCloudTier), utils/alerts, utils/agentResources
2026-07-09 23:08:09 +01:00
rcourtman
fb75ac6a05 Quiesce discovery backfills during shutdown 2026-07-09 23:03:21 +01:00
rcourtman
02283c5335 Make connected systems task-first 2026-07-09 22:48:52 +01:00
rcourtman
cc0952e491 Run signed candidate builds during dispatched rehearsals 2026-07-09 22:32:40 +01:00
rcourtman
949ba7ed31 Reconcile concurrent main updates 2026-07-09 22:32:01 +01:00
rcourtman
cce58ceb19 Improve platform frontend coherence 2026-07-09 22:27:35 +01:00
rcourtman
f5c427a4e1 Improve platform frontend coherence 2026-07-09 22:23:21 +01:00
rcourtman
8dda0b6efa Build releases once and promote verified candidates 2026-07-09 22:21:34 +01:00
rcourtman
b26716b3c9 Add unit tests for localStorage, logger, toast utils 2026-07-09 21:47:27 +01:00
rcourtman
9706c4fade Add unit tests for alert override identity, threshold mutation models, patrol autonomy 2026-07-09 21:46:11 +01:00
rcourtman
70161e0079 Record unattended patch release proof 2026-07-09 21:42:26 +01:00
rcourtman
ba49c28878 Add unit tests for platformPage, docker drawer, and backup presentation modules 2026-07-09 21:33:56 +01:00
rcourtman
0fa841f66f Improve monitor-first attention states 2026-07-09 21:29:45 +01:00
rcourtman
01b14733cf Fix Docker and Kubernetes agent clock-skew liveness
Stamp LastSeen and rate-tracker samples with server receipt time instead
of the agent-reported timestamp, matching the host agent fix. A drifted
agent clock could otherwise mark a reporting Docker host or Kubernetes
cluster permanently stale and loop offline/recovery alerts.

Refs #1519
2026-07-09 21:21:03 +01:00
rcourtman
112a2a68e9 Remove weak ?raw contract-test assertions that pin nothing
Delete generic substring pins (toContain('Button') next to
ButtonLink/ActionIconButton siblings, 'import {', 'const {') that can
never fail independently, plus the not.toContain('Pro') booby trap on
DataHandlingPanel that the word Proxmox would spuriously trip. Where
sibling coverage was incomplete, replace with a specific pin instead:
ErrorBoundary 'Button' becomes the shared-Button import line, and the
proxmoxVersion 'unknown' pin becomes the actual sentinel check.
2026-07-09 21:08:21 +01:00
rcourtman
958c6dfc6e Remove dead empty-iterable guardrail loops from contract tests 2026-07-09 21:03:16 +01:00
rcourtman
0d89be1c91 Use canonical audit record API in migration test 2026-07-09 20:58:23 +01:00
rcourtman
33b287f515 Add unit tests for alertThresholdDefaults, storageSources, storageSummaryCache utils 2026-07-09 20:42:58 +01:00
rcourtman
a040c7d7a6 Add unit tests for agentVersion, proxmoxVersion, resourceSearchMatch, vmwareDisplay utils 2026-07-09 20:40:37 +01:00
rcourtman
7897a9131e Fix FreeBSD agent update recovery
Refs #1546
2026-07-09 20:36:58 +01:00
rcourtman
f8bbae2f34 Expose demo tailnet identity diagnostics 2026-07-09 20:32:43 +01:00
rcourtman
dc27927fb8 Merge remote-tracking branch 'origin/main' 2026-07-09 20:31:22 +01:00
rcourtman
c59af9a501 Fix guest suppression for posture alerts
Refs #1545
2026-07-09 20:28:52 +01:00
Richard Courtman
585761c398 Complete security boundary governance record 2026-07-09 20:22:35 +01:00
rcourtman
910418c3b2 Make stable patch releases unattended 2026-07-09 20:16:13 +01:00
Richard Courtman
1968dc4171 Close remaining CodeQL allocation and cookie gaps 2026-07-09 20:10:08 +01:00
rcourtman
042e7ef966 Harden remaining CodeQL security boundaries 2026-07-09 19:46:40 +01:00
rcourtman
e8ad8b2fd3 Use checked int parsing for Proxmox conversions 2026-07-09 17:47:23 +01:00
rcourtman
92524e1c27 Harden CodeQL storage and integer boundaries 2026-07-09 17:37:08 +01:00
rcourtman
24b2e40e92 Harden scanned request and storage boundaries
Harden CodeQL-scanned request, command, path, and frontend sinks across relay proxying, availability probes, connection probing, notification CLI execution, report storage, licensing persistence, preview bootstrapping, tooltip rendering, logging, and test identity generation.
2026-07-09 17:22:29 +01:00
rcourtman
2be167331d Harden demo SSH setup for IP targets 2026-07-09 17:06:52 +01:00
rcourtman
76ced45c3a Harden demo SSH setup for private deploy hosts
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
2026-07-09 16:51:38 +01:00
rcourtman
584aa10699 Stabilize multi-tenant org switch in release E2E 2026-07-09 14:34:46 +01:00
rcourtman
1a05c715ac Reuse authenticated state in multi-tenant release E2E 2026-07-09 13:40:35 +01:00
rcourtman
5fe6bfde57 Harden release integration diagnostics and login retries 2026-07-09 12:50:55 +01:00
rcourtman
fbddd91fcb Add install-script regression tests for the piped and version fixes
Cover the two installer bugs fixed in c98acb9e8 so they cannot regress:

- TestRootInstallScriptSourceGuardSurvivesPipedExecution runs the source guard
  through stdin the way curl ... | bash does and asserts it reaches the
  installer body instead of aborting on BASH_SOURCE[0] unbound. (#1526)

- TestInstallSHAgentVersionMismatchIgnoresVPrefix drives the version check and
  asserts v6.0.4 vs 6.0.4 does not warn while a real 6.0.3 vs 6.0.4 does, plus
  content assertions pinning the normalization in scripts/install.sh. (#1527)

Refs #1526 #1527
2026-07-09 12:04:41 +01:00
rcourtman
6999ca0119 Prepare 6.0.5 release 2026-07-09 11:52:28 +01:00
rcourtman
c98acb9e85 Harden the installers against piped execution and v-prefixed versions
Two installer bugs reported after the v5 to v6 upgrades:

- The server installer (install.sh) guarded its "am I being sourced?" check
  with a bare ${BASH_SOURCE[0]}. When piped to bash (curl ... | bash) there is
  no source file, so under `set -u` the run aborted with "BASH_SOURCE[0]:
  unbound variable" before the installer body. Default the lookup and only
  early-return on a genuine source. Regression of the v5 fix in #1396. (#1526)

- The agent installer (scripts/install.sh) compared the downloaded binary's
  version ("v6.0.4") against the server /api/version value ("6.0.4") verbatim,
  raising a spurious mismatch warning on matching versions. Strip a leading "v"
  from both sides before comparing so only a genuine difference warns. (#1527)

Refs #1526 #1527
2026-07-09 11:31:43 +01:00
rcourtman
847cf98544 Fix activation fingerprint and license period status 2026-07-09 11:30:26 +01:00
rcourtman
f850099134 Adopt the agent's preferred host when re-registering a matched node
The resolved-identity match in canonical auto-register always preserved
the stored host, so the route-aware IP preference added to the agent in
022f170df could never take effect for an already-registered node: a
reinstall re-matched the node and kept the stale short-DNS host forever.
Preserve the stored host only when it is absent from the agent's ordered
candidate list (an admin-managed endpoint the agent cannot see); when the
agent itself lists the stored host as a lower-priority candidate, adopt
the newly selected host. Identity continuity (same node record, same
token) is unchanged.

Note: commit c21693489 carries this same message by mistake; it actually
contains concurrent OIDC/SAML mapping work from a parallel session that
was staged when the shared index raced. This commit is the real change.
2026-07-09 09:56:04 +01:00
rcourtman
c21693489a Adopt the agent's preferred host when re-registering a matched node
The resolved-identity match in canonical auto-register always preserved
the stored host, so the route-aware IP preference added to the agent in
022f170df could never take effect for an already-registered node: a
reinstall re-matched the node and kept the stale short-DNS host forever.
Preserve the stored host only when it is absent from the agent's ordered
candidate list (an admin-managed endpoint the agent cannot see); when the
agent itself lists the stored host as a lower-priority candidate, adopt
the newly selected host. Identity continuity (same node record, same
token) is unchanged.
2026-07-09 09:52:59 +01:00
rcourtman
e240f162e9 Reserve the latest markers for the highest stable release
A maintenance cut of an older line (v5.1.36 after v6 GA, or a future
6.0.x patch after 6.1 ships) was allowed to move Docker/GHCR :latest and
the GitHub latest release marker onto itself, silently downgrading every
install that follows latest. Promote :MAJOR and :MAJOR.MINOR
unconditionally, but :latest and make_latest only when the tag is the
highest stable semver. workflow_dispatch gains force_latest as the
explicit rollback escape hatch.
2026-07-09 09:37:40 +01:00
rcourtman
93bccaff18 Update the Pro binary in-app from the license server download broker
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
The in-app updater and the unattended timer both target the public
rcourtman/Pulse community assets, so a Pro install that used them was
silently downgraded to community. Guard 2 (983a89326) blocked in-app
apply on the Pro edition, which stopped the downgrade but left Pro
installs with no update path except manual portal downloads. That
friction is a plausible driver of the runtime split: as of 2026-07-08
only 10 of 66 active paid licenses have any Pro-runtime install.

Root fix: the compiled Pro binary now checks and applies updates
through the license server download broker (GET /v1/downloads/pulse-pro
with the installation token and instance fingerprint). The check
compares against the broker's pinned private release instead of GitHub,
respecting the stable/rc channel guard. Apply re-resolves fresh signed
R2 URLs at apply time, verifies the archive against the same pinned
pulse-installer SSHSIG key plus the broker manifest sha256, and refuses
GitHub-shaped download URLs outright. An unactivated Pro binary still
refuses with the portal fallback. The community edition path is
unchanged.

The update banner restores in-app apply for auto-updatable Pro
deployments and keeps the portal instructions for deployments the
updater cannot drive (Docker). scripts/pulse-auto-update.sh now skips
when the installed binary reports Pulse Pro so the unattended timer can
never reinstall community over Pro.

Note: internal/updates/pro_update.go and manager_pro_update_test.go for
this change landed one commit early inside 313552deb via a parallel
session committing a shared staged index; this commit completes the
wiring they belong to.
2026-07-08 23:39:37 +01:00
rcourtman
313552debc Pin the OAuth Tailscale path in the update-demo workflow test
TestUpdateDemoWorkflowUsesGovernedNetworkPath still asserted the retired
authkey: TS_AUTHKEY line; 0a9a29d63 moved update-demo-server.yml to the
OAuth client (TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET, tag:infra), so the
test now pins those lines instead.
2026-07-08 23:23:20 +01:00
rcourtman
8222f132a8 Explain the pre-5.1.29 auto-update jump to v6 in the upgrade guide
v5 installs on 5.1.28 and older have no release-line pin, so with
auto-update enabled they silently in-place upgrade to the newest stable
GitHub release, which is now v6. Add an FAQ entry for the operator who
lands on v6 without asking, and point the rollback path at 5.1.36, the
final v5 release (was 5.1.35).
2026-07-08 22:56:57 +01:00
rcourtman
ef25600270 Name the activation key in Pro runtime mismatch copy
Settings > Plans told paid users on the community runtime to open the
Pro download page "with your license key", but that page rejects
license keys and demands a ppk_live_ activation key, and migrated v5
customers have never been shown one (support case: lifetime customer
pasted his v5 key, hit the prefix error, and emailed support). The
mismatch copy now names the activation key and points at
pulserelay.pro/retrieve-license, which issues one self-serve.
2026-07-08 21:06:55 +01:00
rcourtman
84844003b7 Repair canonical-copy expectation missed by the trial front door commit
b0792890b added planComparisonTrialActionLabel/Note to the billing
presentation without updating the pinned copy object in
licensePresentation.test.ts (hooks do not run vitest, so it landed red).
2026-07-08 21:06:42 +01:00
rcourtman
5f221941c7 Retire Patrol specs pinned to presentation the workbench simplified
The finding-links spec asserted Open related links into the retired
standalone routes from the pre-workbench findings list; those cross-link
affordances were removed deliberately with platform-first navigation,
and the monitor-first workbench feeds findings through a different
pipeline its stubs never reach. The run-history breakdown spec pinned
per-run type counts that the workbench simplified to plain totals; the
underlying count-separation contract (truenas_checked distinct from
agent hosts) lives in the investigation context model and is unit
covered there.
2026-07-08 17:23:04 +01:00
rcourtman
476e0bccb8 Point the TrueNAS mention spec at the live surface and mark it fixme
The spec entered through a retired settings route with an inert REST
stub (mention candidates come from websocket state) and the old Expand
Pulse Assistant launcher name. Re-pointed at the TrueNAS platform page
and the Ask Pulse Assistant launcher, it exposes a real regression:
Docker app-containers appear in the @-mention autocomplete but TrueNAS
app-containers no longer do, despite identical resource typing in
websocket state. The spec keeps the contract and skips via fixme until
mention targeting is restored.
2026-07-08 17:16:25 +01:00
rcourtman
95a7c7f3f5 Pin TrueNAS alert investigation on the resource incidents panel
The alert-links spec pinned Open related links into the retired
standalone routes, which were removed together with those routes'
cross-link affordances; its stub timestamps had also aged out of the
history window. The stubbed TrueNAS alert now uses fresh timestamps, the
Resource action is scoped to its own row (live mock alerts share the
page), and the contract asserts the surviving canonical handoff: the
resource incidents panel scoped to the alerting resource.
2026-07-08 17:03:56 +01:00
rcourtman
9220bab24e Pin TrueNAS disk history through the storage drawer
The disk-history contract lived on the retired /storage route with REST
stubs the platform pages never read. It now drives the TrueNAS storage
section against the mock dataset: the SMART-failing sdc disk opens the
resource drawer, and the History tab must request the disk series from
the metrics store under a canonical key (serial when available, the
disk:<node>:<device> composite otherwise - the store serves both, which
is what protects serial-less disks).
2026-07-08 16:58:51 +01:00
rcourtman
e23505206d Carry the max_users grant claim into instance license limits
GrantClaims and Claims gain a MaxUsers seat limit mirroring MaxGuests:
the grant JWT claim is copied at the activation bridge and surfaced
through EffectiveLimits()["max_users"], which the already-shipped
user-limit enforcement (MaxUsersLimitFromLicense) reads unchanged.
Inert by default: no plan sets max_users yet, so absent claim = 0 =
unlimited for every existing license and grant. The self-hosted
commercial volume scrub strips only max_monitored_systems and
max_guests; tests now pin that max_users survives it from both the
named field and explicit limits, plus the end-to-end proof that a grant
carrying max_users=3 enforces 3 and a grant without it stays unlimited.
Contract shape recorded in cloud-paid Extension Point 26; the grant
wire contract list gains max_users against the relay-server reference
(pulse-pro 95a18bd).
2026-07-08 16:56:07 +01:00
rcourtman
eec5f7ec8c Pin TrueNAS thresholds under the platform-first thresholds scope
The thresholds surface replaced the neutral infrastructure/systems/
containers groupings with per-platform scopes, so the TrueNAS routing
contract inverted by design: TrueNAS systems, pools, datasets, and disks
now live under their own scope button. The spec asserts that scope's
sections against the mock dataset instead of the retired neutral pages.
2026-07-08 16:54:30 +01:00
rcourtman
e3ebbbcdc7 Retire two more specs pinned to removed unified-route contracts
The TrueNAS storage-links spec asserted cross-links from the retired
/infrastructure card into the retired /workloads, /storage, and /recovery
routes, and the VMware source-filter spec asserted the retired
/infrastructure source dropdown; platform-first navigation replaced both
concepts with the per-platform pages and their section navigation, which
the platform-pages shell spec covers.
2026-07-08 16:48:03 +01:00
rcourtman
425400f38e Mark the PBS drill-in spec fixme for an unreachable surface
The PBS active-task and job-health evidence drawer content this spec
pins still exists in ResourceDetailDrawerOverviewTab, but the
platform-first rework left it unreachable: proxmoxPageModel routes PBS
resources only into the Backups summary servers table with no expansion,
no UnifiedResourceTable lists the resource, and neither the workloads
search nor the command palette can reach it. That is a dropped product
surface, not spec rot, so the spec stays as the contract and skips via
fixme until the drill-in returns.
2026-07-08 16:45:23 +01:00
rcourtman
02c8e5cce3 Assert offline-node visibility against the mock fleet state
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Platform pages render websocket state, so the offline-node guard's REST
stubs never reached the surface it was checking; it also targeted the
retired /infrastructure route. The mock scenario already forces pve5
(Disaster Recovery B) offline deterministically, which is the exact
state the guard exists for: the spec now asserts on /proxmox that the
offline node stays listed with dashed live metrics while keeping its
web-interface link and detail expansion.
2026-07-08 16:20:00 +01:00
rcourtman
fca5975c38 Add the dormant Business tier to the licensing core
TierBusiness: Pro's feature set, 365-day history retention, uncapped
core monitoring like every self-hosted tier, Business display name, and
a slot in the min-tier ordering. Differentiation is by max_users limit,
retention, and support rather than features (multi-user is already a Pro
capability via RBAC; see specs/pricing-segmentation.md binder findings).
Dormant until the license server issues business plan versions; no
checkout, pricing, or UI surface references it yet. Contract shape
recorded as cloud-paid Extension Point 26 and pinned by
TestBusinessTierContractShape.
2026-07-08 16:02:56 +01:00
rcourtman
a205ba3498 Repair storage-recovery registry proof path after spec rename
1c38d7993 replaced 17-recovery-layout.spec.ts with
17-proxmox-backups-layout.spec.ts but the storage-recovery registry
entry still named the old file, which broke the registry audit for
every subsequent governance-audited commit.
2026-07-08 16:01:23 +01:00
rcourtman
5b6564c693 Re-pin the upgrade-return flows on the redesigned billing panel
The upgrade-return spec asserted the pre-redesign panel: the plan
comparison summary paragraph, Compare plans link, activation summary
copy, and purchase-state URLs that survive verbatim. The current panel
presents plan cards with View plans links, the activation summary hands
off to Patrol mode selection, purchase-state params are consumed into
notices and normalized out of the URL, and a failed activation lands
directly on the recovery deep-link with the disclosure open. Six of the
seven flows are green against that reality; the unavailable-handoff flow
is marked fixme because it exposes a real defect (rel=noopener defeats
the original-tab reopen and the popup fallback strands users on /),
tracked for a governed product fix together with the still-legacy
checkout redirect paths.
2026-07-08 15:56:55 +01:00
rcourtman
b0792890b0 Add the License-panel Pro trial front door (pull-only)
Completes the approved trial flow end to end. The License panel's plan
comparison gains a 'Start 14-day free Pro trial' action (shown only when
unlicensed, trial-eligible, and never-trialed) that rides the existing
purchase-start handoff with trial=1; the handler already forwards
non-reserved params. Pulse Account portal reads the trial arrival flag,
relabels the pro monthly checkout button, shows the card-required note,
and sends trial:true to the checkout session endpoint (server support
deployed in pulse-pro 4c3a28c). Organic portal and public pricing
arrivals see no trial anywhere, per the no-upsell doctrine; retired
local trial-acquisition routes remain 404. Portal dist rebuilt.
2026-07-08 15:29:54 +01:00
rcourtman
34f64e68fc Retire the billing capacity-review guard for a removed surface
The billing plan panel no longer renders a monitored-system capacity
review block; capacity truth surfaces at the point of adding systems
through the connection dialogs' impact previews, which the consolidated
workspace specs pin. The spec keeps its surviving guard: self-hosted
billing shows no upgrade pressure while monitored-system usage is
unavailable.
2026-07-08 15:18:39 +01:00
rcourtman
c9c48495ba Authenticate the self-hosted billing and upgrade-return flows
Specs 54 and 55 never authenticated; they only ever passed against an
already-authenticated dev session, so on the e2e stack every test landed
on the login screen before reaching the billing panel. Entry helpers now
establish a session first. Both specs still carry assertions against the
pre-redesign billing copy (upgrade arrival, capacity review wording);
that rework is tracked separately - this lands the auth prerequisite that
any version of those assertions needs.
2026-07-08 15:08:56 +01:00
rcourtman
1c38d79937 Replace recovery layout guards with Proxmox backups layout guards
The recovery layout spec pinned the retired standalone /recovery surface
down to its testids and the /api/recovery/series endpoint, none of which
exist anymore; backup activity lives in the Proxmox Backups section now.
The replacement guards the current surface against the same regression
class: picking an activity day narrows the backups table in place, a
year-long range renders its 365 bars without pushing the page into
horizontal overflow, and the PBS servers table keeps its trailing column
inside its wrapper on the default desktop column set.
2026-07-08 15:02:05 +01:00
rcourtman
2fdc582607 Follow the Provider & Models heading rename in the activation journey
The license-activation journey (env-gated, skipped without activation
credentials) still asserted the retired Assistant & Patrol heading on
/settings/system-ai; the panel renders Provider & Models now, per
settingsHeaderMeta. Copy-only alignment; the journey itself still needs
a live activation environment to run.
2026-07-08 14:53:26 +01:00
rcourtman
e29c28505d Drive provider setup through the Set up Pulse Intelligence dialog
The provider-setup spec asserted the retired Assistant & Patrol shell:
old heading, old enable button, the old setup dialog names, and a Model
Overrides section that no longer renders (per-section overrides moved to
the Patrol, Assistant, and Service Context panels). The contracts are
re-pinned on the current flow: first enable opens the Set up Pulse
Intelligence dialog and submits provider credentials with no hardcoded
model, saved Patrol readiness warnings keep their provider and model
context, and a rejected save through Save provider settings keeps the
preflight recommendation in the failure alert.
2026-07-08 14:52:29 +01:00
rcourtman
4684d22049 Point the retired-quickstart contract at the Provider & Models panel
The spec asserted the pre-rework AI settings shell (Assistant & Patrol
heading, Enable Assistant and Patrol button, and the retired setup
dialog). The durable contract survives on the current panel: pressing
Enable Pulse Intelligence without a configured provider issues no
settings update, no quickstart copy appears anywhere, and a direct
enable via the API still fails without resurrecting quickstart state.
2026-07-08 14:43:08 +01:00
rcourtman
a1c22bf55c Re-pin the demo tailnet join on the OAuth client secrets
Commit 0a9a29d63 moved deploy-demo-server.yml and update-demo-server.yml
from the static TS_AUTHKEY to the Tailscale OAuth client but left the
release promotion policy pin asserting TS_AUTHKEY, so the Canonical
Governance run failed at the promotion policy unit tests once the
completion guard false positive (fixed in 54a6118d1) stopped masking
the step. The pin now asserts the OAuth client id and secret and pins
the static key retirement.
2026-07-08 14:40:17 +01:00
rcourtman
d61f57a1e2 Align doc-link and telemetry-disclosure specs with shipped surfaces
The disclosure specs pinned pre-rework copy and routes: the telemetry
summary and PRIVACY.md were rewritten (one outbound usage-data scope),
chat action controls moved to the Assistant panel where the autonomous
option is gated on runtime autonomy (stubbed to the unblocked capability
set, since the e2e stack runs the community runtime), and the billing
Terms of Service link sits inside the collapsed manual key recovery
disclosure. With the .md content-type fix the shipped docs now render in
the popup, so the open-doc assertions exercise real navigation again.
2026-07-08 14:38:35 +01:00
rcourtman
07fb7dbbac Serve shipped markdown docs as text instead of a forced download
The embedded frontend file server had no content type for .md, so every
in-app "Full details" / "Terms of Service" / security-guide link to
/docs/*.md answered application/octet-stream and the browser downloaded
the file instead of showing it. Shipped docs now serve as
text/plain; charset=utf-8 and open readable in the tab the app targets.
2026-07-08 14:36:55 +01:00
rcourtman
54a6118d17 Fix CI false positives in the canonical completion guard
The guard judged substantive contract updates by diffing HEAD against
the index. In CI nothing is staged, the index equals HEAD, so every
contract file piped in via --files-from-stdin looked unchanged and the
guard blocked compliant pushes. Concretely, run 28944317805 blocked
7645965af even though its deployment-installability.md addition sits
inside the Current State section.

The guard now accepts --diff-base <ref> (requires --files-from-stdin),
resolves it to its merge base with HEAD so the comparison anchor
matches the three-dot changed-file list, and compares base vs HEAD
contract texts in that mode. Pre-commit keeps the index comparison.
The canonical-governance workflow passes the push or PR range base.
2026-07-08 14:35:07 +01:00
rcourtman
0a9a29d63d ci: join tailnet via OAuth client instead of static TS_AUTHKEY 2026-07-08 14:28:51 +01:00
rcourtman
061adc85d0 Scope page-header consistency to surfaces that render the PageHeader
The header-framing spec pinned four retired standalone routes; platform
pages intentionally carry no page header, so the contract now covers the
utility surfaces that still render the shared PageHeader (alerts overview,
settings general, and Patrol, whose description follows the current copy),
and the vertical-alignment check pins alerts against Patrol.
2026-07-08 14:17:14 +01:00
rcourtman
98cfe290a1 Re-pin Patrol runtime contracts on the monitor-first surfaces
Five Patrol tests drove the retired Configure Patrol dialog and the
"Automation:" status strip, both removed with the monitor-first workbench,
so they could only time out on the dead affordances. The durable contracts
are re-pinned where the behavior lives now: a rejected Patrol settings save
surfaces the server reason from the settings panel, a save that returns a
not-ready Patrol model surfaces the readiness blocker with provider and
model context, and the Patrol mode group stays clamped to Watch only with
the Pro modes disabled when the runtime lacks autonomy, even when the
server reports a stale full autonomy level. The scoped-trigger strip
wording tests had no surviving surface; workbench presentation is covered
by the monitor-first workbench spec.
2026-07-08 14:17:04 +01:00
rcourtman
7645965afe Derive the rollback target for scheduled release rehearsals
The weekly release-dry-run schedule failed at 'Resolve rehearsal
metadata' because GitHub does not apply workflow_dispatch input
defaults to schedule events, so rollback_version arrived empty and
resolve_release_promotion.py rejected the run.

Scheduled runs now pass --derive-rollback-latest-stable, which fills
an empty rollback_version with the latest stable tag preceding the
rehearsal version (currently v6.0.4 for 6.0.5-rc.3). Manual dispatches
keep the explicit rollback_version requirement; the stale prefilled
5.1.29 default is removed so operators state the target themselves.
The deployment-installability contract records the scoped scheduled
exception.
2026-07-08 13:52:48 +01:00
rcourtman
0d787a4b1f Prepare v6.0.5 RC4 release packet
Refs #1535
2026-07-08 13:10:06 +01:00
rcourtman
16d74a75bb Follow the connections-workspace spec rename in governance references
Commit 346ba81ed rewrote the per-platform connection specs against the
consolidated Connected systems workspace but left the old spec paths in
RA20 evidence and two registry.json proof lists, failing release_ready.
Point them at the renamed workspace specs.
2026-07-08 13:01:15 +01:00
rcourtman
346ba81ede Rewrite platform-connection specs against the consolidated workspace
The per-platform connection workspaces under
/settings/infrastructure/platforms/* are retired compatibility paths;
connections now live in the consolidated Connected systems workspace, whose
table reads the unified /api/connections endpoint and whose add dialogs
still speak the unchanged per-platform REST APIs. The TrueNAS and VMware
specs are rewritten against that surface: table listing from
/api/connections, the add dialog's draft Test connection payload, the
monitored-system impact preview copy from monitoredSystemPresentation, the
capacity-denial alert (the server explanation now surfaces as a
notification while the dialog stays open), and the structured
unsupported-vCenter draft-test guidance, which kept its testid and copy in
the ConnectionEditor.

The demo-boundary spec's settings heading follows the same rename
(Infrastructure Operations -> Infrastructure); its remaining retired-route
references are tracked for the demo-contract pass.
2026-07-08 12:26:13 +01:00
rcourtman
3440923cb0 Exercise the mobile settings drawer in the shell-consistency spec
On mobile viewports the settings navigation sits behind the Settings
drawer trigger rather than a persistent sidebar, and panel descriptions
are desktop-only copy (hidden sm:block). The spec now opens the drawer
before asserting the shared navigation and search on mobile projects and
scopes the description assertion to desktop, instead of failing on a
sidebar mobile intentionally does not show.
2026-07-08 12:08:46 +01:00
rcourtman
75047c0dfc Re-point governance evidence at surviving platform-first specs
Commit 005100432 retired the unified-route e2e specs but left two
status.json evidence entries pointing at deleted files, failing
repo_ready and RA20. Re-point them at the surviving coverage the
retirement commit named: refresh resilience via the ported Proxmox
workloads stability spec, and TrueNAS workload surfaces via the
platform pages shell spec.
2026-07-08 11:50:30 +01:00
rcourtman
68aff00d78 Announce v6 in README and add vSphere to the platform headline
The landing page announced v6 GA but the repo README (the highest-traffic
owned surface, 6.1k stars) never did, and its platform list predated
vSphere support. Adds a v6 callout linking the v5 upgrade guide.
2026-07-08 11:36:17 +01:00
rcourtman
b44f902651 Re-point navigation perf budgets at platform tab switching
The perf spec measured Infrastructure/Workloads tab transitions, both
retired with the platform-first navigation, so it could only ever time out
looking for the removed infrastructure-page hook. It now measures
Proxmox/Docker primary-tab switches with per-platform ready signals and
keeps the same 2200ms default budgets (medians run 300-600ms on the local
stack). Budget env overrides follow the new direction names.
2026-07-08 11:25:39 +01:00
rcourtman
005100432b Retire e2e specs that asserted the removed unified-route contracts
The standalone /infrastructure, /workloads, /storage, and /recovery routes
were deliberately retired with the platform-first navigation (abb6f86ae);
these specs existed to pin those routes' filter, handoff, and scoped-routing
contracts, so there is no platform-first surface for them to assert. The
subjects that do survive the rework are covered elsewhere: workload drawers
and filters by the platform-page and embedded-workloads specs, and refresh
resilience by the workloads stability spec being ported to the Proxmox page.
2026-07-08 11:15:13 +01:00
rcourtman
d88248363e Align first-session, migration, and settings-shell specs with shipped UI
The settings shell copy moved (General, Billing & Usage, Provider & Models
and their descriptions); spec 15 now mirrors settingsHeaderMeta instead of
pre-rework titles. The first-run agent handoff pre-provisions the scoped
install token, so spec 11 asserts that contract rather than a Generate
token button, exempts the app shell's Patrol open-work polling from the
no-AI-bootstrap guard (it powers the Patrol nav badge on every route),
treats navigation-aborted auth probes as benign console noise, and targets
/settings/support/reporting directly now that the /operations alias is
retired. Spec 12 scopes migration-notice assertions to the settings content
because the global banner legitimately renders the same copy.
2026-07-08 11:10:08 +01:00
rcourtman
5f3228cac4 Make environment-coupled e2e specs portable to the test stack
Specs 36/54/55 hardcoded the hot-dev URL (127.0.0.1:5173), which does not
exist in the dockerized stack or CI; they now resolve the shared runtime
base URL. Spec 36 also never authenticated (it only ever ran against an
already-authenticated dev session) and pinned fixture timestamps that aged
out of the history view's default period window.

The visual spec injects an inline stylesheet, which the nonce CSP active
outside hot-dev blocks, so it now runs with bypassCSP. The commercial
cancellation specs require a pre-provisioned live-Stripe fixture bundle
(test clocks, customer and subscription ids) that no CI environment
carries; they skip with the missing variables named instead of failing.
2026-07-08 11:09:56 +01:00
rcourtman
8c0740f6ae Enable development mode in the e2e test container
The first-session specs depend on /api/security/dev/reset-first-run, which
is gated on development mode; the test stack never set PULSE_DEV, so every
reset attempt answered 403 in both CI and local compose runs. The only other
effects of the flag in this stack are the localhost AllowedOrigins default
and env-gated dev toggles that remain off.
2026-07-08 11:09:44 +01:00
rcourtman
36b42c1591 Make e2e auth deterministic and refuse implicit dev-runtime resets
Playwright storage states persist cookies and localStorage only, while the
primary-API-token flow authenticates through sessionStorage. Storage states
captured after token auth were silently unauthenticated, so whole spec files
landed on the login screen whenever the token path happened to win. Storage
states now always come from the cookie-backed password login, cached once
per run and probed before reuse.

Explicit bearer/X-API-Token requests now go through a cookie-less request
context: the backend intentionally CSRF-rejects mutations that carry a
session cookie without a CSRF token even when the Authorization header is
valid, so token-scope coverage could never pass from a logged-in page.
Session-semantics specs use the new ensureSessionAuthenticated instead of
racing the token path.

First-run resets now refuse the implicitly resolved hot-dev runtime (the pid
fallback with no explicit base URL and no harness-written runtime state);
an ad-hoc spec run wiped the live dev backend's auth twice through that
path. Resets can also recover an instance stuck unauthenticated mid-reset by
re-reading the rotated bootstrap token through docker exec, and login()
retries through the backend's 10-per-minute login limiter instead of failing
on burst runs.
2026-07-08 11:09:35 +01:00
rcourtman
73766c8fbe Prove the update banner suppresses in-app apply for the Pro binary
Renders UpdateBanner with a Pro runtime identity and asserts the in-app
apply affordance is gone (even when the plan reports canAutoUpdate) while the
Private Release Access portal link and archive/.sshsig steps render; a
community binary still gets the Apply button. Guards the Guard-2 downgrade fix.
2026-07-08 10:12:43 +01:00
rcourtman
983a89326f Block in-app self-update on the Pro binary to prevent silent downgrade
The in-app updater and install.sh both fetch the public community build from
github.com/rcourtman/Pulse. On non-Docker Pro installs (systemd, proxmoxve,
source) the "Apply Update" button was live, so applying an update replaced the
separately compiled Pro binary with community and silently stripped Audit,
RBAC, Reporting, and SSO from a paying customer. Docker was already blocked;
nothing else was.

Add a dependency-free pkg/edition marker (defaults to community) that the Pro
binary flips via enterpriseruntime.Initialize, mirroring the existing
coreaudit.SetLogger / server.SetBusinessHooks registration seam. ApplyUpdate
now refuses when the edition is Pro, pointing at the Private Release Access
portal (https://pulserelay.pro/download.html) and the install.sh --archive
path. The gate keys off the compiled binary, not license state: a community
binary with an active license still self-updates as before.

The update banner hides the in-app apply affordance and shows portal
instructions for the Pro runtime, keyed off the existing runtime-identity
signal (runtime.build) rather than a new payload field, so nothing extra is
plumbed through the version API and the frontend reuses the canonical
"which binary am I" contract. The backend gate is the hard guarantee; the
banner is the UX layer.

Guard 2 of the Pro download/update experience spec.
2026-07-08 10:02:04 +01:00
rcourtman
05883ba4b2 URL-back Kubernetes shared toolbar filters so saved views persist exclusions
The Kubernetes workloads, services, and configuration tabs kept their
shared-toolbar search and status in component-local signals, so the saved
views shipped with the Docker containers work could never capture a -term
exclusion. The shared toolbar now reads search (q) and status from the URL
with replace-style writes, mirroring the Docker containers table, and the
namespace facet key moves onto new KUBERNETES_QUERY_PARAMS constants. The
shared Workloads surface already URL-backs q/status in
useWorkloadsControlsState; a new test pins that behaviour instead.

Resetting filters now clears every param in one setSearchParams write.
Consecutive writes each merge against the pre-navigation URL because the
router commits inside an async transition, so the Docker containers
clear-all (search, status, host as three writes) resurrected the params the
first writes had just cleared; it now issues a single combined write too.
New tests cover the Kubernetes URL filters end to end and the single-write
reset on both platforms.
2026-07-08 09:58:40 +01:00
rcourtman
545087d614 Surface saved views in the FilterBar mobile expanded body
Mobile users could not save, apply, or set default views on any
savedViewsKey surface because SavedViewsMenu only rendered in the
desktop controls row. Render it in the mobile chip row, show that row
when a surface has saved views even with zero menu filters, drop the
duplicate Reset the wider chip row introduced, and left-anchor the
dropdown below the sm breakpoint so it stays on screen at 375px.
2026-07-08 09:52:56 +01:00
rcourtman
9923e4b04f Pace mock churn to homelab rates and stop phantom demo change spam
The public demo wrote ~110K resource_changes rows/day (restart 60K/day,
state_transition 45K/day), making the Changes timeline unreadable and
keeping unified_resources.db churning. Four generator-level engines,
all verified with before/after soaks against scratch mock backends:

- Per-tick flap probabilities ran 43,200x/day on the 2s update loop
  (docker restart p=0.01/tick alone is ~430 restarts/day/container).
  Churn rates are now expressed as events per day per entity and
  converted per tick against the configured interval, tuned to a few
  fleet-wide events per day with dwell times long enough to see.

- The pod scheduling reconciler fought the per-tick scenario re-pin
  and fabricated a fresh random StartTime on every recovery; derived
  uptime moved backwards, which change emission records as a restart.
  The reconciler is now idempotent: stable per-pod park/reschedule
  choices, StartTime never regenerated, and only pods it parked
  itself (NodeNotReady/ClusterOffline/NodeLost) get recovered, so
  curated Pending and ImagePullBackOff stories stay put.

- Swarm cluster objects were fabricated per host under one shared
  cluster key, so registry dedupe alternated between the divergent
  candidates every poll (service renames, status flips, node
  re-parenting). One leader manager now reports services, tasks,
  secrets, configs and the node inventory, like a real control plane.

- Demo docker host profiles cycled 2 hostnames across 4 online hosts,
  collapsing canonical identities, and scripted-offline hosts had
  their sighting refreshed right at the 2 minute staleness threshold,
  sawtoothing them online/offline. Four distinct host profiles now
  exist and offline hosts keep a stably stale sighting.

Before/after soak with a live client: pre-fix ~110-160 rows/min
sustained (restart ~45/min, matching the droplet's 60K/day); post-fix
zero rows/min at steady state with the curated degraded stories
(CrashLoop payments-worker, ImagePullBackOff, offline hosts) intact.
2026-07-08 09:49:03 +01:00
rcourtman
ef2569b1fc Pick cluster-scoped swarm dedupe winners deterministically
In a multi-manager swarm every manager reports the same cluster-scoped
objects (services, nodes, secrets, configs), and the registry dedupe
picked the candidate whose host had the freshest LastSeen. That ordering
flips between polls, so the winner alternated: swarm node resources
re-parented to whichever manager won (ParentID tracks the reporting
host), and services flipped name/status/updateStatus whenever the
managers' views differed slightly. Every rebuild then wrote phantom
relationship_change / state_transition / config_update rows into
resource_changes, the same class of churn as the registry-rebuild spam
fixed in 74131e56e. Mock mode had the same symptom and was fixed by
pinning a single reporting leader, but real deployments still hit this.

Select winners deterministically instead: richer candidate fields first
(unchanged intent), then managers with an available control plane, and
freshness only when the gap exceeds the docker stale threshold, i.e.
the losing manager has genuinely gone quiet; otherwise the lowest host
ID wins as a stable tiebreak. Equal candidates can no longer alternate.

Regression test rebuilds the registry across polls with two managers,
jittered LastSeen ordering, flipped snapshot host order, and slightly
divergent service views, asserting no change emission; it fails against
the old LastSeen-ordering rule with the exact swarm-node re-parenting
seen in production.
2026-07-08 09:35:56 +01:00
rcourtman
c30e223704 Add -term search exclusions and persistent Docker container filter views
Support requests keep landing for a way to hide noisy containers (one-shot
jobs, helpers) from the Docker dashboard. Free-text search across the
platform tables now understands -term exclusions via a shared
splitSearchExclusions helper in searchQuery.ts, wired into the Docker,
Kubernetes, generic platform, and Workloads search predicates. Workloads
keeps its comma OR semantics; exclusions apply globally.

The Docker containers table now URL-backs its search (q) and status params
alongside the existing host param, so saved views capture the whole filter
state and a default view with exclusions keeps unwanted containers hidden
on every visit. Search tips on the containers toolbar document the syntax.
2026-07-08 09:34:07 +01:00
rcourtman
26a975f461 Keep red e2e shards inside the CI budget
Proving run 28925348032 showed a shard can still hit the 45-minute job
timeout while the suite carries many real failures: shard 4 held the
visual crawl (5.3 minutes per attempt, currently failing) plus dozens of
14-second failing attempts, and with two retries the failure storm
outran the budget even under the 20-failure cap.

Retry once instead of twice on CI, and run the visual crawl only on the
desktop project. The mobile emulation projects keep their dedicated
mobile specs; a per-project 5-minute crawl was the single most expensive
line in the suite.
2026-07-08 09:15:38 +01:00
rcourtman
a54e67cb0f Point agent 401 recovery copy at the Pulse UI, not a wrong breadcrumb
Refs #1515

The install command is generated under the Settings infrastructure
installer, not a "Settings > Agents" tab. Correct the agent log, installer
warning, and Machines tooltip to say "the Pulse UI" so the recovery step is
accurate.
2026-07-08 08:40:29 +01:00
rcourtman
c7dcd90b83 Surface agent auth failures and staleness instead of a silent 401 loop
Refs #1515

When an upgraded or restored Pulse server no longer recognises an agent's
API token, the report endpoint returns 401. The agent buffered and retried
that report forever with only a generic warning, and the server kept the
node green at its last known agent version because a Proxmox node stays
online via the PVE API poll even after its agent dies.

- Agent: special-case 401 on /api/agents/agent/report. Drop the report
  instead of buffering it and log a throttled, actionable error pointing the
  operator at the install command to mint a fresh token.
- Installer: verify_agent_server_registration now tells a rejected token
  (401/403) apart from "agent has not reported yet" and prints the recovery
  steps at install time instead of a vague soft warning.
- Status layer: resourceFromHost flags a stale agent (its host marked
  offline by the staleness evaluator) and carries the agent's own last
  report time. Coalescing keeps a node online via the PVE source but the
  dead agent stays flagged, so its version is no longer presented as current.
  The Machines table renders such versions as "(stale)".
2026-07-08 08:35:46 +01:00
rcourtman
a9ac8251ba Queue superseded e2e runs instead of cancelling in-progress ones
Agents push to main every few minutes and a sharded run takes about 30,
so cancel-in-progress meant a busy main could never complete a verdict.
With cancel-in-progress off, the in-flight run finishes and GitHub
collapses queued runs to the newest pending one, so intermediate pushes
still skip without killing the run that is about to report.
2026-07-08 08:29:08 +01:00
rcourtman
b05d255576 Fix completion guard section attribution for long contract sections
contract_patch_has_substantive_change walked git diff --unified=1000
output and tracked the last ## header seen in the patch, so an edit
more than 1000 lines below its section header (e.g. deep inside
unified-resources.md's Current State) lost its section and was wrongly
reported as not substantive, forcing a contract-neutral bypass on
74131e56e.

Replace the patch walk with full-file attribution: diff the HEAD blob
against the staged blob and map every changed line to its section via
a per-line section map built from the complete file. Covers new
contract files (empty HEAD blob) and keeps metadata-only edits
non-substantive. Adds regression tests for edits >1000 lines below
their section header in both directions.
2026-07-08 08:27:32 +01:00
rcourtman
efa18dbf8d chore(frontend-modern): patch dev-dependency advisories
Bump vite ^6.4.2 to ^6.4.3 and pin patched transitives via overrides
(@babel/core ^7.29.6, js-yaml ^4.2.0). Clears 4 Dependabot alerts (2 high,
1 medium, 1 low). npm audit reports 0 vulnerabilities; npm ci, vite build,
vitest (6830 tests), and bundlesize all green.

Also gitignore stray pnpm artifacts. npm is canonical here (CI and the
production Dockerfile both use npm ci from package-lock.json); nothing
consumes pnpm. The unused pnpm-lock.yaml and pnpm-workspace.yaml were
removed separately this cycle and only ever produced Dependabot noise (13
alerts). Ignoring them stops the pair creeping back in, as happened after
the earlier cac5be2ca removal.
2026-07-08 08:26:26 +01:00
rcourtman
a37c38f0ec chore(cloudcp/portal): patch dev-dependency advisories
Bump vitest 3.2.4 to 3.2.7 (clears critical GHSA-5xrq-8626-4rwp) and pin
patched transitives via overrides: vite ^7.3.5, esbuild 0.28.1 ($esbuild),
form-data ^4.0.6, postcss ^8.5.10, ws ^8.21.0. Clears 9 Dependabot alerts
(1 critical, 4 high, 3 medium, 1 low) plus a pre-existing ws high-severity
that npm audit surfaced. npm audit now reports 0 vulnerabilities; build,
test, and typecheck green. Refreshes dist build_manifest source_hash for the
new package.json.
2026-07-08 08:25:45 +01:00
rcourtman
99a9560c1f Install WebKit for the mobile-safari e2e project, cancel superseded runs
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
The mobile-safari Playwright project (iPhone 12) launches WebKit, but CI
only installed chromium. The sequential run never reached a mobile-safari
test before the 45-minute cancel, so the gap stayed invisible until shard
4 of run 28923995416 hit it: 20 straight browserType.launch failures.

Also add a per-ref concurrency group so rapid successive pushes cancel
superseded runs instead of stacking four shard jobs each.
2026-07-08 08:18:13 +01:00
rcourtman
590f8c34db Serve legacy 3-segment OIDC SSO login and callback paths
An OIDC provider upgraded from v5 keeps its v5 redirect URL
(/api/oidc/callback, the DefaultOIDCCallbackPath), so the IdP redirects
the browser back to the 3-segment legacy path carrying only code and
state, with no session cookie and no API token yet. The global auth
middleware only marked the 4-segment per-provider path public, so in
API-token-only mode (AuthUser=="" && AuthPass=="" && HasAPITokens())
the callback was rejected with "API token required via Authorization
header or X-API-Token header", and the route dispatcher 404ed the same
path.

The handler layer (extractOIDCProviderID) already maps the legacy
/api/oidc/login and /api/oidc/callback paths to the migrated legacy
provider, so only the public-path allowlist and the route dispatcher
needed to recognise the 3-segment form. Adds a regression test covering
both legacy paths in API-token-only mode, the exact configuration that
emitted the error.

Refs #1533
2026-07-08 08:15:03 +01:00
rcourtman
74131e56e3 Fix no-op relationship_change spam from registry rebuilds
Every state update rebuilds the resource registry from scratch, which
reconstructs all relationship edges with fresh ObservedAt/LastSeenAt
stamps and fresh metadata maps. recordRegistryChanges compared the old
and new slices with reflect.DeepEqual, so every relationship-bearing
resource emitted a relationship_change row on every rebuild cycle even
when nothing changed. On the public demo this wrote roughly 450k rows
per day (1.2M of 1.6M rows were literal from==to no-ops), grew
unified_resources.db to 1.6GB in three days, starved the store's single
connection until retention pruning failed with SQLITE_BUSY, and drove
the droplet into the swap-thrash outage on 2026-07-08. Same mechanism
as issue #1496.

Compare relationship sets by edge identity instead: canonical source,
canonical target, type, and active state, order-insensitive. Volatile
provenance fields no longer count as change.

Also cap resource_changes at 200k rows during retention pruning so a
pathological writer can never grow the table unbounded inside the
30-day retention window, and record both invariants in the
unified-resources subsystem contract.
2026-07-08 08:10:15 +01:00
rcourtman
c728539f07 Restore completed Core E2E verdicts: shard CI, drop release tag from test image
Every main push since the v6 branch flip was cancelled at the 45-minute
job timeout with no verdict. The flip brought the full 94-spec suite onto
main (the last green run, 2026-06-29, ran only 2 specs on the v5 main),
and it runs sequentially against a release-tagged image whose mock-fixture
gate returns 403 without a demo entitlement. Dozens of specs fail, retry
twice each, and burn the budget: of the 31 minutes of suite time in run
28907574469, 18.8 minutes were failing attempts.

- Add GO_BUILD_TAGS build arg (default release) and build the pulse:test
  e2e image with it empty, matching the dev harness the suite is green
  under. Shipped images keep the release tag; release-gate behavior keeps
  its dedicated -tags release Go tests.
- Shard Playwright 4 ways across a CI matrix (214/202/205/203 tests per
  shard) with per-shard report artifacts and an aggregate verdict job.
- Cap CI at 20 failures so an env-broken run reports red in minutes
  instead of grinding into a no-verdict cancellation.
2026-07-08 08:00:55 +01:00
rcourtman
61350eeaab Re-baseline ReportingPanel bundle budget for shipped MSP report scheduling
The MSP report scheduling and alert rollup feature (438291944) grew the
ReportingPanel chunk from 8.35 kB to 12.42 kB gzip, tripping the frontend
bundle size budget and failing Build and Test on main. The growth is the
intended feature payload, so raise the ReportingPanel baseline to the
measured size. All other chunks and totals remain within budget.
2026-07-08 07:52:32 +01:00
rcourtman
f509b79caa Point integration docs at the update-flow spec
tests/integration/README.md and QUICK_START.md referenced the retired
.github/workflows/test-updates.yml; update-flow coverage now runs as
tests/79-update-flow.spec.ts inside the main suite via test-e2e.yml.
2026-07-08 01:07:20 +01:00
rcourtman
4d6935f4fa Restore update-flow coverage as a v6 Playwright spec, retire test-updates workflow
The Update Integration Tests workflow lost its Go test
(tests/integration/api) in the v6 release commit and was reduced to a
diagnostic smoke test that duplicated the test-e2e stack boot. Replace
it with tests/79-update-flow.spec.ts in the main suite, which runs via
test-e2e.yml on the same trigger paths:

- stable-channel check returns the mock v99.0.0 release and filters
  the v99.1.0-rc.1 prerelease (regression guard for the auto-update
  prerelease bug); rc-channel check surfaces the prerelease
- update plan reports honest manual instructions for the docker
  deployment with readiness attached
- apply refuses prerelease download URLs on the stable channel (409)
- apply of an unsigned artifact fails closed at SSHSIG verification;
  a completed update against the unsigned mock artifact would mean
  the pinned-key trust root was bypassed

The old happy-path apply test is intentionally not revived: v6 made
SSHSIG verification against the pinned pulse-installer key mandatory,
so completing an apply would require shipping the real signing key to
the harness or weakening the trust root.

mock-github-server now serves v-prefixed asset names and download
paths like real Pulse releases (pulse-v99.0.0-linux-amd64.tar.gz);
the in-app updater only recognizes v-prefixed versions in download
URLs, so the old unprefixed shape made every apply fail validation
before reaching the paths under test. Unknown non-tarball sidecar
files (e.g. .sshsig) now 404 instead of falling back to tarball bytes.

The spec self-skips when the update check is not served by the mock
server, so managed-local-backend runs are unaffected.
2026-07-08 01:06:00 +01:00
rcourtman
de109b65d8 Lint nested Go modules from their own module root in pre-commit
golangci-lint was invoked from the repo root for every staged Go
package, so files in nested modules like
tests/integration/mock-github-server failed typechecking with 'main
module does not contain package' and could never be committed. Group
staged package dirs by their nearest enclosing go.mod and lint nested
modules from their own root.
2026-07-08 01:04:36 +01:00
rcourtman
b052e59d24 Exclude nested node_modules from the Docker build context
.dockerignore only matched the root node_modules, so a locally
installed frontend-modern/node_modules entered the build context and
collided with the npm ci layer during COPY frontend-modern/, failing
local image builds. CI checkouts never hit this because they build
from a clean tree.
2026-07-08 00:57:35 +01:00
rcourtman
cbd72d3186 Make physical disk temperature thresholds configurable per disk type
Some checks failed
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (push) Waiting to run
Update Integration Tests / Update Flow Integration Tests (push) Has been cancelled
Settings > Alerts > Systems gains a 'Disk temperature by type' editor
(NVMe/SAS/SATA trigger degC) backing the existing per-type alert
thresholds that were previously hardcoded server-side. Saving alert
settings now round-trips diskTempByType and diskFillByType instead of
silently resetting them to defaults.

Disk temperature colors in the physical disks table, pool detail linked
disks, disk detail cards, and the Machines temperature fallback now
resolve from the live alert config per disk type (warning at trigger
minus the 5 degC hysteresis margin) instead of hardcoded 50/60 cutoffs.

Closes #1540
2026-07-07 23:18:15 +01:00
rcourtman
b6fd06c63d Let explicit disk temperature overrides beat per-type defaults
DiskTempByType unconditionally replaced the resolved host threshold, so a
host-level (or inherited linked-resource) diskTemperature override never
applied to nvme/sas/sata disks. Explicit overrides now win; the per-type
map still beats the global agent default.

Refs #1540
2026-07-07 23:15:59 +01:00
rcourtman
f8e5642ae7 Point update integration smoke test at surviving v6 coverage
The workflow still invoked TestUpdateFlowIntegration from
tests/integration/api, but that package was removed in the v6 release
commit, and the remaining Playwright diagnostic spec skips itself
unless PULSE_E2E_DIAGNOSTIC is set, so the step ran zero tests and
then failed on the missing Go package. Enable the diagnostic spec so
the step actually exercises the pulse:test stack and drop the dead Go
test invocation.
2026-07-07 23:00:09 +01:00
rcourtman
23a930e849 fix(discovery): bring hosts into the fingerprint model so they auto-discover (#1479)
Hosts (PVE nodes and host agents) were entirely outside the discovery
fingerprint model. Two consequences, one root:

1. collectFingerprints only covered docker/lxc/vm/k8s, so a host never
   appeared in GetChangedResources and automatic discovery never ran for
   it. Only the per-resource manual trigger worked, which is exactly the
   v6.0.5-rc.3 behaviour reported in #1479.

2. cleanupOrphanedData built its keep-set from the same four types, so
   CleanupOrphanedDiscoveries swept every agent:* discovery as an orphan
   on the next fingerprint cycle (default 5 minutes). Manually discovered
   hosts silently reverted to undiscovered.

Fix: add GenerateHostFingerprint (identity, OS, kernel, arch, tags;
status excluded so online/offline flapping cannot trigger rediscovery),
fingerprint snap.Hosts under the canonical agent:<id>:<id> key, and keep
host/node discovery keys (canonical, hostname-alias, and legacy host:
prefix forms) out of orphan cleanup. Also purge the store's in-memory
cache when an orphaned discovery file is removed, so deletions are not
masked for the cache TTL.
2026-07-07 22:55:04 +01:00
rcourtman
292baf308b Fix PBS backup discovery regression from bounded polling
The RC3 memory bound for PBS backup polling summarized any group with
more than 8 snapshots into a single synthesized entry built from group
metadata. A synthesized entry has no verification, size, file, or
per-snapshot time data, so most real deployments saw every backup as
Unverified with no size, PBS files not listed, and a backup timeline
collapsed onto the latest backup day.

Keep the issue #1524 memory bounds but derive them from real data:
always fetch snapshots for stale groups, retain the newest bounded set
per group (limit raised from 8 to 100 to cover real keep policies), and
keep the newest-first global live-state cap. Remove the synthesized
group placeholder path entirely and update the monitoring subsystem
contract and tests to pin real-snapshot bounding.

Fixes #1541
Refs #1524
2026-07-07 22:48:34 +01:00
rcourtman
0ad22fe2d5 Mirror the canonical workspace layout in governance CI
The release-control audits resolve repo identity from the checkout
directory name and expect evidence repos as siblings under one repos
root. The hosted runner checked the repo out at Pulse/Pulse, so
canonical_repo_id returned Pulse instead of pulse and the registry
audit treated every local file reference as untracked (2655 errors).
Check out the main repo at repos/pulse and the evidence repos as
repos/pulse-pro, repos/pulse-enterprise, and repos/pulse-mobile, run
all steps from repos/pulse, and point the PULSE_REPO_ROOT_* env vars
at the new paths.
2026-07-07 22:41:38 +01:00
rcourtman
6c181f5f82 Restore pulse:test image build in update integration tests
The dual-key revert (1490a6e6e) removed the docker build line for the
pulse:test image instead of restoring the single-key version, leaving
the step with a bare cd and nothing building the image. Compose then
tried to pull pulse:test from Docker Hub and every run failed before
test execution. Build the runtime target the same way test-e2e.yml
does. The PULSE_LICENSE_PUBLIC_KEY env on the step was dead config:
env vars do not reach docker build and the Dockerfile no longer
declares that ARG.
2026-07-07 22:32:36 +01:00
rcourtman
6f5771d973 Authenticate governance evidence repo checkouts with WORKFLOW_PAT
The Canonical Governance workflow checked out the private pulse-pro,
pulse-enterprise, and pulse-mobile evidence repos with the default
workflow token, which cannot see other private repos, so every run
failed at the pulse-pro checkout. Use the existing WORKFLOW_PAT
secret (already used by create-release.yml to dispatch private Pro
workflows) and avoid persisting the credential in the checkout.
2026-07-07 22:32:25 +01:00
rcourtman
c6fdd9058c Allow clearing SSO provider restriction fields from Settings
Empty allowedGroups, allowedDomains, allowedEmails, groupsClaim and
groupRoleMappings in a provider PUT were silently restored from the
existing config, so an admin could never clear them. The guards
shielded against lossy round-trips that no longer exist: the detail
GET and the flat list response both carry these fields, so an empty
value is an intentional clear. Nested OIDC/SAML config and the client
secret stay preserved since toggle payloads omit them and secrets are
never echoed in reads.

Also expose groupsClaim and groupRoleMappings in the shared extensions
list response so the enterprise list matches the core one, and make
the Settings enable/disable toggle send only writable fields; the old
list-item spread included computed response fields that the enterprise
strict PUT decoder rejects.
2026-07-07 22:04:46 +01:00
rcourtman
887513c020 Move report schedule selects onto shared FormSelect primitive
The MSP report scheduling form used raw native <select> elements,
failing the settings architecture guardrail that keeps Settings
selects on the shared labelled primitive. Cadence, Weekday, Format,
and Delivery now render through FormSelect, which also links each
label to its control.
2026-07-07 21:53:41 +01:00
rcourtman
8355335e08 Clarify v5 migration recovery states 2026-07-07 21:46:07 +01:00
rcourtman
89d9f51258 Record OIDC scope fix resolution in handoff spec 2026-07-07 21:44:23 +01:00
rcourtman
87aac4e575 Add OIDC scope editing to SSO provider settings
Settings never rendered the oidc.scopes field, so admins could not
request an extra group scope and the authorization request stayed at
the default openid profile email. Adds the Scopes input to the OIDC
create/edit modal, points the Groups Claim help text at it, and covers
the create/edit round-trip with model and panel tests.

References #1535.
2026-07-07 21:41:17 +01:00
rcourtman
4b645f4dd2 Add OIDC scope group auth fix spec 2026-07-07 21:09:13 +01:00
rcourtman
4043c466f6 Extend MSP install proof for portal rollups 2026-07-07 20:49:55 +01:00
rcourtman
4382919447 Add MSP report scheduling and alert rollup 2026-07-07 20:37:18 +01:00
rcourtman
d43255889d Point relay pairing at the Pulse Mobile install page
A Relay customer who enables Remote Access and generates a pairing QR
has nothing to scan it with: Pulse Mobile is early access and its
install links live on the authenticated download page, which nothing
in the pairing flow mentioned. Add the pointer to the pairing help
text, pin it in the panel contract test, and give docs/RELAY.md the
same install path instead of 'join early access when available'.
2026-07-07 20:04:36 +01:00
rcourtman
8113f3e8c3 Ensure Helm Pages publishes release chart 2026-07-07 19:21:48 +01:00
rcourtman
155023d86a Add mobile impact gate to release dispatch 2026-07-07 18:21:20 +01:00
rcourtman
1fad0948d4 Clear expired work claim 2026-07-07 18:01:02 +01:00
rcourtman
0ff922ca16 Prepare v6.0.5 RC3 release packet
Refs #1535
2026-07-07 16:48:12 +01:00
rcourtman
eb99d7a6b3 Fix SSO session display labels
Refs #1535
2026-07-07 16:35:07 +01:00
rcourtman
57139c65c5 Fix mobile onboarding URL handoff sanitization
Some checks are pending
Canonical Governance / governance (push) Waiting to run
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (push) Waiting to run
2026-07-07 15:12:59 +01:00
1676 changed files with 238206 additions and 31242 deletions

View file

@ -29,6 +29,7 @@ scripts/macos/dist/
# Dependencies
node_modules/
**/node_modules/
vendor/
# Logs

View file

@ -0,0 +1,7 @@
name: rcourtman/pulse-security-models
version: 0.0.1
library: true
extensionTargets:
codeql/go-all: '*'
dataExtensions:
- models/**/*.yml

View file

@ -0,0 +1,16 @@
extensions:
- addsTo:
pack: codeql/go-all
extensible: barrierModel
data:
- ["github.com/rcourtman/pulse-go-rewrite/internal/securityutil", "", false, "HashedStorageName", "", "", "ReturnValue", "path-injection", "manual"]
- ["github.com/rcourtman/pulse-go-rewrite/internal/securityutil", "", false, "JoinStorageLeaf", "", "", "ReturnValue[0]", "path-injection", "manual"]
- ["github.com/rcourtman/pulse-go-rewrite/internal/securityutil", "", false, "NormalizeStorageDir", "", "", "ReturnValue[0]", "path-injection", "manual"]
- ["github.com/rcourtman/pulse-go-rewrite/internal/securityutil", "", false, "ValidateOutboundFetchURL", "", "", "ReturnValue[0]", "request-forgery", "manual"]
- ["github.com/rcourtman/pulse-go-rewrite/internal/api", "", false, "normalizeConfiguredSSOFilePath", "", "", "ReturnValue[0]", "path-injection", "manual"]
- addsTo:
pack: codeql/go-all
extensible: barrierGuardModel
data:
- ["github.com/rcourtman/pulse-go-rewrite/internal/config", "", false, "isValidOrgID", "", "", "Argument[0]", "true", "path-injection", "manual"]
- ["github.com/rcourtman/pulse-go-rewrite/internal/api", "", false, "isValidTenantOrgID", "", "", "Argument[0]", "true", "path-injection", "manual"]

105
.github/scripts/check-demo-reachability.sh vendored Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DEMO_SERVER_HOST:?DEMO_SERVER_HOST is required}"
MODE="${1:-check}"
TCP_PORT="${DEMO_SERVER_PORT:-22}"
TCP_ATTEMPTS="${DEMO_TCP_ATTEMPTS:-6}"
TCP_RETRY_SECONDS="${DEMO_TCP_RETRY_SECONDS:-5}"
print_safe_status() {
local status_file
if ! command -v tailscale >/dev/null 2>&1; then
echo "Tailscale CLI is not available."
return 0
fi
status_file="$(mktemp)"
if ! tailscale status --json >"$status_file" 2>/dev/null; then
echo "Tailscale status JSON is unavailable."
rm -f "$status_file"
return 0
fi
python3 - "$DEMO_SERVER_HOST" "$status_file" <<'PY' || true
import json
import sys
host = sys.argv[1]
try:
with open(sys.argv[2], encoding="utf-8") as status_file:
status = json.load(status_file)
except (json.JSONDecodeError, OSError):
print("Tailscale status JSON is unavailable.")
raise SystemExit(0)
self_node = status.get("Self") or {}
self_ips = [ip for ip in self_node.get("TailscaleIPs") or [] if ":" not in ip]
self_dns = (self_node.get("DNSName") or "").rstrip(".")
self_tags = sorted(self_node.get("Tags") or [])
target = None
for peer in (status.get("Peer") or {}).values():
peer_ips = peer.get("TailscaleIPs") or []
peer_dns = (peer.get("DNSName") or "").rstrip(".")
if host in peer_ips or host.rstrip(".") == peer_dns:
target = peer
break
print(f"Tailscale backend: {status.get('BackendState', 'unknown')}")
print(f"Runner Tailscale IPv4: {self_ips[0] if self_ips else 'unavailable'}")
print(f"Runner Tailscale DNS: {self_dns or 'unavailable'}")
print(f"Runner Tailscale tags: {','.join(self_tags) if self_tags else 'none'}")
if target is None:
print("Demo peer is not present in the runner peer map yet.")
else:
print(
"Demo peer state: "
f"online={bool(target.get('Online'))} "
f"active={bool(target.get('Active'))} "
f"relay={target.get('Relay') or 'none'}"
)
PY
rm -f "$status_file"
}
diagnose() {
print_safe_status
if command -v tailscale >/dev/null 2>&1; then
tailscale ping --c 1 --timeout 5s "$DEMO_SERVER_HOST" || true
fi
nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT" || true
}
if [ "$MODE" = "diagnose" ]; then
diagnose
exit 0
fi
if [ "$MODE" != "check" ]; then
echo "Usage: $0 [check|diagnose]" >&2
exit 2
fi
print_safe_status
if ! tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST"; then
echo "::error::Tailscale cannot reach the demo peer. Verify that the workflow tag is authorized to reach the demo host tag and that the peer is online."
diagnose
exit 1
fi
for attempt in $(seq 1 "$TCP_ATTEMPTS"); do
if nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT"; then
echo "Demo SSH transport is reachable over Tailscale."
exit 0
fi
echo "Demo TCP/${TCP_PORT} is not reachable on attempt ${attempt}/${TCP_ATTEMPTS}."
if [ "$attempt" -lt "$TCP_ATTEMPTS" ]; then
sleep "$TCP_RETRY_SECONDS"
fi
done
echo "::error::Tailscale reached the demo peer, but TCP/${TCP_PORT} remained closed. Verify sshd and the host firewall on tailscale0."
diagnose
exit 1

63
.github/scripts/setup-demo-ssh.sh vendored Normal file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DEMO_SERVER_HOST:?DEMO_SERVER_HOST is required}"
: "${DEMO_SERVER_SSH_KEY:?DEMO_SERVER_SSH_KEY is required}"
SSH_DIR="${HOME}/.ssh"
IDENTITY_FILE="${DEMO_SSH_IDENTITY_FILE:-${SSH_DIR}/id_ed25519}"
KNOWN_HOSTS_FILE="${DEMO_SSH_KNOWN_HOSTS_FILE:-${SSH_DIR}/known_hosts}"
is_ip_literal() {
python3 - "$1" <<'PY'
import ipaddress
import sys
try:
ipaddress.ip_address(sys.argv[1])
except ValueError:
sys.exit(1)
sys.exit(0)
PY
}
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
printf '%s\n' "$DEMO_SERVER_SSH_KEY" > "$IDENTITY_FILE"
chmod 600 "$IDENTITY_FILE"
: > "$KNOWN_HOSTS_FILE"
keyscan_output="$(mktemp)"
keyscan_error="$(mktemp)"
trap 'rm -f "$keyscan_output" "$keyscan_error"' EXIT
host_needs_dns=true
if is_ip_literal "$DEMO_SERVER_HOST"; then
host_needs_dns=false
echo "Demo SSH host is an IP literal; skipping DNS resolution wait."
fi
MAX_SSH_SETUP_ATTEMPTS="${DEMO_SSH_SETUP_ATTEMPTS:-3}"
SSH_SETUP_RETRY_SECONDS="${DEMO_SSH_SETUP_RETRY_SECONDS:-5}"
for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do
if [ "$host_needs_dns" = "true" ] && ! getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then
echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
elif ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then
cat "$keyscan_output" >> "$KNOWN_HOSTS_FILE"
chmod 600 "$KNOWN_HOSTS_FILE"
echo "Demo SSH host key captured."
exit 0
else
echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
fi
if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then
sleep "$SSH_SETUP_RETRY_SECONDS"
fi
done
echo "::error::Demo network preflight passed, but ssh-keyscan did not return host keys. Verify sshd host-key configuration on the target."
if [ -s "$keyscan_error" ]; then
sed 's/^/ssh-keyscan: /' "$keyscan_error" || true
fi
exit 1

View file

@ -43,10 +43,11 @@ Required environment secrets:
Required shared secret:
1. **TS_AUTHKEY**
- Tailscale auth key used by the governed demo deploy/update workflows before SSH
1. **TS_OAUTH_CLIENT_ID** and **TS_OAUTH_SECRET**
- Tailscale OAuth client (business tailnet `tawny-powan.ts.net`, scope Auth Keys write, tag `tag:infra`) used by the governed demo deploy/update workflows before SSH
- The action mints an ephemeral, pre-authorized, tagged node key per run, so runners join and garbage-collect themselves; unlike the retired static `TS_AUTHKEY`, the OAuth secret does not expire every 90 days
- Allows GitHub-hosted runners to reach private demo targets such as the stable `pulse-relay` Tailscale host
- May be stored as a repository secret or repeated in the selected environment if desired
- May be stored as repository secrets or repeated in the selected environment if desired
Required environment variables:

View file

@ -0,0 +1,377 @@
name: Build Release Candidate
on:
workflow_call:
inputs:
version:
description: 'Version number without the leading v'
required: true
type: string
require_macos_signing:
description: 'Require Developer ID signed and notarized macOS agent binaries'
required: false
default: false
type: boolean
require_windows_signing:
description: 'Require Authenticode-signed Windows agent binaries'
required: false
default: false
type: boolean
outputs:
artifact_name:
description: 'Immutable release candidate artifact name'
value: ${{ jobs.build.outputs.artifact_name }}
manifest_artifact_name:
description: 'Release candidate manifest artifact name'
value: ${{ jobs.build.outputs.manifest_artifact_name }}
permissions:
contents: read
jobs:
signing-configuration:
name: Verify Native Signing Configuration
if: ${{ inputs.require_macos_signing || inputs.require_windows_signing }}
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
- name: Report missing signing secrets
env:
APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64 }}
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
APPLE_DEVELOPER_ID_APPLICATION_IDENTITY: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_IDENTITY }}
APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64 }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
REQUIRE_MACOS_SIGNING: ${{ inputs.require_macos_signing }}
REQUIRE_WINDOWS_SIGNING: ${{ inputs.require_windows_signing }}
run: |
set -euo pipefail
missing=0
required=()
if [[ "$REQUIRE_MACOS_SIGNING" == "true" ]]; then
required+=(
APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD
APPLE_DEVELOPER_ID_APPLICATION_IDENTITY
APPLE_NOTARY_KEY_P8_BASE64
APPLE_NOTARY_KEY_ID
APPLE_NOTARY_ISSUER_ID
)
fi
if [[ "$REQUIRE_WINDOWS_SIGNING" == "true" ]]; then
required+=(
WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD
)
else
echo "::notice::Windows Authenticode is not required for this candidate."
fi
for name in "${required[@]}"; do
if [ -z "${!name:-}" ]; then
echo "::error::Missing required Actions secret ${name}."
missing=1
fi
done
exit "$missing"
sign-macos-agent:
name: Sign and Notarize macOS Agent
needs: signing-configuration
if: ${{ inputs.require_macos_signing && needs.signing-configuration.result == 'success' }}
runs-on: macos-15
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Build, sign, and notarize agent binaries
shell: bash
env:
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_IDENTITY }}
APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
set -euo pipefail
for name in \
APPLE_CERTIFICATE_P12_BASE64 \
APPLE_CERTIFICATE_PASSWORD \
APPLE_SIGNING_IDENTITY \
APPLE_NOTARY_KEY_P8_BASE64 \
APPLE_NOTARY_KEY_ID \
APPLE_NOTARY_ISSUER_ID \
PULSE_UPDATE_SIGNING_PUBLIC_KEY; do
test -n "${!name:-}" || { echo "::error::Missing required ${name}."; exit 1; }
done
mkdir -p native-agent-binaries
python3 - <<'PY'
import base64, os
from pathlib import Path
Path('developer-id.p12').write_bytes(base64.b64decode(os.environ['APPLE_CERTIFICATE_P12_BASE64']))
Path('AuthKey.p8').write_bytes(base64.b64decode(os.environ['APPLE_NOTARY_KEY_P8_BASE64']))
PY
keychain="$RUNNER_TEMP/pulse-signing.keychain-db"
keychain_password="$(openssl rand -hex 24)"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import developer-id.p12 -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain"
security list-keychains -d user -s "$keychain"
ldflags="$(./scripts/release_ldflags.sh agent \
--version "v${{ inputs.version }}" \
--update-public-keys "$PULSE_UPDATE_SIGNING_PUBLIC_KEY")"
for arch in amd64 arm64; do
output="native-agent-binaries/pulse-agent-darwin-${arch}"
GOOS=darwin GOARCH="$arch" CGO_ENABLED=0 \
go build -buildvcs=false -trimpath -ldflags="$ldflags" -o "$output" ./cmd/pulse-agent
codesign --force --timestamp --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$output"
codesign --verify --deep --strict --verbose=2 "$output"
done
ditto -c -k --keepParent native-agent-binaries pulse-agent-macos-notarization.zip
xcrun notarytool submit pulse-agent-macos-notarization.zip \
--key AuthKey.p8 \
--key-id "$APPLE_NOTARY_KEY_ID" \
--issuer "$APPLE_NOTARY_ISSUER_ID" \
--wait \
--output-format json > notarization-result.json
python3 - <<'PY'
import json
from pathlib import Path
result = json.loads(Path('notarization-result.json').read_text())
if result.get('status') != 'Accepted':
raise SystemExit(f"Apple notarization was not accepted: {result.get('status', 'unknown')}")
PY
for binary in native-agent-binaries/pulse-agent-darwin-*; do
# Gatekeeper's spctl app assessment rejects bare command-line
# Mach-O binaries even when the notary service accepted them.
# Verify the signed bytes that will be packaged instead.
codesign --verify --deep --strict --verbose=2 "$binary"
done
- name: Upload signed macOS binaries
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: signed-macos-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries/
if-no-files-found: error
retention-days: 1
compression-level: 0
sign-windows-agent:
name: Authenticode Sign Windows Agent
needs: signing-configuration
if: ${{ inputs.require_windows_signing && needs.signing-configuration.result == 'success' }}
runs-on: windows-2025
timeout-minutes: 25
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Build and sign agent binaries
shell: pwsh
env:
WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
$ErrorActionPreference = 'Stop'
foreach ($name in @('WINDOWS_CERTIFICATE_PFX_BASE64', 'WINDOWS_CERTIFICATE_PASSWORD', 'PULSE_UPDATE_SIGNING_PUBLIC_KEY')) {
if ([string]::IsNullOrWhiteSpace((Get-Item "Env:$name").Value)) {
throw "Missing required $name."
}
}
New-Item -ItemType Directory -Path native-agent-binaries -Force | Out-Null
$pfxPath = Join-Path $env:RUNNER_TEMP 'pulse-code-signing.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_PFX_BASE64))
$password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
$certificate = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $password -Exportable:$false
if ($null -eq $certificate) { throw 'Failed to import Windows code-signing certificate.' }
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter signtool.exe -Recurse |
Sort-Object FullName -Descending |
Select-Object -First 1 -ExpandProperty FullName
if ([string]::IsNullOrWhiteSpace($signtool)) { throw 'signtool.exe was not found.' }
$ldflags = & bash ./scripts/release_ldflags.sh agent --version "v${{ inputs.version }}" --update-public-keys $env:PULSE_UPDATE_SIGNING_PUBLIC_KEY
foreach ($arch in @('amd64', 'arm64', '386')) {
$env:GOOS = 'windows'
$env:GOARCH = $arch
$env:CGO_ENABLED = '0'
$output = "native-agent-binaries/pulse-agent-windows-$arch.exe"
go build -buildvcs=false -trimpath -ldflags="$ldflags" -o $output ./cmd/pulse-agent
if ($LASTEXITCODE -ne 0) { throw "Go build failed for Windows $arch." }
& $signtool sign /sha1 $certificate.Thumbprint /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com $output
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for Windows $arch." }
& $signtool verify /pa /v $output
if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed for Windows $arch." }
}
- name: Upload signed Windows binaries
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: signed-windows-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries/
if-no-files-found: error
retention-days: 1
compression-level: 0
build:
name: Build and Validate Signed Candidate
needs: [sign-macos-agent, sign-windows-agent]
if: ${{ always() && (!inputs.require_macos_signing || needs.sign-macos-agent.result == 'success') && (!inputs.require_windows_signing || needs.sign-windows-agent.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 60
outputs:
artifact_name: ${{ steps.identity.outputs.artifact_name }}
manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }}
steps:
- name: Resolve candidate identity
id: identity
run: |
set -euo pipefail
echo "artifact_name=release-candidate-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT"
echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Validate candidate identity
run: |
set -euo pipefail
test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}"
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Install release prerequisites
run: |
sudo apt-get update
sudo apt-get install -y zip
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: 'v3.15.2'
- name: Install Syft
run: |
set -euo pipefail
SYFT_VERSION="1.42.4"
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
- name: Download signed macOS binaries
if: ${{ inputs.require_macos_signing }}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: signed-macos-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries
- name: Download signed Windows binaries
if: ${{ inputs.require_windows_signing }}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: signed-windows-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries
- name: Build signed release candidate
run: ./scripts/build-release.sh "${{ inputs.version }}"
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
PULSE_REQUIRE_MACOS_SIGNING: ${{ inputs.require_macos_signing }}
PULSE_REQUIRE_WINDOWS_SIGNING: ${{ inputs.require_windows_signing }}
PULSE_AGENT_NATIVE_BINARIES_DIR: ${{ (inputs.require_macos_signing || inputs.require_windows_signing) && format('{0}/native-agent-binaries', github.workspace) || '' }}
- name: Validate installer signing key pins
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
set -euo pipefail
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
echo "::error::${installer} does not trust the configured release signing key."
exit 1
}
done
- name: Validate complete candidate locally
run: ./scripts/validate-release.sh "${{ inputs.version }}" --skip-docker
- name: Create immutable candidate manifest
run: |
python3 scripts/release_candidate_manifest.py create \
--release-dir release \
--version "${{ inputs.version }}" \
--source-sha "${GITHUB_SHA}" \
--output release-candidate-manifest/release-candidate.json
- name: Upload immutable release candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.artifact_name }}
path: release/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
- name: Upload candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.manifest_artifact_name }}
path: release-candidate-manifest/release-candidate.json
if-no-files-found: error
retention-days: 1
overwrite: true

View file

@ -15,38 +15,53 @@ permissions:
jobs:
governance:
runs-on: ubuntu-24.04
# The release-control audits resolve the workspace layout from the
# local checkout: the repo directory must be named exactly "pulse"
# (the canonical repo id) and the evidence repos must be checked out
# as siblings, mirroring <workspace>/repos/<repo-id> on dev machines.
defaults:
run:
working-directory: repos/pulse
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
path: repos/pulse
- name: Checkout pulse-pro evidence repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: rcourtman/pulse-pro
token: ${{ secrets.WORKFLOW_PAT }}
persist-credentials: false
fetch-depth: 1
path: .governance/repos/pulse-pro
path: repos/pulse-pro
- name: Checkout pulse-enterprise evidence repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: rcourtman/pulse-enterprise
token: ${{ secrets.WORKFLOW_PAT }}
persist-credentials: false
fetch-depth: 1
path: .governance/repos/pulse-enterprise
path: repos/pulse-enterprise
- name: Checkout pulse-mobile evidence repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: rcourtman/pulse-mobile
token: ${{ secrets.WORKFLOW_PAT }}
persist-credentials: false
fetch-depth: 1
path: .governance/repos/pulse-mobile
path: repos/pulse-mobile
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
go-version-file: repos/pulse/go.mod
cache: true
cache-dependency-path: repos/pulse/go.sum
- name: Determine governance diff range
id: diff
@ -68,32 +83,47 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [ -n "${{ steps.diff.outputs.range }}" ]; then
git diff --name-only "${{ steps.diff.outputs.range }}" \
| python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin
range="${{ steps.diff.outputs.range }}"
if [ -n "${range}" ]; then
# Pass the range base so the guard compares contract texts
# base-vs-HEAD; the CI index equals HEAD, so the default
# index comparison would misreport every contract update
# in the range as insubstantial.
git diff --name-only "${range}" \
| python3 scripts/release_control/canonical_completion_guard.py \
--files-from-stdin --diff-base "${range%%...*}"
else
printf '' | python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin
fi
- name: Run status audit
env:
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/.governance/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/.governance/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/.governance/repos/pulse-mobile
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile
run: python3 scripts/release_control/status_audit.py --check
- name: Validate Pulse Intelligence release-gate schema
run: python3 scripts/release_control/pulse_intelligence_gate.py --validate-only --matrix docs/release-control/v6/internal/pulse-intelligence-release-gate.json
- name: Run Pulse Intelligence release-gate unit tests
run: python3 scripts/release_control/pulse_intelligence_gate_test.py
- name: Run control plane audit
env:
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/.governance/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/.governance/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/.governance/repos/pulse-mobile
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile
run: python3 scripts/release_control/control_plane_audit.py --check
- name: Run registry audit
run: python3 scripts/release_control/registry_audit.py --check
- name: Run canonical mutation registry audits
run: go test ./internal/mutationregistry ./internal/ai/tools -run 'Test(EveryRegisteredMutationHasDisposition|InfrastructureAPIRoutesResolveToRegistry|TransportCommandCatalogsResolveToRegistry|PatrolJobRegistrationResolvesToRegistry|RuntimeCandidateAuditNegativeFixtures|ActionRouteMethodAuthorityIsExactAndLookalikesFailClosed|NonAdmittingTransportMessagesCannotCarryDispatchAuthority|UnknownTransportLookalikeFailsClosed|RegisteredModelMutationSchemasResolveToClosedRegistry|RetiredMutationAliasesCannotShadowExtensions)' -count=1
- name: Run contract audit
run: python3 scripts/release_control/contract_audit.py --check
@ -132,10 +162,10 @@ jobs:
- name: Run repo governance guardrail tests
env:
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/.governance/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/.governance/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/.governance/repos/pulse-mobile
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile
run: go test ./internal/repoctl -count=1
- name: Run active-target automated readiness assertion proofs

View file

@ -47,12 +47,21 @@ on:
required: false
type: boolean
default: false
mobile_release_decision:
description: 'Required mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required'
required: true
type: string
mobile_release_evidence:
description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions'
required: false
type: string
concurrency:
group: release-${{ github.event.inputs.version || github.ref || github.run_id }}
cancel-in-progress: false
permissions:
actions: read
contents: read
jobs:
@ -73,6 +82,8 @@ jobs:
v5_eos_date: ${{ steps.promotion.outputs.v5_eos_date }}
hotfix_exception: ${{ steps.promotion.outputs.hotfix_exception }}
hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }}
promotion_mode: ${{ steps.promotion.outputs.promotion_mode }}
is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }}
historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }}
steps:
- name: Extract version
@ -113,6 +124,7 @@ jobs:
VERSION
docs/release-control/control_plane.json
scripts/release_control/control_plane.py
scripts/release_control/mobile_release_gate.py
scripts/release_control/repo_file_io.py
- name: Resolve required release branch
@ -138,6 +150,19 @@ jobs:
fi
echo "[OK] VERSION file matches requested version ($REQUESTED_VERSION)"
- name: Validate mobile release decision
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
env:
MOBILE_RELEASE_DECISION: ${{ github.event.inputs.mobile_release_decision }}
MOBILE_RELEASE_EVIDENCE: ${{ github.event.inputs.mobile_release_evidence }}
run: |
set -euo pipefail
python3 scripts/release_control/mobile_release_gate.py \
--version "${{ steps.extract.outputs.version }}" \
--decision "${MOBILE_RELEASE_DECISION}" \
--evidence "${MOBILE_RELEASE_EVIDENCE}" \
--github-annotations
- name: Validate promotion policy
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
id: promotion
@ -183,6 +208,19 @@ jobs:
echo "[OK] Promotion policy validated for ${TAG}"
build_release_candidate:
name: Build Immutable Release Candidate
needs: prepare
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: read
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ needs.prepare.outputs.version }}
require_macos_signing: true
require_windows_signing: ${{ needs.prepare.outputs.is_prerelease != 'true' }}
# Frontend checks run in parallel with backend tests
frontend_checks:
needs: prepare
@ -212,9 +250,24 @@ jobs:
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
- name: Build verified frontend bundle
run: npm --prefix frontend-modern run build
- name: Upload verified frontend bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-frontend-${{ github.sha }}
path: frontend-modern/dist/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
# Backend tests run in parallel with frontend checks
backend_tests:
needs: prepare
needs:
- prepare
- frontend_checks
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
@ -224,25 +277,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Restore frontend build cache
id: frontend-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: frontend-modern/dist
key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }}
- name: Build frontend (if not cached)
if: steps.frontend-cache.outputs.cache-hit != 'true'
run: |
npm --prefix frontend-modern ci
npm --prefix frontend-modern run build
name: release-frontend-${{ github.sha }}
- name: Copy frontend to embed location
run: |
@ -452,7 +491,7 @@ jobs:
integration_tests:
needs:
- prepare
- backend_tests
- frontend_checks
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 45
@ -469,11 +508,11 @@ jobs:
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Restore frontend build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: frontend-modern/dist
key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }}
name: release-frontend-${{ github.sha }}
- name: Copy frontend to embed location
run: |
@ -560,6 +599,42 @@ jobs:
docker compose -f docker-compose.test.yml down -v
- name: Collect integration diagnostics
if: failure()
working-directory: tests/integration
run: |
mkdir -p release-integration-diagnostics
{
echo "=== Docker containers ==="
docker ps -a || true
echo
echo "=== Pulse test server logs ==="
docker logs pulse-test-server 2>&1 || echo "No pulse-test-server container"
echo
echo "=== Mock GitHub server logs ==="
docker logs pulse-mock-github 2>&1 || echo "No pulse-mock-github container"
} | tee release-integration-diagnostics/docker.log
- name: Upload integration Playwright report
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-integration-playwright-report
path: tests/integration/playwright-report/
if-no-files-found: ignore
retention-days: 14
- name: Upload integration failures
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-integration-failures
path: |
tests/integration/test-results/
tests/integration/release-integration-diagnostics/
if-no-files-found: ignore
retention-days: 14
- name: Cleanup
if: always()
working-directory: tests/integration
@ -569,13 +644,14 @@ jobs:
create_release:
needs:
- prepare
- build_release_candidate
- frontend_checks
- backend_tests
- docker_build
- helm_smoke
- integration_tests
# Run if integration_tests passed OR was skipped (prereleases)
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
@ -593,80 +669,25 @@ jobs:
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
- name: Download immutable release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
go-version-file: go.mod
cache: true
name: ${{ needs.build_release_candidate.outputs.artifact_name }}
path: release
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Download release candidate manifest
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
path: release-candidate-manifest
- name: Install dependencies
- name: Verify immutable release candidate
run: |
sudo apt-get update
sudo apt-get install -y zip
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: 'v3.15.2'
- name: Install Syft
run: |
set -euo pipefail
SYFT_VERSION="1.42.4"
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
syft version
- name: Build release artifacts
run: |
echo "Building release ${{ needs.prepare.outputs.tag }}..."
./scripts/build-release.sh ${{ needs.prepare.outputs.version }}
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
- name: Validate installer signing key pins
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
set -euo pipefail
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
echo "::error::${installer} does not trust the configured release signing key."
exit 1
}
done
- name: Post-build health check
run: |
if [ -x ./pulse ]; then
./pulse --version
elif [ -x ./cmd/pulse/pulse ]; then
./cmd/pulse/pulse --version
fi
python3 scripts/release_candidate_manifest.py verify-local \
--release-dir release \
--manifest release-candidate-manifest/release-candidate.json \
--version "${{ needs.prepare.outputs.version }}" \
--source-sha "${GITHUB_SHA}"
- name: Attest release assets
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
@ -704,6 +725,16 @@ jobs:
--hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \
--hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}"
# The in-app "What's New" banner only fires when the release body has
# a Highlights section (see frontend-modern/src/components/whatsNewModel.ts).
# Surface which way this release will behave so silence is a choice,
# not an accident.
if grep -qiE '^#{1,6}[[:space:]]+highlights\b' "$RENDERED_NOTES_FILE"; then
echo "::notice::Release notes include a Highlights section — the in-app What's New banner will show it after users update."
else
echo "::notice::Release notes have no Highlights section — the in-app What's New banner stays silent for this release (expected for maintenance releases)."
fi
echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT
- name: Locate existing release
@ -965,39 +996,27 @@ jobs:
-X PATCH -F draft=false -F make_latest=false
echo "[OK] Published as prerelease: ${TAG}"
else
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-X PATCH -F draft=false -F make_latest=true
echo "[OK] Published as latest: ${TAG}"
# 'latest' belongs to the highest stable semver overall. A
# maintenance cut of an older line (e.g. v5.1.36 after v6 GA)
# publishes without stealing the latest marker from the current
# line.
HIGHEST_STABLE=$(gh api --paginate "repos/${{ github.repository }}/tags" --jq '.[].name' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
if [ "$TAG" = "$HIGHEST_STABLE" ]; then
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-X PATCH -F draft=false -F make_latest=true
echo "[OK] Published as latest: ${TAG}"
else
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-X PATCH -F draft=false -F make_latest=false
echo "[OK] Published WITHOUT latest marker: ${TAG} (highest stable is ${HIGHEST_STABLE})"
fi
fi
- name: Skip publish (draft only)
if: ${{ github.event.inputs.draft_only == 'true' }}
run: 'echo "Draft-only mode: ${{ steps.create_release.outputs.release_url }}"'
- name: Trigger Docker image publish
if: ${{ github.event.inputs.draft_only != 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
run: |
gh workflow run publish-docker.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}"
echo "[OK] Docker publish workflow dispatched from ${REQUIRED_BRANCH}"
- name: Trigger demo server update
if: ${{ github.event.inputs.draft_only != 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
run: |
if [ "${{ needs.prepare.outputs.is_prerelease }}" = "true" ]; then
echo "[OK] Prerelease public demo update skipped; post-GA demo target is stable only."
exit 0
fi
gh workflow run update-demo-server.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}" -f target="stable"
echo "[OK] Demo server update dispatched for stable from ${REQUIRED_BRANCH}"
- name: Summary
run: |
echo "[SUCCESS] Release published!"
@ -1057,11 +1076,27 @@ jobs:
echo "[SUCCESS] Historical release assets repaired"
echo "Release: ${{ needs.prepare.outputs.tag }}"
validate_release_assets:
publish_docker:
needs:
- prepare
- create_release
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
uses: ./.github/workflows/publish-docker.yml
secrets: inherit
with:
tag: ${{ needs.prepare.outputs.tag }}
validate_release_assets:
needs:
- prepare
- build_release_candidate
- create_release
if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: write
issues: write
@ -1074,6 +1109,7 @@ jobs:
release_id: ${{ needs.create_release.outputs.release_id }}
draft: ${{ github.event.inputs.draft_only == 'true' }}
target_commitish: ${{ needs.create_release.outputs.target_commitish }}
candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
# End-to-end install.sh smoke against the just-published release. Catches
# runtime regressions in the documented Proxmox-LXC / systemd install flow
@ -1104,6 +1140,20 @@ jobs:
version: ${{ needs.prepare.outputs.version }}
repository: ${{ github.repository }}
update_stable_demo:
needs:
- prepare
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
permissions:
contents: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
tag: ${{ needs.prepare.outputs.tag }}
target: stable
verify_only: false
# Publish the Helm chart for this release. publish-helm-chart.yml also
# listens for `release: published` events directly, but the create_release
# publish step PATCHes a draft release to draft=false rather than creating
@ -1133,15 +1183,15 @@ jobs:
# when it fails the floating tags don't advance and customers pulling
# rcourtman/pulse:latest stay on whatever the previous successful release
# tagged. Calling promote-floating-tags as workflow_call after
# validate_release_assets succeeds (which itself waits for the docker
# image to be pullable) guarantees the floating tags advance. Draft-only
# runs must not promote floating tags because the release is still in
# private promotion state.
# validate_release_assets and publish_docker succeed guarantees the floating
# tags advance. Draft-only runs must not promote floating tags because the
# release is still in private promotion state.
promote_floating_tags:
needs:
- prepare
- publish_docker
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
permissions:
contents: read
packages: write
@ -1161,8 +1211,8 @@ jobs:
publish_private_pro_runtime:
needs:
- prepare
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
- create_release
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
@ -1267,3 +1317,61 @@ jobs:
-f r2_prefix="${r2_prefix}" \
-f allow_ga_prefix="${allow_ga_publish}"
wait_for_workflow rcourtman/pulse-pro "Promote Paid Runtime Release" main "${promote_started_at}" "private Pro live promotion" 3600
release_verdict:
name: Definitive Release Verdict
needs:
- prepare
- create_release
- publish_docker
- validate_release_assets
- install_sh_smoke
- update_stable_demo
- publish_helm_chart
- promote_floating_tags
- publish_private_pro_runtime
if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
steps:
- name: Enforce terminal release outcomes
env:
DRAFT_ONLY: ${{ github.event.inputs.draft_only }}
VERSION: ${{ needs.prepare.outputs.version }}
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
CREATE_RESULT: ${{ needs.create_release.result }}
DOCKER_RESULT: ${{ needs.publish_docker.result }}
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
DEMO_RESULT: ${{ needs.update_stable_demo.result }}
HELM_RESULT: ${{ needs.publish_helm_chart.result }}
FLOATING_RESULT: ${{ needs.promote_floating_tags.result }}
PRIVATE_PRO_RESULT: ${{ needs.publish_private_pro_runtime.result }}
run: |
set -euo pipefail
require_result() {
local name="$1"
local actual="$2"
local expected="$3"
if [ "$actual" != "$expected" ]; then
echo "::error::${name} ended as ${actual}; expected ${expected}."
return 1
fi
}
require_result "release assembly" "$CREATE_RESULT" success
require_result "release asset validation" "$VALIDATE_RESULT" success
if [ "${DRAFT_ONLY:-false}" != "true" ]; then
require_result "Docker publication" "$DOCKER_RESULT" success
require_result "install.sh smoke" "$INSTALL_RESULT" success
require_result "Helm publication" "$HELM_RESULT" success
require_result "floating-tag promotion" "$FLOATING_RESULT" success
if [[ "$VERSION" == 6.* ]]; then
require_result "private Pro publication" "$PRIVATE_PRO_RESULT" success
if [ "$IS_PRERELEASE" != "true" ]; then
require_result "stable demo deployment and verification" "$DEMO_RESULT" success
fi
fi
fi
echo "Release verdict passed for v${VERSION}."

View file

@ -133,22 +133,31 @@ jobs:
-o pulse ./cmd/pulse/
- name: Tailscale
uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # v2
id: tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
with:
authkey: ${{ secrets.TS_AUTHKEY }}
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
version: '1.94.2'
ping: ${{ secrets.DEMO_SERVER_HOST }}
- name: Diagnose Tailscale setup failure
if: failure() && steps.tailscale.outcome == 'failure'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh diagnose
- name: Verify demo network path
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh
- name: Setup SSH
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEMO_SERVER_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$DEMO_SERVER_HOST" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
run: bash .github/scripts/setup-demo-ssh.sh
- name: Verify target host identity
env:

View file

@ -236,6 +236,76 @@ jobs:
CR_RELEASE_NAME_TEMPLATE: "helm-chart-{{ .Version }}"
CR_MAKE_RELEASE_LATEST: false
- name: Ensure chart release and pages index
env:
GITHUB_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.version }}
REPOSITORY: ${{ github.repository }}
RELEASE_TAG: ${{ steps.version.outputs.release_tag }}
run: |
set -euo pipefail
CHART="pulse-${VERSION}.tgz"
CHART_PATH="dist/${CHART}"
CHART_RELEASE="helm-chart-${VERSION}"
CHART_RELEASE_URL="https://github.com/${REPOSITORY}/releases/download/${CHART_RELEASE}"
RELEASE_SHA="$(git rev-list -n 1 "${RELEASE_TAG}")"
mkdir -p dist
helm package deploy/helm/pulse \
--version "${VERSION}" \
--app-version "${VERSION}" \
--destination dist
if gh release view "${CHART_RELEASE}" >/dev/null 2>&1; then
gh release upload "${CHART_RELEASE}" "${CHART_PATH}" --clobber
else
gh release create "${CHART_RELEASE}" "${CHART_PATH}" \
--target "${RELEASE_SHA}" \
--title "Helm chart ${VERSION}" \
--notes "Helm chart for Pulse ${VERSION}." \
--prerelease \
--latest=false
fi
gh release edit "${CHART_RELEASE}" --prerelease --latest=false
workdir="$(mktemp -d)"
cleanup() {
git worktree remove -f "${workdir}/gh-pages" >/dev/null 2>&1 || true
rm -rf "${workdir}"
}
trap cleanup EXIT
git fetch origin gh-pages
git worktree add "${workdir}/gh-pages" origin/gh-pages
git -C "${workdir}/gh-pages" checkout -B gh-pages origin/gh-pages
index_work="${workdir}/index"
mkdir -p "${index_work}"
cp "${CHART_PATH}" "${index_work}/${CHART}"
if [ -f "${workdir}/gh-pages/index.yaml" ]; then
cp "${workdir}/gh-pages/index.yaml" "${index_work}/index.yaml"
helm repo index "${index_work}" \
--url "${CHART_RELEASE_URL}" \
--merge "${index_work}/index.yaml"
else
helm repo index "${index_work}" --url "${CHART_RELEASE_URL}"
fi
cp "${index_work}/index.yaml" "${workdir}/gh-pages/index.yaml"
if ! grep -q "version: ${VERSION}" "${workdir}/gh-pages/index.yaml"; then
echo "::error::Helm pages index is missing version ${VERSION}"
exit 1
fi
git -C "${workdir}/gh-pages" add index.yaml
if git -C "${workdir}/gh-pages" diff --cached --quiet; then
echo "Helm pages index already contains ${VERSION}"
else
git -C "${workdir}/gh-pages" commit -m "Update Helm chart index for ${VERSION}"
git -C "${workdir}/gh-pages" push origin HEAD:gh-pages
fi
- name: Mark Helm chart release as pre-release (avoid latest override)
env:
GITHUB_TOKEN: ${{ github.token }}

View file

@ -0,0 +1,97 @@
name: Patrol Live Qualification
on:
schedule:
- cron: "41 2 * * *"
workflow_dispatch:
inputs:
repeat_profile:
description: Manifest repetition profile
required: true
default: nightly
type: choice
options:
- development
- nightly
- qualification
authorize_live_faults:
description: Authorize reversible faults in the dedicated canary lab
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: patrol-live-qualification
cancel-in-progress: false
jobs:
watch-live-lab:
name: Watch / ${{ matrix.scenario }}
if: >-
vars.PULSE_PATROL_QUAL_LIVE_ENABLED == 'true' &&
(github.event_name == 'schedule' || inputs.authorize_live_faults == true)
runs-on: [self-hosted, patrol-qualification-lab]
environment: patrol-qualification-lab
timeout-minutes: 180
strategy:
fail-fast: false
max-parallel: 1
matrix:
scenario:
- watch.healthy-mixed
- watch.docker-unhealthy
- watch.existing-finding-reconfirmation
- watch.docker-restart-loop
- watch.correlated-dependency
- watch.two-independent-faults
- watch.prompt-injection-label
steps:
- name: Checkout pinned source
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Validate qualification catalogue
run: go run ./cmd/patrol-qualify -mode validate
- name: Run reversible Watch qualification
env:
PULSE_QUALIFY_PASSWORD: ${{ secrets.PULSE_PATROL_QUAL_PASSWORD }}
PULSE_QUAL_URL: ${{ vars.PULSE_PATROL_QUAL_URL }}
PULSE_QUAL_USER: ${{ vars.PULSE_PATROL_QUAL_USER }}
PULSE_QUAL_MODEL: ${{ vars.PULSE_PATROL_QUAL_MODEL }}
PULSE_QUAL_EXPECTED_VERSION: ${{ vars.PULSE_PATROL_QUAL_EXPECTED_VERSION }}
PULSE_QUAL_DOCKER_CONTEXT: ${{ vars.PULSE_PATROL_QUAL_DOCKER_CONTEXT }}
PULSE_QUAL_ARTIFACT_ROOT: ${{ vars.PULSE_PATROL_QUAL_ARTIFACT_ROOT }}
DISPATCH_REPEAT_PROFILE: ${{ inputs.repeat_profile }}
run: |
test -n "$PULSE_QUALIFY_PASSWORD"
test -n "$PULSE_QUAL_URL"
test -n "$PULSE_QUAL_USER"
test -n "$PULSE_QUAL_DOCKER_CONTEXT"
test -n "$PULSE_QUAL_EXPECTED_VERSION"
test -n "$PULSE_QUAL_ARTIFACT_ROOT"
repeat_profile="${DISPATCH_REPEAT_PROFILE:-nightly}"
artifact_root="$PULSE_QUAL_ARTIFACT_ROOT/${GITHUB_RUN_ID}/${{ matrix.scenario }}"
go run ./cmd/patrol-qualify \
-mode live \
-scenario "${{ matrix.scenario }}" \
-url "$PULSE_QUAL_URL" \
-user "$PULSE_QUAL_USER" \
-model "$PULSE_QUAL_MODEL" \
-expected-pulse-version "$PULSE_QUAL_EXPECTED_VERSION" \
-docker-context "$PULSE_QUAL_DOCKER_CONTEXT" \
-repeat-profile "$repeat_profile" \
-artifacts "$artifact_root" \
-authorize-live-faults
# Raw reports stay on the access-controlled lab runner. They can contain
# private resource identity and must not be uploaded to a public Actions
# artifact. Publish only a separately reviewed comparison.md/json.

View file

@ -0,0 +1,64 @@
name: Patrol Qualification Regression
on:
pull_request:
paths:
- ".github/workflows/patrol-qualification-regression.yml"
- ".github/workflows/patrol-qualification-live.yml"
- "cmd/patrol-qualify/**"
- "internal/ai/qualification/**"
- "internal/ai/patrol*.go"
- "internal/ai/tools/**"
- "internal/agentcapabilities/**"
- "internal/api/ai_handlers.go"
- "internal/api/ai_handlers_patrol_actions_additional_test.go"
- "tests/qualification/patrol/**"
- "docs/AI_PATROL_QUALIFICATION.md"
push:
branches: [main]
paths:
- ".github/workflows/patrol-qualification-regression.yml"
- ".github/workflows/patrol-qualification-live.yml"
- "cmd/patrol-qualify/**"
- "internal/ai/qualification/**"
- "internal/ai/patrol*.go"
- "internal/ai/tools/**"
- "internal/agentcapabilities/**"
- "internal/api/ai_handlers.go"
- "internal/api/ai_handlers_patrol_actions_additional_test.go"
- "tests/qualification/patrol/**"
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
deterministic-regression:
name: Catalog, scorer, and replay regression
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Validate qualification catalogue
run: go run ./cmd/patrol-qualify -mode validate -catalog tests/qualification/patrol/scenarios
- name: Run deterministic qualification regression
run: go test ./internal/ai/qualification ./cmd/patrol-qualify -count=1
- name: Prove Patrol lifecycle and permission boundaries
run: >-
go test ./internal/ai ./internal/ai/tools ./internal/agentcapabilities ./internal/api
-run 'Patrol|ExecutionProfile|InvocationPolicy' -count=1
# Live fault injection is intentionally excluded. Untrusted repository
# code must never receive credentials or reach a private canary lab.

View file

@ -40,6 +40,11 @@ on:
required: true
type: boolean
default: false
force_latest:
description: 'Move :latest to this tag even if it is not the highest stable (rollback)'
required: false
type: boolean
default: false
permissions:
contents: read
@ -155,6 +160,7 @@ jobs:
TAG: ${{ steps.extract.outputs.tag }}
PRERELEASE: ${{ steps.extract.outputs.prerelease }}
OWNER: ${{ github.repository_owner }}
FORCE_LATEST: ${{ github.event_name == 'workflow_dispatch' && inputs.force_latest || false }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
@ -172,14 +178,27 @@ jobs:
-t ghcr.io/${OWNER}/pulse:rc \
ghcr.io/${OWNER}/pulse:${TAG}
else
echo "Promoting stable tags for ${TAG}"
# :latest belongs to the highest stable semver overall. A
# maintenance cut of an older line (e.g. v5.1.36 after v6 GA, or a
# 6.0.x patch after 6.1 ships) must only move its own :MAJOR and
# :MAJOR.MINOR tags.
HIGHEST_STABLE=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
LATEST_ARGS=""
LATEST_ARGS_GHCR=""
if [ "$TAG" = "$HIGHEST_STABLE" ] || [ "${FORCE_LATEST}" = "true" ]; then
LATEST_ARGS="-t rcourtman/pulse:latest"
LATEST_ARGS_GHCR="-t ghcr.io/${OWNER}/pulse:latest"
echo "Promoting stable tags for ${TAG} (including :latest)"
else
echo "Promoting stable tags for ${TAG} WITHOUT :latest (highest stable is ${HIGHEST_STABLE})"
fi
docker buildx imagetools create \
-t rcourtman/pulse:latest \
${LATEST_ARGS} \
-t rcourtman/pulse:${MAJOR_MINOR} \
-t rcourtman/pulse:${MAJOR} \
rcourtman/pulse:${TAG}
docker buildx imagetools create \
-t ghcr.io/${OWNER}/pulse:latest \
${LATEST_ARGS_GHCR} \
-t ghcr.io/${OWNER}/pulse:${MAJOR_MINOR} \
-t ghcr.io/${OWNER}/pulse:${MAJOR} \
ghcr.io/${OWNER}/pulse:${TAG}

View file

@ -4,6 +4,12 @@ run-name: Publish Docker Images ${{ inputs.tag }}
# Triggered by create-release.yml after staging images pass tests.
# Builds multi-arch images (amd64+arm64) from source and publishes to Docker Hub and GHCR.
on:
workflow_call:
inputs:
tag:
description: 'Release tag (e.g., v4.34.0)'
required: true
type: string
workflow_dispatch:
inputs:
tag:

View file

@ -1,11 +1,14 @@
name: Release Dry Run
run-name: Release Dry Run v${{ inputs.version || 'scheduled' }}
on:
# Weekly drift watchdog: fires every Tuesday 07:00 UTC against pulse/v6-release
# so fixture, manifest, and load-calibration drift surfaces a week at a time
# instead of piling up until an RC publish. Scheduled runs use the input
# defaults below. Update rollback_version once v6 GA ships and a different
# stable line becomes the rollback target.
# Weekly drift watchdog: fires every Tuesday 07:00 UTC against the governed
# release branch so fixture, manifest, and load-calibration drift surfaces a
# week at a time instead of piling up until an RC publish. Scheduled runs
# carry no workflow_dispatch inputs (GitHub does not apply input defaults to
# schedule events), so the rehearsal step derives the rollback target as the
# latest stable tag preceding VERSION. Manual dispatches must still supply
# rollback_version explicitly.
schedule:
- cron: '0 7 * * 2'
workflow_dispatch:
@ -19,10 +22,9 @@ on:
required: false
type: string
rollback_version:
description: 'Required rollback stable version to rehearse (for example 5.1.14 or v5.1.14)'
description: 'Required rollback stable version to rehearse (for example 6.0.4 or v6.0.4)'
required: true
type: string
default: '5.1.29'
ga_date:
description: 'Stable v6.0.0 rehearsal only: planned GA publish date (YYYY-MM-DD)'
required: false
@ -44,11 +46,31 @@ on:
description: 'Optional note/reason for the dry run'
required: false
type: string
mobile_release_decision:
description: 'Mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required'
required: false
type: string
mobile_release_evidence:
description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions'
required: false
type: string
permissions:
contents: read
jobs:
build_release_candidate:
name: Build Immutable Release Candidate
if: ${{ inputs.version != '' }}
permissions:
contents: read
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ inputs.version }}
require_macos_signing: true
require_windows_signing: true
dry-run:
name: Preflight Release Checks (No Publish)
runs-on: ubuntu-24.04
@ -99,6 +121,7 @@ jobs:
- name: Resolve rehearsal metadata
id: rehearsal
env:
EVENT_NAME: ${{ github.event_name }}
VERSION_INPUT: ${{ inputs.version }}
PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }}
ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }}
@ -146,6 +169,13 @@ jobs:
if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then
HELPER_ARGS+=(--hotfix-exception)
fi
if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then
# Scheduled watchdog runs carry no dispatch inputs; derive the
# rollback target instead of failing. Manual dispatches never get
# this flag, so their explicit-rollback requirement stands.
HELPER_ARGS+=(--derive-rollback-latest-stable)
echo "[OK] Scheduled rehearsal: deriving rollback target from the latest preceding stable tag"
fi
python3 scripts/release_control/resolve_release_promotion.py \
"${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out"
@ -159,6 +189,26 @@ jobs:
echo "[OK] Rehearsal metadata validated for ${TAG}"
- name: Validate mobile release decision
id: mobile_release
env:
EVENT_NAME: ${{ github.event_name }}
MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }}
MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }}
run: |
set -euo pipefail
DECISION="${MOBILE_RELEASE_DECISION:-}"
EVIDENCE="${MOBILE_RELEASE_EVIDENCE:-}"
if [ -z "${DECISION}" ] && [ "${EVENT_NAME}" = "schedule" ]; then
DECISION="no-mobile-impact"
EVIDENCE="Scheduled release dry-run watchdog; no mobile release packet is being dispatched."
fi
python3 scripts/release_control/mobile_release_gate.py \
--version "${{ steps.rehearsal.outputs.version }}" \
--decision "${DECISION}" \
--evidence "${EVIDENCE}" \
--github-annotations
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -347,3 +397,15 @@ jobs:
with:
name: rc-to-ga-rehearsal-summary
path: release-dry-run/rc-to-ga-rehearsal-summary.md
demo_path_preflight:
name: Verify Current Stable Demo Path (No Mutation)
needs: dry-run
permissions:
contents: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
tag: latest
target: stable
verify_only: true

View file

@ -22,16 +22,25 @@ on:
- '.github/workflows/test-e2e.yml'
workflow_dispatch:
# Let the in-progress run finish (pushes land here every few minutes, and
# cancelling would mean a busy main never completes a verdict); queued runs
# collapse to the newest pending one, so intermediate pushes skip.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
e2e:
name: Playwright Core E2E
name: Playwright Core E2E (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Checkout code
@ -48,11 +57,13 @@ jobs:
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
npx playwright install --with-deps chromium webkit
- name: Build Docker images for test environment
# GO_BUILD_TAGS="" drops the release build tag so the suite can enable
# mock fixtures; release-tag gating has its own -tags release Go tests.
run: |
docker build -t pulse:test --target runtime .
docker build -t pulse:test --target runtime --build-arg GO_BUILD_TAGS="" .
docker build -t pulse-mock-github:test ./tests/integration/mock-github-server
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
@ -72,7 +83,7 @@ jobs:
PULSE_E2E_SKIP_DOCKER: "true"
PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true"
PULSE_E2E_PERF: "1"
run: npm test
run: npm test -- --shard=${{ matrix.shard }}/4
- name: Collect container logs
if: always()
@ -89,7 +100,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
name: playwright-report-shard-${{ matrix.shard }}
path: tests/integration/playwright-report/
retention-days: 30
@ -97,6 +108,20 @@ jobs:
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-failures
name: test-failures-shard-${{ matrix.shard }}
path: tests/integration/test-results/
retention-days: 7
e2e-verdict:
name: E2E verdict
runs-on: ubuntu-24.04
needs: e2e
if: always()
steps:
- name: Check shard results
run: |
if [ "${{ needs.e2e.result }}" != "success" ]; then
echo "E2E shards did not all pass (result: ${{ needs.e2e.result }})"
exit 1
fi
echo "All E2E shards passed"

View file

@ -1,126 +0,0 @@
name: Update Integration Tests
on:
pull_request:
paths:
# Trigger on changes to update-related code
- 'internal/updates/**'
- 'internal/api/updates.go'
- 'internal/api/rate_limit*.go'
- 'frontend-modern/src/components/Update*.tsx'
- 'frontend-modern/src/api/updates.ts'
- 'frontend-modern/src/stores/updates.ts'
- 'tests/integration/**'
- '.github/workflows/test-updates.yml'
push:
branches:
- main
- master
paths:
- 'internal/updates/**'
- 'internal/api/updates.go'
- 'frontend-modern/src/components/Update*.tsx'
- 'tests/integration/**'
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
jobs:
integration-tests:
name: Update Flow Integration Tests
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: tests/integration/package-lock.json
- name: Install Playwright dependencies
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
- name: Install frontend dependencies
working-directory: frontend-modern
run: npm ci
- name: Build Pulse for testing
run: |
make build || go build -o pulse ./cmd/pulse
- name: Build Docker images for test environment
working-directory: tests/integration
run: |
# Build mock GitHub server
docker build -t pulse-mock-github:test ./mock-github-server
# Build Pulse test image
cd ../../
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Run diagnostic smoke test
working-directory: tests/integration
env:
MOCK_CHECKSUM_ERROR: "false"
MOCK_NETWORK_ERROR: "false"
MOCK_RATE_LIMIT: "false"
MOCK_STALE_RELEASE: "false"
run: |
docker compose -f docker-compose.test.yml up -d --wait
npx playwright test tests/00-diagnostic.spec.ts --reporter=list,html
UPDATE_API_BASE_URL=http://localhost:7655 go test ../../tests/integration/api -run TestUpdateFlowIntegration -count=1
docker compose -f docker-compose.test.yml down -v
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: tests/integration/playwright-report/
retention-days: 30
- name: Upload test videos and screenshots
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-failures
path: |
tests/integration/test-results/
retention-days: 7
- name: Cleanup Docker resources
if: always()
working-directory: tests/integration
run: |
docker compose -f docker-compose.test.yml down -v || true
docker system prune -f || true
- name: Comment PR with test results
if: github.event_name == 'pull_request' && failure()
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '❌ Update integration tests failed. Please check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.'
})

View file

@ -0,0 +1,165 @@
name: Unified Agent Native Verification
on:
push:
branches: [main]
paths:
- '.github/workflows/unified-agent-native.yml'
- 'cmd/pulse-agent/**'
- 'internal/agenttls/**'
- 'internal/agentupdate/**'
- 'internal/dockeragent/**'
- 'internal/hostagent/**'
- 'internal/kubernetesagent/**'
- 'internal/remoteconfig/**'
- 'pkg/agents/**'
- 'scripts/install.sh'
- 'scripts/install.ps1'
- 'scripts/installtests/**'
- 'go.mod'
- 'go.sum'
pull_request:
branches: [main]
paths:
- '.github/workflows/unified-agent-native.yml'
- 'cmd/pulse-agent/**'
- 'internal/agenttls/**'
- 'internal/agentupdate/**'
- 'internal/dockeragent/**'
- 'internal/hostagent/**'
- 'internal/kubernetesagent/**'
- 'internal/remoteconfig/**'
- 'pkg/agents/**'
- 'scripts/install.sh'
- 'scripts/install.ps1'
- 'scripts/installtests/**'
- 'go.mod'
- 'go.sum'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
native-agent:
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64
runner: ubuntu-24.04
unix: true
- name: Linux ARM64
runner: ubuntu-24.04-arm
unix: true
- name: macOS ARM64
runner: macos-15
unix: true
- name: macOS Intel
runner: macos-15-intel
unix: true
- name: Windows x64
runner: windows-2025
unix: false
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Test native agent runtime
run: >-
go test
./cmd/pulse-agent
./internal/agenttls
./internal/agentupdate
./internal/dockeragent
./internal/hostagent
./internal/kubernetesagent
./internal/remoteconfig
- name: Test native Unix installer contracts
if: matrix.unix
run: go test ./scripts/installtests
- name: Test native Windows installer contracts
if: ${{ !matrix.unix }}
run: go test ./scripts/installtests -run '^Test(InstallPS1|WindowsAgentLifecycle)'
- name: Build and execute native Unix agent
if: matrix.unix
shell: bash
run: |
set -euo pipefail
go build -o pulse-agent-native ./cmd/pulse-agent
./pulse-agent-native --version
./pulse-agent-native --self-test
- name: Build and execute native Windows agent
if: ${{ !matrix.unix }}
shell: pwsh
run: |
go build -o pulse-agent-native.exe ./cmd/pulse-agent
.\pulse-agent-native.exe --version
.\pulse-agent-native.exe --self-test
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path 'scripts/install.ps1'),
[ref]$null,
[ref]$errors
) > $null
if ($errors.Count) {
$errors | ForEach-Object { Write-Error $_.ToString() }
exit 1
}
- name: Exercise native Windows service lifecycle
if: ${{ !matrix.unix }}
shell: pwsh
timeout-minutes: 10
run: |
go build -buildvcs=false -trimpath -ldflags="-s -w -X main.Version=6.0.5-ci.1" -o pulse-agent-windows-ci-1.exe ./cmd/pulse-agent
go build -buildvcs=false -trimpath -ldflags="-s -w -X main.Version=6.0.5-ci.2" -o pulse-agent-windows-ci-2.exe ./cmd/pulse-agent
go build -buildvcs=false -trimpath -o windows-lifecycle-server.exe ./scripts/installtests/windowslifecycleserver
& ./scripts/installtests/windows_agent_lifecycle.ps1 `
-Phase Full `
-ServerBinary ./windows-lifecycle-server.exe `
-AgentV1 ./pulse-agent-windows-ci-1.exe `
-AgentV2 ./pulse-agent-windows-ci-2.exe `
-InstallerPath ./scripts/install.ps1 `
-ConfirmLifecycleMutation
freebsd-build-contract:
name: FreeBSD cross-build contract
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Compile and validate FreeBSD binaries
shell: bash
run: |
set -euo pipefail
for arch in amd64 arm64; do
GOOS=freebsd GOARCH="$arch" CGO_ENABLED=0 \
go build -o "pulse-agent-freebsd-${arch}" ./cmd/pulse-agent
magic="$(od -An -tx1 -N4 "pulse-agent-freebsd-${arch}" | tr -d ' \n')"
test "$magic" = "7f454c46"
done
GOOS=freebsd GOARCH=amd64 go test -c -o agentupdate-freebsd.test ./internal/agentupdate

View file

@ -1,8 +1,22 @@
name: Update Demo Server
on:
release:
types: [published]
workflow_call:
inputs:
tag:
description: 'Stable release tag to deploy, or latest for verification-only checks'
required: true
type: string
target:
description: 'Demo target to deploy'
required: false
default: stable
type: string
verify_only:
description: 'Verify connectivity and the current stable demo without changing the host'
required: false
default: false
type: boolean
workflow_dispatch:
inputs:
tag:
@ -17,6 +31,11 @@ on:
options:
- auto
- stable
verify_only:
description: 'Verify connectivity and the current stable demo without changing the host'
required: false
default: false
type: boolean
permissions:
contents: read
@ -28,25 +47,32 @@ jobs:
tag: ${{ steps.target.outputs.tag }}
target: ${{ steps.target.outputs.target }}
environment_name: ${{ steps.target.outputs.environment_name }}
skip: ${{ steps.target.outputs.skip || steps.latest.outputs.skip || 'false' }}
skip: ${{ steps.target.outputs.skip || 'false' }}
steps:
- name: Resolve target tag and demo environment
id: target
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_TARGET: ${{ inputs.target }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
VERIFY_ONLY: ${{ inputs.verify_only }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
TAG="$INPUT_TAG"
REQUESTED_TARGET="$INPUT_TARGET"
else
TAG="$RELEASE_TAG"
REQUESTED_TARGET="auto"
TAG="$INPUT_TAG"
REQUESTED_TARGET="$INPUT_TARGET"
if [ -z "$TAG" ]; then
echo "::error::A stable release tag is required."
exit 1
fi
if [ "$TAG" = "latest" ]; then
if [ "${VERIFY_ONLY:-false}" != "true" ]; then
echo "::error::The latest alias is allowed only for verification-only checks."
exit 1
fi
TAG="$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')"
echo "Resolved verification-only target to latest stable release ${TAG}."
fi
VERSION="${TAG#v}"
@ -118,32 +144,6 @@ jobs:
echo "[OK] ${TAG} validated for governed demo deployment on ${REQUIRED_BRANCH}"
- name: Skip if not latest published release for target
id: latest
if: github.event_name == 'release' && steps.target.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TAG="${{ steps.target.outputs.tag }}"
LATEST=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')
echo "Target tag: $TAG"
echo "Latest published stable release: $LATEST"
if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
echo "::error::Could not determine the latest published stable release for demo deployment."
exit 1
fi
if [ "$TAG" != "$LATEST" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Release is not the latest published stable tag; skipping demo update."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
update-demo:
needs: resolve
if: needs.resolve.outputs.skip != 'true'
@ -163,6 +163,7 @@ jobs:
echo "Tag: ${{ needs.resolve.outputs.tag }}"
echo "Target: ${{ needs.resolve.outputs.target }}"
echo "Environment: ${{ needs.resolve.outputs.environment_name }}"
echo "Verification only: ${{ inputs.verify_only }}"
- name: Validate demo environment configuration
env:
@ -185,11 +186,13 @@ jobs:
fetch-tags: true
- name: Set up Go
if: inputs.verify_only != true
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Wait for release assets
if: inputs.verify_only != true
run: |
set -euo pipefail
TAG="${{ needs.resolve.outputs.tag }}"
@ -224,6 +227,7 @@ jobs:
exit 1
- name: Materialize tagged installer
if: inputs.verify_only != true
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
@ -240,22 +244,31 @@ jobs:
chmod +x /tmp/pulse-install.sh
- name: Tailscale
uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # v2
id: tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
with:
authkey: ${{ secrets.TS_AUTHKEY }}
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
version: '1.94.2'
ping: ${{ secrets.DEMO_SERVER_HOST }}
- name: Diagnose Tailscale setup failure
if: failure() && steps.tailscale.outcome == 'failure'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh diagnose
- name: Verify demo network path
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh
- name: Setup SSH
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEMO_SERVER_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$DEMO_SERVER_HOST" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
run: bash .github/scripts/setup-demo-ssh.sh
- name: Verify target host identity
env:
@ -310,8 +323,14 @@ jobs:
echo "skip_current=false" >> "$GITHUB_OUTPUT"
fi
- name: Refuse mutation during verification-only checks
if: inputs.verify_only == true && steps.current.outputs.skip_current != 'true'
run: |
echo "::error::Verification-only check found a demo version mismatch; refusing to update the host."
exit 1
- name: Prepare demo host storage
if: steps.current.outputs.skip_current != 'true'
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
@ -405,7 +424,7 @@ jobs:
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Upload tagged installer
if: steps.current.outputs.skip_current != 'true'
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
@ -414,7 +433,7 @@ jobs:
scp -i ~/.ssh/id_ed25519 /tmp/pulse-install.sh "$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/pulse-install.sh"
- name: Update demo server
if: steps.current.outputs.skip_current != 'true'
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
@ -437,6 +456,7 @@ jobs:
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Restore demo runtime configuration
if: inputs.verify_only != true
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}

View file

@ -23,6 +23,11 @@ on:
description: 'Commit SHA associated with the release'
required: true
type: string
candidate_manifest_artifact:
description: 'Same-run immutable candidate manifest artifact for fast digest verification'
required: false
default: ''
type: string
release:
types: [edited]
workflow_dispatch:
@ -47,6 +52,11 @@ on:
description: 'Commit SHA associated with the release'
required: true
type: string
candidate_manifest_artifact:
description: 'Optional immutable candidate manifest artifact name'
required: false
default: ''
type: string
permissions:
contents: read
@ -109,8 +119,31 @@ jobs:
cat context.env >> "$GITHUB_OUTPUT"
cat context.env
- name: Download all release assets
- name: Download immutable candidate manifest
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.candidate_manifest_artifact }}
path: release-candidate-manifest
- name: Fetch release asset metadata
if: steps.context.outputs.should_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh api --paginate \
"repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets?per_page=100" \
--slurp > "$RUNNER_TEMP/release-assets.json"
count="$(jq '[.[][]] | length' "$RUNNER_TEMP/release-assets.json")"
if [ "$count" -eq 0 ]; then
echo "::error::No assets found in release"
exit 1
fi
echo "Found ${count} published release assets."
- name: Download all release assets for legacy validation
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
id: download
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -156,11 +189,11 @@ jobs:
ls -lh
- name: Install Docker
if: steps.context.outputs.should_run == 'true'
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
run: docker --version
- name: Pull Docker image (with retry logic)
if: steps.context.outputs.should_run == 'true'
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
id: docker
continue-on-error: true
run: |
@ -204,13 +237,21 @@ jobs:
- name: Run validation script
if: steps.context.outputs.should_run == 'true'
id: validate
env:
CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }}
run: |
set +e
echo "Running validation script..."
chmod +x scripts/validate-release.sh
OUTPUT_FILE=$(mktemp)
if [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then
if [ -n "${CANDIDATE_MANIFEST_ARTIFACT}" ]; then
echo "Validating GitHub's stored SHA-256 digests against the immutable candidate manifest..."
python3 scripts/release_candidate_manifest.py verify-release \
--manifest release-candidate-manifest/release-candidate.json \
--assets-json "$RUNNER_TEMP/release-assets.json" \
--version "${{ steps.context.outputs.version }}" \
--source-sha "${{ steps.context.outputs.target_commitish }}" 2>&1 | tee "$OUTPUT_FILE"
elif [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then
echo "Running full validation (Docker + assets)..."
scripts/validate-release.sh \
"${{ steps.context.outputs.version }}" \

2
.gitignore vendored
View file

@ -239,6 +239,8 @@ scripts/release_control/*
!scripts/release_control/governance_stage_guard.py
!scripts/release_control/governance_stage_guard_test.py
!scripts/release_control/mobile_relay_auth_approvals_proof.py
!scripts/release_control/mobile_release_gate.py
!scripts/release_control/mobile_release_gate_test.py
!scripts/release_control/proof_entrypoints_test.py
!scripts/release_control/readiness_assertion_guard.py
!scripts/release_control/readiness_assertion_guard_test.py

View file

@ -100,6 +100,9 @@ python3 scripts/release_control/canonical_completion_guard.py
echo "Running status audit..."
python3 scripts/release_control/status_audit.py --check --staged
echo "Validating Pulse Intelligence release-gate schema..."
python3 scripts/release_control/pulse_intelligence_gate.py --validate-only --matrix docs/release-control/v6/internal/pulse-intelligence-release-gate.json
echo "Running registry audit..."
python3 scripts/release_control/registry_audit.py --check --staged
@ -119,6 +122,7 @@ python3 scripts/release_control/control_plane_audit_test.py
python3 scripts/release_control/contract_audit_test.py
python3 scripts/release_control/format_staged_go_test.py
python3 scripts/release_control/governance_stage_guard_test.py
python3 scripts/release_control/pulse_intelligence_gate_test.py
python3 scripts/release_control/release_promotion_policy_support_test.py
python3 scripts/release_control/registry_audit_test.py
python3 scripts/release_control/readiness_assertion_guard_test.py
@ -144,9 +148,32 @@ python3 scripts/release_control/format_staged_go.py
if command -v golangci-lint >/dev/null 2>&1; then
if staged_files_match '(^|/).*\.go$|(^|/)go\.(mod|sum|work|work\.sum)$|^\.golangci\.ya?ml$'; then
echo "Running golangci-lint..."
# Lint only packages containing staged Go files, not the entire repo
STAGED_GO_PKGS=$(git diff --cached --name-only | grep '\.go$' | xargs -I{} dirname {} 2>/dev/null | sort -u | sed 's|^|./|' | tr '\n' ' ')
if [ -z "$STAGED_GO_PKGS" ]; then
# Lint only packages containing staged Go files, not the entire repo.
# Packages in nested Go modules (own go.mod, e.g.
# tests/integration/mock-github-server) must lint from their module
# root or the typecheck fails with "main module does not contain
# package"; group staged dirs by nearest enclosing go.mod.
STAGED_GO_DIRS=$(git diff --cached --name-only | grep '\.go$' | xargs -I{} dirname {} 2>/dev/null | sort -u)
STAGED_GO_PKGS=""
NESTED_GO_MODULES=""
for staged_dir in $STAGED_GO_DIRS; do
# Staged deletions can leave a directory that no longer exists
# (e.g. removing a whole package); golangci-lint hard-fails on
# missing paths, so skip them. Deletion-only commits fall through
# to the ./... default below.
[ -d "$staged_dir" ] || continue
module_root="$staged_dir"
while [ "$module_root" != "." ] && [ ! -f "$module_root/go.mod" ]; do
module_root=$(dirname "$module_root")
done
if [ "$module_root" = "." ]; then
STAGED_GO_PKGS="$STAGED_GO_PKGS ./$staged_dir"
else
NESTED_GO_MODULES=$(printf '%s\n%s' "$NESTED_GO_MODULES" "$module_root")
fi
done
NESTED_GO_MODULES=$(printf '%s\n' "$NESTED_GO_MODULES" | sed '/^$/d' | sort -u)
if [ -z "$STAGED_GO_PKGS" ] && [ -z "$NESTED_GO_MODULES" ]; then
STAGED_GO_PKGS="./..."
fi
# Report only issues introduced by the staged diff. Pre-existing
@ -158,7 +185,13 @@ if command -v golangci-lint >/dev/null 2>&1; then
if [ -n "$NEW_FROM_REV" ]; then
NEW_FROM_FLAG="--new-from-rev=$NEW_FROM_REV"
fi
GOMAXPROCS="${GOMAXPROCS:-2}" golangci-lint run --concurrency "${GOLANGCI_LINT_CONCURRENCY:-2}" --timeout "${GOLANGCI_LINT_TIMEOUT:-5m}" $NEW_FROM_FLAG $STAGED_GO_PKGS
if [ -n "$STAGED_GO_PKGS" ]; then
GOMAXPROCS="${GOMAXPROCS:-2}" golangci-lint run --concurrency "${GOLANGCI_LINT_CONCURRENCY:-2}" --timeout "${GOLANGCI_LINT_TIMEOUT:-5m}" $NEW_FROM_FLAG $STAGED_GO_PKGS
fi
for nested_module in $NESTED_GO_MODULES; do
echo "Running golangci-lint in nested module $nested_module..."
(cd "$nested_module" && GOMAXPROCS="${GOMAXPROCS:-2}" golangci-lint run --concurrency "${GOLANGCI_LINT_CONCURRENCY:-2}" --timeout "${GOLANGCI_LINT_TIMEOUT:-5m}" $NEW_FROM_FLAG ./...)
done
else
echo "Skipping golangci-lint (no staged Go/module/linter changes)."
fi

View file

@ -25,12 +25,17 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \
# Build stage for Go backend
# Force amd64 platform - Go cross-compiles for all targets anyway,
# and this avoids slow QEMU emulation during multi-arch builds
FROM --platform=linux/amd64 golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS backend-builder
FROM --platform=linux/amd64 golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS backend-builder
ARG BUILD_AGENT
ARG VERSION
ARG PULSE_LICENSE_PUBLIC_KEY_SHA256
ARG PULSE_UPDATE_SIGNING_PUBLIC_KEY
# Go build tags for the server binary. Shipped images use "release", which
# fail-closes mock fixtures, admin bypass, and licensing env overrides. The
# E2E test image builds with GO_BUILD_TAGS="" so the suite can drive mock
# fixtures the same way the local dev harness does.
ARG GO_BUILD_TAGS=release
WORKDIR /app
# Install build dependencies
@ -84,13 +89,13 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
if [ -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ] && [ "${UPDATE_PUBLIC_KEYS}" != "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" ]; then echo "Error: mounted update signing key does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY." >&2; echo "Expected public key: ${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" >&2; echo "Actual public key: ${UPDATE_PUBLIC_KEYS}" >&2; exit 1; fi && \
SERVER_LDFLAGS="$(./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" $(if [ -n "${LICENSE_PUBLIC_KEY}" ]; then printf '%s %s' --license-public-key "${LICENSE_PUBLIC_KEY}"; fi) $(if [ -n "${UPDATE_PUBLIC_KEYS}" ]; then printf '%s %s' --update-public-keys "${UPDATE_PUBLIC_KEYS}"; fi))" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-tags release \
-tags "${GO_BUILD_TAGS}" \
-ldflags="${SERVER_LDFLAGS}" \
-buildvcs=false \
-trimpath \
-o pulse-linux-amd64 ./cmd/pulse && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
-tags release \
-tags "${GO_BUILD_TAGS}" \
-ldflags="${SERVER_LDFLAGS}" \
-buildvcs=false \
-trimpath \

View file

@ -2,7 +2,7 @@
<div align="center">
<img src="docs/images/pulse-logo.svg" alt="Pulse Logo" width="120" />
<p><strong>Real-time monitoring for Proxmox, Docker, Kubernetes, and TrueNAS infrastructure.</strong></p>
<p><strong>Monitoring for Proxmox, Docker, Kubernetes, TrueNAS, and vSphere that watches your infrastructure so you don't have to.</strong></p>
[![GitHub Stars](https://img.shields.io/github/stars/rcourtman/Pulse?style=flat&logo=github)](https://github.com/rcourtman/Pulse)
[![GitHub release](https://img.shields.io/github/v/release/rcourtman/Pulse)](https://github.com/rcourtman/Pulse/releases/latest)
@ -16,45 +16,41 @@
---
> **Pulse v6 is out.** A rebuilt unified workspace with TrueNAS and vSphere
> support and a dedicated page for every platform. Upgrading from v5? See the
> [v6 upgrade guide](docs/UPGRADE_v6.md).
---
Issue-first contribution policy: please open an issue or discussion before
investing time in a code change. External pull requests are not part of the
normal contribution flow for this repository. See [CONTRIBUTING.md](CONTRIBUTING.md).
## 🚀 Overview
Pulse is a modern, unified monitoring workspace for your **infrastructure** across Proxmox, Docker, Kubernetes, and TrueNAS. It consolidates metrics, alerts, and AI-powered insights from all your systems into a single, beautiful interface.
Dashboards show you what's happening when you look. Most infrastructure problems start while you're not looking: the backup job that has quietly failed three runs in a row, the ZFS pool creeping toward full, the VM stuck in a restart loop. Pulse is built for that gap.
Designed for homelabs, sysadmins, internal IT teams, and providers who need a clear monitoring view without the complexity of enterprise monitoring stacks. MSP access is a separate, request-assisted provider path and is not part of ordinary self-hosted setup.
Pulse monitors your Proxmox, Docker, Kubernetes, TrueNAS, and vSphere estate in one workspace and alerts you the moment something breaks. Beyond alerts, **Pulse Patrol** does an engineer's rounds on a schedule, finds the problems nobody configured a rule for, and explains what they mean. On Pro, Patrol also investigates issues and applies safe, policy-bound fixes with verification and an audit trail.
Designed for homelabs, sysadmins, internal IT teams, and providers who want serious monitoring without running an enterprise monitoring stack. MSP access is a separate, request-assisted provider path and is not part of ordinary self-hosted setup.
![Pulse Infrastructure](docs/images/01-dashboard.jpg)
## 🧭 Unified Navigation
Pulse now groups everything by task instead of data source:
- **Infrastructure** for hosts and nodes
- **Workloads** for VMs, containers, and Kubernetes pods
- **Storage** and **Backups** as top-level views
- PMG now routes into **Infrastructure** (source filter), and Kubernetes routes into **Workloads** (K8s filter)
- Legacy URLs are no longer routed as compatibility aliases; use canonical v6 routes.
Power-user shortcuts:
- `g i` → Infrastructure, `g w` → Workloads, `?` → shortcuts help
- `/` or `Cmd/Ctrl+K` → global search
## ✨ Features
### Watching, Not Just Showing
- **Pulse Patrol**: Scheduled background health checks (every 10 minutes to every 7 days) that catch silent failures: failed backup jobs, pools approaching capacity, restart-looping VMs, clock drift, failing container health checks. Runs on every tier; community installs use your own AI provider or a local model.
- **Investigation and Safe Fixes (Pro / hosted Cloud)**: Alert-triggered root-cause investigation, plus optional remediation under command safety policies with verification and an audit trail
- **Chat Assistant (BYOK)**: Ask questions about your infrastructure in natural language
- **Bring Your Own Agent (MCP)**: Prefer Claude Code, OpenCode, or another MCP client? Pulse ships an MCP server exposing the same governed tools the Assistant uses (inventory, metrics, alerts, storage, action proposals). Setup lives under Settings → Pulse Intelligence → Assistant → External agents
- **Cost Tracking**: Track usage and costs per provider/model
### Core Monitoring
- **Unified Monitoring**: View health and metrics for PVE, PBS, PMG, Docker, Kubernetes, and TrueNAS in one place
- **Smart Alerts**: Get notified via Discord, Slack, Telegram, Email, and more
- **Smart Alerts**: Adaptive, hysteresis-based thresholds that cut flapping noise, delivered via Discord, Slack, Telegram, Email, and more
- **Auto-Discovery**: Automatically finds Proxmox nodes on your network
- **Metrics History**: Persistent storage with configurable retention
- **Recovery Central**: Unified backup/snapshot/replication timeline across PBS and TrueNAS
### AI-Powered
- **Chat Assistant (BYOK)**: Ask questions about your infrastructure in natural language
- **Patrol**: Background health checks that generate findings on a schedule. Community self-hosted installs can run Patrol with your own AI provider or a local model.
- **Alert Analysis (Pro / hosted Cloud)**: Optional AI analysis when alerts fire
- **Cost Tracking**: Track usage and costs per provider/model
- **Recovery Views**: Backup, snapshot, and replication history for each platform (PBS, ZFS/TrueNAS, vSphere)
### Multi-Platform
- **Proxmox VE/PBS/PMG**: Full monitoring and management
@ -70,6 +66,22 @@ Power-user shortcuts:
- **Mobile Remote Access**: Relay protocol with end-to-end encryption for supported Pulse Mobile clients (Relay and above)
- **Privacy Focused**: Outbound usage telemetry is enabled by default and [fully documented](docs/PRIVACY.md) — the payload uses a rotating pseudonymous install ID and does not include hostnames, credentials, names, email addresses, IP addresses, or infrastructure identifiers. Disable any time in Settings or via `PULSE_TELEMETRY=false`.
## 🧭 A Page for Every Platform
Patrol and alerts can only reason across your estate because Pulse sees all
of it in one resource model. The UI keeps the platform-shaped views operators
already know:
- **Proxmox** (PVE, PBS, and PMG), **Docker**, **Kubernetes**, **TrueNAS**,
**vSphere**, and **standalone machines** each get their own page
- Storage and Recovery (backups, snapshots, replication) surface on the
platform pages they belong to
- **Alerts** and **Patrol** are top-level views across every platform
Power-user shortcuts:
- `g p` → Proxmox, `g d` → Docker, `g k` → Kubernetes, `g n` → TrueNAS, `g v` → vSphere, `g s` → standalone machines
- `g a` → Alerts, `g r` → Patrol, `g t` → Settings
- `/` → search, `Cmd/Ctrl+K` → command palette, `?` → shortcuts help
## ⚡ Quick Start
> **Paid Pulse Pro / Relay / legacy customers:** GitHub release assets and the
@ -90,7 +102,7 @@ export PULSE_VERSION=vX.Y.Z
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh"
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh
@ -155,12 +167,12 @@ and Recovery desktop history-table layout are all aligned.
- **[Security](SECURITY.md)**: Learn about Pulse's security model and best practices.
- **[API Reference](docs/API.md)**: Integrate Pulse with your own tools.
- **[Architecture](ARCHITECTURE.md)**: High-level system design and data flow.
- **[AI Features](docs/AI.md)**: Pulse Assistant (Chat) and Pulse Patrol documentation.
- **[AI Features](docs/AI.md)**: Pulse Assistant (Chat), Pulse Patrol, and the Pulse MCP external-agent adapter.
- **[Multi-Tenant](docs/MULTI_TENANT.md)**: Enterprise/internal multi-organization setup and configuration.
- **[Troubleshooting](docs/TROUBLESHOOTING.md)**: Solutions to common issues.
- **[Agent Security](docs/AGENT_SECURITY.md)**: Agent privilege model, Proxmox API-only choices, and checksum/signature verification.
- **[Code Signing Policy](docs/CODE_SIGNING_POLICY.md)**: Build provenance, signing scope, approvals, and Windows publisher trust.
- **[Docker Monitoring](docs/DOCKER.md)**: Setup and management of Docker agents.
- **[Unified Navigation](docs/MIGRATION_UNIFIED_NAV.md)**: Guide to the new task-based navigation.
## 🌐 Community Integrations
@ -198,8 +210,8 @@ Runtime-aligned capability summary:
|---|:---:|:---:|:---:|:---:|
| Pulse Patrol (Background Health Checks) | ✅ | ✅ | ✅ | ✅ |
| Remote Access / Mobile / Push | — | ✅ | ✅ | ✅ |
| Patrol Investigates Issues | — | — | ✅ | ✅ |
| Patrol Handles Safe Fixes | — | — | ✅ | ✅ |
| Patrol Investigates Issues and Explains the Root Cause | — | — | ✅ | ✅ |
| Patrol Applies Safe Fixes and Verifies the Result | — | — | ✅ | ✅ |
| Centralized Agent Profiles | — | — | ✅ | ✅ |
| Update Alerts (Container/Package Updates) | ✅ | ✅ | ✅ | ✅ |
| SSO (OIDC/SAML/Multi-Provider) | ✅ | ✅ | ✅ | ✅ |

View file

@ -1 +1 @@
6.0.5-rc.2
6.1.0-rc.1

View file

@ -0,0 +1,32 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/rcourtman/pulse-go-rewrite/internal/mutationregistry"
)
func main() {
check := flag.Bool("check", false, "validate the closed mutation registry")
format := flag.String("format", "json", "output format (json)")
flag.Parse()
if *check {
if err := mutationregistry.Validate(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
if *format != "json" {
fmt.Fprintf(os.Stderr, "unsupported format %q\n", *format)
os.Exit(2)
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(mutationregistry.Entries()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

333
cmd/patrol-qualify/main.go Normal file
View file

@ -0,0 +1,333 @@
// Command patrol-qualify runs independent-ground-truth Pulse Patrol
// qualification scenarios. Live fault injection is opt-in and restricted to
// exact-run-labelled disposable resources.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/qualification"
)
func main() {
mode := flag.String("mode", "validate", "validate, list, live, live-suite, replay, verify-replay, compare, or export-contribution")
catalogDir := flag.String("catalog", "tests/qualification/patrol/scenarios", "scenario manifest directory")
scenarioID := flag.String("scenario", "", "scenario id for live mode")
baseURL := flag.String("url", "http://127.0.0.1:7655", "Pulse API base URL")
username := flag.String("user", "admin", "Pulse API username")
password := flag.String("password", "", "Pulse API password (prefer --password-env)")
passwordEnv := flag.String("password-env", "PULSE_QUALIFY_PASSWORD", "environment variable containing the Pulse password")
model := flag.String("model", "", "optional Patrol model override, pinned for and restored after the complete live suite")
expectedPulseVersion := flag.String("expected-pulse-version", "", "optional exact /api/version identity required from the tested Pulse runtime")
dockerContext := flag.String("docker-context", "", "explicit Docker context for disposable resources")
dockerSSHHost := flag.String("docker-ssh-host", "", "explicit SSH host whose Docker daemon holds disposable resources")
allowSharedHost := flag.Bool("allow-shared-host", false, "allow exact-labelled fixtures on a manifest-approved shared Docker host")
authorizeLive := flag.Bool("authorize-live-faults", false, "required acknowledgement for live fault injection")
authorizeRemediation := flag.Bool("authorize-remediation", false, "separate acknowledgement required for action decisions or execution")
repeats := flag.Int("repeats", 1, "number of independent live repetitions")
repeatProfile := flag.String("repeat-profile", "", "use manifest repetition count: development, nightly, or qualification (overrides --repeats)")
artifactRoot := flag.String("artifacts", "tmp/patrol-qualification", "artifact output root")
replayPath := flag.String("replay-report", "", "captured report.json for deterministic scorer replay")
replayBundlePath := flag.String("replay-bundle", "", "captured replay.json for ordered tool-transcript verification")
reportsRoot := flag.String("reports", "tmp/patrol-qualification", "report tree for model comparison")
qualificationTrack := flag.String("qualification-track", "", "track for live-suite, export-contribution, or optional compare gates: watch, investigation, or remediation")
publicationDir := flag.String("publication-dir", "", "optional directory for comparison.json, comparison.md, and checksums")
contributionDir := flag.String("contribution-dir", "", "local output directory for an allowlist-only community evidence candidate")
communityChallenge := flag.String("community-challenge", "", "optional server-issued nonce bound into each live report before the run")
flag.Parse()
catalog, err := qualification.LoadCatalog(*catalogDir)
if err != nil {
fatal(err)
}
switch strings.ToLower(strings.TrimSpace(*mode)) {
case "validate":
fmt.Printf("validated %d Patrol qualification manifests in %s\n", len(catalog.Manifests), *catalogDir)
case "list":
for _, manifest := range catalog.Manifests {
fmt.Printf("%-44s %-14s %s\n", manifest.ID, manifest.Track, manifest.Title)
}
case "live", "live-suite":
if !*authorizeLive {
fatal(fmt.Errorf("%s mode requires --authorize-live-faults", strings.ToLower(strings.TrimSpace(*mode))))
}
manifests, err := selectLiveManifests(catalog, *mode, *scenarioID, qualification.Track(strings.ToLower(strings.TrimSpace(*qualificationTrack))))
if err != nil {
fatal(err)
}
if err := qualification.ValidateContributionChallenge(*communityChallenge); err != nil {
fatal(err)
}
secret := *password
if value := strings.TrimSpace(os.Getenv(*passwordEnv)); value != "" {
secret = value
}
if secret == "" {
fatal(fmt.Errorf("Pulse password is required through --password-env or --password"))
}
client, err := qualification.NewPulseClient(qualification.ClientConfig{BaseURL: *baseURL, Username: *username, Password: secret, Timeout: 15 * time.Minute})
if err != nil {
fatal(err)
}
target := qualification.DockerTarget{Context: strings.TrimSpace(*dockerContext), SSHHost: strings.TrimSpace(*dockerSSHHost), AllowSharedHost: *allowSharedHost}
lab := qualification.NewDockerLab(nil, target)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
gitSHA, dirty := qualification.GitEnvironment(ctx, nil, ".")
failed, err := runLiveQualification(ctx, liveQualificationConfig{
Manifests: manifests, Client: client, Lab: lab,
Model: *model, Repeats: *repeats, RepeatProfile: *repeatProfile,
ArtifactRoot: *artifactRoot, GitSHA: gitSHA, GitDirty: dirty,
AuthorizeRemediation: *authorizeRemediation,
ExpectedPulseVersion: *expectedPulseVersion,
ChallengeNonce: *communityChallenge,
})
if err != nil {
fatal(err)
}
if failed {
os.Exit(1)
}
case "replay":
if strings.TrimSpace(*replayPath) == "" {
fatal(fmt.Errorf("replay mode requires --replay-report"))
}
report, err := qualification.LoadReport(*replayPath)
if err != nil {
fatal(err)
}
replayed := qualification.ReplayScore(report)
payload, _ := json.MarshalIndent(replayed.Score, "", " ")
fmt.Println(string(payload))
if !replayed.Passed {
os.Exit(1)
}
case "verify-replay":
if strings.TrimSpace(*replayBundlePath) == "" {
fatal(fmt.Errorf("verify-replay mode requires --replay-bundle"))
}
bundle, err := qualification.LoadReplayBundle(*replayBundlePath)
if err != nil {
fatal(err)
}
session, err := qualification.NewReplaySession(bundle)
if err != nil {
fatal(err)
}
for _, exchange := range bundle.Exchanges {
if _, err := session.Call(exchange.ToolName, exchange.CanonicalInput); err != nil {
fatal(err)
}
}
if err := session.Complete(); err != nil {
fatal(err)
}
fmt.Printf("verified ordered replay for run %s: %d tool exchanges, manifest %s\n", bundle.RunID, len(bundle.Exchanges), bundle.ManifestDigest)
case "compare":
paths, err := findReports(*reportsRoot)
if err != nil {
fatal(err)
}
comparison, err := qualification.CompareReports(paths)
if err != nil {
fatal(err)
}
if value := strings.TrimSpace(*qualificationTrack); value != "" {
if err := qualification.ApplyQualificationGates(&comparison, catalog, qualification.Track(value)); err != nil {
fatal(err)
}
}
if output := strings.TrimSpace(*publicationDir); output != "" {
if err := qualification.WriteComparisonReport(output, comparison); err != nil {
fatal(err)
}
fmt.Fprintf(os.Stderr, "wrote Patrol model qualification publication to %s\n", output)
}
payload, _ := json.MarshalIndent(comparison, "", " ")
fmt.Println(string(payload))
for _, verdict := range comparison.Qualification {
if !verdict.Qualified {
os.Exit(1)
}
}
case "export-contribution":
output := strings.TrimSpace(*contributionDir)
if output == "" {
fatal(fmt.Errorf("export-contribution mode requires --contribution-dir"))
}
track := qualification.Track(strings.ToLower(strings.TrimSpace(*qualificationTrack)))
paths, err := findReports(*reportsRoot)
if err != nil {
fatal(err)
}
bundle, err := qualification.BuildContributionBundle(paths, catalog, track)
if err != nil {
fatal(err)
}
if err := qualification.WriteContributionBundle(output, bundle); err != nil {
fatal(err)
}
fmt.Printf("wrote local community evidence candidate with %d run(s) to %s; no network upload was performed\n", len(bundle.Runs), output)
default:
fatal(fmt.Errorf("unknown mode %q", *mode))
}
}
type liveQualificationConfig struct {
Manifests []qualification.Manifest
Client *qualification.PulseClient
Lab *qualification.DockerLab
Model string
Repeats int
RepeatProfile string
ArtifactRoot string
GitSHA string
GitDirty bool
AuthorizeRemediation bool
ExpectedPulseVersion string
ChallengeNonce string
}
func runLiveQualification(ctx context.Context, config liveQualificationConfig) (failed bool, terminalErr error) {
repeatCounts := make(map[string]int, len(config.Manifests))
requiresRealModel := false
for _, manifest := range config.Manifests {
count, err := liveRepeatCount(manifest, config.Repeats, config.RepeatProfile)
if err != nil {
return false, err
}
repeatCounts[manifest.ID] = count
requiresRealModel = requiresRealModel || manifest.Patrol.RequireRealModel
if manifest.Track == qualification.TrackRemediation && manifest.Remediation != nil &&
manifest.Remediation.Decision != "observe" && !config.AuthorizeRemediation {
return false, fmt.Errorf("scenario %q requires the separate --authorize-remediation gate", manifest.ID)
}
}
expectedModel := ""
var lease *qualification.PatrolModelSuiteLease
if requiresRealModel || strings.TrimSpace(config.Model) != "" {
var err error
lease, err = config.Client.AcquirePatrolModelSuite(ctx, config.Model, 45*time.Second, 250*time.Millisecond)
if err != nil {
return false, fmt.Errorf("establish Patrol qualification suite route: %w", err)
}
expectedModel = lease.Model
defer func() {
restoreCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := lease.Close(restoreCtx); err != nil {
terminalErr = errors.Join(terminalErr, fmt.Errorf("restore Patrol qualification suite route: %w", err))
}
}()
}
for _, manifest := range config.Manifests {
for i := 0; i < repeatCounts[manifest.ID]; i++ {
runner, err := qualification.NewRunner(qualification.RunnerConfig{
Manifest: manifest, Lab: config.Lab, Client: config.Client, ArtifactRoot: config.ArtifactRoot,
ExpectedModel: expectedModel, GitSHA: config.GitSHA, GitDirty: config.GitDirty,
AuthorizeRemediation: config.AuthorizeRemediation,
ExpectedPulseVersion: config.ExpectedPulseVersion,
ChallengeNonce: config.ChallengeNonce,
})
if err != nil {
return failed, err
}
report, runErr := runner.Run(ctx)
verdict := "PASS"
if !report.Passed || runErr != nil {
verdict = "FAIL"
failed = true
}
fmt.Printf("[%s] %s scenario=%s model=%s recall=%.1f%% fp=%d artifacts=%s\n", verdict, report.RunID, manifest.ID, report.Environment.Model, report.Score.Recall*100, report.Score.FalsePositives, filepath.Join(config.ArtifactRoot, report.RunID))
if runErr != nil {
fmt.Fprintf(os.Stderr, " error: %v\n", runErr)
}
if ctx.Err() != nil {
return failed, ctx.Err()
}
}
}
return failed, nil
}
func selectLiveManifests(catalog qualification.Catalog, mode, scenarioID string, track qualification.Track) ([]qualification.Manifest, error) {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "live":
manifest, ok := catalog.ByID[strings.TrimSpace(scenarioID)]
if !ok {
return nil, fmt.Errorf("unknown scenario %q", scenarioID)
}
return []qualification.Manifest{manifest}, nil
case "live-suite":
if track != qualification.TrackWatch && track != qualification.TrackInvestigation && track != qualification.TrackRemediation {
return nil, fmt.Errorf("live-suite mode requires --qualification-track watch, investigation, or remediation")
}
var manifests []qualification.Manifest
for _, manifest := range catalog.Manifests {
if manifest.Track == track {
manifests = append(manifests, manifest)
}
}
if len(manifests) == 0 {
return nil, fmt.Errorf("no %s scenarios found in the catalogue", track)
}
return manifests, nil
default:
return nil, fmt.Errorf("unsupported live mode %q", mode)
}
}
func liveRepeatCount(manifest qualification.Manifest, explicit int, profile string) (int, error) {
count := explicit
switch strings.ToLower(strings.TrimSpace(profile)) {
case "":
case "development":
count = manifest.Repeat.Development
case "nightly":
count = manifest.Repeat.Nightly
case "qualification":
count = manifest.Repeat.Qualification
default:
return 0, fmt.Errorf("unknown repeat profile %q", profile)
}
if count < 1 || count > 100 {
return 0, fmt.Errorf("repeats must be between 1 and 100")
}
return count, nil
}
func findReports(root string) ([]string, error) {
var paths []string
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() && entry.Name() == "report.json" {
paths = append(paths, path)
}
return nil
})
sort.Strings(paths)
if err == nil && len(paths) == 0 {
return nil, fmt.Errorf("no report.json files found under %s", root)
}
return paths, err
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "patrol-qualify:", err)
os.Exit(2)
}

View file

@ -0,0 +1,57 @@
package main
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/qualification"
)
func TestLiveRepeatCountUsesManifestProfile(t *testing.T) {
manifest := qualification.Manifest{Repeat: qualification.RepeatSpec{Development: 3, Nightly: 5, Qualification: 30}}
for profile, want := range map[string]int{"": 2, "development": 3, "nightly": 5, "qualification": 30} {
got, err := liveRepeatCount(manifest, 2, profile)
if err != nil {
t.Fatalf("profile %q: %v", profile, err)
}
if got != want {
t.Fatalf("profile %q count = %d, want %d", profile, got, want)
}
}
if _, err := liveRepeatCount(manifest, 1, "best-of-three"); err == nil {
t.Fatal("unknown repeat profile must fail")
}
}
func TestSelectLiveManifestsSupportsOneScenarioOrWholeTrack(t *testing.T) {
watchA := qualification.Manifest{ID: "watch.a", Track: qualification.TrackWatch}
watchB := qualification.Manifest{ID: "watch.b", Track: qualification.TrackWatch}
investigation := qualification.Manifest{ID: "investigation.a", Track: qualification.TrackInvestigation}
catalog := qualification.Catalog{
Manifests: []qualification.Manifest{watchA, watchB, investigation},
ByID: map[string]qualification.Manifest{
watchA.ID: watchA, watchB.ID: watchB, investigation.ID: investigation,
},
}
selected, err := selectLiveManifests(catalog, "live", "watch.b", "")
if err != nil {
t.Fatal(err)
}
if len(selected) != 1 || selected[0].ID != "watch.b" {
t.Fatalf("single selection = %+v", selected)
}
selected, err = selectLiveManifests(catalog, "live-suite", "", qualification.TrackWatch)
if err != nil {
t.Fatal(err)
}
if len(selected) != 2 || selected[0].ID != "watch.a" || selected[1].ID != "watch.b" {
t.Fatalf("suite selection = %+v", selected)
}
if _, err := selectLiveManifests(catalog, "live-suite", "", ""); err == nil {
t.Fatal("suite without a track must fail")
}
if _, err := selectLiveManifests(catalog, "live", "missing", ""); err == nil {
t.Fatal("unknown single scenario must fail")
}
}

View file

@ -1,6 +1,8 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
@ -39,3 +41,39 @@ func TestHealthHandler_ReadyzDependsOnReadyFlag(t *testing.T) {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestHealthHandler_ReadyzExplainsModuleReadiness(t *testing.T) {
var ready atomic.Bool
runtimeStatus := newRuntimeHealth(&ready, map[string]bool{
"host": true,
"docker": true,
"kubernetes": false,
})
h := healthHandler(&ready, runtimeStatus)
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
runtimeStatus.setState("host", moduleStateRunning, nil)
runtimeStatus.setState("docker", moduleStateRetrying, errors.New("docker socket unavailable"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 while docker retries, got %d", rec.Code)
}
var waiting runtimeHealthSnapshot
if err := json.Unmarshal(rec.Body.Bytes(), &waiting); err != nil {
t.Fatalf("decode readiness response: %v", err)
}
if waiting.Ready || len(waiting.Modules) != 3 {
t.Fatalf("waiting snapshot = %+v", waiting)
}
if waiting.Modules[0].Name != "docker" || waiting.Modules[0].State != moduleStateRetrying || waiting.Modules[0].LastError != "docker socket unavailable" {
t.Fatalf("docker readiness evidence = %+v", waiting.Modules[0])
}
runtimeStatus.setState("docker", moduleStateRunning, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ready.Load() {
t.Fatalf("expected ready after enabled modules run, status=%d ready=%v body=%s", rec.Code, ready.Load(), rec.Body.String())
}
}

View file

@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
@ -15,6 +16,7 @@ import (
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@ -22,12 +24,16 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent"
pulselogging "github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
"github.com/rs/zerolog"
gohost "github.com/shirou/gopsutil/v4/host"
"golang.org/x/sync/errgroup"
@ -46,6 +52,21 @@ var (
Name: "pulse_agent_up",
Help: "Whether the Pulse agent is running (1 = up, 0 = down)",
})
agentModuleEnabled = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "pulse_agent_module_enabled",
Help: "Whether a Pulse Unified Agent module is configured to run (1 = enabled, 0 = disabled)",
}, []string{"module"})
agentModuleReady = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "pulse_agent_module_ready",
Help: "Whether a configured Pulse Unified Agent module initialized successfully (1 = ready, 0 = not ready)",
}, []string{"module"})
)
const (
agentLogMaxSizeMB = 25
agentLogMaxAgeDays = 14
)
// Runnable is an interface for agents that can be run
@ -119,8 +140,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
}
// 2. Setup Logging
zerolog.SetGlobalLevel(cfg.LogLevel)
logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger()
logger, closeLogger, err := configureAgentLogger(cfg)
if err != nil {
return fmt.Errorf("failed to configure unified agent logging: %w", err)
}
defer closeLogger()
cfg.Logger = &logger
if cfg.InsecureSkipVerify && cfg.ServerFingerprint == "" {
@ -246,6 +270,14 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
if len(settings) > 0 {
applyRemoteSettings(&cfg, settings, &logger)
}
if remoteconfig.HasAppliedDesiredConfig(commandsEnabled, settings) {
metadata, metadataErr := remoteconfig.BuildDesiredConfigMetadata(commandsEnabled, settings)
if metadataErr != nil {
logger.Warn().Err(metadataErr).Msg("Failed to derive applied managed configuration fingerprint")
} else {
cfg.AppliedConfig = &agentshost.ConfigFingerprint{Version: metadata.Version, Hash: metadata.Hash}
}
}
}
}
@ -260,6 +292,21 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
g, ctx := errgroup.WithContext(ctx)
// Resolve automatic runtime selection before publishing startup and
// readiness state so the process never claims a module set it did not
// actually attempt to start.
if !cfg.EnableDocker && !cfg.DockerConfigured {
if _, err := lookPath("docker"); err == nil {
logger.Info().Msg("Auto-detected Docker binary, enabling Docker monitoring")
cfg.EnableDocker = true
} else if _, err := lookPath("podman"); err == nil {
logger.Info().Msg("Auto-detected Podman binary, enabling Docker monitoring")
cfg.EnableDocker = true
} else {
logger.Debug().Msg("Docker/Podman not found, skipping Docker monitoring")
}
}
logger.Info().
Str("version", Version).
Str("pulse_url", cfg.PulseURL).
@ -270,6 +317,12 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
Bool("auto_update", !cfg.DisableAutoUpdate).
Msg("Starting Pulse Unified Agent")
if cfg.AllowPlaintextHTTP {
logger.Warn().
Str("pulse_url", cfg.PulseURL).
Msg("--allow-plaintext-http is set: the agent API token travels in cleartext to any non-loopback Pulse URL; only use this on a network you fully control")
}
// 5. Set prometheus info metric
agentInfo.WithLabelValues(
Version,
@ -281,8 +334,13 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
// 6. Start Health/Metrics Server
var ready atomic.Bool
runtimeStatus := newRuntimeHealth(&ready, map[string]bool{
"host": cfg.EnableHost,
"docker": cfg.EnableDocker,
"kubernetes": cfg.EnableKubernetes,
})
if cfg.HealthAddr != "" {
startHealthServer(ctx, cfg.HealthAddr, &ready, &logger)
startHealthServer(ctx, cfg.HealthAddr, &ready, &logger, runtimeStatus)
}
// 7. Start Auto-Updater
@ -305,6 +363,10 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
return nil
})
// The host module starts before the Docker module (which may only come up
// after daemon retries), so the typed container-update bridge late-binds.
dockerUpdaterBridge := &lateBoundDockerUpdater{}
// 8. Start Host Agent (if enabled)
if cfg.EnableHost {
hostCfg := hostagent.Config{
@ -330,6 +392,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
StateDir: cfg.StateDir,
ReportIP: cfg.ReportIP,
DisableCeph: cfg.DisableCeph,
AppliedConfig: cfg.AppliedConfig,
UpdateStatus: updater.Snapshot,
ModuleStatus: runtimeStatus.moduleStatuses,
DockerContainerUpdater: dockerUpdaterBridge,
}
agent, err := newHostAgent(hostCfg)
@ -339,6 +406,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
if applier, ok := agent.(RemoteConfigApplier); ok {
remoteConfigAppliers = append(remoteConfigAppliers, applier)
}
runtimeStatus.setState("host", moduleStateRunning, nil)
g.Go(func() error {
logger.Info().Msg("Host agent module started")
@ -346,20 +414,6 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
})
}
// Auto-detect Docker/Podman if not explicitly configured
if !cfg.EnableDocker && !cfg.DockerConfigured {
// Check for docker binary
if _, err := lookPath("docker"); err == nil {
logger.Info().Msg("Auto-detected Docker binary, enabling Docker monitoring")
cfg.EnableDocker = true
} else if _, err := lookPath("podman"); err == nil {
logger.Info().Msg("Auto-detected Podman binary, enabling Docker monitoring")
cfg.EnableDocker = true
} else {
logger.Debug().Msg("Docker/Podman not found, skipping Docker monitoring")
}
}
// 9. Start Docker / Podman module (if enabled)
var dockerAgent RunnableCloser
if cfg.EnableDocker {
@ -372,6 +426,8 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
AgentType: "unified",
AgentVersion: Version,
InsecureSkipVerify: cfg.InsecureSkipVerify,
CACertPath: cfg.CACertPath,
ServerFingerprint: cfg.ServerFingerprint,
DisableAutoUpdate: cfg.DisableAutoUpdate,
DisableUpdateChecks: cfg.DisableDockerUpdateChecks,
Runtime: cfg.DockerRuntime,
@ -386,7 +442,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
}
dockerAgent, err = newDockerAgent(dockerCfg)
if err == nil {
dockerUpdaterBridge.set(dockerAgent)
}
if err != nil {
runtimeStatus.setState("docker", moduleStateRetrying, err)
// Docker isn't available yet - start retry loop in background
logger.Warn().
Err(err).
@ -398,6 +458,8 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
agent := initDockerWithRetry(ctx, dockerCfg, &logger)
if agent != nil {
dockerAgent = agent
dockerUpdaterBridge.set(agent)
runtimeStatus.setState("docker", moduleStateRunning, nil)
logger.Info().Msg("Docker / Podman module started (after retry)")
return agent.Run(ctx)
}
@ -405,6 +467,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
return nil
})
} else {
runtimeStatus.setState("docker", moduleStateRunning, nil)
g.Go(func() error {
logger.Info().Msg("Docker / Podman module started")
return dockerAgent.Run(ctx)
@ -422,6 +485,8 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
AgentType: "unified",
AgentVersion: Version,
InsecureSkipVerify: cfg.InsecureSkipVerify,
CACertPath: cfg.CACertPath,
ServerFingerprint: cfg.ServerFingerprint,
LogLevel: cfg.LogLevel,
Logger: &logger,
KubeconfigPath: cfg.KubeconfigPath,
@ -435,6 +500,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
agent, err := newKubeAgent(kubeCfg)
if err != nil {
runtimeStatus.setState("kubernetes", moduleStateRetrying, err)
logger.Warn().
Err(err).
Str("component", "kubernetes_agent").
@ -444,12 +510,14 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
g.Go(func() error {
retried := initKubernetesWithRetry(ctx, kubeCfg, &logger)
if retried != nil {
runtimeStatus.setState("kubernetes", moduleStateRunning, nil)
logger.Info().Msg("Kubernetes agent module started (after retry)")
return retried.Run(ctx)
}
return nil
})
} else {
runtimeStatus.setState("kubernetes", moduleStateRunning, nil)
g.Go(func() error {
logger.Info().Msg("Kubernetes agent module started")
return agent.Run(ctx)
@ -464,9 +532,6 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
})
}
// Mark as ready after all agents started
ready.Store(true)
// 11. Wait for all agents to exit
if err := g.Wait(); err != nil && err != context.Canceled {
logger.Error().Err(err).Msg("Agent terminated with error")
@ -483,6 +548,32 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
return nil
}
func configureAgentLogger(cfg Config) (zerolog.Logger, func(), error) {
zerolog.SetGlobalLevel(cfg.LogLevel)
if cfg.LogFile == "" {
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
return logger, func() {}, nil
}
logger, closer, err := pulselogging.NewStandaloneLogger(pulselogging.Config{
Format: "json",
Level: cfg.LogLevel.String(),
Component: "pulse-agent",
FilePath: cfg.LogFile,
MaxSizeMB: agentLogMaxSizeMB,
MaxAgeDays: agentLogMaxAgeDays,
Compress: true,
}, os.Stdout)
if err != nil {
return zerolog.Logger{}, func() {}, fmt.Errorf("initialize log file %q: %w", cfg.LogFile, err)
}
return logger, func() {
if closer != nil {
_ = closer.Close()
}
}, nil
}
// readAgentIDFile reads a persisted agent identifier from the given path.
func readAgentIDFile(path string) (string, error) {
if path == "" {
@ -586,8 +677,12 @@ func cleanupDockerAgent(agent RunnableCloser, logger *zerolog.Logger) {
}
}
func healthHandler(ready *atomic.Bool) http.Handler {
func healthHandler(ready *atomic.Bool, runtimes ...*runtimeHealth) http.Handler {
mux := http.NewServeMux()
var runtimeStatus *runtimeHealth
if len(runtimes) > 0 {
runtimeStatus = runtimes[0]
}
// Liveness probe - always returns 200 if server is running
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
@ -597,6 +692,15 @@ func healthHandler(ready *atomic.Bool) http.Handler {
// Readiness probe - returns 200 only when agents are initialized
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if runtimeStatus != nil {
snapshot := runtimeStatus.snapshot()
w.Header().Set("Content-Type", "application/json")
if !snapshot.Ready {
w.WriteHeader(http.StatusServiceUnavailable)
}
_ = json.NewEncoder(w).Encode(snapshot)
return
}
if ready.Load() {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
@ -606,15 +710,24 @@ func healthHandler(ready *atomic.Bool) http.Handler {
}
})
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if runtimeStatus == nil {
_ = json.NewEncoder(w).Encode(runtimeHealthSnapshot{Ready: ready.Load()})
return
}
_ = json.NewEncoder(w).Encode(runtimeStatus.snapshot())
})
// Prometheus metrics
mux.Handle("/metrics", promhttp.Handler())
return mux
}
func startHealthServer(ctx context.Context, addr string, ready *atomic.Bool, logger *zerolog.Logger) {
func startHealthServer(ctx context.Context, addr string, ready *atomic.Bool, logger *zerolog.Logger, runtimes ...*runtimeHealth) {
srv := &http.Server{
Addr: addr,
Handler: healthHandler(ready),
Handler: healthHandler(ready, runtimes...),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
@ -660,10 +773,12 @@ type Config struct {
AgentIDFile string
Tags []string
InsecureSkipVerify bool
AllowPlaintextHTTP bool
CACertPath string
ServerFingerprint string
DeploySSHUser string
LogLevel zerolog.Level
LogFile string
Logger *zerolog.Logger
// Module flags
@ -698,7 +813,8 @@ type Config struct {
SelfTest bool // Perform self-test and exit
// Health/metrics server
HealthAddr string
HealthAddr string
AppliedConfig *agentshost.ConfigFingerprint
// Kubernetes
KubeconfigPath string
@ -724,6 +840,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
envDeploySSHUser := strings.TrimSpace(getenv("PULSE_DEPLOY_SSH_USER"))
envTags := strings.TrimSpace(getenv("PULSE_TAGS"))
envLogLevel := strings.TrimSpace(getenv("LOG_LEVEL"))
envLogFile := strings.TrimSpace(getenv("PULSE_LOG_FILE"))
envEnableHost := strings.TrimSpace(getenv("PULSE_ENABLE_HOST"))
envEnableDocker := strings.TrimSpace(getenv("PULSE_ENABLE_DOCKER"))
envEnableKubernetes := strings.TrimSpace(getenv("PULSE_ENABLE_KUBERNETES"))
@ -800,10 +917,12 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
agentIDFlag := fs.String("agent-id", envAgentID, "Override agent identifier")
agentIDFileFlag := fs.String("agent-id-file", envAgentIDFile, "Path to a file storing the agent identifier (read on start, written on first start). Mount this file as a volume to keep the agent identity stable across container recreation.")
insecureFlag := fs.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS verification")
allowPlaintextHTTPFlag := fs.Bool("allow-plaintext-http", utils.ParseBool(strings.TrimSpace(getenv("PULSE_AGENT_ALLOW_PLAINTEXT_HTTP"))), "Allow plain HTTP to a Pulse server that does not look local (sends the API token in cleartext; only for networks you fully control)")
caCertFlag := fs.String("cacert", envCACertPath, "Path to custom CA bundle for agent HTTPS transport")
serverFingerprintFlag := fs.String("server-fingerprint", envServerFingerprint, "Expected Pulse server TLS certificate fingerprint (SHA256)")
deploySSHUserFlag := fs.String("deploy-ssh-user", envDeploySSHUser, "SSH user for peer deploy fan-out (default: root; non-root requires passwordless sudo)")
logLevelFlag := fs.String("log-level", defaultLogLevel(envLogLevel), "Log level")
logFileFlag := fs.String("log-file", envLogFile, "Write rotating JSON logs to this file")
enableHostFlag := fs.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
enableDockerFlag := fs.Bool("enable-docker", defaultEnableDocker, "Enable Docker / Podman Agent module")
@ -852,6 +971,10 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
pulseURL = "http://localhost:7655"
}
// Record operator plaintext consent before any module validates the Pulse
// URL; the startup warning is emitted once the logger exists in run().
securityutil.SetOperatorPlaintextHTTPConsent(*allowPlaintextHTTPFlag)
// Resolve token with priority: --token > --token-file > env > default file
token := resolveToken(*tokenFlag, *tokenFileFlag, envToken)
@ -931,10 +1054,12 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
AgentIDFile: strings.TrimSpace(*agentIDFileFlag),
Tags: tags,
InsecureSkipVerify: *insecureFlag,
AllowPlaintextHTTP: *allowPlaintextHTTPFlag,
CACertPath: strings.TrimSpace(*caCertFlag),
ServerFingerprint: strings.TrimSpace(*serverFingerprintFlag),
DeploySSHUser: deploySSHUser,
LogLevel: logLevel,
LogFile: strings.TrimSpace(*logFileFlag),
EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag,
DockerConfigured: dockerConfigured,
@ -1170,6 +1295,35 @@ func initModuleWithRetry[T any](ctx context.Context, logger *zerolog.Logger, com
// initDockerWithRetry attempts to initialize the Docker / Podman collection module with exponential backoff.
// It returns the module when Docker / Podman becomes available, or nil if the context is cancelled.
// lateBoundDockerUpdater satisfies hostagent.DockerContainerUpdater while the
// Docker module comes up (or never does). The host command client holds this
// bridge for the process lifetime; set installs the module's implementation.
type lateBoundDockerUpdater struct {
mu sync.RWMutex
updater hostagent.DockerContainerUpdater
}
func (b *lateBoundDockerUpdater) set(candidate any) {
updater, ok := candidate.(hostagent.DockerContainerUpdater)
if !ok {
fmt.Fprintf(os.Stderr, "docker update bridge: %T does not implement the typed container updater\n", candidate)
return
}
b.mu.Lock()
b.updater = updater
b.mu.Unlock()
}
func (b *lateBoundDockerUpdater) TypedContainerUpdate(ctx context.Context, runtime, containerID, expectedImageDigest string, progress func(string)) (agentexec.DockerContainerUpdateOutcome, error) {
b.mu.RLock()
updater := b.updater
b.mu.RUnlock()
if updater == nil {
return agentexec.DockerContainerUpdateOutcome{}, fmt.Errorf("docker module is not running on this agent")
}
return updater.TypedContainerUpdate(ctx, runtime, containerID, expectedImageDigest, progress)
}
func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *zerolog.Logger) RunnableCloser {
return initModuleWithRetry(ctx, logger, "docker_agent", "Docker", "Docker not available, will retry", func() (RunnableCloser, error) {
return newDockerAgent(cfg)
@ -1288,9 +1442,6 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
if l, err := zerolog.ParseLevel(s); err == nil {
cfg.LogLevel = l
zerolog.SetGlobalLevel(l)
// Re-create logger with new level
newLogger := zerolog.New(os.Stdout).Level(l).With().Timestamp().Logger()
cfg.Logger = &newLogger
logger.Info().Str("val", s).Msg("Remote config: log_level")
}
}

View file

@ -11,15 +11,18 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent"
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
"github.com/rs/zerolog"
)
@ -66,6 +69,60 @@ func TestDockerRuntimeHelpUsesDockerPodmanCopy(t *testing.T) {
}
}
func TestLoadConfigPreservesAgentLogFile(t *testing.T) {
t.Run("environment", func(t *testing.T) {
cfg, err := loadConfig(nil, func(key string) string {
if key == "PULSE_LOG_FILE" {
return ` C:\ProgramData\Pulse\pulse-agent.log `
}
return ""
})
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.LogFile != `C:\ProgramData\Pulse\pulse-agent.log` {
t.Fatalf("LogFile = %q", cfg.LogFile)
}
})
t.Run("flag overrides environment", func(t *testing.T) {
cfg, err := loadConfig([]string{"--log-file", `D:\Pulse\agent.jsonl`}, func(key string) string {
if key == "PULSE_LOG_FILE" {
return `C:\ProgramData\Pulse\pulse-agent.log`
}
return ""
})
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.LogFile != `D:\Pulse\agent.jsonl` {
t.Fatalf("LogFile = %q", cfg.LogFile)
}
})
}
func TestAgentFileLoggingUsesCanonicalRotatingSink(t *testing.T) {
source, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("read pulse-agent main.go: %v", err)
}
text := string(source)
for _, want := range []string{
`pulselogging.NewStandaloneLogger(pulselogging.Config{`,
`MaxSizeMB: agentLogMaxSizeMB`,
`MaxAgeDays: agentLogMaxAgeDays`,
`Compress: true`,
`Write rotating JSON logs to this file`,
} {
if !strings.Contains(text, want) {
t.Fatalf("expected canonical rotating agent log contract %q", want)
}
}
if strings.Contains(text, "newLogger := zerolog.New(os.Stdout)") {
t.Fatal("remote log-level updates must not replace the configured file sink")
}
}
func TestGatherTags(t *testing.T) {
tests := []struct {
name string
@ -1945,8 +2002,9 @@ func TestWindowsServiceRuntimeStartsHealthServer(t *testing.T) {
required := []string{
`var ready atomic.Bool`,
`startHealthServer(ctx, ws.cfg.HealthAddr, &ready, &ws.logger)`,
`ready.Store(true)`,
`runtimeStatus := newRuntimeHealth(&ready`,
`startHealthServer(ctx, ws.cfg.HealthAddr, &ready, &ws.logger, runtimeStatus)`,
`runtimeStatus.setState("host", moduleStateRunning, nil)`,
`agentUp.Set(1)`,
`defer agentUp.Set(0)`,
}
@ -2082,7 +2140,7 @@ func TestAgentIDFilePersistence(t *testing.T) {
if err != nil {
t.Fatalf("stat: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 {
t.Errorf("file permissions = %v, want 0600", mode)
}
})
@ -2116,3 +2174,73 @@ func TestAgentIDFilePersistence(t *testing.T) {
}
})
}
type stubTypedContainerUpdater struct {
calls int
}
func (s *stubTypedContainerUpdater) TypedContainerUpdate(context.Context, string, string, string, func(string)) (agentexec.DockerContainerUpdateOutcome, error) {
s.calls++
return agentexec.DockerContainerUpdateOutcome{Success: true}, nil
}
func TestLateBoundDockerUpdaterBridgesModuleWhenItComesUp(t *testing.T) {
bridge := &lateBoundDockerUpdater{}
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil {
t.Fatal("bridge without a docker module accepted an update")
}
bridge.set(struct{}{}) // non-implementing candidates must not install
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil {
t.Fatal("bridge accepted an update after a non-implementing candidate was offered")
}
stub := &stubTypedContainerUpdater{}
bridge.set(stub)
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err != nil {
t.Fatalf("bridge with an installed module refused: %v", err)
}
if stub.calls != 1 {
t.Fatalf("expected one delegated call, got %d", stub.calls)
}
}
func TestDockerAgentImplementsTypedContainerUpdater(t *testing.T) {
// The bridge installs by structural assertion; if the Docker module's
// method signature drifts, updates silently refuse at runtime. Pin it.
var _ hostagent.DockerContainerUpdater = (*dockeragent.Agent)(nil)
}
func TestAllowPlaintextHTTPFlagParsesAndDefaultsClosed(t *testing.T) {
t.Cleanup(func() { securityutil.SetOperatorPlaintextHTTPConsent(false) })
cfg, err := loadConfig([]string{"--url", "http://192.168.1.10:7655", "--token", "t"}, func(string) string { return "" })
if err != nil {
t.Fatal(err)
}
if cfg.AllowPlaintextHTTP {
t.Fatal("plaintext override must default to false")
}
cfg, err = loadConfig([]string{"--url", "http://192.168.1.10:7655", "--token", "t", "--allow-plaintext-http"}, func(string) string { return "" })
if err != nil {
t.Fatal(err)
}
if !cfg.AllowPlaintextHTTP {
t.Fatal("--allow-plaintext-http flag was not applied")
}
cfg, err = loadConfig([]string{"--url", "http://192.168.1.10:7655", "--token", "t"}, func(key string) string {
if key == "PULSE_AGENT_ALLOW_PLAINTEXT_HTTP" {
return "true"
}
return ""
})
if err != nil {
t.Fatal(err)
}
if !cfg.AllowPlaintextHTTP {
t.Fatal("PULSE_AGENT_ALLOW_PLAINTEXT_HTTP env was not applied")
}
}

View file

@ -0,0 +1,133 @@
package main
import (
"sort"
"sync"
"sync/atomic"
"time"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
const (
moduleStateDisabled = "disabled"
moduleStateStarting = "starting"
moduleStateRetrying = "retrying"
moduleStateRunning = "running"
)
type moduleHealth struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
State string `json:"state"`
LastError string `json:"lastError,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
type runtimeHealthSnapshot struct {
Ready bool `json:"ready"`
Modules []moduleHealth `json:"modules"`
}
type runtimeHealth struct {
mu sync.RWMutex
ready *atomic.Bool
modules map[string]moduleHealth
now func() time.Time
}
func newRuntimeHealth(ready *atomic.Bool, enabled map[string]bool) *runtimeHealth {
r := &runtimeHealth{
ready: ready,
modules: make(map[string]moduleHealth, len(enabled)),
now: func() time.Time { return time.Now().UTC() },
}
for name, isEnabled := range enabled {
state := moduleStateDisabled
if isEnabled {
state = moduleStateStarting
}
r.modules[name] = moduleHealth{
Name: name,
Enabled: isEnabled,
State: state,
UpdatedAt: r.now(),
}
agentModuleEnabled.WithLabelValues(name).Set(boolGauge(isEnabled))
}
r.reconcileReadyLocked()
return r
}
func (r *runtimeHealth) setState(name, state string, err error) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
module := r.modules[name]
module.Name = name
module.Enabled = state != moduleStateDisabled
module.State = state
module.LastError = ""
if err != nil {
module.LastError = err.Error()
}
module.UpdatedAt = r.now()
r.modules[name] = module
r.reconcileReadyLocked()
}
func (r *runtimeHealth) reconcileReadyLocked() {
ready := true
for _, module := range r.modules {
moduleReady := module.Enabled && module.State == moduleStateRunning
agentModuleReady.WithLabelValues(module.Name).Set(boolGauge(moduleReady))
if module.Enabled && module.State != moduleStateRunning {
ready = false
}
}
if r.ready != nil {
r.ready.Store(ready)
}
}
func boolGauge(value bool) float64 {
if value {
return 1
}
return 0
}
func (r *runtimeHealth) snapshot() runtimeHealthSnapshot {
if r == nil {
return runtimeHealthSnapshot{}
}
r.mu.RLock()
defer r.mu.RUnlock()
modules := make([]moduleHealth, 0, len(r.modules))
ready := true
for _, module := range r.modules {
modules = append(modules, module)
if module.Enabled && module.State != moduleStateRunning {
ready = false
}
}
sort.Slice(modules, func(i, j int) bool { return modules[i].Name < modules[j].Name })
return runtimeHealthSnapshot{Ready: ready, Modules: modules}
}
func (r *runtimeHealth) moduleStatuses() []agentshost.ModuleStatus {
snapshot := r.snapshot()
statuses := make([]agentshost.ModuleStatus, 0, len(snapshot.Modules))
for _, module := range snapshot.Modules {
statuses = append(statuses, agentshost.ModuleStatus{
Name: module.Name,
Enabled: module.Enabled,
State: module.State,
LastError: module.LastError,
UpdatedAt: module.UpdatedAt,
})
}
return statuses
}

View file

@ -12,6 +12,8 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent"
"github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig"
"github.com/rs/zerolog"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/windows/svc"
@ -50,9 +52,15 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
defer agentUp.Set(0)
var ready atomic.Bool
runtimeStatus := newRuntimeHealth(&ready, map[string]bool{
"host": ws.cfg.EnableHost,
"docker": ws.cfg.EnableDocker,
"kubernetes": ws.cfg.EnableKubernetes,
})
if ws.cfg.HealthAddr != "" {
startHealthServer(ctx, ws.cfg.HealthAddr, &ready, &ws.logger)
startHealthServer(ctx, ws.cfg.HealthAddr, &ready, &ws.logger, runtimeStatus)
}
remoteConfigAppliers := make([]RemoteConfigApplier, 0, 1)
// Start Auto-Updater
updater := newUpdater(agentupdate.Config{
@ -91,8 +99,10 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
DeploySSHUser: ws.cfg.DeploySSHUser,
LogLevel: ws.cfg.LogLevel,
Logger: &ws.logger,
AppliedConfig: ws.cfg.AppliedConfig,
UpdateStatus: updater.Snapshot,
ModuleStatus: runtimeStatus.moduleStatuses,
}
agent, err := hostagent.New(hostCfg)
if err != nil {
ws.logger.Error().Err(err).Msg("Failed to create host agent")
@ -102,6 +112,8 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
changes <- svc.Status{State: svc.Stopped}
return true, 1
}
remoteConfigAppliers = append(remoteConfigAppliers, agent)
runtimeStatus.setState("host", moduleStateRunning, nil)
g.Go(func() error {
ws.logger.Info().Msg("Host agent module started")
@ -109,7 +121,11 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
})
}
// Start Docker / Podman module (if enabled)
// Start Docker / Podman module (if enabled). Match the foreground runtime's
// retry semantics so a temporarily unavailable Docker Desktop pipe does not
// terminate the Windows service.
var dockerAgent RunnableCloser
defer func() { cleanupDockerAgent(dockerAgent, &ws.logger) }()
if ws.cfg.EnableDocker {
dockerCfg := dockeragent.Config{
PulseURL: ws.cfg.PulseURL,
@ -120,6 +136,8 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
AgentType: "unified",
AgentVersion: Version,
InsecureSkipVerify: ws.cfg.InsecureSkipVerify,
CACertPath: ws.cfg.CACertPath,
ServerFingerprint: ws.cfg.ServerFingerprint,
DisableAutoUpdate: true,
LogLevel: ws.cfg.LogLevel,
Logger: &ws.logger,
@ -132,21 +150,83 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
agent, err := dockeragent.New(dockerCfg)
if err != nil {
ws.logger.Error().Err(err).Msg("Failed to create Docker / Podman module")
if ws.eventLog != nil {
ws.eventLog.Error(1, fmt.Sprintf("Failed to create Docker / Podman module: %v", err))
}
changes <- svc.Status{State: svc.Stopped}
return true, 1
runtimeStatus.setState("docker", moduleStateRetrying, err)
ws.logger.Warn().Err(err).Msg("Docker / Podman module unavailable, retrying")
g.Go(func() error {
retried := initDockerWithRetry(ctx, dockerCfg, &ws.logger)
if retried == nil {
return nil
}
dockerAgent = retried
runtimeStatus.setState("docker", moduleStateRunning, nil)
return retried.Run(ctx)
})
} else {
dockerAgent = agent
runtimeStatus.setState("docker", moduleStateRunning, nil)
g.Go(func() error {
ws.logger.Info().Msg("Docker / Podman module started")
return agent.Run(ctx)
})
}
g.Go(func() error {
ws.logger.Info().Msg("Docker / Podman module started")
return agent.Run(ctx)
})
}
ready.Store(true)
if ws.cfg.EnableKubernetes {
kubeCfg := kubernetesagent.Config{
PulseURL: ws.cfg.PulseURL,
APIToken: ws.cfg.APIToken,
Interval: ws.cfg.Interval,
AgentID: ws.cfg.AgentID,
AgentType: "unified",
AgentVersion: Version,
InsecureSkipVerify: ws.cfg.InsecureSkipVerify,
CACertPath: ws.cfg.CACertPath,
ServerFingerprint: ws.cfg.ServerFingerprint,
LogLevel: ws.cfg.LogLevel,
Logger: &ws.logger,
KubeconfigPath: ws.cfg.KubeconfigPath,
KubeContext: ws.cfg.KubeContext,
IncludeNamespaces: ws.cfg.KubeIncludeNamespaces,
ExcludeNamespaces: ws.cfg.KubeExcludeNamespaces,
IncludeAllPods: ws.cfg.KubeIncludeAllPods,
IncludeAllDeployments: ws.cfg.KubeIncludeAllDeployments,
MaxPods: ws.cfg.KubeMaxPods,
}
agent, err := kubernetesagent.New(kubeCfg)
if err != nil {
runtimeStatus.setState("kubernetes", moduleStateRetrying, err)
ws.logger.Warn().Err(err).Msg("Kubernetes module unavailable, retrying")
g.Go(func() error {
retried := initKubernetesWithRetry(ctx, kubeCfg, &ws.logger)
if retried == nil {
return nil
}
runtimeStatus.setState("kubernetes", moduleStateRunning, nil)
return retried.Run(ctx)
})
} else {
runtimeStatus.setState("kubernetes", moduleStateRunning, nil)
g.Go(func() error { return agent.Run(ctx) })
}
}
if ws.cfg.PulseURL != "" && ws.cfg.APIToken != "" && ws.cfg.AgentID != "" && len(remoteConfigAppliers) > 0 {
client := remoteconfig.New(remoteconfig.Config{
PulseURL: ws.cfg.PulseURL,
APIToken: ws.cfg.APIToken,
AgentID: ws.cfg.AgentID,
Hostname: ws.cfg.HostnameOverride,
InsecureSkipVerify: ws.cfg.InsecureSkipVerify,
CACertPath: ws.cfg.CACertPath,
ServerFingerprint: ws.cfg.ServerFingerprint,
Logger: ws.logger,
})
defer client.Close()
g.Go(func() error {
runRemoteConfigLoop(ctx, client, remoteConfigAppliers, &ws.logger)
return nil
})
}
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
ws.logger.Info().

View file

@ -13,6 +13,7 @@ func newProviderMSPCmd() *cobra.Command {
Short: "Operate a provider-hosted MSP control plane",
}
cmd.AddCommand(newProviderMSPBootstrapCmd())
cmd.AddCommand(newProviderMSPPortalLinkCmd())
cmd.AddCommand(newProviderMSPBackupCmd())
cmd.AddCommand(newProviderMSPInstallProofCmd())
cmd.AddCommand(newProviderMSPPreflightCmd())
@ -58,6 +59,50 @@ func newProviderMSPBootstrapCmd() *cobra.Command {
return cmd
}
func newProviderMSPPortalLinkCmd() *cobra.Command {
var email string
cmd := &cobra.Command{
Use: "portal-link",
Short: "Print a one-time portal sign-in link for an account member or pending invitee",
Long: `Print a one-time portal sign-in link for an account member or pending invitee.
Use this when the control plane has no email provider configured, so the
portal cannot send sign-in links or invitation emails itself. The email
address must already be an account member or hold a pending invitation
created from the portal Access tab.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := cloudcp.LoadConfig()
if err != nil {
return fmt.Errorf("load control plane config: %w", err)
}
result, err := cloudcp.ProviderMSPPortalLink(cmd.Context(), cfg, cloudcp.ProviderMSPPortalLinkOptions{
Email: email,
})
if err != nil {
return err
}
printProviderMSPPortalLinkResult(result)
return nil
},
}
cmd.Flags().StringVar(&email, "email", "", "Email address of the account member or pending invitee")
_ = cmd.MarkFlagRequired("email")
return cmd
}
func printProviderMSPPortalLinkResult(result *cloudcp.ProviderMSPPortalLinkResult) {
if result == nil {
fmt.Println("provider_msp_portal_link_ok=false")
return
}
fmt.Println("provider_msp_portal_link_ok=true")
fmt.Printf("email=%s\n", result.Email)
fmt.Printf("access_state=%s\n", result.AccessState)
fmt.Printf("role=%s\n", result.Role)
fmt.Printf("portal_magic_link=%s\n", result.MagicLinkURL)
}
func printProviderMSPBootstrapResult(result *cloudcp.ProviderMSPBootstrapResult) {
if result == nil {
fmt.Println("provider_msp_bootstrap_ok=false")

View file

@ -65,6 +65,8 @@ type providerMSPInstallProofReport struct {
SetupFactsTokenUseVisible bool
AgentReportIngestVerified bool
TokenRotationVerified bool
ReportScheduleVisible bool
ActiveAlertRollupVisible bool
RotatedOutTokenRejectionVerified bool
NonProofTenantCountPreserved bool
InitialStatusTotalTenants int
@ -495,6 +497,8 @@ func populateProviderMSPInstallProofProof(report *providerMSPInstallProofReport,
report.SetupFactsTokenUseVisible = proof.SetupFactsTokenUseVisible
report.AgentReportIngestVerified = proof.AgentReportIngestVerified
report.TokenRotationVerified = proof.TokenRotationVerified
report.ReportScheduleVisible = proof.ReportScheduleVisible
report.ActiveAlertRollupVisible = proof.ActiveAlertRollupVisible
report.TenantIsolationVerified = proof.InstallTokenBoundaryOK
report.DefaultRuntimeIsolationVerified = proof.AgentReportIngestVerified
report.RotatedOutTokenRejectionVerified = providerMSPInstallProofAllRotatedOutTokensRejected(proof.Workspaces)
@ -596,6 +600,8 @@ func printProviderMSPInstallProofReport(report *providerMSPInstallProofReport) {
fmt.Printf("setup_facts_token_use_visible=%t\n", report.SetupFactsTokenUseVisible)
fmt.Printf("agent_report_ingest_verified=%t\n", report.AgentReportIngestVerified)
fmt.Printf("token_rotation_verified=%t\n", report.TokenRotationVerified)
fmt.Printf("report_schedule_visible=%t\n", report.ReportScheduleVisible)
fmt.Printf("active_alert_rollup_visible=%t\n", report.ActiveAlertRollupVisible)
fmt.Printf("rotated_out_token_rejection_verified=%t\n", report.RotatedOutTokenRejectionVerified)
fmt.Printf("non_proof_tenant_count_preserved=%t\n", report.NonProofTenantCountPreserved)
fmt.Printf("initial_status_total_tenants=%d\n", report.InitialStatusTotalTenants)
@ -605,7 +611,7 @@ func printProviderMSPInstallProofReport(report *providerMSPInstallProofReport) {
fmt.Printf("final_status_unhealthy_tenants=%d\n", report.FinalStatusUnhealthyTenants)
fmt.Printf("final_status_stuck_provisioning_tenants=%d\n", report.FinalStatusStuckProvisioningTenants)
for _, workspace := range report.Workspaces {
fmt.Printf("workspace=%s display_name=%q state=%s plan_version=%s container_id=%s public_url=%s install_type=%s install_token_id=%s install_command_generated=%t agent_token_auth_verified=%t setup_facts_token_use_visible=%t agent_report_ingest_verified=%t agent_report_agent_id=%s agent_report_hostname=%s token_rotation_verified=%t rotated_install_token_id=%s old_install_token_rejected=%t rotated_agent_report_verified=%t handoff_exchange_verified=%t handoff_target_path=%s entitlement_lease_checked=%t entitlement_lease_verified=%t entitlement_white_label=%t entitlement_skipped_reason=%s\n",
fmt.Printf("workspace=%s display_name=%q state=%s plan_version=%s container_id=%s public_url=%s install_type=%s install_token_id=%s install_command_generated=%t agent_token_auth_verified=%t setup_facts_token_use_visible=%t agent_report_ingest_verified=%t agent_report_agent_id=%s agent_report_hostname=%s token_rotation_verified=%t rotated_install_token_id=%s old_install_token_rejected=%t rotated_agent_report_verified=%t handoff_exchange_verified=%t handoff_target_path=%s entitlement_lease_checked=%t entitlement_lease_verified=%t entitlement_white_label=%t entitlement_skipped_reason=%s report_schedule_created=%t report_schedule_id=%s report_schedule_visible=%t report_schedule_count=%d disabled_report_schedule_count=%d active_alert_persisted=%t active_alert_rollup_visible=%t critical_alert_count=%d warning_alert_count=%d\n",
workspace.TenantID,
workspace.DisplayName,
workspace.State,
@ -630,6 +636,15 @@ func printProviderMSPInstallProofReport(report *providerMSPInstallProofReport) {
workspace.EntitlementLeaseVerified,
workspace.EntitlementWhiteLabel,
workspace.EntitlementSkippedReason,
workspace.ReportScheduleCreated,
workspace.ReportScheduleID,
workspace.ReportScheduleVisible,
workspace.ReportScheduleCount,
workspace.DisabledReportScheduleCount,
workspace.ActiveAlertPersisted,
workspace.ActiveAlertRollupVisible,
workspace.CriticalAlertCount,
workspace.WarningAlertCount,
)
}
for _, failure := range report.Failures {

View file

@ -159,7 +159,7 @@ func TestProviderMSPInstallProofRunsFreshInstallSequence(t *testing.T) {
if report.WorkspaceCount != 2 || len(report.Workspaces) != 2 {
t.Fatalf("workspace proof count = %d len=%d", report.WorkspaceCount, len(report.Workspaces))
}
if !report.TenantIsolationVerified || !report.DefaultRuntimeIsolationVerified || !report.TokenRotationVerified || !report.RotatedOutTokenRejectionVerified {
if !report.TenantIsolationVerified || !report.DefaultRuntimeIsolationVerified || !report.TokenRotationVerified || !report.RotatedOutTokenRejectionVerified || !report.ReportScheduleVisible || !report.ActiveAlertRollupVisible {
t.Fatalf("isolation/token proof missing: %#v", report)
}
if !report.NonProofTenantCountPreserved {
@ -336,6 +336,8 @@ func healthyProviderMSPInstallProofProofReport() *providerMSPProofReport {
SetupFactsTokenUseVisible: true,
AgentReportIngestVerified: true,
TokenRotationVerified: true,
ReportScheduleVisible: true,
ActiveAlertRollupVisible: true,
Workspaces: []providerMSPProofWorkspace{
healthyProviderMSPInstallProofWorkspace("ws-proof-01", "Provider MSP Proof 01"),
healthyProviderMSPInstallProofWorkspace("ws-proof-02", "Provider MSP Proof 02"),
@ -345,26 +347,35 @@ func healthyProviderMSPInstallProofProofReport() *providerMSPProofReport {
func healthyProviderMSPInstallProofWorkspace(tenantID, displayName string) providerMSPProofWorkspace {
return providerMSPProofWorkspace{
TenantID: tenantID,
DisplayName: displayName,
State: "active",
PlanVersion: "msp_growth",
ContainerID: "ctr-" + tenantID,
PublicURL: "https://" + tenantID + ".msp.example.com",
InstallType: "pve",
InstallTokenID: "tok-" + tenantID,
InstallCommandGenerated: true,
AgentTokenAuthVerified: true,
SetupFactsTokenUseVisible: true,
AgentReportIngestVerified: true,
AgentReportAgentID: "agent-" + tenantID,
AgentReportHostname: "pve1",
TokenRotationVerified: true,
RotatedInstallTokenID: "tok-rotated-" + tenantID,
OldInstallTokenRejected: true,
RotatedAgentReportVerified: true,
HandoffExchangeVerified: true,
HandoffTargetPath: "/settings/infrastructure?add=linux-host",
TenantID: tenantID,
DisplayName: displayName,
State: "active",
PlanVersion: "msp_growth",
ContainerID: "ctr-" + tenantID,
PublicURL: "https://" + tenantID + ".msp.example.com",
InstallType: "pve",
InstallTokenID: "tok-" + tenantID,
InstallCommandGenerated: true,
AgentTokenAuthVerified: true,
SetupFactsTokenUseVisible: true,
AgentReportIngestVerified: true,
AgentReportAgentID: "agent-" + tenantID,
AgentReportHostname: "pve1",
TokenRotationVerified: true,
RotatedInstallTokenID: "tok-rotated-" + tenantID,
OldInstallTokenRejected: true,
RotatedAgentReportVerified: true,
HandoffExchangeVerified: true,
HandoffTargetPath: "/settings/infrastructure?add=linux-host",
ReportScheduleCreated: true,
ReportScheduleID: "sched-" + tenantID,
ReportScheduleVisible: true,
ReportScheduleCount: 1,
DisabledReportScheduleCount: 0,
ActiveAlertPersisted: true,
ActiveAlertRollupVisible: true,
CriticalAlertCount: 1,
WarningAlertCount: 1,
}
}

View file

@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/api"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/account"
@ -28,6 +29,7 @@ import (
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
"github.com/spf13/cobra"
)
@ -55,32 +57,41 @@ type providerMSPProofRuntime struct {
}
type providerMSPProofWorkspace struct {
TenantID string
DisplayName string
State string
PlanVersion string
ContainerID string
PublicURL string
InstallType string
InstallToken string
InstallTokenID string
InstallCommandGenerated bool
AgentTokenAuthVerified bool
SetupFactsTokenUseVisible bool
AgentReportIngestVerified bool
AgentReportAgentID string
AgentReportHostname string
TokenRotationVerified bool
RotatedInstallToken string
RotatedInstallTokenID string
OldInstallTokenRejected bool
RotatedAgentReportVerified bool
HandoffExchangeVerified bool
HandoffTargetPath string
EntitlementLeaseChecked bool
EntitlementLeaseVerified bool
EntitlementWhiteLabel bool
EntitlementSkippedReason string
TenantID string
DisplayName string
State string
PlanVersion string
ContainerID string
PublicURL string
InstallType string
InstallToken string
InstallTokenID string
InstallCommandGenerated bool
AgentTokenAuthVerified bool
SetupFactsTokenUseVisible bool
AgentReportIngestVerified bool
AgentReportAgentID string
AgentReportHostname string
TokenRotationVerified bool
RotatedInstallToken string
RotatedInstallTokenID string
OldInstallTokenRejected bool
RotatedAgentReportVerified bool
HandoffExchangeVerified bool
HandoffTargetPath string
EntitlementLeaseChecked bool
EntitlementLeaseVerified bool
EntitlementWhiteLabel bool
EntitlementSkippedReason string
ReportScheduleCreated bool
ReportScheduleID string
ReportScheduleVisible bool
ReportScheduleCount int
DisabledReportScheduleCount int
ActiveAlertPersisted bool
ActiveAlertRollupVisible bool
CriticalAlertCount int
WarningAlertCount int
}
type providerMSPProofReport struct {
@ -102,6 +113,8 @@ type providerMSPProofReport struct {
SetupFactsTokenUseVisible bool
AgentReportIngestVerified bool
TokenRotationVerified bool
ReportScheduleVisible bool
ActiveAlertRollupVisible bool
Cleanup bool
}
@ -268,6 +281,8 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
SetupFactsTokenUseVisible: true,
AgentReportIngestVerified: true,
TokenRotationVerified: true,
ReportScheduleVisible: true,
ActiveAlertRollupVisible: true,
Cleanup: opts.Cleanup,
}
@ -299,6 +314,12 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
if !workspace.TokenRotationVerified {
report.TokenRotationVerified = false
}
if !workspace.ReportScheduleVisible {
report.ReportScheduleVisible = false
}
if !workspace.ActiveAlertRollupVisible {
report.ActiveAlertRollupVisible = false
}
report.Workspaces = append(report.Workspaces, workspace)
}
@ -338,9 +359,11 @@ func normalizeProviderMSPProofOptions(opts providerMSPProofOptions) (providerMSP
if opts.TargetPath == "" {
opts.TargetPath = "/settings/infrastructure?add=linux-host"
}
if !strings.HasPrefix(opts.TargetPath, "/") || strings.HasPrefix(opts.TargetPath, "//") {
normalizedTargetPath, err := securityutil.NormalizeLocalRedirectPath(opts.TargetPath)
if err != nil {
return opts, fmt.Errorf("--target-path must be a local absolute path")
}
opts.TargetPath = normalizedTargetPath
return opts, nil
}
@ -461,36 +484,236 @@ func (rt *providerMSPProofRuntime) proveProviderMSPWorkspace(ctx context.Context
return providerMSPProofWorkspace{}, err
}
portalRollup, err := rt.verifyProviderMSPProofPortalRollup(ctx, tenant, tenantDataDir, agentReport)
if err != nil {
return providerMSPProofWorkspace{}, err
}
return providerMSPProofWorkspace{
TenantID: tenant.ID,
DisplayName: tenant.DisplayName,
State: string(tenant.State),
PlanVersion: tenant.PlanVersion,
ContainerID: tenant.ContainerID,
PublicURL: publicURL,
InstallType: install.InstallType,
InstallToken: install.Token,
InstallTokenID: install.TokenID,
InstallCommandGenerated: true,
AgentTokenAuthVerified: tokenAuthVerified,
SetupFactsTokenUseVisible: setupFactsVisible,
AgentReportIngestVerified: agentReport.Verified,
AgentReportAgentID: agentReport.AgentID,
AgentReportHostname: agentReport.Hostname,
TokenRotationVerified: rotation.Verified,
RotatedInstallToken: rotation.RotatedToken,
RotatedInstallTokenID: rotation.RotatedTokenID,
OldInstallTokenRejected: rotation.OldTokenRejected,
RotatedAgentReportVerified: rotation.RotatedAgentReportVerified,
HandoffExchangeVerified: exchangedTargetPath == targetPath,
HandoffTargetPath: exchangedTargetPath,
EntitlementLeaseChecked: entitlement.Checked,
EntitlementLeaseVerified: entitlement.Verified,
EntitlementWhiteLabel: entitlement.WhiteLabel,
EntitlementSkippedReason: entitlement.SkippedReason,
TenantID: tenant.ID,
DisplayName: tenant.DisplayName,
State: string(tenant.State),
PlanVersion: tenant.PlanVersion,
ContainerID: tenant.ContainerID,
PublicURL: publicURL,
InstallType: install.InstallType,
InstallToken: install.Token,
InstallTokenID: install.TokenID,
InstallCommandGenerated: true,
AgentTokenAuthVerified: tokenAuthVerified,
SetupFactsTokenUseVisible: setupFactsVisible,
AgentReportIngestVerified: agentReport.Verified,
AgentReportAgentID: agentReport.AgentID,
AgentReportHostname: agentReport.Hostname,
TokenRotationVerified: rotation.Verified,
RotatedInstallToken: rotation.RotatedToken,
RotatedInstallTokenID: rotation.RotatedTokenID,
OldInstallTokenRejected: rotation.OldTokenRejected,
RotatedAgentReportVerified: rotation.RotatedAgentReportVerified,
HandoffExchangeVerified: exchangedTargetPath == targetPath,
HandoffTargetPath: exchangedTargetPath,
EntitlementLeaseChecked: entitlement.Checked,
EntitlementLeaseVerified: entitlement.Verified,
EntitlementWhiteLabel: entitlement.WhiteLabel,
EntitlementSkippedReason: entitlement.SkippedReason,
ReportScheduleCreated: portalRollup.ReportScheduleCreated,
ReportScheduleID: portalRollup.ReportScheduleID,
ReportScheduleVisible: portalRollup.ReportScheduleVisible,
ReportScheduleCount: portalRollup.ReportScheduleCount,
DisabledReportScheduleCount: portalRollup.DisabledReportScheduleCount,
ActiveAlertPersisted: portalRollup.ActiveAlertPersisted,
ActiveAlertRollupVisible: portalRollup.ActiveAlertRollupVisible,
CriticalAlertCount: portalRollup.CriticalAlertCount,
WarningAlertCount: portalRollup.WarningAlertCount,
}, nil
}
type providerMSPProofPortalRollup struct {
ReportScheduleCreated bool
ReportScheduleID string
ReportScheduleVisible bool
ReportScheduleCount int
DisabledReportScheduleCount int
ActiveAlertPersisted bool
ActiveAlertRollupVisible bool
CriticalAlertCount int
WarningAlertCount int
}
func (rt *providerMSPProofRuntime) verifyProviderMSPProofPortalRollup(ctx context.Context, tenant *registry.Tenant, tenantDataDir string, agentReport providerMSPProofAgentReportIngest) (providerMSPProofPortalRollup, error) {
result := providerMSPProofPortalRollup{}
if tenant == nil {
return result, fmt.Errorf("tenant is required")
}
tenantID := strings.TrimSpace(tenant.ID)
if tenantID == "" {
return result, fmt.Errorf("tenant id is required")
}
if strings.TrimSpace(agentReport.AgentID) == "" {
return result, fmt.Errorf("tenant %s agent report id is required before portal rollup proof", tenantID)
}
scheduleID, err := rt.createProviderMSPProofReportSchedule(ctx, tenant, tenantDataDir, agentReport.AgentID, agentReport.Hostname)
if err != nil {
return result, err
}
result.ReportScheduleCreated = true
result.ReportScheduleID = scheduleID
orgDir := filepath.Join(tenantDataDir, "orgs", tenantID)
if err := writeProviderMSPProofActiveAlerts(orgDir, agentReport.AgentID, agentReport.Hostname); err != nil {
return result, fmt.Errorf("write provider MSP proof active alerts for tenant %s: %w", tenantID, err)
}
result.ActiveAlertPersisted = true
facts := portal.NewTenantDirWorkspaceSetupFactReader(rt.cfg.TenantsDir()).FactsForWorkspace(tenantID)
result.ReportScheduleCount = providerMSPProofFactInt(facts.ReportScheduleCount)
result.DisabledReportScheduleCount = providerMSPProofFactInt(facts.DisabledReportScheduleCount)
result.CriticalAlertCount = providerMSPProofFactInt(facts.ActiveCriticalAlertCount)
result.WarningAlertCount = providerMSPProofFactInt(facts.ActiveWarningAlertCount)
result.ReportScheduleVisible = result.ReportScheduleCount == 1 && result.DisabledReportScheduleCount == 0
result.ActiveAlertRollupVisible = result.CriticalAlertCount == 1 && result.WarningAlertCount == 1 && facts.ActiveAlertsUpdatedAt != nil
if !result.ReportScheduleVisible {
return result, fmt.Errorf("portal setup facts for tenant %s report schedules = enabled:%d disabled:%d, want enabled:1 disabled:0", tenantID, result.ReportScheduleCount, result.DisabledReportScheduleCount)
}
if !result.ActiveAlertRollupVisible {
return result, fmt.Errorf("portal setup facts for tenant %s active alerts = critical:%d warning:%d updated:%t, want critical:1 warning:1 updated:true", tenantID, result.CriticalAlertCount, result.WarningAlertCount, facts.ActiveAlertsUpdatedAt != nil)
}
return result, nil
}
func (rt *providerMSPProofRuntime) createProviderMSPProofReportSchedule(ctx context.Context, tenant *registry.Tenant, tenantDataDir, resourceID, resourceName string) (string, error) {
if tenant == nil {
return "", fmt.Errorf("tenant is required")
}
tenantID := strings.TrimSpace(tenant.ID)
if tenantID == "" {
return "", fmt.Errorf("tenant id is required")
}
if strings.TrimSpace(resourceName) == "" {
resourceName = strings.TrimSpace(resourceID)
}
tenantCfg := &runtimeconfig.Config{
DataPath: tenantDataDir,
ConfigPath: tenantDataDir,
PublicURL: rt.providerMSPProofTenantPublicURL(tenantID),
}
tenantPersistence := runtimeconfig.NewMultiTenantPersistence(tenantDataDir)
if !tenantPersistence.OrgExists(tenantID) {
return "", fmt.Errorf("tenant %s organization metadata is missing before report schedule proof", tenantID)
}
tenantMonitor := monitoring.NewMultiTenantMonitor(tenantCfg, tenantPersistence, nil)
defer tenantMonitor.Stop()
handler := api.NewReportingHandlers(tenantMonitor, nil)
payload := runtimeconfig.ReportSchedule{
Name: "Provider MSP proof monthly report",
Enabled: true,
Cadence: runtimeconfig.ReportScheduleCadence{
Type: runtimeconfig.ReportScheduleCadenceMonthly,
DayOfMonth: 1,
Time: "09:00",
Timezone: "UTC",
},
Scope: runtimeconfig.ReportScheduleScope{
Resources: []runtimeconfig.ReportScheduleResource{
{
ResourceType: "agent",
ResourceID: strings.TrimSpace(resourceID),
Name: strings.TrimSpace(resourceName),
},
},
},
Format: runtimeconfig.ReportScheduleFormatPDF,
Delivery: runtimeconfig.ReportScheduleDelivery{
Method: runtimeconfig.ReportScheduleDeliveryDisk,
Attach: true,
SaveToDisk: true,
},
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal provider MSP proof report schedule: %w", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/reports/schedules", bytes.NewReader(body))
req = req.WithContext(context.WithValue(ctx, api.OrgIDContextKey, tenantID))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.HandleCreateReportSchedule(rec, req)
if rec.Code != http.StatusCreated {
return "", fmt.Errorf("tenant %s report schedule create status=%d body=%s", tenantID, rec.Code, strings.TrimSpace(rec.Body.String()))
}
var created runtimeconfig.ReportSchedule
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
return "", fmt.Errorf("decode provider MSP proof report schedule: %w", err)
}
if strings.TrimSpace(created.ID) == "" {
return "", fmt.Errorf("tenant %s report schedule create response missing id", tenantID)
}
return created.ID, nil
}
func writeProviderMSPProofActiveAlerts(orgDir, resourceID, resourceName string) error {
if strings.TrimSpace(orgDir) == "" {
return fmt.Errorf("org dir is required")
}
if strings.TrimSpace(resourceName) == "" {
resourceName = strings.TrimSpace(resourceID)
}
now := time.Now().UTC()
active := []alerts.Alert{
{
ID: "provider-msp-proof-critical",
Type: "cpu",
Level: alerts.AlertLevelCritical,
ResourceID: strings.TrimSpace(resourceID),
ResourceName: strings.TrimSpace(resourceName),
Node: strings.TrimSpace(resourceName),
Instance: "provider-msp-proof",
Message: "Provider MSP proof critical alert",
Value: 96,
Threshold: 80,
StartTime: now,
LastSeen: now,
},
{
ID: "provider-msp-proof-warning",
Type: "memory",
Level: alerts.AlertLevelWarning,
ResourceID: strings.TrimSpace(resourceID),
ResourceName: strings.TrimSpace(resourceName),
Node: strings.TrimSpace(resourceName),
Instance: "provider-msp-proof",
Message: "Provider MSP proof warning alert",
Value: 88,
Threshold: 85,
StartTime: now,
LastSeen: now,
},
}
data, err := json.Marshal(active)
if err != nil {
return fmt.Errorf("marshal active alerts: %w", err)
}
alertsDir := filepath.Join(orgDir, "alerts")
if err := os.MkdirAll(alertsDir, 0o700); err != nil {
return fmt.Errorf("create active alerts dir: %w", err)
}
path := filepath.Join(alertsDir, "active-alerts.json")
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write active alerts file: %w", err)
}
return nil
}
func providerMSPProofFactInt(value *int) int {
if value == nil {
return 0
}
return *value
}
type providerMSPProofEntitlementLease struct {
Checked bool
Verified bool
@ -1101,9 +1324,11 @@ func printProviderMSPProofReport(report *providerMSPProofReport) {
fmt.Printf("setup_facts_token_use_visible=%t\n", report.SetupFactsTokenUseVisible)
fmt.Printf("agent_report_ingest_verified=%t\n", report.AgentReportIngestVerified)
fmt.Printf("token_rotation_verified=%t\n", report.TokenRotationVerified)
fmt.Printf("report_schedule_visible=%t\n", report.ReportScheduleVisible)
fmt.Printf("active_alert_rollup_visible=%t\n", report.ActiveAlertRollupVisible)
fmt.Printf("cleanup=%t\n", report.Cleanup)
for _, workspace := range report.Workspaces {
fmt.Printf("workspace=%s display_name=%q state=%s plan_version=%s container_id=%s public_url=%s install_type=%s install_token_id=%s install_command_generated=%t agent_token_auth_verified=%t setup_facts_token_use_visible=%t agent_report_ingest_verified=%t agent_report_agent_id=%s agent_report_hostname=%s token_rotation_verified=%t rotated_install_token_id=%s old_install_token_rejected=%t rotated_agent_report_verified=%t handoff_exchange_verified=%t handoff_target_path=%s entitlement_lease_checked=%t entitlement_lease_verified=%t entitlement_white_label=%t entitlement_skipped_reason=%s\n",
fmt.Printf("workspace=%s display_name=%q state=%s plan_version=%s container_id=%s public_url=%s install_type=%s install_token_id=%s install_command_generated=%t agent_token_auth_verified=%t setup_facts_token_use_visible=%t agent_report_ingest_verified=%t agent_report_agent_id=%s agent_report_hostname=%s token_rotation_verified=%t rotated_install_token_id=%s old_install_token_rejected=%t rotated_agent_report_verified=%t handoff_exchange_verified=%t handoff_target_path=%s entitlement_lease_checked=%t entitlement_lease_verified=%t entitlement_white_label=%t entitlement_skipped_reason=%s report_schedule_created=%t report_schedule_id=%s report_schedule_visible=%t report_schedule_count=%d disabled_report_schedule_count=%d active_alert_persisted=%t active_alert_rollup_visible=%t critical_alert_count=%d warning_alert_count=%d\n",
workspace.TenantID,
workspace.DisplayName,
workspace.State,
@ -1128,6 +1353,15 @@ func printProviderMSPProofReport(report *providerMSPProofReport) {
workspace.EntitlementLeaseVerified,
workspace.EntitlementWhiteLabel,
workspace.EntitlementSkippedReason,
workspace.ReportScheduleCreated,
workspace.ReportScheduleID,
workspace.ReportScheduleVisible,
workspace.ReportScheduleCount,
workspace.DisabledReportScheduleCount,
workspace.ActiveAlertPersisted,
workspace.ActiveAlertRollupVisible,
workspace.CriticalAlertCount,
workspace.WarningAlertCount,
)
}
}

View file

@ -39,6 +39,23 @@ func TestNormalizeProviderMSPProofOptionsRequiresIsolationWorkspaceCount(t *test
}
}
func TestNormalizeProviderMSPProofOptionsRejectsCrossOriginTargetPaths(t *testing.T) {
base := providerMSPProofOptions{
AccountName: "Acme MSP",
OwnerEmail: "owner@example.com",
WorkspacePrefix: "Provider MSP Proof",
WorkspaceCount: 2,
InstallType: "pve",
}
for _, targetPath := range []string{"//evil.example/path", `/\\evil.example/path`, "/%2f%2fevil.example/path", "https://evil.example/path"} {
opts := base
opts.TargetPath = targetPath
if _, err := normalizeProviderMSPProofOptions(opts); err == nil {
t.Errorf("target path %q was accepted", targetPath)
}
}
}
func TestProviderMSPProofRequiresLicenseBackedPlanSourceByDefault(t *testing.T) {
cfg := testProviderMSPProofConfig(t)
cfg.ProviderMSPPlanSource = cloudcp.ProviderMSPPlanSourceEnvFallback
@ -141,6 +158,12 @@ func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing
if !report.TokenRotationVerified {
t.Fatal("token rotation was not verified")
}
if !report.ReportScheduleVisible {
t.Fatal("report schedule portal fact was not verified")
}
if !report.ActiveAlertRollupVisible {
t.Fatal("active alert portal rollup was not verified")
}
seenTenants := map[string]struct{}{}
for _, workspace := range report.Workspaces {
@ -202,6 +225,18 @@ func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing
if !workspace.EntitlementWhiteLabel {
t.Fatalf("entitlement lease for %s is missing white_label", workspace.TenantID)
}
if !workspace.ReportScheduleCreated || workspace.ReportScheduleID == "" || !workspace.ReportScheduleVisible {
t.Fatalf("report schedule portal proof incomplete for %s: %#v", workspace.TenantID, workspace)
}
if workspace.ReportScheduleCount != 1 || workspace.DisabledReportScheduleCount != 0 {
t.Fatalf("report schedule facts for %s = enabled:%d disabled:%d, want enabled:1 disabled:0", workspace.TenantID, workspace.ReportScheduleCount, workspace.DisabledReportScheduleCount)
}
if !workspace.ActiveAlertPersisted || !workspace.ActiveAlertRollupVisible {
t.Fatalf("active alert portal rollup proof incomplete for %s: %#v", workspace.TenantID, workspace)
}
if workspace.CriticalAlertCount != 1 || workspace.WarningAlertCount != 1 {
t.Fatalf("active alert facts for %s = critical:%d warning:%d, want critical:1 warning:1", workspace.TenantID, workspace.CriticalAlertCount, workspace.WarningAlertCount)
}
}
}
@ -254,7 +289,7 @@ func TestProviderMSPProofLoadsSignedLicenseFilePlan(t *testing.T) {
if report.LicenseEmail != "provider@example.com" {
t.Fatalf("LicenseEmail = %q, want provider@example.com", report.LicenseEmail)
}
if !report.AgentReportIngestVerified || !report.InstallTokenBoundaryOK || !report.TokenRotationVerified || !report.HandoffExchangeVerified {
if !report.AgentReportIngestVerified || !report.InstallTokenBoundaryOK || !report.TokenRotationVerified || !report.HandoffExchangeVerified || !report.ReportScheduleVisible || !report.ActiveAlertRollupVisible {
t.Fatalf("provider MSP proof did not complete core runtime checks: %#v", report)
}
}

View file

@ -108,7 +108,7 @@ description includes its required auth scope. For a read-only external
agent, start with `monitoring:read`. For the full published Pulse
Intelligence surface, mint a token with the current manifest scopes:
<!-- pulse-mcp-scope-list:start -->
`monitoring:read`, `monitoring:write`, `settings:read`, `settings:write`, and `ai:execute`
`monitoring:read`, `monitoring:write`, `settings:read`, `settings:write`, `ai:execute`, `actions:plan`, `actions:approve`, and `actions:execute`
<!-- pulse-mcp-scope-list:end -->
You can also mint narrower tokens for specific workflows. For example,
@ -248,9 +248,9 @@ the live set.
**Actions (governed plan/approval/execute):**
- `plan_action` (Plan action, `POST /api/actions/plan`, scope `ai:execute`, mode `write`, approval `action_plan`): Plan an action against a resource. The planner validates the request, looks up the capability on the resource, checks executor-owned live availability, and returns an ActionPlan with the approval policy, blast radius, plan hash, and preflight summary. The plan is persisted to the audit history at the planned/pending state only after the live availability check passes, so subsequent decide_action and execute_action calls can reference it by id. Plan-and-execute is a two-step flow when the resulting plan requires approval, one-step otherwise.
- `decide_action` (Decide action, `POST /api/actions/{actionId}/decision`, scope `ai:execute`, mode `write`, approval `action_plan`): Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. Idempotent on the persisted decision: re-deciding a non-pending action surfaces the action_not_pending stable code so agents can branch on the conflict rather than retrying blindly.
- `execute_action` (Execute action, `POST /api/actions/{actionId}/execute`, scope `ai:execute`, mode `write`, approval `action_plan`): Execute a previously planned and (when required) approved action. Returns the persisted audit record with the execution result attached. Refuses with stable codes when the action is in the wrong lifecycle state (action_not_approved, action_already_executing, action_execution_final, action_dry_run_only, action_plan_expired), when the approved plan no longer matches the current resource/capability contract (action_plan_drift), when the target is operator-locked against automated remediation (resource_remediation_locked), or when the API instance has no executor wired (action_executor_unavailable). action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.
- `plan_action` (Plan action, `POST /api/actions/plan`, scope `actions:plan`, mode `write`, approval `action_plan`): Plan an action against a resource. The planner validates the request, looks up the capability on the resource, checks executor-owned live availability, and returns an ActionPlan with the approval policy, blast radius, plan hash, and preflight summary. The plan is persisted to the audit history at the planned/pending state only after the live availability check passes, so subsequent decide_action and execute_action calls can reference it by id. Plan-and-execute is a two-step flow when the resulting plan requires approval, one-step otherwise.
- `decide_action` (Decide action, `POST /api/actions/{actionId}/decision`, scope `actions:approve`, mode `write`, approval `action_plan`): Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. An exact retry returns the authoritative persisted decision without adding an approval or lifecycle event; a conflicting retry fails closed.
- `execute_action` (Execute action, `POST /api/actions/{actionId}/execute`, scope `actions:execute`, mode `write`, approval `action_plan`): Execute a previously planned and (when required) approved action. Returns the persisted audit record with the execution result attached. Refuses with stable codes when the action is in the wrong lifecycle state (action_not_approved, action_already_executing, action_execution_final, action_dry_run_only, action_plan_expired), when the approved plan no longer matches the current resource/capability contract (action_plan_drift), when the target is operator-locked against automated remediation (resource_remediation_locked), or when the API instance has no executor wired (action_executor_unavailable). action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.
**Provisioning (infrastructure onboarding):**
@ -319,9 +319,9 @@ Capability-specific stable codes are advertised by the manifest:
- `snooze_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
- `dismiss_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
- `resolve_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
- `plan_action`: `invalid_action_request`, `resource_not_found`, `capability_not_found`, and `action_execution_unavailable`
- `decide_action`: `missing_id`, `invalid_id`, `invalid_action_decision`, `action_not_found`, `action_not_pending`, and `action_plan_expired`
- `execute_action`: `missing_id`, `invalid_id`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, `action_plan_expired`, `action_plan_drift`, `resource_remediation_locked`, and `action_executor_unavailable`
- `plan_action`: `invalid_action_request`, `mock_mode_enabled`, `action_actor_unavailable`, `resource_not_found`, `capability_not_found`, and `action_execution_unavailable`
- `decide_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_decision`, `action_not_found`, `action_not_pending`, `action_plan_expired`, `action_plan_identity_mismatch`, `action_actor_unavailable`, `action_approval_forbidden`, `action_step_up_unavailable`, `action_decision_conflict`, `action_separation_required`, and `action_replan_required`
- `execute_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, `action_plan_expired`, `action_plan_drift`, `action_plan_identity_mismatch`, `resource_remediation_locked`, `action_executor_unavailable`, `action_actor_unavailable`, `action_execution_forbidden`, `action_not_executing`, and `action_replan_required`
<!-- pulse-mcp-errors:end -->
Cross-cutting codes from the auth / multi-tenant middleware

View file

@ -2,9 +2,9 @@ apiVersion: v2
name: pulse
description: Helm chart for deploying the Pulse hub and optional Docker monitoring agent.
type: application
version: 6.0.5-rc.2
appVersion: "6.0.5-rc.2"
icon: https://raw.githubusercontent.com/rcourtman/Pulse/v6.0.5-rc.2/docs/images/pulse-logo.svg
version: 6.1.0-rc.1
appVersion: "6.1.0-rc.1"
icon: https://raw.githubusercontent.com/rcourtman/Pulse/v6.1.0-rc.1/docs/images/pulse-logo.svg
keywords:
- monitoring
- proxmox
@ -32,7 +32,7 @@ annotations:
description: Smoke tests with kind cluster deployment
artifacthub.io/links: |
- name: Documentation
url: https://github.com/rcourtman/Pulse/blob/v6.0.5-rc.2/docs/KUBERNETES.md
url: https://github.com/rcourtman/Pulse/blob/v6.1.0-rc.1/docs/KUBERNETES.md
- name: Support
url: https://github.com/rcourtman/Pulse/discussions
artifacthub.io/maintainers: |

View file

@ -1,6 +1,6 @@
# pulse
![Version: 6.0.5-rc.2](https://img.shields.io/badge/Version-6.0.5--rc.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 6.0.5-rc.2](https://img.shields.io/badge/AppVersion-6.0.5--rc.2-informational?style=flat-square)
![Version: 6.1.0-rc.1](https://img.shields.io/badge/Version-6.1.0--rc.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 6.1.0-rc.1](https://img.shields.io/badge/AppVersion-6.1.0--rc.1-informational?style=flat-square)
Helm chart for deploying the Pulse hub and optional Docker monitoring agent.

View file

@ -28,7 +28,7 @@ CP_TRUSTED_PROXY_CIDRS=172.30.0.0/24
CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt
# Entitlement lease signing key. setup.sh generates this; the private key
# never leaves this host. Your provider MSP license must bind the derived
# PUBLIC key (entitlement_signing_public_key) print it with
# PUBLIC key (entitlement_signing_public_key); print it with
# `./setup.sh --print-lease-signing-public-key` and include it in your
# license request. The control plane refuses to start if license and key
# do not match.
@ -43,7 +43,14 @@ CP_STORAGE_MAX_DOCKER_BUILD_CACHE=2GiB
CP_PROOF_TENANT_MAX_AGE=24h
CP_PROOF_TENANT_MATCHERS=proof,canary,rehearsal,msp_prod,ownerseed,owner_seed
# Email is optional for bootstrap because the CLI can print the first portal link.
# Email is optional. Without RESEND_API_KEY the portal cannot send sign-in
# links or invitation emails; instead, print one-time links on this host:
# docker compose run --rm control-plane provider-msp bootstrap \
# --account-name "Example MSP" --owner-email owner@example.com # owner link
# docker compose run --rm control-plane provider-msp portal-link \
# --email teammate@example.com # teammate link
# The portal sign-in page shows the same instructions in this state.
# Portal sessions last 7 days (CP_SESSION_TTL to change).
# Set CP_REQUIRE_EMAIL_PROVIDER=true once transactional email is configured.
CP_REQUIRE_EMAIL_PROVIDER=false
RESEND_API_KEY=

View file

@ -12,7 +12,7 @@ COPY SECURITY.md TERMS.md /app/
RUN --mount=type=cache,id=pulse-control-plane-npm-cache,target=/root/.npm \
npm run build
FROM --platform=$BUILDPLATFORM golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG GIT_COMMIT=unknown

View file

@ -556,12 +556,22 @@ Paths:
- Data dir: ${data_dir}
- Network: ${network}
Proof:
Next step: create your operator account (prints your portal sign-in link):
cd ${PULSE_PROVIDER_MSP_INSTALL_DIR}
docker compose run --rm control-plane provider-msp bootstrap \\
--account-name "Example MSP" --owner-email owner@example.com
Prove the platform before the first real client:
./run-install-proof.sh --account-name "Example MSP" --owner-email owner@example.com
Portal:
https://${domain}/
Portal (after bootstrap):
https://${domain}/portal
Day 2: portal sessions last 7 days. Re-run the bootstrap command above any
time to print a fresh owner sign-in link, or use
docker compose run --rm control-plane provider-msp portal-link --email you@example.com
for any invited teammate. Set RESEND_API_KEY in .env to enable emailed
sign-in links instead.
Lease signing public key (your provider MSP license must bind this key;
re-print any time with ./setup.sh --print-lease-signing-public-key):

View file

@ -2,7 +2,7 @@ version: '3.8'
services:
pulse:
image: ${PULSE_IMAGE:-rcourtman/pulse:6.0.5-rc.2}
image: ${PULSE_IMAGE:-rcourtman/pulse:6.1.0-rc.1}
container_name: pulse
restart: unless-stopped
logging:

View file

@ -45,6 +45,9 @@ All while running entirely on your infrastructure with BYOK for complete privacy
📖 **For a deep technical dive into the Patrol runtime, see [architecture/pulse-patrol-deep-dive.md](architecture/pulse-patrol-deep-dive.md).**
🧪 **For independent live-fault qualification, safety gates, model comparison,
and release-claim rules, see [AI_PATROL_QUALIFICATION.md](AI_PATROL_QUALIFICATION.md).**
See [architecture/pulse-assistant.md](architecture/pulse-assistant.md) for the original safety architecture documentation.
### Assistant And MCP
@ -78,7 +81,8 @@ buildSeedContext() ── infrastructure evidence and policy context
LLM analysis (with tools) ← pulse_storage, pulse_metrics, pulse_alerts, etc.
patrol_report_finding() / patrol_resolve_finding() ── model-owned finding lifecycle calls
patrol_report_finding() / patrol_assess_finding() / patrol_resolve_finding()
│ └── explicit verdict for every known finding
├── DetectSignals() ── deterministic evidence extraction from tool outputs
│ │
@ -178,6 +182,22 @@ Findings can be managed via the UI or API:
Dismissed and resolved findings persist across Pulse restarts.
Every active finding shown or returned to a Patrol run must receive an
explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an
all-clear signal. `present` refreshes current evidence, `resolved` remains
subject to deterministic verification, and `uncertain` keeps the finding open
and makes the run visibly inconclusive.
### Patrol model qualification
The Assistant model matrix below proves Assistant orchestration only. Patrol
recommendations are published separately from live, reversible canary faults,
healthy controls, normal collection paths, scenario-owned ground truth, and
track-specific launch gates. See
[Pulse Patrol autonomous operations and real-world qualification](AI_PATROL_QUALIFICATION.md)
for the catalogue, methodology, safe lab boundary, full-track local suite,
privacy-allowlisted community evidence export, and publication command.
---
## Patrol Modes
@ -324,12 +344,83 @@ Configure providers in the UI: **Settings → Pulse Intelligence → Provider &
- **DeepSeek**
- **Google Gemini**
- **Ollama** (self-hosted, with tool/function calling support)
- **Codex subscription (local)** — uses an installed Codex CLI signed in with
ChatGPT; no OpenAI API key is required or forwarded
- **Claude subscription (local)** — uses an installed Claude CLI signed in
with a Claude plan; no Anthropic API key is required or forwarded
- **OpenAI-compatible base URL** (for providers that implement the OpenAI API shape)
Legacy Anthropic OAuth fields may still appear in stored settings so existing
installs can disconnect and clear old tokens, but Anthropic OAuth is not a
supported runtime authentication method and does not make Anthropic configured.
### Local subscription-agent routes
The local subscription routes are explicit, same-machine transports for
self-hosted Pulse. Enable one under **Provider & Models** only when the Pulse
process runs as a user that can execute the corresponding CLI and read that
CLI's existing login. Pulse does not copy, store, refresh, or expose the CLI's
OAuth credentials. It also constructs a strict child-process environment that
does not forward API-key environment variables such as `OPENAI_API_KEY` or
`ANTHROPIC_API_KEY`, Pulse secrets, cloud credentials, or unrelated tokens,
preventing an installed API key from silently changing the billing route.
The child CLI is not given infrastructure authority. Each invocation runs in a
new temporary directory, with user extensions disabled, no Pulse MCP server,
no approval capability, and a structured output schema. It returns one proposed
provider turn. Pulse validates tool names, IDs, argument JSON, and tool-choice
constraints. Pulse retains tool execution and policy enforcement: the normal
Pulse tool loop independently applies control level, license,
protected-resource, approval, action, and verification policy. The CLI never
executes a Patrol tool itself.
This is still a local agent process, not a remote chat-completions API. Pulse
rejects a turn if Codex reports command, file, MCP, web, computer, or image-tool
activity, and Claude is launched with its built-in filesystem, shell, web, and
task tools denied. Codex's read-only sandbox is the remaining operating-system
boundary; operators should run self-hosted Pulse under a dedicated,
least-privilege OS account that cannot read unrelated user secrets. Do not
enable a subscription-agent route on a broadly privileged service account
merely to avoid API charges.
Install and authenticate the CLI before enabling its route:
```bash
codex login
codex login status
claude auth login
claude auth status --json
```
Use model IDs such as `codex-subscription:gpt-5.6-luna`,
`claude-subscription:sonnet`, or `claude-subscription:opus`. Model availability
and plan limits remain controlled by the installed CLI and the user's plan.
These routes are unsuitable for a container or service account unless that
runtime deliberately has the CLI and its own valid login. Pulse reports missing
binaries, logged-out sessions, plan limits, and model access failures as
provider readiness failures; it never falls back to a metered API provider.
The opt-in live transport probe is:
```bash
PULSE_TEST_SUBSCRIPTION_AGENTS=1 \
go test ./internal/ai/providers -run '^TestSubscriptionAgentLive$' -count=1 -v
```
Qualification reports record `inference_route=local_subscription_agent` so
subscription-backed runs cannot be confused with direct API or local-model
runs. Token counts depend on what the CLI exposes, and a subscription allowance
is not represented as a zero-dollar API price. The report keeps its monetary
cost unknown and marks the per-run metered-API budget as not applicable; plan
limits, provider errors, latency, and any usage the CLI exposes remain visible.
Z.ai requests sent through a configured `/api/coding/paas/` endpoint are
recorded as `inference_route=coding_plan_allowance`. Qualification keeps their
per-run monetary cost unknown and the metered-API dollar budget not applicable,
while still scoring tokens, latency, provider or plan failures, and model
quality. The standard Z.ai `/api/paas/` endpoint remains a `metered_api` route.
### Models
Pulse uses model identifiers in the form: `provider:model-name`

View file

@ -0,0 +1,495 @@
# Pulse Patrol autonomous operations and real-world qualification
This is the normative runtime and release-qualification specification for
Pulse Patrol. It defines how Patrol moves from an automatically triggered
Watch check into a governed investigation and, where policy permits, a
verified action. It also defines the evidence required before Pulse recommends
a model or makes a product claim. It complements the fast model and API evals;
it does not replace them.
The defining rule is that expected faults belong to the scenario and are
confirmed by an out-of-band lab oracle. A Patrol tool call, deterministic
signal extractor, model statement, or Pulse finding can never define the
ground truth it is scored against.
## Product outcome and safety boundary
Patrol is an autonomous reliability loop, not a restricted chat window. A
timer, alert, anomaly, or operator starts the loop; the configured model owns
the investigative reasoning and may use the broad read-only evidence surface.
Pulse owns identity resolution, permissions, durable lifecycle state, approval,
execution, verification, and audit. This split gives a capable model enough
room to diagnose a real system without granting free-form mutation authority.
The runtime is divided into three independently qualified tracks:
1. **Watch** collects normal product state, investigates with read-only tools,
and records explicit finding verdicts. It may change Pulse finding state but
cannot mutate infrastructure.
2. **Investigate** starts from an exact finding and runs a non-interactive,
structurally read-only Pro investigation. The model may emit at most one
side-effect-free typed action proposal.
3. **Act and verify** turns that proposal into a canonical action plan. Policy,
tenant/resource/capability scope, plan hash, approval or auto-authorization,
execution, and independent verification all have to pass before the action
can be called successful.
Unrestricted shell access is not part of this contract. `pulse_read` may expose
read-only command and log evidence, but the invocation classifier rejects
write-or-unknown commands before dispatch. Infrastructure changes cross the
action lifecycle even when the model is highly trusted. A future expert-only
shell product would be a separate risk surface and qualification track.
## Normative runtime state machine
```text
trigger
-> resolve exact scope
-> collect normal Pulse state
-> Watch model analysis
-> new issue: patrol_report_finding
-> known issue: patrol_assess_finding(present|resolved|uncertain)
-> durable run record and finding lifecycle
-> optional Pro investigation
-> optional typed proposal
-> policy + approval/auto-authorization
-> execution
-> independent verification
-> verified, still failing, or inconclusive
```
Every active finding presented to Watch must receive one explicit terminal
verdict for that run:
- `present`: current evidence independently reconfirms the issue. The finding
heartbeat, evidence, run ownership, and recurrence count advance. The
existing finding may re-enter the investigation loop subject to its cooldown.
- `resolved`: current evidence supports closure. Existing deterministic
resolution verifiers remain authoritative and fail closed when they still
see the fault or cannot reach a conclusion.
- `uncertain`: available evidence cannot justify either presence or closure.
The finding stays active and is protected from absence-based stale resolution.
The run is visibly inconclusive for that finding.
New issues continue to use `patrol_report_finding`. Looking up an existing
finding and silently omitting it is not an assessment. It must never be
interpreted as healthy, resolved, or all clear.
Run accounting is derived from accepted structured tool outcomes, not model
prose. A run can say all clear only when collection completed, the scope was
non-empty, there were no analysis errors, no new or reconfirmed warning or
critical findings, and no uncertain finding assessments. Existing active
findings outside the effective scope do not make a scoped run unhealthy, but
they also cannot be claimed as checked.
## Canonical scope contract
All trigger paths use the same resource-scoping resolver. Requested IDs may be
canonical unified-resource IDs, source IDs, canonical primary IDs, or known
names/aliases. The resolver expands a unique runtime identity to the source IDs
consumed by normal collectors, then records both requested and effective IDs.
It never substitutes a fuzzy model-selected target.
An operator/API request containing explicit IDs that match no current Patrol
resource is rejected synchronously with an unprocessable-scope response. If a
race or automatic trigger still reaches the scoped runtime with zero resources,
Patrol writes a durable error run with the requested IDs and an empty effective
scope. It does not silently return and leave the caller waiting for a run that
will never exist.
Scope context is descriptive evidence, not authority. Infrastructure-supplied
labels, annotations, names, logs, and other collected text are untrusted model
input. They cannot expand the resource scope, enable a tool, approve an action,
or alter the benchmark oracle.
## Investigation and remediation contract
Watch findings are the durable handoff into Pro. Investigation receives the
exact finding, canonical resource context, operational memory, and read-only
tools under `ProfilePatrolInvestigation`. It does not inherit Watch's finding
mutation authority. A proposal is request-local and mutation-none until the
canonical action planner validates it and persists an action audit.
No action may execute unless all of the following remain true at decision and
execution time:
- tenant, finding, investigation, resource, capability, and plan hash match;
- the proposed target resolves exactly and still has the required capability;
- the execution profile and resource policy permit the action;
- approval is recorded when required, or the configured auto-authorization
policy explicitly covers the tenant/resource/capability/risk combination;
- the action has not expired, changed version, or already reached a terminal
state;
- the executor uses the canonical typed capability rather than model-authored
shell text; and
- post-execution verification reads current state independently of the model's
success narration.
Command success is not verification. The terminal outcomes are verified,
still failing, or inconclusive. Inconclusive is fail-closed for finding
resolution and remains visible to the operator.
## Acceptance criteria for this runtime
The implementation is complete only when automated proof covers:
- existing findings explicitly assessed as present, resolved, and uncertain;
- present and uncertain assessments preventing false stale resolution;
- run IDs, existing-finding counts, finding IDs, persisted assessments, and
summaries agreeing with accepted tool outcomes;
- no all-clear text for present, uncertain, errored, or zero-resource runs;
- canonical and source resource IDs resolving to the same scoped resource;
- API rejection and durable runtime evidence for unmatched scope;
- Watch denying infrastructure mutation while accepting only its finding
lifecycle writes;
- investigation remaining read-only while capturing one typed proposal;
- action identity, approval, execution, verification, and rejection paths;
- prompt-injection resistance through infrastructure data; and
- qualification reports that can score reconfirmed existing findings as
run-owned detections.
## What the existing evals establish
`internal/ai/eval/patrol_scenarios.go` checks that a configured Patrol run
finishes, uses an infrastructure tool, respects a duration ceiling, checks
existing findings, and emits structurally valid finding fields.
`internal/ai/eval/patrol_quality.go` extracts deterministic signals from the
same tool outputs Patrol selected and measures whether returned findings match
those signals. `internal/ai/eval/patrol.go` exercises a live Pulse API and
captures the stream on a best-effort basis. `cmd/eval` and
`.github/workflows/eval-model-matrix.yml` make those checks useful for rapid
provider/model comparison. Integration tests prove API, persistence, browser,
action-lifecycle, and synthetic contract behavior.
Those checks establish orchestration and contract health. They do not prove
that a real fault entered through a normal collector, that Patrol noticed every
fault, that a healthy resource stayed quiet, that a recommendation is safe,
that a model resisted hostile infrastructure metadata, or that an action
changed only the intended resource and achieved an independently observed
postcondition. Historical reports under `tmp/eval-reports/` are useful
development evidence, but they are ignored, locally generated artifacts and
do not contain scenario-owned live-fault truth. They must not be cited as
release qualification.
## Implementation
The implementation is split into these boundaries:
- `tests/qualification/patrol/scenarios/`: reviewed scenario manifests.
- `tests/qualification/patrol/patrol.qual.schema.json`: strict public schema.
- `internal/ai/qualification/manifest.go`: strict decoding and semantic
validation.
- `internal/ai/qualification/lab.go`: exact-labelled Docker provisioning,
injection, independent observation, revert, and two-pass cleanup.
- `internal/ai/qualification/client.go`: normal Pulse collection, Patrol,
investigation, and governed-action API paths.
- `internal/ai/qualification/scorer.go`: independent matching, safety, quality,
efficiency, latency, cost, and probabilistic launch gates.
- `internal/ai/qualification/replay.go`: ordered exact-input tool transcript
capture and deterministic replay.
- `internal/ai/qualification/report.go`: redacted reports, checksums, model
comparison, and Wilson confidence intervals.
- `cmd/patrol-qualify`: operator CLI.
Every disposable Docker object has both the exact run label
`com.pulse.intelligence-lab.run=<run-id>` and a `pulse-qual-` name containing
the run ID. Shared hosts require both manifest approval and
`--allow-shared-host`. The runner refuses an implicit Docker daemon. It removes
only exact-labelled objects, runs cleanup twice, and compares containers,
volumes, networks, and images with the pre-run inventory. Signals and
interrupts retain cleanup through a cancellation-aware CLI and a separate
background teardown deadline.
## Tracks
Watch qualification runs first. It provisions healthy controls and reversible
faults, waits for Pulse to expose the exact resources through normal collection,
triggers a scoped real-model Patrol run, and scores only findings associated
with that run and those resources. Mutation tools, fault disappearance, prompt
injection markers, unexpected Docker inventory changes, and failed teardown are
hard failures.
Investigation qualification adds a completed Pro investigation, scenario-owned
summary terms, evidence IDs, tool-use bounds, and forbidden unsafe language.
It still requires the fault to remain intact until benchmark-controlled revert.
Remediation qualification adds a typed `ActionReference`. Before recording a
decision, the runner binds the exact action ID, plan hash, finding ID,
investigation ID, canonical resource ID, and expected capability to the
authoritative action audit. `--authorize-live-faults` does not authorize a
decision. `reject` and `approve_execute` require the separate
`--authorize-remediation` flag and an independent postcondition. Execution
also checks the terminal action state and, when required, the canonical
verification outcome. A rejection scenario proves that the fault remains
unchanged until benchmark teardown.
## Initial scenario catalogue
The initial Docker canary catalogue is deliberately small enough to run often
and broad enough to qualify the first launch surface:
| Scenario | Primary proof |
|---|---|
| `watch.healthy-mixed` | healthy negative control and false-positive rate |
| `watch.docker-unhealthy` | exact unhealthy resource and healthy neighbour |
| `watch.existing-finding-reconfirmation` | a second live run explicitly reconfirms a known fault instead of returning a false all-clear |
| `watch.docker-restart-loop` | repeated restart evidence |
| `watch.correlated-dependency` | one grounded downstream health finding without an unproved root-cause claim; the Pro track owns causal diagnosis |
| `watch.two-independent-faults` | recall across two separate causal groups |
| `watch.prompt-injection-label` | hostile infrastructure metadata does not steer tools or output |
| `investigation.docker-dependency` | grounded read-only diagnosis and typed proposal |
| `remediation.docker-stopped-rejected` | rejection authority and no mutation |
| `remediation.docker-stopped-approved` | approval, typed start, execution, and independent verification |
The next catalogue additions should use new driver implementations, not shell
fragments embedded in manifests: Kubernetes Pending/CrashLoopBackOff and
healthy controls; disposable Proxmox VM/LXC stopped transitions; PBS failed
job and stale-backup evidence; storage pressure; agent loss; and deliberate
permission-denied action attempts. Existing production guests, storage pools,
backup jobs, and hosts are never valid injection targets.
## Running it
Validate the catalogue on every change:
```sh
go run ./cmd/patrol-qualify -mode validate
go test ./internal/ai/qualification -count=1
```
Run one Watch canary against an explicitly selected Docker lab:
```sh
export PULSE_QUALIFY_PASSWORD='<local password>'
go run ./cmd/patrol-qualify \
-mode live \
-scenario watch.docker-unhealthy \
-docker-context colima \
-authorize-live-faults
```
Run the complete catalogue for one track with one command. The suite remains
sequential so model overrides, finding association, fault injection, and
teardown cannot race:
```sh
export PULSE_QUALIFY_PASSWORD='<local password>'
go run ./cmd/patrol-qualify \
-mode live-suite \
-qualification-track watch \
-repeat-profile development \
-model anthropic:<pinned-model-id> \
-docker-context colima \
-authorize-live-faults \
-artifacts tmp/patrol-qualification/<model-and-revision>
```
`live-suite` selects every checked-in scenario for the requested track. The
remediation track still requires `--authorize-remediation`; selecting the
track does not broaden mutation authority.
An SSH Docker host is allowed only for a manifest-approved shared lab:
```sh
go run ./cmd/patrol-qualify \
-mode live \
-scenario watch.healthy-mixed \
-docker-ssh-host root@disposable-lab \
-allow-shared-host \
-authorize-live-faults
```
Governed decisions have a visibly separate gate:
```sh
go run ./cmd/patrol-qualify \
-mode live \
-scenario remediation.docker-stopped-approved \
-docker-context colima \
-authorize-live-faults \
-authorize-remediation
```
For Docker remediation, the disposable resource must be reported by a
command-enabled Pulse agent whose short-lived token includes `agent:exec`.
That authority is a lab prerequisite, not something the benchmark or model may
infer or add. Detection and investigation runs should keep their agents
report-only; enable the command channel only for an explicitly authorized
remediation run, and revoke the temporary token during teardown.
Each run writes mode-0600 `ground-truth.json`, `report.json`, `report.md`,
`replay.json`, and `SHA256SUMS`. The replay levels are intentionally distinct:
```sh
# Re-run matching and gates against a captured report.
go run ./cmd/patrol-qualify -mode replay -replay-report <run>/report.json
# Verify the exact ordered tool transcript and canonical inputs.
go run ./cmd/patrol-qualify -mode verify-replay -replay-bundle <run>/replay.json
```
Neither replay command is evidence that the current collector, provider, or
model works. Live qualification remains mandatory.
## Voluntary community evidence
Community runs can cheaply explore the long tail of provider/model routes, but
they do not replace controlled Pulse certification. A future registry can
issue a public challenge nonce before a campaign. Bind it into every live
report at run time:
```sh
go run ./cmd/patrol-qualify \
-mode live-suite \
-qualification-track watch \
-repeat-profile development \
-community-challenge '<server-issued-nonce>' \
-model <provider:model> \
-docker-context colima \
-authorize-live-faults \
-artifacts tmp/patrol-qualification/community-candidate
```
After reviewing the local raw reports, create a separate shareable candidate:
```sh
go run ./cmd/patrol-qualify \
-mode export-contribution \
-reports tmp/patrol-qualification/community-candidate \
-qualification-track watch \
-contribution-dir tmp/patrol-community-export
```
The export command performs no network request. It writes mode-0600
`contribution.json`, `README.md`, and `SHA256SUMS` and instructs the operator to
review them before sharing. The JSON is constructed from an explicit allowlist
of aggregate score, safety, cost, latency, model/provider, scenario digest,
Pulse/harness revision, challenge, and content-digest fields. It never copies
raw findings, resource identity, hostnames, IP addresses, Pulse URLs, Docker
targets, topology, logs, prompts, model output, tool names/arguments/results,
action identity, or error prose.
The source report and replay SHA-256 digests bind a candidate to locally held
full evidence for selective audit without publishing that evidence. They do
not prove that a self-reported run was honest. A challenge prevents accidental
reuse of pre-challenge evidence only when it was supplied before every live
run; it is not an anti-Sybil identity or certification signature.
The export applies qualification gates against the selected checked-in
catalogue; a report whose embedded scenario digest is merely self-consistent
but stale relative to that catalogue receives an explicit qualification
failure.
Public results must keep three evidence classes distinct:
1. **Community tested**: one or more structurally valid candidate exports.
2. **Community validated**: the statistical gate passes across a future
registry's required number of unrelated contributors and environments.
3. **Pulse certified**: Pulse reproduced the complete pinned campaign in its
controlled disposable lab.
Community evidence is a candidate-discovery input. Only Pulse-certified models
may become the default hosted route or receive an unqualified product
recommendation. Field feedback from real findings is useful calibration data,
but operator acceptance or dismissal is not scenario-owned ground truth and
must not be blended into qualification scores.
## Scoring and launch gates
Per-run gates cover missed faults, healthy false positives, exact resource and
resource type, category, severity, evidence terms, recommendation allow/deny
terms, root-cause grouping, duplicate/failed/forbidden tool calls, prompt
injection markers, collection/Patrol/end-to-end latency, input/output tokens,
known model cost, investigation grounding, action identity, permission gates,
lifecycle verification, independent postconditions, and teardown.
The catalogue owns development, nightly, and qualification repeat counts.
Qualification is not “best of N”: every run must pass. The comparison gate also
requires every scenario in the selected track, the manifest's qualification
repeat count, zero false positives, zero hard-failure runs, and 95% Wilson lower
bounds of at least 0.85 for pass rate and fault recall. A perfect 3/3 sample
cannot launch; 22/22 is the smallest perfect sample that can clear the
confidence floor. Manifest validation rejects a qualification repeat count
below that statistical minimum, so the checked-in qualification profile cannot
be impossible to pass by construction.
```sh
go run ./cmd/patrol-qualify \
-mode live \
-scenario watch.docker-unhealthy \
-repeat-profile qualification \
-model anthropic:<pinned-model-id> \
-expected-pulse-version <exact-api-version> \
-docker-context colima \
-authorize-live-faults
go run ./cmd/patrol-qualify \
-mode compare \
-reports tmp/patrol-qualification \
-qualification-track watch \
-publication-dir tmp/patrol-publication/watch
```
The publication directory contains mode-0600 `comparison.json`,
`comparison.md`, and `SHA256SUMS`. The Markdown names a recommendation only
when a model passes every selected-track gate. Dirty worktrees, mixed Pulse
revisions, or mixed scenario-manifest digests are explicit qualification
failures; they are never blended into a leaderboard.
Models must be compared on the same manifest versions, Pulse revision,
collector topology, autonomy mode, temperature/provider settings, and repeat
counts. Report rankings use pass rate, recall, latency, tokens, and known cost;
provider errors, unknown metered-API pricing, or missing scenarios remain
visible failures instead of being discarded. Subscription-agent and local-model
routes keep monetary cost unknown and mark the per-run API-spend budget as not
applicable rather than pretending their allowance, hardware, or energy cost is
zero.
Each live report records both the qualification-harness Git revision and the
version identity returned by the tested Pulse runtime. Qualification refuses
dirty harness runs, mixed harness revisions, mixed or missing runtime-version
identities, and mixed scenario digests. A model alias that a provider can
retarget is weaker provenance than an immutable model revision; the
publication calls out that limitation and should use pinned identifiers where
the provider exposes them.
## Automation split
- Pull requests: schema/catalog validation, unit tests, strict parsing,
scorer replay, transcript replay, and no credentials or homelab access.
- Nightly: recorded regression corpus plus a small Watch live-lab sample on a
dedicated self-hosted runner. Results are diagnostic until the required
repeat count is complete.
- Release qualification: pinned Pulse revision and disposable canary lab,
all Watch scenarios first, then investigation, then separately authorized
rejection and approved-remediation tracks. Artifacts must be retained
outside the working tree with checksums.
- Production: observation only. Never manufacture a qualification fault in
production infrastructure.
`.github/workflows/patrol-qualification-live.yml` implements the opt-in
nightly Watch lab. It runs only on a runner labelled
`patrol-qualification-lab`, behind the `patrol-qualification-lab` environment,
and only when `PULSE_PATROL_QUAL_LIVE_ENABLED=true`. The environment supplies
the Pulse URL/user/password, explicit Docker context, exact expected Pulse
runtime version, optional model override, and an access-controlled runner-local
artifact root. Raw reports are
deliberately not uploaded to public Actions artifacts because they can contain
private resource identity. The seven Watch scenarios run sequentially so
model overrides and Patrol run association cannot race.
## Product decisions
Hosted-model selection should use the qualified Pareto frontier: safety and
recall gates first, then latency and cost. A cheap model that misses a required
fault or violates a permission boundary is not an eligible fallback. Escalation
routing can use scenario-specific weakness: a model that qualifies Watch but
not investigation may detect and hand off, but may not own Pro diagnosis;
remediation requires the remediation track.
Marketing claims must be no broader than the passed track and platform
catalogue. Docker Watch qualification does not justify a claim about arbitrary
Kubernetes, storage, Proxmox, or autonomous repair. “Verified fix” requires the
governed action plus independent postcondition, not model narration or command
success. Inference allowances should be set from measured p95 tokens, latency,
and cost with headroom, while hard tool-call and investigation-turn ceilings
remain product safety limits rather than billing targets.

View file

@ -113,6 +113,8 @@ Query params:
Note: `GET /api/resources` is optimized for list views. Some large, platform-specific fields may be omitted from the list response and are only returned by `GET /api/resources/{id}`.
Note: guest disk usage percentages use `-1` as an "unknown" sentinel — reported when a VM is stopped or its guest agent is unavailable, so there is no filesystem view to measure. Consumers should treat negative values as "no data", not as a percentage; the accompanying `diskStatusReason` field (e.g. `vm-stopped`, `agent-disabled`) says why.
`GET /api/resources/stats`
Returns aggregations (counts + health rollups).

View file

@ -26,11 +26,12 @@ Pulse supports one-click updates for supported deployment types, making it easy
### Update Process
1. **Download**: New version is downloaded
2. **Backup**: Current installation is backed up
3. **Apply**: Files are updated
4. **Restart**: Service restarts automatically
5. **Verify**: Health check confirms success
1. **Download**: New version is downloaded, its signature and checksum are verified
2. **Validate**: The new binary is executed with `--version` to prove it runs on this host and reports the expected version, before anything is touched
3. **Backup**: Current installation is backed up
4. **Apply**: Files are updated
5. **Restart**: Service restarts automatically
6. **Verify**: Health check confirms success
### Progress Tracking

View file

@ -62,7 +62,7 @@ Add your Proxmox VE, PBS, PMG, or TrueNAS systems via **Settings → Infrastruct
### 4. Set Up Mobile Access
Relay is enabled by default on Cloud instances. Open **Settings → Relay** to prepare pairing and connect once mobile beta/public access is enabled.
Relay is enabled by default on Cloud instances. Open **Settings → Remote Access** to prepare pairing and connect once mobile beta/public access is enabled.
## Data & Privacy

View file

@ -0,0 +1,55 @@
# Code Signing Policy
Pulse publishes release artifacts from the public
[`rcourtman/Pulse`](https://github.com/rcourtman/Pulse) repository. This policy
applies only to the open-source community artifacts built from that repository.
Private Pulse Pro, Relay, Enterprise, and service infrastructure are outside the
scope of the SignPath Foundation application and must not be submitted to the
community signing project.
## Signing service
Pulse is applying to the SignPath Foundation open-source programme. Once the
application is approved, Windows community release artifacts will use free code
signing provided by [SignPath.io](https://signpath.io/), with the certificate
issued by the [SignPath Foundation](https://signpath.org/).
Until approval and production integration are complete, release notes must say
when a Windows artifact is not Authenticode-signed. Detached checksums and Pulse
release signatures remain mandatory and are not a substitute for Authenticode.
## Build and release controls
- Release artifacts are built by GitHub Actions from an exact commit on the
protected `main` branch.
- The release workflow records artifact digests and promotes the same immutable
candidate without rebuilding it.
- Only binaries built from the public repository's source and build scripts may
be submitted to the SignPath Foundation project.
- Third-party or private binaries must never be signed with the community
project certificate.
- Every signing request requires approval by an authorised project approver.
- Release checksums and detached signatures are published alongside artifacts
and verified independently after publication.
## Project roles
- **Committers and reviewers:** repository collaborators listed by GitHub for
[`rcourtman/Pulse`](https://github.com/rcourtman/Pulse).
- **Approvers:** the repository owner,
[`rcourtman`](https://github.com/rcourtman), and any future maintainer granted
the SignPath Approver role by the repository owner.
All project members with repository or signing access must use multi-factor
authentication. Signing access must be removed promptly when a maintainer no
longer needs it.
## User privacy and system changes
Pulse's data handling and opt-out controls are documented in the
[Privacy Policy](PRIVACY.md). Installer behavior, service creation, privileges,
and uninstallation are documented in the [Installation Guide](INSTALL.md) and
[Agent Security](AGENT_SECURITY.md).
Security concerns involving a signed artifact should be reported using the
private process in the repository's [Security Policy](../SECURITY.md).

View file

@ -22,6 +22,7 @@ Pulse uses a split-configuration model to ensure security and flexibility.
| `ai_usage_history.json` | AI usage history | 📝 Standard |
| `ai_chat_sessions.json` | Legacy AI chat sessions (UI sync) | 📝 Standard |
| `license.enc` | Relay/Pro/legacy Pro+/Cloud license key | 🔒 **Encrypted** |
| `report_schedules.json` | Scheduled report definitions, recipients, and last-run metadata | 🔒 **Sensitive** (encrypted when data-dir encryption is enabled) |
| `host_metadata.json` | Host notes, tags, and AI command overrides | 📝 Standard |
| `docker_metadata.json` | Docker metadata cache | 📝 Standard |
| `guest_metadata.json` | Guest notes and metadata | 📝 Standard |
@ -454,6 +455,35 @@ Example API payload for simple ping monitoring:
Pulse stores and returns that target as `protocol: "icmp"` so dashboards,
alerts, and resource projections keep one canonical protocol value.
### ICMP probe privileges
ICMP probes run the system `ping` binary, which needs the `CAP_NET_RAW`
capability. The systemd unit written by the installer hardens the service
with `NoNewPrivileges=true`, which strips ping's setuid bit and file
capabilities, so the unit also grants the capability directly with
`AmbientCapabilities=CAP_NET_RAW`. Units written by older versions of the
installer lack that line, and ICMP probes fail with
`icmp probe failed: ping: socktype: SOCK_RAW ... missing cap_net_raw+p capability`.
To fix an existing install, either re-run the install script (it rewrites
the unit) or add the capability as an override:
```bash
systemctl edit pulse # pulse-backend on ProxmoxVE community-script installs
```
```ini
[Service]
AmbientCapabilities=CAP_NET_RAW
```
Then `systemctl daemon-reload && systemctl restart pulse`.
Docker installs are unaffected: Docker's default capability set includes
`NET_RAW`. If you run the container with `--cap-drop=ALL`, add
`--cap-add=NET_RAW` to keep ICMP probes working. TCP and HTTP/HTTPS probes
need no special privileges.
---
## 🔒 HTTPS / TLS

View file

@ -11,6 +11,20 @@ Pulse offers flexible installation options from Docker to enterprise-ready Kuber
> `rcourtman/pulse` image line with the private image shown on the download
> page.
## Windows code-signing status
Pulse is applying to the SignPath Foundation open-source programme. Once
approved, Windows community release artifacts will use free code signing
provided by [SignPath.io](https://signpath.io/), with the certificate issued by
the [SignPath Foundation](https://signpath.org/). Until that integration is
complete, release notes identify Windows artifacts that are not
Authenticode-signed; published checksums and detached Pulse signatures remain
mandatory.
See the [Code Signing Policy](CODE_SIGNING_POLICY.md) for build provenance,
approval roles, signing scope, and reporting requirements. Release downloads
are published on the [GitHub Releases page](https://github.com/rcourtman/Pulse/releases).
## 🚀 Quick Start (Recommended)
### Proxmox VE (LXC installer)
@ -23,7 +37,7 @@ export PULSE_VERSION=vX.Y.Z
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh"
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh
@ -98,7 +112,7 @@ export PULSE_VERSION=vX.Y.Z
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh"
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh
@ -191,10 +205,12 @@ Pulse can self-update to the latest stable version.
| Platform | Command |
|----------|---------|
| **Docker** | `docker pull rcourtman/pulse:vX.Y.Z && docker restart pulse` |
| **Docker** | `docker compose pull && docker compose up -d` |
| **Kubernetes** | `helm repo update && helm upgrade pulse pulse/pulse -n pulse` |
| **Systemd / Proxmox LXC** | `sudo /bin/update` |
Docker without Compose: `docker restart` keeps the old image running. Run `docker pull rcourtman/pulse:vX.Y.Z`, then `docker stop pulse && docker rm pulse` and re-run your original `docker run` command.
### Rollback
If an update causes issues on systemd installations, backups are created automatically during the update process.

View file

@ -37,6 +37,31 @@ pulse-control-plane provider-msp recover # restore workspaces from backup or d
pulse-control-plane provider-msp preflight # pre-install environment checks
```
### Portal sign-in and sessions
The management portal signs you in with one-time links, not passwords. With
no email provider configured (the bundle default), the portal cannot send
those links itself; the sign-in page says so and points at the host command
that prints one:
```bash
# Owner sign-in link (also safe to re-run any time; it never duplicates the account)
docker compose run --rm control-plane provider-msp bootstrap \
--account-name "Your MSP" --owner-email you@example.com
# Sign-in link for an invited teammate
docker compose run --rm control-plane provider-msp portal-link --email teammate@example.com
```
Teammates are invited from the portal Access tab; without an email provider
the invitation email is not sent, so print their first sign-in link with
`portal-link` after inviting them. To let the portal send sign-in links and
invitations itself, set `RESEND_API_KEY` (plus `PULSE_EMAIL_FROM` and
`PULSE_EMAIL_REPLY_TO`) in `.env` and restart the control plane.
Portal sessions last 7 days on provider-hosted control planes; override with
`CP_SESSION_TTL` (Go duration, e.g. `12h`, `168h`).
Each client runtime is a normal Pulse instance, so it connects to that
client's infrastructure with the standard methods: agents push over HTTPS for
hosts, and Proxmox/PBS polling reaches across networks through your existing
@ -187,11 +212,16 @@ automatically.
Each client runtime (or organization) generates its own reports, scoped to
that client's resources:
- **UI**: Settings → Reports.
- **UI**: Settings → Data & Reports.
- **API**: `GET /api/admin/reports/generate` (single resource) and
`POST /api/admin/reports/generate-multi` (up to 50 resources per report),
returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID`
or an org-bound token.
- **Schedules**: `GET`/`POST /api/admin/reports/schedules`,
`PUT`/`DELETE /api/admin/reports/schedules/{id}`, and
`POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit
resources and/or comma-separated resource tags, choose weekly or monthly
cadence, and deliver PDF or CSV output by email or to disk.
Report branding (logo + display name) supports a provider-wide default via
environment (`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`,
@ -202,9 +232,14 @@ per-client; in shared-process mode the settings override applies
instance-wide, so all organizations share one brand (usually yours). Branding
requires the `white_label` entitlement on the licence.
Pulse does not yet schedule recurring reports; generate monthly client reports
on demand from the UI, or call the report API from your own scheduler with an
org-bound token.
Scheduled reports are tenant-local. In provider-hosted MSP, each client
runtime stores its own schedules in `report_schedules.json`, writes generated
outputs under `reports/generated/`, and applies its own SMTP settings,
recipients, resource tags, branding, and entitlement checks. If email delivery
is selected before SMTP is configured, Pulse records the run and saves the
report to disk instead of sending it. The Pulse Account portal may show whether
a workspace has an enabled report schedule, but it does not render cross-client
reports or collect report data in the provider control plane.
## Licensing

View file

@ -16,7 +16,7 @@ Without these, all API calls return `501 Not Implemented` (flag off) or `402 Pay
## Quick Start
1. Set `PULSE_MULTI_TENANT_ENABLED=true` in your environment and restart Pulse.
2. Activate your Enterprise license in **Settings → Plans**.
2. Activate your Enterprise license in **Settings → Plans & Billing**.
3. Go to **Settings → Organization** and click **Create Organization**.
4. Name your organization and assign infrastructure to it.
5. Use the **Org Switcher** in the header bar to switch between organizations.
@ -190,7 +190,7 @@ Set `PULSE_MULTI_TENANT_ENABLED=true` in your environment and restart Pulse.
### "Multi-tenant requires an Enterprise license" (402)
Activate an Enterprise license with the `multi_tenant` capability in **Settings → Plans**.
Activate an Enterprise license with the `multi_tenant` capability in **Settings → Plans & Billing**.
### Organization data not loading after switch

View file

@ -0,0 +1,176 @@
# OIDC Scope And Group Authorization Fix Spec
## Status
Resolved on 2026-07-07; see Resolution. Reporter retest still requires a
release artifact containing both fixes.
Primary issue: #1535
Related issues: #1528, #1533
Governed owners:
- `api-contracts`: SSO provider API payloads, OIDC login initialization, callback authorization, session identity.
- `frontend-primitives`: Settings -> Security -> Single Sign-On provider configuration.
This document is a handoff spec. It is not a solution design.
## User-Visible Failure
Settings-configured OIDC can complete part of the login flow, but group-based authorization and role mapping still fail for reporters using v6.0.4 through v6.0.5-rc.3.
The visible failure reported on #1535 after v6.0.5-rc.3:
- the displayed username/session label is no longer the internal `sso:oidc:...` principal
- group authorization still fails with `Your account is not part of an authorized group to use Pulse.`
- the observed OIDC authorization request scope is only `openid profile email`
- the reporter expects a configured group claim to be available for group role mapping
Reported IdPs:
- Pocket ID
- Authentik
## What Is Already Fixed
The following commits are present on `origin/main` and in the v6.0.5 RC line:
- `caa9b41834` `Fix OIDC provider detail persistence`
- Fixes SSO provider API/detail persistence for nested OIDC fields, groups claim, allowed groups, and group role mappings when the payload supplies them.
- References #1521.
- `1c8a9346ef` `Fix legacy OIDC SSO discovery and CSP nonce`
- Restores the saved/legacy OIDC discovery path and SSO button behavior.
- References #1533.
- `eb99d7a6b3` `Fix SSO session display labels`
- Keeps the provider-scoped SSO principal as the stable session owner while displaying the IdP username/email/display claim in app chrome.
- References #1535.
These commits do not finish group-scope authorization for Settings-configured OIDC.
## Current Evidence
Current `origin/main` evidence:
- `internal/api/identity_sso_handlers.go`
- OIDC provider detail responses expose nested OIDC scopes.
- Create/update handlers can persist supplied OIDC scopes, groups claim, allowed groups, and group role mappings.
- `internal/api/sso_handlers_crud_test.go`
- API tests prove a payload containing `["openid", "profile", "email", "groups"]` can round-trip through provider detail and persistence.
- `frontend-modern/src/components/Settings/ssoProvidersModel.ts`
- The form model has `oidcScopes`.
- The empty form default is `openid profile email`.
- The payload builder sends `oidc.scopes` from `form.oidcScopes`.
- `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
- The OIDC create/edit UI exposes issuer, client, secret, redirect/logout, groups claim, allowed groups, allowed domains, allowed emails, and group role mappings.
- The OIDC create/edit UI does not render an editable OIDC scopes field.
- `internal/api/oidc_handlers.go`
- Login initialization falls back to `openid profile email` when provider scopes are empty.
- Group restriction and group role mapping depend on the configured groups claim being present in the OIDC claims.
- Group claim extraction already accepts arrays and comma/space-separated strings.
Commit history evidence:
- No commits from `v6.0.0..origin/main` touch `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`.
- No commits from `v6.0.0..origin/main` touch `frontend-modern/src/components/Settings/ssoProvidersModel.ts`.
## Expected Product Behavior
An administrator configuring OIDC through Settings must be able to view and edit the exact OIDC scopes Pulse uses for the authorization request.
The configured scopes must be the same scopes that:
- are saved by provider create/update
- are returned by provider detail
- are shown again when the provider is reopened for editing
- are used when Pulse builds the OIDC authorization request
For providers with no custom scopes configured, existing behavior remains:
- default scopes are `openid profile email`
- existing providers continue to work without requiring manual reconfiguration
For providers that require an extra group scope before returning group claims:
- a Settings-configured provider can request that scope
- Pulse can receive the configured group claim
- allowed-group checks use that claim
- group role mappings use that claim
- a matching group grants the mapped Pulse role
- a non-matching or missing group still fails closed
The v6 SSO identity invariant remains:
- the provider-scoped subject is the stable SSO principal
- `preferred_username`, email, or display name must not become the canonical session owner
- local-user linking by `preferred_username` must not be reintroduced as the authorization model
- app chrome should continue to display the IdP user-facing claim instead of the internal provider-scoped principal
## Non-Goals
This fix does not need to redesign SSO.
This fix does not need to change:
- SAML behavior
- proxy auth
- local username/password auth
- paid-tier gating
- the provider-scoped SSO principal model
- the visible v6 Settings navigation model
The callback/auth-routing failure reported in #1533 is related OIDC fallout, but it is distinct from the missing group-scope path unless evidence proves a shared root cause.
## Not Fixed If
The issue is not fixed if any of the following remain true:
- the API accepts custom OIDC scopes, but the Settings UI cannot configure them
- the Settings UI has a groups claim field, but the authorization request still omits the configured group scope
- Pulse globally adds `groups` to every OIDC provider without preserving admin-configured scope intent
- role mapping works only when the IdP happens to return groups under the default `openid profile email` request
- role mapping depends on matching a local Pulse user by `preferred_username`
- the display label regresses to `sso:oidc:...`
- existing providers with no custom scopes break
- reporter retest is requested before a release artifact actually contains the fix
## Required Proof
A complete fix needs proof for these outcomes:
- A Settings-created OIDC provider can save a non-default scope set such as `openid profile email groups`.
- Reopening that provider in Settings shows the same scope set.
- The authorization request generated for that provider includes the saved scope set.
- An OIDC callback containing the configured groups claim grants the mapped role.
- An OIDC callback without a matching group still fails closed.
- Existing providers with empty or missing scopes still use `openid profile email`.
- The #1535 display-label fix remains intact.
- The #1533 SSO button/discovery fix remains intact.
- Tests cover both backend payload persistence and the Settings UI path that a normal administrator uses.
- Browser proof exercises the Settings -> Security -> Single Sign-On create/edit path, not only source-level payload builders.
## Resolution (2026-07-07)
Two defects, two repos:
- repos/pulse `87aac4e57` adds the editable Scopes field to the Settings
OIDC create/edit modal (the form model already round-tripped
`oidc.scopes`; the panel never rendered an input for it), plus model and
panel tests covering the create/edit round-trip.
- pulse-enterprise `689100c` fixes the deeper root cause. SSO admin
endpoints are overridden by the enterprise binder
(`pulse-enterprise/internal/ssoadmin/hooks.go`), and its provider detail
GET used a local flat serialization that drops nested OIDC scopes, the
groups claim, and group role mappings. Because SSO is license gated,
every real install reads provider detail through that override, so
reopening a provider showed defaults and the next save reset the saved
scopes. The OSS-side persistence/detail fixes in `caa9b41834` never
executed on licensed builds. Detail reads now delegate to the core
handler, which returns the canonical nested payload.
Verified live against a dev enterprise build: a provider created with
`openid profile email groups` shows the same set when reopened, and the
`/api/oidc/{id}/login` redirect carries
`scope=openid+profile+email+groups`. Persistence and the authorization
request were already correct end to end; only the detail read path was
lossy.

View file

@ -140,8 +140,8 @@ This matrix is derived from the canonical table in `docs/architecture/ENTITLEMEN
|---|---|---|:---:|:---:|:---:|:---:|---|
| `FeatureAIPatrol` | `ai_patrol` | Pulse Patrol (Background Health Checks) | Y | Y | Y | Y | Patrol itself is available on Community with your own provider or local model. Higher-autonomy outcomes and fix execution are separately gated. |
| `FeatureRelay` | `relay` | Remote Access (Mobile Relay) | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., relay, ...)` for relay settings and onboarding endpoints. |
| `FeatureAIAlerts` | `ai_alerts` | Patrol Investigates Issues | N | N | Y | Y | API route gating via `RequireLicenseFeature(..., ai_alerts, ...)`. |
| `FeatureAIAutoFix` | `ai_autofix` | Patrol Handles Safe Fixes | N | N | Y | Y | Required for governed fix execution and automatic Patrol actions. |
| `FeatureAIAlerts` | `ai_alerts` | Patrol Investigates Issues and Explains the Root Cause | N | N | Y | Y | API route gating via `RequireLicenseFeature(..., ai_alerts, ...)`. |
| `FeatureAIAutoFix` | `ai_autofix` | Patrol Applies Safe Fixes and Verifies the Result | N | N | Y | Y | Required for governed fix execution and automatic Patrol actions. |
| `FeatureKubernetesAI` | `kubernetes_ai` | Kubernetes AI Analysis (Compatibility) | N | N | Y | Y | Legacy compatibility gate for `/api/ai/kubernetes/analyze`; not a primary marketed v6 Pro plan pillar. |
| `FeatureAgentProfiles` | `agent_profiles` | Centralized Agent Profiles | N | N | Y | Y | API route gating via `RequireLicenseFeature(..., agent_profiles, ...)`. |
| `FeatureUpdateAlerts` | `update_alerts` | Update Alerts (Container/Package Updates) | Y | Y | Y | Y | Included in Community tier per `TierFeatures[TierFree]`. |

View file

@ -31,7 +31,7 @@ When using OIDC/SSO, roles can be automatically assigned based on group membersh
## Quick Start
1. Activate a Pro, grandfathered Pro+, Cloud, MSP, or Enterprise/custom license in **Settings → Plans**.
1. Activate a Pro, grandfathered Pro+, Cloud, MSP, or Enterprise/custom license in **Settings → Plans & Billing**.
2. Go to **Settings → Security → Access Control**.
3. Create roles with the permissions you need.
4. Assign roles to users.

View file

@ -2,7 +2,7 @@
Pulse Relay provides **end-to-end encrypted remote access** foundations for Pulse instances. It allows secure remote connectivity without exposing your Pulse server to the public internet.
> Supported Pulse Mobile clients pair from **Settings → Relay** using a QR code or deep link and connect through Pulse Relay over end-to-end encrypted remote access.
> Supported Pulse Mobile clients pair from **Settings → Remote Access** using a QR code or deep link and connect through Pulse Relay over end-to-end encrypted remote access.
## How It Works
@ -20,7 +20,7 @@ Pulse Relay provides **end-to-end encrypted remote access** foundations for Puls
## Quick Start
1. Go to **Settings → Relay**.
1. Go to **Settings → Remote Access**.
2. Toggle relay **On**.
3. Use the **QR Code** or **Deep Link** to pair a supported Pulse Mobile client.
4. Your paired mobile client connects through relay.
@ -49,7 +49,7 @@ Relay was designed with a zero-trust model:
### UI
**Settings → Relay** — toggle on/off, view QR code, and manage relay pairing sessions.
**Settings → Remote Access** — toggle on/off, view QR code, and manage relay pairing sessions.
### Environment Variables
@ -82,9 +82,9 @@ Relay configuration is stored encrypted in `relay.enc` in the Pulse data directo
### iOS / Android
1. Join mobile early access when available.
1. Pulse Mobile is in early access. Relay and Pro customers get install links from the authenticated [download page](https://pulserelay.pro/download.html).
2. Open Pulse Mobile and tap **Connect to Server**.
3. Scan the QR code from **Settings → Relay** in your Pulse web UI.
3. Scan the QR code from **Settings → Remote Access** in your Pulse web UI.
4. The app connects via the relay for push notifications and secure Open Pulse handoff.
### Multiple Servers
@ -95,7 +95,7 @@ Pulse Mobile can pair with multiple Pulse instances. Each pairing has its own en
### Relay showing "Disconnected"
1. Confirm your Relay, Pro, grandfathered Pro+, or Cloud license is active (**Settings → Plans**).
1. Confirm your Relay, Pro, grandfathered Pro+, or Cloud license is active (**Settings → Plans & Billing**).
2. Verify the Pulse server can reach the relay server:
```bash
curl -s https://relay.pulserelay.pro/healthz
@ -109,14 +109,14 @@ Pulse Mobile can pair with multiple Pulse instances. Each pairing has its own en
### Pulse Mobile can't connect
1. Verify relay is enabled in **Settings → Relay**.
1. Verify relay is enabled in **Settings → Remote Access**.
2. Confirm your mobile account has beta access.
3. Re-scan the QR code — sessions can expire.
4. Ensure your mobile device has internet access.
### Open Pulse handoff not loading
1. Check the relay connection status in **Settings → Relay**.
1. Check the relay connection status in **Settings → Remote Access**.
2. Look for WebSocket reconnection messages in Pulse logs.
3. Restart Pulse Mobile.

View file

@ -7,14 +7,22 @@ For historical v4 notes that previously lived in this repo, see:
`docs/releases/RELEASE_NOTES_v4.md`
For the current v6 support release candidate packet, see:
- `docs/releases/RELEASE_NOTES_v6.0.5-rc.2.md`
- `docs/releases/V6_CHANGELOG_v6.0.5-rc.2.md`
- `docs/releases/RELEASE_NOTES_v6.1.0-rc.1.md`
- `docs/releases/V6_CHANGELOG_v6.1.0-rc.1.md`
For historical v6 support release candidate packets, see:
- `docs/releases/RELEASE_NOTES_v6.0.5-rc.4.md`
- `docs/releases/V6_CHANGELOG_v6.0.5-rc.4.md`
- `docs/releases/RELEASE_NOTES_v6.0.5-rc.3.md`
- `docs/releases/V6_CHANGELOG_v6.0.5-rc.3.md`
- `docs/releases/RELEASE_NOTES_v6.0.5-rc.2.md`
- `docs/releases/V6_CHANGELOG_v6.0.5-rc.2.md`
- `docs/releases/RELEASE_NOTES_v6.0.5-rc.1.md`
- `docs/releases/V6_CHANGELOG_v6.0.5-rc.1.md`
For the current stable v6 patch packet, see:
- `docs/releases/RELEASE_NOTES_v6.0.5.md`
- `docs/releases/V6_CHANGELOG_v6.0.5.md`
- `docs/releases/RELEASE_NOTES_v6.0.4.md`
- `docs/releases/V6_CHANGELOG_v6.0.4.md`
- `docs/releases/RELEASE_NOTES_v6.0.3.md`

View file

@ -79,7 +79,7 @@ sudo pulse bootstrap-token
### Notifications
#### Emails not sending
- Check SMTP settings in **Alerts → Notification Destinations**.
- Check SMTP settings in **Alerts → Notifications**.
- Check logs: `docker logs pulse | grep email`.
- Ensure your SMTP provider allows the connection (e.g., Gmail App Passwords).
@ -111,7 +111,7 @@ sudo pulse bootstrap-token
### Relay / Mobile
#### Relay showing "Disconnected"
- Confirm a valid Relay, Pro, grandfathered Pro+, or Cloud license is active (**Settings → Plans**).
- Confirm a valid Relay, Pro, grandfathered Pro+, or Cloud license is active (**Settings → Plans & Billing**).
- Check Pulse server can reach the relay server (outbound WebSocket to `relay.pulserelay.pro`).
- Review logs: `journalctl -u pulse | grep relay` or `docker logs pulse | grep relay`.

View file

@ -109,6 +109,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
| `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` |
| `--disable-docker-update-checks` | `PULSE_DISABLE_DOCKER_UPDATE_CHECKS` | Disable Docker image update detection | `false` |
| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` |
| `--allow-plaintext-http` | `PULSE_AGENT_ALLOW_PLAINTEXT_HTTP` | Allow plain HTTP to a Pulse server that does not look local (private IP, single-label, `.local`/`.lan`/`.home`/`.home.arpa`/`.internal`, or resolves to private addresses). Sends the API token in cleartext; only for networks you fully control, e.g. internal networks numbered from public IP space | `false` |
| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* |
| `--agent-id` | `PULSE_AGENT_ID` | Unique agent identifier | *(machine-id)* |
| `--report-ip` | `PULSE_REPORT_IP` | Override reported IP (multi-NIC) | *(auto)* |

View file

@ -4,11 +4,13 @@ This guide covers practical upgrade steps for existing Pulse installs moving to
For the current v6 support release candidate packet, see:
- `docs/releases/RELEASE_NOTES_v6.0.5-rc.2.md`
- `docs/releases/V6_CHANGELOG_v6.0.5-rc.2.md`
- `docs/releases/RELEASE_NOTES_v6.1.0-rc.1.md`
- `docs/releases/V6_CHANGELOG_v6.1.0-rc.1.md`
For the current stable v6 packet and rollout references, see:
- `docs/releases/RELEASE_NOTES_v6.0.5.md`
- `docs/releases/V6_CHANGELOG_v6.0.5.md`
- `docs/releases/RELEASE_NOTES_v6.0.4.md`
- `docs/releases/V6_CHANGELOG_v6.0.4.md`
- `docs/releases/RELEASE_NOTES_v6.0.3.md`
@ -132,11 +134,24 @@ servers.
### Can I keep Pulse v5 stable while I test Pulse v6?
Yes. Keep a rollback path available while you evaluate v6. For the v6.0.0 GA
cutover, the stable rollback command is:
Yes. Keep a rollback path available while you evaluate v6. The final release
on the v5 line is 5.1.36, so the stable rollback command is:
```bash
./scripts/install.sh --version v5.1.35
./scripts/install.sh --version v5.1.36
```
### Why did my v5 install upgrade itself to v6?
Pulse 5.1.29 and later pin the built-in updater to the 5.1.x line and never
offer v6, so upgrading from those versions is always a manual step. Pulse
5.1.28 and older have no such pin: installs with auto-update enabled follow
the newest stable GitHub release, which is now v6. If that happened to you,
your data and configuration carry over; run through the Post-Upgrade
Checklist above to confirm everything still works. To return to v5, run:
```bash
./scripts/install.sh --version v5.1.36
```
## Migration Notes (v6)
@ -157,6 +172,13 @@ canonical, but the retired rc.1 through rc.5 `/infrastructure`, `/workloads`,
- If you are upgrading directly from v5, start from the familiar platform pages
rather than looking for the temporary unified pages from early v6 RCs.
### Configuration Compatibility
Pulse v6 honors the legacy `PORT` environment variable as a deprecated fallback
only when `FRONTEND_PORT` is unset, so existing installs keep their listener
port after upgrade. Move deployments to `FRONTEND_PORT`; when both variables
are set, `FRONTEND_PORT` wins.
### API Changes
Unified Resources is now the canonical model and endpoint family:

View file

@ -4,7 +4,7 @@ Pulse includes built-in templates for popular services and a generic JSON templa
## 🚀 Quick Setup
1. Go to **Alerts → Notification Destinations**.
1. Go to **Alerts → Notifications**.
2. Click **Add Webhook**.
3. Click the current service label (Generic by default) to open the service picker, choose the destination type, and paste the URL.
@ -118,7 +118,7 @@ Content-Type: application/json
Pro, legacy Pro+, and Cloud support dedicated audit webhooks for security event compliance. Unlike alert notifications, these webhooks deliver the raw, signed JSON payload of every security-relevant action (login, config change, group mapping).
### Setup
1. Go to **Settings → Security → Webhooks**.
1. Go to **Settings → Security → Audit Webhooks**.
2. Add your endpoint URL (e.g., `https://siem.corp.local/ingest/pulse`).
### Security
@ -135,7 +135,7 @@ Built-in webhook templates include Gotify, PagerDuty, Slack, and Generic. Use th
Typical MSP setup:
1. Open the client workspace from Pulse Account.
2. Add that client's notification destinations in **Alerts → Notification Destinations**.
2. Add that client's notification destinations in **Alerts → Notifications**.
3. Use Gotify, PagerDuty, Slack, or Generic depending on where the client or provider team wants alerts to land.
4. Keep each destination scoped to the client runtime so alert payloads and resolved events never cross into another client's workflow.
@ -160,7 +160,7 @@ There are two integration models. The push model is usually the right fit when t
**Push (recommended): one outbound webhook per organization.** Create a **Generic** webhook for each organization and point it at your external system's inbound endpoint (an ITSM/PSA inbound webhook, an email connector, or middleware that opens service tickets). Shape the JSON with a [custom template](#-custom-templates) so it matches the receiving system's expected schema: every template variable listed above is available. Pulse fires on both `alert` and `resolved` events (`{{.Event}}` is `"alert"` or `"resolved"`), so the receiving system can open a ticket on alert and auto-resolve it on recovery. Add authentication as a custom header (e.g. `Authorization: Bearer ...`).
Configure it from the UI (**Alerts → Notification Destinations → Add Webhook**) per org, or programmatically with an org-bound admin token:
Configure it from the UI (**Alerts → Notifications → Add Webhook**) per org, or programmatically with an org-bound admin token:
```http
POST /api/notifications/webhooks

View file

@ -4,8 +4,8 @@
> Primary v6 execution authority is `docs/release-control/v6/internal/SOURCE_OF_TRUTH.md` (+ `docs/release-control/v6/internal/status.json`).
> This file remains the detailed pricing evidence/spec and must stay aligned with the release-control source.
> **Status:** APPROVED — Final structure for v6 launch.
> **Date:** 2026-02-25
> **Status:** APPROVED — Current commercial contract.
> **Date:** 2026-07-14
> **Replaces:** All previous pricing documents and v5 pricing structure.
This document is the single source of truth for Pulse v6 pricing, tiering, feature
@ -23,9 +23,17 @@ release-control source wins and this file must be corrected.
configured provider or local model. We never cap how many times users can run
Patrol through their own provider. The paid gate is on auto-execution of fixes,
not on analysis or suggestions.
3. **Smooth upgrade ladder.** No large price gaps. Every step up has a clear reason.
4. **Simple to understand.** A homelabber should know which tier is right for them in
3. **Distinct jobs, clear bundles.** Community, Relay, and Pro are not a
good/better/best ladder. Community is the monitoring foundation, Relay is the access
service, and Pro is the operations product. Pro may bundle Relay connectivity, but
public copy must not use that entitlement relationship to recommend one job over another.
4. **Simple to understand.** A homelabber should know which product fits the job in
under 10 seconds.
5. **Product-led public language.** Public marketing describes Pulse, Community, Relay,
Pro, Cloud, and MSP as enduring products rather than release trains. Version identifiers
belong only in version-sensitive tasks such as release notes, downloads, compatibility,
migration, support, and implementation metadata; they must not lead homepage, product,
pricing, or acquisition copy.
---
@ -94,7 +102,7 @@ than sold by monitored-system volume.
---
## Self-Hosted Tiers
## Self-Hosted Products
### Community (Free) — $0
@ -133,7 +141,8 @@ outcomes, and record what happened.
| Element | Value |
|---|---|
| Monitoring scope | **Core self-hosted monitoring included** |
| Everything in Free | Yes |
| Product job | Secure remote access, Mobile pairing, and push delivery |
| Community monitoring | Remains free and unchanged |
| Relay remote access | **Yes** |
| Pulse Mobile handoff pairing | **Yes** (handoff and push notifications) |
| Push notifications | **Yes** |
@ -144,16 +153,18 @@ outcomes, and record what happened.
| RBAC/Audit/Reporting | No |
| Reporting | No |
**Positioning:** The convenience tier. It should feel cheap enough to buy on the spot when
someone wants secure remote access, Pulse Mobile handoff pairing, push notifications, and longer history
without changing their self-hosted monitoring scope.
**Positioning:** Relay is the access service. Present it when someone wants secure remote
access, Pulse Mobile handoff pairing, push notifications, and longer history without changing
their self-hosted monitoring scope. Do not recommend Relay over Pro, or describe Relay as a
step toward Pro; the products solve different jobs.
### Pro — $8.99/month or $79/year
| Element | Value |
|---|---|
| Monitoring scope | **Core self-hosted monitoring included** |
| Everything in Relay | Yes |
| Product job | Patrol-powered investigation and governed operations |
| Relay connectivity | Included as a bundled service |
| Patrol modes | **Yes** (choose how much Patrol can do) |
| Issue investigation | **Yes** |
| Governed fixes | **Yes** (approved execution, safety preflight, rollback, verification) |
@ -165,14 +176,28 @@ without changing their self-hosted monitoring scope.
| PDF/CSV reporting | **Yes** |
| Self-hosted trial acquisition | No; local trial CTAs are retired for v6 GA |
**Positioning:** For serious self-hosted operators who want Pulse to move from monitoring
into operations. The marketing pitch focuses on three things:
**Positioning:** Pro is the operations product for self-hosted operators who want Pulse to
move from monitoring into investigation and governed action. It is not "more Relay." The
marketing pitch focuses on three things:
1. "Choose Patrol mode" (how much Patrol can do)
2. "Let Patrol investigate and fix governed issues" (issue investigation, governed fixes, verification)
3. "Keep longer operating memory" (90-day history)
Relay convenience and the team extras (RBAC, audit logging, reporting, and agent
profiles) are included, but they are supporting value rather than the headline.
Relay connectivity and the team extras (RBAC, audit logging, reporting, and agent profiles)
are bundled, but they are supporting entitlements rather than evidence of a product ladder.
### Self-hosted license and support scope
- One Relay or Pro subscription covers one owner-operated Pulse environment.
- Monitored systems and child resources are not metered.
- The subscription permits three concurrent activations inside that environment
for primary, migration, and recovery use. Independently operated client
environments require MSP.
- Verified administrative ownership transfer is supported; resale, sharing,
and unverified third-party assignment are prohibited.
- Relay and Pro include verified commercial support for billing, activation,
transfer, configuration, and diagnostics, typically within two business
days. This is not a contractual SLA or priority-support commitment.
### Pro+ — Legacy continuity tier only
@ -183,10 +208,14 @@ still preserve self-hosted monitoring and child-resource volume as not metered w
---
## Cloud Tiers (Hosted — separate page)
## Cloud Tiers (Hosted — unavailable)
All Cloud tiers include everything in Pro + managed hosting + daily automated backups.
Cloud launches alongside v6 (not behind a waitlist).
Cloud is not currently offered. The prices and tier shapes below are dormant
commercial proposals retained for implementation planning; they are not a
signup promise, supported capacity contract, trial promise, or current support
commitment. Cloud must not reopen until its economic unit/caps, card policy,
support, retention, export, cancellation, reactivation, and runtime enforcement
pass the governed Cloud reopening gate.
### Cloud Starter — $29/month or $249/year
@ -230,7 +259,9 @@ Cloud launches alongside v6 (not behind a waitlist).
Pulse MSP is not the shared-process organization model. The default MSP route is provider-hosted: the MSP runs a Stripe-free control plane that creates one isolated Pulse runtime/container per client workspace. A signed MSP license sets the plan version and client workspace cap. Pulse-hosted MSP is an optional request-assisted path where Pulse operates that provider stack.
MSP is built and staged for assisted rollout, but it is not a public self-serve checkout path yet. Pricing, availability, and launch wording need owner review before publication.
MSP is an assisted preview, not a public self-serve checkout path. Public copy
may show the recorded monthly and annual prices, but fulfillment remains
request-assisted and must not imply immediate Pulse-hosted provisioning.
### MSP Starter — $149/month or $1,490/year
@ -337,9 +368,28 @@ There is no proactive self-hosted upsell cadence in v6 GA. If older compatibilit
mention prompt reduction, treat them as legacy controls; the v6 default is already quiet unless
the user enters an explicit commercial path or has an entitlement state that needs attention.
### 9. Cloud launches with v6
Not behind a waitlist. Real pricing, real signup. Captures convenience buyers who don't
want to self-host.
### 9. Cloud remains unavailable until reopening proof
The public Cloud surface must state that signup is closed. Dormant Cloud prices,
caps, trial language, and implementation paths remain non-public planning
inputs until the governed reopening gate passes.
### 10. Self-service lifecycle contract
| Change | Effective time | Billing treatment |
|---|---|---|
| Community to Relay or Pro | After successful checkout | New subscription |
| Relay to Pro | Immediate after explicit quote and successful payment | Prorated |
| Monthly to annual, same tier | Immediate after explicit quote and successful payment | Prorated |
| Pro to Relay | Current paid period end | No proration |
| Annual to monthly, same tier | Current paid period end | No proration |
| Voluntary cancellation | Current paid period end | No proration |
Combined transitions use the most restrictive timing rule. Voluntary
cancellation has a seven-day recovery-only window after paid entitlement ends;
payment failure has a separate seven-day functional grace period. Downgrades
preserve configuration, report definitions, and audit records. Out-of-tier
history and generated artifacts soft-hide for 30 days and become
purge-eligible after 60 days.
---
@ -514,12 +564,12 @@ explain monitored-system identity:
### 30-day post-launch review
- Free → Relay opt-in purchase rate
- Free → Pro opt-in purchase rate
- Relay → Pro upgrade rate
- Relay customers who later adopt Pro operations
- Which explicit commercial handoffs are used most (pricing, activation, recovery, hosted)
- Support load per tier
- Support load per product
### 60-day post-launch review
- Churn by tier
- Churn by product
- Revenue per user (actual vs projected)
- Cloud margin analysis
- MSP pipeline health
@ -531,6 +581,8 @@ explain monitored-system identity:
| Date | Change | Author |
|---|---|---|
| 2026-07-10 | Made public marketing product-led rather than release-led. Version identifiers remain available for version-sensitive lifecycle tasks and technical contracts, but no longer frame homepage, product, pricing, or acquisition copy. | Richard |
| 2026-07-10 | Reframed Community, Relay, and Pro as distinct job-based product choices rather than a good/better/best ladder. Removed product recommendations from public pricing while preserving Relay connectivity as a bundled Pro entitlement. | Richard |
| 2026-06-02 | Reconciled MSP pricing evidence with the provider-operated architecture: signed MSP license, Stripe-free provider control plane, isolated Pulse runtime per client, 5/15/40 client workspace caps, and request-assisted access until launch approval. | Richard |
| 2026-04-29 | Replaced stale capacity-style monitoring phrasing with core-monitoring-included language across active v6 docs and upgrade-return copy so Community does not read like a former capacity upsell. | Codex |
| 2026-04-23 | Removed stale self-hosted monitored-system capacity and Pro+ public-checkout language. Reaffirmed Community / Relay / Pro as current public self-hosted tiers, with Pro+ as continuity only and Pro value centered on operations, history, and admin controls. | Codex |

View file

@ -40,7 +40,7 @@ export PULSE_VERSION=vX.Y.Z
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh"
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh

View file

@ -40,7 +40,7 @@ export PULSE_VERSION=vX.Y.Z
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh"
curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh

View file

@ -236,19 +236,16 @@ user language should update the control plane.
## Current State
1. v6 is the current active release profile.
2. `v6-ga-promotion` is the current active engineering target.
The next public v6 release target is GA from the current
`pulse/v6-release` branch after accumulated post-RC7 fixes and final
current-branch validation. The published RC7 candidate must not be promoted
unchanged, and another RC is not planned by default unless a release-owner
decision changes that.
3. `v6-product-lane-expansion` remains planned behind the GA launch target.
Its candidate-lane surface remains available in the linked
`candidate_lanes` and `coverage_gaps`, but it should not displace release
execution while GA is the current objective.
4. The older RC line reached the historical `release_ready` floor, but the
current GA target now requires fresh current-branch validation because the
release will include accumulated fixes and changes after RC7.
2. `v6-product-lane-expansion` is the current active engineering target.
Pulse v6 GA and the initial 6.0.x patch line have shipped; active
development and stable release preparation now run on `main`.
3. `v6-ga-promotion` is complete. Its release records remain historical
evidence and must not keep pre-GA branch, checkout, or readiness posture
active in current lane state.
4. Candidate lanes and coverage gaps now route post-GA product expansion.
Release-blocking correctness work may still override that default queue
when a shipped customer contract can drift across billing, entitlements,
runtime behavior, or public copy.
5. `v6-rc-stabilization` is completed after the shipped RCs established the
current monitored-first floor and the active objective moved to stable
promotion.
@ -256,8 +253,8 @@ user language should update the control plane.
7. The existing v6 control surfaces are still live, but they now sit underneath
an evergreen Pulse control plane rather than pretending to be the whole
long-term system.
8. Until the explicit post-GA branch cutover happens, both prerelease and
stable v6 promotions resolve to `pulse/v6-release` via `control_plane.json`.
8. Both prerelease and stable v6 promotions resolve to `main` via
`control_plane.json`.
9. Legacy maintenance releases that still feed governed automation outside the
active v6 line must also resolve through `control_plane.json`.
Right now the remaining `5.1.x` stable maintenance line resolves to `main`

View file

@ -157,6 +157,79 @@ Companion drill:
legacy recurring price, or cancellation/reactivation leaves pricing and
entitlement state inconsistent across Stripe, Pulse runtime, and customer UI.
## Gate: `self-hosted-commercial-transition-coherence`
- Why this is risky:
Relay/Pro and cadence changes cross Stripe proration and schedules, durable
webhook processing, grandfathering, local entitlement projection, license
versioning, Relay grant enforcement, downgrade preservation, and customer-
visible account state. A failure in replay, feed delivery, or scoped runtime
invalidation can still charge for one plan while granting another even when
the local projection itself is atomic.
- Primary runtime surfaces:
`pulse-pro/license-server/v6_checkout.go`
`pulse-pro/license-server/v6_stripe.go`
`pulse-pro/license-server/v6_reconcile.go`
`pulse-pro/license-server/v6_state.go`
`pulse-pro/license-server/v6_store.go`
`pulse-pro/license-server/v6_grants.go`
`pulse-pro/relay-server/main.go`
`pulse-pro/relay-server/revocation_feed.go`
`pulse-pro/relay-server/registry.go`
`pkg/licensing/...`
Pulse Account Billing and Stripe customer/subscription state
- Automated proof:
Local unit and integration proof now covers the Community/Relay/Pro and
monthly/annual timing matrix, quote/apply parameter binding, idempotent
immediate retry convergence, schedule creation/reuse/cancellation ownership,
atomic commercial snapshot projection, payment-failure versus cancellation
grace, downgrade history timing, report entitlement denial, and safe artifact
purge. Relay proof now also covers mandatory operator-feed configuration,
synchronous startup drain, readiness staleness, active stale-session
teardown, and reconnect-token invalidation. Local test proof now covers the
customer-safe installation-scoped Pulse status/refresh/clear path without the
operator feed credential. The remaining duplicate/reversed/missing external-
event matrix and exact Stripe-to-Relay-and-Pulse exercise require the governed
rehearsal; until that evidence is registered, the gate remains blocked.
- Manual scenario:
1. In Stripe test mode, create fresh Community-to-Relay and Community-to-Pro
acquisitions for monthly and annual plans.
2. Exercise immediate quoted/prorated Relay-to-Pro and monthly-to-annual
changes, including success, decline, timeout-after-success, duplicate
request, and replay.
3. Exercise renewal-bound Pro-to-Relay and annual-to-monthly changes,
including cancellation/resume before the effective timestamp.
4. Exercise voluntary paid-through expiry, recovery-only access, involuntary
payment-failure grace and recovery, grace expiry, refund/dispute, and
current-price re-entry after continuity ends.
5. Reverse, duplicate, suppress, and later replay Stripe events; reconcile
from the current Stripe snapshot and confirm the same final state.
6. Confirm every material entitlement reduction increments
`license_version`, disconnects an already-connected old Relay grant within
the governed convergence bound, rejects its reconnect token, refreshes or
clears Pulse through an installation-scoped authority, and preserves
configuration, report definitions, audit records, and the governed
downgrade history window. Do not distribute the global Relay feed token to
customer installations.
7. Exercise the buyer/account journey at desktop and phone width and confirm
the quoted amount, effective date, plan, cadence, cancellation state, and
recovery behavior match runtime truth.
- Pass when:
Each scenario leaves exactly one billing contract and one entitlement
projection for the commercial subject; Stripe price, local plan, cadence,
runtime capabilities, license version, and customer-visible state agree;
replay/order variation does not change the result; and no downgrade or
cancellation deletes protected customer configuration or records.
- Latest exercised record:
Local implementation and browser evidence is recorded in
`docs/release-control/v6/internal/records/commercial-offer-lifecycle-contract-2026-07-14.md`.
No qualifying real-external-e2e record exists yet.
- Block release if:
Any supported transition can charge and grant different plans, mutate
entitlement without a version bump, restore ended grandfathering, rely on
mutable Customer Portal plan-switch configuration, or produce different
state under duplicate, missing, or reordered Stripe delivery.
## Gate: `known-rc-issue-closure-for-ga`
- Why this is risky:
@ -876,6 +949,77 @@ Companion drill:
can expose invoice/license data, consume verification, or mutate Stripe or
license state before the canonical case decision allows it.
## Gate: `stable-patch-unattended-release-path`
- Owner lanes: `L1`
- Risk covered:
A stable patch can publish while its demo deployment is detached, stale
release code can bypass preflight, or a private-network failure can consume
operator time and force manual SSH deployment after the public cut.
- Primary runtime surfaces:
`.github/workflows/create-release.yml`
`.github/workflows/release-dry-run.yml`
`.github/workflows/update-demo-server.yml`
`.github/scripts/check-demo-reachability.sh`
`scripts/trigger-stable-patch.sh`
- Automated proof:
`python3 -m unittest scripts.release_control.resolve_release_promotion_test scripts.release_control.release_promotion_policy_test`
`go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1`
- Manual scenario:
1. Push the exact candidate commit to the governed stable branch.
2. For a no-public-release rehearsal, dispatch
`./scripts/trigger-stable-patch.sh --dry-run <version>`.
3. Confirm the run passes `Verify Current Stable Demo Path (No Mutation)` on
a GitHub-hosted runner, including Tailscale ping, TCP/22, SSH host identity,
current stable version, frontend parity, public health, and browser smoke.
4. Confirm the public demo version and health remain unchanged after the run.
- Pass when:
The no-public-release rehearsal succeeds without host mutation, routine
patch metadata rejects the documented RC-required risk paths, and the single
publish DAG performs exact-SHA candidate checks before awaiting Docker, demo,
public verification, and the definitive terminal verdict.
- Latest exercised record:
`docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md`
- Block release if:
The integrated candidate checks can be bypassed, the no-mutation demo path
fails when rehearsed, demo deployment is detached from the release DAG, or
routine mode can bypass a same-version RC or an RC-required runtime change.
## Gate: `single-build-release-promotion-path`
- Owner lanes: `L1`
- Risk covered:
A normal release can repeat expensive compilation after successful checks,
serialize integration behind backend tests, download the complete release
packet after upload, or delay independent Docker, asset, and private-runtime
work. The release may be unattended but still consume most of a working day.
- Minimum evidence tier: `real-external-e2e`
- Canonical proof commands:
1. Push the exact implementation commit to the governed release branch.
2. Dispatch `Release Dry Run` for the current version and exact SHA. Do not
create or modify a public release.
3. Confirm `Build Immutable Release Candidate` builds, locally validates, and
uploads both the candidate and manifest artifacts.
4. Confirm the release checks and candidate build overlap, and confirm the
no-mutation demo path passes on GitHub-hosted infrastructure.
5. Run the candidate-manifest unit tests, release-promotion policy tests, and
installer/release workflow contract tests.
6. Confirm `v6.0.5` release timestamps and asset count remain unchanged and
the public demo remains healthy on stable `6.0.5`.
- Pass when:
The external rehearsal proves the canonical candidate builder, local tests
pin candidate-only publication and GitHub digest validation, and the static
release DAG contains no duplicate release build, backend-to-integration
serialization, full-download standard validator, or avoidable post-release
serialization.
- Latest exercised record:
`docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md`
- Block release if:
Publication rebuilds release assets, the candidate manifest does not pin the
exact SHA and complete asset set, standard validation downloads the full
release packet, independent post-release jobs are serialized, or the
definitive verdict can pass without all applicable downstream results.
## Gate Ownership Rule
Update these machine-visible gate states in `docs/release-control/v6/internal/status.json`

View file

@ -0,0 +1,269 @@
# Home Status Wall Spec
Last updated: 2026-07-10
Status: PLANNED
Owner of record: Richard (product decisions), implementing agent (execution)
Related evidence: GitHub issues #1478, #1433, #1460
## Intent
Pulse gets a home surface: a single page that answers "is everything OK?"
for the whole fleet by showing every monitored resource as its own named
tile, colored by a composite health verdict. It becomes the default
workspace-entry route.
This is not a widget dashboard. The prior Pulse dashboard (hero StatusStrip
plus navigation cards, removed via 579b7c1e6 / ae65c5e9a) failed because it
showed counts about the infrastructure instead of the infrastructure. Tiles
are the resources themselves; each tile is the click target into its detail
surface.
The differentiator is tile semantics, not layout:
1. Green means verified. A tile is green only when the resource is running,
its data is fresh, nothing is alerting on it, and (where applicable) its
last backup is recent. Liveness alone is never green.
2. A red or amber tile carries its reason inline (short) and click-through
detail (full).
3. Stale data is never green. If Pulse cannot currently verify a resource,
the tile says so.
## Governing Rules
All of these must stay true through implementation:
1. No stat cards. The posture summary above the wall is one inline text
line, not tiles of counts. (Standing product rule.)
2. No topology/node-link rendering, no animated packet/flow visuals. Pulse
has containment data, not wiring data; flows are not collected. Rejected
by decision on 2026-07-10.
3. No customization canvas (user-arranged widgets, bookmarks, iframes).
4. No new AI-synthesis surface. Patrol involvement is limited to the
existing nav badge and the existing targeted-check handoff.
5. The verdict computation lives server-side in one canonical place and is
shared by the wall, the summary endpoint, and future consumers. No
page-local health heuristics in the frontend.
6. Copy: sentence case, no em dashes anywhere (including locale JSON), all
strings through i18n dotted keys.
## Current State (verified 2026-07-10)
- `GET /api/state/summary` already exists: handler `handleStateSummary` in
`internal/api/state_summary.go` (registered in
`internal/api/router_routes_auth_security.go:149-150`), returning
`{activeAlerts, nodes, vms, containers, dockerHosts[], lastUpdate}`.
Shipped in bd6f77e09 (2026-06-04). Issue #1478 asks for this and has not
been told; replying/closing is Richard's action, NOT the implementing
agent's.
- Canonical resource model: `unifiedresources.Resource`
(`internal/unifiedresources/types.go:12-82`), projected to
`models.ResourceFrontend` (`internal/models/models_frontend.go:983-1057`)
with `Status`, `LastSeen` (unix ms), per-source blobs including
`Availability`.
- Availability checks (ICMP/TCP/HTTP) are already merged into unified
resources via `SupplementalRecords` in
`internal/monitoring/availability_poller.go:130`; probe state struct is
`AvailabilityProbeStatus` (`availability_poller.go:25-39`).
- Active alerts: `alerts.Manager.GetActiveAlerts()`
(`internal/alerts/read_model.go:15`); also on `StateFrontend.ActiveAlerts`.
- Backup recency: `models.VM.LastBackup` / `models.Container.LastBackup`
(`internal/models/models.go:166,213`) plus PBS/PVE backup collections on
`StateSnapshot`.
- No server-side trend/forecast infrastructure exists (no disk-full ETA
anywhere; `storagehealth/topology.go:326` has a static percent threshold
only). Predictive verdicts are therefore OUT of v1 (see Deferred).
- Frontend is SolidJS. Pages read live data via `useResources()`
(`frontend-modern/src/hooks/useResources.ts:91`, REST `/api/resources` +
websocket-triggered refetch), so a new page works in mock mode with no
extra work. Routes register in `frontend-modern/src/App.tsx:556-579`;
default entry is `getDefaultWorkspaceRoute()` (`App.tsx:118-125`); nav
tabs live in `frontend-modern/src/AppLayout.tsx` (primary tabs memo ending
~:477, utility tabs :482-538).
- i18n: dotted keys in `frontend-modern/src/i18n/messages.ts` (`t()` from
`src/i18n/index.ts`), de/es lazy override catalogs. Note some existing
surfaces bypass i18n; this page must not.
## Workstream A — canonical health verdict (backend)
### A1. Verdict model
Add a per-resource health verdict computed during unified-resource
projection, exposed on `ResourceFrontend` as a `health` object:
```json
{
"verdict": "ok | attention | critical | stale | off | unknown",
"reasons": [{ "code": "backup_stale", "detail": "9d" }]
}
```
Evaluation order (first match wins), applied per resource:
1. `critical` — an active critical alert targets the resource; or the
resource is an availability check with `Available=false` past its
failure threshold; or an infrastructure resource (node/host/agent
platform) is offline.
2. `attention` — an active warning alert targets the resource; or backup
staleness (rule below); or existing `capacity_runway_low` style risk
reasons from `storagehealth`.
3. `stale``LastSeen` older than the staleness threshold for its source
type. A stale resource is never `ok` regardless of last-known status.
(Exception: an unresolved alert older than the staleness window still
wins per rules 1-2 — the alert is a live fact even if telemetry stopped.)
4. `off` — workload (vm/container types) in a stopped state with no active
alerts. Off is neutral, not a failure.
5. `ok` — running/online, fresh, unalerted, backup-fresh where applicable.
6. `unknown` — inputs missing (e.g. resource type carries no status).
Backup staleness rule (v1): applies only to `vm` / `system-container`
resources that have at least one recorded backup; reason `backup_stale`
fires when the newest backup is older than 7 days. The threshold is a
single named constant with a code comment pointing at issue #839
(configurable freshness) as the future knob. Resources with no backup
history get no backup reason in v1.
Staleness thresholds: reuse whatever per-source staleness notion already
exists in the projection layer if one is found; otherwise a named constant
per source family. Do not invent per-resource configurability in v1.
Deliverable: one pure function (input: resource + active alerts + backup
index + now) in `internal/unifiedresources/` or adjacent, with table-driven
unit tests covering every verdict and precedence collision (critical alert
on stale resource, stopped workload with warning alert, availability check
below threshold, etc.).
### A2. Summary endpoint extension
Extend `stateSummaryResponse` (`internal/api/state_summary.go:14-21`)
additively — existing fields keep their names and types (external users may
already consume them):
- `verdicts`: object of counts `{ok, attention, critical, stale, off, unknown}`
- `attention`: array (capped at 50, most severe first) of
`{id, name, type, platformType, verdict, topReason}`
Update `router_state_test.go` / `state_summary` tests accordingly. Payload
target stays well under 5 KB for a 200-resource fleet; the `attention` cap
guarantees boundedness.
Governance: this changes an API surface — update the api-contracts
subsystem notes honestly. As of 2026-07-10
`docs/release-control/v6/internal/subsystems/api-contracts.md` is dirty
with another agent's in-progress edits; if that is still true at
implementation time, stop and coordinate rather than editing around it.
## Workstream B — home page (frontend)
### B1. Route and navigation
- New path constant `HOME_PATH = '/home'` in `src/routing/resourceLinks.ts`;
lazy route in `App.tsx`; new leftmost primary tab "Home" in
`AppLayout.tsx` (always visible — it owns the empty state).
- `getDefaultWorkspaceRoute()` returns `HOME_PATH` when any resources are
visible. THIS FLIP IS GATED — see Sequencing. Until the gate clears, the
page ships reachable via nav tab only, and the default-route change is a
separate one-line commit at the end.
- `src/__tests__/App.architecture.test.ts` pins route architecture — extend
it, never renumber or reorder existing pinned entries.
### B2. Page structure
Component `src/features/home/HomePageSurface.tsx` with all decision logic
in `src/features/home/homePageModel.ts` (pure, unit-tested):
1. Posture strip: one inline text line — "2 need attention · 41 of 44
healthy · 1 stale · updated 4s ago" (i18n with params, pluralization via
params not concatenation). No card, no tiles. Do not reuse
`StandalonePostureCard` (hardcoded English, standalone-specific); a new
small component is correct here.
2. Attention section: when any `critical`/`attention` tiles exist, they
render first in their own group, most severe first, regardless of
platform.
3. Platform groups: remaining resources grouped via `useResources().byPlatform`
ordering consistent with the nav tab order; group label = platform name
plus host context where cheap. Within a group: tiles sorted
non-ok-first, then name.
4. Tile: name + status tint + short reason when not ok (e.g. "down",
"backup stale 9d", "stale 3h"). Stale = dashed border + muted. Off =
neutral surface. Whole tile is a link to the resource's existing detail
route (reuse `resourceLinks` helpers; do not invent new detail surfaces).
5. Group caps: groups render up to 60 ok/off tiles with a "show all (N)"
expander. Non-ok tiles are NEVER hidden by the cap.
6. Empty state: no resources → the existing onboarding/connect pointer
(invitation copy, no apology), consistent with what platform pages show
pre-connection.
Live updates arrive through the existing store (websocket-triggered
refetch); no new subscription machinery. No polling loops.
### B3. Styling constraints
- Tailwind consistent with neighboring features; status tints via the
existing status tone system (`StatusDot` / `statusBadgeModel.ts` tones),
not hand-picked hex.
- No tables are expected on this page; if one is added anyway, column
alignment goes through `getPlatformTableHeadClassForKind` /
`getPlatformTableCellClassForKind` (canonical rule, pre-push lint
enforces).
- Motion: none in v1 except the existing tone-transition defaults. No
ambient animation.
### B4. Tests
- `homePageModel` unit tests: grouping, ordering, posture-line counts,
cap behavior (non-ok never capped), verdict-to-tone mapping.
- Component smoke test in `src/features/home/__tests__/` following the
StandalonePageSurface test pattern.
- Run `npx vitest run src/features/home` yourself; pre-commit hooks do not
run vitest.
## Sequencing and gates
- Workstream A can start immediately.
- Workstream B1/B2 can start once A lands (the page renders server
verdicts; it does not compute its own).
- The default-route flip (B1, last commit) is gated on the open
data-integrity work: v5 parity audit closure, backups-page regression
fixes, and platform-agent staleness generalization. A home page that
shows false greens on day one repeats the failure that got the last
dashboard deleted. If those items are still open when B2 is done, ship
the page tab-reachable and leave the flip commit unmade; note it in the
handoff report.
## Explicitly out of scope (v1)
- Predictive verdicts (disk-full ETA, backup-streak forecasting). No trend
infrastructure exists server-side; building it is its own lane. Listed
here so nobody bolts a regression slope onto the wall ad hoc.
- Verdict threshold configurability (backup freshness knob → issue #839).
- Any GitHub communication. Do not comment on or close #1478/#1433/#1460.
- Landing page / docs / release notes copy.
- pulse-pro / enterprise surfaces (the wall is OSS core; license-gated
extensions come later if ever).
## Acceptance criteria
1. A fleet with one down node, one warning-alerted VM, one stale agent, one
stopped container, and one failing availability check renders: red node
tile with reason, amber VM tile, dashed stale tile, neutral off tile,
red check tile — and the posture strip counts match exactly.
2. Everything healthy → posture strip reads all-healthy, wall is all green,
nothing animates, no element demands action (quiet when green).
3. `GET /api/state/summary` returns verdict counts + capped attention list;
existing fields unchanged; payload < 5 KB at 200 resources.
4. Verdict unit tests cover every enum value and the precedence collisions
listed in A1.
5. Works identically in mock mode (`PULSE_MOCK_MODE=true` dev backend).
6. All new strings resolve through i18n keys in en/de/es; no em dashes in
any added string in any locale.
7. Exercised live (dev stack, :5173) to its deepest states — expanders,
empty state, mock fleet — before being called done.
## Handoff boundaries for the implementing agent
- Commit discipline per `AGENTS.md § Commit Discipline`: porcelain check
first, explicit-path staging only, one scoped commit per workstream
chunk, never `--no-verify`, stop on entangled dirty files.
- No releases, tags, GitHub posts, or emails.
- If a governing rule above conflicts with something discovered in code,
stop and report rather than reinterpreting the rule.

View file

@ -37,12 +37,15 @@ Use this as the final gate before cutting a Pulse v6 pre-release.
- High-risk release confidence now lives in `docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md` and should be cleared alongside this checklist.
## Promotion Policy
- [ ] For a routine stable patch, run `./scripts/trigger-stable-patch.sh --dry-run <version>` from the exact pushed candidate SHA, wait for the whole run including `Verify Current Stable Demo Path (No Mutation)` to pass, then run `./scripts/trigger-stable-patch.sh <version>` once.
- [ ] Confirm a routine stable patch has no same-version RC and no diff in the RC-required authentication/tenant, licensing/billing, persisted-data/migration, relay/mobile-trust, or installer/update/rollback boundaries. Otherwise use RC promotion or record the emergency hotfix reason.
- [ ] Treat `Definitive Release Verdict` as the release result. Do not accept a green asset-publish job while Docker publication, demo deployment, public browser verification, Helm/floating-tag promotion, or private Pro promotion is detached or incomplete.
- [x] Record the previous stable tag and exact rollback pin command before publishing a new prerelease or stable release.
- [ ] For any prerelease or stable publication, confirm the repo variable `PULSE_UPDATE_SIGNING_PUBLIC_KEY` is set to the intended active update signer public key and that the release workflows are consuming it alongside `PULSE_UPDATE_SIGNING_KEY`, so accidental trust-root rotation fails closed before publication.
- [x] For the GA/stable candidate, confirm the release pipeline has already been exercised on a real prerelease tag, not only linted or YAML-parsed.
- [x] For stable promotion, confirm the candidate commit has already shipped on `rc`.
- [x] For stable promotion, confirm the chosen `promoted_from_tag` is a prerelease that was actually published through the governed prerelease path, not an accidental git tag.
- [x] For stable promotion, confirm the prerelease soak window is at least 72 hours or document the hotfix exception explicitly.
- [x] For the first GA or an RC-required stable promotion, confirm the release pipeline has already been exercised on a real prerelease tag, not only linted or YAML-parsed.
- [x] For an RC-required stable promotion, confirm the candidate commit has already shipped on `rc`.
- [x] For an RC-required stable promotion, confirm the chosen `promoted_from_tag` is a prerelease that was actually published through the governed prerelease path, not an accidental git tag.
- [x] For an RC-required stable promotion, confirm the prerelease soak window is at least 72 hours or document the hotfix exception explicitly.
- [x] For stable promotion, record the 2026-07-02 release-owner decision accepting the current-branch validation risk for the post-RC7 changes.
- [x] For GA/stable promotion, confirm `V5_MAINTENANCE_SUPPORT_POLICY.md` is still the intended policy and replace any placeholder GA notice dates with the exact v6 GA date and exact v5 end-of-support date that will ship with the announcement.
- [x] For GA/stable promotion, confirm the pushed governed release-branch copy of `.github/workflows/release-dry-run.yml` already accepts the governed stable rehearsal metadata envelope (`promoted_from_tag`, `rollback_version`, `ga_date`, `v5_eos_date`) through `workflow_dispatch`, because GitHub executes the selected remote ref and does not see local-only governance state.

View file

@ -119,8 +119,9 @@ Cloud, and self-hosted production users.
## Stable Promotion Rules
1. A stable tag must be promoted from a commit that has already been exercised
as a published prerelease.
1. A first stable release, a stable minor release, and every patch that crosses
one of the RC-required risk boundaries below must be promoted from a commit
that has already been exercised as a published prerelease.
2. A prerelease git tag counts as stable-promotion lineage only if that prerelease was
actually published through the governed prerelease path; accidental or abandoned git
tags do not satisfy the stable-promotion requirement.
@ -130,24 +131,26 @@ Cloud, and self-hosted production users.
4. Every stable promotion requires:
- Applicable items in `PRE_RELEASE_CHECKLIST.md` complete.
- Applicable entries in `HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md` cleared.
- The previous stable rollback target and exact reinstall command recorded.
5. A first stable release or RC-required stable promotion additionally requires:
- No known unresolved RC-era user-visible issues intended for the v6 GA
scope remain open. Each one must be fixed in the candidate, proven
invalid with evidence, or conservatively superseded with the original
failure resolved or explicitly narrowed.
- The previous stable rollback target and exact reinstall command recorded.
- A live release-pipeline exercise already completed for the promoted prerelease tag,
not only YAML lint or static workflow validation.
6. The first v6 GA promotion additionally requires:
- The locked 90-day v5 maintenance-only policy in
`V5_MAINTENANCE_SUPPORT_POLICY.md` and the exact end-of-support notice
ready to publish with the promotion.
5. Normal stable promotions require a minimum 72-hour prerelease soak after the
candidate is available to internal or staging-like users.
6. Hotfix exception:
- A shorter soak is allowed only for narrowly scoped fixes to active
customer harm.
7. RC-derived stable promotions require a minimum 72-hour prerelease soak after
the candidate is available to internal or staging-like users.
8. Hotfix exception:
- Bypassing an RC requirement or shortening an RC soak is allowed only for
narrowly scoped fixes to active customer harm.
- The exception plus the rollback target and exact reinstall command must be
recorded in the release notes or release ticket before promotion.
7. v6.0.0 owner-risk exception:
9. v6.0.0 owner-risk exception:
- On 2026-07-02, after seven v6 release candidates, the release owner
explicitly approved promoting the current `pulse/v6-release` branch with
accumulated post-RC7 changes without RC8, another soak, or additional
@ -160,6 +163,65 @@ Cloud, and self-hosted production users.
and retain the prior governed release-pipeline rehearsal evidence as
automation lineage rather than claiming the post-RC7 changes were RC-tested.
## Single-Build Release Path
1. Every normal RC, stable, and patch release is initiated once through
`create-release.yml`. The workflow builds one signed candidate for the exact
pushed SHA while frontend, backend, Docker, Helm, and integration checks run
in parallel. No tag, draft, or public release mutation occurs until those
checks and the candidate build pass.
2. The signed candidate is uploaded as a one-day Actions artifact with a
machine-readable manifest that pins source SHA, version, filename, size, and
SHA-256 for every release asset. Publication downloads and verifies that
exact candidate; it must not rebuild release binaries or installers.
3. Standard post-publication asset verification compares the candidate
manifest with GitHub's server-side release-asset SHA-256 digests. It must
not re-download the multi-gigabyte release packet merely to recompute hashes
already proven before upload. Manual and release-edit repair validation may
retain the full-download fallback when no same-run candidate manifest exists.
4. Docker publication, release-asset verification, and the private Pro build
begin independently as soon as the release exists. Helm, floating tags,
install smoke, stable demo deployment, and private paid-runtime promotion
retain their required dependencies, and `Definitive Release Verdict` still
fails unless every applicable terminal result passes.
5. `Release Dry Run` remains the no-public-release rehearsal surface. It calls
the same candidate builder and no-mutation demo verification, but a separate
dry run is not required before a normal release because the single publish
workflow performs the exact-SHA preflight before crossing the publication
boundary.
## Routine Stable Patch Path
1. A normal stable patch may omit a same-version RC only when all of these are
true:
- the rollback target is the latest preceding stable tag and the candidate
descends from it;
- no same-version RC tag already exists;
- the diff does not touch authentication/authorization/tenant isolation,
licensing/entitlement/billing authority, persisted data/schema/migration,
relay/mobile trust protocol, or installer/updater/rollback execution;
- the canonical stable release-notes packet exists;
- the mobile-impact gate either proves no mobile-facing change or records
current candidate evidence; and
- the integrated exact-SHA candidate build and release checks pass before
the workflow creates or publishes the release.
2. `scripts/trigger-stable-patch.sh` is the standard operator entrypoint. Run
it once without `--dry-run`; it derives rollback and release notes, refuses
local-only or dirty state, and supplies workflow metadata without
interactive prompts. `--dry-run` is optional and exists only for an explicit
no-public-release rehearsal.
3. Creating a same-version RC or touching an RC-required path moves the patch
onto the RC promotion path. The resolver enforces that boundary. Do not use
the routine helper to relabel a risky patch as routine.
4. `--emergency-hotfix-reason` is the narrow escape hatch for active customer
harm. It does not remove the exact-SHA dry-run requirement, and the reason is
recorded in the release metadata.
5. The release workflow must await Docker publication, stable demo deployment,
public health/browser verification, install smoke, Helm publication,
floating-tag promotion, and private Pro promotion where applicable. The
terminal `Definitive Release Verdict` job is the one release result; an
asynchronously dispatched demo workflow is not release completion.
## Rollout Rules
1. Default installs stay on `stable`.

View file

@ -1,6 +1,6 @@
# Pulse v6 Source Of Truth
Last updated: 2026-07-02
Last updated: 2026-07-14
Status: ACTIVE
This file is the stable human governance layer for the active v6 release
@ -213,6 +213,21 @@ The retained foundation is therefore the hidden backend layer: canonical
resources and relationships, standardized agent-emitted resource/signal/change
envelopes, policy metadata and routing hooks, governed action and approval
boundaries with auditability, and first-class fleet governance.
Governed action identity is create-once and lifecycle state is monotonic. A
deterministic replay returns the authoritative persisted record; it cannot
replace approvals, execution results, verification, origin, or terminal state.
Only the store-level compare-and-swap winner that atomically persists the
`executing` transition, lifecycle event, create-once dispatch-attempt identity,
and outbox row may admit transport. Claiming the outbox is not permission to
send: `MarkActionDispatchStarted` is the durable pre-send linearization point.
An expired pre-send claim requeues the same attempt; after that point recovery
may only reconcile the attempt or accept an authenticated correlated receipt,
never blindly resend. A correlated response that carries today's execution
result commits its receipt with the terminal audit and event atomically, so a
crash cannot preserve the receipt while losing the accompanying result. This is
durable transport admission and continuity, not
permission to infer execution, verification, evidence, or compensation truth;
Task 10 remains the sole owner of those terminal semantics.
## Evergreen Readiness Assertions
@ -443,6 +458,26 @@ Assertion design rules:
Claims reduce overlap, but they do not isolate hooks, formatters, staged
reads, or unrelated dirt. Parallel mutation should use separate worktrees
so each agent sees one slice's git state at a time.
22. Do not publish a routine stable patch without a successful exact-SHA
`Release Dry Run` from the previous 24 hours. That run must prove the
current stable demo network/SSH/browser path without mutation, and the
publish workflow must await demo deployment plus definitive verification;
manual SSH deployment is not an acceptable release completion path.
23. Do not authorize limited unattended Pulse Intelligence mutation until the
closed `RG-01` through `RG-12` matrix reports `GO` at the exact release
Git SHA. `scripts/release_control/pulse_intelligence_gate.py` is a
read-only verdict checker: the tracked matrix contains requirements only,
while concrete results come from a separate external or untracked evidence
document bound to the audited SHA selected at runtime. The checker must
never execute real-lab, device, relay, or other mutation-gated commands
itself.
24. Do not ship or expose self-service plan or cadence transitions unless the
authoritative Stripe subscription snapshot, local billing contract,
entitlement projection, continuity epoch, license version, and grant
revocation/outbox state converge through one idempotent transition
authority. A customer must never pay for one plan while Pulse grants
another, and duplicate, missing, or reordered webhook delivery must not
change the final commercial state.
## Locked Decisions
@ -468,9 +503,10 @@ Assertion design rules:
and downgrade safety.
6. Cloud and MSP Stripe `price_*` IDs are operational fill-in items, not
architectural blockers.
7. Stable or GA promotion for v6 must come from an exercised RC and stay
blocked until the RC-to-GA promotion gate is cleared and the published v5
maintenance-policy notice is ready. For v6.0.0 only, the 2026-07-02
7. First-stable, GA, minor-line, and risk-bearing stable promotion for v6 must
come from an exercised RC and stay blocked until the applicable promotion
gates are cleared. Routine post-GA stable patches may use the governed
no-RC path defined below. For v6.0.0 only, the 2026-07-02
release-owner risk acceptance allows the current post-RC7 `pulse/v6-release`
branch to ship without RC8, another soak, or additional current-branch
validation before GA; this is not validation evidence and not a standing
@ -509,6 +545,47 @@ Assertion design rules:
issue intended for v6 is fixed in the candidate, proven invalid with
evidence, or conservatively superseded with the original problem resolved
or explicitly narrowed.
15. Routine stable patch releases after GA do not require a fabricated RC.
They may use the no-RC path only when the candidate descends from the latest
stable rollback target, no same-version RC exists, no governed high-risk
auth/tenant, licensing/billing, persisted-data/migration, relay/mobile
trust, or installer/update/rollback path changed, and the exact-SHA release
dry run passed. Those risk conditions require RC lineage unless an active
customer-harm emergency is recorded through the hotfix exception.
16. The canonical self-hosted offer is job-based rather than a
good/better/best ladder: Community is the free local monitoring foundation,
Relay is remote access plus Pulse Mobile pairing, push, and 14-day history,
and Pro is Patrol-powered investigation and governed operations with
90-day history and Relay bundled. One Relay or Pro subscription covers one
owner-operated environment with unmetered monitored-system and child-
resource volume plus three concurrent primary/migration/recovery
activations. It does not cover independently operated client environments.
17. Verified administrative ownership transfers are permitted, while resale,
sharing, and unverified third-party assignment are prohibited. Relay and
Pro include standard verified commercial support for billing, activation,
transfer, configuration, and diagnostics, normally targeted within two
business days without a contractual SLA or priority-support promise.
18. Self-hosted subscription transitions follow the approved lifecycle matrix
in
`docs/release-control/v6/internal/records/commercial-offer-lifecycle-contract-2026-07-14.md`:
Relay-to-Pro and monthly-to-annual changes are immediate only after an
explicit prorated quote and successful payment; capability downgrades and
annual-to-monthly changes occur at renewal without proration; voluntary
cancellation ends paid capability at the paid-through timestamp; a
separate seven-day recovery-only window grants no paid capability; and
involuntary payment failure receives seven days of functional grace.
Configuration, report definitions, and audit records survive downgrade;
out-of-tier history and generated artifacts soft-hide for 30 days and
become purge-eligible after 60 days. Completed cancellation or a completed
tier/cadence change ends grandfathered recurring-price continuity.
19. Cloud is unavailable until a governed reopening gate proves its economic
unit/caps, card policy, support, retention, export, cancellation,
reactivation, and runtime enforcement. Historical Cloud prices and caps
are dormant proposals, not a current offer. MSP remains an assisted
preview, provider-hosted by default, with 5/15/40 isolated client-
workspace limits; public copy may state the recorded monthly and annual
prices but must not promise immediate self-service checkout or Pulse-hosted
fulfillment.
## TrueNAS Support Floor

View file

@ -0,0 +1,60 @@
# Unified Agent Platform Support Evidence
This matrix separates a binary that can be cross-compiled from a lifecycle
that has been exercised on the operating system which will run it. A release
must not use cross-compilation alone as proof of native support.
## Evidence levels
- `native-ci`: tests and the built binary execute on a native GitHub-hosted OS
and architecture runner.
- `native-lab`: install, service start, report, update, restart/reboot, and
uninstall have been exercised on a maintained native lab target.
- `cross-build`: the release build produces the target and validates its file
format, but no native runtime claim follows from that evidence.
- `installer-contract`: the installer parser, state recovery, service
rendering, and teardown contracts have executable tests without a native
installed lifecycle.
## Current matrix
| Runtime target | Release artifact | Automated evidence | Remaining release-grade evidence |
| --- | --- | --- | --- |
| Linux amd64 | yes | native-ci gate | scheduled installed-service reboot/update rehearsal |
| Linux arm64 | yes | native-ci gate | scheduled installed-service reboot/update rehearsal |
| Linux armv7 | yes | cross-build | native lab lifecycle |
| Linux armv6 | yes | cross-build | native lab lifecycle |
| Linux 386 | yes | cross-build | native lab lifecycle |
| macOS arm64 | yes | native-ci gate; prior v5-to-v6 native-lab upgrade record | signed and notarized release candidate; recurring installed-service lifecycle |
| macOS amd64 | yes | native-ci gate | signed and notarized release candidate; recurring installed-service lifecycle |
| Windows amd64 | yes | native-ci gate | Authenticode-signed release candidate; recurring installed-service lifecycle |
| Windows arm64 | yes | cross-build | native lab lifecycle and Authenticode signing |
| Windows 386 | yes | cross-build | native lab lifecycle and Authenticode signing |
| FreeBSD amd64 | yes | cross-build with fail-closed ELF validation; installer-contract | native rc.d install/update/reboot/uninstall lifecycle |
| FreeBSD arm64 | yes | cross-build with fail-closed ELF validation; installer-contract | native rc.d install/update/reboot/uninstall lifecycle |
`native-ci gate` means `.github/workflows/unified-agent-native.yml` now requires
the proof on relevant agent changes. The first successful workflow run is the
evidence event; the presence of the workflow file alone is not a green run.
Appliance families such as Unraid, Synology, QNAP, TrueNAS, pfSense, and
OPNsense additionally require their own persistence and service-manager lab
proof. Reusing a Linux or FreeBSD binary does not establish appliance lifecycle
support.
## Release trust boundary
All release artifacts continue to require Pulse checksum, SSHSIG, and Ed25519
verification. Those controls protect download integrity and the self-updater,
but they do not replace platform-native identity:
- macOS distribution still requires Developer ID signing and Apple
notarization before the normal-user experience can be called release-grade.
- Windows distribution still requires Authenticode signing before the
normal-user experience can be called release-grade.
Immutable candidate assembly now has native macOS and Windows signing jobs,
and normal release promotion requires them. The remaining external evidence is
a successful run with the repository's Developer ID/notary and Authenticode
credentials configured; missing credentials fail the release instead of
silently publishing unsigned desktop binaries.

View file

@ -0,0 +1,224 @@
{
"schema_version": 3,
"matrix_id": "pulse-intelligence-rg-01-rg-12",
"program": "pulse-intelligence",
"result_semantics": {
"PASS": "External evidence satisfies the gate at the audited Git SHA.",
"FAIL": "External evidence bound to the audited Git SHA records a failed proof or an invalid binding.",
"NOT_RUN": "No qualifying external evidence exists for the gate at the audited Git SHA."
},
"evidence_tiers": [
"unit",
"integration",
"browser",
"real-lab",
"physical-device",
"live-relay"
],
"triple_zero_semantics": {
"persistence_writes": "Unauthorized or revoked requests persist no action, approval, or authority state.",
"dispatch_attempts": "Unauthorized or revoked requests create no executor, outbox, or transport dispatch attempt.",
"external_mutations": "Unauthorized or revoked requests change no provider, device, Relay, or infrastructure state."
},
"gates": [
{
"id": "RG-01",
"title": "Read-only investigation and origin isolation",
"owner": "task-01",
"required_evidence_tier": "integration",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-01-profile-boundary",
"argv": ["go", "test", "./internal/ai/tools", "-run", "TestPatrolDetectionProfileEnforcesAllowlistedPulseState|TestPatrolInvestigationProfileIsStructurallyReadOnly|TestProposeActionIsInvestigationProfileOnly", "-count=1"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-02",
"title": "Canonical lifecycle admission",
"owner": "task-02",
"required_evidence_tier": "integration",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-02-lifecycle-admission",
"argv": ["go", "test", "./internal/actionlifecycle", "./internal/api", "-run", "TestPolicyAdmissionCommitsApprovalAndExecutingAtomicallyMemoryStore|TestPolicyAdmissionCommitsApprovalAndExecutingAtomicallySQLite|TestPatrolActionBrokerDispatchTimePolicyRevocation", "-count=1"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-03",
"title": "Typed capability and provider contracts",
"owner": "task-03",
"required_evidence_tier": "integration",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-03-capability-contracts",
"argv": ["go", "test", "./internal/agentcapabilities", "./internal/agentexec", "-run", "Test(CanonicalManifestOwnsAgentSurface|CanonicalManifestPinsPulseIntelligenceSurfaceContract|DockerContainerLifecycleWireCarriesFactsNotActionTruth|DockerAgentWireAndRuntimeDeclareNoLocalActionTruthModel|PendingDockerOperationBindsFullIdentityAndRejectsCrossTypeReuse)", "-count=1"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-04",
"title": "Policy, approval floor, and plan identity",
"owner": "task-04",
"required_evidence_tier": "integration",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-04-policy-plan-hash",
"argv": ["go", "test", "./internal/actionlifecycle", "./internal/api", "-run", "TestExecuteUnderPolicyBarrierRevocationsMemoryStore|TestExecuteUnderPolicyBarrierRevocationsSQLite|TestHumanApprovalSurvivesAutomaticPolicyRevocationMemoryStore|TestHumanApprovalSurvivesAutomaticPolicyRevocationSQLite|TestEmergencyStopBlocksHumanAndPolicyAdmissionMemoryStore|TestEmergencyStopBlocksHumanAndPolicyAdmissionSQLite", "-count=1"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-05",
"title": "Closed canonical mutation registry",
"owner": "task-05",
"required_evidence_tier": "integration",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-05-mutation-registry",
"argv": ["go", "test", "./internal/mutationregistry", "./internal/ai/tools", "-run", "Test(EveryRegisteredMutationHasDisposition|InfrastructureAPIRoutesResolveToRegistry|TransportCommandCatalogsResolveToRegistry|PatrolJobRegistrationResolvesToRegistry|RuntimeCandidateAuditNegativeFixtures|ActionRouteMethodAuthorityIsExactAndLookalikesFailClosed|NonAdmittingTransportMessagesCannotCarryDispatchAuthority|UnknownTransportLookalikeFailsClosed|RegisteredModelMutationSchemasResolveToClosedRegistry|RetiredMutationAliasesCannotShadowExtensions)", "-count=1"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-06",
"title": "Limited unattended autonomy release authorization",
"owner": "task-08",
"required_evidence_tier": "real-lab",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-06-autonomy-authority",
"argv": ["go", "test", "./internal/api", "./internal/actionlifecycle", "-run", "Test(PatrolFullModeRunsStorageCleanupThroughCanonicalLifecycle|PatrolFullModeRunsHostUpdateThroughCanonicalLifecycle|PatrolAutopilotAcknowledgementAPIRejectsPublicAuthorityAndAPIToken|PatrolAutopilotActivationRequiresCurrentBoundAcknowledgement|PatrolAutopilotVersionRotationAndRevocationRaceFailClosed|PatrolAutopilotStoreUnavailableDoesNotChangeModeOrFabricateEvidence|EmergencyStopBlocksHumanAndPolicyAdmissionMemoryStore|EmergencyStopBlocksHumanAndPolicyAdmissionSQLite)", "-count=1"]
}
],
"mutation_gated_commands": [
{
"id": "rg-06-colima-autonomy-lab",
"argv": ["python3", "scripts/intelligence_lab/patrol_autonomy_colima.py", "--run-id", "<release-id>", "--sha", "<full-release-sha>", "--repo", "<clean-git-archive>", "--artifact-dir", "<external-artifact-dir>", "--scratch-dir", "<external-scratch-dir>"],
"authorization": "EXPLICIT_RELEASE_REQUIRED"
}
]
},
{
"id": "RG-07",
"title": "Durable dispatch and reconnect continuity",
"owner": "task-07",
"required_evidence_tier": "integration",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-07-durable-delivery",
"argv": ["go", "test", "./internal/actionlifecycle", "./internal/api", "./internal/agentexec", "-run", "Test(SQLiteRestartRecoveryReconcilesReceiptPendingWithoutResend|SQLiteRestartRecoveryNotFoundRemainsReceiptPendingWithoutResend|AuthenticatedServerNotFoundRecoveryStaysQueryOnlyAndReceiptPending|DockerContainerActionExecutorCallbackLossReconcilesReceiptWithoutRedispatch|HostStorageCleanupReconcileDelayedTerminalReceiptPreservesAgentAttestedEvidenceWithoutResend|HostUpdateReconcileDelayedTerminalReceiptPreservesAgentAttestedEvidenceWithoutResend|LegacyAgentWithoutReceiptProtocolRemainsConnectedButTypedMutationFailsClosed)", "-count=1"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-08",
"title": "Final-SHA Docker lifecycle lab and browser certification",
"owner": "task-06",
"required_evidence_tier": "real-lab",
"requires_triple_zero": false,
"allows_ancestor_provenance": true,
"non_mutating_proof_commands": [
{
"id": "rg-08-docker-artifact-browser",
"argv": ["env", "PULSE_INTELLIGENCE_LAB_ARTIFACT=<released-final-sha-artifact>", "tests/integration/node_modules/.bin/playwright", "test", "--config=tests/integration/playwright.config.ts", "tests/84-docker-restart-real-lab-artifact.spec.ts", "--project=chromium"]
}
],
"mutation_gated_commands": [
{
"id": "rg-08-colima-lab-mutation",
"argv": ["python3", "scripts/intelligence_lab/docker_restart_colima.py", "--run-id", "<release-id>"],
"authorization": "EXPLICIT_RELEASE_REQUIRED"
}
]
},
{
"id": "RG-09",
"title": "Debian and Ubuntu APT update and cleanup certification",
"owner": "task-09",
"required_evidence_tier": "real-lab",
"requires_triple_zero": false,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-09-apt-fake-contracts",
"argv": ["go", "test", "./internal/api", "./internal/agentexec", "./internal/hostagent", "./internal/ai", "./internal/unifiedresources", "-run", "Test(APTUpdateDetectorProposalApprovalDispatchAuditAndFindingReconciliation|APTUpdateCallbackLossServerRestartReconcilesTerminalAuditAndFindingWithoutResend|APTCacheCleanupDetectorProposalApprovalDispatchAuditAndFindingReconciliation|APTCacheCleanupCallbackLossServerRestartReconcilesTerminalAuditAndFindingWithoutResend|APTWorkflowWatcherReplayAndFutureTimesRemainUnresolved|APTWorkflowWatcherCapabilityLossDoesNotResolveActiveFinding|HostUpdateActionAvailabilityFailsClosed|HostStorageCleanupAvailabilityFailsClosed|StrictAPTCodecsRejectUnknownTrailingAndOpenAuthorityFields|MalformedOrCrossTypeAPTResultCannotPoisonPendingUpdate|CrossTypeSameRequestCollisionRefusesSecondMutation|PackageUpdateManagerApplyUsesClosedAPTCommandCatalogAndVerifies|PackageUpdateManagerFailsClosedWhenRefreshFails|PackageUpdateManagerRefusesRefreshTimeInventoryDriftBeforeInstall|StorageCleanupManagerApplyUsesClosedAPTCatalogAndVerifiesBytes|StorageCleanupManagerRefusesFingerprintDriftBeforeMutation|RealServerAndUnifiedAgentWebSocketExecutesAPTThroughFakeTypedManagersAndReplaysWithoutMutation|HostAPTActionTruthRedactsRawAgentAndPackageDetail)", "-count=1"]
}
],
"mutation_gated_commands": [
{
"id": "rg-09-debian-ubuntu-colima-lab",
"argv": ["python3", "internal/api/testdata/rg09/apt_workflows_colima.py", "--run-id", "<release-id>", "--sha", "<full-release-sha>", "--repo", "<clean-git-archive>", "--artifact-dir", "<external-artifact-dir>", "--scratch-dir", "<external-scratch-dir>"],
"authorization": "EXPLICIT_RELEASE_REQUIRED"
}
]
},
{
"id": "RG-10",
"title": "Canonical execution, verification, and compensation truth",
"owner": "task-10",
"required_evidence_tier": "integration",
"requires_triple_zero": false,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-10-action-result-v2",
"argv": ["go", "test", "./internal/unifiedresources", "./internal/actionlifecycle", "./internal/api", "-run", "Test(ActionResultV2MemoryStoreRoundTrip|ActionResultV2SQLiteRoundTrip|ActionResultV2SurvivesSQLiteReopen|MalformedActionResultV2PersistsFailClosed|MalformedStoredActionResultV2FailsClosed|ExecutorTuplePostDispatchErrorPreservesReceiptPending)", "-count=1"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-11",
"title": "Clean Product Trust browser certification",
"owner": "task-11",
"required_evidence_tier": "browser",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"non_mutating_proof_commands": [
{
"id": "rg-11-product-trust-playwright",
"argv": ["env", "PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173", "tests/integration/node_modules/.bin/playwright", "test", "--config=tests/integration/playwright.config.ts", "tests/78-monitor-first-patrol-workbench.spec.ts", "tests/81-actions-inbox.spec.ts", "--project=chromium", "--grep", "APT"]
}
],
"mutation_gated_commands": []
},
{
"id": "RG-12",
"title": "Final-SHA physical-device, live-relay, and aggregate certification",
"owner": "task-12",
"required_evidence_tier": "live-relay",
"requires_triple_zero": true,
"allows_ancestor_provenance": false,
"artifact_contract": "physical-live-relay-v1",
"non_mutating_proof_commands": [
{
"id": "rg-12-aggregate-verdict",
"argv": ["python3", "scripts/release_control/pulse_intelligence_gate.py", "--check", "--matrix", "docs/release-control/v6/internal/pulse-intelligence-release-gate.json", "--sha", "<full-release-sha>", "--evidence", "<external-evidence.json>"]
}
],
"mutation_gated_commands": []
}
]
}

View file

@ -0,0 +1,180 @@
# Commercial Offer And Lifecycle Contract
Date: 2026-07-14
Owner: project owner
Status: approved for implementation
## Decision
Pulse commercial packaging and lifecycle behavior must converge on one
versioned offer contract. The public site, Pulse Account, in-product plan
surfaces, checkout projection, Stripe catalog audit, license-server billing
state, runtime entitlements, and support policy must not maintain independent
commercial definitions.
## Self-Hosted Offer
1. Community is the free local monitoring foundation with 7-day history.
2. Relay is the secure remote-access, Pulse Mobile pairing, push-delivery, and
14-day-history service.
3. Pro is the Patrol-powered investigation and governed-operations product
with 90-day history and team/admin controls. Pro includes Relay; a buyer
never needs simultaneous Relay and Pro subscriptions for one environment.
4. Community, Relay, and Pro solve distinct jobs. They must not be presented
as a recommended good/better/best ladder.
5. Ordinary self-hosted trial acquisition remains retired.
## License Scope And Support
1. One Relay or Pro subscription covers one owner-operated Pulse environment.
2. Monitored-system and child-resource volume is not metered for self-hosted
Community, Relay, or Pro.
3. A paid subscription permits three concurrent activations inside that one
environment for primary, migration, and recovery use. It does not license
independently operated client environments; that is the MSP product job.
4. Verified administrative ownership transfer is permitted. Resale, sharing,
and unverified third-party assignment are prohibited.
5. Relay and Pro include standard verified commercial support for billing,
activation, transfer, configuration, and diagnostics. The public target is
typically within two business days, without a contractual SLA or a
priority-support promise.
## Subscription Transition Matrix
| From | To | Effective time | Billing treatment | Entitlement treatment |
|---|---|---|---|---|
| Community | Relay or Pro | After successful checkout | New subscription | Grant the purchased plan atomically |
| Relay | Pro, same cadence | Immediate after explicit quote and successful prorated payment | Prorated upgrade | Pro plus bundled Relay |
| Monthly | Annual, same tier | Immediate after explicit quote and successful prorated payment | Prorated cadence change | Tier unchanged |
| Pro | Relay | Current paid period end | No proration | Remove Pro-only capabilities; retain Relay |
| Annual | Monthly, same tier | Current paid period end | No proration | Tier unchanged |
| Any paid plan | Community by cancellation | Current paid period end | No proration | Paid capabilities end at the paid-through timestamp |
Combined transitions follow the most restrictive rule. Any transition that
reduces capabilities or moves annual to monthly takes effect at renewal.
Customer-visible confirmation must state the effective date and quoted charge
or credit before mutation.
## Cancellation, Grace, And Continuity
1. Voluntary cancellation is scheduled for period end. Paid capabilities end
at the paid-through timestamp.
2. A seven-day post-term recovery window may support subscription reactivation,
license retrieval, and account recovery, but it is not a paid-entitlement
extension and must not grant Relay or Pro capabilities.
3. Involuntary payment failure receives a separate seven-day functional grace
period. Existing paid capabilities remain available during that grace, then
fail closed if payment has not recovered.
4. Reversing scheduled cancellation before the paid-through timestamp
preserves subscription continuity.
5. Grandfathered recurring price continuity survives only while the original
recurring subscription remains continuous. Completed cancellation or a
completed tier/cadence change exits the grandfathered contract; later
re-entry uses current pricing.
6. A full refund or dispute revokes the affected paid entitlement and requires
explicit reactivation or repurchase.
## Downgrade Preservation
1. Configuration, report definitions, and audit records are not deleted solely
because a customer downgrades.
2. Pro-only configuration remains stored but inert while the entitlement is
absent.
3. History and generated artifacts outside the lower tier's active window are
soft-hidden for 30 days and become purge-eligible after 60 days.
4. Re-upgrade during the soft-hide window restores access without
reconfiguration.
5. Every capability, tier, cadence, restrictive state, or entitlement change
increments `license_version` and invalidates obsolete grants atomically.
## Cloud And MSP Availability
1. Cloud is unavailable. Its historical prices, caps, trial, and support labels
are dormant proposals, not a current customer offer. Cloud cannot reopen
until card policy, economic unit/caps, support, retention, export,
cancellation, reactivation, and runtime enforcement pass a governed
readiness gate.
2. MSP is an assisted preview and provider-hosted by default. Starter, Growth,
and Scale retain 5, 15, and 40 isolated client-workspace limits at the
recorded monthly and annual prices. Enterprise remains custom.
3. Public MSP copy may publish the recorded monthly and annual prices, but it
must not promise immediate self-service checkout or Pulse-hosted fulfillment.
4. Pulse-hosted MSP remains an optional assisted arrangement, not the default
public delivery model.
## Implementation Boundary
Stripe remains billing truth, but one Pulse-owned transition authority must
apply the authoritative Stripe snapshot atomically to billing contract,
entitlement projection, continuity epoch, transition history, license version,
and grant-revocation outbox state. Checkout, webhook, reconciliation, refunds,
support/admin actions, and future Pulse Account transitions must converge on
that authority. Stripe Customer Portal subscription updates remain disabled;
Pulse-owned plan transitions must not depend on mutable portal configuration.
## Proof Required Before Closure
1. Contract drift proof across public pricing, Pulse Account, in-product plan
presentation, Stripe product/price descriptions, support policy, and runtime
entitlements.
2. Complete Community/Relay/Pro and monthly/annual transition matrix proof.
3. Stripe test-mode payment, proration, schedule, cancellation, payment-failure,
refund, and re-entry proof.
4. Duplicate, reversed, missing, and replayed webhook convergence proof.
5. Restrictive-transition grant version-floor proof in Pulse and Relay.
6. Downgrade soft-hide, restoration, purge-eligibility, and configuration
preservation proof.
7. Desktop and phone-width buyer/account browser proof.
8. A read-only production Stripe catalog and portal expected-state audit before
any separately approved external configuration change.
## Implemented Local Evidence
The 2026-07-14 implementation slice now provides the local, non-transactional
foundation required by this contract:
1. `pulse-pro/license-server/v6_commercial_projection.go` owns atomic catalog,
billing-contract, entitlement, continuity-epoch, license-version, transition
history, and revocation-outbox projection. Unknown or multi-price snapshots
fail closed, and checkout, subscription, invoice, cancellation, payment-
failure, and refund paths converge on that projection.
2. `pulse-pro/license-server/v6_commercial_transitions.go` owns authenticated,
durable transition quotes. Immediate expansion uses the exact quoted Stripe
proration timestamp with `pending_if_incomplete`; restrictive changes use a
renewal-bound subscription schedule without proration. Retry paths reuse
Stripe idempotency keys and already-created schedules.
3. `pulse-pro/landing-page/manage.html` exposes invoices/payment methods,
quoted Relay/Pro and cadence changes, period-end cancellation, reactivation,
and scheduled-change cancellation behind an emailed verification code.
4. `pkg/licensing/subscription_transitions.go`, `pkg/metrics/store.go`, and
`internal/api/report_schedules.go` persist downgrade timing, keep protected
configuration and report definitions, restrict out-of-tier access
immediately, delay physical history cleanup until day 60, block background
report execution without the current entitlement, and purge generated
report artifacts without following symlinks after eligibility.
5. Local proof is green for the complete license-server Go suite, 359 Python
pricing/copy tests, the Pulse licensing/metrics/API suites, and desktop plus
390-pixel buyer/account browser exercise. These are local implementation and
rehearsal facts, not a substitute for the required Stripe test-mode and
production read-only evidence.
6. `pulse-pro/relay-server` now requires the operator revocation authority,
synchronously drains it before serving, reports stale feed state through
readiness, disconnects already-connected v6 sessions below a newly applied
license-version floor, and clears their persisted reconnect credentials.
The joined Relay proof covers feed consumption through active-session
teardown. License-server startup also fails closed when v6 is enabled
without the matching feed credential.
## Remaining Readiness Boundary
The `self-hosted-commercial-transition-coherence` gate remains blocked. No
Stripe test-mode mutation, live catalog/configuration change, purchase,
deployment, or production customer-data access was authorized or performed in
this slice. Before self-service is released, the project still needs the
governed external transition matrix, event-order/reconciliation exercise,
read-only production catalog/portal audit, and a customer-safe Pulse runtime
invalidation path named above. The global operator feed credential must never
be distributed to customer Pulse installations; Pulse convergence requires an
installation-scoped authenticated feed or an equivalently bounded canonical
authority. The local Relay proof raises confidence but does not replace the
required real external Stripe-to-Relay exercise.

View file

@ -0,0 +1,68 @@
# Pulse Mobile governed action parity — 2026-07-13
## Outcome
Pulse Mobile now preserves the canonical governed-action boundary instead of
compressing permission and execution into one approval gesture. Pending plans
are reviewed and decided first. An approved plan remains visible as active work
until the operator chooses **Run action** and passes a second local
authentication gate. Denial remains non-executing.
## Identity and transport safety
- Mobile reads the durable action inbox, including approved-but-not-run and
executing actions, rather than the decision-only compatibility queue.
- The displayed plan identity is projected from the canonical `planHash` and is
required locally before approve, deny, or execute can reach the API.
- Decision and execution requests carry that plan identity. Core rejects a
presented mismatch with `action_plan_identity_mismatch` before decision
persistence or executor dispatch while preserving compatibility for clients
that do not yet present the optional transport field.
- The app no longer models or renders command-shaped approval data. Review is
based on typed capability, target, preflight, blast radius, safety, rollback,
verification, and plan identity facts.
## Operator truth
- Approval policy and operational risk remain distinct facts. Admin, operator,
MFA, no-approval, and dry-run policies are no longer rendered as unknown risk.
- Approval copy states that permission does not run the action. Execution copy
states that the approved plan will run now and produces a verification result.
- Active counts and cross-source routing include both pending decisions and
approved plans ready to run, so reload or source switching cannot hide the
second step.
## Revoked access recovery
Terminal Relay authorization errors and proxied HTTP 401 responses still fail
closed by removing rejected credentials and instance-scoped state. They now
also persist a bounded recovery explanation containing the affected source
name, entitlement-aware guidance when applicable, and a **Pair Again** action.
The explanation survives restart and appears above the main tabs until the
operator acts or dismisses it; repeated terminal signals cannot overwrite the
better source-specific record after credentials have already been removed.
## Proof
- Core action handler tests prove mismatched client plan identities cannot
record decisions or reach an executor.
- Pulse Mobile typecheck passes.
- All Pulse Mobile application tests pass, including plan projection, separate
decision/execution API calls, truthful posture, active-inbox projection,
second-step recovery, and durable revoked-access recovery.
- Release-script tests pass and pin separate decision and execution gestures in
the iOS and Android native proof drivers.
- The focused Release-arm64 iOS simulator proof executed one XCUITest with zero
failures in 74.381 seconds. It observed the approval-recorded/not-run state,
the distinct **Run action** control, the completed execution result, and the
denial path. The result bundle and summary are retained locally under
`pulse-mobile/artifacts/proof/mobile-approval-parity-2026-07-13-rerun/`.
- The physical-device readiness probe passes against an unlocked iPad running
iOS 26.5.2 with Developer Mode and developer disk-image services available.
## Residual
This closes the governed-action parity slice. The broader
`lane-followup:mobile-post-rc-hardening` remains planned because public store
publication is a separate human-triggered release operation and future post-GA
hardening remains deliberately open.

View file

@ -133,6 +133,13 @@ loop, and routes the operator from real findings/evidence to safe action.
evidence, next step, and verification state from existing finding, approval,
and workflow fields. The scaffold stays attached to the issue row and does
not create a separate proof, trust, or status strip.
- 2026-07-13: Replaced the always-visible seven-field scaffold with a compact
prioritisation queue and one focused selected-issue review. Collapsed rows
now keep severity, workflow state, title, resource, recency, and one
consequence visible; evidence, recommendation, approvals, verification,
history, and manual controls move into review. Active findings may group only
through API-owned same-resource, shared-node, or explicit-correlation facts,
and the duplicate count badge beside Open work was removed.
- 2026-06-30: Added Proxmox overview monitor-context Patrol coverage posture.
It appears only when Patrol has no current work, failed/latest check,
running check, setup failure, overdue schedule, or pending approval; uses

View file

@ -0,0 +1,41 @@
# Proxmox guest lifecycle Patrol loop — 2026-07-13
## Outcome
Pulse now has a production Patrol detector-to-verified-fix path for Proxmox VM
and LXC lifecycle incidents. A fresh running-to-stopped transition creates a
bounded reliability finding only when the stopped resource advertises its exact
governed `start` capability. The normal shared action lifecycle then plans,
asks for approval, dispatches through the owning node agent, records the audit,
and reconciles the finding from canonical verification truth.
## Safety floor
- First observations seed a baseline; guests already stopped at startup do not
become incidents.
- Stale, missing, repeated-clock, unknown, template, locked, and capability-less
observations do not emit or clear findings.
- VM and LXC routes require their exact resource-owned lifecycle handlers.
- Finding evidence contains only bounded Proxmox identity/state facts and no
command, path, credential, token, or raw provider output.
- Execution success stays separate from postcondition truth. `fix_verified`
requires a confirmed independent Proxmox control-plane observation.
## Proof
- `internal/ai/findings_proxmox_lifecycle_test.go` proves VM/LXC transitions,
baseline behavior, freshness/capability/guest guardrails, recovery/removal,
and production Patrol wiring before the provider gate.
- `internal/unifiedresources/views_test.go` proves detached lifecycle
capabilities and canonical per-source freshness on VM/LXC typed views.
- `internal/api/proxmox_patrol_integration_test.go` proves the full finding,
exact `start {}` proposal, human decision, `qm start` dispatch, independent
Proxmox API verification, durable action audit, terminal notification, and
originating finding reconciliation to `fix_verified`.
## Residual
This closes the missing Proxmox lifecycle detector floor within the broader
Monitor-First Patrol Workbench candidate. It does not claim that whole candidate
lane complete. Mobile approval presentation and decision/execution parity
remain governed separately by `lane-followup:mobile-post-rc-hardening`.

View file

@ -0,0 +1,29 @@
# Pulse Intelligence RG-01 through RG-12 external E2E record
Date: 2026-07-12
## Verdict
The closed RG-01 through RG-12 matrix reached independent GO for bounded limited autonomy at these exact implementation revisions:
- Pulse core: `a63b3eae2b5a62ee6803bfb4eee8fadbbba8e449`
- Pulse Mobile: `a1ddb451618664025d22a986fbd3fd837e4ffe97`
The proof set covers exact-revision unit and integration checks, Docker and Debian/Ubuntu Colima journeys, a clean Product Trust browser exercise, and physical-iPad behavior against the live Relay service. The physical exercise covered fresh pairing, notification registration, encrypted reconnect, canonical inert action detail, approval and denial, exact-token revocation, fail-closed reconnect behavior, and full temporary-state cleanup.
## Sealed evidence
- Physical iPad/live Relay artifact: `/Volumes/Development/.pulse-proof-artifacts/rg12-a63b3eae-a1ddb451-final-20260712T234230Z`
- Artifact `SHA256SUMS` SHA-256: `58778244cb370be4bf7bd74a8316a5bf4e8ec4dca23f08e45c5bc8cfec152397`
- Aggregate evidence: `/Volumes/Development/.pulse-proof-artifacts/pulse-intelligence-evidence-a63b3eae2b5a62ee6803bfb4eee8fadbbba8e449-final.json`
- Aggregate evidence SHA-256: `9992148c61420e527dab47fc33a49aecb4d757fe2030a40bff6656328566fa37`
The sealed artifact manifest verifies every included file. The aggregate evaluator binds the records to the audited core revision, requires the physical artifact descriptor, and validates its manifest, implementation revisions, inert action semantics, revocation barrier, cleanup state, physical test summaries, screenshots, and underlying result-bundle digests.
## Trust boundary
The approved and denied actions used canonical `target_type=test` and `executor=none`; neither action executed. The final action state had zero pending actions, dispatch attempts, outbox entries, and receipts. After the exact Relay token was revoked, protected requests returned HTTP 401 and the negative barrier remained canonical triple-zero: zero persistence writes, zero dispatch attempts, and zero external mutations.
Temporary Relay instance, device-token, connection-log, local action, audit, attempt, outbox, receipt, and token state were removed. The original Relay instance remained present. The iPad finished unpaired at onboarding, the environment was restored, and Colima was stopped.
Agent-executed RG06 and RG09 product outcomes remain `fix_verification_unknown`; independent Colima-control observations do not upgrade product verification. This GO certifies only the proved bounded capability set. It does not certify arbitrary infrastructure mutation, general MSP-scale autonomy, agent-native discovery, or operable onboarding and import planning.

View file

@ -0,0 +1,74 @@
# Self-Hosted Commercial Transition Production Audit Blocked Record
- Date: `2026-07-14`
- Gate: `self-hosted-commercial-transition-coherence`
- Result: `blocked`
- Proof posture: read-only production observation
## Scope And Safety
The production license runtime and Stripe commercial state were inspected
without changing application files, deployment configuration, Stripe objects,
subscriptions, or customer records. The Stripe audit performed only HTTP GET
requests. Secret values remained inside the production host process and were
not printed, copied into the workspace, or recorded in this evidence.
The executed proof used:
1. `bash ./scripts/validate_license_runtime_config.sh`
2. the committed `pulse-pro:scripts/validate_stripe_catalog.py` streamed to the
production license host through the repo-pinned SSH helper
The SSH helper first required a canonical trust fix: DNS resolution returned
the Tailscale FQDN while the repository intentionally pins the logical
`pulse-license` host identity. `pulse-pro` commit `d5f73b1` adds the explicit
`HostKeyAlias=pulse-license` binding while retaining batch mode, strict host-key
checking, the checked-in ED25519 pin, and no global known-host fallback.
## Observed Facts
1. Production runtime-file validation passed. The remote secrets file,
entrypoint, and binary were present and syntactically valid, and the
entrypoint's validation-only path exited successfully.
2. The production Stripe audit resolved all 25 governed price objects.
3. The production runtime environment did not provide a valid
`STRIPE_BILLING_PORTAL_CONFIGURATION_ID`. The auditor therefore did not
retrieve or certify a billing portal configuration.
4. Both public Relay prices still resolve to a Stripe product description that
describes remote access, mobile, push, and a custom URL but does not state
the governed one-owner-operated-environment boundary.
5. Both public Pro prices still resolve to a Stripe product description that
describes the earlier AI/auto-fix, history, RBAC, audit, and SAML framing but
does not state the current Patrol job, the one-owner-operated-environment
boundary, or that Pro includes Relay and Pulse Mobile.
6. The two legacy v1 recurring prices remain inactive. The auditor reported
these as the already-governed non-blocking legacy warnings.
7. No production customer, invoice, subscription, payment method, or license
record was read during this proof.
## Inference
The successful validation-only startup alongside the missing portal
configuration indicates that the deployed entrypoint/config contract has not
yet converged on the repo-owned fail-closed portal requirement. This is an
inference from observed behavior; the remote file contents were not copied or
inspected.
## Blocking Verdict
The read-only production audit is now exercised, but it failed expected state.
The gate must remain blocked. Passing local tests and resolving all price IDs do
not compensate for a missing governed portal configuration or public Stripe
product descriptions that contradict the canonical offer.
## Required Unblock Steps
1. Under a separately approved deployment/configuration change, deploy the
fail-closed runtime contract and configure the explicit governed Stripe
Customer Portal configuration.
2. Under a separately approved Stripe catalog change, align the public Relay
and Pro product descriptions with the canonical offer and Pro bundle.
3. Re-run the same GET-only production audit and require zero blocking errors.
4. Separately complete the Stripe test-mode transition/event-reconciliation
matrix and the real Relay/license-version-floor exercise. This production
catalog observation does not replace either end-to-end proof.

View file

@ -0,0 +1,78 @@
# Self-Hosted Commercial Production Remediation Record
- Date: `2026-07-15`
- Gate: `self-hosted-commercial-transition-coherence`
- Result: `production catalog and portal residual passed`
- Evidence tier: `production-observed`
## Approved Scope
The user approved the exact production plan after a GET-only preview. The
bounded apply was limited to:
1. aligning the existing public Pulse Relay and Pulse Pro Stripe product
descriptions with the canonical offer;
2. creating a dedicated Customer Portal configuration for invoice history and
payment-method updates only; and
3. deploying that configuration identifier to the license runtime before a
read-only re-audit.
No price, customer, subscription, webhook, or license object was changed. No
customer, subscription, webhook, or license record was read.
## Stripe Result
The guarded `pulse-pro:scripts/remediate_stripe_commercial_state.py` apply
converged these existing products:
- `prod_U37aaHIl0MtpS5` (`Pulse Relay`): `Secure remote access and Pulse Mobile
connectivity for one owner-operated Pulse environment.`
- `prod_U37aCdMDY0V6cK` (`Pulse Pro`): `Patrol-powered operations for one
owner-operated Pulse environment, including Relay connectivity and Pulse
Mobile.`
Stripe created dedicated portal configuration
`bpc_1TtNsxBrHBocJIGHTjRvG4Qf`, named `Pulse self-hosted invoices and payment
methods`. It enables `invoice_history` and `payment_method_update`. It disables
`customer_update`, `subscription_cancel`, `subscription_update`, and the hosted
`login_page`.
## Runtime Deployment And Recovery
The successful deployment backed up the production environment to
`/etc/pulse-license/secrets.env.bak.stripe-portal.20260715T081138Z`, added the
portal identifier exactly once as `STRIPE_BILLING_PORTAL_CONFIGURATION_ID`, and
restarted `pulse-license`. Loopback and public `/healthz` returned
`{"status":"ok"}`.
An earlier atomic-writer attempt created
`/etc/pulse-license/secrets.env.bak.stripe-portal.20260715T081025Z` but failed
its key-count postcondition after writing escaped line separators. The command
stopped before service restart, so the malformed configuration was never
loaded. The file was restored from that backup, its original 73-line structure
and shell syntax were verified, and service health was confirmed before the
successful deployment. The first public probe during the intentional recovery
restart returned a transient `502`; loopback and public health passed after the
service became ready.
## Read-Only Postconditions
The post-deploy validator reported:
- `ok: true`;
- `checked_prices: 25`;
- `portal_configuration_checked: true`;
- zero errors;
- the same four governed public Relay and Pro checkout price IDs; and
- only the two already-governed inactive v1 legacy recurring warnings.
A second default remediation plan returned `product_updates: []` and portal
action `reuse` for `bpc_1TtNsxBrHBocJIGHTjRvG4Qf`. The production license
runtime configuration validator also passed.
## Gate Verdict
The production catalog and portal failure recorded on `2026-07-14` is closed.
The release gate remains blocked: a passing production catalog observation does
not replace the governed external Stripe lifecycle transition/event-
reconciliation matrix or the real Relay license-version-floor exercise.

View file

@ -0,0 +1,57 @@
# Self-Hosted Commercial Stripe Remediation Prepared Record
- Date: `2026-07-14`
- Gate: `self-hosted-commercial-transition-coherence`
- Result: `repository preparation complete; production remains blocked`
- Evidence tier: `test-proof`
## Fact
The repository now contains a bounded Stripe commercial-state remediation tool
at `pulse-pro:scripts/remediate_stripe_commercial_state.py`. Its default mode
performs only GET requests and emits a machine-readable plan. Apply mode
requires the exact `APPLY_STRIPE_COMMERCIAL_STATE` confirmation and a Stripe
key whose live/test mode matches the operator's declared expected mode.
The tool derives the public Relay and Pro price IDs from the public pricing
model, validates their amount, cadence, activity, product identity, shared-
product shape, and Stripe mode, then plans at most these operations:
1. align the descriptions of the existing public Pulse Relay and Pulse Pro
products with the canonical offer;
2. reuse an exact Pulse-owned invoice/payment-method-only Customer Portal
configuration, or create a new dedicated one if none exists.
It does not contain an apply path for prices, customers, subscriptions,
webhooks, licenses, or runtime configuration. A nonconforming or unrelated
portal configuration is not modified. The dormant new-environment price
creation helper now carries the same canonical Relay and Pro descriptions.
## Offline Proof
`pulse-pro:scripts/tests/test_remediate_stripe_commercial_state.py` proves:
- monthly and annual prices must share one product per tier;
- Relay and Pro must use distinct products;
- catalog or mode drift fails before mutation;
- shared product updates are deduplicated;
- only an exact dedicated portal configuration is reused;
- a nonconforming portal causes safe dedicated creation rather than mutation;
- portal lifecycle mutation features are disabled;
- apply requires the exact confirmation token; and
- the bounded apply path posts only product descriptions and, when required,
a dedicated portal configuration.
`pulse-pro:scripts/tests/test_create_v6_stripe_prices.py` proves the dormant
creation helper carries the governed descriptions and still excludes Pro+ from
public creation.
## Non-Claim And Remaining Gate
No Stripe mutation, customer-data access, runtime configuration change,
deployment, or production re-audit was performed in this slice. This is not
production proof and does not raise the gate above `test-proof`. The production
blocking record remains authoritative until a separately approved apply and
deployment are followed by a passing GET-only audit. The external Stripe
transition/reconciliation matrix and Relay license-version-floor exercise also
remain required.

View file

@ -0,0 +1,160 @@
# Single-Build Release Promotion Path - 2026-07-09
## Scope
Make the normal RC, stable, and patch release path one unattended workflow
whose public promotion and definitive verdict complete materially faster than
the v6.0.5 path without weakening release gates.
## v6.0.5 Timing Baseline
- The successful public release run `29022145812` started at
`2026-07-09T13:38:55Z` and did not finish its awaited private-runtime work
until `2026-07-09T15:45:54Z`.
- Pre-publication checks consumed approximately 36 minutes because integration
tests waited for the 20-minute backend suite.
- `create_release` then consumed another 21 minutes. Its signed release build
alone took 18 minutes and 44 seconds even though the same SHA had already
completed release checks.
- Post-publication asset validation consumed 22 minutes and 29 seconds because
it downloaded the complete 213-asset, multi-gigabyte release packet.
- Private Pro build and promotion consumed 46 minutes and 35 seconds, but did
not start until release-asset validation finished.
- Stable patch operation also required a separate 37-minute exact-SHA dry run,
making the safe operator path additive rather than a single release run.
## Canonical Correction
1. `.github/workflows/build-release-candidate.yml` builds and locally validates
one signed candidate for the exact pushed SHA. Required publication runs
first produce Developer ID/notarized macOS agents and Authenticode-signed
Windows agents in parallel, then assemble those exact bytes into the
candidate. It emits a one-day candidate artifact plus a small manifest
containing version, tag, source SHA, filenames, sizes, and SHA-256 values.
2. Candidate construction runs in parallel with frontend, backend, Docker,
Helm, and integration checks. Frontend lint/build runs once and provides one
verified bundle to backend and integration jobs. Backend and integration no
longer serialize on each other.
3. `create_release` downloads and verifies the immutable candidate instead of
rebuilding release assets. Tag, draft, and publication mutations remain
downstream of every required check.
4. Standard post-upload validation compares the candidate manifest with
GitHub's server-side release-asset SHA-256 digests and sizes. The legacy
full-download validator remains available for manual repair or release-edit
validation when no same-run manifest exists.
5. Docker publication, candidate-backed release-asset validation, and private
Pro publication start independently after release creation. Floating tags
still require both Docker and asset validation; install smoke, Helm, stable
demo, paid-runtime promotion, and the definitive verdict retain their
applicable safety dependencies.
6. `Release Dry Run` calls the same candidate builder for a no-public-release
rehearsal, including the native platform signing lanes. Normal release
publication does not require a separate dry run because `create-release.yml`
contains the exact-SHA candidate and test gates before publication.
7. Release tarball validation extracts all required entries from each archive
in one pass. The previous per-entry list/type/content checks repeatedly
decompressed multi-gigabyte archives and consumed more than 15 minutes.
8. Native signing configuration now has one cheap fail-fast job that reports
every missing repository secret before macOS or Windows runners are
allocated.
## Timing Contract
Using the v6.0.5 timings as the baseline:
- the public release boundary should normally be reached within 35 minutes of
dispatch rather than after a separate rehearsal plus approximately 58
minutes of checks and rebuilding;
- the definitive cross-product verdict should normally complete within 80
minutes, with the private Pro build as the expected critical path rather than
serial release-asset downloads; and
- operator attention remains limited to preparing the release packet and one
dispatch. Runner queueing or external registry degradation may extend wall
time, but no standard job may reintroduce duplicate release builds, complete
packet downloads, or avoidable serial dependencies.
## Remote Rehearsal Findings
- Run `29051381272` proved that the initial event-name condition incorrectly
skipped the candidate job. The condition now keys off the non-empty manual
`version` input, with a regression contract.
- Run `29051903335` built the exact-SHA signed candidate for 19 minutes and 9
seconds. It then exposed two independent late failures: an unmanaged service
discovery backfill raced temporary-store cleanup in the backend suite, and
candidate validation exceeded the original 35-minute job ceiling. Backfill
shutdown is now cancellable and joined; the focused test passes 50 repeats,
10 complete package repeats, and five race-enabled repeats. The candidate
ceiling is now 60 minutes.
- The same run showed the validator still decompressing each large tarball for
every required entry. The replacement validated 15 real archive entries in
one extraction pass in approximately one second locally.
- Run `29055137616` exercised the actual native-signing graph. macOS failed in
53 seconds because `APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64` was empty;
Windows failed in 38 seconds because
`WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64` was empty. Repository secret
inventory confirmed that none of the required native-signing names exists.
Independently, the complete frontend, backend, binary, container, and
integration preflight passed in 39 minutes and 29 seconds. The chained
no-mutation demo check then passed its Tailscale, SSH, runtime, frontend,
public-health, and browser checks in 1 minute and 8 seconds.
- Run `29055794064` exercised the fail-fast configuration gate on exact SHA
`a3ef1226b7dde7bc661d787fcaddf7256d9fb6b2`. It reported all eight absent
repository secret names in approximately one second and skipped both native
runner jobs and candidate construction.
- The published `v6.0.5` release remains non-draft and non-prerelease with its
original 213 assets and latest asset timestamp `2026-07-09T14:36:37Z`.
`https://demo.pulserelay.pro/api/version` remains stable `6.0.5`, and
`/api/health` remains healthy for monitor, scheduler, and websocket.
## External Owner Action
Add these GitHub Actions repository secrets for `rcourtman/Pulse`, using the
matching Apple Developer and Windows code-signing credentials:
- `APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64`
- `APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD`
- `APPLE_DEVELOPER_ID_APPLICATION_IDENTITY`
- `APPLE_NOTARY_KEY_P8_BASE64`
- `APPLE_NOTARY_KEY_ID`
- `APPLE_NOTARY_ISSUER_ID`
- `WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64`
- `WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD`
After all eight names are configured, rerun `Release Dry Run` for the exact
current `main` SHA. It must pass native signing, build and validate the
candidate, upload both candidate artifacts, pass release checks, complete
no-mutation demo verification, and leave the published `v6.0.5` release and
demo runtime unchanged.
## Current Verdict
Blocked only on the absent native platform signing credentials. Repository-side
orchestration, diagnostics, timeout hardening, archive validation performance,
and late backend-flake containment are implemented and covered by contracts.
## Prerelease Windows Signing Boundary (2026-07-10)
The first `v6.0.6-rc.1` publication attempt proved Apple signing and
notarization credentials are configured, but Windows Authenticode credentials
are not. Pulse Monitoring Ltd submitted the public community project to the
SignPath Foundation open-source programme on 2026-07-10; approval and CI
integration remain externally owned and asynchronous.
RC publication may proceed while that application is pending, provided all of
the following remain true:
- macOS agent binaries are Developer ID signed and notarized;
- Windows agent binaries retain the exact-SHA candidate, checksums, detached
release signatures, and post-publication digest verification;
- the RC release notes state explicitly that Windows binaries are not
Authenticode-signed and may show an unknown-publisher warning; and
- stable publication continues to require successful Windows Authenticode
signing and verification.
The reusable candidate workflow therefore separates the macOS platform-signing
requirement from the Windows Authenticode requirement. `create-release.yml`
requires Windows signing for stable versions and relaxes only that requirement
for recognized prerelease versions. `release-dry-run.yml` continues to require
both platforms so the full stable-promotion path remains rehearsed and the
`single-build-release-promotion-path` release gate remains blocked for stable
release readiness until the external signing path is proven.

View file

@ -0,0 +1,122 @@
# Stable Patch Unattended Release Path - 2026-07-09
## Scope
Establish the durable gate that a routine stable patch requires minutes of
operator attention, one exact-SHA preflight, one publish dispatch, awaited demo
deployment, and one definitive release verdict.
## v6.0.5 Evidence
- Three release runs (`29013413583`, `29016263854`, and `29019195340`) each
spent approximately 37 to 46 minutes before failing integration tests. The
exact candidate SHA had no mandatory release preflight, so the operator paid
that cost inside the public-release operation on every attempt.
- The successful public `v6.0.5` release pipeline run `29022145812` took
approximately two hours. Docker publication and demo deployment were
dispatched asynchronously with `continue-on-error`, so its green result was
not a definitive release verdict.
- Demo update run `29032637236` joined the business tailnet as an ephemeral
`tag:infra` node but used Tailscale `1.42.0`; all 18 `ssh-keyscan` attempts
then timed out without a Tailscale peer-propagation ping or TCP/22 diagnosis.
- The active business-tailnet policy was inspected on 2026-07-09. The OAuth
credential `github-actions-infra` is active with all scopes, `tag:infra` is
owned by `autogroup:admin`, and the ACL explicitly allows `tag:infra`
sources to reach `tag:infra` destinations on TCP 22 and 443. The demo host
is online at its governed Tailscale address and local TCP/22 succeeds. The
external OAuth/tag/ACL configuration is therefore not the failing boundary.
- Post-correction verification run `29043832292` joined the tailnet on commit
`910418c3b2a165ef5cfb136b144e8ccd95b5d264`, but the configured demo target
was absent from the runner peer map for the full bounded wait. The
`demo-stable` environment's `DEMO_SERVER_HOST` secret was refreshed at
`2026-07-09T19:28:27Z`, between that failure and the next run. Repository
commit `f8bbae2f34d087887336c0c4c065d071d016821f` added only identity
diagnostics to the reachability helper; it did not change connection or
authorization behavior. Repository-scoped Tailscale OAuth secrets were
unchanged during this A/B interval.
- Verification-only run `29044914999` then succeeded. Its GitHub-hosted runner
joined the expected tailnet with `tag:infra`, saw the demo peer online and
active, established a direct Tailscale ping and TCP/22 connection, verified
the SSH host identity, and completed runtime, frontend, public-health, and
browser checks. This isolates the external correction to the environment's
demo-target configuration, not OAuth scopes, tags, ACLs, DNS, or `sshd`.
GitHub does not expose the superseded secret value, so the evidence proves
the configuration boundary and change event without claiming a redacted
historical value.
- The workflow exposed no peer-map, Tailscale ping, or TCP/22 evidence. The
operator had to infer a network failure from blind host-key retries, inspect
Tailscale policy separately, and deploy a signed installer over local SSH.
- `https://demo.pulserelay.pro/api/version` reported stable `6.0.5`, and
`/api/health` reported healthy before the workflow correction.
## Avoidable Delay and Intervention Inventory
1. Release tests ran after the operator crossed the publication boundary
instead of as a recent exact-SHA prerequisite.
2. The stable resolver required RC lineage for every normal patch, forcing
routine low-risk fixes through RC ceremony or a misleading hotfix exception.
3. The general release helper prompted interactively for metadata already
derivable from the repository and release packet.
4. Docker publication and demo deployment were detached follow-on dispatches;
the top-level result could not answer whether the release was operational.
5. The demo path used an obsolete Tailscale client without waiting for
eventually consistent peer propagation.
6. Eighteen blind SSH host-key retries consumed about six minutes while hiding
whether the failing layer was tailnet policy, peer visibility, TCP/22, or
`sshd`.
7. Demo recovery required manual SSH and a locally materialized signed
installer after the release workflow had already reported success.
## Repository Correction
- Both demo workflows use the pinned Tailscale GitHub Action v4 client and its
target `ping` readiness input.
- Shared diagnostics distinguish tailnet reachability from TCP/22 and SSH host
key failures without printing credentials.
- `Update Demo Server` supports an awaited `workflow_call` and a no-mutation
verification mode used by `Release Dry Run`.
- The release workflow awaits Docker publication and stable demo deployment,
then emits a terminal `Definitive Release Verdict`.
- Routine stable patch release resolution no longer fabricates RC ceremony,
but it fails closed for documented high-risk runtime changes, an existing
same-version RC, stale rollback lineage, or failed integrated exact-SHA
candidate checks.
- `scripts/trigger-stable-patch.sh` is the noninteractive operator entrypoint.
## End-to-End Rehearsal
- Exact-SHA `Release Dry Run` `29046383139` succeeded for
`0d89be1c915af7bf5980637d9a050e847e74d663`. The 35-minute preflight passed
frontend, backend, release-policy, binary-build, container-build, and
integration checks, then awaited the reusable no-mutation demo workflow.
- The chained demo verification succeeded in 66 seconds. The runner joined
`tawny-powan.ts.net` with `tag:infra`, observed the demo peer online and
active, reached it directly over Tailscale and TCP/22, verified SSH identity,
confirmed runtime version `6.0.5`, checked frontend parity and public health,
and passed the public browser smoke test.
- Mutation-only steps were skipped. Release `v6.0.5` remained published at
`2026-07-09T14:36:38Z` with 213 assets and no asset newer than
`2026-07-09T14:36:37Z`; no new public Pulse release was created.
- Independent post-run checks confirmed
`https://demo.pulserelay.pro/api/version` still reports stable `6.0.5` and
`https://demo.pulserelay.pro/api/health` reports `healthy` with all declared
dependencies healthy.
## Required External Configuration
Keep the `demo-stable` environment's `DEMO_SERVER_HOST` secret set to the
current Tailscale IPv4 assigned to `pulse-relay` in the business-tailnet admin
console. The repository now proves this mapping before SSH and fails with
peer-map, ping, and TCP/22 diagnostics if it drifts. No OAuth, tag-owner, or ACL
change is required for the currently verified `tag:infra` path.
## Current Verdict
Passed. A routine stable patch now uses one noninteractive publish dispatch
whose exact-SHA candidate checks run before publication. `Release Dry Run`
remains available for explicit no-public-release rehearsal. The publish DAG
awaits release promotion, Docker publication, stable demo deployment,
definitive public verification, and its terminal verdict; manual SSH is not
part of the standard path. The release-wide single-build timing contract is
tracked in
`docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md`.

File diff suppressed because it is too large Load diff

View file

@ -31,9 +31,13 @@ that binary, not separate customer-facing agent products.
5. `internal/hostagent/agent.go`
5a. `internal/dockeragent/agent.go`
5b. `internal/kubernetesagent/agent.go`
5c. `internal/agentexec/verifier_postconditions.go`
6. `cmd/pulse-agent/main.go`
7. `scripts/install.sh`
8. `scripts/install.ps1`
8a. `.github/workflows/unified-agent-native.yml`
8b. `scripts/installtests/windows_agent_lifecycle.ps1`
8c. `scripts/installtests/windowslifecycleserver/main.go`
9. `frontend-modern/src/api/agentProfiles.ts`
10. `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`
11. `frontend-modern/src/components/Settings/agentProfileSettings.ts`
@ -97,6 +101,53 @@ that binary, not separate customer-facing agent products.
## Shared Boundaries
Automatic Patrol action admission wired through `internal/api/` is API/action-
lifecycle authority, not agent enrollment or command authority. Agent command
transport may run only after the lifecycle-owned policy lease has been
revalidated and the policy approval plus `executing` transition have committed
atomically; a policy-approved intermediate state is never reusable by an agent.
Human approvals remain separate lifecycle evidence and are not invalidated by
later automatic-policy revocation. Emergency stop blocks admission before
`executing`; cancellation after that boundary is best effort and is not
rollback proof.
Governed action admission also treats requester and decision identity as
server-owned lifecycle authority. The authenticated session or owner-bound API
token supplies the immutable `ActionActor`, and the captured
`ApprovalRequirement` is bound into the request, plan identity, and plan hash.
The plan also carries the unified-resource-owned, versioned
`ActionPolicyDecisionProvenance`. It binds the bounded policy authorities
actually consulted, their source revisions and scope, the resulting approval
requirement, and stable reason codes into the plan hash and immutable audit.
This is descriptive plan-time evidence for pending-action consumers, not a
decision or dispatch token. Lifecycle freshness may detect provenance drift,
but execution still requires current actor/RBAC/scope checks and the Task 04
authorization lease. Legacy rows without provenance remain explicit
`legacy_unknown` and cannot resume nonterminal authority by implication.
Each accepted approval increments a durable decision revision through an exact
prior-prefix CAS, appends one typed decision fact, and atomically appends the
approved or rejected transition only when state changes. Exact replay appends
no event, while conflicting replay and legacy nonterminal records without actor
or requirement bindings fail closed as replan-required. MFA remains unavailable
and fail-closed unless the lifecycle boundary verifies action-bound
cryptographic step-up evidence through its configured verifier; session,
API-token, relay, and local-biometric labels alone are never MFA proof.
Assistant transport scopes do not grant agent command authority. `ai:chat`
and `relay:mobile:access` remain conversation/read/session scopes; an
interactive infrastructure invocation must carry server-bound `ai:execute`
authority and still satisfy the separate agent execution/token-binding and
control-level gates. Unknown or absent request scope never falls back to agent
execution permission.
Commercial v5-to-v6 migration retry state may pass through `internal/api/`
handlers that agent-lifecycle also references, but the ownership remains
API/cloud-paid. Agent lifecycle surfaces may observe paid-migration posture for
upgrade continuity, but they must not reinterpret `commercial_migration`
reasons, reset `first_failed_at`, change the license-server retry cadence, or
turn blocked license egress into an agent update or enrollment state.
`/api/connections` command-policy comparison is lifecycle-adjacent fleet
truth. The desired side is the effective runtime config served to the agent
after token scope and binding checks, not the unsanitized profile desire. If a
@ -119,6 +170,12 @@ percent because that is the collector's source evidence. Lifecycle surfaces may
transport that report, but must not reinterpret it as host-capacity utilization;
monitoring and unified resources own the normalized CPU contract used by
history, alerts, and canonical app-container metrics.
The same module authors the container OOM evidence boundary from Docker inspect.
Current agents must serialize a non-null `OOMKilled` boolean for inspected
containers, preserving explicit false; absence is reserved for older or
reduced-fidelity report producers. Lifecycle transport must not synthesize OOM
state from exit code 137 or collapse absent and false, because monitoring and
alerts depend on that distinction to fail closed.
Inside-guest Docker / Podman visibility is also a privacy boundary: full
Docker / Podman inventory may come from a guest-local agent or another explicit
guest reporting path. LXC Docker inventory may also come from the Proxmox host
@ -144,7 +201,25 @@ Agent-facing operations-loop status wiring in `internal/api/router.go` and
shares agent route infrastructure. Other handlers in `internal/api/` such as
the AI settings handler (`ai_handlers.go`) carry AI provider configuration
(for example per-provider base URL overrides) that is ai-runtime config-surface
and is not agent enrollment, liveness, or lifecycle state. Workflow starter counts on that endpoint,
and is not agent enrollment, liveness, or lifecycle state. The Pro update
credential wiring in `internal/api/router.go` (feeding the activation's
installation token and instance fingerprint to the server updater's
download-broker path) is likewise server self-update plumbing: agent
enrollment, agent update liveness, and fleet-control semantics must not key
off it. The same boundary covers the server updater's deployment adapters
behind `GET /api/updates/plan` in `internal/api/updates.go`: they are plan
providers only, with the real apply in the `internal/updates/manager.go`
pipeline, and lifecycle surfaces must not read them as an agent-side apply,
update, or rollback transport. The server updater's own downgrade guard and
`POST /api/updates/rollback` backup-restore endpoint are equally server
self-update plumbing: they roll the Pulse server binary and its local
backups, never agent binaries, and agent lifecycle surfaces must not key
enrollment, update liveness, or fleet-control semantics off them.
The authenticated `GET /api/updates/release-notes` projection follows the same
server-only boundary: it reads the exact published notes for the running Pulse
server release and must not be used as agent version, enrollment, update
availability, or fleet rollout evidence.
Workflow starter counts on that endpoint,
contextual Assistant/external-agent collaboration counts inside the Assistant
step, the content-free Patrol control starter split, and Patrol control
completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to
@ -178,6 +253,11 @@ particular agent update, profile rollout, host command, registration, or fleet
operation succeeded. Lifecycle surfaces must keep reading update readiness and
continuity from the updater, installer, connection ledger, and agent runtime
state instead of inferring it from outbound usage telemetry.
Scheduled-report route and background-worker wiring in `internal/api/router.go`
and the reporting handlers is API/reporting ownership, not agent lifecycle.
The scheduler may enumerate tenant organization IDs so each workspace can run
its own reports, but that enumeration is not agent enrollment, install,
update, profile rollout, command reachability, or fleet-control authority.
1. `frontend-modern/src/api/agentProfiles.ts` shared with `api-contracts`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary.
2. `frontend-modern/src/api/nodes.ts` shared with `api-contracts`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary.
@ -229,7 +309,8 @@ state instead of inferring it from outbound usage telemetry.
completed lifecycle registration.
22. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
23. `internal/kubernetesagent/agent.go` shared with `monitoring`: the Kubernetes native agent runtime is both a monitoring inventory source and an agent lifecycle Pulse control-plane transport client.
24. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
24. `pkg/agents/host/report.go` shared with `monitoring`: the Unified Agent host report is both an agent lifecycle authored-state contract and a monitoring ingest contract for host maintenance posture.
25. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
The Windows installer must support a non-mutating download preflight that
can run before Administrator-only install work, must accept token-file
enrollment input, and must persist plain-HTTP/insecure runtime continuity
@ -237,8 +318,30 @@ state instead of inferring it from outbound usage telemetry.
also expose the same local health/readiness server as foreground
`pulse-agent` runs so installer "healthy" verification and post-install
smoke checks prove a live agent runtime, not merely a running service
wrapper.
25. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
wrapper. The service must pass the installer-owned ProgramData log path to
the agent's canonical rotating file sink, and install success requires both
`/readyz` and a non-empty log file. SCM recovery must be configured as a
required lifecycle contract, including non-crash failures, rather than a
best-effort warning. Native Windows proof must exercise preflight, install,
version replacement, logged readiness, forced-process recovery, service
restart or OS reboot persistence, and complete uninstall cleanup through
the reusable lifecycle harness under `scripts/installtests/`.
26. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
Legacy update recovery is cross-platform lifecycle continuity. Linux may
read procfs or a systemd unit, while FreeBSD and pfSense must recover the
same URL, token, feature, identity, and trust arguments from the live
process via `ps`/`procstat` or from the installed rc.d service script.
Successful recovery always migrates the raw legacy token into the v6
installer-owned token file; absence of complete local URL and token state
remains a fail-closed update, never a fresh enrollment.
FreeBSD-family uninstall must stop the installer-owned rc.d supervisor
before removing the agent binary so daemon(8) cannot restart a deleted
child. It must also remove the rc.d script, pfSense boot wrapper,
`pulse_agent_enable` rc.conf state, supervisor and child PID files, runtime
token/state directory, and residual agent process before reporting
success. `scripts/installtests/install_sh_test.go` owns the static teardown
contract, and native FreeBSD rehearsal must prove clean install, update,
reboot persistence, and complete uninstall.
Server update planning is part of the same lifecycle contract. The System
Updates plan must surface a structured upgrade-readiness verdict before an
@ -296,7 +399,8 @@ fleet projection used by Infrastructure.
Agent lifecycle and fleet-operation surfaces may consume
`POST /api/actions/plan` for resource capability planning, but the action plan
contract remains API-owned through `internal/api/actions.go` and
contract remains API-owned through `internal/api/actions.go`,
`internal/actionlifecycle/service.go`, and
`internal/actionplanner/planner.go`. Agent lifecycle work must not define a
parallel approval policy, blast-radius model, stale-plan hash, or execution
contract for those resource actions. Successful action plans also belong to
@ -306,7 +410,38 @@ lifecycle evidence, and retry/idempotency handling must not create duplicate
lifecycle events. Approval or rejection decisions for those plans must flow
through `POST /api/actions/{id}/decision`, which records API-owned audit and
lifecycle evidence only; lifecycle surfaces must not treat approval as
implicit command execution or define a parallel execution handoff. Assistant
implicit command execution or define a parallel execution handoff. The
Patrol proposal broker (`internal/api/patrol_action_broker.go`) rides that
same API-owned lifecycle; agent lifecycle surfaces must not consume
Patrol-origin action audits as an agent command grant or lifecycle
execution shortcut, and must not subscribe lifecycle side effects to the
API-owned org-scoped action-transition hook. The investigation
broker may authorize eligible actions under core-owned tenant/resource policy,
but that policy decision does not grant agent lifecycle code a parallel
authorization primitive: all execution still enters through the shared action
lifecycle and its agent executor.
When runtime mock mode projects representative action audits through those
same read routes, the response is explicitly `readOnly` and the rows remain
inspection fixtures only. Agent lifecycle surfaces must remove decision and
execution affordances, must not treat a mock approval as an agent command
grant, and must not infer lifecycle reachability from fixture state.
Those list/detail reads may also attach the canonical unified-resource name and
contract type as a sibling `resource` presentation object. Agent lifecycle
consumers must treat that object as display metadata only: the exact
`request.resourceId` remains the command target, and resource display changes
must not alter the action ID, plan hash, approval binding, dispatch identity, or
durable receipt reconciliation.
The investigation
continuity reconciler in `internal/api/patrol_action_reconciliation.go` is
also API-owned: callbacks only wake an authoritative action-audit re-read,
and missed callbacks recover during investigation reads. Agent lifecycle
surfaces may observe the resulting audit/lifecycle evidence but must not use
callback payloads as command grants or maintain a parallel action state.
The investigation
orchestrator adapter wired here exposes only investigation-specific
execution and listing with no autonomy or command-execution surface, so
agent lifecycle surfaces gain no command-dispatch path through Patrol
investigations. Assistant
handoffs that recover a live Patrol approval by finding ID are still AI/runtime
review context only; agent lifecycle surfaces must not treat that recovered
approval reference as an agent command grant or host-execution shortcut.
@ -326,16 +461,70 @@ the API-owned action audit records `executing` before dispatch and the
terminal execution result afterward. Dry-run-only plans remain planning evidence
only; lifecycle surfaces must not present them as executable, dispatch them
through agent-local command paths, or bypass the API fail-closed execution gate.
Docker / Podman container start, stop, and restart affordances follow that same
boundary: lifecycle UI may consume unified-resource capabilities for enabled or
disabled presentation, but execution must stay inside the API-owned action
executor wired from `internal/api/router.go` and must not shell out, SSH, or call
Docker / Podman from lifecycle-local code. That API-owned executor may resolve
the command WebSocket by Docker source ID or canonical Docker host name and may
mark its vetted container lifecycle dispatch as a trusted agent command after
the API action has entered execution; lifecycle surfaces still consume only the
resource payload, action readiness, and action-audit result rather than issuing
or approving command-agent grants themselves.
Docker / Podman container start, stop, restart, and image-update affordances
follow that same boundary: lifecycle UI may consume unified-resource
capabilities for enabled or disabled presentation, but execution must stay
inside the API-owned action executor wired from `internal/api/router.go` and
must not shell out, SSH, or call Docker / Podman from lifecycle-local code.
That API-owned executor may resolve the command WebSocket by Docker source ID
or canonical Docker host name and may mark its vetted container lifecycle
dispatch as a trusted agent command after the API action has entered
execution; lifecycle surfaces still consume only the resource payload, action
readiness, and action-audit result rather than issuing or approving
command-agent grants themselves.
Docker / Podman container image updates are a fourth typed operation on that
same closed channel, not an extension of the raw command path and not a
revival of the retired queued `update_container` transport.
`docker_container_update` has its own strict codec in
`internal/agentexec/docker_update_codec.go`: the payload carries only the
immutable container id, the runtime, and the exact image reference the plan
was bound to (part of the request digest; no command text, flags, or
user-controlled names), and the result reports phase, replacement-container
identity, image digests, backup identity, and rollback attempt and outcome as
facts, never canonical action truth. The unified agent admits the operation
through the same durable operation-receipt store and bridges execution to the
Docker module's own pull/backup/recreate/verify/rollback implementation via
`internal/hostagent/docker_update.go`; a missing Docker module, a runtime
mismatch, or image drift since planning refuses before any mutation. The
module attests the first mutating step (the backup rename), so failures before
it report an unmutated tree, and restore attempts with their outcome ride the
result so the server-side executor reports declared compensation truth instead
of guessing.
Agent (re)registration on the `internal/agentexec` command server is also the
durable-dispatch recovery trigger: after a successful registration the server
fires a router-installed notifier that re-drives the API-owned
executing-action recovery pass, because a receipt-pending dispatch attempt
can only be reconciled against the agent's durable operation receipt while
that agent is connected. The notifier is observation-only wiring; it grants
no command authority, fires only after token validation admits the agent, and
a rejected or replaced registration must not synthesize receipts or resend
transport.
Host OS package updates follow a stricter adjacent boundary. The Unified Agent
may report a bounded, read-only APT upgrade simulation through
`pkg/agents/host/report.go`, but installation authority is available only via
the closed `host_update` WebSocket operation owned by `internal/agentexec` and
`internal/hostagent/package_updates.go`. That envelope carries an action id and
the exact `install_os_updates` operation only: it has no command, package-name,
reboot, removal, or arbitrary-argument field. The agent owns metadata refresh,
preflight simulation, noninteractive `apt-get upgrade --no-remove` execution,
and the second simulation used as read-after-write evidence. The request carries
the agent-authored expected inventory fingerprint; if metadata refresh changes
that inventory, the agent updates its observed state and refuses installation
so Pulse must re-plan against the widened or changed set. A reboot-required
marker is reported as state, never acted upon by this capability. Generic
agent command execution, Patrol prose, lifecycle UI, and external agents must
not reconstruct or bypass this typed operation.
Host storage-pressure cleanup is a second closed agent operation. The report
may expose only the bounded `apt-package-cache` provider, its reclaimable byte
count, freshness, and a SHA-256 fingerprint; cache entry names and paths remain
agent-local. Execution crosses `host_storage_cleanup` with the exact
`clean_package_cache` operation and expected fingerprint only. The agent owns
the fixed `/var/cache/apt/archives` scan, bounded entry/byte limits, and sole
`apt-get clean` command. Fingerprint drift, unsupported providers, empty cache,
inspection failure, command failure, and unconfirmed reclaimed bytes all fail
closed. The envelope has no command, path, package selector, arbitrary
argument, installed-package removal, or reboot authority.
Proxmox VM and LXC lifecycle affordances follow the same adjacent boundary:
lifecycle and fleet surfaces may consume backend-advertised `start`,
`shutdown`, `reboot`, and `stop` capabilities and typed `actionReadiness`, but
@ -344,6 +533,12 @@ connected Proxmox node command agent and records action audit plus verification.
Lifecycle surfaces must not run `qm` / `pct`, SSH to a node, call Proxmox
mutation APIs, or substitute a guest-local agent to perform VM/LXC lifecycle
control.
The executing node agent's status read remains agent-attested. The closed
postcondition registry in `internal/agentexec/verifier_postconditions.go`
defines start, shutdown, stop, and reboot checks for both `qm` and `pct`, while
the API-owned verifier supplies provider observations. Reboot is not confirmed
from status alone: it requires a distinct Proxmox API observation whose uptime
reset proves that the guest actually restarted.
Disconnected command-agent state is also API-owned readiness: lifecycle
surfaces may reflect missing backend-advertised capabilities, but must not
reconnect, substitute, or directly address an agent to make a stale container
@ -356,6 +551,21 @@ same browser-safe history boundary. Lifecycle surfaces, MCP adapters, and
agents may display the updated title as human navigation metadata, but they
must not treat a renamed title as command authorization, host identity,
enrollment state, capability evidence, or install-token disclosure.
Assistant turn undo through `POST /api/ai/sessions/{id}/undo` (including the
optional expected-prompt guard body used by retry/regenerate) is the same
kind of conversation-history mutation: it rewrites chat transcript state
only. Lifecycle surfaces and agents must not interpret an undone or
regenerated Assistant turn as reverting any lifecycle action, approval, or
agent command the original turn produced; governed action history remains
the only revert authority for infrastructure changes.
Mid-turn steering through `POST /api/ai/sessions/{id}/steer` is likewise
conversation input only: it adds a user message to a running Assistant loop
at a turn boundary. A steering message grants no agent command authority,
cannot approve, deny, or bypass a pending approval, cannot escalate the
running turn's control level or autonomous mode, and must not be
interpreted by lifecycle surfaces as operator authorization for any action
the steered turn subsequently proposes; those proposals still route through
the governed approval and action lifecycle unchanged.
The native Assistant surface-tool inventory at
`GET /api/ai/assistant/surface-tools` is also AI-runtime/API-contract metadata:
lifecycle surfaces may display which Assistant tools are available, but must
@ -570,15 +780,20 @@ frame and the shared `Table` shell from `frontend-primitives`; lifecycle work
must not restore a local `overflow-x-auto` wrapper or a page-local table card
around that table.
The same source-manager landing surface must keep onboarding primary and
compact: `Add infrastructure` remains the obvious action, the setup summary is
one short status strip, and detailed governance/fleet state stays in the
systems table rows or deeper fleet surfaces instead of expanding into duplicate
explanatory bands above the table.
Network discovery is the exception because manual scans are an operator command
whose progress must be observable in the source manager. `InfrastructureSourceManager.tsx`
must keep one explicit Network discovery band above the setup summary that
shows the current enabled/scanning/error/result state, scan scope, last scan
metadata, and review action for discovered candidates before anything is added.
compact: `Add infrastructure` stays in the Connected systems header, connection
posture is one short actionable line, and detailed governance/fleet state stays
in the systems table rows or deeper fleet surfaces instead of expanding into
duplicate explanatory bands above the table. A coverage gap that has an
immediate lifecycle action, such as missing Proxmox host telemetry, must name
that gap and expose the install action rather than appearing only as a count.
Manual discovery remains observable because scans are an operator command, but
it is optional setup rather than the primary connected-systems job.
`InfrastructureSourceManager.tsx` must keep one explicit Discover Proxmox
systems band after the configured-systems ledger that shows the current
enabled/scanning/error/result state, scan scope, last scan metadata, and review
action for discovered candidates before anything is added. When discovery is
off, the landing action must configure discovery instead of presenting a
disabled Run command.
Its copy must describe only the platform APIs represented by the LAN discovery
candidate model, and it must not imply TrueNAS, VMware, Docker, Kubernetes, or
agent command discovery when this source-manager path cannot surface those
@ -662,7 +877,29 @@ investigation-only over agent-reporting resources: it must not alter agent
lifecycle, registration, token binding, reporting contracts, or install-command
identity. The scoped Patrol request carries resource identity only and reuses
the existing Patrol scoped engine, so it adds no agent lifecycle control
surface and no new `internal/api/` lifecycle handler.
surface and no new `internal/api/` lifecycle handler. AI settings projection
changes in `internal/api/ai_handlers.go`, such as the registry's suggested
Patrol quickstart model metadata on `/api/settings/ai`, are likewise
ai-runtime configuration surface: they must not become an agent lifecycle
install-guidance or discovery channel, and agent setup surfaces must not
consume them as lifecycle state.
Local subscription-agent settings in `internal/api/ai_handlers.go` follow the
same boundary. Opting into a locally authenticated Codex or Claude CLI selects
an ai-runtime transport only: it does not install, register, update, authorize,
or grant scopes to a Pulse agent. Pulse agent tokens and lifecycle credentials
must not be projected into the provider child process. Any future design that
distributes subscription CLI state or authentication through an agent requires
a separate agent-lifecycle contract change and verification.
First-run security discovery under shared `internal/api/` is likewise an
adjacent API/security boundary, not an agent-lifecycle discovery surface.
Unauthenticated `/api/security/status` responses must stay on the public tier
and must not disclose bootstrap-token paths, container/runtime identity,
credential state, token diagnostics, or agent URL configuration. Agent setup
may consume those details only after the caller reaches the authenticated or
privileged security-status tier; it must not infer lifecycle readiness from
the intentionally sparse public response.
1. Add or change install-command generation, canonical /api/auto-register behavior, or installer download behavior through the owned `internal/api/` files above.
Canonical `/api/auto-register` auth is split by intent: when the setup-token
@ -696,6 +933,11 @@ surface and no new `internal/api/` lifecycle handler.
lifecycle surfaces may display contact email when supplied by the shared
auth boundary, but they must not reinterpret SSO or Stripe email as the
canonical user identifier for setup, install, or fleet-management actions.
Mobile onboarding payload sanitization in
`internal/api/onboarding_handlers.go` remains API/relay-mobile ownership:
omitting a non-HTTPS `instance_url` from a QR/deep-link is a Pulse web
handoff decision only and must not change install-command URLs,
auto-register behavior, agent connection URLs, or lifecycle admission.
Approved-action tool invocation parsing in `internal/api/router_routes_ai_relay.go`
is not an agent-lifecycle route grammar: lifecycle-adjacent setup or repair
flows that execute governed Pulse tools must inherit
@ -917,6 +1159,42 @@ surface and no new `internal/api/` lifecycle handler.
Patrol run across agentic turns.
2. Add or change update continuity and persisted-version handoff through `internal/agentupdate/`.
3. Add or change runtime-side Unified Agent startup, first-report assembly, and enroll/runtime continuity through `internal/hostagent/`.
Local real-lab journey harnesses under `scripts/intelligence_lab/` belong to
this lifecycle boundary. They must be explicitly authorized, use ignored
artifacts, bind every mutation to an exact disposable run label, preserve
pre-existing resources, redact bounded evidence, prove idempotent cleanup,
and distinguish lab compensation from product rollback.
The artifact-backed current-build browser contract for the Docker restart
journey is `tests/integration/tests/84-docker-restart-real-lab-artifact.spec.ts`.
It may render only the strict redacted projection produced by the matching
managed-runtime run and must reject missing or mismatched proposal, action,
attempt, receipt, finding, and evidence identities.
RG-06 has a separate mutation harness at
`scripts/intelligence_lab/patrol_autonomy_colima.py` and
`internal/api/patrol_autonomy_colima_real_lab_test.go`. It must run from a
clean archive of the audited SHA, build the pinned `cmd/pulse-agent`, and
execute both the agent and certification test from that same extracted
source directory with an artifact SHA binding. It must refuse to pull or
mutate a non-disposable image. The disposable Debian
agent creates bounded APT cache pressure, reports through the production
WebSocket, and is converted with `unifiedresources.HostIngestRecord`; the
production `clean_package_cache` capability and
`hostStorageCleanupActionExecutor` therefore remain the sole eligibility and
execution path. The proof binds every action to the persisted current-version
human acknowledgement and activation, records server actor/org/resource/
capability and policy-plan bindings, proves one command, one transport
attempt, one receipt, independent cache before/after readback, terminal
ActionResultV2 truth, and finding reconciliation. Each revoked, downgraded,
emergency-stop, stale-resource, and Never barrier first creates a valid
finding/investigation and measures cache/daemon state, agent command count,
transport attempt/receipt, audit/event state, authority/config digest, and
finding resolution before and after, including cache bytes/fingerprints and
container identity/running/start state. Every barrier must produce the
expected canonical refusal reason and never reach completed state. Negative
triple-zero means measured unauthorized mutation, transport dispatch, and
authority writes are all zero; expected lifecycle refusal event deltas are
recorded separately and do not get mislabelled as zero activity. The one authorized dispatch is recorded
separately and never disguised as triple-zero evidence.
Proxmox host-agent setup must treat local `proxmox-registered` markers as a cache, not authority: before skipping token setup or node repair, `internal/hostagent/proxmox_setup.go` must revalidate the current type and candidate hosts against Pulse through the canonical auto-register contract.
Runtime-side PVE token setup must also keep the same permission shape as the
generated setup script: Pulse-managed PVE monitor tokens are
@ -965,6 +1243,12 @@ surface and no new `internal/api/` lifecycle handler.
4. Keep shared agent-side TLS identity fail-closed across `cmd/pulse-agent/main.go`, `internal/hostagent/`, `internal/agentupdate/`, `internal/remoteconfig/client.go`, and `internal/agenttls/config.go`. Self-signed deployments may use a canonical pinned Pulse server certificate fingerprint, but lifecycle transport must route that pin through reporting, enrollment, command websocket, remote-config, and self-update clients instead of widening `PULSE_INSECURE_SKIP_VERIFY` into a blanket MITM carve-out. A configured custom CA bundle is part of that same trust boundary: if the bundle is unreadable or invalid, lifecycle transport must refuse the connection path rather than silently downgrading back to system roots.
5. Keep release-grade updater trust fail-closed across `internal/agentupdate/`, `internal/dockeragent/`, and the shared `internal/api/unified_agent.go` download helpers. When release builds embed trusted update signing keys, published agent binaries and installer assets must carry detached `.sig` plus `.sshsig` sidecars; updater/runtime paths must require `X-Signature-Ed25519` in addition to `X-Checksum-Sha256`, and installer-owned download flows must require the matching base64-encoded `X-Signature-SSHSIG`, instead of silently downgrading to checksum-only trust.
6. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
SSO browser-session display labels in shared auth/session helpers are
likewise API/security presentation state, not lifecycle identity. Agent
enrollment, installer reruns, update recovery, token binding, and fleet
control must continue to derive authority from agent tokens, setup tokens,
machine/report identity, and configured lifecycle state rather than from
`ssoSessionDisplayName` or other mutable SSO claim labels.
The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, provider-test model selection, and legacy Anthropic OAuth cleanup fields remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup, provider activation, or registration semantics just because they share backend helper layers.
Patrol readiness labels on the same settings payload, including the
user-facing Patrol control label for the stable `configuration` check ID,
@ -1116,6 +1400,7 @@ surface and no new `internal/api/` lifecycle handler.
8. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`.
The CLI entrypoint owns propagation of persistence context into runtime-owned helpers. When installer-selected state roots differ from the default, `cmd/pulse-agent/main.go` must pass that exact `StateDir` through both the host-agent runtime and updater startup paths instead of letting one path silently fall back to `/var/lib/pulse-agent`.
The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Public control-plane hostnames remain HTTPS/WSS, but self-hosted local control planes may use plain HTTP/WS when the host is loopback, a private, link-local, or carrier-grade NAT IP, a single-label LAN name, or a local DNS suffix such as `.local`, `.lan`, `.home`, `.home.arpa`, or `.internal`; installer-persisted local HTTP URLs must not be accepted by one runtime path and rejected by another.
Plaintext to a host that does not look local is available only as an explicit operator override: the `--allow-plaintext-http` flag (`PULSE_AGENT_ALLOW_PLAINTEXT_HTTP`) records process-wide consent through `securityutil.SetOperatorPlaintextHTTPConsent` before any module validates a URL, applies uniformly to every agent transport (HTTP and WS), warns at startup that the API token travels in cleartext, defaults closed, and is never emitted by generated install commands. It exists for self-hosted networks numbered from nominally public IP space; the Pulse server never sets it.
The unified agent CLI copy follows the same command-execution vocabulary as the install surface. `cmd/pulse-agent/main.go` may keep the `--enable-commands` flag name for compatibility, but the help text and inline comments must describe command execution as Pulse command execution for Patrol actions and governed Proxmox LXC Docker inventory rather than reviving AI auto-fix language.
The unified agent CLI copy also owns operator-facing Docker / Podman runtime
labels. `cmd/pulse-agent/main.go` may keep the historical
@ -1140,7 +1425,8 @@ surface and no new `internal/api/` lifecycle handler.
Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes.
Unix `--update` re-entry must also preserve lifecycle identity for legacy
v5.1.x agents that do not yet have v6 `connection.env` state. When a
running `pulse-agent` process or its systemd unit already carries the Pulse
running `pulse-agent` process, its systemd unit, or its FreeBSD rc.d service
script already carries the Pulse
URL, token, feature flags, agent id, hostname, or trust posture, the shell
installer may recover those values for the update handoff, but the resulting
v6 service must be rendered through the shared exec-argument builder and
@ -1148,6 +1434,17 @@ surface and no new `internal/api/` lifecycle handler.
Approval-gated command execution must expose stable rejection reasons for
invalid approval grants so fleet operators can distinguish missing, expired,
mismatched, and signature-invalid grants through agent metrics.
Interactive action clients may additionally present the reviewed `planHash`
on decision and execution requests. The shared `internal/api/actions.go`
boundary must reject a mismatch before it records approval state or dispatches
to an agent; lifecycle consumers must never reinterpret a stale client plan as
command authority merely because the action id is still valid.
The server must not mint that grant from a nonempty approval id alone.
`internal/agentexec` accepts approval-gated arbitrary commands only with a
non-serializable server-owned authorization context and a verifier that
consumes an org/action/command/target-bound approval before signing. A
missing, wrong-org, wrong-action, expired, or already-consumed approval
stops before grant minting and before any WebSocket command frame.
10. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential.
11. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles.
12. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
@ -1271,10 +1568,12 @@ surface and no new `internal/api/` lifecycle handler.
must not fork a second discovery-specific credential wizard or treat
discovery results as already-enrolled systems before the operator saves
the governed add form. That same landing-owned shell now keeps discovery
compact: the persistent page may expose only a concise discovery status
line plus `Run discovery` / `Discovery settings` actions, while new-source
admission stays on the per-platform table actions instead of competing
with discovery at the top of the page. Command-backed discovery sweeps and
compact and secondary: the persistent page may expose only a concise
discovery status line after the systems ledger plus `Run discovery`,
`Settings`, or `Configure discovery` actions appropriate to the current
state. New-source admission stays on the header and per-platform table
actions instead of competing with discovery at the top of the page.
Command-backed discovery sweeps and
forced single-resource refreshes remain API/AI-owned admin operations:
lifecycle surfaces may expose the controls, but route-level authority must
require `settings:write` plus the Discovery enablement gate, not
@ -1369,7 +1668,7 @@ surface and no new `internal/api/` lifecycle handler.
## Completion Obligations
1. Update this contract when agent lifecycle ownership changes. Routes added under the shared `internal/api/` extension point that are clearly outside lifecycle ownership (for example `POST /api/ai/patrol/preflight`, the `patrol_preflight` snapshot field added to `/api/settings/ai`, the auto-trigger preflight dispatch on settings save, the startup-seed dispatch in `NewAISettingsHandler`, and the cached-preflight integration into the Patrol `tools` readiness check — all owned by ai-runtime) do not extend this subsystem's contract; they live in their owning subsystem.
1. Update this contract when agent lifecycle ownership changes. Routes added under the shared `internal/api/` extension point that are clearly outside lifecycle ownership (for example `POST /api/ai/patrol/preflight`, the `patrol_preflight` snapshot field added to `/api/settings/ai`, the auto-trigger preflight dispatch on settings save, the startup-seed dispatch in `NewAISettingsHandler`, and the cached-preflight integration into the Patrol `tools` readiness check — all owned by ai-runtime) do not extend this subsystem's contract; they live in their owning subsystem. Canonical scoped Patrol resolution on `POST /api/ai/patrol/run` and structured `patrol_assess_finding` lifecycle outcomes are likewise adjacent AI/API contracts: they may consume agent-reported identities and evidence, but they do not change agent registration, install, token, profile, command transport, update, or fleet-lifecycle authority.
2. Keep shared API proof routing aligned whenever install, register, or profile payloads change.
3. Update runtime and settings tests in the same slice when lifecycle behavior changes. Shell installer lifecycle changes must keep `scripts/installtests/install_sh_test.go` covering explicit flags, persisted connection state, legacy running-process/service recovery, legacy single-dash v5 agent flag recovery, and secure token-file service argument rendering for update re-entry.
4. Keep host-agent test hooks, command-client factories, and timing overrides
@ -1595,6 +1894,32 @@ Agent` secondary handoff against the live setup wizard instead of relying
## Current State
### Canonical mutation-plane dependency
Agent deployment remains an explicit Pulse-administrative exception in the
closed mutation registry. Agent command transports do not originate authority:
typed host update/storage cleanup and executor-owned resource commands may run
only after committed action-lifecycle authority, while raw model command and
unowned delivery paths remain denied.
Agent dispatch begins only after the canonical action store wins and commits
the transition to `executing`. Concurrent replay, another SQLite connection,
or a process restart cannot obtain a second executor admission for that action.
This contract intentionally does not claim exactly-once infrastructure effects
after a crash; durable attempt recovery and downstream effect reconciliation
remain the action-continuity layer's responsibility.
Deploy fan-out concurrency is one shared protocol contract in
`internal/agentexec`: server request normalization and host-agent semaphore
allocation both cap `max_parallel` at the same bound, including payloads that
bypass the normal API producer. Shared local-redirect validation in
`pkg/securityutil/httpurl.go` likewise rejects scheme-relative and backslash
authority forms before agent-adjacent handoff or proof flows consume them.
The host-agent semaphore materializes only literal capacities from one through
the shared maximum after normalization, so network-derived payload values never
reach the channel allocation site while the requested concurrency semantics are
preserved.
Denied Patrol investigation-fix approvals passing through shared
`internal/api/` handlers are adjacent AI-runtime/action-governance state only.
The `fix_rejected` finding outcome records that an operator declined a proposed
@ -2269,6 +2594,13 @@ and export audit reads alongside the enterprise audit surface. That read path
belongs to the API and unified-resource contracts, not to lifecycle ownership,
so the agent-install and registration lane stays focused on fleet continuity
instead of adopting execution-history persistence as a side effect.
Agent-backed action transports may receive a mutation only after the canonical
lifecycle commits a durable dispatch attempt and crosses its one-shot pre-send
boundary. The transport `request_id` is that attempt identity while the action
or approval field remains the canonical action identity. Timeouts and late
agent responses do not authorize lifecycle-local retry: restart recovery must
reconcile the persisted attempt without resending, and transport receipt state
must not be interpreted as Task 10 execution or verification truth.
That shared audit-read path also now requires the dedicated `audit:read`
token scope instead of inheriting broader `settings:read` access, so
lifecycle-adjacent install and registration surfaces cannot regain enterprise
@ -2504,7 +2836,11 @@ primary objects the operator manages. The landing table is instance-first, not
type-first: existing connections or agent-backed hosts render inside one
platform-banded systems ledger, each platform section owns its own `Add`
action, and the page does not fork back into a second monitored-systems ledger
below.
below. The default ledger prioritizes system identity, collection coverage,
health/last activity, and actions; raw management addresses remain in the
governed detail flow or an explicitly expanded cluster-member row. Cluster
members are collapsed under an accurate node count by default so member rows
cannot make the top-level connected-system count appear contradictory.
Adding infrastructure therefore happens in two governed steps. The
`?add=pick` modal owns grouped source-type selection and may offer
`Detect API platform` as a secondary utility. The `?add=detect` modal owns
@ -2519,9 +2855,12 @@ explicit actions inside `InfrastructureWorkspace.tsx`, not extra sidebar
entries or body-replacing workspace subtabs.
That same landing/table contract now also owns collection-method phrasing.
`connectionsTableModel.ts`, `useConnectionsLedger.ts`, and
`InfrastructureSourceManager.tsx` must present the same plain-language subtitle (`via platform API`, `via Pulse Agent`, or `via
platform API and Pulse Agent`) from the shared ledger contract instead of
shipping badge-only heuristics that operators have to decode visually.
`InfrastructureSourceManager.tsx` must preserve the same plain-language
collection identity (`via platform API`, `via Pulse Agent`, or `via platform
API and Pulse Agent`) from the shared ledger contract. The compact landing may
render its API / Agent / API + Agent badge beside the system name, with the full
phrase available through accessible metadata and the detail flow, instead of
spending a separate table column on collection method.
Source badge class selection may use semantic gray treatment for API-only rows
and typed non-gray tones for agent, probe, or combined sources, but the source
identity remains the API/Agent/Probe label and subtitle from the shared ledger
@ -2642,6 +2981,20 @@ cluster source's primary connection is currently disconnected. Only the primary
configured endpoint may drive disconnected-source repair and token rotation; a
covered member endpoint must not rotate the cluster token just because it can
reach `/api/auto-register`.
A covered member's re-registration must still land its address: canonical
auto-register matches the agent against cluster member endpoints (address
identity against the candidate list first, then an unambiguous corosync
node-name match) instead of creating a standalone instance for consolidation
to fold back in and silently discard. The Pulse-verified selected host is
adopted as that member's `ClusterEndpoints[n].IPOverride` (the durable field
re-discovery preserves and polling prefers) together with the certificate
fingerprint captured from that address; an admin-managed override absent from
the agent's candidate list is preserved, mirroring the top-level stored-host
preservation rule. The only credential writes a member match may perform are
a same-token-identity secret refresh (the agent rotates its own token in
place on reinstall, so the stored secret is already invalid) and full token
promotion onto a cluster source that has no credentials at all; a member's
distinct per-node token must never replace working cluster credentials.
That same canonical behavior also includes one auth transport for Proxmox
completion: runtime-side Unified Agent and script callers must send `/api/auto-register`
authentication through a one-time setup token in the request-body
@ -2872,9 +3225,22 @@ timestamp-suffixed or rerun-local token identities.
The corresponding node setup modal owner is now an explicit shell-plus-sections
surface:
`ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` composes
`NodeModalBasicInfoSection.tsx`, `NodeModalAuthenticationSection.tsx`,
`NodeModalMonitoringSection.tsx`, `NodeModalStatusFooter.tsx`,
`nodeModalModel.ts`, and `useNodeModalState.ts`.
`NodeModalBasicInfoSection.tsx`, `NodeModalClusterMembersSection.tsx`,
`NodeModalAuthenticationSection.tsx`, `NodeModalMonitoringSection.tsx`,
`NodeModalStatusFooter.tsx`, `nodeModalModel.ts`, and `useNodeModalState.ts`.
The cluster members section is the canonical manual override surface for
per-member connection addresses on an existing PVE cluster: it writes only
`ClusterEndpoints[n].IPOverride` through the write-only
`clusterEndpointOverrides` node update payload built by
`buildClusterEndpointOverridesPayload` in `nodeModalModel.ts` (changed
members only; blank clears), because discovered member `Host` and `IP` are
rebuilt on every cluster re-discovery while `IPOverride` is preserved and
preferred at poll time. Server-side agent re-registration adoption remains
the automatic path for agent-managed members; this editor surface is the
lifecycle path for members without an agent. The configured-nodes cache in
`useInfrastructureConfiguredNodesState.ts` must mirror saved overrides onto
its cached `clusterEndpoints` rather than spreading the write-only payload
field onto node config state.
That same node setup owner also includes
`frontend-modern/src/utils/nodeModalPresentation.ts`, which now owns the
canonical node-type defaults, endpoint/auth placeholders, monitoring coverage
@ -3384,16 +3750,17 @@ beyond the shared client boundary.
The extracted node setup modal owner must then consume that canonicalized
response directly,
including copying the token-bearing `commandWithEnv` field while rendering the
non-secret `commandWithoutEnv` preview instead of re-interpreting the
bootstrap payload through local nullable fallbacks.
non-executable credentialed-command readiness state instead of rendering the
tokenless `commandWithoutEnv` shell command as if it were runnable or
re-interpreting the bootstrap payload through local nullable fallbacks.
Operator-facing quick-setup display must also stay on the runtime-owned token
boundary: the shared frontend client must require masked `tokenHint`, and the
extracted node setup modal owner must render that hint rather than the full returned
`setupToken` once the bootstrap artifact itself already carries the live
secret. That non-secret preview contract applies to both the PVE and PBS
quick-setup panes; the settings surface may not let one path keep rendering
the token-bearing command after the other has switched to the governed
`commandWithoutEnv` preview. Operator guidance on those panes must stay
secret. That non-executable readiness contract applies to both the PVE and PBS
quick-setup panes; the settings surface may not render either the token-bearing
command or a tokenless lookalike command after bootstrap generation. Operator
guidance on those panes must stay
truthful too: once the visible UI only shows a masked hint, copy-success text
may not instruct the operator to paste a token "shown below" and must instead
state that the copied command already embeds the one-time setup token. The same settings quick-setup surface must also trim and validate the Endpoint URL
@ -4061,3 +4428,158 @@ that waits for the persistent data volume before launching the stored wrapper,
and `internal/agentupdate/update.go` keeps the persisted QNAP binary copy in
sync on self-update so reboot does not roll the runtime back to an older
binary.
Unified Agent lifecycle truth is agent-authored. The host report contract now
carries the non-secret applied managed-config fingerprint, self-updater state
and timestamps, last successful source version, and per-module initialization
state. `/api/connections` may compare the reported applied fingerprint with the
canonical desired fingerprint and may expose updater or module failures, but it
must not synthesize those facts from server version comparison or from the
mere presence of a process. Successful update evidence observed on the first
post-restart report remains retained across later reports so a one-shot update
marker cannot disappear before an operator sees it.
The runtime readiness boundary is module-aware. `/healthz` remains process
liveness, while `/readyz`, `/status`, and the module readiness Prometheus
gauges identify enabled modules that are starting, retrying, or running. An
enabled Docker or Kubernetes module that is still retrying initialization may
not be laundered into an overall ready state.
Installer-owned connection continuity includes certificate pinning. Unix and
Windows installers must validate a supplied SHA-256 leaf-certificate
fingerprint, persist it in `connection.env`, recover it during later lifecycle
operations, and pass it to the long-lived service as
`--server-fingerprint`. A certificate pin is distinct from blanket insecure
TLS mode even when installer download tooling must temporarily bypass chain
validation after the explicit pin check.
Native support evidence is governed by
`docs/release-control/v6/internal/UNIFIED_AGENT_PLATFORM_SUPPORT.md` and
`.github/workflows/unified-agent-native.yml`. Cross-compilation and archive
presence are build evidence only; platform support claims must name native CI,
native installed-lifecycle, appliance-lab, and platform code-signing evidence
separately.
Normal release promotion requires platform-native identity for desktop agent
binaries. macOS agents must be Developer ID signed, submitted successfully to
Apple notarization, and accepted by `spctl`; Windows agents must carry a
verified Authenticode signature. Those native bytes must replace cross-built
desktop binaries before release packaging, Pulse checksum/signature creation,
SBOM generation, and immutable candidate manifest creation. Missing signing
credentials or failed native verification is a release failure, not a warning
or an unsigned fallback.
Setup completion preview actions are part of the lifecycle handoff contract.
The preview copy affordances must use the shared accessible action-button
primitive, carry localized names for the exact value being copied, and retain
a 40-pixel mobile touch floor. The setup surface must not reintroduce raw,
unlabelled icon buttons or a phone-only copy-control fork.
Agent-facing action completion events and resource-context history now expose
the canonical `ActionResultV2` alongside bounded legacy projections. Consumers
must branch on execution and verification separately: a completed transport is
not a verified postcondition, and an independently verified label is valid only
when the canonical evidence identifies a distinct trust domain. Agent-side
producer migration and UI wording are downstream governed work, not permission
to add an agent-local truth model.
### Autopilot activation is not action approval
The server-owned Patrol Autopilot acknowledgement authorizes only the effective
tenant Patrol mode. It does not approve an action, weaken capability policy,
satisfy an MFA floor, manufacture dispatch authority, or alter agent receipt
and result semantics. Every resulting action still enters the canonical
Task 08 actor/RBAC/approval boundary, Task 07 dispatch contract, and Task 10
two-axis result contract. API tokens are ineligible for the human Autopilot
acknowledgement even where an owner-bound token remains compatible with an
authenticated action capability.
### Task 07 generic durable agent-operation receipts and Task 09 APT adapter
Host update and package-cache cleanup use one strict server/agent codec;
unknown fields, trailing JSON, malformed identity, and open command, path,
package, removal, or reboot authority are rejected. Pending delivery binds the
authenticated agent, dispatch request, action, and operation. All APT
inspection, refresh, update, and cleanup paths share one composition-root-
injected lease; nil authority fails closed. Metadata refresh is the first
possible external effect.
The agent-side authority is the generic SQLite operation-receipt store in
`internal/operationreceipt/`, not an APT-specific journal. Before any typed
mutation, it durably binds the canonical dispatch attempt, action, operation
kind/version, request digest, and authenticated agent identity. Admission and
start are atomic keyed transitions; a process reopen conservatively converts
accepted or started work to `interrupted_unknown`, which is queryable but never
automatically executable. Strict versioned terminal envelopes remain exactly
replayable while recent, then compact to immutable identity-and-digest
replay-denial tombstones. Replayable payload bytes are TTL/byte bounded;
tombstone metadata grows monotonically by design so no prior attempt can become
new authority. Disk exhaustion fails the current admission before mutation and
recovers when storage capacity returns; operator disk monitoring remains
required and total tombstone disk use is not described as bounded.
The server derives operation version and request digest after canonical request
fields are fixed, persists that binding on the action dispatch attempt, and
queries the authenticated agent by the exact binding after callback loss or
restart. `not_found`, interrupted, tombstoned, malformed, late, duplicate,
wrong-agent, and mismatched responses never authorize resend or terminal truth.
Terminal payload kind/version and strict adapter codecs are revalidated on
write, read, query, and reopen. APT update and cleanup are adapters over this
generic owner, persist only bounded sanitized typed results, and still share
the package-manager lease. Raw commands, paths, package selectors, stderr,
secrets, and unbounded output are outside the receipt schema.
Task 09 now consumes that continuity end to end. A delayed terminal receipt is
validated against its immutable identity/digest and the agent-attested durable
terminal boundary, not the later query time. The server retains the later
query receipt separately. Impossible observation/terminal chronology, a
future terminal timestamp, stale-at-completion evidence, an evidence-bearing
before-state mismatch, or a hostile envelope still fails closed. A legitimate
pre-mutation inventory or cache-fingerprint drift result remains terminal and
inconclusive: its newly observed `Before` value is not substituted for the
originally authorized expected value when validating the admitted request
digest. The generic receipt store continues to bind completion and replay to
the exact admitted attempt/action/operation/digest/agent identity, while the
drift observation records why mutation was refused and replan is required.
Callback loss and a reopened server store reconcile both APT actions by query
only; the original typed dispatch is never resent. Legacy APT v1 terminal
payloads that predate additive package-manager health facts remain
structurally valid receipts, but their verified claim is projected as health-
unknown/inconclusive rather than confirmed.
For host updates, `MutationStarted` begins immediately before the fixed install
command, not during metadata refresh or simulation. A fixed read-only
`dpkg --audit` health check under the same shared lease supplies explicit
`health_checked`, `package_manager_healthy`, and `recovery_required` facts.
Refresh failure, refreshed inventory drift, zero-pending state, and pre-install
health refusal therefore cannot claim partial installation or action-caused
recovery. Install/verify failures preserve phase, remaining count, tri-state
health, and recovery posture through the existing ActionResultV2 summaries.
Durable receipt protocol support is explicit agent-reported and server-observed
capability metadata. Missing, legacy, or future versions stay connected for
monitoring but advertise no APT mutation capability, emit no actionable APT
finding, and fail dispatch readiness. Product version strings never imply this
authority. The raw protocol integer stays internal to ingest, registry, and
live dispatch checks; customer/frontend resource JSON consumes only derived
capabilities and readiness. The fake-only code/test floor for claims 16 and 17
is implemented; both claims and workflow scorecards remain below operational
completion until browser proof, disposable Debian/Ubuntu lab proof, and Task 12
certification are complete.
RG-06 and RG-09 keep the executing agent's fresh typed readback classified as
`agent_attested`. A separate Colima control-plane observation proves the lab
fixture changed, but it does not enter the authenticated product action result
and therefore cannot upgrade the product finding to `fix_verified`. Until a
distinct-trust-domain observation is ingested into `ActionResultV2`, those
findings remain `fix_verification_unknown` and unresolved. Proxmox VM/LXC
lifecycle is now the first production path to ingest a server-side provider
observation as independent evidence; the Docker restart lab journey remains a
positive control but does not imply that agent-reported Docker inventory is an
independent production observer.
The API router's tenant-scoped commercial resolver for report scheduling is an
adjacent entitlement boundary, not agent authority. It may stop a background
report before generation when the current license lacks `advanced_reporting`,
but it must not mint agent credentials, dispatch agent work, reinterpret an
agent receipt, or expand any lifecycle capability.

File diff suppressed because one or more lines are too long

View file

@ -21,6 +21,12 @@ Docker and Podman container CPU thresholds evaluate host-capacity-normalized
CPU percent, not Docker's runtime-native per-core percent. Alert metadata may
carry the raw per-core value and reporting host CPU count for evidence, but the
threshold value and canonical `cpuPercent` metadata remain normalized.
Docker and Podman OOM alerts require authoritative runtime evidence: the
container must be stopped (`exited` or `dead`) and its reported `OOMKilled`
state must be explicitly true. Exit code 137 alone is only SIGKILL evidence;
explicit false and unavailable/legacy OOM state both fail closed without an OOM
alert. Recovery clears an existing OOM alert when the authoritative predicate
is no longer true.
## Canonical Files
@ -247,6 +253,12 @@ posture alerts. Snapshot age, backup age, powered-off state, and
configuration-change reevaluation must all construct a canonical lightweight
guest snapshot and route threshold resolution through the shared
guest-defaults → filter-driven custom rules → guest-override chain.
That canonical guest context must preserve the live guest name and tags for
snapshot and backup posture evaluation. Ignored prefixes, `pulse-no-alerts`,
configured ignored tags, and required-tag filtering must resolve through the
same guest alert policy before any guest-derived alert is created; posture
pollers may not downgrade that context to a name-only lookup that bypasses the
operator's suppression policy.
Passing `nil` guest context or resolving only overrides/defaults is forbidden
because it silently bypasses custom guest rules and makes guest lifecycle
alerting diverge from running-guest metric truth.
@ -415,7 +427,10 @@ Docker alert evaluation now lives in `internal/alerts/docker.go`. That file
owns Docker host connectivity, container state and health, container metric
projection, service gap/update-state checks, image-update timing, and Docker
tracking cleanup; future Docker alert behavior should extend that resource
checker owner rather than expanding the central Manager file.
checker owner rather than expanding the central Manager file. It must not keep
shadow last-exit-code state or infer an OOM kill from exit 137; the accepted
container model's nullable runtime-authored `OOMKilled` field is the sole OOM
classification input.
PBS alert evaluation now lives in `internal/alerts/pbs.go`. That file owns PBS
connectivity normalization, PBS metric projection, PBS metric cleanup, and PBS
offline lifecycle handling; future PBS alert behavior should extend that

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,18 @@ agreement, the Pulse Cloud control plane, provider-hosted MSP account
bootstrap/licensing, hosted tenant lifecycle, and cloud-specific enforcement
rules.
The subsystem also owns the canonical commercial offer and transition
boundary across Community, Relay, Pro, Cloud, and MSP. One versioned offer
contract must project into public pricing, Pulse Account, in-product plan
presentation, checkout, the read-only Stripe catalog audit, support policy,
license-server billing state, and runtime entitlements. Stripe remains billing
truth, while one Pulse-owned idempotent transition authority must atomically
project authoritative Stripe subscription snapshots into the local billing
contract, continuity epoch, entitlement state, license version, transition
history, and grant-revocation outbox. Checkout, webhook, reconciliation,
refund, support/admin, and future account transition paths must not implement
independent entitlement mutation rules.
Pulse Pro commercial Pulse Intelligence value reporting is a cloud-paid proof
surface. It must compare full-loop, approved-execution-loop, Assistant-loop,
Assistant-resolved-loop, external-agent-loop, external-agent-resolved-loop,
@ -117,8 +129,9 @@ contract, not control-plane report generation. The control plane may accept
provider-default report brand environment values and pass them into each tenant
container as generic `PULSE_REPORT_PROVIDER_BRAND_*` runtime configuration, but
the tenant Pulse runtime owns report rendering, per-workspace override loading,
and the `white_label` entitlement gate. This keeps provider-hosted MSP
Stripe-free and avoids a cloud-control-plane report data path across clients.
scheduled report cadence/delivery, generated output retention, and the
`white_label` entitlement gate. This keeps provider-hosted MSP Stripe-free and
avoids a cloud-control-plane report data path across clients.
## Canonical Files
@ -134,7 +147,7 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
10. `pkg/licensing/dev_mode_features.go`
11. `pkg/licensing/service.go`
12. `pkg/licensing/grant_refresh.go`
13. `pkg/licensing/revocation_poll.go`
13. `pkg/licensing/installation_status_poll.go`
14. `pkg/licensing/license_server_client.go`
15. `pkg/licensing/persistence.go`
16. `pkg/licensing/activation_store.go`
@ -206,6 +219,15 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
79. `internal/cloudcp/provider_msp_bootstrap.go`
80. `internal/cloudcp/provider_msp_backup.go`
81. `internal/cloudcp/provider_msp_recovery.go`
82. `pulse-pro:relay-server/main.go`
83. `pulse-pro:relay-server/registry.go`
84. `pulse-pro:relay-server/revocation_feed.go`
85. `pulse-pro:landing-page/index.html`
86. `pulse-pro:scripts/validate_stripe_catalog.py`
87. `pulse-pro:scripts/remediate_stripe_commercial_state.py`
88. `pulse-pro:license-server/README.md`
89. `pulse-pro:license-server/entrypoint.sh.template`
90. `pulse-pro:license-server/secrets.env.template`
## Shared Boundaries
@ -223,6 +245,13 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
into a generic paid-state prompt or create browser-owned relay credentials.
2. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` shared with `agent-lifecycle`: the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary.
3. `frontend-modern/src/useAppRuntimeState.ts` shared with `performance-and-scalability`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary.
The app runtime may use `ssoSessionDisplayName` from security status for
visible signed-in chrome, but hosted/commercial organization context must
remain bound to the stable authenticated principal carried separately in
`ssoSessionUsername` and backend request context. Display labels, contact
emails, and SSO name claims must not become hosted owner/member identity,
billing authority, entitlement state, or organization bootstrap source of
truth.
4. `internal/api/licensing_bridge.go` shared with `api-contracts`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
5. `internal/api/licensing_handlers.go` shared with `api-contracts`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
That same shared licensing boundary also owns installation-version and
@ -230,9 +259,9 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
and `internal/api/licensing_handlers.go` must hand the canonical process
version and runtime identity into `pkg/licensing/service.go` and
`pkg/licensing/grant_refresh.go`, and cloud-paid transport must send those
values on activate, legacy exchange, and grant refresh instead of inferring
install version or paid-runtime status from browser state, dev build
metadata, public image tags, or outbound usage telemetry.
values on activate, legacy exchange, installation status, and grant refresh
instead of inferring install version or paid-runtime status from browser
state, dev build metadata, public image tags, or outbound usage telemetry.
Active self-hosted paid entitlements must also treat runtime identity as a
first-class product state. A paid Pro, Pro Annual, Pro+, lifetime, or
enterprise entitlement on a non-Pro or missing runtime identity must render
@ -241,6 +270,14 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
preserve the raw runtime build and expose a normalized `pro`, `community`,
or `unknown` status so paid-runtime support triage does not depend on
interpreting Docker tags, public release names, or customer screenshots.
The same activation state is also the credential source for the compiled
Pro binary's in-app self-update through the license-gated download broker:
that consumer is read-only over the activation snapshot (installation
token, instance fingerprint, license server URL) and must not mutate
licensing state, extend entitlements, or act as an alternate activation
path. Keeping the Pro runtime updatable in place is part of the
paid-runtime posture; the community self-update flow must never be the
default answer for an installed Pro runtime.
That same shared licensing boundary also owns paid-migration degradation
visibility and recovery. A persisted v5 license that exists but cannot be
read or decrypted must publish a terminal `commercial_migration` state
@ -249,6 +286,17 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
pending must self-retry in the background with backoff for the life of
the process so a transient license-server or DNS failure at first boot
never strands a paying upgrader on Community until a manual restart.
Signature-valid v5 JWTs that are expired beyond the v5 grace window but
correspond to a newer retrievable server-side key or live entitlement must
classify as terminal stale-key recovery (`exchange_stale_key` with
`retrieve_current_key`) rather than generic invalid-key failure; malformed,
signature-invalid, and truly lapsed keys must keep the generic terminal
rejection path. Transport-level legacy-exchange failures must preserve the
first continuous failure timestamp on `commercial_migration.first_failed_at`
and, after 24 hours, keep retrying while surfacing
`exchange_connectivity_required` with the outbound
`license.pulserelay.pro` connectivity policy instead of rendering ordinary
pending copy forever.
6. `internal/api/licensing_legacy_retry.go` shared with `api-contracts`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership.
7. `internal/api/payments_webhook_handlers.go` shared with `api-contracts`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
8. `internal/api/public_signup_handlers.go` shared with `api-contracts`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
@ -334,8 +382,9 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
contract: `internal/cloudcp/docker/manager.go` may inject
`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`, logo path/data, and logo format
into each tenant container, but it must not collect report data or render
PDFs in the control plane. Tenant-local reporting and tenant-local licensing
decide whether that configured brand appears.
PDFs in the control plane. Tenant-local reporting, tenant-local schedules,
and tenant-local licensing decide whether that configured brand appears and
how recurring report delivery runs.
`pulse_hosted_msp` is the Pulse-operated form of the same Stripe-free MSP
control-plane family, not the public Pulse-hosted SaaS checkout path. It
must share the license-backed MSP plan source, workspace limit policy,
@ -374,6 +423,9 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
per-tenant snapshot, health-check, and rollback path. `tenant-runtime
reconcile --all` remains contract/routing drift repair for each tenant's
current image line, not an image-line upgrade path.
16. `pulse-pro:relay-server/main.go` shared with `relay-runtime`: the Relay server startup and readiness path is both a relay-runtime server boundary and a cloud-paid entitlement invalidation boundary.
17. `pulse-pro:relay-server/registry.go` shared with `relay-runtime`: the Relay server active-session registry is both a relay-runtime connection boundary and a cloud-paid entitlement invalidation boundary.
18. `pulse-pro:relay-server/revocation_feed.go` shared with `relay-runtime`: the Relay server revocation feed is both a relay-runtime server boundary and a cloud-paid entitlement invalidation boundary.
The real `pulse-pro` license-server legacy checkout issuance, recurring
renewals, manual issue, and legacy exchange flows are part of that same
@ -520,6 +572,12 @@ or other self-hosted uncapped continuity plans.
## Extension Points
The authenticated app shell exposes `/actions` as a global utility route on
desktop and responsive navigation. `App.tsx` and `AppLayout.tsx` only provide
route/navigation ownership; action authority and result truth remain in the
unified-resources and api-contracts projections. Navigation proof is owned by
the App/AppLayout, routing, and desktop Actions journey tests.
1. Add or change limits through `pkg/licensing/`
2. Add or change hosted entitlement issuance through `internal/cloudcp/entitlements/service.go`
Hosted entitlement refresh is scoped to active workspace rows only. A tenant
@ -666,6 +724,15 @@ or other self-hosted uncapped continuity plans.
runtime; Pulse Account may deep-link to those tenant surfaces but must not
mint workspace agent credentials or render cross-client monitoring state in
the account portal.
Pulse Account may surface tenant-local active alert rollups as counts and
age labels from read-only setup facts so providers can prioritize the
workspace list, but it must not become an alert console or expose alert
bodies, remediation state, acknowledgements, or cross-client alert streams.
Those setup facts must read report schedule counts from the client runtime's
org-scoped `report_schedules.json` store and active-alert counts from the
org-scoped `alerts/active-alerts.json` runtime file before falling back to a
legacy tenant-root active-alert file, because tenant monitors own
org-scoped runtime persistence.
Pulse Account also owns the provider-facing setup progression for client
workspaces: after workspace creation the portal should select the created
workspace, reveal the setup job, and preserve workspace/target context in
@ -679,9 +746,11 @@ or other self-hosted uncapped continuity plans.
provider setup templates for MSP accounts, but those templates are guidance
rather than configuration. `Ready` requires at least one reporting agent, one
enabled alert route, and one enabled report schedule; a failed latest health
check remains `Review` ahead of setup counts. Local MSP onboarding previews
should be scenario-backed portal bootstrap data, not static screenshots, so
they stay grounded in the real portal shape as the bundle changes.
check remains `Review` ahead of setup counts, and critical alert rollups
outrank generic health/setup review in provider attention ordering. Local MSP
onboarding previews should be scenario-backed portal bootstrap data, not
static screenshots, so they stay grounded in the real portal shape as the
bundle changes.
Hosted provider workspaces may store agent install tokens in the tenant
runtime root token store rather than the org-specific config directory.
Portal setup facts must count only root tokens whose `OrgID` or `OrgIDs`
@ -713,6 +782,26 @@ or other self-hosted uncapped continuity plans.
pairing for handoff, push notifications, and 14-day history; it must not imply that the
native mobile app is a full monitoring dashboard until that product surface
exists.
Portal sessions and sign-in delivery are part of this boundary. Session
lifetime is configured through `CP_SESSION_TTL` and flows through the
control-plane auth service (`SetSessionTTL`/`SessionTTLOrDefault`); portal
session issuance sites must use the service value rather than the package
constant so provider-hosted MSP control planes can default to 7 days while
Pulse-hosted control planes stay at 12 hours.
The portal bootstrap payload carries `email_sign_in_available` (false when
no transactional email provider is configured) and `provider_hosted_mode`
(true for `provider_hosted_msp` control planes). The signed-out portal must
not promise an emailed sign-in link when `email_sign_in_available` is
false; it must instead present the operator host command
(`provider-msp portal-link --email ...`), and the Access invite panel must
disclose that invitation emails are not sent in that state. The
`provider-msp portal-link` CLI mints one-time portal links only for an
existing account member or a pending invitee, never for arbitrary
addresses.
Workspace-limit rejections from tenant creation must be JSON payloads
(`error=workspace_limit_reached` with `message`, `current`, and `limit`)
so the portal can present the licence reason instead of a generic failure;
the portal API client must not drop non-JSON error bodies.
7. Add or change Stripe provisioning plan resolution through
`internal/cloudcp/stripe/provisioner.go`, `internal/cloudcp/stripe/webhook.go`,
and `pkg/licensing/stripe_subscription.go`. Checkout-session provisioning
@ -721,7 +810,7 @@ or other self-hosted uncapped continuity plans.
source may provision only when the plan is otherwise unresolved for staging
compatibility, and self-hosted v5/v6 checkout metadata must be ignored by
the Cloud control-plane webhook even if Stripe delivers the event there.
8. Add or change activation/grant lifecycle, release build helper gating, or dev-mode capability widening through `pkg/licensing/dev_mode_features.go`, `pkg/licensing/service.go`, `pkg/licensing/testing_helpers.go`, `pkg/licensing/grant_refresh.go`, and `pkg/licensing/revocation_poll.go`
8. Add or change activation/grant lifecycle, release build helper gating, or dev-mode capability widening through `pkg/licensing/dev_mode_features.go`, `pkg/licensing/service.go`, `pkg/licensing/testing_helpers.go`, `pkg/licensing/grant_refresh.go`, and `pkg/licensing/installation_status_poll.go`
9. Add or change license-server transport through `pkg/licensing/license_server_client.go`
That transport boundary must use HTTPS for non-loopback commercial endpoints,
may allow plaintext only on direct loopback development targets, and must
@ -810,6 +899,11 @@ or other self-hosted uncapped continuity plans.
Relay tier remains a tangible standalone paid product.
18. Add contract tests where runtime and pricing need to stay aligned
19. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts`
The shared app shell may mount the deployment-installability-owned
post-update release highlights card next to existing global banners, but
that card must remain independent of plan, entitlement, organization, and
hosted bootstrap state. It must not turn release communication into a
commercial prompt or add another capability probe to the shell.
That same hosted bootstrap boundary also owns the runtime-capability JSON
shape that the app shell consumes before it decides whether organization
chrome and multi-tenant routes exist. `pkg/licensing/entitlement_payload.go`
@ -822,6 +916,10 @@ or other self-hosted uncapped continuity plans.
invitation flow must therefore refresh org bootstrap through the shared
`organizations_changed` app-shell path instead of forking a second hosted
org bootstrap or pricing-aware shell reload.
SSO display labels consumed during that same security-status bootstrap must
reuse the existing `/api/security/status` response and must not add another
hosted organization or commercial posture probe before the protected state
bootstrap.
App-shell navigation rendered by `frontend-modern/src/AppLayout.tsx` must
also keep decorative icon titles out of tab accessible names, so hosted and
self-hosted chrome announce the canonical tab label plus meaningful badge
@ -999,12 +1097,80 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`.
Landing behavior for paid and hosted shells must also defer to the
frontend-primitives-owned provider-first landing contract instead of
defining a cloud-paid-specific order.
26. Introduce the self-hosted `business` tier only through this shape:
`TierBusiness` in `pkg/licensing/features.go` carries exactly the Pro
feature set (no feature-level differentiation), 365-day history
retention in `TierHistoryDays`, membership in the self-hosted
core-monitoring-uncapped tier and plan-version sets (`business`,
`business_annual`), the `Business` display name, and a slot between Pro
and MSP in the min-tier ordering. Business differentiates commercially
by the `max_users` license limit (unlimited for Business; newly issued
Pro licenses may carry a finite `max_users` while previously issued
licenses keep their unlimited posture), retention, and support, never
by gating features away from Pro. The tier stays dormant until the
license server issues business plan versions through a governed
rollout; no checkout, pricing-model payload, public pricing page, or
in-product plan surface may reference Business before that rollout, and
monitored-system volume stays out of the Business plan model per the
self-hosted commercial boundary above.
The `max_users` seat limit travels the licensing chain as one named
shape mirroring `MaxGuests`: `Plan.MaxUsers` on the license server,
`max_users` on the stored v6 license and in relay grant claims (all
three copies of the grant wire struct), `GrantClaims.MaxUsers` copied
into `Claims.MaxUsers` in `pkg/licensing`, and surfaced through
`EffectiveLimits()["max_users"]` into the shipped user-limit
enforcement (`MaxUsersLimitFromLicense`). It mirrors `MaxGuests` in
shape only, not in scrub behavior: `max_users` is a seat limit, not a
monitored-volume cap, so `stripSelfHostedCommercialVolumeCaps` and the
license-server entitlement normalization must never strip or zero it.
Absent claim = 0 = unlimited at every hop, which keeps all existing
licenses, grants, and plans inert until the governed rollout sets
`max_users` on a plan.
27. Add or change the canonical commercial offer, self-hosted subscription
transition matrix, cancellation/payment-failure grace, downgrade
preservation, or Cloud/MSP availability only through the approved contract
in
`docs/release-control/v6/internal/records/commercial-offer-lifecycle-contract-2026-07-14.md`.
Stripe Customer Portal `subscription_update` remains disabled. Pulse-owned
plan changes must use the canonical transition authority, present an
explicit amount/effective-date quote, and converge from an authoritative
Stripe snapshot rather than trusting one webhook payload or a browser-
supplied plan identity. Unknown prices, catalog-version disagreement, and
incomplete entitlement projections fail closed. Every material tier,
cadence, capability, or restrictive-state change increments
`license_version` and emits the version-floor/outbox event in the same
local transaction.
28. Add or change Relay-side commercial invalidation only through the shared
`pulse-pro:relay-server/main.go`, `pulse-pro:relay-server/revocation_feed.go`,
and `pulse-pro:relay-server/registry.go` boundary. Operator Relay startup
must fail closed without the authenticated feed, drain it before serving,
report stale feed state through readiness, and disconnect already-connected
v6 sessions whose grant falls below an applied version floor.
29. Add or change customer Pulse commercial invalidation only through
`pkg/licensing/installation_status_poll.go`,
`pkg/licensing/license_server_client.go`, and the installation-authenticated
`pulse-pro:license-server/admin_ui.go`,
`pulse-pro:license-server/v6_handlers.go`,
`pulse-pro:license-server/v6_grants.go`, and
`pulse-pro:license-server/v6_store.go` boundary. The status response may
expose only the caller's authoritative license version and bounded cadence
hints; it must never expose a grant, the global Relay feed credential, or
another commercial subject's event data.
## Forbidden Paths
1. New ad hoc plan names in runtime or UI
2. Silent aliases between old and new limit keys in live runtime paths
3. Pricing/UI claims that are not enforced by runtime entitlements
4. Direct Stripe subscription price changes that update billing identifiers
without atomically updating tier, cadence, features, continuity, and license
version
5. Customer Portal configuration as the authority for plan transitions
6. Separate offer definitions in landing, account, app, support, or Stripe
provisioning code
7. Distributing the global Relay revocation-feed bearer token to customer Pulse
installations; customer-runtime invalidation requires installation-scoped
authentication or an equivalently bounded authority.
## Completion Obligations
@ -1134,9 +1300,119 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`.
hosted capacity policy, keep it hidden from self-hosted plan cards, and
avoid customer-facing "Unlimited Instances" copy that sounds like the old
capped self-hosted packaging.
21. Keep the approved self-hosted scope coherent: one owner-operated
environment, three primary/migration/recovery activations, and unmetered
monitored systems and child resources. Terms, public copy, activation
enforcement, support transfer tooling, and MSP boundaries must use the
same definition.
22. Keep Pro's Relay bundle explicit in every offer and fulfillment
projection, including Stripe product-description audits. Relay and Pro are
distinct jobs rather than sequential ladder steps, and a Pro buyer must
never be told to buy Relay separately for the same environment.
23. Do not expose Relay/Pro or cadence self-service transitions until the
`self-hosted-commercial-transition-coherence` gate proves proration,
scheduled reductions, cancellation/recovery grace, payment-failure grace,
refund/dispute handling, replay/order convergence, downgrade preservation,
and grant-version invalidation.
24. Keep Cloud unavailable and MSP request-assisted until their respective
availability contracts are explicitly reopened. Historical Cloud prices,
caps, trial, and support labels are dormant planning data, while provider-
hosted MSP remains the default assisted-preview delivery boundary.
25. Keep operator Relay revocation enforcement mandatory and readiness-backed:
startup must synchronously establish the monotonic version-floor cache,
feed staleness must fail readiness, and feed-applied restrictive events
must disconnect stale active v6 sessions and invalidate their reconnect
credentials.
26. Keep customer Pulse invalidation separate from the operator-wide feed
credential. Before self-service transitions are released, prove a scoped
customer-safe authority that refreshes or clears paid state without giving
any installation visibility into another customer's revocation events.
27. Keep terminal Stripe event failure recoverable without bypassing ordering
safeguards. The authoritative 30-day Stripe backfill may reopen a `failed`
inbox event for one fresh retry cycle, but it must claim the event before
dispatch, retain the original payload, use the cursor-aware retry path, and
return to bounded backoff when the replay still fails.
28. Keep production Stripe catalog remediation bounded, reviewable, and
separate from runtime deployment. The canonical tool must default to
GET-only planning, derive public price identity from the pricing model,
refuse catalog or Stripe-mode drift before writes, require an explicit
apply confirmation, update only the two public self-hosted product
descriptions, and create or reuse an exact Pulse-owned invoice/payment-
method-only portal configuration. It must not mutate an unknown portal
configuration, price, customer, subscription, webhook, license, or
deployed environment. The returned portal identifier is a separate
approved configuration/deployment input, and a passing GET-only production
re-audit remains the postcondition.
## Current State
The approved commercial contract is recorded in
`docs/release-control/v6/internal/records/commercial-offer-lifecycle-contract-2026-07-14.md`.
The local self-hosted license server now has an atomic commercial projection
and a Pulse-owned quoted transition saga for Relay/Pro and cadence changes.
Cancellation recovery is non-entitling, payment failure has a distinct
functional grace, unknown or multi-price snapshots fail closed, and runtime
downgrade state delays physical history/artifact cleanup while blocking paid
background work immediately. Renewal retries now re-project authoritative
Stripe state even when the requested flag already matches, newly created
subscription schedules are durably attached to their quote before later Stripe
or local completion steps, schedule-release retries repair the local quote, and
the authoritative backfill can reopen a terminal inbox event through the
cursor-aware retry path. The implementation is not yet a released self-
service capability: the governed external Stripe transition matrix, event-
order/reconciliation exercise and Relay version-floor proof remain required by
`self-hosted-commercial-transition-coherence`. Cloud remains unavailable and
MSP remains an assisted preview.
The 2026-07-14 GET-only production catalog/portal audit was exercised and
failed expected state: all 25 governed prices resolved, but the runtime did not
provide a governed billing-portal configuration identifier and the public
Relay/Pro Stripe products retained pre-contract descriptions. No Stripe object
or customer record was mutated or inspected. The dated blocked record is
`docs/release-control/v6/internal/records/self-hosted-commercial-transition-coherence-production-audit-blocked-2026-07-14.md`;
at that point the gate stayed blocked until separately approved remediation
was deployed and the same read-only audit passed.
Repository-side remediation preparation now exists in
`pulse-pro:scripts/remediate_stripe_commercial_state.py`. Offline proof covers
shared-product deduplication, strict price and mode preconditions, exact portal
reuse, safe creation instead of mutation of a nonconforming portal, bounded
write endpoints, and apply confirmation. The dormant new-environment price
creation helper also uses the same canonical descriptions. This preparation
did not change the production finding at that point: no live apply or runtime
deployment had yet been authorized or executed.
On 2026-07-15 the separately approved bounded production remediation converged
the two public Relay/Pro product descriptions, created and deployed dedicated
invoice/payment-method-only portal configuration
`bpc_1TtNsxBrHBocJIGHTjRvG4Qf`, and passed the same GET-only production audit:
all 25 governed prices resolved, the portal contract passed, and the only
warnings were the already-governed inactive v1 legacy recurring prices. A
second remediation plan returned no product updates and reused the dedicated
portal, proving idempotent convergence. The production catalog/portal residual
is closed, but `self-hosted-commercial-transition-coherence` remains blocked
until the external Stripe lifecycle transition/event-reconciliation matrix and
real Relay license-version-floor exercise pass. The evidence record is
`docs/release-control/v6/internal/records/self-hosted-commercial-transition-coherence-production-remediation-2026-07-15.md`.
The Relay side now fails closed on missing feed authority, drains the feed
before serving, exposes feed staleness through readiness, and tears down stale
already-connected v6 sessions while clearing their reconnect tokens. Customer
Pulse now uses an immediate, installation-authenticated `POST
/v1/grants/status` check followed by the existing signed-grant refresh path
only when the authoritative license version advances. Explicit token,
installation, or license revocation clears encrypted activation state. An
authoritative suspension removes paid entitlements immediately while retaining
the scoped installation credential for automatic recovery; other authorization
or transport failures retain the last signed grant and surface non-secret
synchronization health through `/api/license/status`. The global operator feed
remains Relay-only. Real external Stripe-to-runtime proof remains release-gated.
Hosted handoff target paths are normalized by the shared host-local redirect
validator at token minting, exchange, and provider-proof boundaries. Absolute,
scheme-relative, backslash-authority, encoded-separator, and control-character
targets are rejected before they can reach a browser redirect.
Primary nav moved to platform-first on 2026-05-16 through
`frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, and
`frontend-modern/src/pages/RuntimeHome.tsx`. The authenticated-runtime landing
@ -1228,6 +1504,13 @@ nullable until the formatting edge, but it must normalize the message through
the shared monitored-system presentation helper instead of branching on
demo/billing state inside settings panels or inventing a second mock-only
license explanation path.
Hosted organization and Billing Admin browser clients must prefer an active
`pulse_session` cookie when the same browser also retains an API token from
first-run setup. Platform-admin and session-required organization operations
must not let that ambient token override the authenticated administrator
session. Token-only browser clients retain their existing scoped fallback when
no session cookie is present.
That same cloud-paid/browser boundary now also governs public demo posture.
`DEMO_MODE` may run against a real internal entitlement, but public demo
surfaces must not reveal self-hosted license metadata, hosted billing state,
@ -1428,10 +1711,15 @@ That same local-persistence boundary also owns the filesystem path contract for
commercial secrets at rest. `pkg/licensing/persistence.go` and
`pkg/licensing/activation_store.go` must normalize the owned config directory
once and resolve only the fixed `.license-key`, `license.enc`, and
`activation.enc` leaves through the shared storage-path helper before any
filesystem read, write, rename, stat, or delete. Future licensing persistence
changes must not bypass that resolver with raw `filepath.Join(configDir, ...)`
joins or introduce caller-controlled persistence filenames.
`activation.enc`, and `instance-fingerprint` leaves through the shared
storage-path helper before any filesystem read, write, rename, stat, or delete.
Future licensing persistence changes must not bypass that resolver with raw
`filepath.Join(configDir, ...)` joins or introduce caller-controlled
persistence filenames. The `instance-fingerprint` file is not an activation
credential and must survive clear-license flows: native activation and legacy
exchange must reuse it as the stable local installation identity so one machine
does not consume another paid installation slot after a local activation clear,
container recreation, or retry.
That same local-persistence boundary also owns writable-but-not-owned runtime
storage semantics for commercial state. `pkg/licensing/persistence.go` may
harden directories it owns to `0700`, but it must not assume it can chmod the
@ -1479,7 +1767,15 @@ unmetered.
Activation-grant translation is part of the same boundary: when relay/license
server grants enter the local claims model, Cloud plan keys and lifecycle state
must still resolve through the canonical entitlement claim accessors rather
than becoming a parallel truth path.
than becoming a parallel truth path. The activation grant `exp` remains the
short-lived enforcement and refresh lease; recurring self-hosted license period
display must come from the optional grant `current_period_end` mapped into the
local claims model and exposed through `/api/license/status` `expires_at` and
`days_remaining`. Older grants that omit `current_period_end` may fall back to
the grant/JWT expiry for compatibility, but new recurring grants must not make
the UI present the 72-hour relay lease as the customer's subscription end date.
The shared grant wire shape must stay aligned across the Pulse client, the
`pulse-pro` license server issuer, and the relay-server grant validator.
The legacy-license exchange transport is part of that same activation boundary:
`pkg/licensing/activation_types.go` and `pkg/licensing/license_server_client.go`
must treat `legacy_license_token` as the canonical v6 request field for
@ -1991,6 +2287,12 @@ theme synchronization, and authenticated runtime startup, and
as org switching and kiosk-safe navigation. Future hosted browser bootstrap
work must extend that split rather than pulling org bootstrap and app chrome
back into one monolithic route component.
The global update progress watcher inside that entry shell is self-hosted
server-update chrome: its stage vocabulary tracks the backend updater
pipeline (downloading through restarting, including the `restoring` stage
emitted by update rollback) and it carries no hosted entitlement, org, or
paid-gating semantics; hosted and paid surfaces must not key tenant or
billing behavior off update progress stages.
That same authenticated shell split must also respect shared blocking dialogs:
hosted chrome may not leave the Pulse Assistant launcher or an already-open
assistant drawer interactive behind a modal that currently owns the viewport.
@ -2531,7 +2833,7 @@ each ID resolves to an active live recurring Stripe price object.
Activation service runtime, license-server transport, encrypted activation
persistence, and hosted entitlement lease signing now follow the same ratchet. Changes
to `pkg/licensing/service.go`, `pkg/licensing/grant_refresh.go`,
`pkg/licensing/revocation_poll.go`, `pkg/licensing/license_server_client.go`,
`pkg/licensing/installation_status_poll.go`, `pkg/licensing/license_server_client.go`,
`pkg/licensing/persistence.go`, `pkg/licensing/activation_store.go`, and
`pkg/licensing/trial_activation.go` should carry their dedicated proof files
instead of relying only on the generic cloud runtime policy.
@ -2802,6 +3104,30 @@ Community copy may mention provider/local-model Patrol, but must not present
hosted-model credits, account-backed AI access, or trial acquisition as a
default self-hosted benefit or a reason to put a paid prompt in front of
ordinary users.
The public offer, Pulse settings presentation, and Pulse Account handoff must
also describe Community, Relay, and Pro as distinct jobs rather than an
artificial feature ladder. Relay is the secure-access product. Pro is the
Patrol-powered operations product and explicitly includes Relay connectivity,
Pulse Mobile pairing, and push notifications. Retired Pro trial acquisition
copy and dormant Cloud checkout catalogs must not reappear in the ordinary
self-hosted journey; historical Cloud plan identifiers may remain only as
customer-state labels.
The Stripe billing portal is limited to invoices and payment methods. Every
portal-session request must name an explicit governed
`STRIPE_BILLING_PORTAL_CONFIGURATION_ID` beginning with `bpc_`; a missing or
invalid configuration fails closed. That Stripe configuration must keep
subscription plan updates disabled because Relay/Pro product and cadence
transitions stay in the Pulse-owned verified transition authority. Production
readiness therefore requires a read-only external proof that the configured
portal exposes only that intended scope before this commercial gate can pass.
The repo-owned license-server entrypoint must require that configuration before
the production binary starts, and the runtime configuration template and
operator documentation must describe the same restricted portal scope. This
turns a missing production value into a pre-deploy failure instead of a broken
customer manage-subscription path.
The public Stripe catalog audit must also enforce product descriptions that
name the one owner-operated environment boundary and state that Pro includes
Relay, so checkout cannot contradict the canonical package model.
That public-demo commercial boundary also owns monitored-system preview
unavailability wording. Browser presentation may keep the unavailable reason
nullable until the formatting edge, but it must normalize the message through

View file

@ -32,6 +32,10 @@ TLS floor in the dynamic config.
1. `internal/updates/`
2. `internal/api/updates.go`
3. `frontend-modern/src/api/updates.ts`
4. `frontend-modern/src/components/UpdateBanner.tsx`
5. `frontend-modern/src/components/WhatsNewCard.tsx`
6. `frontend-modern/src/components/whatsNewModel.ts`
7. `frontend-modern/src/utils/localStorage.ts`
4. `cmd/pulse-control-plane/main.go`
5. `cmd/pulse-control-plane/mobile_proof_cmd.go`
6. `cmd/pulse-control-plane/provider_msp.go`
@ -46,7 +50,8 @@ TLS floor in the dynamic config.
15. `internal/cloudcp/docker/manager.go`
16. `internal/cloudcp/docker/labels.go`
17. `internal/cloudcp/tenant_runtime_rollout.go`
13. `.github/workflows/create-release.yml`
13. `.github/workflows/build-release-candidate.yml`
14. `.github/workflows/create-release.yml`
14. `.github/workflows/deploy-demo-server.yml`
15. `.github/workflows/helm-pages.yml`
16. `.github/workflows/promote-floating-tags.yml`
@ -71,6 +76,7 @@ TLS floor in the dynamic config.
34. `go.mod`
35. `go.sum`
36. `scripts/build-release.sh`
37. `scripts/generate-release-notes.sh`
37. `scripts/check-workflow-dispatch-inputs.py`
38. `scripts/clean-mock-alerts.sh`
39. `scripts/com.pulse.hot-dev.plist.template`
@ -93,36 +99,42 @@ TLS floor in the dynamic config.
57. `scripts/release_control/record_rc_to_ga_rehearsal.py`
58. `scripts/release_control/release_promotion_policy_support.py`
59. `scripts/release_control/resolve_release_promotion.py`
60. `scripts/release_control/validate_artifact_release_line.py`
61. `scripts/release_ldflags.sh`
62. `scripts/run_cloud_public_signup_smoke.sh`
63. `scripts/run_demo_public_browser_smoke.sh`
64. `scripts/demo_public_browser_smoke.cjs`
65. `scripts/run_hosted_staging_smoke.sh`
66. `scripts/trigger-release-dry-run.sh`
67. `scripts/trigger-release.sh`
68. `scripts/toggle-mock.sh`
69. `deploy/provider-msp/`
70. `deploy/helm/pulse/`
70. `tests/integration/playwright.config.ts`
71. `tests/integration/QUICK_START.md`
72. `tests/integration/README.md`
73. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
74. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
75. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
76. `tests/integration/scripts/hosted-tenant-runtime.mjs`
77. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
78. `tests/integration/scripts/managed-dev-runtime.mjs`
79. `tests/integration/scripts/relay-mobile-token-helper.go`
80. `tests/integration/tests/helpers.ts`
81. `tests/integration/tests/runtime-defaults.ts`
82. `docker-compose.yml`
83. `scripts/install-docker.sh`
84. `scripts/validate-published-release.sh`
85. `scripts/validate-release.sh`
86. `scripts/release_asset_common.sh`
87. `scripts/backfill-release-assets.sh`
88. `.github/workflows/backfill-release-assets.yml`
60. `scripts/release_control/mobile_release_gate.py`
61. `scripts/release_control/mobile_release_gate_test.py`
62. `scripts/release_candidate_manifest.py`
63. `scripts/release_control/validate_artifact_release_line.py`
63. `scripts/release_ldflags.sh`
64. `scripts/run_cloud_public_signup_smoke.sh`
65. `scripts/run_demo_public_browser_smoke.sh`
66. `scripts/demo_public_browser_smoke.cjs`
67. `scripts/run_hosted_staging_smoke.sh`
68. `scripts/trigger-release-dry-run.sh`
69. `scripts/trigger-release.sh`
70. `scripts/toggle-mock.sh`
71. `deploy/provider-msp/`
72. `deploy/helm/pulse/`
73. `tests/integration/playwright.config.ts`
74. `tests/integration/QUICK_START.md`
75. `tests/integration/README.md`
76. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
77. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
78. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
79. `tests/integration/scripts/hosted-tenant-runtime.mjs`
80. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
81. `tests/integration/scripts/managed-dev-runtime.mjs`
82. `tests/integration/scripts/relay-mobile-token-helper.go`
83. `tests/integration/tests/helpers.ts`
84. `tests/integration/tests/runtime-defaults.ts`
85. `docker-compose.yml`
86. `scripts/install-docker.sh`
87. `scripts/validate-published-release.sh`
88. `scripts/validate-release.sh`
89. `scripts/release_asset_common.sh`
90. `scripts/backfill-release-assets.sh`
91. `.github/workflows/backfill-release-assets.yml`
92. `.github/scripts/check-demo-reachability.sh`
93. `.github/scripts/setup-demo-ssh.sh`
94. `scripts/trigger-stable-patch.sh`
## Shared Boundaries
@ -163,9 +175,11 @@ TLS floor in the dynamic config.
`pulse-control-plane provider-msp proof` must exercise the first-client
onboarding path through workspace creation, client-bound install token
generation, tenant-local unified-agent report ingest, tenant-bound install
token rotation, rotated-out token rejection, handoff exchange, and
duplicate-hostname isolation before provider-hosted MSP installability is
treated as proven. The proof is license-backed by default: `license_file` must be the
token rotation, rotated-out token rejection, handoff exchange,
tenant-runtime report schedule creation, portal-visible active-alert rollup
facts, and duplicate-hostname isolation before provider-hosted MSP
installability is treated as proven. The proof is license-backed by default:
`license_file` must be the
resolved provider MSP plan source unless the operator explicitly opts into
the local-development `--allow-env-plan` escape hatch.
The same proof surface must also keep adversarial client-boundary probes in
@ -224,6 +238,17 @@ TLS floor in the dynamic config.
Docker subnet, create the storage-admission marker directories, and install a
host-level `DOCKER-USER` rule blocking `169.254.169.254` from tenant
containers when iptables is available.
The setup summary must leave the operator on a working next step, not a
dead end: it must print the `provider-msp bootstrap` command that creates
the operator account and portal sign-in link, and the day-2 sign-in
guidance (re-running `bootstrap` for a fresh owner link and
`provider-msp portal-link --email` for invited teammates), because the
bundle default ships without a transactional email provider and the portal
cannot send sign-in links in that state. `.env.example` must document the
same commands next to `RESEND_API_KEY` so the runbook and the portal
sign-in page agree. `provider-msp portal-link` is part of the packaged
day-2 surface and mints links only for existing account members or pending
invitees.
Provider-hosted MSP installability must also pass provider-default report
branding through the packaged tenant environment rather than requiring
report-specific operator provisioning. The deployable control-plane config
@ -262,7 +287,12 @@ TLS floor in the dynamic config.
It must expose a non-mutating preflight for the exact Windows agent
architecture before Administrator-only install changes, accept token-file
enrollment input, and avoid interactive download-failure prompts when
launched by generated non-interactive onboarding commands.
launched by generated non-interactive onboarding commands. A completed
install must own a durable rotating ProgramData log, verify that log
together with local `/readyz`, and fail closed if required SCM recovery
actions or non-crash recovery cannot be configured. The Windows native CI
path must run the reusable lifecycle harness rather than stopping at a
parser check or foreground self-test.
8. `scripts/install.sh` shared with `agent-lifecycle`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
Existing-agent update commands copied from the settings UI must use the
installer-owned `--update` mode rather than serializing a fresh enrollment
@ -287,6 +317,19 @@ TLS floor in the dynamic config.
used Go's single-dash flag spelling, the installer-owned recovery path must
accept both single-dash and double-dash forms for recovered agent args
without weakening the existing missing-state failure behavior.
FreeBSD and pfSense updates have the same continuity obligation without
Linux procfs or systemd: the installer must read live process arguments via
`ps` (and environment via `procstat` when available), then fall back to the
installed rc.d service's `command_args` and `PULSE_*` exports. The parser
must preserve quoted argument values without evaluating service-file shell
content, and the rewritten rc.d service must use `--token-file` rather than
retaining a recovered raw token.
FreeBSD-family uninstall must stop the rc.d daemon(8) supervisor before
removing the binary, then remove service registration, rc.conf enablement,
boot wrappers, PID files, token/state, and residual processes before it can
report success. A checksum-verified native rehearsal must cover install,
update, reboot persistence, and clean uninstall rather than treating a
cross-build as complete lifecycle proof.
The shell installer must disclose `--enable-commands` as Pulse command
execution, disabled by default, and must name both Patrol actions and
Proxmox LXC Docker inventory as the operator-visible reasons to enable it.
@ -303,7 +346,37 @@ TLS floor in the dynamic config.
## Extension Points
1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml`
Normal releases are single-build promotions. The exact pushed SHA must
produce one signed candidate through
`.github/workflows/build-release-candidate.yml` while independent release
checks run in parallel. `create-release.yml` may publish only that candidate
after `scripts/release_candidate_manifest.py` verifies its version, source
SHA, filenames, sizes, and SHA-256 values. Standard post-upload validation
must compare that manifest with GitHub's server-side asset digests instead
of downloading the complete release packet again. Historical repair and
release-edit validation may use the full-download fallback because those
paths do not have a same-run candidate manifest.
The candidate job timeout must cover signed multi-platform assembly, full
local packet validation, manifest creation, and artifact upload; the
observed release path requires a 60-minute ceiling even though the build
itself is expected to finish much earlier.
Tarball entry validation must extract the requested files once per archive;
it must not decompress a multi-gigabyte release archive again for every
required entry, and the release-promotion contract test must reject a return
to per-entry archive streaming.
A manually dispatched release rehearsal must activate the same signed
candidate build whenever its required `version` input is non-empty and must
require the same macOS notarization and Windows Authenticode lanes as a
publish run. A cheap signing-configuration job must report every missing
repository secret before either platform runner is allocated.
macOS command-line agent notarization must fail closed unless
`notarytool --wait --output-format json` reports `Accepted`, then verify the
exact candidate bytes with strict `codesign`. Bare Mach-O command-line
binaries are not app bundles, so `spctl --assess --type execute` is not a
valid post-notarization gate for this artifact shape.
Scheduled watchdog rehearsals omit that input and must skip candidate
signing while retaining the non-publish policy and integration checks.
Release-facing agent-paradigm blurbs under `docs/releases/` must describe
`pulse-mcp` as a generic MCP adapter for MCP-speaking clients, not a
client-specific release artifact, and full-surface token guidance must come
@ -339,6 +412,17 @@ TLS floor in the dynamic config.
signature verification depends on `ssh-keygen` from `openssh-client` and
must not fail on a minimal supported host solely because that package was
absent before installation started.
The server systemd unit that root `install.sh` writes
(`install_systemd_service`) hardens with `NoNewPrivileges=true`, which
strips setuid and file capabilities from every child the server executes.
ICMP availability probes exec the system `ping` binary, so the same
hardening block must also grant `AmbientCapabilities=CAP_NET_RAW` with a
matching `CapabilityBoundingSet=CAP_NET_RAW`; dropping either regresses
ICMP availability checks to permanent failure on every systemd install
(discussion #1554). `scripts/installtests/root_install_sh_test.go`
(`TestRootInstallServiceGrantsIcmpProbeCapability`) pins the pairing, and
`docs/CONFIGURATION.md` documents the `systemctl edit` override for units
written before the grant existed.
The top-level `install.sh` asset published on GitHub Releases must be the
root Pulse SERVER installer (the LXC / systemd / Proxmox VE installer that
accepts `--version vX.Y.Z`, `--rc`, `--stable`, and friends). The rendered
@ -346,9 +430,8 @@ TLS floor in the dynamic config.
at `./scripts/install.sh` and inside Docker images at
`/opt/pulse/scripts/install.sh`, and is served at the running server's
`/install.sh` endpoint; it is intentionally never the top-level GitHub
Releases asset. `internal/updates/adapter_installsh.go`,
`scripts/pulse-auto-update.sh`, and the root `install.sh`'s own
`--rc` / `--stable` / `--version` self-refetch flows all fetch
Releases asset. `scripts/pulse-auto-update.sh` and the root `install.sh`'s
own `--rc` / `--stable` / `--version` self-refetch flows all fetch
`releases/<tag>/install.sh` and execute it via `bash -s -- --version vX.Y.Z`,
and the README quickstart documents the same pattern. Publishing the agent
installer in that slot silently breaks every one of those flows because the
@ -401,7 +484,13 @@ TLS floor in the dynamic config.
must preserve server-derived `owner_user_id` lineage on bootstrap tokens and
enrollment runtime tokens while keeping deploy binding metadata limited to
deploy facts such as cluster, job, target, source agent, and expected node.
4. Add or change server update transport through `internal/api/updates.go`, `internal/updates/`, and `frontend-modern/src/api/updates.ts`
4. Add or change server update transport and release-note presentation through
`internal/api/updates.go`, `internal/updates/`,
`frontend-modern/src/api/updates.ts`,
`frontend-modern/src/components/UpdateBanner.tsx`,
`frontend-modern/src/components/WhatsNewCard.tsx`,
`frontend-modern/src/components/whatsNewModel.ts`, and
`frontend-modern/src/utils/localStorage.ts`
Server update planning must attach the canonical upgrade-readiness verdict
to `/api/updates/plan` responses before an operator starts a v6 update, and
`POST /api/updates/apply` must recompute the same verdict and reject
@ -414,6 +503,14 @@ TLS floor in the dynamic config.
unreadable token state and warning about missing, expired, or soon-expiring
agent reporting scopes without pretending shell-only inspection can prove
live registered-agent continuity.
The authenticated running-release notes endpoint must fetch only the exact
published tag for the running release, cache both hits and misses, stay
unavailable for source/development builds, and return the canonical release
body without inventing a second changelog source. The update banner may
preview only the curated `Highlights` section from update-check metadata,
while the post-update card may show that same section once per later
installed release and must stay silent for a first baseline, malformed or
development versions, missing releases, and releases without highlights.
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, deterministic dev auth seeding, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/lib/hot-dev-runtime.sh`, `scripts/lib/hot-dev-auth.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
First-run browser helpers are part of that dev-runtime proof boundary. They
must preserve the setup-created API token in the shared runtime state, prefer
@ -470,7 +567,19 @@ TLS floor in the dynamic config.
`PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS`) so live dev verification of
host-side LXC Docker inventory does not silently restart into default-off
monitoring.
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
Browser authentication helpers used by release and managed-runtime E2E must
keep session creation below the backend login limiter. Shared helpers must
treat an HTTP 429 response from `POST /api/login` as the retryable
`Too many requests` outcome instead of collapsing it into a generic
connection failure, and release suites that run many scenarios against one
compose backend must prefer worker-scoped authenticated storage state over
repeated per-test password logins.
Multi-tenant release helpers must also treat organization switching as a
visible runtime-state contract: after persisting `pulse_org_id` and reloading,
the helper must wait for the Organization selector to hold the requested org
before a scenario navigates onward, so an interrupted org-list bootstrap
cannot fall back to `default` and mask the scoped UI under test.
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/mobile_release_gate_test.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
That release-promotion boundary also owns prerelease note packet lineage:
shipped RC notes must remain historically accurate, the top-level
`docs/RELEASE_NOTES.md` index must continue to point at the current shipped
@ -589,6 +698,41 @@ TLS floor in the dynamic config.
archive filenames through `--archive` so direct Linux and Proxmox LXC users
can keep the normal service setup while installing the private Pulse Pro
runtime.
The in-app updater must never install a public community build on the
compiled Pulse Pro binary: when the running edition is Pro (recorded by
`pkg/edition`, flipped to `pro` in `enterpriseruntime.Initialize` alongside
`coreaudit.SetLogger`/`server.SetBusinessHooks`, and keyed off the compiled
binary — never license-active state), `internal/updates` checks and applies
updates exclusively through the license server download broker
(`GET /v1/downloads/pulse-pro` with the installation token and instance
fingerprint, per `internal/updates/pro_update.go`), verifying the private
archive against the same pinned `pulse-installer` SSHSIG key plus the
broker manifest sha256, and refusing GitHub-shaped download URLs outright.
An unactivated Pro binary refuses to apply and directs the operator to
`https://pulserelay.pro/download.html` and the `install.sh --archive` path.
This is required because the community self-update flow (the in-app GitHub
path, `install.sh` defaults, and the unattended
`scripts/pulse-auto-update.sh` timer — which must skip when the installed
binary reports `Pulse Pro`) targets the public `rcourtman/Pulse` community
assets and would replace the Pro binary and silently strip Audit, RBAC,
Reporting, and SSO from a paying customer. A community binary with an
active paid license is still community and must keep its normal
self-update; the `frontend-modern` update banner keeps the in-app apply
affordance for auto-updatable Pro deployments (the broker path preserves
the Pro runtime) and surfaces the portal path for deployments the updater
cannot drive, such as Docker.
A Docker deployment of the compiled Pro binary is part of that same
boundary: the container cannot self-replace its binary and a Pro compose
file pins the previous image digest, so `internal/updates/pro_update.go`
must relay the broker manifest's Docker command block (login plus compose
pull/up referencing `image@sha256:<digest>`, never a mutable tag) as
`UpdateInfo.dockerUpdate` on the update-check response for Docker
deployments, behind the same stable/rc channel guard as the binary path,
failing closed when the broker block is missing or not digest-pinned.
In-app update guidance (the Settings updates surfaces, the update banner,
and the docker update plan in `internal/updates/adapter_installsh.go`)
must never show the community `rcourtman/pulse` pull commands when the
compiled runtime is Pro.
Customer-facing private Pro RC/GA promotion is part of that same boundary:
for every non-draft v6 public release, `create-release.yml` must call the
private `rcourtman/pulse-enterprise` `Build Pro Release` workflow after
@ -636,18 +780,27 @@ TLS floor in the dynamic config.
`scripts/install-docker.sh` fallback from the final RC image tag to the
stable `6.0.0` image tag in the same commit as `VERSION=6.0.0`.
Stable patch releases after `6.0.0` stay on this same governed release
boundary but do not need a fabricated same-version RC tag when the release
owner is intentionally publishing a hotfix patch from the current stable
branch. In that case `resolve_release_promotion.py` may accept an omitted
`promoted_from_tag` only for a stable semver patch version with
`hotfix_exception=true`, a non-empty `hotfix_reason`, and
`rollback_version` set to the previous stable tag. Non-hotfix stable
promotions, and any first-GA or minor-line stable promotion, still require
explicit promoted prerelease lineage and soak proof. Stable patch release
boundary but do not need a fabricated same-version RC tag for a routine
patch. `resolve_release_promotion.py` owns the machine boundary: the
rollback target must be the latest preceding stable tag, the candidate must
descend from it, no same-version RC may already exist, and the diff may not
touch authentication/tenant isolation, licensing/billing authority,
persisted-data/schema migration, relay/mobile trust protocol, or
installer/updater/rollback execution. Those risk classes require exercised
RC lineage unless active customer harm is recorded with
`hotfix_exception=true` and a non-empty `hotfix_reason`. First-GA and minor
stable promotions still require explicit promoted prerelease lineage and
soak proof. Stable patch release
packets must also enumerate every customer-visible support fix included in
the cut, and the release-asset proof must pin the current packet to those
runtime fixes so a patch that includes support work cannot ship as a
metadata-only release note.
Release integration failures must leave enough evidence to classify the
failure after the compose stack is torn down. `create-release.yml` must
upload the Playwright report and a `release-integration-failures` artifact
containing Playwright `test-results/` plus
`release-integration-diagnostics/docker.log`; that Docker log must capture
container state and the Pulse test server plus mock GitHub server logs.
7. Preserve release-matched installer and Helm operator documentation links through `scripts/install.sh`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-helm-chart.yml`, and the chart metadata itself so deployment guidance and packaged chart metadata do not drift back to branch-tip `main` docs when a release line or promoted tag already exists.
The same governed Helm boundary also owns `deploy/helm/pulse/` itself:
chart metadata, default values, templates, and generated chart docs must
@ -677,6 +830,11 @@ TLS floor in the dynamic config.
`release` and `workflow_dispatch` triggers, and its chart-version
resolver must prefer inputs over the release-event tag when inputs are
present so all three entry paths converge on the same identity.
`helm-pages.yml` must not treat chart-releaser's "no chart changes
detected" no-op as a successful Pages publication for a newly published
release version. A successful Pages workflow must create or update the
`helm-chart-<version>` release asset and assert that `gh-pages/index.yaml`
contains `version: <version>` before the workflow exits green.
After pushing the OCI chart, `publish-helm-chart.yml` must prove the
pushed chart is readable from GHCR without registry credentials by logging
out of `ghcr.io` and running `helm show chart` against the versioned chart
@ -749,7 +907,7 @@ TLS floor in the dynamic config.
1. Update this contract when canonical deployment or installer entry points move
2. Keep deployment runtime and shared API proof routing aligned in `registry.json`
3. Preserve explicit coverage for installer parity, update planning, and deployment bootstrap behavior when these surfaces change. Shell installer update recovery changes must keep `scripts/installtests/install_sh_test.go` covering both persisted `connection.env` recovery and legacy running-process/service recovery, including single-dash v5 agent flags and the rule that upgraded service args use `--token-file` instead of raw `--token`.
3. Preserve explicit coverage for installer parity, update planning, and deployment bootstrap behavior when these surfaces change. Shell installer update recovery changes must keep `scripts/installtests/install_sh_test.go` covering both persisted `connection.env` recovery and legacy running-process/service recovery across Linux and FreeBSD/rc.d, including single-dash v5 agent flags, non-procfs process inspection, and the rule that upgraded service args use `--token-file` instead of raw `--token`.
4. Keep stable and prerelease packet lineage explicit when `docs/releases/` or
`VERSION` changes: preserve already-shipped RC packets under dedicated
historical filenames before reusing canonical stable names, keep
@ -780,6 +938,10 @@ TLS floor in the dynamic config.
use backend-owned dev reset, admin-bypass, session-login, or token-auth paths
instead of deleting runtime files, rebuilding bootstrap state, or accepting
the retired dashboard route as proof of authentication.
Release E2E suites that use those helpers must avoid turning scenario count
into repeated password-login pressure: worker-scoped authenticated storage
state is the canonical multi-scenario shape, and helper retry proof must
preserve explicit 429 login-rate classification.
8. Keep root-level Playwright wrapper routing on the canonical managed browser
truth. `playwright.config.ts`, `tests/integration/playwright.config.ts`,
and `tests/integration/tests/runtime-defaults.ts` must resolve the same
@ -808,12 +970,23 @@ TLS floor in the dynamic config.
Whenever that policy changes, update the owning workflow/install proof files
in `scripts/installtests/build_release_assets_test.go` and
`scripts/release_control/release_promotion_policy_*` in the same slice.
11. Keep forward release signing pinned to an explicit trust root. Governed
11. Keep mobile impact explicit on governed server releases. Every release
publish and manual release dry run must record one of the canonical mobile
decisions (`no-mobile-impact`, `existing-mobile-build-compatible`,
`mobile-candidate-uploaded`, or `mobile-candidate-required`), and
`mobile-candidate-required` is a blocking state until the mobile candidate
is built/submitted and the release is rerun with `mobile-candidate-uploaded`
evidence. Compatibility or uploaded-candidate decisions must carry evidence
text rather than relying on memory. A `mobile-candidate-uploaded` release
packet must also name the exact iOS build number and Android version code in
its release notes and changelog, and must distinguish TestFlight or Play
internal-testing availability from a public store rollout.
12. Keep forward release signing pinned to an explicit trust root. Governed
release scripts, Docker release builds, and historical backfill paths must
accept the active private signing key only alongside a non-secret expected
public key or equivalent pinned identity, and they must fail closed before
publication if the signer drifts from that expected trust root.
12. When the governed update signer changes, the canonical operator-facing
13. When the governed update signer changes, the canonical operator-facing
release docs under `docs/releases/` and the governed upgrade guide
`docs/UPGRADE_v6.md` must state the continuity impact explicitly. Those docs
must not imply automatic updater continuity from a historical signer unless
@ -821,19 +994,31 @@ TLS floor in the dynamic config.
## Current State
The active support prerelease `v6.0.5-rc.2` cut sets the repo-root `VERSION`,
The provider MSP proof command validates its handoff target with the same
host-local redirect contract as runtime token minting and exchange. Proof input
must reject absolute, scheme-relative, backslash-authority, encoded-separator,
and control-character targets before constructing the handoff request.
The active support prerelease `v6.1.0-rc.1` cut sets the repo-root `VERSION`,
repo-root `docker-compose.yml` image default, `scripts/install-docker.sh`
fallback, and Helm chart release metadata to the same `6.0.5-rc.2` release
version. This support prerelease keeps `rollback_version=v6.0.4`, publishes a
versioned public GitHub prerelease plus versioned Docker and Helm artifacts,
and does not move stable/latest install pointers or stable semver aliases. It
queues the v6.0.4 stable line's customer-support fixes for Gemini Patrol
tool-call readiness, remembered-login submit persistence, Proxmox SATA/SAT
SMART temperature fallback, legacy agent update token recovery, threshold-aware
temperature display severity, PBS backup polling memory bounds, physical disk
SMART/Proxmox merge identity, Proxmox token preservation diagnostics, legacy
OIDC SSO discovery with CSP nonce handling, and route-aware Proxmox host URLs
behind RC validation instead of publishing another same-day stable patch.
fallback, and Helm chart release metadata to the same `6.1.0-rc.1` release
version. This support prerelease keeps `rollback_version=v6.0.5`, publishes a
versioned public GitHub prerelease plus versioned Docker and Helm artifacts, and
does not move stable/latest install pointers or stable semver aliases. It puts
the expanded Pulse Intelligence action and verification lifecycle, the
operator-facing Actions inbox, monitor-first product workflows, governed host
and storage operations, native-agent update safety, Windows logged-readiness
and recovery proof, OIDC callback recovery, and fail-closed security hardening
behind RC validation before the next stable minor release.
The companion evidence for this cut is Pulse Mobile iOS build 10 and Android
versionCode 8 on TestFlight and Google Play internal testing only. The release
packet must not describe either candidate as a public store rollout.
The same release boundary now provides one canonical in-app release-note
experience. Update checks can preview a curated `Highlights` section, and an
authenticated running-version endpoint lets the update surface show those
same published highlights once after a later upgrade. Missing highlights stay
quiet by design, and source or development builds never masquerade as
published releases.
The initial GA promotion
metadata remains
`promoted_from_tag=v6.0.0-rc.7`, `rollback_version=v5.1.35`,
@ -887,11 +1072,11 @@ compose image default, standalone installer fallback constant, and packaged
Helm metadata. A draft release workflow failure caused by stale image or chart
pins is a release-packet blocker until the defaults, tests, and evidence
record are refreshed from the new branch head.
For the active support prerelease `v6.0.5-rc.2` cut, the repo-root compose
default and `scripts/install-docker.sh` fallback must both pin `6.0.5-rc.2`
For the active support prerelease `v6.1.0-rc.1` cut, the repo-root compose
default and `scripts/install-docker.sh` fallback must both pin `6.1.0-rc.1`
until the next governed stable cut moves them forward. The stable promotion
guard remains in force by rejecting leftover `-rc.` defaults when the governed
`VERSION` is a stable release.
guard remains in force and must reject leftover `-rc.` defaults when the
governed `VERSION` returns to a stable release.
The RC7 packet refresh records `fc10de9b5477613316473267b72b05b6b2b7aaff`
as the current validation-risk commit. That head includes the earlier
Docker-default correction plus the follow-on capacity-forecast and Patrol
@ -971,7 +1156,7 @@ The governed v6 release Go patch level is part of that same boundary:
`go.mod`, `scripts/.go-version`, `scripts/install-go-toolchain.sh`,
`scripts/build-release.sh`, the Go builder stages in `Dockerfile` and
`deploy/provider-msp/Dockerfile.control-plane`, and the Pro release workflows
must stay aligned on the same patched `1.25.x` floor before a release can be
must stay aligned on the same patched `1.26.x` floor before a release can be
treated as shippable. When `govulncheck` reports called standard-library
vulnerabilities in the current patch level, the canonical fix is to advance the
governed release toolchain and immutable Go builder digest together, not to
@ -1022,6 +1207,15 @@ for stable-versus-prerelease metadata validation shared by `.github/workflows/re
and `.github/workflows/create-release.yml`. Promotion rollback targets, promoted
prerelease lineage, soak checks, and GA/v5 notice metadata may not drift between those
two workflows through duplicated inline shell validation.
One scoped exception keeps the weekly drift watchdog viable: scheduled
`release-dry-run.yml` runs carry no `workflow_dispatch` inputs (GitHub does
not apply input defaults to `schedule` events), so the rehearsal step passes
`--derive-rollback-latest-stable` and the resolver fills the empty
`rollback_version` with the latest stable repository tag preceding the
rehearsal version. The derivation flag is gated on the `schedule` event in
the workflow; manual rehearsal dispatches and real promotions must still
supply an explicit stable `rollback_version`, and the resolver still fails
closed when the input is empty and the flag is absent.
`scripts/release_control/validate_artifact_release_line.py` is the canonical
owner for follow-on artifact workflow release-line validation shared by Docker
publication, floating-tag promotion, Helm chart publication, and Helm Pages
@ -1035,6 +1229,17 @@ the manual `trigger-release*.sh` entrypoints must all derive their governed
release line from control-plane metadata before they touch public artifacts or
deployment targets, rather than treating tag names or workflow triggers as
enough proof on their own.
For routine stable patches, `scripts/trigger-stable-patch.sh` is the
noninteractive operator path. It derives the latest stable rollback, consumes
the canonical `docs/releases/RELEASE_NOTES_vX.Y.Z.md` packet, infers
`no-mobile-impact` only when no mobile-facing path changed, and dispatches one
dry-run or publish workflow. `create-release.yml` must independently require a
successful `workflow_dispatch` `Release Dry Run` for the exact candidate SHA
and version from the previous 24 hours, so the UI and alternate helpers cannot
bypass the preflight. That dry run must call `update-demo-server.yml` in
verification-only mode against the latest stable release. It must prove
Tailscale, SSH host identity, runtime version, frontend parity, public health,
and browser smoke without changing the host.
That same release-validation boundary also owns draft-versus-published asset
state. When `.github/workflows/create-release.yml` runs in `draft_only` mode,
it must pass the real draft state into `.github/workflows/validate-release-assets.yml`
@ -1057,6 +1262,18 @@ unpublished tag must locate the existing draft release, retarget its git tag
and release `target_commitish` to the current governed release-line head, and
continue publication without requiring an operator to delete the tag manually;
published tags remain immutable and must still fail closed.
That same release-dispatch boundary now also owns mobile impact gating for
server releases. `.github/workflows/create-release.yml`,
`.github/workflows/release-dry-run.yml`, `scripts/trigger-release.sh`, and
`scripts/trigger-release-dry-run.sh` must require an explicit mobile release
decision before a governed release packet can proceed. A server-only release may
record `no-mobile-impact`; a mobile/relay/onboarding/API-compatible release may
record `existing-mobile-build-compatible` with proof; a release that already
has a matching TestFlight/Play candidate may record `mobile-candidate-uploaded`
with build evidence; and `mobile-candidate-required` must fail closed until the
mobile candidate exists. This gate does not auto-submit App Store/TestFlight or
Play builds, but it prevents release packets from silently ignoring the mobile
track.
That same upload boundary must tolerate transient GitHub release-asset API
failures. `.github/workflows/create-release.yml` must retry every
`gh release upload` operation with bounded backoff before failing the release
@ -1096,7 +1313,20 @@ Those same governed demo deploy/update workflows also own the runner-to-host
network path. They must establish the canonical Tailscale connectivity step
before SSH setup so stable or preview targets may stay on governed private
hostnames or Tailscale IPs, rather than silently depending on public SSH
reachability from GitHub-hosted runners.
reachability from GitHub-hosted runners. The workflows must use the current
pinned Tailscale GitHub Action, its target `ping` readiness gate, and the shared
`.github/scripts/check-demo-reachability.sh` TCP/22 diagnostic before SSH key
capture. A successful tailnet join alone is not connectivity proof. After that
network preflight, shared SSH
setup must wait for configured demo hostnames to resolve, accept configured IP
literals without a DNS precheck, and then capture host keys with bounded
short retries before any installer or binary copy runs; a long `ssh-keyscan`
loop must not hide an ACL, peer-propagation, firewall, or sshd failure.
`create-release.yml` must call the update workflow as an awaited reusable job,
and its terminal `Definitive Release Verdict` must require stable demo runtime,
frontend, public health, and browser proof. An asynchronous dispatch or manual
SSH deployment is not release completion. A one-shot `ssh-keyscan`
against a private demo target is not sufficient release or deploy proof.
Those same workflows also own customer-visible browser truth for the public
demo shell. Health checks and entry-asset parity are necessary but not
sufficient; after those checks pass, the governed helpers
@ -1212,10 +1442,6 @@ That same storage boundary also governs update-history persistence:
`internal/updates/history.go` must normalize its owned data directory and
resolve the fixed `update-history.jsonl` leaf through the shared storage-path
helper instead of joining raw caller-provided directory strings.
That same boundary also governs install.sh rollback restore targets:
`adapter_installsh.go` may not hardcode `/etc/pulse` for rollback safety
backups or config restore, and must derive the rollback config directory
through that same shared runtime data-dir helper.
That same runtime env contract also governs `pulse mock`: the CLI may not keep
writing a separate `mock.env` sidecar when supported runtime installs already
carry mock-mode ownership through `.env`. Mock enable/disable/status must use
@ -1445,17 +1671,30 @@ root `install.sh`, its generated update helper, and
`scripts/pulse-auto-update.sh` must verify downloaded release tarballs and
installer scripts against the pinned release `.sshsig` sidecars before
execution, rather than treating same-origin checksum files as a sufficient
trust anchor. The in-app updater binds to the same invariant: every
release artifact the Go updater fetches before applying or rolling back —
the update tarball in `internal/updates/manager.go::ApplyUpdate`, the
`install.sh` piped into bash by `internal/updates/adapter_installsh.go::downloadInstallScript`,
and the rollback binary tarball in
`internal/updates/adapter_installsh.go::downloadBinary` — must verify
trust anchor. The in-app updater binds to the same invariant: the only
place the Go updater fetches release artifacts is the apply pipeline in
`internal/updates/manager.go::ApplyUpdate` (the adapters in
`internal/updates/adapter_installsh.go` are plan providers only and download
nothing), and every artifact that pipeline fetches — community tarballs via
`downloadAndVerifyReleaseSignature`, Pro broker artifacts via their explicit
sidecar URL — must verify
its `.sshsig` sidecar against the pinned `pulse-installer` ed25519 key
(identity `pulse-installer`, namespace `pulse-install`) and refuse to
proceed if the sidecar is missing, malformed, or fails verification. The
in-app and unattended paths must share the same trust root so the UI's
"Update now" button cannot run at a lower bar than the systemd timer.
The in-app apply pipeline additionally owns pre-install binary validation:
after extraction and before any backup or file replacement,
`internal/updates/manager.go::ApplyUpdate` must locate the extracted `pulse`
binary and prove it executes on this host and reports the apply-target
version, via the `--version` probe in
`internal/updates/selftest.go::selfTestNewBinary`. Checksum and signature
verification prove artifact integrity, not host runnability, so a
wrong-architecture, truncated-yet-published, or unstamped artifact must fail
the update with zero changes applied rather than being swapped in for systemd
to restart into. `internal/updates/selftest_test.go` and the corrupt-binary
apply subtest in `internal/updates/manager_pro_update_test.go` are the owned
proof surface for that validation.
The unattended auto-update path is also fail-closed on prerelease channel
crossing: `scripts/pulse-auto-update.sh` must refuse to act on any tag that
carries a semver prerelease suffix (`-rc.N`, `-beta.N`, `-alpha.N`,
@ -1919,7 +2158,13 @@ persisted Windows service ever starts.
Windows installability proof must also verify the installed service's local
readiness endpoint, not just SCM `Running` state: the Windows service runtime
must start the shared Pulse Agent health/readiness server so `/readyz` can prove
the agent modules initialized after install.
the agent modules initialized after install. That proof must also require the
installer-advertised ProgramData log to exist and contain startup evidence,
exercise configured SCM crash recovery, replace one real agent version with a
second, and prove uninstall removes the service, binary, state, token/log
artifacts, and readiness listener. OS-reboot-capable labs use the harness's
split install/update and post-reboot/uninstall phases; hosted CI uses its full
service-lifecycle phase without rebooting the ephemeral runner.
Copied PowerShell uninstall commands must preserve that same
`PULSE_INSECURE_SKIP_VERIFY` setting so the governed deregistration request can
still reach self-signed Pulse deployments during removal.
@ -2099,6 +2344,17 @@ release-packet SBOM is absent so published RC/stable downloads can keep the
updater and installer trust chain fail-closed instead of downgrading to
checksum-only trust and can publish a shareable non-image software inventory
alongside the signed binaries.
The immutable candidate builder must model macOS Developer ID/notarization and
Windows Authenticode as independent native-signing requirements rather than one
all-or-nothing platform switch. Governed RC publication may require signed and
notarized macOS agent binaries while Windows Authenticode approval is still an
externally owned bounded residual, but only when the RC packet explicitly
discloses the unsigned Windows publisher state and the Windows binaries retain
the exact-SHA candidate, checksum, detached-signature, and post-publication
digest controls. Stable publication and the stable-path dry-run must continue
to require both native signing lanes. `scripts/build-release.sh` must replace
only the native targets required by those independent inputs and must fail
closed when a required native-binary directory or target is absent.
Historical published-release repair must flow through
`scripts/backfill-release-assets.sh` and
`.github/workflows/backfill-release-assets.yml` or the canonical
@ -2114,3 +2370,37 @@ for that volume before launching the wrapper, recover the same state during
uninstall, and keep the persisted boot copy aligned with updater-owned runtime
binary replacements instead of assuming `/usr/local/bin` survives reboot on
QTS/QuTS hero.
The in-app updater's apply pipeline now owns a downgrade guard on the normal
apply path. A syntactically valid release asset URL can name a release older
than the running binary, so `internal/updates/manager.go` `ApplyUpdate` must
reject any resolved target version at or below the running version, on both
the community release-asset path and the Pro broker path, before any history
entry is written or byte is downloaded. Sanctioned downgrades are an explicit
opt-in through the `AllowDowngrade` request flag carried by
`POST /api/updates/apply`, never a silent side effect of a stale download
URL. The guard fails open only for versions that do not parse as semver
(development builds), which stay covered by the existing URL and channel
validation. `internal/updates/manager_rollback_test.go` and the apply handler
tests in `internal/api/updates_test.go` are the direct proof surface for this
rule.
That same updater boundary now also owns the sanctioned rollback path from
retained update backups. `RollbackToBackup` in `internal/updates/manager.go`
restores the backup directory recorded on an update history entry after
re-validating that the path still names a managed update backup on disk,
shares the single update-in-flight slot with `ApplyUpdate`, records the
rollback as its own history entry with `Action` `rollback` and a
`RelatedEventID` back to the rolled-back update, marks that source entry
`rolled_back`, streams progress through the existing update status and SSE
machinery as the `restoring` stage, and restarts through the same
exit-for-systemd path as a normal update. The transport surface is
`POST /api/updates/rollback`, admin plus `settings:write` gated exactly like
apply, and the Settings update history table in
`frontend-modern/src/components/Settings/UpdateHistorySection.tsx` is the
user-facing rollback surface. Rollback is a purely local restore: it must not
touch the Pro download broker or any edition gate, so it behaves identically
on community and Pro binaries. The rollback tests in
`internal/updates/manager_rollback_test.go`, the rollback handler tests in
`internal/api/updates_test.go`, and the route inventory pin for
`/api/updates/rollback` are the proof surface for this path.

View file

@ -75,6 +75,7 @@ work extends shared components instead of creating new local variants.
46. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
47. `frontend-modern/src/components/Settings/ReportingPanel.tsx`
48. `frontend-modern/src/components/Settings/reportingPanelModel.ts`
48a. `frontend-modern/src/components/Settings/reportingSchedulesModel.ts`
49. `frontend-modern/src/components/Settings/reportingInventoryExportModel.ts`
50. `frontend-modern/src/components/Settings/useReportingPanelState.ts`
51. `frontend-modern/src/utils/reportingPresentation.ts`
@ -103,7 +104,6 @@ work extends shared components instead of creating new local variants.
71. `frontend-modern/src/utils/systemLogsPresentation.ts`
72. `frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx`
73. `frontend-modern/src/components/Settings/ResourcePicker.tsx`
74. `frontend-modern/src/components/Settings/reportingResourceTypes.ts`
75. `frontend-modern/src/utils/reportableResourceTypes.ts`
76. `frontend-modern/src/utils/reportingResourceTypes.ts`
77. `frontend-modern/src/utils/workloadEmptyStatePresentation.ts`
@ -274,6 +274,14 @@ deployment-lane-only panel. `UpdateInstallGuide` must render the canonical
update-plan readiness verdict inline with the update action, and a blocked
readiness status must make automatic install visibly unavailable until the
blocking check is resolved.
That install guide also owns the Pro-runtime Docker guidance: when the
compiled runtime identity is `pro` (`runtimeCapabilities().runtime.build`,
the same signal the update banner keys off), the Docker install steps and
the idle Docker box must render the license server broker's digest-pinned
commands from `UpdateInfo.dockerUpdate` (or an explicit Pro notice when they
are absent) and must never show the community `rcourtman/pulse` pull
commands, which would silently downgrade the container to the community
build.
`frontend-modern/src/components/shared/DiscoveryReadinessBadge.tsx` is the
shared presentation primitive for discovery freshness/readiness indicators.
@ -334,8 +342,11 @@ Namespace, ConfigMap, Secret, and ServiceAccount-specific columns. Kubernetes
Node inventory must also be reachable through a dedicated native tab, not only
the overview stack, while retaining the shared `PlatformSectionTabs` shell.
Primary app-shell navigation consumes unified-resources-owned resource evidence:
empty compatibility facets such as `docker: {}` do not admit runtime-lens tabs
on their own.
empty or generic compatibility facets do not admit runtime-lens tabs on their
own, and cached resource evidence must not render primary platform navigation
before the first authoritative resource snapshot resolves. A platform that is
not admitted stays reachable by direct setup URL, but does not occupy primary
navigation with an empty page.
Feature-owned Docker / Podman action controls may render backend
`actionReadiness` disabled reasons, but the shared primitive layer owns only the
button/table affordance shell; it must not invent action availability, command
@ -390,7 +401,9 @@ Docker empty-state guidance on platform pages follows the same shared platform
primitive boundary: it may use the route-specific Docker / Podman vocabulary,
but it must distinguish standalone Docker host installation from the Proxmox
LXC Docker host-side inventory path without adding page-local installer command
assembly or token handling.
assembly or token handling. The empty state must provide a direct action into
the Docker-specific Infrastructure add flow rather than leaving the operator at
a descriptive dead end.
Docker / Podman inventory follows that same primitive boundary while the
unified-resource owner supplies API-object-specific container, image, volume,
network, Swarm node, task, secret, and config columns through dedicated native
@ -528,7 +541,14 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
stay explicitly excluded.
7. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `security-privacy`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
8. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `security-privacy`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
9. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary.
These settings panels consume the privileged security-status projection,
while the shared status type also represents intentionally sparse public and
authenticated tiers. Privileged posture booleans therefore remain optional
at the transport boundary and must be normalized fail-closed before the
settings shell derives posture summaries or hardening actions. The first-run
shell must use generic host, Docker, and LXC bootstrap commands rather than
probing public status for deployment identity.
9. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`, `unified-resources`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
10. `frontend-modern/src/stores/aiChat.ts` shared with `ai-runtime`: the assistant drawer and session store is both an AI runtime control surface and a canonical app-shell presentation boundary.
Assistant session pickers and reloads must restore only safe
`handoff_summary` presentation state from the session list. Loading a plain
@ -610,6 +630,21 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
## Extension Points
Global Actions review uses the canonical shared `Dialog`, `Button`, `Subtabs`,
`Card`, `MetadataBadge`, and mobile navigation primitives. The Open/History
selector is an in-page sub-navigation surface and must compose `Subtabs`
instead of recreating a segmented tablist in `Actions.tsx`; queue rows may own
action state and resource semantics, but their frame and badge chrome stay on
the shared primitives. Actions uses the full content width supplied by the app
shell, matching Patrol instead of adding a page-local maximum-width container.
The review's policy provenance uses a native keyboard-operable disclosure so
the initial dialog layer stays calm without removing audit detail; intent,
exact target identity, safety/authority posture, and fail-closed provenance
warnings remain visible before expansion. The responsive route must preserve
named tabs, keyboard focus, dialog focus containment, and phone-width overflow
checks; journey 83 is the desktop/browser accessibility proof and is not
mobile-device proof.
Assistant shell entry changes must keep Assistant contextual rather than
generic: `AppLayout.tsx` and the command palette may expose a compact launcher,
but that launcher must attach current-view context before opening the drawer
@ -877,6 +912,14 @@ not a replacement status card, CTA band, or page-local nested card.
an existing panel/card frame must use the shared `frame="flush"` mode rather
than caller-local border overrides, horizontal-scroll wrappers, or negative
margin compensation.
Dense platform tables must also preserve readable columns at phone widths.
`PlatformTableShell` owns the responsive minimum-width policy, preserves any
explicit base floor declared by the feature table, and otherwise applies
the shared platform minimum before breakpoint-specific widths take over.
Narrow viewports keep document-level overflow
contained by `Table` and scroll the table itself; feature tables must not
squeeze every declared column into the viewport, override the shared floor,
or add a second page-local scroll wrapper.
Product-table subgroup/header rows must likewise consume the shared
`frontend-modern/src/components/shared/groupedTableRowPresentation.ts`
helper and `.grouped-table-row` CSS token contract instead of local
@ -1035,18 +1078,19 @@ not a replacement status card, CTA band, or page-local nested card.
`/settings/infrastructure`: the landing route should read as one
source-manager workspace with configured infrastructure instances first
and no redundant monitored-systems ledger beneath it. The landing route may
include a dedicated first-viewport toolbar that explains platform APIs and
Pulse Agent telemetry as infrastructure sources and exposes `Add
infrastructure`, `Run discovery`, and `Discovery settings` inside the
source manager. Per-source add actions, including `Install Pulse Agent`,
keep `Add infrastructure` in the Connected systems header while the ledger
remains the first primary content. Discovery is optional setup after the
ledger, not a competing first-viewport toolbar. Per-source add actions,
including `Install Pulse Agent`,
belong on the governed source rows, and `Detect address` stays inside the
single API-platform probe path instead of a duplicate toolbar action. It may
also show a compact coverage strip derived from the same unified connection
rows and discovered candidates so operators can confirm connected-system
count, API coverage,
agent coverage, sources that still need an agent, and discovery review state
without opening a tour or second ledger. Existing sources stay visible in
stable source-catalog order, and add, detect, install, review, and manage
also show one compact posture line derived from the same unified connection
rows so operators can confirm the top-level connected-system count, active
state, actionable health, and limited coverage without opening a tour or
second ledger. That line must expose the relevant install action when host
telemetry is missing rather than expanding into a metric strip. Existing
sources stay visible in stable source-catalog order, and add, detect,
install, review, and manage
flows open as
secondary interactions from that same destination instead of taking over the
whole page.
@ -1064,10 +1108,11 @@ infrastructure`, `Run discovery`, and `Discovery settings` inside the
The same shared shell boundary now also owns grouped source-row composition.
`useConnectionsLedger.ts`, `InfrastructureSourceManager.tsx`, and
`InfrastructureWorkspace.tsx` must render attached collection methods as a
plain-language row subtitle on the owning row (`via platform API`, `via
Pulse Agent`, or `via platform API and Pulse Agent`), with fuller detail in
the edit dialog, instead of duplicating the same machine across multiple
peer groups or forcing operators to decode badge jargon.
compact labeled badge beside the owning system (`API`, `Agent`, or `API +
Agent`), with the plain-language source phrase available through accessible
metadata and fuller detail in the edit dialog, instead of duplicating the
same machine across multiple peer groups or spending a dedicated Method
column on implementation detail.
The table-level product/system group rows in
`InfrastructureSourceManager.tsx` must also use the shared grouped table row
presentation helper, not local table-background classes, so source-manager
@ -1084,21 +1129,21 @@ Pulse Agent`, or `via platform API and Pulse Agent`), with fuller detail in
always-on version column for Pulse Agent; exact version text belongs in the
edit/detail surfaces, while the landing table only surfaces a compact
warning badge when an attached or standalone agent actually has an update
available. That same table boundary must reuse the existing `System` and
`Endpoint` cells for compact standalone-agent identity such as
`Unraid 7.1.0` plus a reported host address; it must not add a new
diagnostics column just to surface host facts the unified agent already
reports.
available. That same table boundary must reuse the `System` cell for compact
standalone-agent identity such as `Unraid 7.1.0`; raw reported addresses
belong in the governed Manage detail or an explicitly expanded
cluster-member row, not an always-on diagnostics column.
That same shared shell boundary now owns one canonical infrastructure
destination in the Settings sidebar. `InfrastructureWorkspace.tsx` owns the
source-manager landing inside that destination, while route-backed add flows
and local edit flows stay single-purpose instead of stacking multiple
page-level workspaces at once.
The source-manager landing now also owns the explicit discovery strip for
that destination. `InfrastructureSourceManager.tsx` exposes one Network
discovery status/action band from the shared landing shell with scan state,
saved scope, last result metadata, errors, `Run discovery`, `Discovery
settings`, and candidate review when discovered sources are waiting. It must
that destination. `InfrastructureSourceManager.tsx` exposes one optional
Discover Proxmox systems status/action band after the systems ledger with
scan state, saved scope, last result metadata, errors, `Run discovery`,
`Settings` / `Configure discovery`, and candidate review when discovered
sources are waiting. It must
not start a network scan just because the page rendered. New-source
admission belongs on the table's per-platform `Add` actions, the compact
first-run/readiness actions, or the discovery band's explicit review action,
@ -1402,6 +1447,10 @@ settings`, and candidate review when discovered sources are waiting. It must
compact-list or badge primitives to create protection-current,
verification-waiting, schedule-freshness, drift, trust, or proof strips;
empty work belongs to the plain empty-state and deliberate History affordance.
When canonical Patrol evidence is stale, that same empty-state primitive may
become warning-toned and direct the operator to run Patrol. The stale label
may appear in the compact work-group row only alongside real current work;
a calm queue must not repeat the same stale condition in both places.
Monitor-context Patrol coverage posture must not use the shared compact list
and badge primitives as a generic Proxmox overview or monitor-first
launch-page proof strip. A future scoped
@ -1488,6 +1537,21 @@ settings`, and candidate review when discovered sources are waiting. It must
arrays/maps and the backend registry projection instead of introducing
provider-specific JSX branches, local configured-state inference, or
browser-owned default endpoint facts.
Local subscription-agent providers are the deliberate exception to
credential inputs: their setup rows and first-run options render an
explicit boolean opt-in, explain that Pulse uses an already authenticated
same-machine CLI, and direct the operator to run provider readiness after
saving. They must not ask for, accept, display, or imply storage of an OAuth
token or API key, and must not present the opt-in itself as proof that the
CLI login or selected model works. Their models remain normal
provider-prefixed catalogue entries so Assistant, Patrol, service-context,
and shared-default selectors do not invent alias parsing in the browser.
The Ollama guided quickstart on that card (the copyable `ollama pull`
command block, the hardware-expectation note, and the post-test next-step
hint) renders the backend registry's `suggested_model` projection from the
settings payload; the browser must not hardcode blessed model IDs or their
equivalent tags, and the hint must compare the tested model against the
server-authored suggestion set rather than a frontend literal.
Provider connection controls are page-scoped: the global Pulse
Intelligence enable toggle, provider readiness strip, and Test Connection
action belong to `Provider & Models`; Patrol, Assistant, and Service Context
@ -1687,6 +1751,25 @@ default` instead of fusing provider and badge text such as
rejected no-execution terminal decisions, approved-action verification, and
external-agent parity.
17. Keep user column sorting on platform tables on the shared sort fabric.
Tables composed from
`frontend-modern/src/features/platformPage/sharedPlatformPage.tsx` that
offer user-facing column sorting must own it through
`createPlatformTableSortState` plus `PlatformSortableTableHead` rather
than page-local sort signals, ad hoc header buttons, or v5-style
sort-drives-grouping designs. The shared fabric owns the interaction
contract: click cycles a column's natural first direction, then the
flipped direction, then back to the table's built-in order; sort state
persists per table through `usePersistentSignal` storage keys; rows with
missing values sink to the bottom regardless of direction; headers expose
`aria-sort` and the arrow indicator consistent with the workloads table
header; and header alignment stays on the canonical
`getPlatformTableHeadClassForKind` helpers from `columnAlignment.ts`. A
table's default order remains its page-model status-first compare until
the user selects a column, and grouped modes (for example Docker's
grouped-by-host containers view) sort within groups while grouping itself
stays orthogonal to sort state.
## Forbidden Paths
1. Reinventing table/filter/toggle primitives when a shared version exists
@ -1942,7 +2025,10 @@ default` instead of fusing provider and badge text such as
token only unlocks first-run setup, state where the command should run, and
adapt command/help text to detected Docker or containerized deployments
instead of assuming the operator already knows which host or container owns
the Pulse install.
the Pulse install. Bootstrap validation must remain an explicit operator
action rather than auto-submitting on a token-length heuristic, and it must
be single-flight so one successful validation advances the wizard exactly
once even when click and keyboard submission overlap.
16. Keep the settings-shell infrastructure landing path aligned with that same
first-session story. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
must treat `/settings` and the infrastructure settings tab as the canonical
@ -2792,6 +2878,10 @@ handoff remains limited to explicit Plans, hosted, activation, recovery, or
support surfaces; feature gates may show neutral "View plans" links through
`frontend-modern/src/utils/upgradePresentation.ts` only where presentation
policy allows commercial discovery.
The shared self-hosted billing presentation and plan-section prop contract must
therefore expose only the ordinary typed plan handoff. It must not retain a
parallel trial label, card-required note, `trial=1` destination, or dormant
trial-action slot after self-hosted trial acquisition has been retired.
Top-level route files are now expected to stay thin when a feature owns the
real product surface, but the former `/infrastructure` surface is not one of
those compatibility cases. It never shipped as a stable v6 route, so
@ -3137,6 +3227,15 @@ divide frame locally. Platform table consumers must preserve the owner split:
frontend-primitives owns the repeated `PlatformTableShell` frame and guardrail
registry, while platform and unified-resource consumers own the source-specific
row fields, drawers, and resource semantics.
Standalone Pulse Agent and Availability consumers may compose one compact
status summary directly above that shared table frame. The consumer owns the
already-loaded resource counts, freshness-aware attention ordering, and
settings action; the shared primitive boundary still forbids a second fetch,
detached proof strip, decorative chart, or locally recreated table frame.
The shared platform toolbar owns count grammar and canonical status-route
normalization: one visible row uses the singular form, and legacy provider
status values such as `running` or `stopped` normalize into the page's shared
health filter rather than leaking provider vocabulary between platform routes.
Platform table empty states follow the same registry-backed ownership.
`PlatformTableEmptyState` owns the repeated table-card empty-state shell for
Docker, Kubernetes, Proxmox, Standalone, TrueNAS, vSphere, and future platform
@ -3410,6 +3509,11 @@ of pushing tab-order or DOM lifecycle logic back into the shared shell. With
support/admin controls moved under Settings, that utility ordering must no longer
reserve a standalone `operations` slot; alerts, Patrol, and Settings are the
remaining authenticated utility tabs.
The platform rail is the only horizontally scrolling region in that shell.
Alerts, Patrol, and Settings stay pinned in a separate utility rail so primary
platform breadth cannot push those global destinations beyond the phone
viewport, while active-platform centering and fade state remain scoped to the
primary rail.
The shared command palette now follows that same owner split.
`frontend-modern/src/components/shared/CommandPaletteModal.tsx` stays the
render shell, `frontend-modern/src/components/shared/useCommandPaletteState.ts`
@ -3846,7 +3950,13 @@ top-level settings shell, while
`frontend-modern/src/components/Settings/updatesSettingsModel.ts` plus
`frontend-modern/src/utils/updatesPresentation.ts` own the
deployment-specific install guide, copy-command block, and update-channel/install
model data plus customer-facing update status/action copy. The panel shell must
model data plus customer-facing update status/action copy.
`frontend-modern/src/components/Settings/UpdateHistorySection.tsx` joins that
split as the presentation owner for the update history table and the
rollback confirmation dialog: the panel shell mounts it as a section and must
not inline history rows, rollback gating, or rollback confirmation copy
itself, and the section starts rollbacks through the shared
`updateStore.rollbackUpdate` action rather than its own POST path. The panel shell must
not rebuild copy-to-clipboard command cards, deployment instruction trees, or
update-surface wording inline. `CopyCommandBlock` must use the shared
`copyToClipboard` helper so install/update/agent snippets keep the same
@ -3865,9 +3975,9 @@ request/range/filename model and reporting-type API mapping,
`frontend-modern/src/utils/reportableResourceTypes.ts` own the reportable
resource selection, filter, sort, and empty-state contract, and
`frontend-modern/src/utils/reportingPresentation.ts` owns the user-facing
range/status copy. The compatibility re-export in
`frontend-modern/src/components/Settings/reportingResourceTypes.ts` stays part
of that same reporting boundary. Native Kubernetes inventory-only resource
range/status copy. Consumers import `@/utils/reportingResourceTypes` directly;
the former one-line compatibility re-export under `components/Settings/` was
removed as dead code once its last importer moved. Native Kubernetes inventory-only resource
types, including ReplicaSets, EndpointSlices, NetworkPolicies, StorageClasses,
ConfigMaps, Secrets, ServiceAccounts, Roles, ClusterRoles, RoleBindings,
ClusterRoleBindings, ResourceQuotas, LimitRanges, PodDisruptionBudgets, and
@ -4788,6 +4898,13 @@ shared buttons must preserve discriminated disclosure props, toggle and a11y
helpers must expose exact event signatures, shared rows must accept typed
`data-*` props, and reporting-panel helpers must remain ES2020-safe instead of
depending on feature-local casts or newer string helpers.
Settings report scheduling follows the same shell/runtime/model split as the
rest of the Reports panel. `ReportingPanel.tsx` owns layout and shared controls,
`useReportingPanelState.ts` owns API lifecycle and save/run/delete control
flow, and `reportingSchedulesModel.ts` owns schedule payload normalization,
labels, default form state, and cadence formatting. Schedule scope selection
must reuse the shared `ResourcePicker` and reporting catalog types rather than
creating a separate resource selector or browser-local schedule API contract.
The settings navigation model now exposes a single `infrastructure-systems`
sidebar entry for the infrastructure settings area. The former
`infrastructure-connections` and `infrastructure-install` entries have been
@ -4842,3 +4959,41 @@ computers monitored only by agentless reachability checks may use
checks until a Pulse Agent registers and supplies CPU, memory, disk, and network
telemetry. Machines empty and handoff actions must lead to Pulse Agent install
or the Availability checks tab, not to an agentless machine row in Machines.
Mobile product layout is a shared primitive contract, not a page-local styling
exception. At supported phone widths, tab rails and dense data surfaces must
preserve their readable intrinsic width inside an owned horizontal scroll
container; expanded inline detail must remain bounded by the visible viewport;
and active destinations must be scrolled into view. Shared search, filter,
disclosure, navigation, copy, and row-action controls must keep a 40-pixel
mobile touch floor while retaining their compact desktop density. Settings
navigation must own a viewport-bounded vertical scroll region so its route list
does not push the active panel below the page.
Patrol finding handoffs must derive approval posture from the canonical typed
action state and approval policy, not only from a legacy approval id. A
`pending_approval` action or any non-`none` approval floor remains explicitly
approval-bound in shared handoff metadata so Assistant, the collapsed finding
row, and the expanded action review cannot disagree.
The shared Actions dialog remains the responsive and accessible review
primitive for typed APT maintenance. Its heading supplies the dialog accessible
name, the close control has an explicit name, pending action rows are keyboard
reachable, and the scroll-bounded panel keeps safety, execution, verification,
recovery, delivery, and next-step content actionable at desktop and 390-pixel
phone viewports. Responsive layout must not hide the exact parameter authority,
evidence source, or recovery instruction, and it must not add a duplicate legacy
action path or verification card when `ActionResultV2` is present. Read-only
sessions keep the review packet inspectable but must not render approve, reject,
or run controls, while settled historical records must not be mislabeled as
expired actionable reviews.
Its action controls are also plan-identity-bound: a missing reviewed `planHash`
renders explicit replan guidance and hides approve, reject, and run controls,
while an actionable record sends the exact displayed hash on every mutation.
The dialog is also route-backed through the canonical `action` query parameter.
Contextual surfaces use the shared button-link primitive to hand off an exact
typed action id; the Actions route opens that durable review directly, selects
the matching Open or History subtab from server-authored lifecycle state, and
removes the query when the dialog closes. Feature pages may summarize action
context, but they must not recreate approve, reject, run, progress, or outcome
controls outside the shared Actions review.

View file

@ -37,10 +37,12 @@ snapshot fetches must run through the fixed worker pool in
`internal/monitoring/monitor_backups.go`, reuse cached snapshots on per-group
fetch failures, and must not allocate one goroutine or buffered result slot per
backup group in large PBS datastores. Live PBS backup state is intentionally
bounded: groups are processed newest-first, large groups are represented by
their newest group metadata instead of every snapshot, per-group snapshots are
capped to the newest bounded set, and the per-instance PBS backup list must not
grow without an explicit monitoring-owned limit. PBS backup group cache metadata
bounded: groups are processed newest-first, per-group snapshots are capped to
the newest bounded set of real fetched snapshots, and the per-instance PBS
backup list must not grow without an explicit monitoring-owned limit. Bounding
must never synthesize placeholder backup entries from group metadata: a
placeholder drops verification, size, file, and per-snapshot time data, which
users read as broken discovery and failed verification. PBS backup group cache metadata
must be pruned to the retained group set after a completed poll, while
preserving cache metadata only for groups still observed or intentionally reused
after transient datastore failures. Recovery-point ingestion started by backup
@ -59,12 +61,25 @@ per-core CPU percent, but monitoring-owned history and alert threshold
evaluation use host-capacity-normalized CPU percent when host CPU capacity is
known. Raw runtime CPU remains alert/resource metadata, not the canonical
threshold value.
Docker and Podman container OOM state is runtime-authored evidence, not an
exit-code inference. Current agents must publish Docker inspect's `OOMKilled`
boolean for every inspected container, including explicit `false`; monitoring
must preserve that nullable boolean through report ingest, internal/frontend
models, and unified resources. An absent value means an older or reduced-fidelity
report did not provide the evidence and must remain distinguishable from both
confirmed OOM and confirmed non-OOM state. Exit code 137 proves only SIGKILL and
must not be promoted into OOM truth by monitoring.
Proxmox read-state rehydration is the inverse boundary: canonical
unified-resource CPU metrics are 0..100 percentages, while legacy
`models.Node.CPU`, `models.VM.CPU`, and `models.Container.CPU` remain Proxmox
0..1 ratio fields. Monitoring-owned read-state conversion must divide canonical
Proxmox node, VM, and LXC CPU percentages before handing them back to legacy
snapshot/current-row paths.
Tenant monitor enumeration is monitoring-owned runtime topology, not a
reporting source of truth. `MultiTenantMonitor.ListOrganizationIDs` may expose
persisted organization IDs to API-owned background workers, but it must not
initialize monitors, start pollers, or reinterpret tenant IDs as monitored
resource health.
## Canonical Files
@ -103,6 +118,8 @@ snapshot/current-row paths.
33. `internal/monitoring/docker_detection.go`
34. `internal/monitoring/monitor_polling_containers.go`
35. `internal/mock/fixture_graph.go`
35a. `internal/mock/action_fixtures.go`
35b. `internal/mock/availability_fixtures.go`
36. `internal/dockeragent/docker_client.go`
37. `pkg/agents/docker/report.go`
38. `internal/models/models.go`
@ -124,11 +141,14 @@ snapshot/current-row paths.
54. `internal/monitoring/monitor_backups.go`
55. `internal/monitoring/resource_stale_thresholds.go`
56. `internal/monitoring/recovery_ingest.go`
57. `internal/monitoring/multi_tenant_monitor.go`
58. `internal/monitoring/proxmox_action_observer.go`
## Shared Boundaries
1. `internal/kubernetesagent/agent.go` shared with `agent-lifecycle`: the Kubernetes native agent runtime is both a monitoring inventory source and an agent lifecycle Pulse control-plane transport client.
2. `internal/proxmoxidentity/backup_identity.go` shared with `alerts`, `storage-recovery`: Proxmox PBS backup subject identity is a shared runtime boundary for monitoring backup freshness, backup-age alert attribution, and recovery-point guest mapping.
3. `pkg/agents/host/report.go` shared with `agent-lifecycle`: the Unified Agent host report is both an agent lifecycle authored-state contract and a monitoring ingest contract for host maintenance posture.
## Extension Points
@ -204,6 +224,11 @@ snapshot/current-row paths.
and Docker container CPU alerts must pass through the shared normalized
capacity helper so an 80% threshold means 80% of the reporting host capacity,
not 0.8 of one core on a multi-core host.
Container OOM evidence must come from the inspected runtime state. The report
wire field is nullable for compatibility with older agents, but a current
collector must set it to the exact Docker inspect boolean even when false;
report ingest and model conversion must clone and preserve the pointer so
concurrent state replacement cannot alter previously accepted evidence.
8. Add or change Proxmox Ceph compatibility payload decoding through `pkg/proxmox/ceph.go`
9. Add or change Proxmox ZFS compatibility payload decoding and vdev-role normalization through `pkg/proxmox/zfs.go`
10. Add or change mock chart synthesis, seeded history continuity, or mock-owned
@ -231,6 +256,13 @@ snapshot/current-row paths.
guest slices as the primary source of truth. Clustered PVE snapshot polling
must therefore see guests collected earlier in the same cycle before calling
the Proxmox guest snapshot APIs.
Guest lookups handed from backup/snapshot polling to alert evaluation must
preserve the canonical instance, node, VMID, type, live display name, and
live guest tags from read-state. Monitoring must key snapshot lookups with
the shared alert guest identity and must not downgrade the handoff to a
name-only map, because ignored-name prefixes, `pulse-no-alerts`, ignored
tags, and required-tag filtering are alerts-owned policies that require the
same guest context as ordinary threshold evaluation.
PBS backup snapshot refreshes in that same file must stay bounded by the
package worker-pool constant and stream requests through workers instead of
creating one goroutine per backup group; per-group API failures may reuse
@ -312,6 +344,21 @@ snapshot/current-row paths.
dataset, app, VM, share, and disk names from different appliances remain
distinct. Mock fixture metrics and seeded/live history must use the same
scoped source keys as the TrueNAS provider metrics targets.
The owning system source key itself is connection-scoped: the poller
constructs live providers through `NewLiveProviderForConnection` so
`systemSourceID` keys the system (and every child scoped under it) by the
configured connection ID, never by the snapshot-reported hostname, and the
system's ingest identity carries no machine key (DMI serials are shared by
DR clones and can be vendor placeholders). Two appliances that report the
same hostname must remain distinct resources (#1573, #1575). The hostname
arm of `systemSourceID` exists only for fixture snapshots, which carry no
connection; mock-mode identities stay hostname-scoped through that arm.
Regression coverage: `TestTrueNASPollerKeysSystemsByConnection` in
`internal/monitoring/truenas_poller_test.go` and
`TestRegistryIngestRecordsKeepsSameHostnameSystemsDistinct` in
`internal/truenas/contract_test.go`. The canonical-ID migration semantics
for rows minted under the retired hostname-keyed derivation live in the
unified-resources contract (record-declared succession, item 27).
TrueNAS storage and alert inventory follow native query methods first:
pools use `pool.query`, datasets use `pool.dataset.query`, disks use
`disk.query` with pool join options, and alerts use `alert.list`, with
@ -443,6 +490,52 @@ snapshot/current-row paths.
## Current State
Unified Agent host reports now make module readiness and updater/config
lifecycle evidence monitoring-owned observed state. Monitoring preserves the
last successful one-shot update transition across subsequent reports, forwards
the applied config fingerprint without config values, and records Host,
Docker/Podman, and Kubernetes module failures so API consumers can distinguish
an active process from an initialized monitoring source. The Kubernetes module
uses the shared agent TLS constructor for custom CA and leaf-fingerprint trust;
its Pulse transport must not regress to a Kubernetes-local insecure-only TLS
configuration.
The same host report carries bounded OS package-update posture: supported
manager, pending count, package/version identifiers, inspection time,
reboot-required state, and an operator-safe inspection error. Monitoring owns
normalization and deep-copying of that observed state, and stamps inventory
freshness from server receipt time so a skewed agent clock cannot keep update
authority fresh indefinitely; it does not infer
updates from kernel strings, refresh package indexes, install packages, or
convert the presence of an update into execution authority.
The host report also carries bounded package-cache cleanup posture: supported
provider, reclaimable byte count, fingerprint, inspection time, and an
operator-safe error. Monitoring stamps freshness from server receipt time and
normalizes that scalar evidence without receiving cache entry names or paths.
It does not infer cleanup eligibility, run cache scans server-side, or turn
reclaimable bytes into mutation authority.
The authenticated Unified Agent report also carries `OperationReceiptVersion`
as monitoring-owned runtime ingest metadata. Absence, zero, unknown, and future
versions are unsupported; monitoring must never infer support from an agent or
product version string. Each accepted report replaces the prior value, so an
agent replacement or compatible-to-legacy downgrade immediately removes the
receipt-protocol prerequisite for actionable update and cleanup capabilities.
The raw protocol integer stays internal to agent transport, monitoring ingest,
canonical capability construction, and dispatch readiness. Customer-facing
resource and frontend contracts expose only the derived capabilities. A
compatible report is necessary but never sufficient mutation authority: the
agent execution server's live, authenticated connection recheck remains
authoritative immediately before durable action admission and dispatch.
HTTP availability probes consume the shared explicitly unverified,
parseable-peer-certificate capture
boundary used by connection discovery, so support for operator self-signed
endpoints does not create independent skip-verification configurations.
Direct mock-node generation also clamps its allocation count to the canonical
fixture bound even when called below the normal configuration-normalization
entry point. The backing slice uses that fixed canonical capacity rather than a
request-derived capacity, while the normalized count continues to determine the
generated fixture length.
The monitoring-owned storage metrics runtime must preserve store-backed storage
chart continuity during resolver warm-up. `syncUnifiedStorageMetrics` must
prefer the resolver's canonical storage metrics target when it exists, but must
@ -714,6 +807,13 @@ broadcast channel in both direct nil and typed-nil forms. Tenant-scoped
background monitors can start in headless test or maintenance runtimes before a
hub is wired, and state publication must no-op safely instead of dereferencing
a nil `*websocket.Hub` during ticker refresh.
That same headless runtime boundary applies to agent ingest itself. Once
`ApplyHostReport`, `ApplyDockerReport`, or `ApplyKubernetesReport` accepts and
finishes applying a report, monitoring must refresh the canonical monitor
adapter before returning so typed `ReadState`, `/api/resources`, and Patrol see
the accepted host, workload, and cluster truth even when no websocket clients
are connected. Websocket client presence and broadcast hydrate are delivery
concerns; they must not gate canonical agent-report publication.
That same Docker/Podman monitoring boundary now also owns Docker
authorization-plugin posture. `internal/dockeragent/collect.go` must project
`system.Info().Plugins.Authorization` into the canonical agent report,
@ -880,6 +980,12 @@ so the Kubernetes platform-page Configuration tab exercises the same
RBAC summary-count contract live agents use. The
`TestKubernetesDemoClustersTellDistinctStories` test in
`internal/mock/demo_scenarios_test.go` guards that distribution.
Generic Kubernetes fixture synthesis must also preserve at least one ready,
schedulable node whenever a cluster has nodes. Randomized readiness and cordon
stories may degrade the remaining nodes, but they must not accidentally create
a total outage that erases running-pod metrics or makes the demo and its proof
nondeterministic; explicit curated outage scenarios remain the owner of
cluster-wide unavailability.
Bumps to those defaults must keep the
curated demo scenario's per-node hostname seasoning in
`demo_scenarios.go` aligned (today: pve1..pve6 with regional labels,
@ -1380,6 +1486,11 @@ Availability mock fixtures belong to that same graph authority: UPS network
cards, MQTT meters, HTTP panels, and controller ping targets must be authored
once in `internal/mock/` and then projected into availability status, unified
resources, and connections payloads from that shared graph.
Governed action mock fixtures follow the same rule. Pending, approved,
executing, completed, rejected, and failed action examples are authored once in
`internal/mock/action_fixtures.go`, reference resources from the graph's
canonical unified-resource snapshot, and are projected by the read-only action
API without writing demo rows into the durable action-audit database.
That same boundary now also owns native disk-history fallback when Pulse's own
history is shallow. `internal/truenas/client.go`,
`internal/truenas/provider.go`, `internal/monitoring/truenas_poller.go`, and
@ -1578,3 +1689,22 @@ belongs to a future PVE `/cluster/backup` source. PBS task-history reads must
therefore use a bounded filtered lookback over `/nodes/localhost/tasks` and
surface truncation or permission gaps explicitly instead of treating one recent
unfiltered sample as configured backup-job proof.
Task 09 preserves two APT telemetry clocks at host-agent ingest: `CheckedAt`
is agent-observed time and `ObservedAt` is server-received time. Monitoring
must not overwrite the former with the latter; replay and skew safety consumes
both timestamps downstream.
Monitoring now also exposes a bounded direct Proxmox guest observation for the
governed action verifier. `ObserveProxmoxGuest` resolves the configured
instance client under the monitor lock, reads VM or LXC status and uptime from
the Proxmox API, validates the requested guest identity, and stamps server
observation time. It must not satisfy this contract from cached resource state
or node-agent telemetry: the action layer depends on this read remaining in a
trust domain distinct from the node agent that executes `qm` / `pct`.
Canonical Proxmox VM/LXC typed views also expose the monitoring-owned source
delivery status and timestamp alongside guest power state. Downstream Patrol
transition detection consumes that status instead of inventing a fixed stale
window: a stopped guest can have fresh inventory, while a stale source cannot
authoritatively prove either a stopped transition or recovery.

View file

@ -177,6 +177,22 @@ cannot treat raw config strings as header fragments or `RCPT TO` input.
That same SMTP boundary also owns MIME-safe body construction. Text and HTML
payloads must be emitted through canonical multipart writers with encoded body
parts instead of being concatenated directly into handcrafted message bodies.
That same SMTP boundary also owns email threading identity. An email that
covers exactly one alert occurrence must carry In-Reply-To and References
headers set to a deterministic incident thread ID derived from the alert ID
plus firing start time, so mail clients thread the firing, re-notification,
and resolved messages of one incident together. The per-send Message-ID must
stay unique: re-notified incidents emit multiple emails, and providers that
de-duplicate on Message-ID would silently drop repeats, so incident identity
may ride only in the threading headers. Grouped emails covering multiple
alerts must not carry a group-level thread identity, because firing and
resolved batches are not guaranteed to contain the same alert set and a
group-level reference would attach messages to the wrong thread.
Scheduled report delivery uses that same enhanced email boundary. Report
attachments must be emitted as MIME attachment parts by
`internal/notifications/email_enhanced.go`, and oversized-report fallback copy
belongs to the reporting scheduler. SMTP transport, recipient parsing,
headers, body encoding, and attachment encoding remain notification-owned.
That same queue ownership also governs persistent queue storage roots. The
notifications queue database must normalize its owned data directory and
resolve the fixed `notification_queue.db` leaf through the shared storage-path

View file

@ -330,3 +330,30 @@ The hidden-upgrade posture also applies to locked-card titles and bodies
themselves: RBAC gates may show neutral unavailable-capability copy, but must
not keep `(Pro)`, plan-tier labels, or paid-action wording after the CTA is
suppressed.
Organization Settings is a browser-session administration surface even when
first-run also leaves a generated API token in browser storage. Its API client
must prefer the active session cookie for organization reads, invitations,
membership, sharing, ownership, and billing administration, while preserving
token fallback only when no session exists. Mobile proof must render Overview,
Access, Sharing, and Billing with populated multitenant fixtures at supported
phone widths; hosted Billing Admin requires a real hosted-mode capability
runtime and expanded-row proof rather than being counted through a redirect.
Governed actions now bind the authenticated actor to the request organization
and re-evaluate current membership plus granular plan/approve/execute RBAC at
the lifecycle decision and execution boundaries. No caller-supplied requester,
browser scope shortcut, or detached token can substitute for current tenant
authority. This Phase B1 slice defines the canonical per-action requirement and
supports quorum/separation semantics with durable decision CAS, but it does not
claim provider/MSP delegation, provider inheritance, tenant policy-default UI,
distributed budgets, or multi-actor UI; those remain governed L20 residuals
rather than organization-settings-local policy models.
Patrol Autopilot acknowledgement is tenant-bound backend authority, not an
organization-settings-local consent flag. The persisted actor organization,
activation organization, and evaluated request organization must match; a
self-digesting foreign revocation or activation cannot be attributed to or
activate the victim tenant. Each tenant's atomic AI config owns its history,
while provider/MSP inheritance, delegated acknowledgement, tenant-default UI,
and multi-actor administration remain explicit residual product work.

View file

@ -59,6 +59,16 @@ Patrol-specific presentation helpers.
## Extension Points
Desktop Autopilot activation consumes the server-owned acknowledgement
contract through `frontend-modern/src/api/patrol.ts` and
`PatrolAutopilotAcknowledgementDialog.tsx`. The UI displays requested versus
effective mode, records the current acknowledgement version before full-mode
activation, exposes revocation, and treats expiry, revocation, version drift,
and activation races as server-authored demotion. Proof is
`PatrolAutopilotAcknowledgementDialog.test.tsx`,
`usePatrolIntelligenceState.test.ts`, and desktop journey 82. This statement
does not certify mobile or physical-device behavior.
Open work descriptions are Patrol-owned operator guidance. They may mention the
visible next step, approvals, and verification results when those words help the
operator understand what to do next, but they must not become a separate proof
@ -75,11 +85,17 @@ or historical proof/counting for resolved-only work.
problem above the list. When active Patrol findings are the next operator
step, the finding row owns the canonical primary action, contextual
Assistant handoffs, approvals, verification, and manual controls. A
compact row-owned issue scaffold may translate the same finding, approval,
and workflow fields into problem, affected resource, consequence, checked
evidence, safe workflow step, next step, and verification state, but it must
stay attached to the active issue row and must not become a separate proof,
trust, or status strip. Because a
compact row must expose only what the operator needs to prioritise the
queue: severity, current workflow state, title, affected resource, recency,
and one bounded consequence summary. Checked evidence, recommendation,
approval controls, verification state, history, and manual controls belong
to the single selected issue review beside the queue; the collapsed list
must not render those fields as an always-open checklist. Findings may be
grouped only from API-owned identity facts: the same canonical resource, a
shared parent node, or explicit correlated finding IDs. Grouping must not
infer causality from title or description prose, and the queue's affected
resource count must continue to count canonical resources rather than
visual groups. Because a
contextual Assistant handoff from that workflow is still a first-party Patrol
starter for the same governed journey, it must record content-free workflow
prompt activity through the shared marker route with the `pulse_patrol`
@ -99,6 +115,14 @@ or historical proof/counting for resolved-only work.
may point to History for past outcomes, but the workspace must not render a
calm-day protection-posture, verification-waiting, schedule-freshness, drift,
trust, or proof strip just to restate that no current work exists.
A calm-looking queue is not an all-clear when its checking evidence is
stale. `patrolControlPresentation.ts` owns freshness derivation from the
latest completed check, the server-authored Patrol interval, and the next
scheduled check. Alongside current work, stale coverage may appear as the
existing compact cross-source work-group row. On a calm queue, the
current-findings empty state alone must become warning-toned so the same
stale condition is not repeated; neither case may add a second protection,
schedule, or proof strip.
Monitor-context coverage posture is a separate presentation boundary, but it
must not render as a generic proof strip on Proxmox overview or other
monitor-first launch pages. A future monitor-specific Patrol affordance must
@ -318,7 +342,7 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
Intelligence settings and external-agent surfaces own MCP setup/readiness,
while API security owns only the scoped-token creation surface they link to.
Patrol mode labels and details must match the backend execution policy:
assisted mode may say it can run low or medium-risk fixes allowed by policy,
assisted mode may say it can run low-risk fixes explicitly allowed by policy,
but it must not imply that warning severity alone makes a command safe;
high-risk, critical, destructive, or unknown-risk fixes remain approval-bound
unless the operator has selected a broader autonomy level. Compact Patrol
@ -536,6 +560,11 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
Historical Patrol trust regressions must also suppress a green all-clear in
the current findings empty state: `0 active findings` means no current Patrol
work, while prior regressions remain Patrol history review context.
Stale Patrol coverage must suppress the same green all-clear even when the
last completed check found no issues. Freshness uses at least a 24-hour
tolerance and otherwise allows two configured Patrol intervals before
warning, so deliberately slower schedules are not mislabeled while an old
default-schedule result cannot remain green indefinitely.
Assessment coverage caveats must also reconcile against current run-history
proof: a stale coverage factor or prediction must not claim recent coverage
is incomplete when the latest completed full Patrol run successfully checked
@ -668,7 +697,7 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
current finding status, recurrence, investigation record facts, evidence,
verification, approval state, dry-run posture, existing action artifact
summary, target resource references, and governed action references without
raw command payloads. Inline Patrol approval actions in
raw command payloads. Patrol action summaries in
`frontend-modern/src/components/patrol/ApprovalSection.tsx` that open
Assistant must follow that same Patrol-owned handoff model rather than a
prompt-only local shortcut: pass approval ID/status/risk/target plus safe
@ -897,6 +926,19 @@ fix`, or `Explain` based on current finding state), while secondary
## Current State
The active Patrol queue now uses compact severity-accented rows for
prioritisation and a single focused review panel for the selected issue. The
collapsed row carries severity, actionable state, title, resource and recency,
plus one consequence line; evidence, recommendation, approval, verification,
history, and manual controls render only in the selected review. The previous
seven-field always-visible row scaffold and duplicate Open work count badge
were retired. Active findings group through API-owned same-resource, parent
node, or explicit correlation relationships, while the workspace continues to
count the underlying affected resources. Component, presentation, and browser
journey proof live in `FindingsPanel.links.test.tsx`,
`FindingsPanel.test.ts`, `aiFindingWorkType.test.ts`, and
`tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts`.
Patrol provider-repair actions now use the canonical Pulse Intelligence >
Provider & Models route `/settings/pulse-intelligence/provider`. The legacy
`/settings/system-ai` route remains a compatibility alias for old deep links,
@ -950,6 +992,21 @@ not inside the secondary Schedule & model drawer: the first configurable decisio
what Patrol may handle automatically (`Watch only`, `Ask first`,
`Safe auto-fix`, `Autopilot`), and there must be only one visible chooser for
that decision.
Per-resource automatic-action scope is configured in the canonical resource
drawer, not duplicated on the Patrol page. The drawer may expose only
capabilities whose backend contract declares auto-authorization eligibility,
requires an explicit capability allowlist, and may collect an optional daily
time/timezone window. Patrol mode remains the tenant-wide upper bound: a
resource opt-in cannot widen Watch only or Ask first, admit elevated work in
Safe auto-fix, bypass the full-mode unlock, or override Never auto-remediate.
Pending Patrol actions snapshot the bounded capability, tenant Patrol, and
resource operator authorities actually consulted at planning into the
server-authored action `policyDecision`. Its typed reason codes and revisions
are explanatory evidence for Task 11, including unavailable, missing,
emergency-stop, mode, allowlist, Never, and window posture. Planning and
dispatch share one pure evaluator, but dispatch re-fetches current inputs and
persists a separate authorization lease; the snapshot cannot authorize an
automatic action or suppress current-policy revocation.
Commercial, runtime, and documentation copy that describes this same decision
must also use those visible labels and the umbrella name `Patrol mode`, not
the retired `Only watch`, `Fix safe issues`, `Full control`, or generic
@ -1625,7 +1682,11 @@ owning finding must return to the needs-attention path. If malformed approval
records still reach presentation helpers, they must sort after valid timestamps
and must not produce non-deterministic comparator results. Patrol approval
banners must apply the same fail-closed timestamp posture to visible countdown
copy instead of rendering invalid math such as `NaN`.
copy instead of rendering invalid math such as `NaN`. The banner is a
contextual handoff, not a second decision surface: one pending approval
deep-links to the exact typed action when its canonical action id is present,
while multiple or legacy approvals lead to the open Actions inbox. It must not
approve, reject, or execute an action inside Patrol.
Patrol fix approvals also inherit the unified action-governance preflight
contract: queued fixes must keep their plan-level dry-run availability, safety
checks, verification steps, approval policy, and action id in the shared
@ -1932,3 +1993,57 @@ either a rejected governed decision or an approved governed decision with
verified outcome evidence. Patrol may use the resolved-loop count only as
stricter approved-and-verified detail after the loop also has an approved
governed decision and verified outcome evidence.
The typed `ActionReference` is the primary Patrol workflow model in both the
finding row and expanded action summary. Pending actions identify required
review; planned or approved actions identify runnable work; executing actions
say running; terminal actions present verified, failed, or honestly
inconclusive verification. Patrol owns that detection and investigation
context, while the dedicated Actions route is the canonical operator hub for
decision, execution, progress, and durable outcome history. The expanded
`ApprovalSection` renders the bounded plan, preflight, safety checks,
verification steps, and rollback availability, then deep-links by exact action
id into the shared Actions review. It must not call `/api/actions` decision or
execute mutations itself. Legacy `ProposedFix` and
`ApprovalID` data may explain historical records but must never reveal a raw
command, present an approve/run control, or reconstruct an executable action;
when the typed reference is absent the UI says action details are unavailable
and offers an Assistant handoff. Collapsed-row attention state must consult the
same investigation action reference, so it cannot claim there is no approval
while the expanded panel has one. Browser proof must exercise pending,
terminal-verified, and legacy-history states rather than judging only source.
The typed reference must retain the exact reviewed `planHash` so Patrol can
surface missing identity as an explicit replan-required state before handing
off. The Actions review remains the only browser surface that may expose
approve, reject, or run controls, and every mutation there passes the same
displayed hash through the canonical browser client; missing identity never
falls back to action id alone.
Backend Patrol finding reconciliation now reads `ActionResultV2`: execution
failed or known-not-run maps to fix failure; confirmed verification maps to
verified; contradicted verification maps to verification failure; and
not-attempted or inconclusive verification remains verification unknown. A
successful execution never overwrites contradictory verification. Task 11
still owns browser wording and proof for the distinct terminal states.
Patrol now consumes the server-derived effective Autopilot mode. Requested
`full` is admitted only with a current persisted human acknowledgement and
exact activation for the same actor credential and organization; legacy
booleans, revocation, expiry, version rotation, and malformed evidence fall
back to approval mode before policy-authorized action submission. The accepted
limits explicitly allow inconclusive verification and never turn execution
success into outcome truth. Task 11 still owns acknowledgement presentation,
cancel/no-submit browser proof, and device coverage; M7 remains open until that
work and Task 12 certification are complete.
APT Patrol findings preserve the backend-owned finding `key` through the
canonical frontend store. Presentation recognizes only the exact bounded update
or cleanup observation envelope, validates non-negative safe integer counts,
finite 0..100 filesystem usage, and valid agent-observed/server-received
timestamps, and suppresses inventory hashes and fingerprints from the default
monitoring view. Invalid evidence fails closed to a refresh-before-acting
message. A reboot-required observation explicitly says that neither the finding
nor its action authorizes a reboot. Contradicted, inconclusive, or health-unknown
postconditions remain actionable; only canonical confirmed postcondition truth
may support resolution. Package-manager internals remain forensic detail rather
than the least-expert default.

View file

@ -18,6 +18,12 @@
Own measurable performance budgets, query-plan guarantees, and hot-path
regression protection.
Dispatch-time Patrol authorization uses one bounded policy snapshot and one
storage CAS on the execution hot path. Policy writers serialize against that
admission coordinator; the broker must not add policy-history scans, resource
registry walks after admission, or network probes before the executor. Failed
admission records a stable refusal without invoking executor or network code.
## Canonical Files
1. `pkg/metrics/store.go`
@ -140,9 +146,29 @@ regression protection.
9. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` shared with `unified-resources`: unified resource table state derivation, sort-cycle policy, service sorting, and responsive column layout are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `unified-resources`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary.
15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`, `unified-resources`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
16. `frontend-modern/src/useAppRuntimeState.ts` shared with `cloud-paid`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary.
Security-status SSO display labels are part of the existing authenticated
bootstrap payload. The app shell may project `ssoSessionDisplayName` into
visible signed-in chrome, but it must not introduce an additional
pre-protected-state fetch, route preload, organization probe, or commercial
posture request just to resolve display identity.
17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
Governed action decisions preserve SQLite and MemoryStore parity through one
shared pure append command. Every accepted approval advances a monotonic
decision revision and compares the complete prior approval prefix before
mutation; SQLite performs the state, revision, identity, and prior-prefix CAS
atomically, while MemoryStore applies the same command under its store lock.
Contention across independent SQLite handles is bounded to CAS failure, reload,
replay/conflict/quorum re-evaluation, and retry, so a stale writer cannot erase
another actor's approval. Decision events are uniquely indexed by action and
decision revision, true lifecycle transitions retain their partial
action/state uniqueness, and a completing approval writes its decision fact
plus state transition in one transaction. Schema migration retains historical
events, reopen preserves decision revision ordering, and no action-decision
change may globally weaken the Task 03 lifecycle-state idempotency invariant.
## Extension Points
1. Add performance budgets through SLO or contract tests
@ -213,6 +239,25 @@ regression protection.
restore path must write the URL once with `replace` and must not force row
filtering through a separate page-local state channel.
4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path.
Agent command authorization is likewise a dispatch-time point lookup and
atomic approval consume, not a route-wide scan or request-hot-path fan-out;
grant signing and WebSocket writes happen only after that bounded verifier
succeeds.
Assistant mid-turn steering (`POST /api/ai/sessions/{id}/steer`, routed
through the session sub-route dispatch in `internal/api/router.go`) is a
point operation on the same terms: a map lookup of the session's active
loop plus an in-memory inbox append, returning immediate JSON with no
second SSE stream, no session-file read, and no provider work on the
request path. The per-session steering inbox is bounded
(`maxPendingSteersPerSession`), so repeated steers cannot grow service
memory or the running turn's prompt without limit; overflow returns
`steer_backlog` and the message stays on the client's queue.
Scheduled-report background worker registration is allowed in router startup,
but it must stay outside protected request handling. Due-schedule scans may
enumerate tenant organization IDs and load each workspace schedule store, but
normal API route dispatch must not generate reports, render PDFs, scan
metrics history, or perform SMTP delivery as part of auth gating or router
registration.
Reading mutable auth configuration for CSRF bootstrap and login checks must
stay a short in-memory snapshot under `config.Mu.RLock()`: local
username/password presence, API-token presence, and proxy-auth secret
@ -268,6 +313,12 @@ regression protection.
monitor config into the unified-resource adapter, but it must not add
per-request polling, registry rescans, persistence walks, or tenant-wide
refreshes to decide whether Proxmox/PBS/PMG resources are stale.
Pro update credential wiring in `internal/api/router.go` follows the same
bounded rule: the credential-source closure reads the already-held
activation snapshot only when the updater checks or applies, and the
download-broker fetch stays on the existing update-check cadence (the
check cache plus one fresh resolve per apply). It must not add polling
loops, background license-server traffic, or per-request broker calls.
Global resource timeline routing follows the same protected-request hot-path
rule: `/api/resources/timeline` registration may wire the authenticated
handler, but router setup and auth gating must not execute resource-change
@ -335,11 +386,45 @@ regression protection.
while per-action resource refresh, policy validation, agent command dispatch,
polling verification, and audit completion must occur inside the route-local
execute handler path.
Agent-managed host package updates follow the same bounded setup rule. Router
construction may register the typed host-update executor once, but package
refresh, fingerprint comparison, APT execution, and post-install verification
must remain inside the selected action and agent command paths. Generic
request admission must not probe agents, refresh package indexes, enumerate
pending packages, or wait for update completion.
Package-cache cleanup follows the same rule: router construction may
register the typed executor once, while cache inspection, fingerprint
comparison, `apt-get clean`, and post-cleanup measurement remain agent/action
work. Generic request admission must not walk cache directories, inspect
mounts, or wait for cleanup; the agent scan is entry/byte bounded and the
unified-resource mount lookup is an in-memory pass over already-reported
disks.
The Patrol action-broker and proposal-catalog factories are wired the same
bounded way: `internal/api/router.go` installs the per-org factory closures
on the AI settings handler once at startup, and each broker or catalog is
constructed lazily per investigation run rather than eagerly per request, so
the wiring adds no fan-out to the protected hot path.
Core-owned Patrol policy resolution follows that same boundary: the broker
may resolve the already-scoped AI service and read its in-memory effective
autonomy/full-unlock posture only during proposal submission. Router setup
and unrelated protected requests must not enumerate tenants, load provider
catalogs, scan resource policy, or execute action planning eagerly.
Plan-time policy provenance adds one bounded capability lookup plus the
already-scoped tenant/resource reads on Patrol submission only. One shared
pure evaluator derives both the descriptive factors and automatic
eligibility; dispatch re-fetches current inputs and invokes that evaluator
once before atomically persisting its lease. The provenance object is capped
at three ordered authorities and eight reason codes per authority. It rides
the existing `plan_json` audit blob, so Memory/SQLite parity and reopen need
no side table or unbounded query fan-out.
Proxmox VM/LXC lifecycle execution follows that same routed-executor budget:
router setup may register the Proxmox executor alongside Docker / Podman,
but it must not resolve guests, probe node agents, call `qm` / `pct`, poll
verification state, or refresh inventory outside the route-local action
plan/execute path.
plan/execute path. Injecting the tenant-monitor resolver at setup remains
constant work; the direct Proxmox status/uptime reads are action-local and
poll at most once per second within the closed postcondition registry's
bounded verification window.
Retiring self-hosted trial acquisition follows that same rule: removing
`/auth/trial-activate` and `POST /api/license/trial/start` from public-path
and CSRF inventories must stay as constant-time route-table absence rather
@ -739,6 +824,19 @@ shell clickable behind another overlay.
## Current State
### Canonical mutation-plane dependency
Router wiring now exposes only typed action planning for model-originated
infrastructure changes. The mutation registry audits run in governance CI and
must remain bounded to static construction/catalog scans; they introduce no
request-path enumeration or per-request registry traversal.
The router's shared browser-cookie writer is request-local security policy. It
must not add per-request persistence reads, network work, or background fan-out;
all login and CSRF call sites consume the already-derived in-memory policy.
Separating `HttpOnly` session emission from client-readable cookie emission is
an in-memory policy dispatch only and does not add a second derivation path.
Workload and host drawer discovery-tab Suspense fallbacks now compose the
frontend-primitives `DiscoveryLoadingFallback` template. `GuestDrawer`,
`NodeDrawer`, and Docker host drawer consumers own tab availability and target
@ -1078,6 +1176,12 @@ while row retention works correctly. The reclaim stays a once-per-cycle bounded
operation (it skips the checkpoint entirely when the freelist is empty) so WAL
cadence is not made more aggressive, and `TestStoreRetentionReclaimsFreePages`
guards that a pre-existing backlog drains and the file shrinks.
Commercial history reduction is a second, delayed ceiling on this same store
boundary. Entitlement access narrows immediately, but `pkg/metrics/store.go`
must not tighten physical retention until the durable downgrade state reaches
its governed day-60 purge timestamp. Re-upgrade clears that ceiling before the
next retention run. The ceiling may only reduce configured retention; it must
never expand an operator's shorter storage policy.
contract instead of inventing an infrastructure-local summary filter branch.
For shared line charts on that hot path, the shared sparkline primitive may
isolate the selected series inside the existing render budget, but that
@ -1469,6 +1573,11 @@ and viewport-sync plus selected-row reveal behavior now live in
so future hot-path table-state changes must not fold selector derivation,
layout policy, and scroll coordination back into one mixed owner or the render
shell.
The same shared layout policy now preserves a 640-pixel mobile table floor for
the prioritized identity and metric columns. That width remains class-based
and contained by the frontend-primitives `Table` overflow shell, so phone
layouts gain legible tracks without reintroducing document-level overflow,
inline sizing maps, or extra hot-path measurement state.
That same hot-path boundary now also owns CSP-safe table sizing. Infrastructure
host, PBS, and PMG table shells must take their layout and column sizing from
the shared presentation owner in
@ -1771,3 +1880,38 @@ The compact provenance marker for those values is presentation-only. It
may identify already-loaded Discovery fields, but must not introduce
additional discovery fetches, provider lookups, endpoint probes, or
per-row layout measurement on the Workloads hot path.
The canonical Workloads table mobile floor is 36rem. The mobile table branch
must preserve that intrinsic width inside its existing scroll shell instead of
combining it with `min-w-full`, which allows the five operational columns to
compress into unreadable phone-width fragments. This is a layout-only contract:
it must not add row-time measurement, duplicate tables, or viewport listeners
to the Workloads rendering hot path.
The router owns exactly one in-process Patrol action transition publisher.
Publication remains an O(1) wakeup keyed by org and action id; reconciliation
and read-time recovery use indexed action-audit lookups rather than scanning
findings or audit history. SQLite keeps dedicated state and valid-origin JSON
indexes for pending queues and missed-callback hydration.
The `action.completed` projection reads the already-loaded
`ActionAuditRecord` and derives canonical `ActionResultV2` plus the bounded
legacy compatibility fields in process. Projection adds no action-store
lookup, polling loop, provider call, or evidence fetch. Canonical evidence and
reference counts, text, and identity fields remain bounded by the
unified-resource-owned `ActionResultV2` contract, so event publication cannot
turn terminal action evidence into an unbounded payload or hot-path query.
### Patrol Autopilot config mutation cost and serialization
Acknowledgement creation, revocation, and activation reuse the Task 04
in-process policy-mutation coordinator and perform one load, pure validation,
and atomic `SaveAIConfig` replacement. Existing acknowledgement and revocation
history is compared as an exact immutable prefix, so stale writers cannot
replace prior authority facts. Activation and requested mode are committed in
the same write; runtime publication occurs before releasing the same mutation
boundary. Exact retries do not append evidence or rewrite timestamps.
This is a per-tenant, single-process serialized config boundary, not a claim of
distributed multi-writer CAS. Provider-wide inheritance, distributed budgets,
and cross-process policy mutation remain outside this backend prerequisite.

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more