Polls two independent signals about "my MCP tool surface" — the caller's held wallet (VG/credential set) and the Space's live MCP resource set — and calls back when either one's signature changes.
Cross-language contract: ~/Developments/contracts/sdk-space-watch.md
(layr8/contracts). @layr8/sdk's SpaceWatcher (src/space-watch.ts) is
the same abstraction on the Node side; both exist so a caller sees a change
at the same latency regardless of which SDK it's built on. Before this
module, the broker (mcp/src/broker/daemon.ts's watchSpace/1) and Loom
(Loom.Mcp.Catalog) each grew their own bespoke version of this loop
(LAYR8-869/DEBT-055).
What this module does NOT know
fetch_wallet and fetch_resources return {:ok, enumerable} /
{:error, reason}, where enumerable's elements are whatever the CALLER's
domain calls a credential or a resource (a credential id, a resource DID, a
content hash — anything comparable with Kernel.</2). This module computes
an order-independent signature over that enumerable and nothing more — it
never inspects the shape of an element, so it has no dependency on
cloud-node's credential shape or discovery-service's card shape. Translating
from those domains into a flat enumerable of comparable identifiers is the
caller's job (see Loom.Mcp.Catalog for a worked example).
Change semantics
- The signature is the sorted, deduped list of elements. Order in the fetched enumerable never matters.
on_wallet_change/on_resources_changefire with the freshly fetched value ONLY when the signature differs from the last accepted one.- The first successful poll of each signal seeds the baseline silently — a cold start is not a change, so the callback is never invoked for it.
- A fetch error (or an exception raised by the fetch function) is logged and otherwise ignored: the last accepted signature is retained, nothing is notified, and the process does not crash. A transient failure must never read as "everything disappeared."
- Resources debounce an empty result: once a non-empty baseline is
established, an empty poll is accepted (and can notify) only after TWO
CONSECUTIVE empty polls — mirrors the broker's
acceptsDiscovery. Growing the set, or shrinking it to a still-non-empty set, always applies immediately. Wallet never debounces empty: a wallet answering "nothing held" is a different failure shape than a directory blip, and callers must be able to trust it immediately.
Manual refresh
refresh_wallet/1 and refresh_resources/1 cast an immediate extra poll of
that signal without disturbing the regular interval — the generalization of
Loom.Mcp.Catalog.refresh/1, which had no resource-poll analog and no
wallet side at all. Both are safe to call even when no watcher process is
registered: GenServer.cast/2 to a name with no live process returns :ok
without raising, so a caller (or a test that never started the watcher) can
call them unconditionally.
Usage
{:ok, _pid} =
Layr8.SpaceWatcher.start_link(
fetch_wallet: fn -> Loom.Wallet.list_grants(holder_did) end,
fetch_resources: fn -> Loom.Mcp.Directory.list_accounts() end,
on_wallet_change: fn _new_wallet -> Loom.Mcp.Catalog.refresh() end,
on_resources_change: fn new_resources -> handle_resources(new_resources) end,
name: MyApp.SpaceWatcher
)
Summary
Functions
Returns a specification to start this module under a supervisor.
Forces an immediate extra resource poll, in addition to the regular interval.
Forces an immediate extra wallet poll, in addition to the regular interval.
Starts the watcher.
Types
@type fetch_fun() :: (-> {:ok, Enumerable.t()} | {:error, term()})
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec refresh_resources(GenServer.server()) :: :ok
Forces an immediate extra resource poll, in addition to the regular interval.
Async (GenServer.cast/2) and safe to call even when no watcher process is
registered under server — it returns :ok either way.
@spec refresh_wallet(GenServer.server()) :: :ok
Forces an immediate extra wallet poll, in addition to the regular interval.
Async (GenServer.cast/2) and safe to call even when no watcher process is
registered under server — it returns :ok either way.
@spec start_link(keyword()) :: GenServer.on_start()
Starts the watcher.
Options
:fetch_wallet(required) — zero-arg function returning{:ok, enumerable}or{:error, reason}for the caller's current wallet/credential set.:fetch_resources(required) — zero-arg function returning{:ok, enumerable}or{:error, reason}for the caller's current resource set.:on_wallet_change— 1-arity function called with the fresh wallet value when its signature changes. Defaults to a no-op.:on_resources_change— 1-arity function called with the fresh resource value when its signature changes. Defaults to a no-op.:wallet_poll_ms— poll interval for the wallet signal. Defaults to15000.:resource_poll_ms— poll interval for the resource signal. Defaults to60000.:now— injectable clock, a zero-arg function. Not used to gate the poll/debounce logic itself (which is purely event-counted, so it is already exercisable at millisecond scale by passing small:wallet_poll_ms/:resource_poll_msvalues in tests — mirroringLoom.Mcp.Catalog'srefresh_ms/boot_delay_msinjection). Reserved for a consumer or a future extension that wants a monotonic timestamp; defaults to&System.monotonic_time/0.:name— GenServer name. Defaults to__MODULE__.