The Patrol lifecycle work renamed the queued-fix copy (Review fix ->
Recover action, proposed fix -> typed action, and the waiting-for-
approval summaries) and reshaped ApprovalSection's badge constant, but
the unit tests still asserted the old strings and identifiers, leaving
build-and-test red on main since last night. Expectations now match the
shipped source. ApprovalSection kept MetadataBadge and shared Button
variants, so the primitive guardrails re-pin to its current shape
(BADGE_PROPS, no warningSolid variant) rather than flagging a real
regression.
The privileged-session gate (ensureAdminSession) and the settings
capabilities snapshot only accepted a session whose username equals the
configured local admin identity. SSO users are keyed by their
provider-scoped principal (sso:oidc:...), which can never match, so an
SSO session was locked out of every settings-scoped route no matter what
roles it held. That made group role mappings (group=admin) appear
broken (#1535) and surfaced as 'Unable to load SSO providers' when an
SSO user opened the SSO settings panel (#1533).
A session user now passes the gate when their effective RBAC
permissions include the admin action on all resources, which is the
shape the built-in Administrator role assigns through group role
mappings. On an instance with no local admin identity configured at all
(the v5 OIDC-only pattern), SSO sessions pass as before the rewrite,
since they are the only administrators the instance has. Org-scoped
tenant sessions keep their own management rules.
Refs #1533, #1535
Two root causes behind #1516's remaining reports:
- A node whose Proxmox disks/list query fails (PVE probes SMART per disk
inside that call, so dozens of disks can exceed the API window) now
falls back to the linked host agent's smartctl inventory instead of
leaving the Physical Disks view empty. Each node also gets its own
attempt window so one slow node no longer starves the rest of the
cluster, and a poll that runs out of budget saves partial results.
- The -n standby probe guard is dropped for positively confirmed
non-rotational devices in both the host agent and the node sensor
wrapper. The guard exists to avoid spinning up sleeping HDDs; an SSD
has nothing to spin up, and some SATA SSDs answer CHECK POWER MODE
with a bogus standby state that permanently hid their temperature,
attributes and history.
Refs #1516
The public pricing surface and in-app plan-selling copy now say
"Patrol investigates issues and explains the root cause" and
"Patrol applies safe fixes and verifies the result". Rename the
licensing catalog DisplayName/ComparisonName pairs to the title-cased
forms, align the ai_autofix UpgradeReason verbs (apply/verify the
result), regenerate the frontend catalog, and update the Go, vitest,
and Playwright pins plus README and PULSE_PRO docs.
The public pricing surface (pulserelay.pro and the license-server pricing
model, pulse-pro 3d9e365/2da1290) now sells Pro with the real Patrol mode
names and plain-English job framing. The in-app plan cards still used the
old taxonomy (Choose Patrol mode, Patrol investigates issues, Patrol
handles safe fixes) and 'admin controls' phrasing. Pro plan-selling copy
now matches the public wording: mode names spelled out (Ask first, Safe
auto-fix, Autopilot), investigation explains the root cause, safe fixes
are verified, and RBAC/audit/reporting/agent profiles are 'team controls'.
Functional CTAs (the Choose Patrol mode link) and canonical mode labels
are unchanged. Test pins updated.
The Go-side feature catalog names (Patrol Investigates Issues / Patrol
Handles Safe Fixes) still carry the old phrasing and are a separate
follow-up since they regenerate from pkg/licensing.
The document root kept a forced scrollbar (html { overflow-y: scroll })
from before the app-scroll-shell owned scrolling. The root never
scrolls, so the rule only reserved a dead 11px gutter at the right
viewport edge and pulled every fixed right-anchored element off the
true screen edge - parking the desktop Assistant launcher tab under
the scroll shell's own scrollbar, which hit-tests above descendants.
Net effect: the tab's right quarter plus the whole screen-edge strip
silently swallowed clicks, exactly where an edge-docked control gets
aimed at.
Drop the legacy root-scrollbar rule and portal the launcher out of the
scroll shell so its full face receives clicks, flush to the real edge.
Verified live: elementFromPoint returns the button across its entire
width including the last pixel column, and an extreme-edge click opens
the drawer.
The TrueNAS/vSphere sorting adoption (cf0f027b6) moved the activity
table's When header onto PlatformSortableTableHead, so the guardrail's
literal getPlatformTableHeadClassForKind('numeric-value') pin no longer
matches. The alignment intent survives as the sortable head's
kind="numeric-value" prop; pin that form instead. The metadata-badge
and Button guardrail failures predate the sorting commits and are left
for their owner.
The three sorting commits (20cccc1a9, f66c4b46b, cf0f027b6) added the
shared sortable-header fabric and adopted it across Docker, Kubernetes,
TrueNAS, vSphere, and Proxmox nodes tables, but landed frontend-only,
which skips the local governance audit; Canonical Governance CI then
flagged frontend-primitives and unified-resources for missing contract
deltas. Record the fabric's interaction contract (shared sort state,
persistence, missing-values-sink, aria-sort, canonical alignment) and
the two-layer ordering rule (status-first default, user sort on top).
v5 parity follow-up to 20cccc1a9, completing the adoption sweep after the
Kubernetes pass (f66c4b46b). Adopts createPlatformTableSortState +
PlatformSortableTableHead across the TrueNAS systems / apps / services /
network shares / protection / storage topology / VMs tables and the vSphere
hosts / datastores / networks / activity tables: per-table storage keys,
descendingFirst on metric, count, and timestamp columns, the built-in order
preserved until the user sorts, and canonical kind-based alignment via the
sortable head. Composite summary columns (inventory, ports, images, access,
clients, devices, flags, hosts, consumers) stay non-sortable.
The TrueNAS storage topology sorts sibling groups and re-emits the tree
depth-first, so pools re-order against pools and every dataset or disk stays
under its parent; a new sort test pins that plus descending-first metrics and
persistence.
ProxmoxNodesTable drops its bespoke non-persisted createSignal sort for the
same fabric, so the nodes sort now survives reloads like every other platform
table. Its existing sort assertions pass unchanged; the suite now clears
localStorage between tests since the sort persists by design.
Also repairs the platformOverviewLayout guardrails at HEAD: f66c4b46b landed
with the TrueNAS/vSphere head assertions already flipped to the kind form and
the Kubernetes assertions still on the legacy form (a staging race between
parallel agents); this commit carries the version with both sets on the kind
form, which is green against the migrated tables.
v5 parity follow-up to 20cccc1a9 (v5 KubernetesClusters.tsx persisted
k8s-sort-key/k8s-sort-dir sorting; v6 shipped fixed ordering). Adopts the
shared platform-table sort fabric (createPlatformTableSortState +
PlatformSortableTableHead) across the clusters, nodes, pods, deployments,
controllers, services, storage, config, autoscaling, and networking tables:
per-table storage keys, descendingFirst on metric/count columns (pods Ready
stays ascending-first so the least-ready pods surface), the status-first
compare preserved as the base order until the user sorts, and canonical
kind-based alignment via the sortable head's kind prop. Composite summary
columns (ports, selectors, labels, access/policy, bounds, detail) stay
non-sortable. TrueNAS/vSphere adoption is landing separately.
v5 parity: v6 platform tables shipped with fixed status-first ordering and
no sortable headers. Adds opt-in sortable-header support to the shared
platform table fabric (createPlatformTableSortState + PlatformSortableTableHead
in sharedPlatformPage.tsx): click cycles first-direction -> flipped -> built-in
order, state persists per table via usePersistentSignal, missing values sink
to the bottom, aria-sort and arrow indicator match the workloads table, and
alignment stays on the canonical kind helpers.
Adopted across the Docker tables (containers, hosts, images, services,
volumes, tasks, swarm nodes, secrets, configs). Container sorting applies
within host groups and stays orthogonal to grouping, unlike v5's
sort-by-host-equals-grouping design. Kubernetes and TrueNAS/vSphere adoption
are follow-ups.
v5 parity regressions reported by a Pro customer after the v6.0.5
upgrade:
- The Docker containers table lost the grouped view: add a
Grouped/List segmented control (same fabric as the workloads
table), grouped by host with count badges and host web links,
persisted locally and defaulting to grouped for multi-host fleets.
- Container web-interface links were buried in the detail drawer's
access section: the container name now renders through
WebInterfaceNameLink, linking straight to the resource customUrl
like VM/LXC guest names do. Applies to all docker native tables
via the shared name cell.
The websocket broadcast path applies docker metadata (container
customUrl) via applyDockerMetadataToUnifiedResources, but the REST
/api/resources registry seed read the raw unified state view, so the
two payloads drifted: a container web-interface URL saved in the
drawer never appeared in REST-hydrated tables. Apply the same
hydration at the UnifiedResourceSnapshot provider boundary.
OrchestratorChatService is replaced, not extended: it exposes only
ExecuteInvestigationStream (returning a structured
OrchestratorInvestigationResult) and ListInvestigationTools. The generic
ExecuteStream, SetAutonomousMode, ListAvailableTools, and the
AutonomousMode request field are gone, along with
OrchestratorCommandExecutor, OrchestratorApprovalStore, and the
autonomy/fix-verifier/license dependency interfaces. OrchestratorDeps
now carries a REQUIRED ActionBroker and no command/autonomy deps.
The pulse investigation adapter is rewritten to drive
ExecuteInvestigationStream, injecting proposal/finding/investigation
identity from trusted context and feeding a per-org proposal catalog
resolved from the tenant-bound action lifecycle (so acceptance and
planning validate identically). The retired command-execution,
autonomy, and command-shaped approval adapters are deleted; the router
wires the broker and catalog factories onto the AI settings handler.
The sixth side door is removed: PatrolService.generateRemediation-
PlanFromInvestigation and its call, which copied Fix.Commands into an
executable enterprise remediation plan, are deleted, pinned closed by a
source-audit test.
ActionCapabilityParamInfo gains Pattern (mapped by the broker), so the
canonical planner's pattern validation survives the cross-repo
boundary.
L20 readiness assertion RA35 asserts every Patrol-initiated
infrastructure mutation originates as a typed proposal and reaches
execution only through the canonical action lifecycle, with unsupported
proposals failing closed. Contract prose updated across ai-runtime,
api-contracts, agent-lifecycle, performance-and-scalability,
security-privacy, and storage-recovery.
Lands together with the pulse-enterprise orchestrator migration (the
replace directive means both heads move as one window).
Privacy: provider-streamed RawInput overrides on tool_progress events
are unredacted model output; for exposure-restricted tools they are now
discarded instead of replacing the projected form (the override was
reintroducing exactly the values the projector removed). Proven with a
progress event carrying a secret in the raw override.
Schema validation: proposal acceptance now validates through the
planner's exported canonical rules - FindCapability's exact-name
matching (the capture previously matched case-insensitively while
planning matched exactly) and ValidateParams for declared, required,
typed, enum, pattern, and malformed-schema cases - so a proposal that
validates is exactly a proposal the planner will accept. The
sensitive-parameter rejection remains a proposal-specific ratchet on
top.
Fail-closed ratchets: params and evidence identity are deep-cloned on
capture and again on outcome, so caller-side mutation after validation
can never alter the actionable proposal; fingerprint serialization
failures return errors rather than a shared sentinel value; an
investigation run refuses to start without finding and investigation
identity (before any provider call or session exists); and any run
error nils the proposal while preserving simultaneous proposal errors
via errors.Join - a non-nil proposal exists only from a completely
successful run.
Commit A of the coordinated proposal slice (pulse-internal; the
aicontracts interface replacement and enterprise migration follow as
one window because pulse-enterprise builds against this tree via a
replace directive).
patrol_propose_action is a side-effect-free, mutation-none capture
whose schema carries only resource_id, capability_name, params, and
reason. Registry policy rejects it outside the Patrol investigation
profile before the handler runs, and the same check keeps it out of
every other profile's projected manifest. Correlation identity
(proposal, finding, investigation, evidence) is injected from trusted
orchestration context through the request-local ProposalCapture sink,
which executor clones share so one run has exactly one capture.
Tool calls now carry an explicit invocation envelope (tool-use ID,
name, arguments) through ExecuteInvocation; the ID rides the context
because per-turn tool calls execute concurrently. The sink keys on call
identity plus payload fingerprint: idempotent replay re-succeeds, the
same ID with a different payload latches a terminal integrity error,
and a second distinct valid proposal latches terminal ambiguity - both
terminal states invalidate the captured proposal, since concurrency
makes first-wins nondeterministic. Proposals count only after catalog
validation: advertised capability, declared/required/enum parameters,
and sensitive parameters rejected before success with no value echo in
any output.
ExecuteInvestigationStream returns proposal cardinality as a structured
result with typed errors for ambiguity, integrity violations, and the
failed-attempts-only case (never collapsed into the valid zero-proposal
conclusion); ListInvestigationTools projects through the identical
profile path. Proposal parameter values exist only transiently for
provider continuation and validation: the canonical exposure projector
redacts them from the durable transcript and every
tool_start/progress/end stream event, proven end-to-end with a scripted
provider run that also verifies the provider continuation keeps raw
values.
Essential proof included: two concurrent valid proposal calls produce
ErrProposalAmbiguous and a nil proposal regardless of execution order.
Add truenasPageModel.coverage2.test.ts (93 cases; status mappers, incident/rollup
rows, topology cycle-termination, search tokenizers) and
agentMachineTableModel.coverage2.test.ts (55 cases; sortAgentMachines across every
sort key, RAID summary/detail builders, disk/network/thermal accessors). Target
still-uncovered functions from the v8 branch-coverage backlog; new test files only.
The blocked-question branch persisted its refusal only to provider
context, so the durable transcript retained a question call with no
matching result. The refusal is now appended to the transcript as well,
and the previously deferred end-to-end loop proof drives a scripted
provider turn carrying pulse_question plus a sibling pulse_query under
the detection profile, asserting: no waiting-for-answer event, the
persisted call/result pairing, sibling execution, and continued
inference to the model's final answer.
Execution profiles gain the closed-vocabulary ratchet: Valid() pins the
known set, ApplyExecutionProfile and the loop setter reject unknown
values outright, and NonInteractive() fails closed so an unclassifiable
posture can never inherit interactive permissions.
Settings showed PVE cluster members as read-only rows, so a member whose
discovered address is unreachable from Pulse (internal cluster network IP,
stale DNS) had no manual fix; only agent re-registration could adopt a
better IP, which leaves agent-less members stranded. Surfaced in the
"Install issues with V6" support thread.
The node editor now lists cluster members with a per-member connection
address field that writes ClusterEndpoints[n].IPOverride, the field
re-discovery already preserves and EffectiveIP already prefers at poll
time (editing Host would be clobbered on the next cluster refresh).
PUT /api/config/nodes/{id} accepts write-only clusterEndpointOverrides
entries; only changed members ride the payload, an empty value clears
the override, and unknown members are rejected. The configured-nodes
cache mirrors saved overrides instead of spreading the write-only
payload field onto node config state.
A Docker install of the Pro runtime had no sane update path: the binary
self-updater refuses to run in a container, the customer compose file pins
the previous image digest (so `docker compose pull` never updates), and the
in-app guidance showed community `docker pull rcourtman/pulse:<tag>` commands
that silently downgrade the container to the community build - the same
failure mode 93bccaff1 fixed for binary installs.
The update check on the Pro binary already fetches the license server
download broker manifest (GET /v1/downloads/pulse-pro), which carries the
digest-pinned image ref plus ready-to-run compose commands. Relay that block
on UpdateInfo.dockerUpdate for Docker deployments, behind the existing
stable/rc channel guard, failing closed unless both compose commands
reference the digest-pinned ref (never a mutable tag).
Settings -> Updates now renders those commands as copy blocks when an update
is available, and the idle Docker box, the update banner, and the docker
update plan suppress every community-image command whenever the compiled
runtime identity is pro.
Contracts: api-contracts (update transport dockerUpdate block),
deployment-installability (Pro Docker update path), frontend-primitives
(install guide Pro-runtime guidance), with transport and settings-shell
proofs extended accordingly.
Add resourceStateAdapters.coverage2.test.ts (87 cases) covering the canonical
resource/platform mappers (canonicalizeLegacyPlatformData, nodeFromResource,
buildMemory/buildDisk, PBS/PMG instance + job mappers, merge helpers) not
reached by the existing coverage test. Targets still-uncovered functions from
the v8 branch-coverage backlog; new test file only.
Execution posture is now profile-owned through the core-only,
never-serialized tools.ExecutionProfile. Both Patrol profiles are
non-interactive, deny infrastructure mutations, and clear any inherited
autonomous mode. Detection restricts pulse-state mutations to an
explicit allowlist of the finding lifecycle tools - a blanket
pulse-state allowance would also permit alert dismissal and knowledge
writes - while investigation denies all pulse-state mutations, keeping
it structurally read-only. InvocationPolicy gains the allowlist and
Allows() is now tool-name-aware.
Chat turns build ONE effective request executor (control level,
autonomy, profile, resolved context) BEFORE provider projection and
clone that executor per provider attempt, reversing the previous
project-from-base-then-clone order so the offered schema and the
runtime boundary always agree. Scheduled Patrol (ExecutePatrolStream)
now runs under the detection profile instead of autonomous mode -
closing its direct view of Docker/Kubernetes mutation subactions - and
ListAvailableTools projects through the identical path.
toolsForExecutionMode's mode booleans are replaced by the profile on
the executor itself.
Non-interactive profiles independently hide pulse_question from the
manifest AND runtime-block it before the interactive-call-set special
case: a fabricated question call returns a non-interactive error
without emitting a waiting event, and sibling tool calls from the same
provider turn keep processing. Approval waits never block for
non-interactive profiles (they queue), and the tool-only-turn wrap-up
guardrail is interactive-profile-owned instead of keyed on autonomy.
The system prompt describes detection and investigation modes directly
rather than claiming controlled or autonomous execution; the
investigation prompt directs the model toward typed action proposals.
Proofs: detection allowlist enforcement (alerts resolve and knowledge
remember blocked, finding tools allowed, projection agrees),
investigation structural read-only (patrol mutation tools dropped from
the manifest and blocked at runtime), profile clone isolation, profile
prompt modes, and question-tool hiding across profiles.
Review of 3073a5061 found the remaining registration hole: Register
rejected canonical descriptor overrides but still accepted a canonical
NAME with a nil override, inheriting the canonical descriptor while
replacing the governed handler in the map - an extension could re-register
pulse_read and bypass its execution-intent enforcement.
Registration authority is now split. registerBuiltin is the unexported
construction-time path for canonical Pulse tools: shared descriptor
mandatory, overrides rejected. RegisterExtension - the only path exposed
through PulseToolExecutor.RegisterTool - rejects every canonical tool
name outright and requires the extension to declare its own descriptor.
Both paths are append-only: a name registers exactly once, so no later
registration can swap out an already-governed handler.
Proofs cover the exact bypass (canonical name, nil override), extension
and builtin duplicate rejection, builtin override rejection, and
descriptor-less extension rejection. Tests that previously swapped
handlers by re-registering now use fresh executors per scenario.
Contract prose and source pins updated.
Add alertsConfigurationModel.snapshot.test.ts (83 cases, exercising the
factory-fallback branches of readAlertsConfigurationSnapshot and
buildAlertsConfigurationPayload) and alertOverridesModel.projected.test.ts
(81 cases, covering buildProjectedOverrides across every resource-type switch
branch plus the ID-candidate builders). Second-pass coverage targeting still
uncovered functions from the v8 branch-coverage backlog; new test files only.
Add toolPresentation.coverage.test.ts (42 cases) covering chat tool-call
input/output parsing and command-preview formatting, and
resourceDetailDrawerVmwareModel.coverage.test.ts (91 cases) covering the VMware
detail-drawer section builders. Both target previously uncovered pure functions
from the v8 branch-coverage backlog. New test files only; no source changes.
Add licensePresentation.coverage.test.ts (75 cases) and
patrolSummaryPresentation.coverage.test.ts (98 cases) covering previously
uncovered pure presentation/plan/verification functions flagged by the v8
branch-coverage backlog. New test files only; no source changes.
At warning level the used segment turns threshold-yellow, which sat
next to the amber cache segment as one indistinguishable yellow mass.
Give the cache segment a vertical-stripe SVG pattern so the used/cache
split stays readable at every severity color. Stripes are vertical
because the bar's preserveAspectRatio=none scaling would shear a
diagonal texture.
Four classifier ratchets from review of 67c2534c0:
The classification vocabulary is closed: descriptor validation rejects
any class outside the known workflow kinds and mutation targets (an
empty Mutation no longer registers), and InvocationPolicy.Allows
independently denies unknown mutation targets outright, so a class
that somehow bypassed validation still cannot execute. Descriptor
lookups and registration store deep copies, so callers can never
mutate the canonical table through shared case maps or static class
pointers. Registration rejects descriptor overrides for canonical tool
names; overrides exist only for genuinely non-canonical extension and
test names.
Projected governance now derives its action mode from mutation
targets, not workflow kinds, and recomputes it even when no enum value
was filtered; a projection whose remaining invocations mutate nothing
downgrades to scope-only approval metadata (registered scope-only
summaries are preserved). Docker check_updates reclassifies from
{write,none} to {read,none}: it queues a read-only scan, and the write
kind was driving the FSM into verification and making the read-only
Docker projection report mixed. Discovery consequently projects as
mode=read in governance manifests, which is the honest mutation-derived
mode. The pulse_file_edit governance summary no longer claims to read
files. The contract prose also names pulse_read's execution-intent
classifier as mandatory second-stage enforcement for exec, not merely
defense in depth: the static read/none descriptor cannot prove
arbitrary command text safe.
Proofs: open-vocabulary rejection, unknown-target policy denial,
descriptor copy isolation, canonical-override rejection, and the
read-only Docker read/scope-only projection.
The stacked memory bar colors its used segment by threshold (green
below warning, yellow/red above), but the tooltip legend hard-coded
Used as green. At 80% used the bar read as all-yellow while the
tooltip claimed a green Used section. Derive the legend color from
the same severity as the segment.
Every registered Pulse tool now carries a canonical invocation
descriptor (internal/agentcapabilities/invocation.go): static or
discriminator-based, classifying each invocation with a workflow kind
plus a mutation target (none / pulse_state / infrastructure). Mixed
descriptors must exactly cover their schema enum and registration
panics otherwise, so an unclassifiable tool cannot exist. Missing,
malformed, unknown, or fabricated discriminator values classify
fail-closed as infrastructure writes.
Provider projection and runtime enforcement consume the same
descriptor under one InvocationPolicy (control level plus the
request-local, non-serializable deny_infrastructure_mutations
restriction, isolated across executor clones): ListTools and
ListToolGovernance remove forbidden enum values, drop empty tools, and
recompute the offered action mode, while ToolRegistry.Execute blocks
forbidden invocations before the handler runs. This closes the mixed
tool control-level bypass, most seriously Docker action:update, which
previously fell through to direct execution at read-only, and fixes
the Kubernetes misclassification: the retired switch read the action
argument while the schema discriminator is type, so type:scale
classified as read.
pulse_file_edit is now write-only (append/write); file inspection
routes through pulse_read action=file, whose exec path keeps its
structural read-only execution-intent enforcement. ClassifyToolCall
consults the descriptor table first and retains only genuinely
non-registry compatibility cases. The deny restriction is deliberately
separate from autonomous mode, which only suppresses interactive
questions and grants no mutation authority.
Proofs: descriptor validation and fail-closed classification unit
tests, plus the invocation-policy regression suite (scale classifies
write and never invokes at read-only or under deny; Docker update
queues nothing at read-only; autonomous plus deny cannot mutate;
fabricated enum values fail at runtime; filtered projection and
runtime enforcement agree; executor clones keep request policies
isolated). Contracts and registry ownership updated for the new
shared invocation descriptor boundary.
Slice 3a of the typed-lifecycle ratchet; the patrol_investigation
execution profile and patrol_propose_action tool build on this
substrate next.
Ollama is the zero-cost AI path but the setup row offered only a Server
URL, and Patrol then failed on models that cannot emit tool_calls
(#1463, #847, #1425, #1152, #880). Bless qwen3:8b, the model family
Ollama's own tool-calling docs are written against, verified locally
against Patrol's real preflight: qwen3:8b emitted the tool call on
every run; qwen3:4b never did (0/4), so no low-RAM tag is suggested.
- Registry: SuggestedModel/Note/Equivalents on AIProviderDefinition,
projected on /api/settings/ai providers; Ollama default model goes
llama3.2 -> qwen3:8b.
- Provider row: copyable 'ollama pull qwen3:8b' block with hardware
note, and a next-step hint when a successful test resolves a model
outside the blessing set.
- Model resolution: exact-ID blessed preference, so pulling qwen3:8b
makes it the auto-resolved Patrol model with no manual selection.
- Readiness copy names the blessed model (its contract pins landed
with 94ccc7a0c's staging; this commit restores green).
- manual_ollama_preflight_test.go is the env-gated re-blessing
harness; contracts updated for ai-runtime, api-contracts,
frontend-primitives, and the agent-lifecycle/storage-recovery
dependent boundaries; subsystem_lookup_test line pins follow the
api-contracts.md insertion.