◆ Flux

host-services — the resource-handle doctrine

An application that may not open a file, read the clipboard, notify a user, sign them in, take a payment, resize an image or load a typeface is a demo, not a product. This page is the effectful layer that closes that gap: user files, clipboard, notifications and scheduled wake, offline, authentication against your own backend, payments, media operations and sensors, print and PDF, custom fonts, and running another application inside yours.

That is a long list of things a sandboxed script is being allowed to do, and exactly one security argument runs underneath all of it. That argument is the resource-handle doctrine, and it comes first because every family after it is a corollary rather than a fresh story to audit.

New here? Start with Guide §8 — The four planes →

Every family on this page is sealed in design, and its rollout follows the v1 language. That maturity fact is stated once, here; the rest of the page describes each family in the present tense, and the status ledger is where built-vs-designed is tracked.

A note on the samples. A line marked ✗ is a fragment. Flux has no expression-statements, so such a line illustrates a rule; it is not a program. Every positive sample is a program that parses.

The doctrine

Normative, cross-cutting. A script never holds a byte-carrying resource. It holds an opaque, capability-scoped, session-scoped handle key — a string with no structure the script can exploit — and the host, the only holder of the bytes, the socket, the token and the pixel buffer, resolves it. Every transformation is a named host operation on a handle, drawn from a closed catalogue per family: Cmd Op(handleIn, params, C) returns a msg carrying a new handle or metadata — never content.

The resource-handle doctrine Figure — the command and the message cross the membrane; the resource never does. Every family on this page is this picture with different nouns.

What the script may hold

The script holds The host holds
a handle key (string, opaque, session-scoped) the bytes of a file, an archive, a photo
metadata: name, mime, size, w, h the socket, its TLS session, its file descriptor
a verdict: Paid, Declined, Ok(rev), Denied the auth token, the cookie, the refresh loop
a request — inert data in cmds the pixel buffer, the decoded image, the glyph raster

The boundary with pure code is drawn in exactly one place, and it is drawn generously: a declared bounded buffer — buf(N), a vec of bytes over the bits.* substrate — is in-script and pure, so you can write a binary codec, a checksum over data you computed, a bit-packed encoding. What is not in-script is a user file or a platform asset: those are handles plus host ops. The distinction is not “bytes are dangerous”; it is “bytes you did not create do not belong to you”.

Why one doctrine instead of one per family

Each family could have had its own security story: a file API that sanitizes paths, an image API that validates dimensions, an auth API that scopes tokens, a font API that vets a downloaded face. That is one audit per family, one chance to be wrong per family — and one more place, every time the catalogue grows, for a feature to widen a hole nobody is watching.

Why this rule exists. Under the doctrine, the argument is made once. A script cannot exfiltrate what it never held; it cannot forge a handle, because a handle is resolved against a host-side table keyed by the grant; it cannot re-delegate one, because there is no channel that carries authority. A reviewer reading a new family needs to check one thing — does it hand the script the resource, or a key to it? — and if the answer is “a key”, the family inherits the whole security argument for free. That is the property that lets the catalogue grow without the attack surface growing with it.

Three corollaries follow immediately, and they are what you feel while programming:

FLUX
bytes(f.handle)                  // ✗ no such verb — nothing resolves a handle to content
f.handle.pixels                  // ✗ no such field — pixels are host-side, always

Handle lifetime across suspend/resume follows the same session scoping, with an explicit re-pick on resume as the recommended shape; the plan states it as guidance rather than hard-coding it, because the right answer depends on the host’s resume model.

The mould

Every row of the catalogue is the same shape, which is why it is learnable in one sitting rather than family by family:

  1. Intent goes out as an inert Cmd, under a default-deny namespace:verb capability. The command is data — a handle key, a name, a template id, a quantity. It carries no resource.
  2. The outcome comes back as a journaled msg, through the completion constructor the command carried. There is no callback, no promise, no await.
  3. An epoch token absorbs staleness. A command carries an app-supplied scalar; the host echoes it verbatim; a result whose epoch no longer matches is dropped by the arm that receives it.
  4. Revocation rides the membrane. A grant dropped mid-session writes a CapRevoked bound into the journal; a command with a completion constructor is answered [ErrCapRevoked] through it, and a fire-and-forget command is dropped and audited.

A complete application, end to end — pick an image, ask the host what it is, save a copy:

FLUX
app thumbnailer {
  capabilities: [ file:pick, file:save, image:ops ]

  init(p)        = { src: na, out: na, w: 0, h: 0 }
  update(m, msg) = match msg {
                     Choose    -> { model: m, cmds: [ FilePick(["image/png"], 4000000, Picked) ] }
                     Picked(f) -> { model: m with { src: f.handle },
                                    cmds:  [ ImageOp(f.handle, Meta, Info) ] }
                     Info(d)   -> { model: m with { w: d.w, h: d.h }, cmds: [] }
                     Save      -> { model: m, cmds: [ FileSave(m.src, "copy.png", Saved) ] }
                     Saved(ok) -> { model: m with { out: ok }, cmds: [] }
                     Cancelled -> { model: m, cmds: [] }
                   }
  view(m)        = row { button("choose…", Choose) ; text("{m.w}×{m.h}") ; button("save", Save) }
  subs(m)        = []
}

m.src is a string. It is the whole representation of a two-megabyte image inside this program. Notice what is absent: no buffer, no decode, no try, no cleanup — and no way for a compromised dependency to read the user’s picture, because nothing in scope can.

FLUX
cmds: [ Notify("hello", args, Tapped) ]   // ✗ [ErrCapDenied] — notify:send is not in capabilities:

The capability catalogue

Every row is default-deny, host-attenuated, and graded by trust. This table is the map; the sections that follow give each family its grant, its attenuation, and its honest limit.

Capability Grants Host attenuation
file:pick / file:save / file:drop native picker, save-as and download, operating-system drops mime allowlist, size caps, quota; handles session-scoped, never bytes
clip:read / clip:write the clipboard (text in v1) gesture-gated; a read is prompted; pasted content is data, sanitized at render
notify:send Notify, SetBadge, ClearBadge templated content only, rate-limited, consent per the platform permission model
schedule:wake ScheduleWake host-fired; a closed app is relaunched and its payload is the first journaled message
net:offline cache policy per grant, an offline command queue, OnConnectivity replayed under an idempotency key; bounded queue; no multi-writer merge
auth:passkey / auth:session the WebAuthn ceremony; sessions on your own backend ceremonies and forms are host-vetted; the token is host-held; the app sees a handle
pay:checkout Pay, Sub OnEntitlement provider checkout runs host-side; the app never sees the instrument; sellers are vendor-verified
image:ops / capture:photo / capture:qr named ops on handles; camera capture; QR decode closed catalogue, pixels never in-script; consent per capture
geo:read / motion:edges one-shot and watched position; motion edges coarse by default; discrete edges only ([HeldFromEdges])
share:generic Share(record{ text?, urlRef?, fileHandle? }) the platform share sheet: visible and gesture-gated before send
doc:print Print, ExportPdf, ExportImage host-paged render of an already sanitized tree; output is a file handle
asset:font vetted font assets, per-app pinned metrics ([TextMetric]); a declared fallback; never silent reflow
ui:embed appView(appId, params?) child realm, journal and grants — isolated, never inherited
display:awake screen wake-lock visible-only, revocable

Files and user data — file:*

Editors open and save documents; forums attach files; dashboards import and export. Three verbs cover it.

Verb Kind Delivers
FilePick(accept, maxBytes, C) Cmd the native picker → record{ handle, name, mime, size }, or Cancelled
FileSave(handle, suggestedName, C) Cmd save-as / download; the content is a handle, or app-serialized text under quota
a drop on the pane Sub-delivered msg the same record{ handle, name, mime, size }

accept is a closed allowlist of mime types or extensions, capped by the grant — not a pattern the script composes. The drop zone is the pane’s own surface and nothing beyond it ([SurfaceConfine]): an application produces pixels, and accepts drops, only where it holds the lease.

Host ops on a file handle form a closed catalogue — a SHA-256 hash, archive pack and unpack (size-capped), and the image ops of the media family. Text files decode only through declared codecs (CSV, Markdown, JSON): the payload arrives already typed, which is what makes the absence of a regular-expression engine a livable rule rather than a hardship. See text.

The transport of a file — a resumable upload, a ranged download, progress — is not here. It is net, through Sub OnTransfer(reqKey, C). This capability supplies the handle; the network pillar supplies the pipe. The split is deliberate: it keeps one story about backpressure, retries and idempotency instead of two.

The honest limit. A handle does not survive the session. An application that wants “reopen the last document” persists its own state and re-derives, or asks the user to pick again. There is no retained operating-system handle, because a retained handle is ambient authority with a nice name.

The clipboard — clip:*

Both are gesture-gated: they run inside a user action, and a read is prompted. Nothing is ambient — there is no clipboard event without the capability, and there is no format sniffing in the script.

Why a paste is not a hazard. Pasted content arrives as data, and data is sanitized where it is rendered, like every other string: the view is a tree of vetted primitives, not markup. A paste into a rich-text editor routes through the editing protocol of text, which is an operation on a document model, not an injection of bytes into a DOM. The clipboard is therefore an ordinary message source, and the usual message discipline is the whole of its defence.

Notifications, badges and scheduled wake — notify:*, schedule:*

This is the retention loop of anything social — someone replied, a mention, a reminder — and the delivery channel that a purely local alert does not have.

Verb Kind What it does
Notify(template, args, clickMsg) Cmd renders a platform notification from a template; a tap delivers clickMsg
SetBadge(n) / ClearBadge Cmd the application-icon badge
ScheduleWake(at, payload, C) Cmd the host fires at at; if the app is closed it is relaunched
toast(…) ui in-app chrome, with an aria-live contract — a view primitive, not this capability

Content is templated, never a free string handed to the platform layer. The template id and its arguments are checked against a catalogue declared by the application; the host renders it.

Why templates and not strings. A free string crossing into an operating-system surface is the one channel an application could use to say something the platform will attribute to us — a fake system prompt, a fake security warning, a phishing line rendered in the platform’s own chrome. Templates make that inexpressible while leaving the legitimate case (an argument substituted into a phrase you wrote and we vetted) entirely open. The same discipline governs share:generic, with one relaxation, and for a stated reason: the share sheet is visible and gesture-gated, so the user reads and can edit the payload before it leaves.

The launch-message clause

A wake fires while the app is open: an ordinary message. A wake fires while the app is closed: the host relaunches the app — and then what?

Normative delivery rule. A host-initiated (re)launch — a notification tap, a scheduled wake, a deep link — delivers its payload as the first journaled message(s) of the new session, through the declared constructor, ordered before any other subscription delivery.

This is worth stating precisely because it looks like it might be a third exception to “update is the only producer of a Model”, alongside time travel and migration. It is not.

What the clause actually fixes is ordering. Without it, a launch payload could interleave with the first tick or the first connectivity edge differently on two machines, and the re-fold would diverge. Pinning the payload to rank zero makes cold-start deterministic.

FLUX
app reminders {
  capabilities: [ notify:send, schedule:wake ]

  init(p)        = { queued: 0, resumed: na }
  update(m, msg) = match msg {
                     Arm(t)        -> { model: m with { queued: m.queued + 1 },
                                        cmds:  [ ScheduleWake(t, "daily-review", Woke) ] }
                     Woke(payload) -> { model: m with { resumed: payload },
                                        cmds:  [ Notify("review-due", payload, Tapped) ] }
                     Tapped(hit)   -> { model: m with { resumed: hit }, cmds: [] }
                   }
  view(m)        = col { text("queued: {m.queued}") }
  subs(m)        = []
}

The honest limit. There is no background continuous execution. A wake is a relaunch plus a message, not a process that was running while you were not looking. Staying alive while hidden is a different, narrower capability (display:keepalive). Anything else would be a promise the browser does not let us keep.

Server-originated push — the app closed, across devices — is the fan-out half of this family and lives in server (push:send), rolling out with the server plane. This section is the local half.

Offline, cache and connectivity — net:offline

The mechanism is specified in net; its capability face belongs here, because it is what an application actually asks for.

Piece Behaviour
Sub OnConnectivity(C) online / offline / metered, as discrete journaled edges — never a continuous signal
a per-grant cache policy responses served from cache are data like any other; freshness is surfaced
a bounded offline command queue a command emitted offline is queued host-side and replayed on reconnect under an idempotency key

The queue has a declared cap. Overflow is a message to the application, not a silent swelling — the same discipline as every other bounded structure. Completion messages arrive late, and the epoch token absorbs the ones that no longer matter.

A named non-goal. Offline multi-writer merge (conflict-free replicated data types) is not in scope. The queue is single-user, replayed in order. Two people editing the same document offline and reconciling on reconnect is a different product with a different core, and pretending a queue solves it would be a lie told in an API.

Identity for your own backend — auth:*

Not every application signs in through a known provider. Many have accounts on a backend their author runs. That is what this family is for.

Verb Kind What happens
AuthPasskey(action, rpRef, C) Cmd the host performs the WebAuthn ceremony; the script receives an opaque session handle and a verdict
AuthLogin(formRef, C) Cmd credentials are collected in a host-vetted form surface and exchanged against your declared endpoint
Sub OnSession(C) Sub the session lifecycle, as messages
Cmd Logout Cmd ends it
FLUX
variant SessionEvent { Established(h: string) | Refreshed(h: string) | Expired | LoggedOut }
FLUX
app forum {
  capabilities: [ auth:passkey, auth:session ]

  init(p)        = { session: na }
  update(m, msg) = match msg {
                     SignIn     -> { model: m, cmds: [ AuthPasskey(Login, "forum.example", Signed) ] }
                     Signed(h)  -> { model: m with { session: h }, cmds: [] }
                     SignOut    -> { model: m with { session: na }, cmds: [ Logout ] }
                     Session(e) -> match e {
                                     Established(h) -> { model: m with { session: h }, cmds: [] }
                                     Refreshed(h)   -> { model: m with { session: h }, cmds: [] }
                                     Expired        -> { model: m with { session: na }, cmds: [] }
                                     LoggedOut      -> { model: m with { session: na }, cmds: [] }
                                   }
                   }
  view(m)        = col { when is_na(m.session): button("sign in", SignIn) }
  subs(m)        = [ OnSession(Session) ]
}

Three things are absent from that program, and their absence is the design.

The token. It is host-held. m.session is an opaque handle; the host attaches the credential to outbound requests under the grant. Refresh is automatic, host-side, and surfaces only as Refreshed(h). A leaked script cannot leak a credential it never had — the exact argument net makes about the socket, generalized.

The password. Sensitive input is collected in a host-vetted form surface and never transits script memory. This is the same precedent as a wallet picker: the surface that takes the secret is not one the application drew.

The key material. For a passkey there is no key script-side, and none host-side either — it lives in the platform authenticator. The ceremony is the host’s; the script gets a verdict.

FLUX
m with { token: e.token }   // ✗ no such field — a session event carries a handle, not a credential

Identity exposed to the application stays pairwise-opaque: an application learns a stable identifier for itself, not one that correlates a user across applications. The exception is definitional and unavoidable — if the account is on the application’s own backend, the application is the identity provider.

Payments — pay:*

Verb Kind Delivers
Pay(sku, qty, C) Cmd host-mediated checkout in the provider’s own surface → a verdict
Sub OnEntitlement(C) Sub server-verified entitlements and subscriptions
FLUX
variant PayVerdict { Paid(receiptRef: string) | Declined | Cancelled | Pending }

def afterPay(m, v) = match v {
  Paid(r)   -> m with { ui: m.ui with { pending: 0 }, doc: m.doc with { receipt: r } }
  Declined  -> m with { ui: m.ui with { pending: 0 } }
  Cancelled -> m with { ui: m.ui with { pending: 0 } }
  Pending   -> m
}

The script never sees the instrument. Not a card number, not a token, not a redirect it could tamper with: the checkout runs in the provider’s host-side surface, and the application receives a verdict and a receipt reference — another opaque handle, which the server validates (see server).

Amounts are decimal money[Q] — exact fixed-point, carrying the quote currency as an inferred tag rather than as surface syntax. The reason is not fastidiousness: binary floating point cannot represent 0.10, and a price that is off by an ulp is a bug you discover in an accounting reconciliation months later. See asset & currency and the decimal.* namespace in compute.

Pending is a first-class arm, not an error. Some payment methods settle asynchronously; the verdict says so, and the entitlement arrives later through OnEntitlement. An application that grants access on Paid alone and never listens for the entitlement has a bug the type system cannot catch — but match at least forces you to look at Pending.

The capability is provider-agnostic by design; which providers ship first is a rollout choice, not a language one, settled when the checkout surface is stood up.

Media, capture and sensors — image:*, capture:*, geo:*, motion:*

Image operations are a closed catalogue

FLUX
variant ImgOp { Resize(w: num, h: num, fit: fit) | Crop(area: rect) | Rotate(quarter: num)
              | Filter(preset: string) | Meta }

Cmd ImageOp(handle, op, C) takes a handle and one named operation, and returns a new handle or metadata (dimensions, mime). That is the entire surface.

Why the catalogue is closed. The alternative is pixels in the script — an array the program reads and writes, and therefore a convolution, an FFT, a filter kernel you wrote yourself. That is a fine thing for a language to have and a bad thing for this one: it puts an unbounded, data-dependent loop in the middle of a total language, and it hands a sandboxed script the contents of a user’s photograph. A closed catalogue of named ops is the shape that is both sandbox-compatible and honest — and general 2-D signal processing in-script is a named non-goal, not an omission.

Capture

Cmd CapturePhoto(C) yields an asset handle. Cmd ScanQr(C) yields msg(string) — a decoded payload, delivered as data. Both are consent-gated per capture. Neither is the audio/video call path, which is a network profile and lives in net.

Whether capture:* on a desktop browser with no camera degrades to a declared file-pick fallback is a host-behaviour choice the plan leaves to the capability’s first consumer.

Sensors deliver edges, never samples

FLUX
app tracker {
  capabilities: [ geo:read ]

  init(p)        = { fixes: 0, last: na }
  update(m, msg) = match msg {
                     Moved(pos) -> { model: m with { fixes: m.fixes + 1, last: pos }, cmds: [] }
                   }
  view(m)        = col { text("fixes: {m.fixes}") }
  subs(m)        = [ OnGeo(60000, Moved) ]      // at most one fix a minute
}

Cmd GeoOnce(accuracy, C) reads a position once; Sub OnGeo(minInterval, C) watches. Consent is coarse by default — a granted position is a neighbourhood unless the user says otherwise.

Orientation and motion expose discrete-edge subscriptions only: a threshold crossing, a shake gesture. There is no continuous heading, no per-frame accelerometer sample.

Why edges and not samples. This is [HeldFromEdges], and it is a determinism rule before it is a privacy rule. A Model is a fold over journaled messages; the fold must reproduce bit-for-bit on replay, on another device, at another frame rate. A continuous sensor sample is none of those things — its cadence depends on the hardware and the load, so two replays of the same session would see different message counts and diverge. A discrete edge (“the threshold was crossed”, “the device was shaken”) is rank-deterministic: it is a message like any other. A held sample that reaches a Model field a verdict reads is [ErrFirewall], checked at compile time — the same rule that keeps pointer position and hover out of a game’s score.

Share and wake-lock

Cmd Share(record{ text?, urlRef?, fileHandle? }) opens the platform share sheet — visible, gesture-gated, editable by the user before it sends, which is exactly why a free text field is admitted here and refused to Notify. display:awake keeps the screen on while the pane is visible, and is revocable.

Verb Output
Cmd Print(viewRef) the host renders the pane’s retained tree to paged media
Cmd ExportPdf(viewRef, C) the same render, delivered as a file handle → save or share
Cmd ExportImage(sceneRef, format, C) a scene to PNG (raster) or SVG (sanitizer-emitted), as a file handle

The host owns pagination: page size, headers and footers are template tokens; page-break hints are cosmetic container properties on the presentation stratum, so they can never change what the document is, only where it breaks. There is no new script surface at all here — the tree being printed has already been sanitized for the screen, and printing is a second renderer over the same value. Export lands as a file handle, and the doctrine takes over from there: the script never touches the produced pixels either.

Typography — asset:font

A font is a host-loaded, vetted asset, keyed and quota’d like any other, which extends the typography token allowlist for that application only. There is no @font-face, and no font URL — those remain inexpressible.

Why a font is a determinism problem. Text metrics feed layout. If two devices resolve a typeface differently — a fallback here, a slightly different face there — the same program produces a different tree of boxes, and the geometry that the oracle checks byte-for-byte stops matching. So each font, at each version, lands with pinned metrics joining the [TextMetric] set: the measurement routine is shared by the interpreter, the compiled module and the server, and a layout cannot reflow differently on two machines. A missing font is not a silent substitution: the declared fallback applies and a diagnostic is raised.

That is the whole of the relaxation. Custom typography was excluded from the display pillar for exactly this reason, and this capability is its sanctioned re-entry — vetted asset, pinned metrics, declared fallback.

Runtime app composition — ui:embed

appView(appId, params?) -> ui instantiates another application in a child slot: its own WASM realm, its own journal, its own grants.

FLUX
app dashboard {
  capabilities: [ ui:embed ]

  init(p)        = { child: "clock-widget" }
  update(m, msg) = match msg { Swap(id) -> { model: m with { child: id }, cmds: [] } }
  view(m)        = col { appView(m.child, { theme: "dark" }) }
  subs(m)        = []
}

Why embedding cannot amplify authority. Nothing is inherited — in either direction. The child does not receive the parent’s grants, so a hosted mini-app cannot borrow the dashboard’s network access; and the parent does not receive the child’s, so an embedder cannot harvest what a user granted to the app it hosts. The two communicate through exactly two declared channels — the params passed at mount, and the capability-gated context bus — and neither carries a Model, a journal, or a capability. Composition is therefore flat with respect to authority: an application’s manifest is what it is, whoever hosts it.

The child’s lifecycle rides the slot’s port — mount, suspend, dispose — the chartView cycle generalized. A crashed child shows the layout manager’s error card in its own slot, and the embedder keeps running: fault isolation is per-realm.

The consumers are the obvious ones: a dashboard hosting mini-applications, a course page embedding a live exercise, a marketplace composing an app inside an app. It complements package-level composition — packages compose code, appView composes running programs.

The embedding depth cap defaults to depth one, which the plan recommends; a deeper cap is a host-policy knob, not a language change.

What remains inexpressible

Nothing on this page relaxes an invariant. Every family adds a row to the capability table and nothing else — no lattice sort, no grammar symbol, no arrow, and no change to the firewall or to totality. And for every tier, trusted or not, these still have no name in the language: eval and code generation, the raw DOM, a raw socket, a raw database client, a token, a cookie, a global store, and the bytes behind a handle.

A trusted tier grants effects. It never loosens causality, no-repaint (a value, once produced for a step, never changes), totality or the firewall — which is why an application that can print, pay and take a photograph is still an application whose entire behaviour is a fold over its journal.

See also