Skip to content

Varlatch Threat Model

Consolidated from Varlatch’s design decisions and readable on its own. Honesty is the design principle: every strong claim below either maps to an automated test, an integration/E2E check, or a deployment invariant, or it explicitly states why the property cannot be enforced. Claims carry one of three statuses:

  • SHIPPED GUARANTEE: implemented and verified by tests/E2E/deployment invariants.
  • DESIGNED INVARIANT: an architectural rule the implementation follows and enforces where it applies today.
  • DESIGNED / NOT SHIPPED, NOT CURRENTLY GUARANTEED: planned architecture; no protection exists yet.

Plaintext configuration Values and Secrets; the Root KEK; Organization KEKs and per-version DEKs; Varlatch credentials (service/CLI/browser bearers, setup/invite grants, Better Auth sessions and passkeys); authorization state (identities, memberships, Grants, Requirements, Contracts); the Security Audit history; the JWT signing key.

Zone Members Trust
Infrastructure Operator Anyone with host/container exec, volume/env access, Secret Plane DB credentials, the running image, or the KEK (includes Coolify terminal access and the Convex admin key holder) Above the application’s enforcement. Varlatch makes operator actions explicit and audited; it never claims to prevent them.
Secret Plane (varlatchd) The daemon, its DB schema, the KEK in memory Trusted. Its compromise may expose all secrets.
Application Plane (Convex, varlatch-web static assets) Mirrors, product metadata, dashboard serving Untrusted for authority: can break UI, never authorization.
Authenticated Identities Humans (installation-level), machine identities (org-scoped) Only what Grants + roles allow, per request.
Network peers Public internet, tailnet peers Nothing by presence. Tailnet identity is an additional constraint or an explicitly enrolled authentication method, never ambient authority.
AI agents / untrusted subprocesses Anything varlatch run spawns Untrusted. In agent-safe mode (§6) an Agent Run holds only placeholders and no Varlatch credential; plain varlatch run still injects plaintext (§5).
Local Broker The loopback proxy varlatch run --agent-safe starts in the trusted parent Trusted for mediation only: it holds the broker credential and sees plaintext at the substitution boundary, but secret.use authority belongs to the Agent Identity and is evaluated by varlatchd per exercise.
  1. Application Plane ↔ Secret Plane. Convex holds no plaintext, no key material, no authorization state: only one-way Mirrors. Convex cannot mint anything varlatchd accepts; varlatchd never trusts Convex’s word about a caller.
  2. Database roles. varlatchd_runtime (no DDL; INSERT/SELECT-only on audit), varlatchd_migrate (elevated, one-shot job only), convex (own database, zero access to varlatch). Superuser = Infrastructure Operator. On Coolify this separation does not currently hold: Coolify passes every stack environment variable to every service, so runtime varlatchd also receives the migrate and superuser passwords (#28).
  3. Bearer-only /v1. Cookies authenticate only the /auth ceremony surface; the browser exchanges its httpOnly session for a short-lived in-memory bearer.
  4. Ordinary listener vs tailnet listener. Trusted Tailnet Context can only be constructed on the dedicated tailnet listener from the true socket peer via WhoIs; headers, forwarded IPs, and CGNAT-range sources never create it. The tailnet listener is never reverse-proxied. Its optional browser endpoint (HTTPS, off by default) terminates TLS inside varlatchd with the node’s own certificate, so the same socket peer reaches WhoIs, and never resumes a TLS session, so every connection is checked for the name and a valid certificate; it answers only the node’s name, lets only exactly allowlisted origins read cross-origin, and only on the value-read routes, and cookies never authenticate there.
  5. HumanAuth module boundary. Better Auth answers “who is this human?”; its user IDs never leave the module (auth_user_links maps to Varlatch Identities). It holds no authorization authority.

Each maps to its verification. Unit/integration tests run on every push; “clean-room E2E” = scripts/ci-e2e.sh in CI (fresh stack, virtual authenticator).

Claim Status Verified by
Secret Plane DB/backup/disk theft without the KEK yields ciphertext only: per-version DEKs wrapped by per-org KEKs wrapped by the Root KEK; the unwrapped Root KEK is never stored in any database SHIPPED GUARANTEE test/crypto.test.ts round-trip/wrong-key; deployment invariant: KEK is a mounted file only
Ciphertext cannot be transplanted across org/secret/version rows or reused across purposes (AAD binding + purpose discriminators) SHIPPED GUARANTEE test/crypto.test.ts transplant/purpose cases
A wrong KEK is refused at startup and by /readyz (canary verification), and admin kek verify proves backup possession without exposing key material SHIPPED GUARANTEE test/bootstrap-auth.test.ts, test/http.test.ts readyz; live smoke
Root-KEK rewrapping primitive preserves value ciphertext; an operator rotation command with canary/file transition is not implemented TESTED PRIMITIVE, WORKFLOW PLANNED test/crypto.test.ts rotation case
Authorization is default-deny; Grants union additively; the fixed Member bundle denies production value access; Requirements restrict and never grant; admins do not bypass Requirements SHIPPED GUARANTEE test/authz.test.ts (14 evaluator cases)
Org scope derives from authenticated identity + memberships; outsiders get not-found-equivalent responses; machine identities act only inside their own org SHIPPED GUARANTEE test/http.test.ts authorization cases
Tailnet-constrained retrieval fails closed: no Tailnet Context, wrong tag, a foreign tailnet, a device shared into the tailnet (WhoIs Sharer), or the Varlatch node itself ⇒ deny with a diagnosable code; the ordinary listener can never satisfy a Tailnet Requirement; a stolen credential used off-tailnet cannot retrieve constrained resources while the Requirement is in force (a credential that may manage policy can change or remove the Requirement itself: section 5) SHIPPED GUARANTEE test/tailnet.test.ts end-to-end; whois client tailnet-pinning tests; test/tailnet-browser-endpoint.test.ts (browser endpoint over TLS, device from the socket peer, origin and route allowlist, spoofed headers); live smoke
Application Plane JWTs (ES256, 10-min, audience-scoped) are never accepted back as Secret Plane credentials; Convex verifies via JWKS and cannot mint authority SHIPPED GUARANTEE test/jwt.test.ts one-way-trust case
Convex mirror writes require varlatchd’s dedicated mirror identity; mirror reads are scoped by verified token claims SHIPPED GUARANTEE Live verification of mirror auth (foreign-org/anonymous ⇒ empty)
The Convex admin key (and the instance secret it derives from) exists only in deploy-only custody, never in runtime varlatchd SHIPPED on plain Compose; on Coolify only with file-based secrets (#28) Compose per-service environment: scoping and per-service secret files (test-backup-compose.mjs pins each service’s secrets; test-prod-shape.mjs checks a Coolify-shaped installation with file secrets holds none of the other services’ secrets in runtime varlatchd). Coolify passes every variable to every service, so installations still setting secrets as Coolify variables, including CONVEX_ADMIN_KEY/CONVEX_INSTANCE_SECRET, do not hold this until moved to files
Security Audit Events commit transactionally with mutations and before disclosure; the runtime DB role cannot UPDATE/DELETE audit rows SHIPPED GUARANTEE Domain tests; migration 0003 grants; proven live against PostgreSQL (UPDATE/DELETE audit_events ⇒ permission denied)
Each retrieval (Effective Configuration, disclosure, validation, Capability exercise) reads everything from one read-only database snapshot: a value write, a parent write, a Contract activation, a rotation, or a Grant change that commits during the request is invisible to it, never half-applied SHIPPED GUARANTEE test/retrieval-consistency.test.ts (real PostgreSQL, writes injected between a request’s reads), test/retrieval-audit-order.test.ts
Every retrieval decryption is preceded by a committed audit event naming its exact version, including values decrypted only to expand references (audited one reference level at a time); a failed audit commit stops the request before that decryption, and a failed decryption leaves the audit event as the record of the attempt SHIPPED GUARANTEE test/retrieval-audit-order.test.ts (traced connection: no read after the snapshot, injected commit and decryption failures)
Server-side sync delivery audits before it decrypts: each reconcile commits sync.values_decrypted naming the exact versions before decrypting them, even when it then pushes nothing; a failed commit stops the reconcile before any decryption or push SHIPPED GUARANTEE test/sync.test.ts (traced connection, injected audit failure)
The state manifest and stateDigest contain identifiers only (environment, contract revision and semantics version, item sources and version IDs), never plaintext or anything derived from it; the digest is identical for callers with different Grants on the same state, and changes with any captured version, source, rotation window, or contract. A strict retrieval returns values, manifest, and verdicts from one snapshot, or an error with no values SHIPPED GUARANTEE test/strict-retrieval.test.ts, test/retrieval-consistency.test.ts (real PostgreSQL)
Strict startup (varlatch run --strict) starts the command only if the environment it would receive satisfies the snapshot’s active Contract at a semantics version the CLI implements: no missing, withheld, parent-only, invalid, or unresolved-reference item, no required item left out with --omit, an active Contract, and contract.read; otherwise it starts nothing and exits 78. Defaults fill only items with no stored value, never withheld ones; parent values are used only for names allowed with --allow-inherited, and are validated; an item named with --omit reaches the command neither delivered nor inherited SHIPPED GUARANTEE apps/cli/test/strictRun.test.ts; clean-room e2e-strict-run.mjs (bundled CLI against a live server)
Agent-safe strict startup (varlatch run --strict --agent-safe): Secret verdicts are computed only in the operator’s preflight retrieval and only when the operator holds secret.reveal (never on secret.use alone, never for the Broker), after an audit event recording the attempt and purpose with no verdicts; the Broker’s issuance decrypts nothing, is bound to the preflight’s state by a digest that confers no authority, and names only the categories that changed on a mismatch; a Contract Secret present only in the parent environment is a violation, and --allow-inherited cannot name a Secret SHIPPED GUARANTEE test/agent-safe-preflight.test.ts, apps/cli/test/strictRun.test.ts; clean-room e2e-strict-run.mjs
Type generation fetches no configuration values, and the Typed Accessor’s diagnostics contain no values. varlatch types makes one request, for a Contract Revision (contract.read), and writes a module of Contract metadata only (names, types, descriptions, examples, defaults, enum values, and the environment IDs of conditions); it rewrites the file only when its content changes, never writes through a symbolic link, and cleans Contract text so it cannot end a comment or hide in one. The accessor reads process.env and never writes to it or adds a variable; it converts with the revision’s semantics version under the shared golden vectors; any problem throws one error naming items and reasons, never values, and an item that failed never reads as undefined; its object is read-only in strict and sloppy code. It applies no default unless asked, never after a strict run, and never to an item the run context records as withheld. The run context (--strict or --export-context) holds names and identifiers only. An exported context describes the same server state as the values the run delivers: --export-context compares the state digests of its two requests, retries, and starts nothing when they never agree. An unknown context version, malformed JSON, or a semantics version other than the module’s throws; a Contract content hash other than the module’s warns that the types are stale, and throws only when the application asks for that SHIPPED GUARANTEE packages/accessor tests (golden vectors and behaviour against the built bundle, bundle reproducibility); apps/cli/test/typegen.test.ts, typesCommand.test.ts, typesCli.test.ts (bundled CLI against a recording server: one request, same output for every environment), generatedModule.test.ts (compiled under strict ES module and CommonJS settings, run in Node with strict, exported, and missing contexts), exportContext.test.ts, exportContextCli.test.ts; test/semantics-version.test.ts (revision by ID needs contract.read); services/varlatchd/scripts/e2e-client-runtime.mjs (full stack: generated from the real active revision, --check failing after an activation, the compiled module run under --export-context with converted values and an aggregated error that holds no value, the exported context compared with the server’s state)
Audit events contain identifiers only: no plaintext, key/credential material, or value-derived hashes; value changes are expressed as version-ID transitions DESIGNED INVARIANT Enforced by the recordAuditEvent input shape; reviewed, not schema-proven
Human authentication is passkey-only: no password database, no SMTP; setup/invite grants are high-entropy, single-use, expiring; replay is rejected; sign-in without an authenticator fails SHIPPED GUARANTEE Clean-room E2E (7 passkey checks incl. replay rejection), invite tests
Break-glass is narrow: recovery targets a named Installation Admin; disabled admins need explicit --enable; --new-admin only when zero enabled admins exist; every issuance/consumption is audited SHIPPED GUARANTEE test/bootstrap-auth.test.ts
Opaque credentials are hashed at rest, shown once, individually revocable with immediate effect; expiry and disabled-identity checks apply on every request SHIPPED GUARANTEE test/bootstrap-auth.test.ts credentials cases
Expired preview environments fail closed for retrieval and mutation, synchronously, with no dependence on a cleanup worker SHIPPED GUARANTEE test/domain.test.ts expiry case
Contract authority is server-side: revisions are content-hashed server-side (client hashes untrusted), activation is a separate audited action with a semantic diff; unknown environment names in a schema file’s env(...) fail loudly before anything is pushed SHIPPED GUARANTEE test/domain.test.ts, test/http.test.ts, test/env-schema-conditions.test.ts, packages/env-schema tests
secret.use is distinct from secret.reveal, never in the Member bundle, arrives only via explicit Grants, and is tailnet-constrained SHIPPED GUARANTEE test/authz.test.ts secret.use cases
Capability issuance never expands authority: exercise re-evaluates the Agent’s secret.use (incl. Tailnet Requirements against the exercise request) against current state; Grant revocation, Capability revocation, and expiry all deny the very next exercise; rotation resolves at exercise (Model B) SHIPPED GUARANTEE test/capabilities.test.ts; clean-room e2e-broker.mjs
Capabilities are unforgeable handles: exercise needs the ID, the hash-at-rest capability secret, and the bound Broker’s bearer, all agreeing; mismatches are existence-hidden SHIPPED GUARANTEE test/capabilities.test.ts
Reference expansion at exercise is strict and capability-bound: referenced Secrets must be named in the Capability, non-sensitive pull-ins require the Agent’s config.value.read and are audited before use, and any unresolvable reference denies the exercise rather than sending literal ${NAME} upstream SHIPPED GUARANTEE test/capabilities.test.ts reference-expansion cases
Destination constraints are canonical host+port; wildcards never match the apex; every hop is independently checked and successful exercise is audited (host+port + item@versionId) before decryption SHIPPED GUARANTEE test/destination.test.ts, test/capabilities.test.ts
In agent-safe runs the Agent Run’s environment holds opaque placeholders and no reusable Varlatch credential; substitution happens only in broker-originated, TLS-verified requests to allowlisted destinations, at the Substitution Targets recorded on the Capability; redirects are relayed, never followed with substituted material; CONNECT to secret-using destinations is refused (no MITM) SHIPPED GUARANTEE apps/cli/test/broker.test.ts; clean-room e2e-broker.mjs (real TLS upstream, redirect/decoy targets, log scan)
Substitution targets: a Placeholder is substituted only at a target the operator gave on the command line and varlatchd recorded on the Capability (a named header, query parameter, JSON pointer, or form field; never a transport-owned header), at most once per target; the Agent cannot add or widen a target. A secret-bearing agent-safe run without a target or --omit for every stored Secret does not start, and varlatchd refuses both issuance and exercise of a Capability without targets, including one issued before the upgrade. Ambiguity in a targeted surface blocks the request (stray or repeated Placeholders, repeated or folded headers, repeated names under any spelling, encoded Placeholders, duplicate JSON keys, mismatched content types, coded bodies). A failed substituted request sends zero bytes to the destination: before exercise nothing is decrypted, after exercise the value is dropped. Exercise returns only the placed Secrets, and every version it decrypts, reference dependencies included, is named by a committed audit event first. Stored and Contract Secrets inherited from the operator’s shell never reach the Agent, and with an active Contract the run needs contract.read SHIPPED GUARANTEE apps/cli/test/placement.test.ts, apps/cli/test/broker.test.ts (fake upstream counts connections), test/capabilities.test.ts (issuance validation, placements, pre-migration Capability), test/retrieval-audit-order.test.ts (traced dependency audit); clean-room e2e-broker.mjs, e2e-strict-run.mjs
Response scrubbing (defense in depth, never part of the confidentiality guarantee): in the response to a Broker-substituted request, every value exercised for that request (and a rotating item’s retiring value) is replaced with a Placeholder in the status line, every header, and every body byte, as written, JSON-escaped, percent-encoded, and in base64/base64url at every alignment; an overlap region becomes one Placeholder and no byte of any match survives, at every chunking; framing is decided before any header is sent (a small identity body is read whole and relayed exactly when nothing matched; anything else is decoded from gzip/deflate/br and streamed; any other coding is a 502); a failure after headers aborts without a terminating chunk; held bytes are never released on a timer or an error; substituted responses are bounded at 64 MiB of decoded content and 120 seconds idle; no value is held after a response ends SHIPPED GUARANTEE packages/matcher tests (every-split adversarial overlaps, pending-region bound, property test against a whole-input reference); apps/cli/test/scrub.test.ts (raw Agent connection: framing, codings, 64 MiB boundary, idle cutoffs, aborts, hold-back, headers of HEAD/204/304/redirects, every response path); clean-room e2e-broker.mjs
Output redaction (varlatch run --redact; a guard against accidents, never part of the confidentiality guarantee): in the command’s piped stdout and stderr, each Secret delivered to the command in this run, and nothing fetched to build the filter, is replaced with [REDACTED:<name>] as written, JSON-escaped, percent-encoded, and in base64/base64url at every alignment; an overlap region becomes one marker and no byte of any match survives, at every chunking, including a multi-byte character split across writes; output is matched as bytes, so output with no match (binary included) is relayed byte-exact; held bytes are never released on a timer, are released unchanged only when a stream ends cleanly, and are discarded when the run is interrupted or its output closes; no sequence in the output disables masking; values under 8 bytes are not registered and are named, never shown; stdin, signals, and the exit code pass through; a terminal stdout or stderr, and --agent-safe, are refused before anything is fetched SHIPPED GUARANTEE apps/cli/test/redact.test.ts (binary, split characters, pauses without release, clean end and interruption, in-band sequences, every encoded form, overlaps, short values, the refusal decision table, seeded fuzzing against a whole-input reference); apps/cli/test/redact-run.test.ts (CLI bundled from source, real child processes: pauses of seconds, stdin, exit codes, forwarded signals, interruption, a closed reader, --strict, refusals under a pseudo-terminal); apps/cli/test/run-signals.test.ts (the forwarded signals and the exit status in every mode, --redact included); services/varlatchd/scripts/e2e-client-runtime.mjs (run by scripts/ci-e2e.sh against the full stack: a Secret the real server delivered never appears in piped output, split across writes, base64-encoded, or on stderr); packages/matcher tests
Local secret scanning (varlatch scan; a guardrail against accidents, not data-loss prevention): the values are the selected environment’s Secrets this identity may retrieve, at their current and retiring versions, from one ordinary disclosure that needs secret.reveal and whose audit event, committed before any decryption as for every disclosure, records purpose: "scan"; a purpose outside the allowlist is refused before anything is audited, and a purpose grants nothing; nothing is disclosed when no file would be read; values are held in memory for the scan only, and no value, fragment, hash, or fingerprint of one reaches the output, the baseline, or any file; --staged reads the blobs the Git index records, never the working tree, including Git’s temporary index inside a hook; content is scanned as bytes across the whole file, so values spanning lines are found, in the forms output redaction uses, and only complete occurrences are findings; a finding carries the file, line, byte column, item, version, and form, never the value or anything else from its line, even with two Secrets on one line; a file over a size bound, unreadable, a symbolic link, a submodule, or an unmerged path is listed as not scanned and makes the scan exit non-zero, never clean; allow markers and baseline entries hold item names, paths, and version IDs only, and a baseline with any other field is refused; the pre-commit hook is installed only by --install-hook and never replaces a hook it did not write SHIPPED GUARANTEE packages/matcher tests (the scanner against a whole-input search at every split and at random chunkings; the scrubber’s rule over the scanner’s occurrences reproduces the scrubber’s output); apps/cli/test/scan.test.ts (line and byte column at every chunking, multi-line PEM, two Secrets on one line with no line content in any output, complete occurrences only, binary with NUL bytes, per-file and total bounds, a file growing past its bound, short values, allow markers, strict baseline parsing); apps/cli/test/scan-cli.test.ts (CLI bundled from source, recording server, real Git: staged-then-edited and staged-then-deleted Secrets, one disclosure with purpose: "scan", no disclosure when nothing is read, unreadable files, symbolic links, no value in any file under the project, configuration, or temporary directories, denial without secret.reveal, the hook blocking real commits including git commit -a); test/disclosure-purpose.test.ts (audited purpose with current and retiring versions, unchanged events without it, allowlist refusal before audit, no authority from a purpose); services/varlatchd/scripts/e2e-client-runtime.mjs (full stack: a staged Secret found from the Git index though the working copy changed, no value in any output, and the real audit log recording purpose: "scan" for scans only)
Sync Targets are opt-in standing disclosures: creation/widening passes a write-time gate (secret.reveal/config.value.read, decision-time provenance recorded); every push commits sync.push_attempted before material leaves; a destination has exactly one writer; Platform Adapters are a closed allowlist (no generic push-to-URL); Platform Credentials rest KEK-wrapped and are never redisplayed; credential replacement re-authorizes every referencing Target atomically; access checks only read, send a stored credential only to its Connection’s base identity, return Varlatch’s own verdict instead of platform text, and are audited SHIPPED GUARANTEE test/sync.test.ts (gate denial, exclusivity, audit-before-wire, atomic replacement refusal, ciphertext-at-rest, access check); packages/sync adapter tests (sealed-box encryption, https/origin pinning, read-only access checks)
GitHub App connections store no token: the App’s private key rests KEK-wrapped, bound to its App row and purpose, and is never returned; each push, check, or listing gets an installation token minted for that use and never stored (one repository with secrets or environments write for a push, read for a destination check; installation-wide metadata read for a Connection-only check or a listing); registration state is stored only as a hash, single-use, bound to the actor, Organization, and intended GitHub account, and an App GitHub created on another account is refused and never stored; key rotation re-authorizes every non-revoked Target on the App’s Connections atomically, behind each Target’s write-time gate, locking the App, then Connections, then Targets; removal revokes every Connection on the App and deletes the key SHIPPED GUARANTEE test/githubapps.test.ts (state hashing, single use, owner check, key wrap), test/githubapp-connections.test.ts (narrowing per use, no stored token, no expiry), test/githubapp-rotation.test.ts (gate refusal, version guard, removal), test/githubapp-lock-order-postgres.test.ts and test/githubapp-rotation-postgres.test.ts (App-first lock order, no deadlock)
Deletion erases from the live database. Deleting a Project erases every version of its values (payload and wrapped key cleared, under a check constraint), those of environments deleted earlier included, and revokes its Capabilities and Sync Targets, in one transaction. Deleting an Organization is a request: it freezes the Organization at once (every operational change is refused inside the writer’s own transaction, under one lock order, so a write in progress finishes first and a later one sees the freeze) and, after a waiting period of 1 to 30 days and another admin’s approval when there were several, ends every membership, retires machine identities, erases every value, and destroys the Organization KEK, so nothing it protected can be read through the key hierarchy. The deleted Organization’s audit log stays readable only by the people who held Organization-level audit.read at that moment; an Installation Admin can remove such a reader, never add one or read the events SHIPPED GUARANTEE test/project-deletion.test.ts and test/project-deletion-postgres.test.ts (erasure, cleared pointers, revocation; deletion racing writes, Connection revocation, the sync scan and destination reuse), test/org-deletion.test.ts (freeze, suspension and cancel, approval, finalization, destroyed KEK, tombstone readers and export), test/org-deletion-postgres.test.ts (scheduling racing writes, invitation acceptance, pushes, broker issuance and self-revocation; one finalization per request)
Sync ledger content identity is a keyed fingerprint (HKDF from the Org KEK, per Target), never a bare hash; fingerprints appear in no audit event or log DESIGNED INVARIANT Enforced by syncFingerprintKey + audit input shape; reviewed, not schema-proven
Loosening an organization’s security control (its mode towards off, a longer credential lifetime, or cleared lifetimes, alone or within a mixed change) needs organization.manage, a person’s credential, and a step-up approval: a fresh WebAuthn assertion with user verification by a trusted passkey of the person who asked, through a challenge bound to the approval and their dashboard session, for exactly that change (operation, organization, control, normalized request, expected version) and the requester that asked: the exact CLI credential, or a live browser credential of the same dashboard session. Only that person can see or approve it; it is used at most once, inside the change’s transaction, and expires 2 minutes (dashboard) or 10 minutes (CLI) after it was asked for. Machine credentials can never change a control. Controls cannot be set to enforce yet, so no control restricts anything else; the host operator’s reset bypasses all of this (section 5) SHIPPED GUARANTEE test/security-controls.test.ts, test/security-controls-postgres.test.ts (lock order and single use on PostgreSQL)
Validation verdicts are value-derived, so POST …/validate evaluates an item only if the caller may read what the verdict describes: presence needs config.metadata.read, non-sensitive verdicts config.value.read, Secret verdicts secret.reveal including its Requirements (secret.use alone is not enough). Everything else is reported as not evaluated, with an authorization-derived reason only, and a partial evaluation is never valid. Validation decryption is audited before it happens (secret.validated/value.validated, item@versionId, no verdicts). Residual: a secret.reveal holder who can also change the Contract can probe by repeated activation, but can already read the value, and each probe is audited SHIPPED GUARANTEE test/validation-access.test.ts (permission, Tailnet Requirement, audit failure, enum-probe cases); apps/cli/test/validation.test.ts; apps/web/test/validationSummary.test.ts

5. Explicit assumptions and non-guarantees

Section titled “5. Explicit assumptions and non-guarantees”

These are the boundaries of the model. None of them is a bug.

  • The Infrastructure Operator is outside enforcement. Host/exec/volume/DB-superuser/KEK access defeats everything, including audit: append-only holds against runtime authority, not against an operator who can rewrite the database, restore old state, or replace the binary. Operator-resistant audit evidence would require an external trust anchor and does not exist yet.
  • varlatchd compromise may expose all plaintext the process can decrypt, plus the KEK in memory. The Secret Plane is the trusted computing base.
  • KEK loss is permanent data loss. Database backup + matching KEK = recoverable; either alone is not. The raw KEK stored with the backup weakens theft protection to nothing. admin kek export produces a passphrase-wrapped escrow blob (scrypt + AES-256-GCM, KDF parameters AAD-bound) that IS safe to store with backups; loss then reduces to passphrase loss. admin kek split/combine provide k-of-n Shamir sharing for multi-custodian setups; fewer than k shares reveal nothing. Both recovery paths verify against the installation canary before writing a key file.
  • Organization isolation is tenant isolation, not protection from the operator of the installation.
  • Installation Admins have a real escalation path: recovery mediation can take over any human identity, including Organization Admins. It is allowed, single-admin, and loud (high-severity audit), not prevented.
  • Code running in an allowlisted page on an approved device can read what that device may read. With the tailnet browser endpoint, the device check binds to the device, not to the page: once a person has connected in a tab, a script injected into the dashboard can use its bearer against the endpoint exactly as the dashboard does. The dashboard’s strict Content-Security-Policy (scripts and styles from its own origin only, nothing inline, connections only to its own origin, Convex and the endpoint) makes injection harder; it does not make injected code safe. The CLI on that device has the same exposure; audit names the device and the client.
  • Tailscale limitations: revocation is bounded by local netmap freshness (a removed node may retain access until the netmap syncs); tag enrollment delegates identity issuance to whoever assigns that tag in the tailnet; a compromised machine that legitimately satisfies identity and policy can exercise whatever that machine is allowed. Tailnet identity is a constraint layer, never the sole credential.
  • A Tailnet Requirement does not protect itself. Revoking or editing a Requirement needs policy.manage and nothing more: no Tailnet Context and no fresh passkey. A stolen credential that carries policy.manage, such as an Organization Admin’s CLI sign-in, can remove the Requirement from off the tailnet and then retrieve what it constrained. Each change is audited (requirement.updated, requirement.revoked), so it is visible afterwards, not prevented.
  • The host operator can turn security controls off. admin security reset runs on the host, needs nobody’s passkey, and sets an organization’s controls back to off (audited as security_control.reset). It is the way back when nobody can approve, and it means whoever controls the host or the deployment, including through deployment credentials that Varlatch itself serves, bypasses every control.
  • A signed-in session can add a passkey. Adding a passkey from the dashboard needs a session, not a fresh assertion with an existing passkey, so a stolen session can add a passkey of its own and sign in with it later. Such a passkey is recorded as unconfirmed until the person confirms it with a trusted passkey, with user verification; passkeys enrolled through a setup or recovery link, and those that existed before the upgrade that introduced this record, are trusted. Only step-up approvals refuse on that basis: an unconfirmed passkey cannot approve one. Sign-in, device sign-in approval and passkey removal accept any passkey of the person. Each session records whether the authenticator verified the user and which passkey signed in, for the organization security controls that will use it.
  • Convex compromise does not directly expose plaintext or grant Secret Plane authority, but yields metadata, mirror contents (names, structures, audit summaries), and dashboard disruption.
  • JavaScript memory cannot be reliably zeroized. Node/GC gives no precise-erasure guarantee for plaintext or key material that has passed through the process.
  • Environment injection is disclosure. Anything varlatch run places in a child’s environment is fully readable by that process and everything it spawns. Varlatch cannot control what an arbitrary process does with plaintext it was handed.
  • Strict startup is a startup check, not a confidentiality control. It validates what the command receives when it starts; values the command reads later, or changes itself, are outside it.
  • The Typed Accessor is a correctness check, not a confidentiality control. A process can read everything in its environment, and the accessor’s object is inspectable: logging it prints Secrets, as logging process.env would. Without a run context it cannot tell a withheld item from one that is not stored. Generated modules carry Contract metadata, defaults and examples included, so neither may hold a secret.
  • The agent-safe preflight does not establish the Agent’s reference resolvability. A matching state digest shows the same configuration, not the same permissions. Every exercise evaluates the Agent’s authorization and resolves every reference or denies, and a later exercise may use a version the preflight never checked.
  • varlatch run cannot stop a process from leaking what it was given. It is a plain injector. Output redaction (--redact, opt-in) is a guard against accidents, not a control: it masks only complete matches of the delivered Secrets, in the stated forms, in the command’s stdout and stderr. Output that ends with part of a Secret, values under 8 bytes, hex or other transformed values, values split by other text, files the command writes, sockets, and /dev/tty are not covered, and terminal output is not redacted at all. It protects where output is written, not the command’s own view, so it is not an agent protection. Agent-safe mode (§6) is the protection for untrusted processes.
  • Secret scanning is a guardrail, not data-loss prevention. varlatch scan finds only complete occurrences of one environment’s current and retiring Secret versions, in the stated forms. It does not find values under 8 bytes, hex or other transformed values, compressed content (archives and Git’s packed objects included), a value with changed line endings, partial or split values, older versions, or other environments’ Secrets. --staged checks what the next commit records, not Git history, and file names are not checked. A scan is a disclosure: it needs secret.reveal, so an identity without it, an Agent by default, cannot scan.
  • Authorization is effective at the snapshot boundary. A retrieval is authorized by the Grants, Roles, Groups, Requirements, and Capability state in its database snapshot, and by nothing later. A revocation that commits after a request’s snapshot began does not affect that request: it may still complete and return what the snapshot authorized. The next request sees the revocation. The window is one request, with no network call inside it. The audit event records the decision the snapshot produced, so it can follow the revocation’s own event in audit order.
  • An audit event’s client is what the caller says. Events a request records for its own identity name the credential that authenticated it, which varlatchd verified, and a client summarized from the request’s User-Agent (Firefox on Linux, varlatch CLI 0.16.0 on Linux, assisted), which it did not. Anyone holding a credential can send any User-Agent, so the client tells you which tool the credential holder used, the dashboard, the CLI, a coding agent in assisted mode, or the MCP server, not proof of it, and no authorization depends on it. A label holds only fixed words and a release version, never free text from the header.
  • Denial-of-service and side channels (timing, traffic analysis) are out of scope for the MVP model.
  • Outbound sync is a deliberate egress class. With a Sync Target configured, varlatchd initiates TLS connections carrying plaintext Values to allowlisted third-party APIs, authenticated by stored Platform Credentials. The mitigations (the closed adapter allowlist, KEK-wrapped org-level credentials scoped as narrowly as each platform honestly allows, audit-before-disclosure, the write-time disclosure gate, and the installation switch, VARLATCH_SYNC=off) bound the egress; they are not a claim that it is riskless. Sync delivery is a server-side convergence loop, not a caller’s retrieval: it decrypts values to detect what changed, and audits twice: before it decrypts (sync.values_decrypted, naming exact versions, even when nothing is pushed) and before any value leaves the process (sync.push_attempted). A stored Platform Credential can confer authority beyond reading Varlatch’s secrets. Access checks, run before a credential is saved or a Target created, add read-only requests and no new destinations: no Value leaves, a stored credential goes only to its Connection’s base identity, the reply is reduced to Varlatch’s own verdict, and every check is audited. A holder of config.sync.manage can use one to learn whether an https address answers, as a Connection and a failing Target already allowed. Destination listings, for the dashboard’s pickers, follow the same rules and show that holder the names of the repositories or applications a credential can see; they could already attach the Connection to any of them, so the names disclose less than that authority. Listings keep only names and addresses, and are audited with a count.
  • A GitHub App’s key confers the App’s whole authority. Varlatch narrows every token it mints to one use, but the stored private key can mint any token the App’s installations allow: for an App Varlatch registered, Secrets and Environments write and Metadata read on the installed repositories; for an imported App, whatever it holds (the import records extra permissions in the audit log). A varlatchd compromise with the Organization KEK reaches that key. Removing the App from Varlatch deletes the key there and nothing on GitHub: a key copied before removal stays valid until it is deleted on GitHub, and the App until its owner deletes it. Registration runs through the person’s browser: the dashboard posts GitHub’s form (its Content-Security-Policy allows forms to https://github.com), and GitHub sends a one-time code back to the dashboard, which the same actor completes with their own session; the key arrives from api.github.com over TLS in the code exchange.
  • The platform-facing copy is outside Varlatch’s authority. Once pushed, a Value’s protection is the destination platform’s: revocation and crypto-shredding do not extend there, an abandoned destination (after a destination change or Target revocation) is not cleaned up, and Varlatch never claims a pushed value was adopted by running workloads. Rotation completion stays a human decision informed by per-target sync status.
  • Deletion covers the live database, not its copies. When a deletion completes, the current rows hold no envelope of the deleted values and no usable wrapped Organization KEK. PostgreSQL may keep old row versions, its write-ahead log, replicas and snapshots for a while; a process may still hold keys or values in memory for requests already authorized, and a request in flight at finalization may still complete. Backups taken before the deletion completed hold the data until their retention removes them (the hosted backup’s object lock keeps them for its whole term), and restoring one restores the whole installation, deleted data included. Values pushed to platforms, and copies people revealed, downloaded or exported, are not reached: rotate them at their provider. Removing a GitHub App’s registration does not uninstall or delete the App on GitHub, revoke copies of its key, or revoke installation tokens already issued.

6. Agent safety: the credential broker (SHIPPED)

Section titled “6. Agent safety: the credential broker (SHIPPED)”

varlatch run --agent-safe spawns the untrusted Agent with opaque per-run placeholders instead of Secrets and no reusable Varlatch credential (the explicit --agent-metadata mode substitutes exactly one: a ≤1h, revocable, HTTP-layer-read-only agent-run bearer whose authority is still the Agent’s Grants, so sensitive plaintext stays unreachable); a trusted local Broker substitutes real material only in requests it originates itself over verified TLS to Capability-allowlisted destinations; varlatchd remains the secret.use authorization/Capability/audit authority, re-evaluated on every exercise. Its guarantees are in the §4 table with their test mappings. The governing invariant:

secret.use authorizes mediated use of secret material for an explicitly constrained destination. It does not imply secret.reveal.

And the honest limits, stated with the same discipline:

  • Same-user process escape is outside the boundary. A process running as the same OS user may inspect the parent/Broker’s memory, credential file, environment, or sockets. The Broker is not an OS sandbox.
  • Mediation, not a network sandbox. Non-allowlisted traffic passes through unchanged by default (placeholders intact, useless); --agent-network=strict blocks it. An agent can still make arbitrary outbound requests unless separately sandboxed, and a NO_PROXY or no_proxy exemption inherited from the operator’s shell sends matching requests around the Broker even in strict mode (the run adds only the Broker’s own address to each spelling, and removes an inherited entry that would exempt every host).
  • Opaque CONNECT tunnels cannot carry substitution without MITM, which Varlatch deliberately does not do (no local CA). Clients must send inspectable absolute-URI HTTP requests to the Broker; CONNECT to a secret-using destination is refused with a precise diagnostic. This is narrower than generic HTTPS_PROXY compatibility.
  • No response guarantee. Varlatch prevents direct delivery of stored Secret plaintext to the Agent during credential injection. It does not guarantee that an authorized destination will never return secret-derived or plaintext data to the Agent. Response scrubbing is defense in depth: it replaces complete values exercised for the same request, in the forms listed in §4, and misses values under 8 bytes, hex, arbitrary \uXXXX, other character sets, compression inside a body, values split across fields, reflections in responses to other requests, and anything derived from a secret. When two Secrets share a region, only one Placeholder shows. A trailing prefix at a clean end is released unchanged, and output stalls while a possible match is pending.
  • Destination granularity is host+port, not path or method.
  • Substitution targets bound where a Secret goes, not what it does. A Placeholder is substituted only at the targets recorded on the Capability, but a correctly placed credential can still call any endpoint and method the destination permits, and a destination that logs or echoes the target header, or logs URLs with a query target, sees the value. Only the local Broker sees outbound requests, so varlatchd records what was authorized but cannot verify where a value was placed. Keep allowlists narrow and upstream credentials narrowly scoped.
  • Body targets are JSON and form bodies of at most 2 MiB; the Broker cannot repair application signatures computed over placeholder-bearing payloads, the body, or its length.
  • A stray Placeholder is forwarded inert. A Placeholder outside a targeted surface is sent unchanged and reported, not blocked.
  • The Agent inherits the rest of the operator’s environment. Stored Secrets and Contract Secrets are removed from it, but a secret Varlatch does not know about (not stored, not in the Contract) still reaches the Agent. Start agent-safe runs from a clean environment.
  • Plain varlatch run (without --agent-safe) still injects plaintext into the child environment (§5); agent safety is opt-in per run.

The rule this document lives by: a strong security claim must map to an automated test, an integration/E2E check, or a deployment invariant, or explicitly say that the property cannot be enforced (usually: against the Infrastructure Operator) and why. If you find a claim here without one of those, that is a documentation bug; file it as such.

Audit insertion now uses a transactional counter to order worker delivery by commit. This serializes audit writers and should be load-tested for large installations. Delivery remains at least once: a crash after a remote receiver accepts a batch but before cursor persistence can replay it. Consumers must deduplicate event IDs.

Request bodies are capped at 1 MiB on ordinary and tailnet listeners. A bounded process-local limiter permits 600 requests per minute per socket peer, excluding health checks; on the tailnet listeners, where every peer arrives from the sidecar’s loopback address, per recognized device instead. Proxy deployments share the proxy peer’s allowance; forwarding headers are not trusted. This is a basic abuse bound, not a distributed denial-of-service guarantee. Reference expansion is capped at 1 MiB, 10,000 substitutions, and eight reference levels.

Dashboard Convex reads expose only invalidation signals; authoritative resource and audit reads go through the Secret Plane. Human recovery codes and an operator root-KEK rotation workflow remain planned.