Part III — Connected apps: requirements, grants, and credential custody
12. What a connection is
Section titled “12. What a connection is”A connection binds one app to one third-party provider, so that the host — never the app — may attach real credentials to outbound requests on that app’s behalf.
The concept is split in two, and the split is the substance of this part:
- A connection requirement describes what the app needs: the provider, the auth kind, the credential fields, the registration walkthrough, endpoints and scopes, the header/query template, and the hosts it declares it will call. It is credential-free and is written at authoring moments — app build, an auth-touching edit, or a starter install.
- A connection grant describes what the user allowed: approved status, the frozen host ceiling, the approval timestamp, and the revocation tombstone. It is written only on an explicit user approval act.
Credential values are in neither. They live in snug_secrets under the auth:
namespace (§13) and never enter a requirement, a grant, an LLM prompt, or an app iframe.
An app holding requirements but no grant is in the normal pre-connect state: its network calls fail closed and the user is offered a connect flow. A conforming host never treats the presence of a requirement as permission to attach a credential.
12.1 Who may create a requirement
Section titled “12.1 Who may create a requirement”A running app may never propose a connection. There is no frame, no SDK call, and no announce field through which app code can ask for a credential grant. (The open-url frames of §4 carry no credential seat and open nothing without the host’s own confirm.) Exactly three proposers exist:
| Proposer | Channel | Review |
|---|---|---|
| the user | Settings / connect CTA | manual entry |
| the app’s builder assistant | a connection_requirement directive in the build conversation | strong, unless the registry rung pinned the values |
| the install act | the starter’s own connection.json, vouched at install | always strong (field-by-field) |
Two obligations bind all three: a proposer may write declared rows only (a write
aimed at an approved row stages instead — §12.3; a write aimed at a revoked row is
refused outright, and reconnecting discloses the prior revocation), and approval is the
only writer of grants.
One distinction this doctrine does not blur, stated here because readers reasonably ask: proposing a grant (never available to a model at runtime) is different from composing a request under an existing grant (available to the provider chat lane of §16, gated by the unchanged executor). The LLM can author a request; it can never widen what a request may reach, place a credential, or see one.
Starter vouching is a two-fact check: the app’s install_source must resolve to a
bundled starter manifest AND the installed HTML (both the pinned factory version and the
current version) must match the bundled starter bytes. A mismatch means the declaration is
not vouched and is treated as absent.
12.2 Storage: snug_connections
Section titled “12.2 Storage: snug_connections”PRAGMA user_version = 6 (Part II). Normative DDL is USERDB_DDL in userdb-schema.ts:
CREATE TABLE IF NOT EXISTS snug_connections ( app_id TEXT NOT NULL, slot TEXT NOT NULL, requirement_json TEXT NOT NULL, requirement_version INTEGER NOT NULL, provenance TEXT NOT NULL, confidence REAL, status TEXT NOT NULL, pending_requirement_json TEXT, imported INTEGER NOT NULL DEFAULT 0, allowed_hosts TEXT NOT NULL DEFAULT '[]', approved_at TEXT, revoked_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (app_id, slot))| Column | Normative meaning |
|---|---|
app_id | host-assigned app identity. Never app-claimed. |
slot | stable connection id within the app, ^[a-z0-9][a-z0-9-]{0,39}$. Lowercase and dash-only by construction: SQLite compares bytes exactly, so a mixed-case form would fork one provider into two rows. |
requirement_json | the requirement (§12.5), credential-free, schema-valid. |
requirement_version | integer, bumped on every persisted replacement whose canonical form differs. |
provenance | registry | inference | user_docs | starter | user. Drives review posture. |
confidence | model-derived confidence when provenance is model-derived. Display-only — never an approval input. |
status | declared | approved | revoked. Exactly three values. |
pending_requirement_json | a changed requirement staged against an approved row (§12.3). |
imported | 1 when the row arrived by DB import and must be re-reviewed. |
allowed_hosts | the FROZEN host union, computed at approval. Sorted, unique, normalized. |
approved_at / revoked_at | grant timestamps. revoked_at is a tombstone and survives. |
A conforming host MUST bound declared slots per app: AUTH_MAX_SLOTS_PER_APP = 8.
Replacing an existing slot does not count against the cap; revoked tombstones do
count, since they are exactly what a flooding attacker leaves behind.
“Needs re-approval” is derived, never a fourth status:
status = 'approved' AND pending_requirement_json IS NOT NULL. A fourth value would
require a write that moves a row out of approved to signal a pending change — the
silent de-grant/re-grant the staging seat exists to prevent.
12.3 Write rules
Section titled “12.3 Write rules”A conforming host exposes exactly five writers; each is the sole legal author of one
transition, so “which writer may I call?” is answerable from status alone:
| Writer | Legal on | Effect | Refuses |
|---|---|---|---|
putDeclaredConnection | absent or declared row | insert or replace a declared row | approved (stage instead), revoked (explicit reconnect only), over-cap new slot |
stagePendingRequirement | approved row | writes pending_requirement_json only | any non-approved row |
approveConnection | declared row | freezes allowed_hosts from requirement_json (never from a pending column storage may have forged), stamps approved_at, status → approved | — |
reapproveConnection | approved row with pending | re-validates the pending requirement (it is reachable without passing the stager), promotes pending → current, re-freezes hosts, clears pending | — |
revokeConnection | approved row | status → revoked, stamps revoked_at, keeps the row, wipes the auth:<appId>:<slot>:* credential slice | — |
While a change is staged, the grant continues serving requirement_json and its old
frozen hosts — a host MUST NOT bind the executor to pending_requirement_json. The user
is shown the field-by-field diff old→pending, and only re-approval promotes it. There is
no path by which an edit widens a host ceiling without a human seeing the diff.
Canonicalization (for requirement_version and import comparison): recursive key
sort, whitespace-free JSON. Array order is preserved and significant —
registration.instructions is a numbered walkthrough, fields is the wizard’s input
order, scopes is what review renders. The canonical form is compared as a string,
not a digest, so comparison is exact and synchronous.
12.4 Import reconciliation
Section titled “12.4 Import reconciliation”A user DB is portable, so an imported file’s connection rows are untrusted input. On
import, for each incoming snug_connections row:
- byte-identical
(app_id, slot, requirement_json, allowed_hosts)to a locally approved pre-import row → the local grant is restored. Identical rows carry no new attack surface, and blanket demotion would revoke every approval on each routine two-device sync and train approval fatigue. - anything else that validates strictly → lands
declaredwithimported = 1,approved_atcleared, andallowed_hostsrecomputed from the requirement rather than trusted from the column. - structurally unusable rows → dropped, and reported to the user.
Because branch 1 compares stored allowed_hosts bytes, host-union output stability is
normative: deriveConnectionAllowedHosts MUST be sorted, unique, and normalized, or an
otherwise-unchanged connection mass-demotes on the first sync pull.
12.5 The connection requirement contract
Section titled “12.5 The connection requirement contract”Schema: connectionRequirementSchema (connection-requirement.ts). Strict at every
level — an unknown key anywhere is a rejection, never a passthrough, so a seat added in a
future version cannot ride in unreviewed on a channel that predates it.
connectionRequirement = { slot, // ^[a-z0-9][a-z0-9-]{0,39}$ provider: { name, // ≤120, printable ASCII, NFC (§12.6) homepageUrl?, docsUrl? }, // https, ≤300 kind, // api_key | bearer_token | basic_auth // | oauth2_client_creds | oauth2_auth_code // | linked_device | none — SEVEN kinds fields?, // 1..8 × { key ^[a-z0-9_]{1,40}$, label ≤80, // type text|secret|password|url, // description? ≤200, placeholder? ≤60, required? } registration?: { consoleUrl?, // https, ≤300 instructions? }, // ≤10 × ≤300, PLAIN TEXT (§12.7) endpoints?, // authorize/token/refresh/revoke, https, ≤300 scopes?, // ≤64 × ≤200 pkce?, authorizeParams?, request?: { headerTemplate?, // ≤8 entries, name ^[A-Za-z0-9-]{1,64}$, // value ≤300 (§12.8) queryTemplate? }, // ≤8 entries, name ^[A-Za-z0-9_.\[\]-]{1,64}$, // value ≤300 (§12.9) userLayer?, // registry-synthesized ONLY (§12.10) lanHost?: { class, // 'rfc1918-ipv4-literal' (single-member union; additive) label }, // ≤80 (§12.11) declaredApiHosts?, // 1..32 × ≤253, bare hostnames, normalized (IDNA toASCII, // lowercase). Presence rules: §12.11 (XOR with lanHost) testRequest? // { method: 'GET', pathAndQuery ≤200, leading '/' }}Seven kinds. The kind set grew from six to seven when linked_device was appended
(never inserted — a stored row’s kind must never be re-read as a different kind). Widening
the set remains a schema version change, not a configuration change. Three kinds carry
coherence rules enforced at parse:
none— the keyless provider: a public API needing no credential but still needing a host ceiling. It MUST carry nofieldsand norequesttemplate (either placement);declaredApiHostsstays required — keyless means “no credentials”, never “no host gate”. Anoneconnection with no grant still fails closed.linked_device— a provider that authenticates a device session rather than a request (Part V). It MUST declare at least one credential field (the minted helper token’s slot — a row with no field would parse cleanly and then fail mid-send); it MUST carry noendpoints(a linked device never redirects, and a toleratedrefreshUrlwould widen the derived ceiling for a kind that cannot use it); and it MUST carry nolanHostseat (a helper is a capability, not a network host — §18).oauth2_auth_code/oauth2_client_creds—pkcedefaults to true (public-client posture).
declaredApiHosts is a request for a ceiling, never the ceiling. The frozen ceiling
is snug_connections.allowed_hosts, derived at approval as: declaredApiHosts ∪ every
OAuth endpoint host (authorize, token, refresh — it receives long-lived credentials —
revoke) ∪ the embedded userLayer’s declared hosts and endpoint hosts. lanHost
contributes no host; a pre-collection LAN row derives an empty ceiling, which
refuses every host — the correct answer before an address exists.
12.6 Provider name: the confusable guard, and its stated limits
Section titled “12.6 Provider name: the confusable guard, and its stated limits”provider.name is printable ASCII (U+0020–U+007E) and NFC-normalized. It stops
non-ASCII homoglyphs (ѕpotify with Cyrillic U+0455; fullwidth Latin; zero-width and
bidi characters) and registry-key evasion (a homoglyph name normalizes to a different
registry key, misses the borrow ban of §12.12, and keeps attacker-authored endpoints while
looking pinned).
It does not stop pure-ASCII lookalikes: 5potify and C0inbase are accepted, and no
charset rule can reject them without rejecting legitimate names. Those are carried by the
borrow ban’s host-intersection trigger and by the review screen’s provenance disclosure. A
conforming host MUST NOT present this guard to users as protection against lookalike
names.
12.7 Registration walkthroughs are plain text
Section titled “12.7 Registration walkthroughs are plain text”registration.instructions are rendered as a numbered list of plain text — never as
HTML, never as links. They arrive from an untrusted channel and are displayed with the
host’s own chrome and legitimacy; markup here would be phishing wearing the host’s
clothes. consoleUrl is https-only and must be rendered with its full host visible.
12.8 Header templates and the pinned helper enum
Section titled “12.8 Header templates and the pinned helper enum”request.headerTemplate places credentials into outbound requests. The schema bounds the
envelope (entry count, header-name charset, value length); the content rule depends on
the sibling fields list, so it is a separate lint a conforming host MUST apply
before a requirement is reviewed, stored, or rendered.
A template value may reference only:
-
a declared field key from this requirement’s own
fields; -
a pinned request token:
request.method·request.url·request.pathAndQuery·request.body·request.timestamp; -
a pinned helper — the enum has five members with fixed arities:
Helper Arity Semantics timestamp0 unix seconds, memoized per render pass (§12.8.1) base641 UTF-8 in → base64 out hmac_sha2562 hex HMAC-SHA256 hmac_sha256_b642–6 base64(HMAC-SHA256(base64decode(secret), concat(parts)))— the three transforms fused, because the composition is otherwise inexpressible in a flat grammar; the message tail is variadic because real prehash strings are multi-partcdp_jwt2 a provider-scoped Ed25519 JWT signer (Coinbase CDP). Both arguments MUST be declared field keys — never quoted literals, never request tokens. Ed25519 only; no algorithm negotiation. -
a quoted literal.
The grammar is flat: a helper call is a placeholder form, never an argument form. A conforming host MUST reject a nested call and MUST NOT evaluate it. Helpers not in the enum do not exist; adding one is a reviewed spec-level change, never a configuration knob — an unused helper is reachable signing surface.
The lint’s load-bearing job is making the render engine’s unknown-token-as-literal
fallback unreachable: {{hmac_sha256(api_secrt, request.body)}} — one transposed
character — would silently sign the eight-byte string "api_secrt" instead of the
credential. A conforming host rejects that at review time, not at signing time.
A quoted argument is a literal in the ENGINE, not only in the lint. A conforming host
MUST render a quoted argument verbatim — it MUST NOT resolve the quoted text against
credential fields or request tokens. {{base64('api_key')}} MUST render
base64("api_key"), the literal token text, even when api_key is a declared field
holding a live credential. A host that strips the quotes and resolves the bare text emits
the credential from a template that passed review precisely because the quotes made it
look inert. This is a credential-disclosure requirement, not a formatting one.
12.8.1 request.timestamp and the one-timestamp rule
Section titled “12.8.1 request.timestamp and the one-timestamp rule”The timestamp MUST be evaluated once per render pass and memoized. Two independent evaluations can straddle a second boundary, so a signed timestamp and a sent timestamp would disagree intermittently.
request.timestamp is the token that makes the memoization reachable: every signing
scheme of this shape sends the timestamp in one header and signs it inside another, so the
timestamp must be writable in argument position — and the helper form timestamp()
cannot go there, because the grammar admits no nesting. It is a render fact, not a
request fact: minted during the render pass, served from the same memoized value as
{{timestamp()}}. The conformance property: an HMAC recomputed independently from the
timestamp value the host actually sent equals the signature the host sent.
CB-ACCESS-TIMESTAMP: {{request.timestamp}}CB-ACCESS-SIGN: {{hmac_sha256_b64(api_secret, request.timestamp, request.method, request.pathAndQuery, request.body)}}12.9 Query-parameter templates
Section titled “12.9 Query-parameter templates”request.queryTemplate places credentials into the query string — the placement some
providers require and header templates cannot express (OpenWeather’s ?appid=,
CoinGecko’s demo key). Query-parameter names get their own charset,
^[A-Za-z0-9_.\[\]-]{1,64}$ (real query names carry underscores, dots, and bracketed
forms the header rule rejects); both charsets still exclude every character that could
smuggle URL structure or template metacharacters. Values follow §12.8 in full: same
bounds, same vocabulary, same flat grammar — and a conforming host MUST derive both
templates’ lints from one resolution of the declared field keys.
Rendered query values are credentials inside a URL, which makes the URL itself secret-bearing. Two host obligations follow:
- Placement after the ceiling. Query credentials are rendered into the URL only after the frozen-ceiling host checks have passed, so the ceiling decision is always made against the app-supplied URL.
- Scrubbing is enumerated, not aspirational. The credentialed URL MUST NOT appear in any surface the app, the model, or the user’s logs can read: fetch-error messages, response echo surfaces, LLM-visible inspectors, host UI. The request URL returned to the app is the URL the app asked for, never the credentialed one.
12.10 userLayer is registry-synthesized only
Section titled “12.10 userLayer is registry-synthesized only”The embedded org→user second layer keeps two-layer providers expressible. It is
rejected on the assistant, manifest, user-docs and user channels — on the basis of
where it came from, never what it says. A userLayer pointing at genuine provider URLs
is still a model-authored seat, and the next one will not point at a genuine provider.
12.11 LAN-class providers: lanHost and the host XOR
Section titled “12.11 LAN-class providers: lanHost and the host XOR”A provider whose API lives on a device on the user’s own network — a Philips Hue
bridge is the archetype — has no host any registry or author can pin: the address belongs
to the user’s router. lanHost is a DECLARATION THAT A HOST WILL BE COLLECTED, never a
host:
lanHost = { class: 'rfc1918-ipv4-literal', label: 'Bridge IP address' }class is a single-member union today. Future device classes are additive — a new
literal plus its own validator and its own admission rule, never a widening of this one.
(A linked-device helper is deliberately NOT a lanHost class — §18.)
The host XOR (normative). Exactly one host source:
lanHost | declaredApiHosts | verdict |
|---|---|---|
| absent | 1..32 hosts | accepted — the ordinary shape |
| absent | absent or [] | refused (declaredApiHosts required) |
| present | absent | accepted — the pre-collection shape a LAN registry entry emits |
| present | exactly one host of the declared class | accepted — the post-collection shape the wizard writes |
| present | a host outside the class, two or more hosts, or [] | refused |
A public host beside a lanHost would freeze a public host into a ceiling the review
screen presents as “a device on your own network”. Host obligations: (a) a
pre-collection LAN row derives an empty ceiling, so the binding wizard order is collect
the address → approve the row → freeze the ceiling → pair; (b) the schema is the FIRST
of two seats that refuse an off-class host — the registry-borrow path re-validates the
class independently, because a requirement can reach admission without passing this
schema; (c) nothing platform-conditional is persisted: a LAN row opened on a web hub is
disclosed as desktop-only, never refused or rewritten.
LAN transport obligations (desktop hosts): first pairing records a TOFU certificate pin — the SHA-256 fingerprint of the device’s leaf certificate — in the connection’s dynamic state (§13); every later request verifies against the pin and fails closed on mismatch. The pairing window is a disclosed residual: an attacker already on the LAN at the moment of first pairing can be pinned instead of the device.
12.12 The registry
Section titled “12.12 The registry”A host MAY keep a registry of pinned providers. Where it does, five rules govern it:
The borrow ban. A requirement that names a registry provider, or whose
declaredApiHosts intersect a registry entry’s hosts, has the registry’s pinned
values substituted for its own: hosts, endpoints, registration block, and the display
name. Declared values for those seats are discarded, not merged. Both triggers are
required (name-match alone is evaded by renaming; host-match alone is evaded by trading on
a brand while declaring no overlapping host), and the ban is kind-agnostic — an
api_key requirement naming a known OAuth provider must not borrow its legitimacy while
pointing the credential at a host of its choosing. A borrow MUST be surfaced to the user:
“these values came from the host’s registry, not from the app”. For a LAN entry the
borrow path preserves the declaration’s collected address (the registry has nothing to
substitute) and re-validates its class independently of the schema.
Pinned scopes. A registry entry MAY pin a provider’s scope list. A pin is entry-level, never per-flow (privilege breadth is brand identity); it replaces any authored list on a borrow hit; it renders verbatim on review and re-approval diffs; and a pin change on an approved row always re-consents — never silently promotes. The governing principle, stated once: prefer a scope the token cannot exceed over a rule the app promises to follow. A capability excluded from the pinned scopes is structurally unreachable regardless of app code — the strongest control this part offers.
Auth options. An entry MAY offer alternative complete credential flows (e.g. PAT vs
OAuth app). An option carries its own kind, fields, endpoints, and walkthrough — but no
identity seats: display name, hosts, aliases, lanHost, and scopes belong to the
entry, because which hosts may receive a credential is a per-provider decision, never
a per-flow one. Substitution honors the option whose pinned field list the declaration
matches; no match means the default.
Pairing families. Providers whose credential is obtained by ceremony rather than
typed carry a pairing seat in the registry entry — never on the persisted
requirement row. A requirement seat carrying claim mechanics would be a channel through
which a prompt-injected declaration aims an uncredentialed request; registry data is
host-shipped and reviewed. Three families exist, and every one carries a required
verify probe (verify-before-claim: prove the counterparty is live and speaking the
expected protocol before any credential is written):
| Family | Shape | Reference example |
|---|---|---|
exchange | a local pairing exchange (e.g. press-button + POST) against the collected LAN address | Hue |
device-link | start → QR → poll; the poll releases a once-minted helper token (Part V) | |
token-claim | the user pastes a one-time setup token; the host decodes a claim URL, POSTs it once, and receives the durable access credential | SimpleFIN |
Token-claim obligations: the decoded claim URL AND the returned access URL are both
checked against the row’s frozen ceiling (https-only, exact host, no userinfo,
redirects refused on both hops); the returned path must match the registry’s pinned access
path exactly; credentials and the claimVerifiedAt marker are committed together
(one write, no window); every refusal message is a fixed sentence, never derived from
pasted bytes. Note the family is registry data over an ordinary kind (basic_auth for
SimpleFIN) — pairing is not a kind.
Capability facts. browserCallable is tri-state: true/false are documented facts
a wizard may disclose; absent means unknown and is disclosed as unknown — never rendered
as “works”. A desktop OAuth redirect posture the registry does not vouch for means the
wizard refuses honestly at entry, never guesses. A loopback-class redirect posture is
representable only beside PKCE — pkce: false plus a loopback redirect leaves auth-code
injection undefendable, so the combination is refused.
Web-surface capability facts (since 1.0). A registry entry MAY carry two further
render-time seats: webRedirectPosture (sole member today: 'origin-callback' — the
provider’s client registration can accept the connecting web origin’s /oauth/callback
as an exact authorized redirect URI) and webRegistration (the web-surface console
walkthrough). Structural rule: webRegistration and webRedirectPosture require each
other — a posture without its walkthrough is refused, and vice versa — and both require
an OAuth kind. Like the desktop posture, these are registry data resolved at
wizard render time — they are never persisted and are never part of a
ConnectionRequirement; fields, scopes, hosts, and templates remain the row’s, always.
Absence semantics deliberately differ from the desktop posture: an absent web seat does
NOT refuse — the entry-level walkthrough serves the web surface too. A pinned web
walkthrough binds to the row’s endpoints, not to the provider’s name: the override
applies only when the row’s authorize and token URLs byte-match the registry pin, and a
row that merely carries a pinned provider’s name with endpoints of its own keeps its own
registration under the copy-only honesty rules — a reviewed walkthrough must never dress
a flow whose token exchange goes somewhere the registry never vouched for.
12.13 The connection_requirement directive
Section titled “12.13 The connection_requirement directive”Requirements reach the host from a build conversation as a connection_requirement
directive: { v: 1, kind: 'connection_requirement', requirement, confidence?, provenance? }. confidence and provenance on the wire are display-only: the host
computes provenance from the channel it actually received the directive on and recomputes
confidence from the ladder rung it resolved; no gating decision reads the claimed values.
A registry-resolved proposal MAY carry the entry’s alternative flows for the user to pick
between; the pick is reviewed like any declared requirement.
13. Credential custody
Section titled “13. Credential custody”Credential values live in snug_secrets and nowhere else:
| Key | Holds |
|---|---|
auth:<appId>:<slot>:<fieldKey> | one credential value |
auth:<appId>:<slot>:_connection | dynamic connection state for that slot (below) |
auth:_flow:<flowId> | in-flight authorization state (the slot rides in the payload) |
auth:_state_hmac | app-agnostic OAuth state-signing key |
The rules of §9 carry over verbatim: secrets exist in the local runtime copy, are stripped
from hub-origin pushes and default exports (VACUUMed so freed pages leak nothing), and
never enter localStorage/sessionStorage, any frame posted to an app iframe, or any hub
request.
The _connection state is not bookkeeping; four of its fields are security markers:
status: 'pending' | 'connected' | 'expired' | 'error'obtainedAt?, expiresIn?, scopesGranted?, lastError?lanPin?: { fingerprint, cn? } // TOFU leaf-cert pin; cn is diagnostic, never a trust inputlanVerifiedAt?: number // verify-before-claim marker — LAN familylinkVerifiedAt?: number // verify-before-claim marker — linked-device familyclaimVerifiedAt?: number // verify-before-claim marker — token-claim familyThe three verify markers are deliberately separate fields: each describes a different
proof about a different transport, and collapsing them would let a stale marker from one
family vouch for another. Absence of the marker on a connected row means pairing is
still owed — the wizard self-repairs rather than requiring a data migration.
Two rules specific to connections:
- Revocation wipes the slot’s credential slice (
auth:<appId>:<slot>:*) while keeping the row as a tombstone. - A requirement never contains a credential value. It contains field definitions. A host that finds a credential-shaped value inside a requirement rejects the requirement.
OAuth obligations. Authorization-code flows default to PKCE (S256); the state
parameter is HMAC-signed with auth:_state_hmac and verified constant-time; token,
refresh, and revoke POSTs are ceiling-checked like any other credentialed call; access
tokens refresh transparently inside a 60-second expiry skew. Every secret submitted in an
OAuth POST body (client_secret, refresh_token, code, code_verifier, token) joins
the scrub candidate set for that seat’s error handling (§14.3).
14. The connected-fetch executor
Section titled “14. The connected-fetch executor”One seat — and only one — both reads credential values and calls fetch. Everything an app-originated network request passes through is this ordered gate sequence, and the order is normative:
| # | Gate | Obligation |
|---|---|---|
| 1 | Shape | hand-written validation, fail closed; body on GET/HEAD rejected; body byte-capped |
| — | (resolution) | connection-relative URLs (§15) resolve here — translation only, grants nothing |
| 2 | Binding | the acting appId is HOST-assigned; the request carries no identity field |
| 3 | Grant | a connection row must exist with status = 'approved'; imported rows refuse with the distinct NET_IMPORTED_UNAPPROVED; the row must be the unique approved grant claiming this host — two claimants refuse (NET_AMBIGUOUS_CONNECTION), never tiebreak |
| 4 | Ceiling | https-only + exact-hostname membership in the frozen ceiling, punycode-normalized on both sides. One admission: http to an RFC-1918 IPv4 literal already inside the ceiling, under an explicit desktop transport policy; absent policy is byte-identical to the browser profile |
| 5 | SSRF guard | loopback, RFC-1918, link-local/metadata, CGNAT, IPv6 forms refused even for ceiling members; malformed fails closed. Exactly two classes stand this gate down: the LAN class of §12.11 (under the desktop policy) and the sidecar symbolic host of Part V — each stands down this gate only |
| 6 | Confirm | every mutating method (POST/PUT/PATCH/DELETE) requires the user’s confirmation naming host, method, and URL, before any credential is read. The confirm seat carries optional slot and body so a standing grant (§17) can decide on what is being sent; their absence on the wizard’s probe path is what keeps standing grants off probes |
| 6a | Local transports | a send to a local helper transport (Part V) departs here — after gate 6, never before. Transport shape never excuses consent: speech in the user’s name is gated by the confirm gate and nothing else. The helper path injects credentials itself and applies gates 7 and 10’s obligations; a missing helper transport refuses, it never falls back to the network |
| 7 | Strip | app-supplied credential-shaped headers are stripped (C1 belt to the schema’s braces) |
| 8 | Injection | credentials attach host-side per kind (template render / OAuth bearer), ceiling-checked internally |
| 9 | Fetch | redirect: 'manual' on every transport; any 30x is NET_REDIRECT_BLOCKED, never followed |
| 10 | Read | response read under the 1 MiB cap while reading (overflow → terminal NET_SIZE_EXCEEDED); injected credential values scrubbed from body and whitelisted header values, raw and percent-encoded; headers whitelist-filtered |
There is no strictness knob anywhere in this pipeline. Host-bound injection is always strict; a security property that can be disabled by configuration will be disabled in some deployment, and that deployment is the one that gets attacked.
14.1 Scrub honesty
Section titled “14.1 Scrub honesty”The value scrub is exact-substring over the values this request injected (raw and percent-encoded). A provider that re-encodes a credential (base64, hex, split fields) defeats it by design; the frozen ceiling — who can receive the secret at all — is the primary wall, and the scrub is a second line. Hosts MUST NOT present the scrub as more.
14.2 LLM-bound delivery is scrubbed harder than app-bound
Section titled “14.2 LLM-bound delivery is scrubbed harder than app-bound”The executor’s scrub is designed for app-bound delivery, where a resolved LAN address in a response body is the provider’s own data surface. When a result is bound for a model (the provider chat lane, §16), the host MUST additionally scrub every RFC-1918 IPv4 literal unconditionally — LLM-bound delivery exports the body to a third-party API, and a per-class rule would be one mis-wire away from leaking.
14.3 Provider error forwarding
Section titled “14.3 Provider error forwarding”When a provider’s error body must be shown to a human, a conforming host bounds volume
and shape — up to 160 chars, and only a named field of a recognized error envelope (or,
failing that, a best-effort head that is never markup/structure) — after value-scrubbing
with the candidate set only the calling seat can build: gate 10’s injected values at
the executor seat; the submitted form parameters at the OAuth seat. Order is scrub first,
extract second, and the extractor MUST re-scrub its own output — JSON.parse decodes
\u escapes and can reconstitute a correctly-scrubbed secret. The extractor is
explicitly not a credential guard; the value scrub is the control.
15. Connection-relative addressing
Section titled “15. Connection-relative addressing”An app can address its OWN declared connection by slot instead of by a host it cannot know:
snug-connection://<slot><pathAndQuery>Grammar (connection-url.ts): scheme match is case-insensitive and everything after is
exact; // is required; a path is required (apps address resources, not devices); the
slot must match CONNECTION_SLOT_RULE (imported, never restated); pathAndQuery may not
begin // and may not contain \ or #. The parse result is three-way — not a
connection URL (fall through to the literal-URL path untouched), malformed (refused
loudly, never guessed at), or ok.
Resolution happens after gate 1 and grants nothing: the slot selects the connection,
whose frozen ceiling must contain exactly one host (NET_AMBIGUOUS_CONNECTION
otherwise — a symbolic address must have one meaning); the URL is rebuilt against that
host by URL composition, never string concatenation; and the resolved host is re-checked
against the ceiling canonically. Refusals: unknown slot → NET_INVALID_REQUEST;
unapproved → NET_NOT_APPROVED. The resolved host is disclosed to the user (confirm
dialog) and never to the app — refusal messages are host-clean, and error-path text is
scrubbed of resolved forms.
This is the addressing mode every starter SHOULD use: installed apps never receive rebuilds, so a host baked into shipped HTML is a liability the slot indirection removes.
16. The provider chat lane
Section titled “16. The provider chat lane”A conforming host MAY offer a chat surface that composes provider requests on the user’s behalf (Part IV classifies its intents). Where it does:
- The turn’s context receives connection facts — slot, provider name, scope summary, symbolic or public host identity — never credentials, and never a resolved LAN address (LAN rows render symbolically; dotted-decimal RFC-1918 literals are scrubbed from rendered context).
- Requests the model composes execute through the unchanged executor of §14 — same ceiling, same confirm gate, same injection, same scrub. The model is a request author, never a grant author: zero ceiling matches means refusal plus a connect CTA, exactly as it does for app code.
- Mutating-call confirms MAY render inline in the chat surface, but an inline card is presentation, not authority: a card’s resolution becomes an ordinary user message, and the only approval seat remains the executor’s confirm gate. Concurrent confirms queue FIFO — a second confirm MUST NOT orphan the first’s resolver — and an aborted turn denies its own parked confirms by reference identity, so deny-after-decide is a no-op.
- LLM-bound results are scrubbed per §14.2.
17. Standing approvals — PROVISIONAL
Section titled “17. Standing approvals — PROVISIONAL”Status: provisional. The gate contract below is normative for any host that offers standing approvals; the arming channel is deliberately unspecified (the frame vocabulary has no seat for it, and minting one is a future wire revision), and the reference implementation’s grant store is in-memory (a reload disarms). This section pins the shape so implementers do not invent worse ones; it does not claim the surface is finished.
A standing approval is a pre-recorded answer to the confirm gate for a narrow, frozen scope — the mechanism behind “armed auto-reply”. Rules:
- Arming is an explicit user gesture on a host surface, never an app or model act.
- Scope is frozen at arm time: one connection slot + one target (e.g. one thread) + one trigger class. Widening requires disarm + re-arm; no request can talk its way wider.
- The standing gate is a separate gate consulted BEFORE the session confirm gate, and it wraps that gate rather than widening it — the session gate’s key (app, host, method) cannot tell one thread from another, so reusing it would turn one remembered send into a blanket approval. Anything outside the frozen scope returns no opinion and falls through to the ordinary confirm; the refusal to decide is never itself an approval.
- The target is derived from the request with two independent sources that MUST agree (e.g. path segment and body field). Trusting either alone is a vulnerability with two spellings; disagreement is refused, never resolved.
- Guardrails ride the grant and are host-enforced: a rate cap over a rolling window (the send is recorded before the grant answers, or the cap does not hold), quiet hours, a kill switch, and — v1 — one armed target at a time.
- Every unattended act is journaled and the armed state is disclosed wherever the connection is disclosed.
Armed is a recorded answer, not a bypass.