Appearance
Ownership
system.devices enforces ownership across five scoping fields. Together they define which principal and which Space a Device record belongs to, and they prevent accidental cross-tenant leakage and intentional ownership hijacking.
The five scoping fields
| Field | What it scopes |
|---|---|
principalId | The owner: a HiveCast principal id (e.g. p_<sha20>). |
spaceId | The Space the Device runs in: durable Space identity (spc_<hex>). |
authorityRoot | The bus / security / runtime root (e.g. space.spc-7f3a9b2c). |
publicNamespace | Optional typed namespace claim (space.<spacePath>). |
routeKey | Optional v1 alias for spacePath. |
A Device record may have all five (Spaces with public path, paired Devices), four (Spaces without public path), or just principalId (a new principal whose default Space is implicit). A truly local-only, unpaired Host has none.
The "scope mutation cannot lose previous scope" rule
The most important ownership rule: devices.register will refuse to update a record if the new payload disagrees with the existing record on any populated scope field. From SystemDevicesActor.deviceRecordCanCarryForward():
ts
// projects/matrix-3/packages/system/src/SystemDevicesActor.ts:510-536
function deviceRecordCanCarryForward(previous: IDeviceRecord, payload: Record<string, unknown>): boolean {
const nextPrincipalId = readString(payload.principalId);
// ...
if (previous.principalId && previous.principalId !== nextPrincipalId) return false;
if (previous.spaceId && previous.spaceId !== nextSpaceId) return false;
if (previous.authorityRoot && previous.authorityRoot !== nextAuthorityRoot) return false;
if (previous.hostLinkId && previous.hostLinkId !== nextHostLinkId) return false;
if (previous.publicNamespace && previous.publicNamespace !== nextPublicNamespace) return false;
if (previous.routeKey && previous.routeKey !== nextRouteKey) return false;
return true;
}Read carefully: the rule is "if previous had a value, new payload MUST have the same value." Going from principalId: undefined to a populated value is allowed; going from one populated principalId to a different one is not. This is exactly the property you want — the first authoritative heartbeat establishes ownership, and subsequent heartbeats cannot reassign it.
onDevicesRegister(), onDevicesHeartbeat(), onDevicesDeregister(), and onDevicesRuntimesRegister() all gate on this check via deviceMatchesPayloadMutationScope().
How devices.list filters by scope
devices.list({ principalId, spaceId?, authorityRoot?, … }) filters via deviceMatchesScope(). The rules:
ts
// projects/matrix-3/packages/system/src/SystemDevicesActor.ts:442-477
function deviceMatchesScope(device: IDeviceRecord, scope: IDeviceScope): boolean {
if (!scope.hasScope) {
return !device.principalId && !device.spaceId
&& !device.authorityRoot && !device.publicNamespace
&& !device.routeKey;
}
// each populated scope field must match the device's value
// and at least one populated scope must positively match a populated device value
}Two important consequences:
- No-scope query returns ONLY no-scope devices. A
devices.list({})without any scoping fields returns local-owner Devices that have no principal/Space/authority. It does NOT return all Devices system-wide. - At least one positive match is required. A query with
principalId = Xreturns Devices wheredevice.principalId === X. A query with onlyspaceId = Yand a Device record that hasspaceId = Ybut noprincipalIdwould still match — but in practice a paired Device always has both.
How merging respects ownership
hostLinkDeviceView() merges a presence record with a Host Link record only if deviceCanMergeWithHostLink() says they agree on identity:
ts
// projects/matrix-3/packages/system/src/SystemDevicesActor.ts:479-508
function deviceCanMergeWithHostLink(presence, link): presence is IDeviceRecord {
if (presence.deviceId !== link.hostId) return false;
if (presence.hostLinkId !== link.id) return false;
if (presence.principalId !== link.principalId) return false;
if (presence.spaceId !== link.spaceId) return false;
if (presence.authorityRoot !== link.authorityRoot) return false;
if (link.publicNamespace && presence.publicNamespace !== link.publicNamespace) return false;
if (link.routeKey && presence.routeKey !== link.routeKey) return false;
return true;
}If a presence record claims hostId = X but says principalId = Y, and the Host Link for hostId X says principalId = Z, the merge is refused. The view falls back to the host-link projection (no presence data merged in). This neutralizes a buggy or malicious Device that tries to attach to someone else's Host Link.
Cross-Space scoping at higher layers
Above system.devices, the full authority model in WORKSTREAMS/core-and-packaging/MATRIX-AUTHORITY-MODEL.md defines:
- A principal may own multiple Spaces.
- A Device belongs to one (principal, Space) pair.
- The bus authorityRoot is
space.<spaceId>for new Spaces. - Singleton authority mounts (
system.devices,system.runtimes,system.registry,system.auth,host.control) have one live owner per authority root.
Implication: each Space gets its own system.devices. A multi-Space account does not have a single global Device list — it has one per Space, addressed under each Space's authority root.
What the scoping fields are NOT
They are not access-control tokens. The principalId on a Device record is an identity field; it does not by itself authorize the caller. The actual authorization layer is bearer-token verification on auth.hostLink.verifyHeartbeat and the singleton authority property that forces all writes through one authoritative actor per root.
scopes[] on the Host Link record is for capability annotation. Today it is recorded for audit but not used as a per-op gate. That gate is target state.
See also
- Device records — the scoping fields on the record.
- Why hosts do not receive raw OAuth tokens — what flows across the boundary.
WORKSTREAMS/core-and-packaging/MATRIX-AUTHORITY-MODEL.md— cross-actor authority rules.
Source:
projects/matrix-3/packages/system/src/SystemDevicesActor.ts(deviceRecordCanCarryForward(),deviceMatchesScope(),deviceCanMergeWithHostLink()).