Files
pulse/docs
rcourtman 0b6d7bbff2 Keep a remediation lock readable on an ID a succession retired (#2809)
## Problem

A rebuild applies canonical-ID successions to the store, which moves the
predecessor's operator row (or carries its remediation lock onto an
existing successor row), before `replaceRegistryLocked` publishes the
generation that lists the successor. For the length of the rebuild the
previous generation keeps serving the old ID, and the three places that
decide on the lock read the row of that ID:

- `actionlifecycle.validateExecutionPolicy` (`ValidatePlanFresh` passes
because `registry.Get(oldID)` still finds the resource, then the lock
read finds no row and `!found` means not locked);
- the Patrol broker's `loadPatrolActionPolicyInputs` (via
`Service.ResourceOperatorState`), which records `resource missing` and
authorizes against an empty state;
- `isResourceRemediationLocked` in `internal/ai/tools/action_audit.go`.

Nothing re-resolves the ID through the new generation, and the plan step
reads the old ID's row too. Every kind of succession reaches the window:
pin-driven (`PersistIdentityPins`), Proxmox guest eras, and the host and
Docker reporter eras added in #2800. I did not reproduce a concurrent
dispatch; I reproduced the state it would read:
`TestRemediationLockHoldsOnTheRetiredIDWhileThePreviousGenerationServes`
hooks the store between the succession and the publish, and fails on
`main` in all 8 variants (2 era kinds x 2 stores x 2 lock kinds) after
its fixture checks confirm the previous generation still lists the old
ID.

## Fix

`GetResourceOperatorState` (both stores) follows the recorded
successions (`canonical_id_successions`) when the requested ID holds no
row. One walker, `followRetiredID`, serves both stores:

- it answers as the ID the chain **ends on** would, with that row's
remediation block alone (`CarryRemediationLock` into an empty survivor,
`system:identity-change`, dated by the source row so a plan's policy
revision is stable across reads);
- an ID with a row of its own answers with it; a lock the operator
lifted on the successor (edit or clear) stays lifted, and a stale
unlocked row left on an earlier ID does not hide a lock at the end;
- a chain that returns to an ID it has seen has no end, so a lock
anywhere on it counts; there is no depth cap;
- nothing is written, so no ghost rows (`ListResourceOperatorStates`
still lists only rows);
- the SQLite walk runs in one transaction. With one statement per hop, a
reverse succession committing mid-walk can move the lock onto an ID the
walk already passed (a review finding).
`TestRetiredCanonicalIDReadSeesOneStateWhileTheLockMoves` fails when the
transaction is reverted.

Delaying the move was rejected: later steps of the same rebuild read the
store, and it would not protect plans already issued against the old ID.
The alternative of leaving a lock-only row on the predecessor conflicts
with `TestSuccessionBatchOrderKeepsLinkedRemediationLock`'s "no row for
a re-keyed intermediate" and leaves ghost rows.

Seven existing assertions used `GetResourceOperatorState(oldID) == not
found` to prove "the row moved"; they now read rows through
`ListResourceOperatorStates` (`operatorStateRow`), with the same intent.

## Limits (in the contract)

Only the block crosses, so the retired ID still reads none of the row's
other settings while the window is open (as before). An operator edit
made to the retired ID inside the window goes to that ID's own row, not
the successor's (the API writes the ID the served generation lists, and
a succession applies once per predecessor): a lock set there does not
reach the successor, a lock lifted there leaves the successor's lock in
place. A retired ID that a different resource later re-mints with no row
of its own reads the successor's lock until the operator writes it a
row. The contract paragraph that listed the window as a limit now
documents the read.

## Verification

- New tests: `canonical_id_succession_test.go`
(`TestRetiredCanonicalIDReads*`, 5 tests incl. a 150-hop chain, loops,
chain-end, and the interleaving stress test), `registry_test.go` (the
window test above), `internal/actionlifecycle` (`Execute` refuses a
locked resource whose row moved, and the policy read the broker uses
finds it), `internal/ai/tools` (the dispatch gate).
- 9 negative controls on pulse-dev, each caught by a named test: skip
the read in each store, drop the loop guard, nearest row instead of
chain end, whole row instead of the block, wall-clock stamp, ignore
locks on a loop, succession answer overriding an own row, and the
non-snapshot SQLite walk.
- `go test` over `unifiedresources`, `actionlifecycle`, `ai/...`,
`api/...`, `monitoring/...`, `maintenancesentinel`, `truenas`,
`alerts/...`, `agentcontext` on pulse-dev; the interleaving test 8/8
repeats and the retired-ID tests under `-race`. The only two failures
are artifacts of the remote run, not of this change:
`TestServerInfoEndpointReportsDevelopment` needs the `.git` the rsynced
tree lacks, and `TestContainerBootstrapFailuresStopAndClean/hangup`
fails 3/3 the same way on a pristine `origin/main` tree on pulse-dev (a
detached `nohup` run ignores SIGHUP).
- Two independent read-only review rounds before pushing: round 1 found
the SQLite interleaving and two contract overclaims, all fixed; round 2
found nothing.
2026-10-10 15:30:29 +01:00
..
…
2026-10-05 21:53:41 +01:00
…
…
2026-10-05 21:53:41 +01:00
2026-10-05 21:53:41 +01:00

Pulse documentation

Start here for installation, platform setup, security, operations, and Pulse Intelligence. Commands, configuration keys, image names, API fields, and product identifiers remain untranslated in localized guides.

Start here

  • Install Pulse — signed Proxmox/Linux installation, Docker, Docker Compose, Kubernetes, and first-run setup.
  • Production deployment and security — least-privilege Proxmox setup, root-agent boundaries, discovery, supply-chain verification, plan limits, scale evidence, and a rollout checklist.
  • Upgrade from Pulse v5 — migration prerequisites, rollback, agent continuity, and post-upgrade checks.
  • Releases and update channels: choosing Stable or Preview, beta and RC expectations, and how releases are tested and promoted.
  • Configure Pulse — authentication, notifications, discovery, retention, and system settings.
  • Deployment models — data locations, lifecycle, and differences between supported deployment paths.
  • Troubleshooting and FAQ — common failures, diagnostics, and operator questions.

Localized getting started guides: Deutsch · Español

Platforms and agents

VMware vSphere support is early access. Current builds expose dedicated vSphere inventory and recovery context, but operators should validate the integration against their own vCenter before production use.

Monitoring and operations

Pulse Intelligence

Patrol watch-only analysis is available on Community with a local model or the operator's own provider. Investigation and governed fixes require the relevant Pulse Pro capabilities.

Security, privacy, and access

Plans and managed access

Pulse Cloud is not generally available. Ordinary self-hosted Pulse remains the primary installation path; MSP and Enterprise access are explicit commercial paths rather than defaults in self-hosted setup.

Development and reference

Detailed design notes and dated migration specifications may remain in this directory for maintainers, but they are not operator setup guides unless they are linked from the sections above.

Previous versions and migrations

Found a bug? Use the issue forms. For setup questions, use GitHub Discussions.