docs GitHub

Architecture

ADR 0028: The Vault everywhere

Status: proposed, 27 Sep 2026 · Workstream: vault-next · Extends: ADR 0001, 0006 and 0010 · Depends on: ADR 0004 (presence), ADR 0018 (mobile), ADR 0012 (the CDP proxy), ADR 0025 (planner)

Why

The user wants to cancel their password manager. Four things stand in the way. The Vault is empty on day one. It fills only in Chrome and the Capsule. Agents can use an item only through a grant to a whole module. Watchtower finds weak and breached passwords, but nothing tells the person to fix them. The user's words for autofill are "invisible and everywhere":

  • Every fill goes through the operating system's own autofill UI. Vyre adds no popup of its own.
  • A fill costs one Face ID or Touch ID tap, or none while a presence window is fresh.
  • Nothing leaves the box or the device unencrypted.
  • Agents never see plaintext.
  • Every fill is logged.

What already exists

These were built under ADRs 0001, 0006 and 0010 and are not rebuilt here:

  • Import from .env, 1Password CSV and .1pux, Bitwarden CSV and JSON, Chrome CSV and Safari CSV (core/vault/import.js). Duplicates are found by name only.
  • Watchtower (health.js), which marks items weak, reused, old, rotate or 2fa-available. It also runs the opt-in k-anonymity breach check against the HIBP range API.
  • The fill listener, pairing, sessions and the Chrome MV3 extension (fill.js, modules/vault-extension/).
  • The Capsule's native fill (native.js, mac/type.swift).
  • Presence sessions: 30 minutes at most and 5 minutes idle, for reveal, copy and totp (core/presence).
  • Multi-device join, which seals the account keyset to a new device (devices.js), and item sync over /v1/sync.

Threat model

These are added to the lists in ADR 0001, 0006 and 0010.

Defended:

  • An agent reading a login it was lent. An agent grant lets the agent use a login. It never lets the agent read one. vyred puts the value into the agent's computer itself (decision 3), and no tool returns it to an agent.
  • An agent using a grant somewhere else. A grant names exactly one agent, one login and one origin (scheme, host and port). vyred checks the origin of the page it is filling, on its own CDP connection, and ignores whatever the agent claims the origin is.
  • An agent that planted code in the page, or recorded the network, before the fill. vyred fills in a fresh browser context: no service workers, no extensions and no scripts from the agent. It drops the agent's CDP connections for the length of the fill, so Network and Runtime cannot watch the request that carries the password. The agent gets the page back after the login has navigated away.
  • A phishing page in any app. Every platform matches exact origins, as ADR 0010 does.
    • iOS and macOS pass the service identifier (the domain or URL) to the provider, and the provider refuses anything that doesn't match exactly.
    • Android passes the web domain from the view structure, or the app's package plus its signing certificate. A native app fills a login only when the login lists that app, as meta.apps does for the Capsule. Android package names are matched together with their certificate digest (android:<pkg>@<sha256>), because a package name alone is spoofable.
  • A stolen, unlocked phone. Its replica of the vault opens only through a Secure Enclave or StrongBox key that needs Face ID or a fingerprint. The presence window closes when the phone locks.
  • A copied import file lingering. The file is read by vyred and never copied. Preview returns names and counts only. The result tells the person to delete the file.
  • The breach check leaking a password. Only the first five hex characters of a SHA-1 go out, with Add-Padding: true. The check stays opt-in.
  • Logs. Every fill, agent use, grant, revoke, import and reminder is an audit row and an event carrying names, origins and counts. Neither ever carries a value, a hash of a value, a length or a username.

Not defended, stated plainly:

  • The operating system's autofill store holds usernames. For a login to appear in the QuickType bar or the Android suggestion strip, the OS must know (domain, username) before anyone unlocks. This is the one place a username sits outside the seal. It stays on the device, in storage the OS protects. The setting vault.autofill.identities: "usernames" | "names" shows the item name in place of the username, and the default is usernames. Passwords are never put in the OS store.
  • An agent with a shell in its own computer. It could read the browser's memory while a fill is in flight, or the cookies afterwards. Cookies are the point of lending a login. For memory, decision 3 needs computerd to run Chrome as a different uid from the agent's exec user, with ptrace blocked. A lent login is a trust decision per site. When a site has an API, a relayed pass is the better tool.
  • The site itself. A compromised origin can read what it is given, as in ADR 0010.
  • An extension that is not Vyre's. It runs in the same page as the password.

Decisions

1. Import: a preview, duplicates by content, and Apple Passwords

  • vault.import.preview {file, format?} goes through the same presence as import, because it opens the file. It reads and parses the file and returns:

    { format, token, counts: {login, note, card, secret, api-key},
      add: [name], same: [name], conflicts: [{name, existing}], renamed: [{from, to}], skipped: [reason] }
    

    token binds the preview to the file's SHA-256 and its size. vault.import {file, token?, conflicts?: "skip"|"update"} refuses a token that no longer matches the file. What gets imported is exactly what was previewed.

  • Duplicates are judged by content, not only by name. vyred opens the existing logins, since the personal vault is open during the import presence. It keys each login on its origin plus its username.

    • same: the origin, username and password all match. The row is skipped silently.
    • conflicts: the origin and username match but the password differs. The default is to skip. update makes a new version, so the old password stays in history.
    • A different login whose name is already taken gets -2, -3 and so on, listed under renamed.
  • Apple Passwords (macOS 15 and iOS 18) exports Title,URL,Username,Password,Notes,OTPAuth, the same header as Safari. The format is reported as apple-csv, and safari-csv stays as an alias.

  • .env files, one or a folder of them, go through the same two tools. Each file is one env-set named after its path, holding only the variables detect.js calls secret; plain config stays in the file. The preview adds files: [{file, item, state, vars: [{key, secret, type, provider?, mode?, public?, expires?}], kept, git?: {tracked, ignored}}] and templates. A type and a provider are words from a fixed list, never a slice of the value. The token covers every file's path and bytes. vault.import {rewrite: true} then replaces each stored variable's line with KEY=vault://item/KEY, only in files whose values are all in the vault, atomically, with no backup. vyre run -- cmd reads the result.

  • Surfaces: the CLI (vyre vault import --preview, --rewrite) and the Deck's import sheet (the pwa team's surface). Both call these tools.

  • Sources beyond 1Password, Bitwarden, Chrome and Apple (core/vault/import-more.js): LastPass, Dashlane, Keeper, NordPass, Proton Pass, Enpass, KeePass/KeePassXC XML and CSV, Firefox, and the Chromium browsers' CSV. Encrypted exports (a .kdbx, a PGP Proton export) are refused with how to export again. The XML reader refuses any DOCTYPE, so no entity can expand.

1b. Typed credentials

  • Kinds (core/vault/kinds.js, the one list every surface reads): login, authenticator, passkey, card, address, identity, note, api-key, pat, oauth, cloud, db-url, secret, env-set, ssh-key, cert, recovery-codes, wifi, license, file. Each kind names the fields it needs and the field it hands over when a reference names none (vault://kit-github is the PAT's token).
  • Personal kinds (login, authenticator, passkey, card, address, identity, note, recovery-codes, wifi, license) start in the personal vault once there is an account. A grant still moves an item to the agent vault, as before.
  • A passkey, like an ssh-key, is never released, injected, revealed or copied. It signs inside vyred (decision 7, later).
  • details is a new column: expires, scope, provider, issuer, ssid, product, filename, count, rp. Each has a fixed shape and nothing else is accepted. Details are listable and never decide where a value may go, so they are neither sealed nor MACed. A module that edits vyre.db can hide an expiry reminder and no more. Some details are derived from the fields: a certificate's end date, an authenticator's issuer, the count of recovery codes, a network name. Details the caller leaves out are kept across puts, and they ride in backups.
  • Watchtower adds expired and expiring (within 14 days). Reuse also counts the value a typed credential hands over. Decision 4's daily job turns both into planner todos.

1c. The authenticator

  • vault.codes {names?} (people's surfaces, presence, rides the 30-minute window) returns every item with a seed: {name, kind, issuer?, code, next, period, remaining, digits}. Never a seed. vault.totp adds next. The client counts down from remaining and asks again at the rollover, only while the list is on screen (principle 8).
  • vault.codes.import {uris, preview?} takes scanned text: Google Authenticator's otpauth-migration://offline parts (protobuf, decoded in core/vault/otpmigration.js) and otpauth://totp/ links. Parts of one export share a batch id; the import refuses until every part is there and says which to scan. A seed already in the vault (compared by algorithm, digits, period and bytes) is same. New accounts are authenticator items named after the issuer and label. The QR image is read on the client (camera, BarcodeDetector, Vision), so vyred only sees text, and Claude never does: the tool is not offered to mcp.

2. Agent logins: one agent, one login, one origin

A new table, MACed like the other grant rows:

vault_agent_grants(id, item, agent, origin, expires, by, at, revoked, mac, UNIQUE(item, agent, origin))
  • vault.agent.grant {agent, item, origin, expires?} needs presence, with the summary "Let kit sign in to https://app.northwind.test as harlow-drive until 1 Oct". From mcp it waits as pending until vault.approve, like vault.grant. The origin must be one of the login's hosts.
  • vault.agent.grants {agent?, item?} lists grants, active and revoked, with the last use and a use count. vault.agent.revoke {id} needs no presence, since taking access away never does.
  • vault.uses {item?, agent?, since?, limit?} is the log of every use. It is built from audit rows: when, which item, which agent or device, which origin, which surface (ios | android | mac | chrome | firefox | capsule | computer), and the outcome. It is shown on the Deck (item pane: "Used by", and a Grants place in the rail) and in the phone app.
  • An agent uses a grant only through vault.agent.fill (decision 3). There is no agent tool that returns a login field.
  • The existing module grants (vault.grant to agents with needs.vault: ["per-agent"]) stay for API keys handed to an agent's environment. Agent logins are the new path for anything a browser signs into.

3. Filling an agent's computer

vault.agent.fill {item} is callable by a vouched agent caller (agent:<name>, checked by threads.vouch). It fills the agent's own computer and never another agent's. The steps:

  1. vyred checks for an active grant for (item, agent) and reads the origin it names.
  2. It asks computers (computers.fill.begin, internal, owned by the computers workstream) to raise the shield and close the agent's CDP sessions. vyred then gets a CDP endpoint of its own, through computerd with a short-lived token.
  3. It opens the grant's origin in a new browser context (Target.createBrowserContext), checks that the page's origin equals the grant's origin, and finds the username, password and one-time-code fields through the DOM.
  4. It sets each field with Input.insertText on the focused field, never through Runtime.evaluate with the value in the script, and submits.
  5. It waits for a navigation or 20 seconds, whichever comes first, then clears any password field left in the DOM and closes its CDP session.
  6. computers.fill.end lowers the shield and hands the context's target to the agent, whose cookies are now signed in.

The agent receives {filled: ["username","password"], origin, navigated}. Every step is audited. A fill refused because the origin differs says which origin it saw, never a field.

Needs from computers: computers.fill.begin/end (internal), Chrome under its own uid, ptrace blocked, and computerd closing agent CDP websockets on begin.

4. Rotation reminders through the planner

  • A daily job, kept light (principle 8): one run, the earliest after 09:00 local, never more often. It runs Watchtower. If vault.breach is weekly, it also runs the breach check once a week. ask stays the default, and the person turns on weekly from the Watchtower screen.

  • For every item that is breached, reused, old or rotate, and that has no open reminder, it calls planner.add:

    { kind: "todo", title: "Change the password for harlow-drive", list: "Vault",
      tags: ["vault", "rotate", "<reason>"], priority: breached ? 1 : 2, due: <today + 3d if breached, else + 14d> }
    

    It keeps the planner id in vault_marks so it never adds the same reminder twice. When more than 5 reminders are new in a run, it makes one todo ("Change 12 passwords: reused") whose body lists the names, so the planner stays usable.

  • A later put of the item marks the planner item done (planner.done). If the person dismisses the reminder, it is not raised again for the same reason until the reason changes.

  • The planner is reached through ctx.call. If planner.add does not exist yet, nothing happens, and Watchtower still shows the list.

4b. Built (27 Sep 2026)

  • The daily job is core/vault/remind.js. It is one setTimeout, re-armed after each run for the first 09:00 after the last one, or a minute from now when that has passed. It runs on the box. A Mac runs it only when it is not paired with a box (link.status), so nobody is told twice. Reasons that raise todos: expired, breached, expiring, rotate, reused, old. The marks live in vault_reminders(name, reason, planner, state), which is not MACed: a mark only silences a todo. vault.remind.run runs the pass now. The weekly breach check waits on an unattended way to open personal logins, so it still runs only when a person asks.
  • The leak sweep is vault.sweep {path, history?, shell?} (core/vault/sweep.js). It needs presence, because it opens every value. Values are compared in memory and nothing goes to a temp file. It returns places and names only, and never a line of context.
  • Rotation is vault.rotate {name} (core/vault/rotate.js, tools/rotate.js). It is automatic for AWS IAM keys (SigV4), GitLab PATs (self/rotate), Cloudflare user API tokens (roll) and Google Cloud service-account keys. Everything else is guided, with the provider's page and steps. The new credential is stored as a new version BEFORE the old one is revoked. vault.rotation {name} says which way an item rotates. Twilio stays guided: Standard API keys cannot manage keys.

4c. Decided by the lead (27 Sep 2026)

  • Rotation has only been tested against fake providers. Until it is proven against a real account, revoking the old key is the one step that asks for proof, with the new key's name shown. Storing the new key needs no proof. (Not built yet: today vault.rotate asks once for the whole rotation.)
  • References: vault://item/field is the only scheme. vyre:// stays for app deep links.
  • The iOS/macOS extension keeps its session token in the shared Keychain group for 30 minutes, cleared on device lock, matching the presence session.
  • Agent grants must name a project (ADR 0031): a nullable project column on vault_agent_grants, checked on every use, and revoked by project when a teammate is unshared. Not built yet.
  • The extension should sign each request with a paired key (e2e 628e2cd9: x-vyre-proof), and the key becomes required once it ships. Not built yet.

5. The presence window for fills

The standing rule is one proof for about 30 minutes on the Deck, the Capsule and the phone. The CLI gets a fresh proof every time, which polish-cli is building. Applied to fills:

  • A fill session opens with any presence proof: Touch ID, the Capsule's signature, a passkey, or a phone's device key (ADR 0018). It lasts 30 minutes from the proof. It does not extend with use, and it closes on sleep, on screen lock, or when the device locks. The ADR 0010 unlock passphrase is kept only for a browser on a machine with no Touch ID.
  • On a phone the window lives on the phone. The provider extension asks for Face ID when the window is closed. Inside the window it fills without asking, through provideCredentialWithoutUserInteraction.
  • An item marked reprompt (cards by default) always asks.

6. Autofill per platform

PlatformMechanismWhere the value comes fromThe one tap
iPhone, iPadVyreAutofill app extension (ASCredentialProviderViewController): passwords, one-time codes (iOS 18), passkeys (iOS 17, ASPasskeyCredentialRequest). ASCredentialIdentityStore holds (domain, username, recordIdentifier)A local replica of the personal vault on the phone, which joins as a full device through devices.js. The personal VK is wrapped by a Secure Enclave key with .biometryCurrentSet, in the app group container shared by the app and the extension. Sync is /v1/sync through the box, which holds only ciphertextFace ID in the system sheet, or none inside the window
AndroidAutofillService (every app and browser that supports autofill) plus CredentialProviderService (Android 14 and later: passwords and passkeys in Credential Manager)The same replica, with the VK wrapped by a StrongBox or TEE key that requires biometricsBiometricPrompt, or none inside the window
Mac: Safari and native appsA credential provider app extension (ASCredentialProviderViewController, macOS 14 and later) inside the native Capsule app, target sh.vyre.capsule.autofillThe local vyred, over the loopback fill listener. The device token is in the Capsule's keychain access groupTouch ID in the system sheet, or none inside the window
Mac: Capsule ⌘KThe existing vault.fill.nativeThe local vyredThe Capsule's signed presence
Chrome, Arc, Edge, BraveThe MV3 extension. Chrome gives extensions no access to its own autofill dropdown, so a small inline chooser in a closed shadow root is the one Vyre UIThe local vyred, or the box's fill listener behind tailscale serve, for agent-vault items onlyTouch ID through the Capsule helper, or none inside the window
FirefoxThe same extension with browser_specific_settings. The worker becomes an event pageSameSame
Agent computersDecision 3vyredNone: the grant is the person's consent

Passkeys are a new passkey kind: a P-256 private key, the RP id, the user handle and the credential id, all sealed. The device whose vault is open makes the assertion. The key can be exported so it syncs across the person's devices, as synced passkeys do everywhere else. The browser extension's passkey support (a MAIN-world navigator.credentials shim) comes last.

Signing is a blocker. Credential provider extensions on iOS and macOS need the AutoFill Credential Provider entitlement, which comes with a provisioning profile from a paid Apple Developer team. The Capsule is ad-hoc signed today, and the Developer ID is about a month away. Until then the iOS extension is built and tested in the simulator on GitHub Actions, and the macOS extension is built but not installed. Android has no such gate.

7. Passkeys in vyred and the extension (built 27 Sep 2026)

  • core/vault/webauthn.js is the authenticator. It supports ES256 only, attestation: "none" with the Vyre AAGUID 9700b56e-127f-445c-a7ca-560431cc2b48, and the flags UP, UV, BE and BS (a synced passkey, so the counter stays 0). rpIdAllowed() refuses public suffixes, IP literals, http off localhost, and any rpId the origin may not claim.
  • The fill listener gains passkeys (a device token, names only), passkey.create and passkey.get (a device token plus a live session, the same window as a password fill). The worker sends the frame's origin from the sender. A new passkey is a passkey item named after the rpId and the account, with details.rp and details.credential listable. The private key never leaves vyred: release, inject, reveal and copy all refuse it.
  • The extension (passkey-page.js in the MAIN world, passkey-bridge.js isolated) takes over only publicKey create/get that Vyre can serve. It asks in a closed shadow root and acts only on trusted clicks. It falls back to the browser's own authenticator when not paired, unreachable, asked to, under conditional mediation, or when the parent's permissions policy forbids it. Firefox 128 or later.
  • Still to come: conditional mediation (Vyre passkeys in the browser's autofill list), the prf/largeBlob extensions, the Android Credential Manager provider, and the iOS/macOS providers (simulator and CI only until there is an Apple Developer team).

8. Emergency access (built 27 Sep 2026)

A verified contact can reach the owner's items after a waiting period the owner can stop. core/vault/emergency.js holds both sides.

  • vault.emergency.add {person, wait?, items?}, person present. The person must be pinned and verified by fingerprint in share.js; a card that was only pinned, or whose key changed, is refused. The wait is 1d to 30d, 7d by default. Items default to every agent and personal item except ssh-key and passkey.
  • The vault builds the sealed-pass ticket for that person with the same code a sealed pass uses (Vault.ticketFor, factored out of issue), then escrows it: the ticket bytes under a fresh random 32-byte key with AES-256-GCM (aad vyre:emergency:v1:<id>) in <vault>/emergency/<id>.bin, mode 0600, and the key sealed in the agent vault as an internal record (emk_<id>, bound to the grant's MACed refresh time), not an item. The box alone holds ciphertext sealed to the contact's key; the contact alone has no ticket.
  • vault_emergency(id, person, wait_ms, items, created, refreshed, requested, denied, released, removed, mac) is MACed like the other grant rows.
  • Refresh: vault.emergency.refresh (person present), and on every account unlock for grants not refreshed in the last day and not yet released. A refresh never changes the request state.
  • The contact runs vault.emergency.request {owner} and vault.emergency.status {owner}, both person present. Each sends an envelope signed with the contact's device key under the tag vyre:emergency:v1 (audience, timestamp and nonce, as a sync envelope) to the owner's relay listener at POST /v1/emergency.
  • The listener finds the sender by the signing key among verified people with live emergency access; anyone else gets the unknown-pass refusal and at most one audit row a minute. A request with none open sets requested, emits vault.emergency-requested {person, opens}, writes an audit row and adds a planner todo in the Vault list. A status after requested + wait, not denied or removed, opens the escrow, checks the ticket is for the sender's key, sets released, and returns the ticket; before that it returns the state and when it opens. The contact's side accepts the ticket through accept(), so the items land as a sealed pass's do.
  • vault.emergency.deny (closes the request, or a release, and sets denied), vault.emergency.remove (deletes the escrow and the key record) and vault.emergency.list need no presence: taking access away never does. vault.offboard removes emergency access too.
  • Audit rows, events and todos carry names, the wait and dates. Never a value, the escrow key or the ticket.

9. Connections: every key goes in through the Vault (27 Sep 2026)

The user: every key the system needs, at any point, is set up through the Vault, and one place, "Vault, Connections", shows every connection and which surface may use it. Today the Capsule says "Run: vyre voice key", each module has its own way in, and nothing lists a person's four email accounts side by side.

9a. A module says what it needs; it never asks for a key itself. A manifest adds needs.credentials, a list of {id, kind, provider, purpose, item?, optional?, group?}. kind is a kinds.js kind, provider a name in the provider catalog (core/vault/providers.js), item the vault item it fetches (default <module>-<id>), and group joins alternatives (voice: one of Deepgram, OpenAI or ElevenLabs). The module validator checks the shape; ctx.vault.fetch accepts these items as it accepts needs.vault names.

  • vault.need {module?} (people's surfaces, no presence) returns each need with its state: ready, missing, not_granted, pending (an mcp grant waiting), expired, and how to fill it: {how: "field" | "file" | "oauth", fields: [{name, label, secret}], help, next?}. It never returns a value. A group is ready when any member is.
  • vault.connect {module, need, fields? | file? , label?} (people's surfaces, presence) checks the fields against the catalog, puts the item with its kind and details.provider, grants it to the module, and records the connection (9b). how: "file" takes a dropped service-account JSON; how: "oauth" returns next: {tool, input} (for Google, google.connect), since the module that owns the flow runs it. The Capsule shows a secure inline field, the Deck and the phone a sheet, the CLI a hidden prompt (vyre vault connect voice). All four call the same two tools. vyre voice key stays, as a shortcut for vault.connect {module: "voice"}.

9b. A connection is a provider, an account, an auth kind, capabilities and surfaces. Table vault_connections(id, source, ref, provider, account, auth, label, capabilities, surfaces, added, updated, mac), MACed like the grant rows, unique on (source, ref). A row whose MAC fails is granted to no surface. Ids are cn_ and base64url, stable across upserts. Rows come in two ways, and both stay:

  • vault: an item made by vault.connect or put with a catalog provider (API keys, PATs, IMAP and SMTP logins, Apps Script web-app tokens). The vault writes these itself and resyncs them on vault.connected and on its own put and delete events. Nothing polls.
  • google and mcp, read by the vault through their own read-only tools (google.accounts; mcp.servers with the cached mcp.tools), never their tables, and resynced on their events (google.added, google.removed, google.connected, mcp.added, mcp.updated, mcp.removed, mcp.refreshed) and on first read after start. ref is the account name or the server name. A row leaves when its account or server does; a module that is not running is an empty source.
  • A module's own rows. vault.connections.register {ref, provider, account, auth, label?, capabilities? | tools?, items?, use?} (module callers only) upserts on (source, ref), where source is the calling module's name from its caller label, never from the input, and returns {id}. auth is one of oauth, service-account, api-key, password, bearer, none. With tools and no capabilities, capabilities come from the tool names by a small pattern table (gmail_send sends mail; search_threads reads mail on a mail server; list_events is calendar). vault.connections.unregister {ref} removes one of the caller's own rows. A registered row is the module's: a sync of the same source neither changes nor removes it. Two Gmail servers are two rows either way.
  • items names the vault items a row signs in with. A row whose items are not all there and granted to its module has state needs_credential, with needs: [{module, need}] from that module's manifest. An item a module row claims is not listed again as a vault row.

Capabilities are send_mail, read_mail, calendar, files, send_message, speech, llm, search and other; a person may change them and the label (vault.connections.update), and the change survives every resync and re-register. use is a map from capability to {tool, input}: what a module passed, the catalog for vault rows, and for send_mail and read_mail with nothing else, mail.send and mail.search with {account: <connection id>}.

  • Surfaces: capsule, chat, agents, phone. A new connection is granted to capsule and chat; agents is opt-in. The caller decides the surface: capsule, mobile (phone), mcp (chat), mcp:thread:<id> (by the thread's origin from threads.get: capsule is the capsule, one ending :phone the phone, anything else chat), mcp:agent:<n> and tailnet:agent:<n> (agents). cli, local, deck and the owner's own device at the box's tailnet address are the person at a settings screen and see everything.
  • vault.connections.list {capability?, surface?, caller?} (people's surfaces, mcp and modules) returns only the rows granted to the caller's surface, each with uses, and with a capability also use, that one entry. A module must pass surface, or caller (the caller it acts for). So "send an email" in the Capsule offers every account that can send, and Claude in a chat thread sees the same list. Never a value, a token or a field name that holds one. vault.connections.get {id} (modules only) is one row's metadata for the module that acts on it: a row of its own source, or one whose use names one of its tools (so mail reads a row that routes to mail.send, as mail.release does after the Gate approved). Any other row, a row that fails its check and an id that is not there all answer the same not_found.
  • vault.connections.grant {id, surface} (presence), vault.connections.revoke {id, surface} (no presence: taking access away never needs it), vault.connections.sync (people).
  • vault.connections.allowed {id} | {source, ref}, caller (modules only) returns {allowed, surface, reason?}: the check a module makes before it acts on a connection. People are always allowed; a module acting as itself is not a surface and must pass the caller it acts for. A send is still held at the Gate as before; the grant decides who may ask.
  • A module that cannot act because a credential is missing answers with one shape, {code: "needs_credential", message, detail: {module, need, account?}} (the kernel helper core/modules/needs-credential.js), so every surface offers the same fix, vault.connect.
  • The account picker: each row carries default, the capabilities it is the default for (one default per capability; vault.connections.update {id, default_for}, a person's surfaces only, no presence since it changes no access), and last_used, set when allowed says yes, at most once a minute per row. Both only rank rows and sit outside the MAC. With a capability, list adds is_default and sorts default first, then most recently used, then by label. The first time a capability has two or more ready connections and no default, that same call answers suggest_default: true too - once ever for that capability (vault_default_asked), not once per surface, so the Capsule, the Deck and chat never each ask their own copy of the question.
  • Events: vault.connection-added {id, source, provider, account}, vault.connection-removed {id}, vault.connection-changed {id, fields}.
  • A need may take several accounts: multiple: true in needs.credentials, and vault.connect then takes a label and names the item <module>-<label> (mail-northwind, say).

9c. IMAP and SMTP. The connectors team owns a module mail (core/mail, under ADR 0016) with mail.test, mail.search, mail.read, mail.send and mail.release over IMAP4rev1 and SMTP (TLS or STARTTLS, AUTH PLAIN or LOGIN), with no new dependency. A login is an env-set item of provider imap-smtp granted to mail, declared as the need {id: "imap", kind: "env-set", provider: "imap-smtp", multiple: true}, and an Apps Script web app as {id: "apps-script", kind: "env-set", provider: "google-apps-script", multiple: true}. The mail module registers nothing: each login is a vault row. The mail tools take account, a connection id of any source whose capabilities include mail; they ask vault.connections.allowed first. mail.send offers mail:<account> to the Gate and releases through mail.release, as google does. Tests run against fake IMAP and SMTP servers only.

9d. What is not in this step. Apps Script web apps are saved and listed (url and token, capabilities the person picks) but have no adapter yet: each script's shape is its own. OAuth for providers other than Google waits for a module that runs the flow. The Deck and phone sheets are pwa's and mobile's; the Capsule's inline field is capsule-pro's; the settings hub entry "Vault, Connections" is native-core's.

Order of work

  1. This ADR.
  2. Import: preview, duplicates by content, apple-csv, and the CLI.
  3. Agent grants, vault.uses, and the Deck rows through pwa.
  4. Rotation reminders through the planner.
  5. The fill window (decision 5) and the Firefox and Arc build of the extension.
  6. Filling agent computers, with the computers workstream.
  7. The Android AutofillService and CredentialProviderService, built on GitHub Actions.
  8. The iOS extension, with mobile, built on GitHub Actions.
  9. The macOS extension, with capsule-pro, after the Apple team is in place.
  10. Passkeys.
  11. Connections (decision 9): the needs contract, the connections table and tools, the mail module, then the surfaces through their owners.

Consequences

  • The Vault is full on day one, and the person sees exactly what will change before it changes.
  • An agent can sign in to one site as the person without ever holding the password. The person can see every such use and end it in one click.
  • Autofill on phones needs the phone to hold the personal vault, encrypted, which is what every password manager does. The box still cannot open a personal login.
  • iOS and macOS autofill cannot ship until the Apple team exists.