After a license activation succeeds, the ProLicense plan section
shows a success summary card. Without aria-live, a screen reader
user wouldn't know the activation completed without re-focusing
the page. Add role="status" + aria-live="polite" so the success
summary is announced when it appears.
Extending the aria-live pass to user-action feedback messages that
complete an operation:
- Discovery scan success banner: when a discovery scan completes,
a green banner reports success — wrap with role="status" +
aria-live="polite" so a screen reader user knows the long-running
scan finished without needing to re-focus the page.
- WebInterfaceUrlField error/success messages: a small inline
message under the URL input. Error gets role="alert" +
aria-live="assertive" (immediate); success gets role="status" +
aria-live="polite" (validation passed, less urgent).
Distinguish error (assertive — interrupt) vs success (polite — wait
for natural break) consistently. Visual rendering unchanged.
The Verify Patrol button reset its result to empty on every
page load — the operator had to re-click to see the verified
state, even though nothing had changed. This commit adds the
observability layer of the auto-preflight plan: every
RunPatrolToolPreflight result is now cached on the AI Service
and surfaced through /api/settings/ai as patrol_preflight, so
the inline result panel rehydrates on page load with the
most-recent outcome and a "last verified Xs ago" indicator.
Backend: patrolPreflightCache (mutex-guarded) on Service with
defensive-copy CachedPatrolPreflight() accessor; every
RunPatrolToolPreflight branch (success, soft warning, classified
failure, validation early-return) records into the cache.
PatrolPreflightSnapshot projects the cached result onto the
AI settings response. Tests cover both success-then-failure
supersession and the defensive-copy invariant.
Frontend: PatrolPreflightSnapshot type mirrors the wire shape;
hydratePatrolPreflightFromSettings(data) projects the snapshot
into the same response shape the manual button writes;
loadSettings and updateSettings flows call it. The result
panel renders a "last verified Xs ago" line under the
provider/model row when recorded_at_unix is present.
End-to-end smoke verified against deepseek-v4-flash: panel
rehydrates as green "Tool calling verified · last verified
just now" after page reload.
Auto-preflight on save (the trigger half of the resilience
plan) follows in the next commit.
Contracts: ai-runtime, api-contracts, agent-lifecycle (dep),
storage-recovery (dep), frontend-primitives all updated to
reflect the new patrol_preflight surface and hydration
contract. Verification artifacts: settingsArchitecture +
patrolPreflight client tests.
Continuing the aria-live pass:
- AISettings load error banner: when /settings/system-ai fails to
load, the red banner with retry button rendered silently to
screen readers. Added role="alert" + aria-live="assertive" so
the user hears about the failure immediately.
- K8s namespaces drawer load error: same shape — wrapping the
danger Card in a div with role="alert" + aria-live="assertive".
Continuing the aria-live pass:
- PMG instance drawer load error: hard load failure on the
instance details surface — wrap in a div with role="alert"
and aria-live="assertive" so it announces immediately when
the drawer fails to load.
- AI cost dashboard refresh warning: "Couldn't refresh. Showing
last loaded data." — soft warning with a Retry button, so
aria-live="polite" is the right priority (wouldn't interrupt).
- Report merge modal error: a hard error from a user-initiated
merge action — assertive priority.
Three more error surfaces moved from silent (sighted-only) to
properly announced.
The Data Handling panel's error banner (shown when the resource
policy posture API returns a load error) had neither role="alert"
nor aria-live, so a screen reader user wouldn't know an error had
appeared. Add role="alert" + aria-live="polite" — the banner uses
amber tone for "couldn't refresh" rather than a hard failure, so
polite is the right priority here (assertive would interrupt the
user mid-action when this is more of a transient warning).
InfrastructureWorkspace and AvailabilityTargetSlot error banners
already have role="alert" which implies aria-live="assertive"; no
change needed there.
Continuing the aria-live pass started by df56a1f14 for login +
change-password errors. Add `role="alert"` + `aria-live="assertive"`
to:
- Discovery notes saveError: when "Save notes" fails the user
sees a red message — should be announced.
- Suggest Profile Modal error: API failures returning suggestion
data should announce so the user knows what failed.
- Audit Log panel error: load failures or filter errors render a
red banner — should announce.
All three are post-action error feedback (the user just clicked Save
or applied a filter), the highest-priority class for assertive
announcement.
Both the login error banner and the change-password error banner
appeared silently from a screen reader's perspective — when an auth
failure or validation error came back, the visible message rendered
but no announcement fired.
Add `role="alert"` + `aria-live="assertive"` to both. The role+live
combination ensures the message text is announced as soon as it
appears, even if the user's focus is on the input field. Visual
behavior is unchanged.
This is the start of an aria-live pass — there are no live regions
anywhere in the codebase right now, and these two surfaces (login
failure, password-change failure) are the highest-priority places
to start because the user has just performed an action and needs
immediate feedback.
Slice 51's existing tools/call test only covered GET (no body),
so the body-argument extraction, JSON marshaling, Content-Type
header, and path-placeholder substitution working together for
PUT/POST capabilities went untested. If an agent calls
set_operator_state via MCP and the body doesn't make it through
correctly, the substrate would silently swallow the agent's
data and emit a "successful" response.
Three new tests fill the gap:
- SendsPutBodyForWriteCapabilities exercises the canonical
write path against a fake Pulse: PUT method, substituted path,
bearer token header, application/json Content-Type, the body
fields round-tripping through JSON marshaling, the upstream
response surfacing in the MCP content block with isError=false,
and server-populated attribution (setBy) reaching the agent.
- TopLevelArgsMakeUpRequestBody pins the flexibility on the
body argument: agents that pass body fields at the top level
of arguments (no nested "body" key) get them collected into
the upstream request body. This is the shape MCP clients tend
to generate when they read the input schema as "object with
these fields"; the bridge accepts both.
- TopLevelArgsExcludesPathPlaceholders pins the disambiguation
rule for the case both shapes overlap: when arguments include
both a path placeholder and body fields at the top level,
the placeholder goes ONLY in the URL, never duplicated into
the body. Drift here would let canonicalId leak into a PUT
body the server doesn't expect, which currently doesn't
break Pulse but would break any future server that validates
body fields against a stricter schema.
Test-only addition; no production code changed.
Wires the new POST /api/ai/patrol/preflight endpoint into
the Settings UI. The button sits beside the Patrol
Verification Model picker so the verification action lives
where the model is selected.
Three result tones rendered inline:
- green: provider call succeeded and the model emitted a
tool call (the fully-verified state)
- amber: provider accepted the request but the model did
not call the tool (cause=model_tool_support_unverified —
Patrol may still work, recommend a real run)
- red: classified failure surface from the runtime classifier
(tool_choice_rejected, no_tool_capable_endpoint, model
unavailable, auth, billing, etc) with summary, recommendation,
and the resolved provider/model
Distinct from the existing "Run Preflight" button on the
Provider Configuration card, which only fans out per-provider
TestConnection calls. The copy under the new button calls
that distinction out so operators don't conflate them.
Backend wiring stays untouched — the action goes through the
typed runPatrolPreflight client added in e26a57a15. Settings
architecture guardrail extended to assert the wiring.
The AgentApprovalsProvider closure in router.go applied the
BelongsToOrg and CanonicalResourceID filters inline, which made
the substrate's tenant-isolation property impossible to test
without booting the full router. Drift in the closure (e.g.
swapping BelongsToOrg for a hardcoded "default" or dropping the
resource-id check) would let an agent with one org's token see
approvals targeting another org's infrastructure, but no test
sat right next to that logic to catch it.
Extracts the body into a named function in agent_resource_context.go
(pendingApprovalsForResourceFromStore) behind a minimal
approvalsPendingProvider interface. The closure in router.go
now delegates to it. Four unit tests pin the substrate's
isolation property:
- FiltersByOrg: same resource id, two orgs, each query returns
only its own org's approval.
- FiltersByResource: same org, two resource ids, each query
returns only its own resource's approval.
- LegacyEmptyOrgIsDefaultOnly: approvals without OrgID are
treated as default-org per BelongsToOrg's documented
semantics; legacy approvals do not leak into a non-default
org's bundle.
- EmptyInputsReturnNil: defensive shape on nil store, empty
resource id, and empty store.
The existing TestContract_AgentResourceContextWiresApprovalsProvider
pin is updated to follow the extraction. Both halves of the
wire-up are now pinned: router.go installs the closure with the
correct delegation, and agent_resource_context.go owns the
filter logic with both safety checks present.
This is the test the substrate was missing: nothing else proved
that an agent with one org's token cannot see another org's
pending approvals at the bundle layer.
Contract-neutral commit: no wire shape, manifest entry, or error
code changed. The refactor preserves identical behaviour;
PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT is set with a documented
reason since three of the four contract docs the canonical-shape
guard would normally demand are actively mid-edit by another
agent on patrol-preflight work, and trampling them would create
a collision the protocol explicitly forbids.
Both type-ahead inputs in the FilterBar (the values picker inside a
filter chip and the filter selector inside the Add menu) used
`outline-none` with no replacement focus ring. The default browser
focus outline was suppressed and nothing took its place, so a
keyboard user couldn't see which input was focused after Tab-ing in.
Replace with `outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:rounded`
so keyboard focus shows a clear blue ring (and mouse focus stays
clean — focus-visible only triggers for keyboard).
Two number inputs on /alerts/notifications had no min/max bounds:
- SMTP port: missing min/max. The browser would accept any
integer, including negative numbers and values outside the TCP
port range. Added min="1" max="65535".
- Rate limit (per minute): missing min. Negative rate limits make
no sense. Added min="1" so the browser surfaces a validation
bubble when an invalid value is typed.
Both bounds are enforced by the browser's native number-input UI and
form validation, which catches typos before they hit the API.
The existing per-provider /api/ai/test endpoints only call
ListModels — they pass for every provider that returns a
catalog, even when Patrol fails 100% of runs because tools
aren't actually wired up. That gap is what let the DeepSeek
tool_choice rejection silently fail Patrol for 33 days
before the recent fix landed.
POST /api/ai/patrol/preflight runs a one-shot tool-call
round-trip with the configured (or overridden) Patrol
provider+model and a minimal verify_pulse_patrol tool.
Failures route through ClassifyPatrolRuntimeFailure so the
new tool_choice_rejected and no_tool_capable_endpoint causes
surface here too. A successful provider call where the model
returned plain text (no tool call) is reported as a soft
warning (model_tool_support_unverified): Patrol may still
work but the operator should run a real pass to confirm.
The endpoint bypasses the chat service so cost recording
isn't charged for verification, and uses ScopeSettingsWrite
to align with the existing /api/ai/test gating.
Backend + typed frontend client (runPatrolPreflight); UI
button on Assistant & Patrol settings follows.
Contracts updated:
- ai-runtime: completion obligation extended to cover the
new verification surface
- api-contracts: payload shape (tool_call_observed,
duration_ms) noted in obligations
- agent-lifecycle, storage-recovery: dependent-extension
acknowledgment that ai-runtime owns the new route despite
it living under internal/api/
When the Workloads summary band shows a focused workload name (e.g.
"— delly"), the label uses `truncate` with no title, so a long
workload name clips with no way to read the rest. Add a title
attribute mirroring the rendered name so hovering reveals the full
string.
The PMG instance drawer header showed the resource name with `truncate`
but no title attribute, so a long PMG instance name (e.g. a long
hostname returned from the API) clipped without recourse. Mirror the
same pattern used elsewhere in the truncate-tooltip pass — derive
the displayed name once via the chain (resourceName ?? resource.name
?? defaultResourceName) and pass it both as the rendered text and the
title attribute.
Closes the documented limitation in slice 51's pulse-mcp: MCP
clients that process server-initiated notifications can now
react to Pulse's push channel without holding a separate HTTP
connection to /api/agent/events.
The bridge is opt-in via --emit-notifications because not every
MCP client surfaces arbitrary notifications/* methods (Claude
Desktop, today, does not). Autonomous agents that consume the
JSON-RPC stream programmatically benefit; UI-mediated clients
should keep the flag off and use the SSE stream directly.
Implementation: a long-lived goroutine, started once after the
first initialize, that opens /api/agent/events, parses the
substrate's wire format, and emits a JSON-RPC notification
per non-transport event. Method names mirror the SSE event
kinds (notifications/finding.created, notifications/approval.
pending, notifications/action.completed). Params is the SSE data
payload verbatim so agents see the same wire shape an HTTP SSE
consumer would. stream.connected and heartbeat are filtered as
transport plumbing. The consumer reconnects with capped
exponential backoff on transient errors.
When --emit-notifications is on, initialize advertises the
supported event kinds under
capabilities.experimental.pulseNotifications.kinds. Clients that
don't understand the experimental block ignore it silently.
Three tests pin the behaviour: the initialize handshake's
capability block is correctly gated on the flag; the notification
filter rejects transport events and accepts the three substrate
kinds; an httptest.NewServer-backed end-to-end translates a
multi-event SSE stream into JSON-RPC notifications with the
substrate's payload preserved.
Also flagged in AGENT_SUBSTRATE.md "what it does not do yet": the
action-execution endpoints (/api/actions/plan, decision, execute)
emit a different error envelope from the agent surface (APIError
with stable code under "code") versus the agent-stable shape
(stable code under "error"). Adding them to the manifest
requires resolving that mismatch first; recorded as a focused
slice for whenever the substrate's reach extends to direct
agent-driven execution.
The Patrol runtime classifier collapsed three distinct upstream
conditions into one misleading "Selected model does not support
Patrol tools" message:
1. Provider rejected the *value* Pulse sent for tool selection
(e.g. DeepSeek's "deepseek-reasoner does not support this
tool_choice" — the model accepts tools, just not the forced
coercion). The DeepSeek fix in 46145df9 dodges the symptom by
coercing to auto, but the original misclassification pointed
operators at the wrong remediation for 33 days.
2. Provider has no tool-capable endpoint available for the
selected model (OpenRouter's "No endpoints found …" surfaces
this when account-level provider/data filters exclude every
tool-capable route).
3. Model truly lacks tool calling (the literal "tools are not
supported" / "tool calling" cases).
Each now has its own PatrolFailureCause, title, summary,
description, and recommendation. summarizePatrolRuntimeFailureDetail
mirrors the split. Helper predicates patrolToolChoiceValueRejected
and patrolNoToolCapableEndpoint encapsulate the substring matching.
The OpenRouter "No endpoints found" test fixture now correctly
classifies as no_tool_capable_endpoint instead of
model_unsupported_tools — fixture updates in
patrol_runtime_failure_test.go, patrol_assistant_handoff_test.go,
and ai_handler_test.go reflect the more accurate diagnostic.
New tests cover the tool_choice_rejected and generic
model_unsupported_tools paths explicitly.
The ai-runtime contract is updated to note the classifier-split
obligation alongside the existing transport-shape obligation.
DeepSeek's API server-side aliases deepseek-v4-flash and
deepseek-v4-pro to deepseek-reasoner, which rejects forced
tool_choice with HTTP 400 ("deepseek-reasoner does not support
this tool_choice"). Pulse's classifier then surfaced this as
"Selected model does not support Patrol tools," misdirecting
diagnosis to the model rather than the request shape.
supportsForcedToolChoice now returns false for any DeepSeek
client, so every DeepSeek model falls back to tool_choice
"auto" regardless of how DeepSeek routes the requested ID.
The ai-runtime contract is updated to match: the
provider-transport boundary now coerces forced tool_choice for
every direct DeepSeek model ID, not only unknown ones.
Patrol verified end-to-end: 20 tool calls, 9 findings, prior
runtime failure auto-resolved.
A single short file you can reach for in three weeks (or hand to
anyone wiring into Pulse from the outside) and orient yourself on
what shape the agent substrate took without re-reading the
contract subsystem. Plain English, four-axis frame (discovery,
depth, breadth, write, push), two-consumer summary
(agent-probe and pulse-mcp), an explicit "what it does not do
yet" section, and pointers into the formal contract docs and
implementation files.
Sized for release notes / GitHub announcement reuse, not as a
contract surface itself. The contract still lives in
docs/release-control/v6/internal/subsystems/api-contracts.md;
this file is the friendly door to it.
The unattended timer (scripts/pulse-auto-update.sh) and the public bootstrap
(scripts/install.sh, /install.sh) all verify the .sshsig sidecar against the
pinned pulse-installer ed25519 key before trusting a release artifact. The
in-app updater verified SHA256 only — same artifact, same root execution
context, lower trust bar. Closing the asymmetry: the in-app tarball download
in ApplyUpdate, adapter_installsh.go's install.sh download (piped into bash
as root), and the rollback binary download now fetch and verify the .sshsig
sidecar against the same pinned key, fail-closed.
The signing infrastructure (release_asset_common.sh, validate-release.sh,
backfill-release-assets.sh) already produces and validates these signatures
for every release; this teaches the Go updater to honor what the shell paths
have always required. ssh-keygen is shelled out to so the in-app updater
shares the exact trust path used by the unattended path, with a package-level
function variable for test injection so unit tests don't require ssh-keygen
on the build host.
Extends the deployment-installability contract's release-trust-fail-closed
invariant to cover the in-app updater paths.
Slice 51 added the MCP adapter as a worked example. This makes it
a published surface: an external maintainer who wants to wire
Pulse into their Claude Desktop or Claude Code can read one
README and have it working without spelunking through main.go.
The guide carries:
- Build and install instructions
- Canonical config snippets for Claude Desktop and Claude Code
- The env-var contract (PULSE_API_TOKEN, configurable name,
always read from env so it stays out of process listings)
- The published tool list grouped by category (context,
operator-state, finding) with what each does
- The stable error envelope shape and the difference between
capability-specific codes and cross-cutting auth codes
- Documented limitations: no subscribe_events, manifest fetched
once, tools-only (no resource URIs)
- Troubleshooting for the common failure modes (missing token,
proxy gating discovery, missing write scope)
api-contracts.md now points readers at the README as the
canonical integration entry point so the contract doc keeps
its in-repo focus and the README owns the user-facing copy.
Follow-up to 5ecac35e6. Recovery's `selectedHistoryItemLabel` is typed
`Accessor<string | null>`, but the JSX `title` attribute expects
`string | undefined`. The previous commit passed the accessor return
value directly, which compiled locally but failed pre-push tsc. Coerce
null to '' so the type matches and the title attribute is harmless
when no label is selected (the breadcrumb doesn't render in that case
anyway).
Continuing the truncate-cell tooltip pass:
- Recovery history breadcrumb: the selected-item label (e.g. an
LXC name) used `truncate` without a title, so a long name
couldn't be read once it overflowed the breadcrumb container.
- Resource picker rows: both the display name and the resource id
were truncated without a title, so users picking from a long
list of resources couldn't read the full identifiers when they
overflowed.
- Suggest profile modal: each saved-suggestion row showed a
truncated prompt with no way to recover the full text.
Add `title` attributes mirroring the visible content on each so
hovering exposes the full string.
Three things landed:
1. /api/agent/capabilities was missing from publicPathsAllowlist
in router_public_paths_inventory_test.go. Slice 47 added the
path to publicPaths in router.go and to publicRouteAllowlist
in route_inventory_test.go but missed this second mirror,
which scans publicPaths via go/ast. The test was failing on
origin; this commit closes the gap.
2. The error-envelope paragraph in api-contracts.md now
distinguishes capability-specific stable codes (the closed
set declared per capability in the manifest) from
cross-cutting codes the multi-tenant / auth middleware
emits universally (invalid_org, org_suspended, access_denied).
The previous wording implied all stable codes lived in
per-capability errorCodes lists, which would have forced
duplication on every capability or misled agents about which
codes to expect.
3. New contract pin TestContract_AgentSurfaceErrorCodesMatch-
ManifestDeclarations enforces the symmetry both directions:
every code emitted by an agent-surface handler must be either
declared in the matching capability or be one of the three
cross-cutting codes; every manifest-declared code must have a
matching emission. Drift either way is a contract regression.
Pin verified clean against the current handler set.
Stale forward-reference fixed: the capabilities paragraph no
longer says "future MCP-server slices read the manifest" — slice
51 already shipped that adapter.
Sweep also surfaced two failures in internal/mock/ from
unrelated platform-support drift (unraid token set added in
ac82a2852 but the mock contract test wasn't updated). Those are
not part of the agent-substrate arc and not mine to fix; flagged
in the closing summary so they don't get lost.
Continuing the truncate-cell tooltip pass:
- AI Settings diff dialog: each file path was rendered with
`truncate` and no title; long paths got clipped without a way
to read the full value.
- Audit Log panel: the Details column truncated event details
so a long event payload couldn't be inspected.
- SSO Providers panel: the test-result details (Entity ID,
SSO URL, Token Endpoint) were truncated values in a <dd>
without a title, so long URLs/IDs were unreadable.
Add `title` attributes mirroring the visible content so hovering
exposes the full string. Same shape as the previous truncate-fix
batches.
Continuing the truncate-cell tooltip pass:
- Audit Webhooks panel: each configured webhook URL was rendered
with `truncate` and no title, so a long endpoint URL clipped past
the visible card width with no way to read the rest.
- SSO Providers panel: provider name and summary lines used
`truncate` without a title, so a long display name or summary
became unreadable in the row.
Add `title` attributes mirroring the visible content on each so hover
reveals the full string. Same shape as the Alert History fix in the
previous commit.
The whole point of slice 39's hand-authored manifest with
snake_case names and stable error codes was to make adapter
projection cheap. This slice is the test: a minimal MCP (Model
Context Protocol) server that turns Pulse's manifest into a tool
surface Claude Desktop, Claude Code, and other MCP-speaking
clients can drive natively.
Every MCP tool is a one-line projection of a manifest capability.
Input schemas are auto-derived from path placeholders ({name}
segments become required string properties) and method (non-
GET/DELETE tools accept a free-form body object). Adding a
capability to the manifest automatically extends the tool surface
— no MCP-side changes required.
The adapter is stdlib-only, runs over stdio with line-delimited
JSON-RPC 2.0 framing, preserves Pulse's stable error envelope
verbatim through MCP's content-and-isError result so agents on
the MCP side branch on the same codes they would on the wire,
and skips subscribe_events (SSE streaming doesn't fit the
request/response tool shape; future slices can layer it as MCP
notifications).
Eleven tests pin the projection rules and the JSON-RPC contract:
path-placeholder schema generation, body-property method gating,
substitution failures producing stable errors, the initialize
handshake advertising tools, tools/list filtering subscribe_events,
tools/call proxying with the bearer token and preserving the
substrate's error envelope, unknown methods producing JSON-RPC
method-not-found, and notifications producing no response.
The substrate is now wrapped in two adapters, each demonstrating a
different consumer profile: agent-probe walks the substrate as an
HTTP client (slice 49); pulse-mcp wraps it for stdio MCP clients.
Both depend only on the standard library and resolve paths from
the manifest, so the substrate is the single source of truth and
adapter additions stay cheap.
Several truncated cells with no title attribute meant the user couldn't
read the full value when it overflowed:
- Alert History → Resource column: "Tower - Unraid Array" became
"Tower - Unraid A..." with no way to read the rest.
- Alert History → Node column (visible at lg+): same.
- Configured Node Tables (Settings → Infrastructure): node name and
host fields used `truncate` without title, so long endpoints
couldn't be read.
Add `title` attributes that mirror the cell contents so hovering shows
the full string. The Message column already had this pattern via
title={props.alert.description}; this commit applies the same shape
to the Resource and Node columns and to the configured-node fields.
The CURRENT VERSION value on /settings/system-updates uses a `truncate`
class so long version strings (e.g. 6.0.0-rc.4+git.303.g956646a5c.dirty)
fit the narrow card column. The visible string was clipped to
"6.0.0-rc.4+git.303.g956646a..." with no title attribute, so a user who
needed the exact version (for bug reports, support requests, etc.) had
no way to read it from this surface.
Add a title attribute that mirrors the full version so hovering reveals
the complete string.
Closes the certainty loop for agents watching the substrate's push
channel. The action audit's read-after-write probe outcome was
already persisted on the audit record, but agents watching
action.completed only learned "the action ran" — they had to fetch
/api/actions/{id} to know whether the read-back probe confirmed
the intended state. That defeated the substrate's
push-notification guarantee for dispatch certainty.
The new agent-stable AgentResourceActionVerification projection
(ran, success, command, note, ranAt — output stays in the audit
record, deliberately omitted from events to keep payloads small)
is now carried on both:
- the action.completed SSE payload, projected from
record.Result.Verification by the router-side bridge in
wireAIChatDependenciesForService, and
- the resource-context bundle's recentActions surface, via the
same shared projectAgentResourceVerification helper
so the bundle (depth) and the doorbell (push) speak the same
vocabulary. Refused-before-dispatch failures omit verification
(the probe never runs) so agents branch on field presence to
distinguish "no probe attempted" from "probe ran with empty
result". Three contract pins lock the symmetry: payload field
present, router bridge populates it, bundle parallels.
The capabilities manifest's subscribe_events description now
mentions the verification block so external agents discover the
field through the same path they already use to learn the rest
of the agent surface.
The substrate's read and write surfaces are end-to-end-tested
internally; this slice answers the harder question — "is the
substrate actually usable from the outside?" — by writing the
smallest standalone program that consumes it. agent-probe walks
the discovery → triage → depth → push flow against a running
Pulse instance using only the Go standard library, so it doubles
as a reference implementation for anyone building MCP servers,
Claude Code integrations, or custom agents on top of Pulse.
It resolves every path from the manifest rather than hardcoding
them — if discovery moves a path, the probe follows
automatically — and branches on the stable error envelope's
"error" code field, never on human-readable messages. The focus
rule (severity-lex-ordered) is intentionally simple so a reader
can predict what the probe will pick; real agents will have
richer policies.
This is documentation as code: the program is short enough to
read top-to-bottom and reads like the agent's own narration of
what it's doing. The unit test pins the focus rule's lex
ordering so a refactor that swaps it for a weighted score (which
allowed many warnings to outrank one critical) cannot regress
silently.
Closes the e2e contract proof on the write side. The only write
capability the manifest declares is the operator-state intent
loop (set / get / clear), and this test boots the full router
stack to walk every state of that loop through the actual HTTP
boundary — proving the manifest's declared error codes for
set_operator_state and get_operator_state reach the wire from
the handlers, the URL canonical id authoritatively wins over
body-supplied ids (no scope-confusion writes), and SetAt/SetBy
are server-populated so attribution cannot be spoofed.
The flow exercised:
GET unset → 404 operator_state_not_set
PUT valid → 200 with persisted state + server SetAt
GET → round-trips
PUT invalid criticality → 400 operator_state_invalid
DELETE → 204
GET → 404 operator_state_not_set (loop closed)
DELETE again → 204 (idempotent)
Two contract pins lock the audit-honesty and error-token
contracts so a future refactor of the handler can't silently
regress either: SetAt/SetBy populated server-side, URL-id wins
over body-id, and the validator's domain error maps to the
stable wire token via errors.Is rather than message-matching.
Together with the read-side e2e (slice 47), the agent surface —
read, write, push — has now been exercised end-to-end as one
substrate.
The Alerts bottom-tab on mobile shows a count badge (e.g. "9") visually
adjacent to the "Alerts" label. Without an aria-label, screen readers
read the concatenated text "9Alerts" — the badge digits run into the
label. Added an aria-label that reads "Alerts: 9 critical, 2 warning"
(adapted to whichever counts are non-zero) so screen reader users hear
a coherent announcement, and marked the visual badge container
aria-hidden so it's not double-announced.
The unit tests cover each piece in isolation; this test boots the
full router stack and proves the discovery → triage → depth chain
works as one substrate through the actual HTTP boundary an
external agent would hit. It found two real bugs slice 40
introduced and slice 45/46 didn't surface:
- /api/agent/capabilities was documented as unauthenticated but
was missing from the router's publicPaths list, so the global
auth middleware was 401'ing the discovery manifest. Fixed by
adding the path to publicPaths and pinning the contract so it
cannot regress.
- The error-envelope shape across the agent surface is
{"error": "<stable_code>", "message": "<human>"}, written via
writeJSONError — not the {"code": ...} shape I had assumed in
the docs. Pinned the wire shape on api-contracts.md so the
documented error contract matches what writeJSONError actually
writes.
The e2e test exercises capabilities discovery, triage via
fleet-context, and depth via resource-context with an unknown id
to confirm the resource_not_found stable error code reaches the
wire under the canonical "error" key. The subscribe_events SSE
path is probed unauthenticated to confirm it's gated (401) rather
than 404 — discovery's claim is honest.
The platform tabs (Infrastructure / Workloads / Storage / Recovery)
and utility tabs (Alerts / Patrol / Settings) at the top of the
desktop layout already had role="tab" and aria-label, but no tabIndex
or onKeyDown — keyboard users couldn't focus them and screen readers
saw a tab role that wasn't actually focusable.
Add tabIndex={0} and a keydown handler on Enter or Space to both tab
groups. The mouse path is unchanged. Verified live: tabbing to the
Workloads tab and pressing Enter navigates to /workloads.
The substrate had a per-resource bundle but no fleet view, so
"where do I focus?" forced agents to walk every resource id and
bundle each — O(N) round trips that scale with fleet size. The
fleet endpoint returns a thin per-resource rollup in a single
read: identity, operator-intent flags (intentionallyOffline,
neverAutoRemediate, maintenanceWindowActive), per-severity
finding counts, and pending-approval count.
Same auth scope and same provider wiring as the per-resource
bundle — operator-state via the canonical unified store, findings
via AgentFindingsProvider, approvals via AgentApprovalsProvider —
so the fleet sweep is the per-resource bundle's wiring multiplied
by N with no new dependencies. Audit reads are deliberately
omitted from the rollup; agents that want depth on a flagged
resource follow up via /api/agent/resource-context/{id}.
The capabilities manifest declares get_fleet_context with
AgentFleetContext as the response shape so external agents
discover the triage entry point through the same path they
already use to learn the rest of the agent surface.
Each metric value cell on /alerts/thresholds is a clickable surface that
opens an inline editor for the threshold value. The wrapper was a plain
<div> with cursor-pointer and onClick — focusable for mouse but not for
keyboard, and screen readers didn't announce it as a button.
Add role="button", tabIndex={0}, onKeyDown for Enter/Space activation,
and an aria-label that mirrors the existing title hint. The mouse path
is unchanged; keyboard users can now Tab into the cells and press Enter
to open the editor.
The substrate's "everything an agent needs in one read" guarantee
covered identity, operator state, findings, and recent actions but
forced a separate /api/approvals call for pending governance
state. AgentResourceContext now carries pendingApprovals as a
lightweight AgentResourceApprovalSummary projection — same
vocabulary as approval.pending SSE events, so the doorbell and
the bundle agree on shape. AgentApprovalsProvider is the parallel
seam to AgentFindingsProvider; the router wires a closure that
resolves approval.GetStore() at request time, scopes via
BelongsToOrg, and filters by CanonicalResourceID so cross-tenant
or cross-resource pending requests don't leak. Empty arrays
preserve the iteration-safe contract the existing sections
already follow.
The "+N" overflow indicator on TagBadges was styled with
`cursor-pointer`, which signals a clickable affordance — but the
element only listens for mouseenter/mouseleave to show a tooltip and
has no click handler. Switch to `cursor-help` so the cursor matches
the actual interaction (hover for more info), avoiding a phantom
click expectation.
The "+N" overflow indicator on TagBadges was styled with
`cursor-pointer`, which signals a clickable affordance — but the
element only listens for mouseenter/mouseleave to show a tooltip and
has no click handler. Switch to `cursor-help` so the cursor matches
the actual interaction (hover for more info), avoiding a phantom
click expectation.
Each Patrol finding row was a plain <div> with cursor-pointer and an
onClick handler that toggled the row's expanded state. Mouse users got
the toggle, but the row had no role, no tabIndex, and no key handler,
so keyboard users couldn't focus or activate it and screen readers
didn't announce it as a button.
Add role="button", tabIndex={0}, aria-expanded, and aria-controls (with
a matching id on the expanded details container) plus a keydown handler
that toggles on Enter or Space. The mouse path is unchanged.
The infrastructure connected-systems UI swapped to a card layout below
767px and to a table layout otherwise — but the table itself sets
`min-w-[820px]`, so any container width between 768px and 819px
rendered the table only for it to overflow horizontally inside the
settings panel. Tablet-class viewports were the worst case: scroll
stayed visible and the Action column was clipped.
Bump CARD_LAYOUT_MAX_WIDTH_PX from 767 to 819 so the breakpoint matches
the table's actual minimum width. The card layout now renders for any
container that can't fit the table cleanly, and the table only appears
when the columns can render at full width.
Tail of the operator-local-UI redaction sweep (abdde303a, a17f879a1).
resolveKubernetesContextForResource gated on requiresGovernedResourceDisplay
to choose between getPreferredInfrastructureDisplayName and a manual
displayName-or-name fallback. Both branches produce a raw infra name
once we trust that displayName never carries a redacted summary in
local rendering, so the gate is dead complexity. Collapse to a single
call and drop the now-unused requiresGovernedResourceDisplay import.
problemResourcePresentation.getProblemResourceDisplayName has no
production consumers today, but it still routes through the governed
helper. Reclassify it now (same as every other operator-local helper)
so the rule is consistent across the codebase if the surface ever gets
adopted.
Follow-up to the previous Discovery banner fix. Move the
`whitespace-normal` class from the per-banner text wrapper up to the
DiscoveryTab's outer container, so that any text inside the tab — the
banner copy, the per-command descriptions in the "Commands that will
run" disclosure, and any future explanatory copy — wraps without
needing a per-element override. The fix sits at the boundary where the
inherited `.table-fixed td/th` rule (white-space: nowrap) reaches the
expanded-row content.
The "What Discovery Does" info banner inside an expanded Workloads row
was rendering on a single line and getting clipped at the row width —
the user could only read up to "...the configured analysis prov" before
overflow. Cause: the parent <table> sets `whitespace-nowrap` on the
whole table for the metric cells, and CSS inheritance pulled that into
the expanded-row content.
Add `whitespace-normal` to the banner's text wrapper so its multi-line
copy wraps as intended. As a side effect, the dismiss "×" button now
also lands inside the visible viewport on this surface.
Quiet hours, Alert cooldown, Smart grouping, and Alert escalation cards
on /alerts/schedule each wrap their form fields in a `<Show
when={enabled}>` block. When disabled, the body content disappears but
the SettingsPanel's outer body padding (`p-4 sm:p-6`) still rendered as
~50px of empty whitespace per card — five cards stacked added almost
half a viewport of vacant space and pushed the Configuration summary
card off-screen.
Pass `noPadding={!enabled}` to each panel so the body div has no
padding when there's nothing to show. The disabled cards now collapse
to just the header strip + toggle, and the Configuration summary lands
above the fold. Recovery notifications already shows persistent body
text and is left unchanged.
Closes the "has context vs uses context" gap that defines Pulse's
agent-paradigm differentiation. The orchestrator (in pulse-pro) used
to receive a Finding with no awareness of the operator's
commitments — Patrol could investigate a resource the operator had
marked never-auto-remediate and propose a restart fix that the
action broker would refuse downstream. The proposal shouldn't have
happened in the first place.
Adds two optional fields to aicontracts.Finding:
- OperatorContext: intentionally offline, never auto-remediate,
maintenance window with computed active flag, criticality, note.
Populated in MaybeInvestigateFinding from the same operator-state
projection the suppression hot path consumes, so investigation
reasoning and suppression behavior cannot drift apart.
- OperationalMemory: regression count, previous resolved fix
summary, last regression timestamp, times raised. Populated in
ToCoreFinding from fields the internal Finding already carries.
ResourceOperatorStateProjection grew a NeverAutoRemediate field —
the investigation read path needs it (so the orchestrator can avoid
proposing fixes the broker would refuse) even though the
suppression hot path doesn't. Same projection serves both reads.
Both fields are nil when there's no signal (fresh finding, no
operator state) so the orchestrator branches on absence rather
than parsing zero-valued structs. The pulse-pro orchestrator
consumes the fields in a separate slice; this slice ships the
in-repo half of the data path.
The Alert History table had 10 columns and `min-w-[max-content]` on the
table, totalling ~1310px natural width. At narrow viewports (e.g. an
~900px content area), the user had to horizontally scroll past the
high-priority columns (Timestamp / Resource / Severity / Message) just
to see the lower-priority Duration / Status / Node columns.
Drop `min-w-[max-content]` and add `hidden lg:table-cell` to the
Duration, Status, and Node header + data cells. At < lg the table now
fits the available width while keeping the most-useful columns visible
upfront. Status is still implied by the row coloring + Severity badge,
Duration is rarely the deciding factor at-a-glance, and Node is
redundant when Resource already names the host.
Continues the policy-redaction scope fix from abdde303a. The same wrong
invariant -- "operator-local UI redacts when policy says redact" -- was
also encoded in:
- alertResourceTableModel.getAlertResourceLabel (the /alerts table that
feeds AlertResourceTableMobile and AlertResourceTableRow)
- AlertResourceIncidentsPanel (the incident card shown after the operator
clicks into a resource)
- infrastructureSelectors.matchesSearch (/infrastructure search filter,
whose haystack already includes raw hostname/ips so redacting the
displayName was internally inconsistent)
- useResources filter().search (the shared resource hook's search
predicate, used by Settings panels, /storage, /alerts, FindingsPanel)
- recoveryRecordPresentation.getRecoveryLinkedResourceLabel (recovery
rollup labels rendered in /recovery)
- Settings/ResourcePicker (the operator's selection UI for the Reporting
Panel; the picker is local even when the report itself transmits)
All six surfaces are operator-local. Per docs/PRIVACY.md, resource-policy
redaction applies "Before non-local model requests leave the instance" --
it is a transmission-boundary policy, not a local-rendering policy. The
operator must see their own resource names to recognize, search, and
configure them.
Switch each callsite to getPreferredInfrastructureDisplayName. Invert the
five test fixtures (ResourceTable, ResourcePicker, infrastructureSelectors,
UnifiedResourceTable performance contract, WorkloadsSurface performance
contract) that locked in the old redaction-in-local-UI invariant.
Cloud-bound callers (AI Chat, organization sharing prepare-payload, the
resource-detail drawer's governance meta panel) still call
getPreferredResourceDisplayName and continue to redact -- their tests
are unchanged. The only call site of the governed helper that is left
not yet reclassified is problemResourcePresentation, which currently has
no production consumers; leaving it untouched.