The "No workload inventory available" empty state told every session to
"Review source credentials, permissions, and collection status in Settings
→ Infrastructure" and rendered a button to /settings/infrastructure. Since
755a88878 gated that nav item on the infrastructureRead capability, a
non-admin viewer cannot open the page — and once inventory source health is
served at monitoring:read, a viewer with a broken source lands on this exact
state and is pointed at a door that is locked for them.
Gate the call to action on the destination's own capability. Reusing
infrastructureRead rather than a second predicate is what keeps the link and
the nav gate from drifting apart. Without it the copy now names the action a
viewer can actually take: contact an administrator.
The signal did not exist outside Settings — infrastructureRead only reached
useSettingsAccess's local fetch, which runs when Settings mounts and so is no
help to a page deciding whether to link there. Publish it from the
/api/security/status resolve that useAppRuntimeState already performs on
mount, alongside the sessionCapabilities sync that hangs off the same call.
No new request is added. Unresolved sessions keep the link, matching how
settingsNavVisibility treats an unresolved capability set, so an admin never
flickers through the restricted copy.
The banner itself, the admin path, and the monitoring:read inventory-sources
call are all unchanged. The surface's inline fallback now defers to the
shared presentation helper instead of duplicating the copy, so the gate
cannot be bypassed by whichever path renders.
Verified on a scratch instance against real proxy-auth sessions: the viewer
(detailLevel authenticated, infrastructureRead false) gets the reworded copy
and no link at 1280x800 and 375x812, while the admin (privileged) is
byte-identical to before. Because the empty state is only reachable by a
viewer once inventory health is served at monitoring:read, the live exercise
ran with that branch's route present.
755a88878 gated Settings -> Infrastructure on infrastructureRead but left the
System group untouched. Network, Pulse server updates, and Recovery are the
same shape: pure instance administration - the public URL and CORS boundaries,
the server update channel, backup polling and configuration export/import -
behind routes that are all RequireAdmin + settings:read. A session without it
was still offered all three, could deep-link straight at them, and landed on
panels it can never populate.
Serve systemSettingsRead alongside infrastructureRead and gate the three tabs
on it. requiredCapability blocks the route as well as the nav item, so a deep
link now falls through to the capability-aware fallback that 755a88878 added,
landing on the first tab the session can actually reach.
The new field is a sibling of infrastructureRead, not a reuse of it. They
share the canAccessAdminSurface(config.ScopeSettingsRead) derivation today, but
infrastructureRead is named and documented for the data sources behind one
page. Borrowing it to gate a different page is the same defect as re-deriving
a capability: the two would drift the first time one surface's gate changed,
and the api-contracts rule on published capabilities exists to prevent exactly
that. Each field names the surface it gates and is pinned separately.
These hide rather than render an inline gate, matching Infrastructure rather
than the paid-feature items in the same catalog. A free install seeing a paid
tab can act on it by upgrading, so hiding it would cost discoverability. A
non-admin cannot grant themselves admin, so the tab is only noise.
system-general stays ungated on purpose: theme, language, and unit preferences
there are user-scoped, so gating it would take personal settings away from
every non-admin, and the panel is not empty for them.
Verified against real admin and non-admin sessions rather than a stubbed
capability - one binary built from this worktree behind two header-injecting
proxies differing only in the role header. The viewer sees none of the three
tabs and a deep link to /settings/system-network lands on
/settings/monitoring/availability; the admin keeps all three and the same deep
link resolves to the Network panel. Confirmed at 1280x720 and 375x812.
A refusal is the access control working, not a fault, but every one of them
warned twice: once in RequireAuth/RequireAdmin/RequirePermission, and again in
the middleware, which warned on every 4xx unconditionally. A correctly
configured instance therefore could not produce a quiet log, and #1601's rc.9
reporter read that stream as an RBAC regression.
Gating clients one at a time does not fix this. The previous commit stopped six
endpoints being polled by a non-admin UI, and /api/updates/status,
/api/updates/plan and /api/availability-targets still warned, as would every
caller nobody has audited yet.
Refusals now route through logAuthDenial, which records them at debug and counts
them per caller. Attribution prefers the authenticated username so a principal
stays tracked across rotating addresses, falling back to the client IP. Crossing
20 refusals in a minute emits exactly one warn for that window, which is the
shape that separates probing from a UI mounting a surface its session cannot
read; a closed window re-arms it. The tracked set is bounded with oldest-window
eviction so spoofed forwarded-for values cannot grow it. The middleware now
warns only on 5xx.
Enforcement is untouched: every route returns the same status to the same
callers, and the contract test pins that pairing so a future attempt to quiet
the log by relaxing enforcement fails rather than passes. Verified live on a
proxy-auth instance - /api/connections, /api/updates/status and
/api/system/settings still 403 for a viewer and 200 for an admin; 19 refusals
produce no warn, the 20th produces one, and 30 more produce none; an idle
non-admin browser session logged zero warn lines across 90 seconds.
The client drops any inbound frame larger than
MAX_INBOUND_WEBSOCKET_MESSAGE_BYTES (8 MiB). Measured against a real
/api/state payload, resources average 2673 bytes each, so a full
initialState snapshot crosses the guard at 3121 resources and reaches
12.79 MiB at 5000. On those estates the snapshot was discarded and the UI
rendered empty.
Since 413ca4e7b the delta path noticed the missing baseline and asked the
server for a full snapshot via `requestData`, but that response is the same
oversized payload, so it was dropped too: a 30s retry loop that never
hydrated.
Recover over REST instead. /api/state serves the same
monitor.BuildFrontendState() value the socket sends as initialState
(prepareStateForBroadcast is a pass-through), over a transport with no
frame-size ceiling, so the snapshot arrives intact. Feeding it through the
normal message handler establishes the delta baseline a socket snapshot
would have, which is what keeps recovery from stalling again on the next
tick. Both triggers -- a frame dropped as oversized, and a delta arriving
with no baseline -- now share one throttled entry point, so a server that
repeatedly emits an undeliverable snapshot cannot turn into a REST hammer.
Compression was not an option here: permessage-deflate is already enabled on
the hub, and the browser decompresses before the guard runs, so the cap
applies to decompressed bytes either way. Raising the cap only moves the
cliff and inflates transient heap, since an 8 MiB payload already costs a
JSON.parse plus a structuredClone of the resources array.
Verified live against a backend whose real snapshot is ~741 KB, with the cap
temporarily lowered to 256 KiB to reproduce the condition: the guard trips,
REST recovery hydrates 265 resources across 48 types, and subsequent deltas
keep applying to that baseline. With the 8 MiB cap restored the same frame
hydrates over the socket with no REST fetch. Regression tests pin the byte
boundary in both directions, the REST-established baseline, and the absence
of the old requestData loop.
Contract-Neutral: client-side oversized-snapshot recovery on a websocket error path: no alert identity, evaluation, threshold, routing or ownership delta. Preserves the contract's active-alert-truth and bounded-recentlyResolved invariants (alert state now hydrates on estates where the dropped snapshot previously left it empty). No public contract surface added.
Settings -> Infrastructure reads /api/connections, /api/config/nodes,
/api/system/settings, /api/truenas/connections and /api/vmware/connections on
mount and then polls /api/connections every 15s and /api/discover every 30s.
Every one of those is RequireAdmin, so an authenticated non-admin rendered a
page where nothing loaded while each poll reprinted "Non-admin user attempted
to access admin endpoint" at warn level. Measured on a proxy-auth viewer
session: 6 denials/minute from this page alone on an idle tab.
Serve infrastructureRead alongside the other settings capabilities, derived
from the same canAccessAdminSurface(settings:read) expression the routes
enforce, and gate the nav item on it. Two follow-on fixes were needed because
the page is not the only mount point:
- DEFAULT_SETTINGS_TAB is infrastructure-systems, so the blocked-route
fallback pointed straight back at the tab it had just refused. It now falls
back to the first tab the session can actually reach.
- Settings.tsx constructs useInfrastructureSettingsState for every settings
tab, so the discovery poller and the TrueNAS/VMware mount fetches ran no
matter which tab was open. They now wait on the same capability. The
TrueNAS/VMware loads moved from onMount to an effect so admins still load
once the capability resolves, rather than sampling it before it exists.
Verified against a local instance behind a header-injecting proxy-auth shim:
viewer goes from 6 infrastructure denials/minute to 0, admin keeps both
pollers armed (/api/discover x4 and /api/connections x3 over 152s) with 0
denials. Re-checked at 1280x800 and 375x812 for both roles.
The 2026-08-07 commercial-surfaces revision owed a release-note entry, and
its instrumentation (schema-v8 business_estate, checkout source attribution)
has now landed too. v6.2.0-rc.9 was already tagged before any of it merged,
so the copy is staged in the record rather than written into a
version-numbered packet: picking the next version and generating its packet
is the governed release-preparation step, not this one.
The copy is written for someone who will notice paid-feature pages appearing
in a free install and want to know why, so it leads with what changed on
screen, states that every button on the business-estate card dismisses it
permanently, and confirms demo, kiosk, white-label, and MSP tenant runtimes
stay clean. It also declares the telemetry field and the checkout-surface
attribution plainly rather than leaving them for someone to find in a diff.
Every claim was checked against source: the thresholds against
internal/monitoring/business_estate.go, the dismissal behaviour against
BusinessEstateCard.tsx, and the opt-out and schema number against
internal/telemetry/telemetry.go.
Three commits failed today on timeouts that had nothing to do with their
diffs. golangci-lint hit its 5m default while loading packages, and
TestStatusJSONLaneEvidenceReferencesAreStructured hit Go's 10m default after
9m11s with its goroutine parked in a syscall. Both ran at load average 185,
with four pre-commit hooks and two dozen eslint and vitest processes from
other agent sessions competing for the same box. Each failure cost a full
re-run to diagnose, and the same commits passed unchanged once the machine
went quiet.
The repoctl guardrail tests are I/O bound rather than CPU bound: they os.Stat
every evidence path status.json references, across repos, so they degrade
badly under contention while doing nothing wrong. golangci-lint already
exposed GOLANGCI_LINT_TIMEOUT; the go test line had no -timeout at all and
silently inherited the 10m default.
This raises the ceilings to 20m and 30m and adds PULSE_HOOK_GO_TEST_TIMEOUT
for the latter. No check is skipped and no rule is relaxed: on an idle
machine these stages finish in seconds to minutes and the change is inert.
The cost is that a genuinely hung test now takes longer to surface, which is
the better trade against false failures that read as real ones.
Stripe checkouts could not be told apart by origin: every session was
stamped checkout_origin pulserelay_landing, so an upgrade started from an
RBAC gate looked identical to one started from the website. Gate CTAs now
stamp a closed-vocabulary source token (gate-<feature>, estate-card, or the
plans-page default) onto the owned billing plan route; the plan page threads
it into the purchase-start handoff, and the license server persists it on the
checkout intent and stamps Stripe metadata checkout_source.
Attribution is authenticated-session-only by construction. The public
/pricing route and the public pricing URL never carry it, and
getSelfHostedPurchaseStartUrl scrubs the parameter from forwarded query
strings so a crafted website link cannot claim in-app origin. Server-side,
source is validated against the same kebab vocabulary, skip-listed from the
Pulse Account portal redirect exactly as feature already is, and travels
only inside the handoff body. It is request-scoped and persists nothing on
the install; the cancel return echoes it so a retry keeps its origin.
The handoff field is omitempty because the license server decodes strictly,
so source-less installs stay compatible with a server either side of the
field being added. That ordering is recorded in pulse-pro OPERATIONS.md.
The 2026-08-07 telemetry read showed installs at or above 5 PVE nodes, 10
Docker hosts, or 3 VMware hosts convert to paid at ~8x the rate of smaller
estates. The ping now carries that classification as a server-derived
boolean so receiver-side cohort queries keep a stable column even if the
thresholds move later; it is derived in the pkg/server snapshot closure
from the same AggregateInstallSnapshotCounts values the payload already
sends, so no new information leaves the install.
The thresholds move to internal/monitoring/business_estate.go as the
single definition; the session-capability surface behind the in-product
business-estate card delegates to it, and dropping the now-unneeded direct
pkg/licensing import there restores TestPkgLicensingImportBoundary, which
f0e2243b4 had left red. All three payload surfaces (Ping struct, private
receiver, TelemetryPingPreview) move together per
check_telemetry_schema_parity.py, and both PRIVACY.md copies document the
field.
Verified live on an isolated worktree backend with 6 mock PVE nodes: the
Settings telemetry preview renders schema_version 8 with business_estate
true, and /api/security/status still reports
sessionCapabilities.businessEstate true through the delegated thresholds.
3f4595386 removed the context field from manualScopedPatrolRequest and
enabled DisallowUnknownFields on the run body, but the alert
"Have Patrol investigate" button still sent context, so every click
returned 400 invalid_patrol_scope (reported on the #1601 rc.9 retest).
Drop the field from the call site and from PatrolRunScope; the backend
already derives the scoped run's context from alert_type.
Refs #1601
Contract-Neutral: frontend stops sending a field the Patrol run endpoint already rejects; no request or response shape change server-side
The commercial-surfaces revision (f0e2243b4) made security-webhooks
relay-shaped: the catalog entry dropped hideWhenUnavailable so the tab
stays visible for free installs with a panel-owned inline gate, and the
tab already carries a tabFeatureRequirements entry, so
isSettingsNavItemLocked now honestly reports it locked. The
settingsNavigation.integration.test.tsx gatedTabs pin was re-pinned by
the revision; the branchcov2 never-locked pin was not, and has been
failing on main since.
Re-pin to the revision's intent rather than flipping the assertion:
give security-webhooks a dedicated locked pin mirroring system-relay's,
drop it from the never-locked list, and refresh the two comments that
still claimed security-roles and friends carry hideWhenUnavailable
(only the organization-* tabs still do; the security-roles assertion
now exercises the no-requirements branch, so it was swapped for
organization-access to keep the early-branch coverage honest).
The MSP evaluation download step (734024c46, 6b7644345) landed in
docs/MSP.md without updating the copy shipped under
frontend-modern/public/docs, so docsLinks.test.ts's shipped-docs sync
pin has been failing on main. Byte-sync the shipped copy.
The rc.9 delta stream (962d29780) diffs resources against the server's
per-client snapshot, but the client applied those patches to its
canonically merged store. On estates where the canonical coalesce folds
agent and runtime-platform hosts into one resource the two domains
disagree: a merge patch for a coalesced-away ID materialised as a
typeless stub resource, and a server-side removal of the merged
carrier's ID deleted both facets with nothing ever re-adding the
survivor. Resources then vanished progressively — platform tabs
included, since navigation visibility derives from state.resources.
Reported on the #1601 rc.9 retest for both admin and read-only users;
the 403s in that report are an unrelated non-admin update poll.
Keep a pristine copy of the server's resource array, apply deltas to
that raw baseline (the exact objects the server diffed against), then
derive the canonical view through the same merge the full-snapshot path
already uses. Delta application now converges to the same state a full
snapshot would produce, by construction. The raw baseline stays
isolated from the store: entries are structuredClone'd before the
canonical merge because the merge output shares nested references and
reconcile mutates adopted objects in place. A delta arriving before any
full snapshot (for example after an oversized initial payload was
dropped) now requests a full snapshot instead of patching nothing.
The flipped status assertion in websocket-unified.test.ts is
deliberate: the old delta path deleted keys that the canonical merge
preserves, so it disagreed with the full-snapshot path; the assertion
now pins delta/full parity.
Refs #1601
Contract-Neutral: client-side websocket delta application fix; wire protocol and payload shapes unchanged
Free self-hosted sessions now see reactive commercial surfaces: paid-feature
nav items stay visible with panel-owned inline gates (Relay precedent extended
to reporting, roles, users, audit log, audit webhooks), gate CTAs render, and
Plans & Billing is discoverable and carries an MSP/provider section with the
free 60-day evaluation. Adds a one-shot business-estate card for authenticated
free sessions whose monitored estate crosses business-scale thresholds
(>=5 PVE nodes, >=10 Docker hosts, or >=3 VMware hosts); the flag rides
sessionCapabilities only, never the pre-auth presentation policy, so estate
size cannot leak to anonymous visitors. Demo mode and white-label runtimes
(including MSP tenant containers) keep full commercial suppression; the
multi_tenant organization nav items deliberately stay hidden.
Decision record supersedes the 2026-04-25 opt-in record:
docs/release-control/v6/internal/records/self-hosted-commercial-surfaces-revision-2026-08-07.md
RA5 summary, contract pins, nav integration pins, and the RA5 browser spec
are updated to the revised invariant in the same commit.
The public repository surface refresh (40d29ca44) hard-wrapped the
Settings → Infrastructure → Install on a host phrase mid-line and
dropped the v5-to-v6 agent upgrades mention from README.md, breaking
RA10's documentation-currentness-tests proof on main.
- documentation_currentness_test.py: match prose fragments against
whitespace-normalized content, the way markdown renders it, so an
editorial rewrap can no longer fail the guard. Structural fragments
(table rows, gate headings) and all assertNotIn checks still match
raw source.
- README.md: restore the v5-to-v6 agent upgrades mention the refresh
dropped; the invariant is still live while v5 installs migrate.
The server now echoes its version on unified-agent report acks, and the
agent nudges its auto-updater the moment an ack carries a newer version.
After a server upgrade, agents converge within one report interval
instead of waiting out the hourly update check, so the "older Pulse
agent" notice self-resolves in seconds once upgraded agents report in.
The hourly loop stays as the retry and backstop path. Nudges dedupe per
server version, refuse downgrades, skip disabled and development-mode
updaters, and never fire from observer destination acks — only the
authoritative server may steer an agent's updater, and a nudged check
re-validates against the server and runs the existing checksum and
self-test pipeline before swapping binaries. Agents deployed before
this change still converge on their old hourly cadence once; every
upgrade after that lands within a report cycle.
Contract deltas recorded in agent-lifecycle and api-contracts, with
boundary notes in security-privacy (no update authority in the echo),
performance-and-scalability (no steady-state work), and
storage-recovery (nothing persisted).
A pulse-agent installed on a Proxmox node merges into one canonical
resource whose primary ID is node:{sourceID}, so the agent:{hostID}
reference the alert evaluator uses (hostResourceID) matched nothing in
the registry. Per-resource grace overrides and operator-state lookups
then silently resolved to factory policy: alerts fired with no grace
while the policy preview, which receives the canonical ID directly,
showed the override working. Index the prefixed agent ref as a
canonical identity alias so runtime resolution lands on the same
resource the override was saved against.
Refs #1497
Contract-Neutral: behavioral fix#1497: index agent: ref as canonical alias, no public contract shape change
Traefik took the whole operator .env via env_file, so the internet-facing
container's environment carried CP_ADMIN_KEY and the entitlement signing
private key, neither of which it needs. It now receives only ACME/DNS
material: the CF token by explicit passthrough plus an optional
dns-credentials.env that setup.sh creates 0600.
The same wiring removes the silent Cloudflare-only narrowing: the resolver
provider is now ACME_DNS_PROVIDER (default cloudflare) via Traefik's env
override, the CF token is required only for the default provider, and any
other Traefik dnsChallenge provider supplies its credential variables through
dns-credentials.env. Pinned by
TestProviderMSPTraefikEnvIsMinimalAndDNSProviderOverridable; contract delta
in deployment-installability.
The MSP page has led with the free two-client evaluation since 08-03,
but nothing outside pulserelay.pro mentioned MSP at all, so only
visitors who already found the site could discover it. The README is
the highest-traffic owned surface; give MSP the same one-bullet
treatment as the other editions, worded to match msp.html's own claims.
The Start free button on the MSP page lands on this section, which described
the self-issued licence but never said how to obtain the bundle. The natural
guess, the latest stable tarball, ships a setup.sh that predates the
evaluation flow and dies asking for a licence. Point evaluators at main and
state the host prerequisites, which were documented nowhere in this guide.
The docs link drift guard (test_repo_docs_link_drift.py) fails CI because
render_release_body.py links docs/CODE_SIGNING_POLICY.md at blob/main.
Release bodies must reference the docs snapshot of the release they
describe, so the installation section now links blob/v{version}, matching
how the rest of the body pins to the version being cut. The unit test pin
follows. Also skip .claude/ in the drift guard so local runs on a shared
tree are not polluted by stale agent worktrees under .claude/worktrees
that CI never checks out.
a0113b964 moved AlertResourceIncidentsPanel out of HistoryTab and into the
row that asked for it, but left two source-boundary assertions pinning the
old page-level placement:
frontendResourceTypeBoundaries.test.ts:2796
Alerts.helpers.test.ts:429 and the exact-JSX pin at 434
The panel move is correct, so the assertions are what is stale. At page level
the panel opened thousands of pixels above a reader scrolled into the history
and the per-row Resource button read as inert (#1687); the fix mounts it under
the originating row, gated on that row owning the open request so exactly one
panel is open at a time.
Retarget both pins onto AlertHistoryTableAlertRow, where the panel now lives,
and turn the HistoryTab pin negative so a regression back to page-level
placement fails the boundary rather than passing it. The rowKey gate is pinned
alongside the mount because the gate, not the mount, is what makes the button
land in view. The phone card list keeps its behavioural coverage in
__tests__/AlertHistoryMobileList.test.tsx, so it is not duplicated here.
Verified: both boundary suites green (59 tests), full alerts feature suite
green (59 files, 960 tests), prettier clean.
restoreProviderMSPArchiveFile copied each tar entry with an unbounded
io.Copy, so the only limit on what a restore wrote to disk was the size
the archive declared for itself. A gzip bomb, a PAX sparse entry claiming
a huge logical size, or a corrupt stream could fill the target volume.
Bound extraction the way readProviderMSPBackupManifestBytes already
bounds the manifest: a per-entry cap and a cumulative cap across the
whole restore, both enforced against the bytes actually copied rather
than the declared header size. That size comes from the archive, so it
is only good for an early reject, never as the bound. An entry that
overruns fails the restore and its partial file is removed, rather than
being silently truncated into a file that looks complete.
A restore that failed partway had already deleted whatever it replaced,
leaving a half-populated control plane that looks bootable and forcing
the retry to use replace. Roll the partial restore back to an empty
target instead, and say so in the error.
Follows the zip-slip fix in the same function (CodeQL alert 314).
SummaryRowActionButton disclosure buttons and the FilterBar inline select
were resized to 44px mobile touch targets (h-11/min-h-11 with sm:
compact overrides) in the mobile interaction sweep, but these two pins
still asserted the old desktop-only sizes.