RFC 0083 — Device bridge and capability contract
Status: Implemented
Date: July 2026
Author: kasunben
Scope: packages/sdk (new @sovereignfs/sdk/device-client subpath — the capability contract plus provideBridge(); stays dependency-free), new packages/bridge (@sovereignfs/bridge — published; the implementation: transports, protocol, and the shell-side helper consumed by sovereign-mobile and sovereign-desktop), packages/manifest (new device:* permissions), runtime (per-user device consent grants), plugins/account (consent management UI), docs/plugin-development.md, docs/architecture-rules.md, docs/sdk-stability.md. Builds on RFC 0080 (surface model — supplies transport detection); implements the "SDK abstraction" tier that RFC 0058 §"Device API strategy" and RFC 0038 §"Device API tier" both specify but neither designs; supersedes the sdk.device.notify() / sdk.device.secureStore.* sketches in RFC 0038's epic tasks. Related research: 0006.
Incorporated into plan: Yes — epic tasks 3.34–3.35.
Summary
Define the contract by which web content running on a Sovereign instance reaches device capabilities through a native shell — and make it one contract, shared by the Capacitor mobile shell and the Tauri desktop shell, with a Web-API fallback tier so the same plugin code runs unchanged in a plain browser.
Three pieces:
- A contract/implementation split —
@sovereignfs/sdk/device-clientdeclares the capability registry, types, andprovideBridge(); a new published@sovereignfs/bridgeimplements the transports and protocol and registers itself. Both external shell repositories consume the same published implementation, so the two shells cannot drift apart in protocol, and the SDK keeps its zero runtime dependencies by reusing theprovideHost()handoff pattern. - Capability negotiation — the shell announces, at handshake, exactly which capabilities it supports and at which version. Plugins ask
supports('haptics.impact', 1). Nothing ever compares shell versions. sdk.device.*capability calls — the plugin-facing surface, browser-only, returning a typed discriminated result rather than throwing, so "unavailable", "denied", "dismissed", and "failed" stay four distinct product outcomes.
v1 deliberately ships a thin slice — enough capabilities to prove the contract, not the full device surface: haptics.impact and notifications.native for plugins, plus a platform-internal secureStorage tier. Every later capability (camera, biometrics, push, background) is an additive registry entry plus two transport implementations, not a new mechanism.
Motivation
RFC 0058 and RFC 0038 both state that plugin developers call sdk.device.* only, and that the SDK "detects the environment and routes to the correct tier". Neither designs that routing, and packages/sdk/src/device.ts does not exist. The consequence today is six scheduled epic tasks (17.2, 17.4, 17.7, 20.3, 20.5–20.8) that each presume a bridge nobody has specified — so each would arrive at its own, and the mobile and desktop shells would diverge on the first capability that shipped to both.
The immediate need is also concrete: the platform is preparing to serve three surfaces at once — PWA (web and mobile), Tauri desktop, Capacitor mobile. The shells live in separate repositories on their own release cadences. Without a versioned protocol owned here, "the platform and plugin runtime serve all three surfaces" is an aspiration rather than a contract.
Current state (what this builds on)
sdk.device.*does not exist.packages/sdk/src/device.tsis absent. Epic 17.1's deliverables record the deferral explicitly: "packages/sdkpatch — add"desktop"environment tosdk.device.*routing — deferred to task 17.7:sdk.device.*does not exist inpackages/sdkyet". Task 17.7 then says that if the surface still hasn't landed, "ship the desktop check as part of its first implementation instead". That first implementation is RFC 0080's task 3.32; this RFC is the capability layer on top.- The entire
SdkHostis server-side — and this is the load-bearing constraint for this RFC. Every namespace inpackages/sdk/src/host.ts(db,mailer,email,platform,directory,data,activity,portability,plugins,notifications,storage,e2ee,secrets,connections) routes throughrequireHost(), which reads aSymbol.for('@sovereignfs/sdk:host')-keyed global registered once fromruntime/instrumentation.ts. Device capabilities cannot use any of it — a camera, a haptic motor, and a keychain live in the WebView, and no server round-trip can reach them. - The precedent to follow instead is
@sovereignfs/sdk/offline. Its own doc comment records why it is a dedicated subpath rather than part of the barrel: the barrel transitively reaches server-onlynext/headers, and Next's client/server boundary check flags the whole reachable module graph, so a'use client'component importing from the barrel fails to build.offline.tshas noSdkHostentry and noprovideHost()wiring at all — architecturally unlike every other namespace. The device bridge is the second member of that family. Thee2ee-crypto/e2ee-devicemodules are the third. - RFC 0080 supplies transport detection, not invocation. Its server tier (
getSurface()from the injectedx-sovereign-surfaceheader) and its client tier (useDeviceEnvironment(),installed) answer where am I. This RFC answers what can I do here, and how do I ask. - No capability-negotiation or feature-detection pattern exists in the repo.
compatibility.minPlatformVersion/maxPlatformVersion(packages/manifest/src/schema.ts:241-251, RFC 0024) gate a plugin against the platform — both of which the operator controls. A shell is controlled by neither the operator nor the platform, so that mechanism does not transfer. - The permission enum has no device entries.
permissionSchema(packages/manifest/src/schema.ts:19-36) currently holdsauth:session,db:readWrite,db:readOnly,mailer:send,mailer:sendExternal,storage:readWrite,notifications:send,events:publish,events:subscribe,data:provide,data:consume,data:export,data:import,activity:write,e2ee:use,admin:*,offline:write. - RFC 0078 §6 established the honest position on permission enforcement: a permission is centrally enforceable only where every call funnels through a shared host boundary the platform controls (as
mailer:senddoes viarequireMailerPluginContext()insideprovideHost({...})). Where no such boundary exists, a permission is "review/install-time metadata with an optional convenience helper, not a guarantee". That precedent applies here and is sharpened in §5. - Notification infrastructure already exists — RFC 0015's Notification Center and RFC 0016's web push, with
sdk.notifications.send()server-side and a pluggable transport broker (RFC 0034). The web tier ofnotifications.nativeshould route into this rather than parallel it. @sovereignfs/sdkand@sovereignfs/uiare published contracts under NFR-04: patch releases must never contain breaking changes; a breaking change needs at minimum a minor bump plus a migration note. A capability registry must therefore be designed for additive growth from day one.
Proposed design
1. Contract in the SDK, implementation in @sovereignfs/bridge
The split follows the pattern this repository already uses for exactly this problem — the SDK needing to call something it must not depend on.
@sovereignfs/sdk/device-client (a new subpath, joining the five that already exist) owns the contract:
- The capability registry: capability names, versions, request/response types.
DeviceResultand the handshake shape.- The plugin-facing
sdk.device.*API. provideBridge(impl)— the registration entry point.
@sovereignfs/bridge (new package at packages/bridge, published) owns the implementation:
- The
web,capacitor, andtauritransports. - The wire protocol and handshake mechanics.
- The shell-side handler helper.
The SDK keeps zero runtime dependencies. @sovereignfs/bridge depends on @sovereignfs/sdk as a devDependency only — types are erased at build, so its published output carries no runtime dependency while the type contract still has exactly one source of truth. The dependency arrow points implementation → contract, the same direction runtime → @sovereignfs/sdk already points.
Registration mirrors provideHost():
// @sovereignfs/sdk/device-client — declares, never implements
export interface BridgeImpl {
handshake(): Promise<BridgeHandshake>;
invoke(capability: string, payload: unknown): Promise<DeviceResult<unknown>>;
}
export function provideBridge(impl: BridgeImpl): void;runtime registers it from a client bootstrap — the client-side analogue of runtime/instrumentation.ts calling provideHost().
The registration must be stored on a Symbol.for-keyed global, not a module-level variable, for the reason packages/sdk/src/host.ts already documents at length: Next compiles instrumentation, route handlers, and server actions into separate bundles that each get their own module instance, and dev HMR resets module state. There is a second reason here — a plugin could install a different major of @sovereignfs/bridge than the one the platform ships, giving two copies with two independent handshake states, one of which would never resolve. A Symbol.for global makes one registration win in both cases.
Two entry points, so neither side pulls the other's code:
| Entry | Consumer | Contains |
|---|---|---|
@sovereignfs/bridge | runtime (page side) | Transports, handshake client |
@sovereignfs/bridge/shell | the two shell repos | Shell-side handler helper |
@sovereignfs/bridge itself has zero runtime dependencies — no React, no Next, no Node built-ins — and never reaches next/headers, @sovereignfs/db, or anything in SdkHost. It is published and semver-disciplined like the SDK, because two external repositories on independent release cadences consume it.
Why not simply have
packages/sdkdepend on@sovereignfs/bridge? It was the first design considered and it is worse on the facts.packages/sdkhas nodependenciesfield at all today —nextis apeerDependencyand everything else is a devDependency — and@sovereignfs/uiis the same. "SDK zero-deps" is therefore a literal, uniform property of both published packages rather than a rule about two specific imports, and a real dependency at version 1.27.0 would be the first. It would also make the dual-major duplication problem above a live risk instead of a guarded one, and give plugin developers a transitive install for a package they should never need to know exists. TheprovideHost()pattern costs nothing and needs no rule amendment.
2. Transports
export type BridgeTransport = 'web' | 'capacitor' | 'tauri';| Transport | Mechanism | Notes |
|---|---|---|
capacitor | Capacitor plugin calls, mediated by the shell | Promise-based, plugin-namespaced |
tauri | Tauri command invocation, mediated by the shell | Command-name based — a structurally different shape |
web | Standard Web APIs | The always-available floor; reports the rest unavailable |
The two native bridge shapes are genuinely different, which is precisely why plugins must not touch them. RFC 0058 already requires this ("Plugin developers call sdk.device.* only. They do not import Capacitor plugins or branch directly on native shell internals"); this RFC makes it mechanically true.
3. Handshake and capability negotiation
export interface CapabilityDescriptor {
name: string; // e.g. 'haptics.impact'
version: number; // integer, incremented on a breaking payload change
}
export interface BridgeHandshake {
/** Framing/protocol version of @sovereignfs/bridge itself. */
protocolVersion: number;
shell: {
name: string; // 'sovereign-mobile' | 'sovereign-desktop' | …
version: string; // informational only — never branch on it
platform: 'ios' | 'android' | 'macos' | 'windows' | 'linux' | 'web'; // 'web' added in leg 1 — see open question 8
};
/** Exactly what THIS shell build supports. The authoritative list. */
capabilities: CapabilityDescriptor[];
}The rule that makes cross-cadence releases survivable:
Plugins and the platform never compare shell versions. They ask whether a named capability at a given version is present.
shell.versionexists for diagnostics and bug reports only, and branching on it is a review-blocking mistake.
This is not stylistic. A shell updates on the user's schedule — via an app store, subject to a review queue, and often with auto-update disabled entirely — while the instance updates on the operator's schedule. Skew is therefore normal and runs in both directions:
| Situation | Behavior under negotiation |
|---|---|
| New instance, old shell | Newer capability simply absent from capabilities → unavailable, handled path |
| Old instance, new shell | Unknown capability names ignored; nothing breaks |
| Capability payload changed | Shipped as a new version; the shell may advertise both while clients migrate |
| Shell not present at all | Transport is web; capabilities resolve to the Web-API floor or unavailable |
No coordinated release is ever required between this repository and either shell repository. That property is the main reason this RFC exists as a protocol rather than a set of per-capability SDK methods.
4. Typed results — no throws for expected outcomes
export type DeviceResult<T> =
| { status: 'ok'; value: T }
| { status: 'unavailable'; capability: string } // not on this surface, or shell too old
| { status: 'denied' } // user/OS refused, persistently
| { status: 'dismissed' } // user cancelled this attempt
| { status: 'failed'; error: string }; // hardware or runtime errordenied and dismissed are deliberately distinct, because they demand different UI: a denied capability needs a "you'll need to enable this in Settings" path, since asking again will not prompt; a dismissed one should simply let the user try again. Collapsing them — or collapsing either into a thrown error — destroys information the caller cannot recover, and it is the single most common way device abstractions become unpleasant to use.
Exceptions are reserved for programmer error (unknown capability name, malformed payload), never for user or environment outcomes.
5. Permission model — and where it genuinely bites
Two layers, plus the OS.
a. Manifest declaration. New device:* entries in permissionSchema:
{ "permissions": ["device:haptics", "device:notifications"] }v1 adds only the capabilities v1 ships (§7). Each later capability adds its own permission in the same task that adds the capability — never a broad device:* grant.
b. Per-user runtime consent. For any capability that touches personal data or hardware the user would reasonably want to control, the platform records a per-user, per-plugin, per-capability grant, managed in the Account plugin alongthe existing data-consent surface (packages/sdk/src/data.ts's consent model is the precedent). Absent a grant, the bridge prompts once and stores the outcome; a denied grant short-circuits without reaching the OS.
c. The OS prompt still applies on top, and always wins.
Now the honest part, stated plainly because it would otherwise be assumed away.
Client-side plugin identity is self-declared and not verifiable.x-sovereign-plugin-id is a server header injected by middleware. In the browser, every plugin's client code shares one origin and one JavaScript context, so any client code can claim any plugin id when calling the bridge. Consequently:
| What | Enforcement strength |
|---|---|
| Native-only capability, shell exposes only the narrow bridge | Real — the bridge is the sole route to the native API, so an undeclared capability call can be refused |
| Web-API-backed capability | Advisory — a plugin can call navigator.* directly; the platform cannot intercept browser globals |
| Isolation between plugins | None client-side — same origin, same JS context, self-declared identity |
So device:* permissions are install-time and review-time metadata plus the input to an honest consent prompt ("Tally wants to use haptics"), not a security boundary between plugins. This is the same conclusion RFC 0078 §6 reached for offline:write and the same posture RFC 0080 §2 takes on the surface signal. docs/plugin-development.md must say so in those words.
The one structural lever, and it is a shell requirement:
The shell should expose only the narrow bridge surface to page JavaScript, not raw
window.Capacitor,window.__TAURI__, or any direct plugin/command object. That is what makes the first row of the table above true rather than aspirational, and it belongs in both shell repositories' review checklists.
How achievable that is differs by shell, and the difference is not yet verified. Tauri's command surface is opt-in — the shell declares which commands exist, so exposing only bridge commands is straightforward. Capacitor, by contrast, injects its own runtime into the WebView by design, and page JavaScript is the intended caller of Capacitor.Plugins.*. Whether a Capacitor shell can meaningfully withhold that object from page scripts while still using Capacitor plugins itself is an open implementation question.
If it cannot, the honest consequence is that on the capacitor transport the first row of the table collapses into the second — native capability permissions become advisory there too, and the enforcement claim holds only for Tauri. That would not invalidate the contract (portability, negotiation, and typed results are unaffected), but it would need saying in docs/plugin-development.md rather than quietly leaving a stronger claim in place. Verify before relying on the enforcement claim for mobile — workstream 0003 leg 4.
6. Plugin-facing surface
On the existing browser-only subpath from RFC 0080:
// @sovereignfs/sdk/device-client
export function supports(capability: string, version?: number): boolean;
export function getTransport(): BridgeTransport;
export function getShellInfo(): BridgeHandshake['shell'] | null;
export const haptics: {
impact(style?: 'light' | 'medium' | 'heavy'): Promise<DeviceResult<void>>;
};
export const nativeNotifications: {
show(input: { title: string; body?: string; url?: string }): Promise<DeviceResult<void>>;
getPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unsupported'>;
// `pluginId` added in leg 2 — this module is browser-only, so there is no
// server-injected header to attribute the consent grant to; the caller
// must self-declare (see §5's honesty section — this is exactly that).
requestPermission(pluginId: string): Promise<DeviceResult<'granted' | 'denied'>>;
};supports() is synchronous and safe to call in render only after mount — before the handshake resolves it returns false. This is deliberate and matches useDeviceEnvironment() returning null pre-mount: capabilities are progressive enhancement, and a component must render a working state without them.
7. v1 capability slice
Deliberately thin — enough to prove negotiation, permissions, transports, and result semantics end to end.
| Capability | Plugin-facing | capacitor | tauri | web |
|---|---|---|---|---|
haptics.impact | Yes | Haptics plugin | no-op unavailable | Vibration API where present |
notifications.native | Yes | Local notification | OS notification | Web Notifications / existing web push |
secureStorage.* | Yes, since RFC 0093 | Keychain/Keystore | Keychain equivalent | unavailable |
haptics.impact is chosen first precisely because it is trivial, needs no permission prompt, and has a clean no-op fallback — it proves the round trip without any of the interesting risk.
notifications.native is the first capability with real product value, and its web tier routes into the already-shipped Notification Center / web push infrastructure rather than paralleling it. This is the "SDK routes to the correct tier" promise made real on shipped code.
secureStorage was platform-internal in v1, not plugin-facing — superseded by RFC 0093. The reasoning at the time was a direct consequence of §5: since client-side plugin identity is self-declared, a plugin-facing keychain would let any plugin's client code read any other's entries, and its only planned consumer was RFC 0082 §5's durable-session sequel (an OAuth refresh token in the OS keychain), which doesn't need plugin exposure at all. RFC 0093 (the device-only offline tier's key custody design) later made it plugin-facing anyway: sdk.device.secureStorage.{get,set,remove,keys,clear}() in device-client.ts, gated by a new device:secureStorage manifest permission. The identity- verification gap above was never closed — it is accepted, not solved, and documented as such in docs/sdk-stability.md: "secureStorage is the one exception worth naming: its own hardware-backed device-auth gate (Keychain/ Keystore or WebAuthn PRF) is real even though the plugin-id attribution around it is not." The trade RFC 0093 made is that the value being protected is inherently the calling plugin's own data (each device-only plugin's own local records, scoped by the pluginId each call self-declares) — a plugin that lies about its id can only read another plugin's storage location, not bypass the device-auth gate that protects the underlying key, which is the property that actually matters for RFC 0093's threat model. This paragraph is kept as history rather than deleted, so a reader lands on both the original reasoning and what changed it, not just the current state.
Unresolved tension for whoever builds RFC 0082 §5's sequel next: the wire contract has no per-item access-control policy. RFC 0093's secureStorage requires a live biometric-or-passcode prompt on every operation, gated in the native implementation itself (see that RFC's §2 and its now-resolved open question 1) — not a caller-supplied option, since invoke('secureStorage', {op, pluginId, key, value?}) has no field for it. RFC 0082 §5's own refresh -token consumer explicitly wants "silent recovery after a data purge, and biometric unlock" — read together, that sounds like it wants both an ungated silent read (to exchange the token for a session cookie automatically) and a gated one (to unlock), which the current single-capability-name, uniformly-gated design cannot express. Neither RFC resolves this because neither consumer has been built yet (RFC 0082 §5 is explicitly "designed but not built," gated on RFC 0072's own addendum) — it isn't a live bug. Whichever lands second should either confirm uniform gating is actually fine for its own use case, or this contract needs a policy field before both consumers can share one capability name.
8. Relationship to existing epic tasks
Six scheduled tasks currently presume a bridge. Under this RFC each becomes an implementation of it rather than a parallel invention:
| Task | Was | Becomes |
|---|---|---|
| 17.7 | "SDK desktop environment" | Subsumed: RFC 0080 task 3.32 + this RFC's transport detection |
| 20.3 | "Mobile SDK native environment and bridge adapter" | The Capacitor transport of @sovereignfs/bridge |
| 17.2 (notify half) | sdk.device.notify() sketch | The Tauri transport of notifications.native |
| 17.4 | sdk.device.secureStore.* sketch | The Tauri transport of secureStorage |
| 20.5 | Native push (APNs/FCM) | A capability registry entry + Capacitor transport |
| 20.6–20.8 | Photo picker, biometrics, haptics | Registry entries + transports; haptics lands in v1 here |
RFC 0038's inline API sketches (sdk.device.notify(), sdk.device.secureStore.set/get/delete) are superseded by §6's shapes — same intent, typed results and negotiation added. Those epic tasks should be updated when picked up rather than implemented as written.
Docker / config impact
A new published workspace package: tsup build, turbo.json pipeline entry, transpilePackages in both next.config.ts files, and catalog-pinned dev deps per the pnpm catalog: convention. No new env var, port, on-disk path, or native dependency in this repository. The shells' native permission declarations live in their own repositories.
UI flows
Capability present:
plugin mounts → handshake resolves → supports('haptics.impact', 1) === true
→ user taps → haptics.impact('light') → { status: 'ok' }Capability absent (plain browser, or older shell):
plugin mounts → supports(...) === false → affordance not rendered at all
→ (if called anyway) → { status: 'unavailable', capability: … }Consent, first use:
plugin calls notifications.native.show(...)
→ manifest declares device:notifications ✓
→ no stored grant → platform prompt: "Tally wants to send notifications"
├─ Allow → OS prompt → granted → { status: 'ok' }
├─ Not now → { status: 'dismissed' } (may ask again)
└─ Never → grant stored as denied → { status: 'denied' } without OS promptv1 simplification (leg 2, by explicit developer decision, not a silent cut): the "platform prompt" step above does not exist in v1 — building a global overlay primitive any plugin could trigger was comparable in size to leg 1 on its own, for a case ("Do not proceed if" above) that a much smaller change already satisfies.
requestPermission(pluginId)records the consent grant and goes straight to the OS/browser prompt; the calling plugin's own UI (an "Enable notifications" button, shown while the user is already inside that plugin) is what supplies the naming context instead — the standard web pattern. Revisit a platform-rendered prompt only if this proves insufficient in practice.
Undeclared permission:
plugin calls haptics.impact() without device:haptics in its manifest
→ native transport: refused → { status: 'unavailable' }
→ web transport: advisory only — documented as not enforceableAlternatives considered
- Extend
SdkHostwith adevicenamespace. Rejected on a hard architectural fact:SdkHostis server-side, registered via a Node-process global, and no server round-trip can reach a camera or a haptic motor. The browser-only-subpath pattern (@sovereignfs/sdk/offline) is the only shape that works. - Let plugins call Capacitor/Tauri APIs directly. Rejected — already forbidden by RFC 0058, and it would couple every plugin to one shell, destroying the portability that makes shipping a plugin once possible.
- Version-based compatibility (
minShellVersion, mirroring RFC 0024). Rejected: it assumes one party controls both sides' release cadence. Users control shell updates and operators control instance updates, so version gates would strand working combinations and require coordinated releases across three repositories. Capability negotiation removes the need entirely. - Duplicate protocol types in each shell repository, spec-only here. Rejected — this is exactly the divergence RFC 0082 rejected separate shell repositories over, and protocol drift between two shells is far more expensive to discover than a shared dependency is to maintain.
- Throwing on unavailable/denied. Rejected — collapses four distinct product outcomes into one, and forces every caller into
try/catchfor the entirely expected case of "this is a browser". - A single broad
device:*permission. Rejected — a plugin that wants haptics should not thereby be declaring camera access; the whole value of install-time metadata is its specificity. - Plugin-facing
secureStoragein v1. Rejected for now — unsafe while client-side plugin identity is self-declared (§5). Revisit only alongside a verifiable client identity mechanism. packages/sdktaking@sovereignfs/bridgeas a runtime dependency. The first design considered, and rejected on the facts (§1): the SDK has nodependenciesfield at all today and neither does@sovereignfs/ui, so "zero-deps" is a literal property of both published packages rather than a rule about two specific imports. A dependency would also expose the dual-major duplication risk and give plugin developers a transitive install for a package they should never need to know about. TheprovideBridge()handoff — the same patternprovideHost()already uses for the same problem — achieves one source of truth at no cost.- Plugin developers importing
@sovereignfs/bridgedirectly instead ofsdk.device.*. Rejected —sdk.device.*is the plugin-facing contract named in SRS §3.12, RFC 0058, RFC 0038, and CLAUDE.md, and "bridge" is an implementation concept (transports, handshakes) that plugin authors should never have to reason about.
Open questions
Does the per-user consent grant belong in the existing data-consent tables (RFC 0002's model, surfaced in Account → Data) or its own store?Resolved in workstream 0003 leg 2, as recommended: its own store — a newdevice_consent_grantstable, compound-keyed(user_id, plugin_id, capability)and hard-deleted on revoke, mirroringuser_capability_grants's shape rather thanconsent_grants's (which is keyed by consumer/provider/contract/version — the wrong shape for "a capability granted to a plugin").ShouldResolved in workstream 0003 leg 1, as recommended: degrade to theprotocolVersionmismatch be fatal or degrade toweb?webtransport with a recordedconsole.warn, never fatal — an old shell can never hard-break an instance. Implemented in@sovereignfs/bridge'shandshake()/invoke().- Where does the "shell too old for this capability" message surface to the user — silently absent affordance, or an explicit "update your app" hint? Leaning silently absent for v1, since a hint the user cannot act on (auto-update disabled, review queue) is worse than no hint.
Does theResolved by §1's split: all three transports live inwebtransport belong in@sovereignfs/bridgeor in@sovereignfs/sdk/device-client?@sovereignfs/bridge, since the SDK subpath now holds only the contract and must stay dependency-free and implementation-free.- Whether a verifiable client-side plugin identity is achievable at all on a shared origin, and if so at what cost. It gates plugin-facing
secureStorageand any future capability where cross-plugin isolation matters. - Can a Capacitor shell withhold
window.Capacitorfrom page scripts while still using Capacitor plugins itself (§5)? If not, native permission enforcement is advisory on mobile and real only on desktop, and the docs must say so. Must be answered in workstream 0003 leg 4, before the enforcement claim is relied on. Where does the client bootstrap that callsResolved in workstream 0003 leg 1: a module-level call toprovideBridge()live in the runtime, and how is it guaranteed to run before a plugin's firstsupports()call?installWebBridge()at the top ofClientShell.tsx, the platform layout's single client-side entry point rendered around every authenticated route — runs once as soon as that chunk is evaluated, the closest client-side analogue toinstrumentation.ts's server-side guarantee.- Found during leg 1, not anticipated by this RFC:
BridgeHandshake.shell.platform(§3) had no value for "no native shell is present" — the web transport's own handshake must still return a realshellobject. Resolved by extending the union with'web'rather than inventing a misleading OS guess.
Adoption path
- Epic task 3.34 — the contract in
@sovereignfs/sdk/device-client(types, registry,DeviceResult,provideBridge(),Symbol.forstorage) pluspackages/bridge's protocol,webtransport, and shell-side helper. Published as@sovereignfs/bridge0.1.0;@sovereignfs/sdkminor bump for the new subpath. No plugin-facing capability calls yet. - Epic task 3.35 —
device:*permissions in the manifest, per-user consent grants plus the Account UI, and@sovereignfs/sdk/device-client's plugin-facinghaptics/nativeNotifications/supportssurface with the web tier working end to end.@sovereignfs/sdkand@sovereignfs/manifestminor bumps. - External:
sovereign-desktopimplements thetauritransport (reworking tasks 17.2's notify half and 17.4 to this contract). - External:
sovereign-mobileimplements thecapacitortransport (task 20.3, rescoped from "bridge adapter" to "the Capacitor transport"). - Later capabilities land one at a time — registry entry, permission, both transports, docs — each with its own privacy and store-review note. Tasks 20.5–20.8 and 17.2's tray half follow this path.
Sequenced across epics and repositories as workstream 0003.
Changelog
| Version | Date | Change |
|---|---|---|
| 0.2 | August 2026 | Corrected a stale claim: §7 said secureStorage was platform-internal, not plugin-facing — RFC 0093 superseded that decision (it is now plugin-facing, gated by the device:secureStorage permission), and this section's prose and capability table were out of sync with the shipped SDK surface. Updated to record both the original reasoning and what changed it. Flagged a not-yet-live tension for whoever builds RFC 0082 §5's sequel: the wire contract has no per-item access-control policy, and RFC 0093's uniform every-operation gating may not fit that consumer's "silent recovery and biometric unlock" framing. |
| 0.1 | July 2026 | Initial draft |