Security overview

This page is for the security reviewer at a customer that runs Arkush inside its own infrastructure. The page is also for the operator who answers a vendor questionnaire from it. Each section states facts, then names the files that prove them. Every open gap has a row in the last table, with its card.

1. What the software is and where it runs

Arkush is one web application: freeform canvas dashboards, each chart a Vega-Lite spec over SQL, one JSON document per dashboard. The software runs as one process in one container image on the customer's host. The state plane is one SQLite database, state.db, plus a directory of bytes beside it: snapshot payloads, uploaded originals and font faces. No external database server exists. An install is one container and one volume.

The vendor operates no service that the install calls. The software sends no telemetry, no error reports, no usage statistics and no version check. No analytics script loads in the browser. The constitution refuses a product that reports home (CONSTITUTION.md → What We're Not), and a test holds the table below to the source both ways: every host the source names has a row, and every row names a host the source reaches (src/api/outbound-hosts.test.ts). The app's own assets, the DuckDB engine, the code editor and the fonts are served from the image.

What leaves the host, and what leaves the browser:

DestinationWho starts itWhat is sentHow it is bounded
accounts.google.com, www.googleapis.com, and *.googleapis.com in the browser's connect policyThe browser, at sign-in under app-owned sign-in, and for browser-side BigQuery and Sheets datasourcesThe Google sign-in flow under the person's own accountOnly when the install configures Google sign-in or the browser query plane. The document policy admits the Google API origins as one wildcard and every other origin by name.
bigquery.googleapis.comThe browser under the person's own token. The API under an impersonated registered service accountThe datasource's SQL text. The rows come back.A server-side run needs ARKUSH_BQ_PROJECT, a credential, and a registered account named on the datasource. Unset, every server-side run answers no-bigquery.
sheets.googleapis.com, apis.google.com, www.gstatic.com, docs.google.com (a frame)The browser, for Google Sheets datasources and the Picker dialogThe person's own credentialOnly when the install configures the Picker pair
www.youtube-nocookie.com, player.vimeo.comThe browser, on a landing page where an admin embedded a videoA frame loadAdmin-authored copy only
Any https: image addressThe browser, for image blocks and the instance logoA GETAuthor-chosen addresses
The metadata server, or a mounted key fileThe API, to prove its own identityA token request under the app's identityOnly when the install configures a credential
iamcredentials.googleapis.comThe API, when a run names a registered service accountA short-lived token mint under the app's identityOnly when the service-account registry is set
cloudidentity.googleapis.comThe API, to read group membershipThe group addresses an admin registeredOnly under the Google groups backend. Each call stops after 10 s.
iam.googleapis.comThe API, to read who may use a registered service accountThe address of a registered service accountOnly when the service-account registry is set. Each call stops after 10 s, and an answer is kept at most an hour.
oauth2.googleapis.com, Google's JWKSThe API, under app-owned sign-inThe authorization code exchangeOnly under app-owned sign-in
slack.com/api, plus the upload address Slack answersThe API, for scheduled deliveries and subscriptionsRendered chart images and table text, with the bot tokenOnly when an admin registered a destination. Authors name an entry, never a URL.
The hosts in ARKUSH_FETCH_HOSTS. For the Google sources the guide names docs.google.com, .googleusercontent.com and storage.googleapis.comThe API, for url datasourcesA GET. The bearer rides only hops on the start origin.Off unless the list is set. The rules are in section 7.
Any public https: name an agent presents as its client idThe API, when a signed-in person opens an agent's consent pageA GET for the client's metadata documenthttps only, a hostname filter, no redirects, 5 s, 64 KiB, 30 requests per minute per address. A name that resolves to a private range is refused, and a non-default port is refused.

References: docs/exploration/customer-readiness-2026-09.md (the egress lane), src/api/http.ts (the content-security policy), src/app/services/duckdb.ts, src/app/services/google-auth.ts, src/api/signin.ts, src/api/impersonation.ts, src/api/google-groups.ts, src/api/slack.ts, src/api/source-fetch.ts, src/api/oauth.ts, src/api/public-address.ts, index.html, deploy/Dockerfile.

2. Data flow

Figure: [the data-flow figure](handbook/security-data-flow.html), drawn in the handbook's grammar.

The same flow in words:

  1. A person opens the app in a browser. The browser reaches the API service with the API's own signed session cookie. On a header-trust install, the browser reaches the API through the customer's identity proxy, which sets the email header.
  2. Under app-owned sign-in, the API sends the person to the customer's Google Workspace and exchanges the returned code with Google. Under header trust, the proxy owns sign-in, and the API reads the header only.
  3. The API reads and writes the state plane: records in state.db, bytes in blobs/ and fonts/, on the host's own disk.
  4. The API runs server-side SQL against BigQuery under a registered service account that it impersonates, never under its own identity. The account is read-only, and no key for it exists anywhere.
  5. An editor's browser can query BigQuery directly under the person's own Google token. That token never reaches the server.
  6. A viewer's browser reads snapshots only. The DuckDB engine in the browser slices the snapshot rows. No viewer query reaches BigQuery.
  7. An MCP client (an agent) reaches /mcp with OAuth tokens that the API minted after a person consented. The agent acts under that person's rights. The agent never receives the person's Google token.
  8. Nothing else leaves the host unless the configuration names it: a Slack destination, a fetch host, a groups backend.

References: docs/handbook/01-big-picture.html, docs/handbook/06-the-server.html, docs/architecture/00-overview.md.

3. Identity and access

Two identity modes exist, mutually exclusive, picked by configuration. The vendor authenticates nobody.

WARNING: In header-trust mode, a path that reaches the container around the proxy lets any caller name any email. Route every request through the proxy, and publish no host port on the container.

Boot refuses a half-configured identity by name. ARKUSH_ALLOW_ANONYMOUS=true offers account-free use, and an org install leaves it unset. Gap: an install that sets no identity variable at all boots in header trust on the default header name (TASK-4).

Two rights layers. Authoring needs a signed-in identity and nothing more. Two allowlists give the instance rights, and both accept group:<name> entries:

Per-dashboard access belongs to the dashboard. meta.owner, meta.shares (a person or a group, as viewer or editor) and meta.visibility (private, or everyone signed in, view only). One module, src/core/access.ts, evaluates them on every surface: the canvas, the Source panel, the HTTP routes and every MCP tool. Group references resolve server-side from one of two backends: a hand-edited membership file, or the Google directory read under the app's own identity and cached.

Offboarding. Revoke all on the Users page ends every agent grant and every browser session of one account. Removal from the sign-in allowlist stops any token the account still holds at its next use.

The agent OAuth server. The API is its own authorization server for MCP clients.

PropertyFact
FlowAuthorization code with refresh. PKCE with S256 is mandatory.
ClientsDynamic registration with a stateless HMAC client id, and the client-id metadata protocol. A redirect URI is https:, matched exactly, or loopback http:, matched on any port.
ConsentThe consent page is authenticated like the app. The consent fields are signed, live 10 minutes, and are pinned to the identity. A code lives 5 minutes in memory and is single use.
Tokens24 random bytes, stored as SHA-256 hashes, checked by hash, expiry and the sign-in allowlist. An access token lives 4 hours. A refresh token lives 30 days and rotates on every use.
RevocationA person revokes their own grants. An admin revokes any grant. A deleted grant fails at its next check.
DiscoveryThe authorization-server metadata (RFC 8414) and the protected-resource metadata (RFC 9728) are served.
Rate limit30 requests per minute per client address on register, authorize and token, answered 429 with Retry-After.

The OAuth server is weaker than a reviewer expects in these places. No refresh-token reuse detection exists, and an access token is not bound to the client after consent (TASK-49). One scope exists, offline_access, so no narrower consent exists. The metadata advertises client_secret_post and no secret path exists. The limiter's key is the client-claimed forwarded address. The last three carry no card.

References: deploy/README.md (Identity, Offboarding), src/api/identity.ts, src/api/signin.ts, src/api/session-cookie.ts, src/core/access.ts, src/api/oauth.ts, src/api/routes/grants.ts, docs/spec/04-server-documents.md §04D and §04H, docs/spec/07-onboarding.md §07G. History: design-rationale §36, §179, §222.

4. Warehouse credentials

References: AGENTS.md (the read-only credentials invariant), deploy/README.md (Server-side BigQuery, Named service accounts, Offboarding a person), src/api/impersonation.ts, src/api/iam-policy.ts, src/api/run-datasource.ts, src/api/refresh.ts, src/core/document.ts (SNAPSHOT_ROW_CAP). History: design-rationale §76, §197, §232.

5. What is stored where, and for how long

DataWhereRemoval
Dashboard documents, with no snapshot rowsstate.db, with up to 50 archived versions per dashboard, each a full copyMove to trash, then a purge 30 days later. The purge removes the document and its payloads. The version archive is capped, never pruned by age.
Snapshot payloads — one per dashboard and datasourcestate.db below 256 KB of rows, <state>/blobs/docs/ above it as ParquetRemoved with the document, and when a save stops naming a datasource
Uploaded files<state>/blobs/files/, with an entry in state.dbDeletable as an object. A per-person quota applies when ARKUSH_UPLOAD_QUOTA is set.
Instance fonts<state>/fonts/, with an index in state.dbAn analyst deletes a face
Browser sessionsstate.dbSign-out, Revoke all, or expiry 30 days after the last renewal
Agent grantsstate.db, tokens as SHA-256 hashesThe person or an admin revokes
The two generated secrets in the database: oauth, atteststate.db, one row each in the records table, 32 random bytes as hex, made at first bootDelete the row with the service stopped, and the next boot mints a new one. The deploy guide states what each rotation costs.
The session-cookie signing secretARKUSH_SESSION_SECRET when the deployment sets it, else <state>/session-secret, 32 random bytes as hex, file mode 0600, made at first bootReplace the value, or delete the file with the service stopped, and recreate the container. Everyone signs in once more. A copy of state.db holds no session key.
The upgrade copy of the database<state>/state.db.before-v<N>, the whole state.db as it stood before a schema step, one copy at most, file mode 0600The next schema step replaces it. The operator deletes it. Nothing prunes it by age.
Email addressesThe profile store, meta.owner and meta.shares, comment authorship, snapshot refreshedBy, log lines, agent grantsPartial. Each site removes separately, and no path removes one identity across all of them (TASK-51).
Onboarding answersThe profile store in state.dbDELETE /api/profile removes the record
CommentsInside the documentA soft delete scrubs the body and keeps a tombstone with the author's email
Group membershipProcess memory, cached from the Google directoryCache expiry. The record of truth is Google's.
Usage eventsstdout, read by the install's log driverThe sink's retention. No instance retention number is stated.
The hand-edited registries: groups, service accounts, destinations (with Slack bot tokens)YAML files at operator-chosen paths, outside the state planeThe operator edits the file. An admin edits the same file in the app.
The landing copy and the legal pages/data/landing, /data/pagesAn admin edits them in the app
The Google client secret, the refresh-tick bearer, the key fileThe environment, and a file mounted outside the state directoryThe operator. The deploy guide states each rotation.

Encryption at rest is the host's job. The application encrypts nothing at rest and claims no encryption. state.db, its upgrade copy, the session-secret file, the snapshot payloads and the uploaded originals are plain files. state.db, its upgrade copy and the session-secret file are set to file mode 0600. Disk encryption under the state directory and under every backup copy are the customer's controls. The deploy guide says so in its own words.

The rows above, the three retention numbers, the cookie names and the browser storage names are frozen in `src/api/privacy-facts.test.ts`, because an instance's privacy policy and terms state them: a change fails the suite with the obligation to tell the operator through a Privacy item in the changelog, and the release check refuses a tag without one (design-rationale §225).

No app-level retention rule exists beyond the version cap and the trash clock. Account-level export and deletion are TASK-51. The log retention number is the sink's, and no card names one.

References: docs/exploration/compliance-readiness.md (What Arkush holds about a person), deploy/README.md (Volumes, Versions and upgrades, Monitoring, Backup: every path, Encryption at rest, Rotate a secret), src/api/db.ts (loadOrCreateSecret, the file mode, SCHEMA_VERSION and the upgrade copy), src/api/sqlite-store.ts (RETAINED_VERSIONS), src/core/document.ts (TRASH_RETENTION_DAYS), src/api/session-cookie.ts (SESSION_TTL_S). History: design-rationale §29, §196.

6. What is logged

Every log line is one JSON object with t, level and event. Info lines go to stdout, error lines to stderr. A request id rid rides every line of one request. The id is an inbound x-request-id when a proxy supplies one, otherwise a generated one, and the response echoes it.

The event vocabulary. The table of record is docs/spec/04-server-documents.md §04J, derived from the logEvent call sites in src/api/. The families: dashboard acts (doc_*, version_open, comment_write), runs (ds_refresh, ds_refresh_failed, dslib_*, transform_preview, block_render), files and fonts (file_*, font_*), sign-in (signin, signin_refused, signin_failed, signout), agents (mcp_*, grant_revoke, grant_revoke_all, oauth_rate_limited), administration (admin_*, group_*, google_group_*, sa_*, destinations_edit, page_edit, theme_*), deliveries (delivery_*, subscription_*), and the process (listening, shutdown, refresh_tick). A run event carries the user, the dashboard, the datasource, the row count, the path it ran through, the bytes billed and the cache hit. A BigQuery run event also carries serviceAccount: the registered account's address, which every server-side run now has. A share, visibility or owner change emits access_edit with the values before and after.

One `request` line per answered request: method, path without its query string, status, and milliseconds to the last byte. The line carries no identity, so a log pipeline can file it apart from the usage events.

Never logged: a request body, a token, a profile answer, or SQL text. A failed run logs a classified reason and no message, because an engine message can quote SQL. An error line carries the server-side message and stack, and neither reaches a client. An unclassified error answers a fixed sentence.

The sink is the log driver. Retention is the sink's setting. The deploy guide asks the operator to pin the driver to journald, because the default driver drops the logs on rebuild.

Audit gaps. Under header trust the proxy signs people in and the API sees no sign-in act, so the first request an identity makes after the service starts logs the signin line, with provider: "header". No failed-attempt counter exists (TASK-162). No in-app audit view, log-level switch or support bundle exists (TASK-165).

References: src/api/http.ts (logEvent, logError, logRequestOnClose), src/api/run-datasource.ts (serviceAccount), src/api/routes/dashboards.ts (access_edit), docs/spec/04-server-documents.md §04J, deploy/README.md (Monitoring, Backup: every path). History: design-rationale §90, §124, §206.

7. Hardening

Headers on every response. X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Strict-Transport-Security: max-age=31536000, and a content-security policy. The policy has three tiers. JSON, MCP and asset responses get default-src 'none'. Served scripts get a worker-script policy. The app's own HTML gets a document policy. The document policy keeps 'unsafe-eval' for Vega's expression compiler and 'unsafe-inline' for the bootstrap script, by decision. The eval channel stays open, and everything else is closed. No foreign script loads, connect-src is bounded, objects and base overrides are shut, and no other page frames the app. The strict policy is TASK-50. connect-src names the app's own origin and Google only. A test pins the headers.

Cookies. The session cookie is host-only, HttpOnly, SameSite=Lax, and, when the public URL is https:, Secure and named __Host-arkush_session — a name the service reads only under https, so a bare-named cookie is ignored. The cookie is HMAC-signed with a secret that lives outside the state database, so a copy of state.db cannot mint a session (§5), and lives 30 days from the last renewal.

Cross-site writes. The API sends no CORS header, so a cross-origin fetch fails its preflight. The session cookie is SameSite=Lax. A POST, PUT, PATCH or DELETE whose Sec-Fetch-Site header is cross-site answers 403 before any surface reads it. A request with a bearer token is exempt. A request with no fetch metadata passes, so a machine client loses nothing. The deploy guide states the posture in one paragraph.

Rate limits. The OAuth endpoints answer 429 past 30 requests per minute per client address. Every other route is unlimited, by decision. A limit on sign-in, uploads, MCP and the BigQuery routes reverses that decision, so TASK-162 is a design round before any build.

Limits. The request, upload, font, snapshot, stream, query and fetch ceilings are listed once, in the deploy guide's Capacity section (deploy/README.md), with the compose file's own caps beside them.

The server DuckDB engine. Each run opens an in-memory instance and closes it after. Inputs are written under the OS temp directory per run and removed after. Before the caller's SQL runs, the engine sets enable_external_access=false, disabled_filesystems='LocalFileSystem' and lock_configuration=true. A test proves that a network read and a local file read both fail with the engine's permission error. No statement allowlist exists: the string runs verbatim, the lock refuses SET, and a 30 s wall clock stops the run. The server-side chart render uses a loader that refuses every URL.

The `url` fetcher. The mode does not exist until ARKUSH_FETCH_HOSTS is set. An entry matches a host exactly or as a leading-dot suffix. Only https: and gs: start a fetch. Redirects are followed by hand, at most 3 hops, and every hop is re-checked against the list. The bearer rides only hops on the start origin. The body is reduced through DuckDB into rows and a hash, never stored raw. Before each request leaves, the fetcher resolves the name. Every special-purpose range in both IP families is refused: private, loopback, link-local, shared address space, multicast and the rest. A name with one private answer among public ones is refused. The same check guards the client-metadata fetch. One gap is recorded: the fetch resolves the name again when it connects, so a resolver that answers differently twice (DNS rebinding) passes (TASK-172). The Cloud Identity fetch stops after 10 s.

Uploads. A file lands under a server-computed content hash. Ids are minted by the store. The client's file name becomes metadata only. The store's contract suite rejects a path escape.

Errors. No stack trace reaches a client. Some responses name an environment variable so that an operator can act. One admin-only response names an absolute path. A DuckDB error on a blob that does not exist can name the state directory to an agent (TASK-168).

The container. The process runs as the unprivileged node user on an Alpine base. The image declares a HEALTHCHECK, and the compose file declares the same check. The port is exposed and not published. The compose file drops every capability, sets no-new-privileges, and caps the process count at 512. The root filesystem is read-only, with a 1 GiB memory-backed temp directory for the DuckDB engine's per-run inputs.

The health route. GET /healthz needs no identity and touches no warehouse. The answer carries the version and a readiness flag, and answers 503 when the state plane cannot take a write.

References: src/api/http.ts (the headers and the three policies), src/api/hardening.test.ts, src/api/session-cookie.ts, src/api/server.ts (the cross-site guard, the health route), src/api/oauth.ts (FixedWindowLimiter), src/api/config.ts (MAX_BODY_BYTES), src/core/file-entry.ts, src/core/fonts.ts, src/api/main.ts (the timeouts), src/api/events.ts, src/api/duckdb-node.ts, src/api/duckdb-node.test.ts, src/api/render-block.ts, src/api/source-fetch.ts, src/core/source-url.ts, src/api/public-address.ts, src/api/google-groups.ts, src/api/routes/files.ts, src/api/file-store.ts, deploy/Dockerfile, deploy/docker-compose.yml. History: design-rationale §61, §90.

8. Supply chain

Pins. Every runtime dependency is exact-pinned in package.json. The lockfile is committed, and every install runs npm ci. The Node base is the Active LTS line, pinned by exact version tag and by digest: the tag keeps the version readable to a person reading a scan finding, and the digest names the bytes a tag could be repointed away from. The digest is the multi-arch index's, so both architectures of a release build resolve from it, and a test fails a stage that loses either. Dependabot opens one weekly pull request for the minor and patch churn of the development dependencies, one for the actions, and one for a move of the Node base within its line in the Dockerfile, which carries the tag and the digest together. A runtime dependency gets no weekly request, because it moves in a dedicated diff, and Dependabot opens one for it only on an advisory. A major of any package is the maintainer's move. A test holds the runtime list in the Dependabot configuration to package.json.

The gates before publish. The publish job needs the test job. The test job runs the typecheck, the test suite, the duplication gate, the dead-code gate, the marker census, the house rules and the licence gate. On a tag it also runs the release check: a version whose operator-facing surface changed since the previous tag carries an Operations item in its changelog entry. A red check publishes no image. npm audit --audit-level=high runs and does not gate, so a known advisory is reviewed work, not a failed build. The lockfile advisories are untriaged (TASK-145).

SBOM, scan, signature. After the push, the workflow emits two CycloneDX inventories as one workflow artifact per build. One lists the npm packages from the lockfile. The other lists the image filesystem with its Alpine packages. A scan over the pushed digest reports every high and critical finding, and fails the job on a critical finding that has a fixed version. On a release tag, the workflow signs the pushed digest with cosign, keyless. The certificate names this workflow at that tag, and the entry lands in Sigstore's public transparency log. A main push is unsigned. Buildx provenance attestation stays off by decision. Every action in the workflow is pinned by commit, with the release it resolves to beside it, and a test fails on one that is not. The workflow comments spell out the cosign verify command that checks a pulled release against the workflow identity.

The release set. A release tag also assembles one bundle of files, kept in a release bucket and attached to the run: the linux/amd64 image as a compressed tarball, a checksum file over every other member, that checksum file's own detached cosign signature, both SBOM files, the bundle's source maps, the registry digest, and a release note. It exists for the security review that scans a file before anything enters a network, and for a customer whose process takes files rather than a registry pull. The release note is lifted from the version's changelog entry, classified items first, because the changelog itself ships inside the image and is read after the update rather than before it. deploy/README.md carries the verify-and-load steps.

What the image contains. The built app. The API as one minified bundle, plus the two operator scripts. The runtime node_modules, pruned to a keep list that a test checks against the bundle's own externals. The pruned package.json. The handbook chapters that describe behavior. The operator documentation under /app/docs/: the deploy guide, this overview, SECURITY.md and the changelog, so a person who holds the image holds the documentation for its version. The base compose file at /app/deploy/docker-compose.yml, which is the stack the deploy guide's install step starts, so the install needs no source tree either. The DejaVu fallback font. THIRD-PARTY.md and the Apache-2.0 licence text, at /app/THIRD-PARTY.md and /app/licenses/Apache-2.0.txt, for the transcribed IBM Carbon glyphs, and the Help panel shows the same text. The image contains no source tree, no test, no source map and no .env file. The source maps for the bundle travel in the release set instead, so a stack trace from an install can be read back against source without the image carrying one. The image carries the OCI version, revision and source labels. The title and licenses labels stay unset, because the repository grants no licence.

Licences. The production dependency tree carries permissive licences only: MIT, BSD-2-Clause, BSD-3-Clause, ISC, Apache-2.0, 0BSD and Unlicense. One exception is the SVG rasterizer, @resvg/resvg-js, with its platform binding, under MPL-2.0 and unmodified. One package, dompurify, is dual-licensed MPL-2.0 or Apache-2.0. No package is GPL, LGPL or AGPL. npm run lint:licenses reads the same tree and refuses a package under a licence outside that list, in CI before every publish (scripts/licenses.mjs). The npm SBOM the publish workflow attaches to every release lists each package with its version and licence.

References: package.json, package-lock.json, .github/workflows/publish-image.yml, .github/dependabot.yml, deploy/Dockerfile, src/api/dockerfile.test.ts, src/api/supply-chain.test.ts, scripts/licenses.mjs, scripts/release-check.mjs, THIRD-PARTY.md, licenses/Apache-2.0.txt. History: design-rationale §90, §222, §225.

9. Vulnerability disclosure and patches

Report a vulnerability to security@arkush.app, whoever operates the install. SECURITY.md states the commitments, and its Self-hosted installs section states them for a customer that runs its own. An acknowledgment comes in 3 working days and a first assessment in 10 working days. A fix for a critical problem ships 14 days after the assessment, one for a high problem 30 days after it, and one for a medium or low problem 90 days after it. The same table measures a published advisory in a dependency, counted from the day the maintainer learns of it. One set of times covers every install, because the fix is a release tag in every case. The policy sends a report about how somebody else's instance is RUN — its host, its proxy, its configuration, its data — to that instance's operator, and a defect in the software to the address above. The customer's own team can test its own install, and the policy's safe harbor covers it.

Applying a tag is the operator's act. The maintainer holds no route into a customer's install and pushes no update to it. Fixes ship as new image tags on the current line. No version branch and no backported fix exists, so an install several versions behind moves forward through the boot refusals that name each step.

Support is best effort, with no response time promised for a question that is not a security report. The operator documentation rides in the image, so an install can be run, updated, backed up and decommissioned without the maintainer.

GET /.well-known/security.txt serves the install's own contact when an admin has set one on the admin page, and 404 when none is set. No penetration test report exists.

References: SECURITY.md, docs/spec/04-server-documents.md §04J, src/api/hardening.test.ts.

The customer is the controller of whatever its warehouse holds. A snapshot is warehouse output, and the software cannot know what a snapshot contains. A deletion promise is therefore about the object, never about the contents. No browser fetch reaches a host the customer did not configure: the engine's own extensions are served from the app. The three sentences above are doctrine and gates, not a description of one version: the constitution refuses a product that reports home, a test fails the build on a host with no row in section 1, and a second test freezes the warranted facts — the destinations, the policy origins, the licence allowlist, the format version, the documentation set in the image — so that a change to any of them fails with the obligation to amend the agreement or drop the change (src/api/vendor-commitments.test.ts, design-rationale §225).

References: docs/exploration/compliance-readiness.md, section 1 of this page.

11. Gaps

TopicGapCardPriority
Identity bootNo identity variable at all boots header trust on the default header name. An unset public URL counts as localhost.TASK-4p3
Privacy policyThe published privacy policy owes three sentencesTASK-7p2
Export and deletionNo account-level export, no deletion of one identity across every store, no retention ruleTASK-51p2
Log retentionNo retention number is stated for the log sinknone
Rate limitsOAuth only. No failed-attempt counter. A sign-in limit reverses a recorded decision, so a design round comes first.TASK-162p2
DiagnosticsNo in-app audit view, no log-level switch, no support bundleTASK-165p3
Offboarding visibilityA schedule the sweep stops for a departed person shows in the tick's report and the log only; no screen says soTASK-201p2
OAuth tokensNo refresh-token reuse detection. An access token is not bound to the client after consent.TASK-49p2
OAuth metadataOne scope. client_secret_post advertised with no secret path. The limiter key is client-claimed.none
Content-security policy'unsafe-eval' and 'unsafe-inline' in the document policyTASK-50p2
DNS rebindingThe resolved-address check and the connect resolve the name separately, so a resolver that answers differently twice passes.TASK-172p2
AdvisoriesThe lockfile advisories are untriaged by reachable APITASK-145p2
Error textA DuckDB error on a blob that does not exist can name the state directory to an agentTASK-168p3
Penetration testNo report existsnone
Disclosure templateNo template for an org operator's own disclosure policynone

References: docs/exploration/customer-readiness-2026-09.md (the questionnaire table and the cards), backlog/tasks/.