Commit Graph

5945 Commits

Author SHA1 Message Date
rcourtman e087eff00e Stop polluting Patrol finding lifecycle with no-op heartbeats
Every Patrol scan that re-detected an already-active finding was
appending a "detected (same_state -> same_state)" lifecycle event
with the message "Detected by Pulse Patrol". A finding active for
6 scans rendered as 6 stacked rows reading
"Detected Detected by Pulse Patrol (detected -> detected)".

Backend: drop the unconditional re-detection lifecycle append in
findings.go. The lifecycle should record state transitions, not
heartbeats — TimesRaised and LastSeenAt already track recurrence,
and the genuine transition events ("regressed", "reminded",
"suppression_lifted") are emitted from their own branches upstream.

Frontend: defensively hide (from -> to) spans where from === to
and strip the type-label prefix from the message so already-
persisted polluted lifecycle entries also render cleanly until
they age out of the per-finding event cap.

Adds a test that locks in the new backend behavior.
2026-05-10 17:13:07 +01:00
rcourtman 3da835c5bc Publish a distribution path for pulse-mcp
The MCP adapter shipped in slice 51 with one install option:
clone the repo and go build. This slice integrates pulse-mcp
into Pulse's existing governed release pipeline so a Pulse
release publishes a pulse-mcp binary alongside the unified agent
and the install scripts that bring it home in one command.

What ships:

  - scripts/build-release.sh extended to build pulse-mcp for
    the same multi-OS matrix as the unified agent, package
    per-platform tarballs and zips, and copy bare binaries to
    RELEASE_DIR for /releases/latest/download/ redirect
    compatibility.
  - .github/workflows/create-release.yml extended to upload
    the bare pulse-mcp binaries plus install-mcp.sh and
    install-mcp.ps1 as release assets.
  - scripts/install-mcp.sh: bash one-line installer that
    detects platform/arch, downloads the matching binary from
    the configured release (latest by default), verifies SHA256
    against the published checksums.txt, places at
    ~/.local/bin/pulse-mcp (or /usr/local/bin if not writable).
    Honors PULSE_MCP_VERSION, PULSE_MCP_BIN_DIR, PULSE_MCP_REPO,
    PULSE_MCP_NO_VERIFY env vars; declines Windows shells with
    a pointer at the .ps1 sibling.
  - scripts/install-mcp.ps1: PowerShell installer for Windows,
    placing pulse-mcp.exe at $LOCALAPPDATA\pulse-mcp.

Documentation aligned:

  - cmd/pulse-mcp/README.md gains an Install section above
    Quick start with three options: one-line installer,
    GitHub Release download, go install. Documents the macOS
    Gatekeeper bypass since v1 is unnotarized by design.
  - The Settings -> API Access agent-integrations panel now
    surfaces the curl|bash command above the config snippet so
    operators see "install pulse-mcp" before "configure your
    MCP client."
  - docs/releases/AGENT_PARADIGM.md drops the "no published
    distribution path" item from "what it does not do yet" and
    documents the Gatekeeper / Homebrew gaps as next-tier
    follow-ups.

Trade-offs surfaced and chosen:

  - Same cadence as Pulse: pulse-mcp ships per Pulse release,
    not on its own track. The MCP server reads the manifest
    from the Pulse it talks to, so version alignment is the
    natural model.
  - No Homebrew tap or core formula in v1. Maintaining a tap
    is real ongoing work; foundation supports adding Homebrew
    later as a layer.
  - No Docker image. Stdio JSON-RPC fights Docker's stdin
    /stdout pattern.
  - No notarization in v1. SHA256 verification through the
    installer preserves the audit trail; README documents the
    Gatekeeper bypass.

Subsystem contract: deployment-installability.md gains
scripts/install-mcp.sh, scripts/install-mcp.ps1, and
cmd/pulse-mcp/ in canonical files (mid-list entries
renumbered) plus a paragraph documenting the new MCP entry
point alongside the existing installer family.

Verification artifacts:

  - scripts/installtests/build_release_assets_test.go gains
    TestBuildReleasePackagesPulseMcpForAllPlatforms which pins
    the build/package/copy wiring and the load-bearing
    install-mcp.sh helpers (platform detection, SHA256
    verification, install-dir resolution).
  - scripts/release_control/render_release_body_test.py gains
    test_agent_paradigm_release_notes_blurb_documents_-
    distribution_path which pins the AGENT_PARADIGM.md draft's
    install-mcp.sh reference and the four-axis frame so a
    future edit cannot regress the install story silently.

Smoke-tested install-mcp.sh locally on darwin-arm64: platform
detection, install-dir resolution, URL building, and 404 error
handling all correct. The full end-to-end install path becomes
live the moment a Pulse release ships pulse-mcp binaries; the
next RC cut will exercise it.
2026-05-10 17:04:49 +01:00
rcourtman 6ea6f11a7d Label more icon-only buttons for screen readers
Continues the labelling sweep: close X buttons in
UpdateConfirmationModal, ChangePasswordModal, SuggestProfileModal,
UserAssignmentsDialog, the Patrol configuration popover, the Toast
dismiss control, the SSO provider test result and metadata preview
dismiss buttons, and the compact ContainerUpdateBadge button (which
becomes icon-only when compact). Each gets aria-label + title; the
inline SVG icons get aria-hidden so the button label is the only
announced text.
2026-05-10 16:59:44 +01:00
rcourtman 057df88c79 Flatten Patrol header toggle row
The Patrol-enabled toggle and Configure Patrol button were wrapped in
a bordered card with a second inner card around the toggle alone.
That stacked two card-on-card surfaces between the page header and
the assessment summary. Removed both card wrappers; the toggle and
Configure button now sit on a flat row, reducing chrome between the
page title and the actual content.
2026-05-10 16:53:12 +01:00
rcourtman af422ddf2f Verify Patrol now tests the form's pending model, not the saved one
Two real UX bugs in the Verify Patrol panel:

1. The button silently tested the previously-saved model
   instead of the operator's pending dropdown selection.
   Clicking Verify after changing the model would re-run
   preflight against the OLD model and the operator would
   believe the new selection was verified.

2. The result panel rendered the cached green badge even
   when the form's current selection was a different model
   from the one in the cache, with no visual cue that the
   verified result was for something other than what they
   were looking at.

Fixes:

- runPatrolToolPreflight now passes form.patrolModel as the
  model override on the POST. Empty form value falls through
  to the configured shared default on the backend.
- PatrolPreflightControl computes isStaleAgainstFormSelection
  by comparing the bare model name in form.patrolModel to
  the cached result.model. When stale, the panel switches
  to amber tone with a headline that names both models and
  a detail line prompting the operator to click Verify Patrol.
- stripModelProvider helper handles "provider:model" prefix.

Architecture guardrail extended with three assertions
covering the form-aware verify path and the staleness
indicator wiring. frontend-primitives contract updated.

Live verified: changing the dropdown from deepseek-v4-flash
to deepseek-v4-pro before saving switches the panel from
green "Tool calling verified" to amber "Verified result is
for deepseek-v4-flash, your current selection is
deepseek/deepseek-v4-pro · Click Verify Patrol to test the
pending selection."
2026-05-10 16:51:17 +01:00
rcourtman 9f86861c53 Label icon-only buttons for screen readers
Three close/remove buttons in Settings → RBAC role editor and
ResourcePicker rendered only a Lucide icon with no aria-label or title,
so assistive tech announced them as "button" with no purpose. Add
aria-label + title to each: dialog close, remove permission row, and
remove selected resource chip.
2026-05-10 16:50:41 +01:00
rcourtman 174d2b04d3 Collapse Patrol summary verification + metric chips behind a toggle
The Patrol Assessment card was rendering a Verification + Latest activity
panel and a metric chips grid for every Patrol view, pushing the trust
strip, tabs, and findings list further down the page. The recommended
next step and Discuss with Assistant entry points stay always-visible —
the rest folds behind a Show details / Hide details toggle, defaulting
to compact.
2026-05-10 16:49:45 +01:00
rcourtman 4b6e409747 Draft a release-notes blurb for the agent paradigm work
Sources cleanly into release announcements without forcing a
fresh draft when the next RC or GA cuts.

Sized as a focused source draft (under 130 lines): one-paragraph
headline, three operator-facing bullets, the four-axis frame
(discover, read, write, push) lifted and condensed from
docs/AGENT_SUBSTRATE.md, an integrators-pointing section that
names the two reference adapters and the canonical contract, an
honest "what it does not do yet" paragraph (no published
pulse-mcp distribution; no real-world consumer feedback yet),
and an audit-trail summary.

Style matches the existing docs/releases/RELEASE_NOTES_v6_*.md
draft pattern: technical, plainspoken, no marketing prose,
honest about scope. No em dashes per project rule.

Lives at docs/releases/AGENT_PARADIGM.md alongside the existing
release-notes drafts so a release author finds it where they
already look. The header explicitly frames it as a source
draft to drop into announcements, GitHub prerelease descriptions,
or a "What Changed" section in a versioned release notes file,
trimmed or expanded as the cut requires.

Contract-neutral commit: docs-only addition, no runtime code,
wire shape, manifest entry, error code, version artifact,
release workflow input, governed metadata, or canonical-files
contract changed. PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT used with
a documented reason since the deployment-installability
verification-artifact rule wants a Python release-promotion
test touched, which would be wrong scope for a markdown draft.
2026-05-10 16:44:26 +01:00
rcourtman 297556fb65 Surface cached preflight in Patrol tools readiness check
Previously the "Patrol tools" readiness check was static
model-name pattern matching: it told the operator whether
the selected model is on Pulse's tool-capable allowlist,
not whether tools have actually been verified to work.
After today's preflight cache, that's strictly less
information than what we already know.

resolvePatrolToolsCheck now consults
aiService.CachedPatrolPreflight() and grounds the check
in real evidence when a result for the configured
provider+model exists:

  - cached green (success + tool_call_observed) →
    "Tool calling verified <age> against <model>." (ready)
  - cached failure → classified summary + "(last preflight
    <age>)." (not_ready)
  - cached soft warning (model_tool_support_unverified) →
    same with warning status
  - no cache or model mismatch → static fallback

formatPatrolPreflightAge produces stable English ("just
now", "5m ago", "2h ago", "3d ago") with full unit
coverage (11 cases).

HandleGetAISettings now also includes patrol_readiness in
its response — previously only the PUT response carried it,
so the Patrol page only got augmented readiness after a
save. The frontend already had patrol_readiness typed and
read it from useAISettingsState.

ai-runtime, api-contracts, agent-lifecycle (dep), and
storage-recovery (dep) contracts updated.
2026-05-10 16:41:16 +01:00
rcourtman 8cb2f2d66b Confirm before removing audit webhook URLs
The remove (×) button on each configured audit-webhook URL called
handleRemoveWebhook immediately — a single accidental click stopped
audit deliveries to that endpoint, breaking any external
SIEM/SOC/log-aggregation receiver that depends on Pulse's stream.

Add a confirmation Dialog matching the pattern from
APITokenManager (2a24a505e) and SSO Providers panel: clicking the
trash icon now opens "Remove audit webhook?" modal that names the
URL and explains the consequence; the user has to press "Remove
webhook" to actually trigger the delete. Cancel or backdrop-click
dismisses without action.

AgentProfilesPanel and AIProviderConfigurationSection already use
native confirm() for their destructive actions — no change needed
there.
2026-05-10 16:38:58 +01:00
rcourtman d4efc08909 Surface agent integrations in Settings → API Access
The agent substrate has been entirely API-side until now. An
operator opening Pulse had no way to know it existed, no way to
see what an agent connected to their instance could do, and no
quick path to a working MCP integration. Slice 59 closes that
visibility gap.

A new AgentIntegrationsPanel renders below the existing
APITokenManager on the API Access tab. The panel:

- Fetches /api/agent/capabilities at mount and renders the
  declared capabilities grouped by category (Context, Operator
  state, Patrol findings, Action governance), each row showing
  name, method+path, scope chip, description, and the stable
  error codes the manifest declares. Adding a capability on the
  backend extends this list automatically; nothing in the panel
  is hardcoded against the substrate's surface.

- Generates an MCP config snippet using window.location.origin so
  the snippet is correct for whichever URL the operator is
  reading from. Includes a copy-to-clipboard button (matching
  the existing CopyCommandBlock pattern) and a brief explainer
  pointing at the right config path for Claude Desktop and
  Claude Code.

- Links to cmd/pulse-mcp/README.md, cmd/agent-probe, and
  docs/AGENT_SUBSTRATE.md so an integrator has the full setup
  story without leaving the panel.

Wiring is one import + one component placement in
APIAccessPanel.tsx. The api tab already concerns "what can be
done with API tokens"; adding the agent surface as a sibling
section under the same tab keeps the operator's mental model
coherent (one place for machine-driven access) and avoids
growing the Settings tab inventory or touching
settingsNavigationModel, the registry, the loaders, or the
routing tests.

Two architectural pins land alongside:

- settingsArchitecture.test.ts gains a guardrail that the
  AgentIntegrationsPanel sits as a sibling section inside
  APIAccessPanel rather than being lifted into its own tab.
  Drift would fragment the agent surface across navigation.
- The frontend-primitives contract documents the
  sibling-panel-over-new-tab pattern for additive operator
  surfaces closely related to an existing tab's intent.
- The security-privacy contract documents the new section's
  presence on the API Access tab and pins that token minting
  still flows through APITokenManager — the new section
  surfaces what tokens unlock, not a parallel auth path.

Verified against the running dev server: the page renders the
four category sections (Context, Operator state, Patrol
findings, Action governance), the live MCP config snippet
contains the deployment's own origin, and the manifest endpoint
serves all 14 capabilities (the 11 substrate capabilities + the
3 action capabilities from slice 58). TypeScript clean across
the frontend.
2026-05-10 16:35:40 +01:00
rcourtman f74add8271 Auto-seed Patrol preflight cache on Pulse startup
Closes the cold-start gap in the preflight observability
layer: every Pulse restart blanked the cached "last verified"
indicator until the next save or manual click, which meant
operators saw "never verified" on every upgrade or process
restart even when the configured Patrol model was working
fine.

NewAISettingsHandler now reuses the existing
aiSettingsUpdateRequiresPatrolPreflight predicate with a nil
"prior config" — semantically "no in-memory cache yet, just
booted." When the loaded config has assistant enabled and a
Patrol model, the handler dispatches the same async
TriggerPatrolPreflightAsync the save path uses. Routine
boots where assistant is disabled (or no Patrol model is
selected) skip the dispatch so we never write a misleading
"Pulse Assistant is not enabled" entry into the cache.

Live verified: after Pulse restart, /api/settings/ai
surfaces a fresh patrol_preflight with success=true within
~6s of boot, no operator action required.

Predicate test extended with two named cases that document
the dual-purpose use (startup seed + skip-when-disabled).
ai-runtime, api-contracts, agent-lifecycle (dep), and
storage-recovery (dep) contracts updated.
2026-05-10 16:34:34 +01:00
rcourtman 2a24a505ea Confirm before revoking API tokens
The Revoke button on /settings/api called handleDelete immediately —
a single accidental click permanently revoked the token, breaking any
agents, scripts, or integrations using it with no undo path.

Add a confirmation Dialog (matching the SSO Providers panel's pattern)
that opens when Revoke is clicked. The user has to explicitly press
"Revoke token" in the modal for the delete to fire; "Cancel" or
backdrop-click dismisses without action. Modal copy spells out that
revocation is permanent and lists the consequence so the user can
make an informed choice.

Updated the existing token-revocation test to click through the new
confirmation step.
2026-05-10 16:33:48 +01:00
rcourtman 642f01e5d1 Add skip-to-content link for keyboard users
Pulse had no skip-to-content affordance, so keyboard-only users had
to Tab through the entire top navigation chrome (kiosk button, logout
button, status badge, brand lockup, all the platform tabs, all the
utility tabs, etc.) before reaching page content on every navigation.

Add a "Skip to main content" link as the first focusable element in
AppLayout. Visually hidden via sr-only by default; on focus, a state
signal toggles to a visible blue button at the top-left. Activating
the link jumps focus to <main id="main">.

Tried Tailwind's `focus:not-sr-only` and `focus:top-2` variants first
but the dev-server CSS didn't pick the variants up reliably (the
focus state reported but the styles never applied). Switched to a
SolidJS focus signal driving a class swap, which works deterministically.
2026-05-10 16:26:28 +01:00
rcourtman 33b6bf97d1 Auto-trigger Patrol preflight when settings save moves Patrol transport
Closes the resilience gap: today an operator picks a Patrol
model, saves, and Patrol silently fails on its first
scheduled run because nothing verified the model actually
calls tools. Now the save handler dispatches a one-shot
preflight in the background whenever the change actually
moved Patrol's transport, and the cached result surfaces on
the next /api/settings/ai poll via patrol_preflight.

Trigger conditions (aiSettingsUpdateRequiresPatrolPreflight):
  - new config has assistant enabled AND a Patrol model
  - AND any of:
      * no prior config (first save)
      * assistant was disabled, now enabled
      * Patrol model changed
      * API key for the new patrol model's provider changed

Routine saves that don't touch Patrol transport (theme,
control level, discovery toggles, unrelated provider keys)
skip preflight entirely so they don't burn provider tokens
or add 5-10s latency to every save.

Service-level TriggerPatrolPreflightAsync runs the call in a
goroutine with a 30s timeout. Detection helper has full
unit coverage including the negative paths.
2026-05-10 16:14:26 +01:00
rcourtman 728c42e47b Bring action endpoints onto the agent surface with the agent-stable envelope
Closes the last known gap in the agent substrate. The three
action endpoints (POST /api/actions/plan, /api/actions/{id}/decision,
/api/actions/{id}/execute) previously emitted the platform-wide
APIError shape (stable code under "code", human under "error").
The agent surface uses the inverted shape (stable code under
"error", human under "message"), so adding action capabilities
to the manifest as-is would have forced agents to remember which
envelope each capability uses.

The slice refactors actions.go to emit the agent-stable envelope
across all 42 writeErrorResponse call sites. writeJSONError gains
a writeJSONErrorWithDetails sibling so the 13 calls that pass
field-level reasons (validation failures) preserve that
information under a new optional `details` field. The action
endpoints' JSON shape becomes:

  {"error": "<stable_code>", "message": "<human>",
   "details"?: {"<field>": "<reason>"}}

Frontend impact: zero. Verified that no frontend code consumes
the three action endpoints; the refactor is API-only.

Three new manifest entries (plan_action, decide_action,
execute_action) under a new "action" category, with their
declared error codes pinned per capability. Internal-failure 5xx
codes (audit-store outages, encode failures) are not declared
per capability; agents branch on 5xx generically.
TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations now
audits actions.go alongside the existing two handler files, with
a documented internal-only allowlist for the 5xx codes.

The TestAgentSubstrate_ActionEndpointsEmitAgentStableEnvelope e2e
test exercises one error path through each endpoint via the actual
HTTP boundary, asserting the agent-stable envelope reaches the
wire and the legacy APIError fields (code, status_code, timestamp)
do NOT — drift back would mean the refactor regressed.

The TestContract_ActionDryRunOnlyExecutionErrorJSONSnapshot pin
is updated to match the new envelope shape; the manifest's
category allowlist gains "action".

api-contracts.md documents the new envelope (with details map),
the action governance loop's place in the substrate, the
ai:execute scope distinction from monitoring:write, and the
"manifest projection has a footnote" trade-off: bringing an
existing endpoint into the agent surface may require migrating
its error envelope, but the substrate keeps a single envelope
contract rather than carrying a translation wrapper layer.
agent-lifecycle.md and storage-recovery.md document the action
surface joining the agent paradigm and its zero-new-persistence
posture respectively. AGENT_SUBSTRATE.md's "what it does not do
yet" no longer lists the action surface; it now reflects the
real outstanding items (consumer feedback, an in-Pulse agent
integrations panel, a distribution path for pulse-mcp).
2026-05-10 15:16:17 +01:00
rcourtman dcd2698573 Mirror PageHeader title into document.title
Previous commit (feefc4bb4) set per-tab document.title from AppLayout
based on the active top-level tab. That covers the seven primary
surfaces but leaves sub-routes ambiguous — every settings sub-page
read as "Settings · Pulse", every alerts tab as "Alerts · Pulse".

Have PageHeader sync its title prop into document.title whenever the
title is a plain string (which is the common case). Page-specific
titles like "Alert History", "Maintenance Schedule", "General",
"Network", "Security Overview" now show in browser tabs and history,
and screen-reader page-title announcements identify the specific
surface.

Verified live across 13 routes:
  /alerts/history          → "Alert History · Pulse"
  /alerts/notifications    → "Notifications · Pulse"
  /alerts/schedule         → "Maintenance Schedule · Pulse"
  /settings/system-general → "General · Pulse"
  /settings/system-network → "Network · Pulse"
  /settings/security-overview → "Security Overview · Pulse"
  ... etc
2026-05-10 15:14:42 +01:00
rcourtman feefc4bb41 Reflect active tab in document.title
Every Pulse route rendered as a browser tab labeled "Pulse" — the
title never changed even when navigating between Infrastructure,
Workloads, Storage, Recovery, Alerts, Patrol, and Settings. Multi-tab
users couldn't tell tabs apart, browser history showed identical
entries, and screen-reader page-title announcements gave no useful
context.

Add a createEffect in AppLayout that maps the active tab id (resolved
via getActiveTabForPath on location.pathname) to a label and writes
"{label} · Pulse" to document.title. Verified live: all seven top-level
routes update correctly.

Title:
  /infrastructure        → Infrastructure · Pulse
  /workloads             → Workloads · Pulse
  /storage               → Storage · Pulse
  /recovery              → Recovery · Pulse
  /alerts                → Alerts · Pulse
  /patrol                → Patrol · Pulse
  /settings/...          → Settings · Pulse
2026-05-10 15:09:19 +01:00
rcourtman 76b9e2bb32 Announce ProLicense activation success to screen readers
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.
2026-05-10 15:04:00 +01:00
rcourtman 1c1629ff5d Announce success/error feedback to screen readers
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.
2026-05-10 15:02:08 +01:00
rcourtman add1096ec8 Cache Patrol preflight outcome and hydrate UI on settings load
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.
2026-05-10 14:58:26 +01:00
rcourtman 6a3ee57755 Announce AISettings + K8s namespaces errors to screen readers
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".
2026-05-10 14:57:24 +01:00
rcourtman 84f582707a Announce PMG, AI cost, and merge-modal errors to screen readers
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.
2026-05-10 14:54:42 +01:00
rcourtman b4404ce6d2 Announce DataHandlingPanel error banner to screen readers
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.
2026-05-10 14:50:53 +01:00
rcourtman 51f75bcaf4 Announce more error banners to screen readers
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.
2026-05-10 14:49:07 +01:00
rcourtman df56a1f14f Announce login + change-password errors to screen readers
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.
2026-05-10 14:46:51 +01:00
rcourtman 76832797f4 Pin the MCP adapter's write path through tools/call
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.
2026-05-10 14:45:04 +01:00
rcourtman c01282e8c5 Add Verify Patrol button to Assistant & Patrol settings
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.
2026-05-10 14:40:13 +01:00
rcourtman 404f87854e Pin cross-org and cross-resource isolation on the bundle's pending approvals
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.
2026-05-10 14:38:10 +01:00
rcourtman bbbc8b0b93 Restore focus indicator on FilterBar search inputs
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).
2026-05-10 14:36:48 +01:00
rcourtman 4d52b4e87b Add bounds to email SMTP port and rate-limit inputs
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.
2026-05-10 14:33:45 +01:00
rcourtman e26a57a157 Add POST /api/ai/patrol/preflight tool-call verification
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/
2026-05-10 14:30:41 +01:00
rcourtman 566520f43e Add hover title to Workloads focused-label
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.
2026-05-10 14:29:44 +01:00
rcourtman 5202a945a2 Add hover title to PMG instance drawer resource name
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.
2026-05-10 14:28:23 +01:00
rcourtman 818221b457 Translate Pulse SSE events into MCP notifications when opted in
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.
2026-05-10 14:19:44 +01:00
rcourtman f2d9d2aba8 Split overgreedy "tools not supported" classifier into three causes
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.
2026-05-10 14:10:18 +01:00
rcourtman 46145df925 Coerce DeepSeek tool_choice to "auto" so Patrol stops failing
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.
2026-05-10 00:04:13 +01:00
rcourtman 6eb5bca06b Add docs/AGENT_SUBSTRATE.md as the arc's session marker
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.
2026-05-09 23:16:16 +01:00
rcourtman 2f0468a87b Verify SSHSIG on in-app update artifacts
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.
2026-05-09 23:14:07 +01:00
rcourtman 3a502fefda Publish the cmd/pulse-mcp integration guide
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.
2026-05-09 23:11:54 +01:00
rcourtman 7e4e3f03ce Fix TS error on Recovery breadcrumb title attribute
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).
2026-05-09 23:10:17 +01:00
rcourtman 5ecac35e6f Add hover titles to more truncated user-data fields
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.
2026-05-09 23:05:54 +01:00
rcourtman eeb2975d22 Stability sweep on the agent-substrate arc
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.
2026-05-09 23:04:22 +01:00
rcourtman 0378477ea7 Add hover titles to more truncated user-data fields
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.
2026-05-09 22:56:38 +01:00
rcourtman 2b2c5d87dd Add hover titles to more truncated rows
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.
2026-05-09 22:54:12 +01:00
rcourtman d6a68f8044 Add cmd/pulse-mcp — MCP adapter wrapping the agent substrate
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.
2026-05-09 22:51:37 +01:00
rcourtman 1ca4ecccb6 Add hover titles to truncated table cells
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.
2026-05-09 22:49:27 +01:00
rcourtman 3266056ca5 Show full version on hover in Updates panel
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.
2026-05-09 22:47:05 +01:00
rcourtman 8aa22d0605 Surface action verification on the action.completed SSE payload
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.
2026-05-09 22:45:15 +01:00
rcourtman 956646a5c1 Add cmd/agent-probe — worked example consuming the agent substrate
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.
2026-05-09 22:28:00 +01:00