Atomic DigitalOcean deployment¶
Veetbot deploys to one Ubuntu Droplet at api.veetbot.com. PostgreSQL, the API,
the interactive and asynchronous run workers, the maintenance worker, the
credential-free execution service, the browser-profile service, Docker with
gVisor, and Nginx share that host, with the optional schedule, notification,
surface, and calling roles when they are enabled. CircleCI packages the tested
main commit and promotes an immutable release below /opt/veetbot/releases.
The same pipeline publishes the complete MkDocs site at docs.veetbot.com from
a checksummed artifact tied to that release.
The production deployment scripts require the Ubuntu GNU userland: Bash, GNU
coreutils (including mv -T and sha256sum), GNU tar and findutils, and
util-linux flock. Local macOS deployment-script tests require the matching
Homebrew tools:
The harness maps gmv, gsha256sum, gtar, gfind, and the Homebrew
util-linux flock into the production command names during both fixture
creation and deployment. Privileged Nginx and systemd operations remain stubbed.
The flow adapts the useful deployment invariants from avitus/mankunku to Veetbot's Python and systemd runtime. ADR-0048 records the differences and security boundary.
Public website¶
The visitor-facing homepage and Google OAuth policy pages are a separate static
site defined under website/:
https://www.veetbot.com/https://www.veetbot.com/privacyhttps://www.veetbot.com/tos
The source is a Next.js static export with no account system, analytics,
database, object storage, or Veetbot application credential. Run
make website-install test-website locally. CircleCI repeats that build in a
credential-free Node job and passes only the generated website/out tree to
release packaging.
The application, documentation, and website artifacts receive the same release
identity. The Nginx deployment validates and atomically promotes the website at
/opt/veetbot/shared/website/current; configuration validation or reload
failure restores both static-site pointers and the prior configuration. ADR-0084
records the boundary and rationale.
Semantic-index database prerequisite¶
ADR-0179 adds the rebuildable memory_vectors table and pgvector 0.8.7. The release
builds veetbot-postgres:16-alpine-vector0.8.7 from deploy/postgres.Dockerfile
before restarting PostgreSQL or migrating. It retains the existing PostgreSQL 16
Alpine locale environment and volume path; it does not switch an existing volume
to the hosted CI image. Follow the existing backup and migration rehearsal before
production delivery. Semantic retrieval remains disabled: this schema and image
change does not activate a provider, backfill private memory or enable recall.
Release flow¶
static + contract + integration + sandbox
|
v
package tested commit
|
v
SSH stage + checksum verify
|
v
lock -> uv sync -> image -> PostgreSQL -> migrate -> preflight
|
v
current symlink + systemd restart
|
v
release.sh: local release header + API contract probe
|
v
CircleCI: public release header
Each release is named YYYYMMDD-HHMMSS-<7-character-commit>. The server:
- takes
/opt/veetbot/shared/deploy.lock; - refuses a timestamped release older than the currently active release;
- installs a release-local
.venvfromuv.lockusing a shared download cache, and compiles the bytecode of its dependencies and ofsrc, because the units' read-only filesystem keeps Python from caching it at run time; - builds
agent-core-sandbox:<release-id>; - checks active provider pins against the staged registry on an existing installation, then ensures the local PostgreSQL service is running;
- reconciles owner permissions into the staged
.owner-scopes.env, appliesalembic upgrade head, and runs the production preflight; - stops both run workers, rechecks active provider pins, then switches
/opt/veetbot/current, tags the sandbox image asproduction, and restarts the credential-free execution service and all application units; - requires the local readiness probe to return
X-Veetbot-Release: <release-id>within 180 one-second attempts (VEETBOT_HEALTH_TIMEOUT_SECS; every unit restarts at once, and the API has needed more than a minute to bind), makes an authenticated request to the authoritative session index and model settings, and requires every process to run from the promoted directory; - retains the five newest valid releases; and
- retains the two newest timestamped
agent-core-sandboxandveetbot-browser-profile-serviceimage tags and prunes build cache that no build has used for 48 hours.
The provider-pin checks enforce ADR-0119's requirement to drain runs before a
provider-registry change. They include queued, running, approval-waiting and
user-waiting runs across every tenant. A mismatch, malformed pin or failed
probe refuses promotion; the report names only run IDs. Finish or explicitly
cancel the listed runs on the current release, then retry deployment. Terminal
runs and queued runs that have not selected a provider do not block a release.
The second check runs with both pin-writing workers stopped, closing the race
with the first check. A failure before promotion restarts the previous workers.
Install the updated deploy/sudoers/veetbot-deploy contract before the first
release using this guard; its two additional exact commands stop and restart
veetbot-worker veetbot-async-worker.
A run already stranded by an earlier registry change fails as
model_permanent_error with ProviderPinUnavailableError and asks the owner to
send a new message. It never substitutes the new registry or executes the
pending tool. The provider pin and approval history remain intact; a terminal
failed run is not automatically retried. Terminal checkpoints discard provider
continuation state even when failure precedes entry to the model loop.
Before step 3 compiled bytecode, every service start and every stdio MCP server
spawn compiled its imports from source. On the idle production host, importing
the CLI took 17.5 s of CPU from source and 8.4 s from bytecode, and a Gmail
server's module 4.1 s and 2.2 s. The API gives in-flight requests five seconds
when it is stopped, then cancels them; clients resume a cut run event stream
from Last-Event-ID.
Release pruning normally runs as the deployment identity, which also owns the bytecode each release compiles. Older releases may contain service-owned Python bytecode from deployments that predate the strict read-only systemd filesystem policy. If direct removal of such a release fails, the script retries with the just-built sandbox image as uid 0 in a one-shot, network-disabled container. The container has a read-only root filesystem, only the releases directory is mounted writable, and its fixed entrypoint removes only the validated release name selected by the retention loop. This fallback uses the deployment identity's existing Docker trust boundary; application identities still receive no Docker access.
Image retention runs only after every verification above has passed. For each
of the two repositories it sorts the YYYYMMDD-HHMMSS-<revision> tags newest
first and removes every tag after the second, which keeps the active release's
image and its predecessor, the manual rollback target. It never removes the tag
just released, a tag that docker ps reports a container running, or a tag
outside that pattern such as production, local, or dev. Each removal is
independent: a failure is logged as a warning and the release still succeeds,
so a rerun removes only what remains. The same step runs
docker builder prune --all --force --filter until=48h, which discards build
cache no build has touched in two days while keeping the dependency and
Chromium layers that consecutive builds share warm. The step prints
docker system df before and after so the CircleCI deployment log records the
reclaimed space. VEETBOT_KEEP_IMAGES overrides the tag count. The
deploy/browser-profile-service.Dockerfile installs dependencies and Chromium
above the application source so that only the final layers change between
releases; without that order each release snapshot repeats those layers and
the retained tags share nothing.
A successful server release returns control to CircleCI, which polls the public
TLS readiness endpoint until it reports the same release ID. Exhausting that
bounded public-probe budget fails the CircleCI job after promotion; it is not a
failure inside deploy/app/release.sh.
The optional Telegram and WhatsApp channels run in the separate surface role. Use the Telegram surface runbook for the bot, token file, both environments, activation, pairing, live smoke, and rollback, and the WhatsApp integration runbook for the Meta owner ceremony that adds the second channel; do not place channel credentials in the application environment, which refuses to start with one.
A pre-promotion failure removes only its staged directory. A post-promotion failure remains visible for diagnosis and manual rollback. Database migrations are never downgraded automatically.
One-time host preparation¶
Inventory the shared Droplet before changing it:
sudo ss -ltnp
sudo systemctl --type=service --state=running
sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
docker --version
docker compose version
nginx -v
uv --version
df -h
free -h
Confirm port 8000 is free, choose a free loopback PostgreSQL port, and record all existing Docker containers. Reuse the existing Docker and Nginx installations; do not replace services used by other applications on the Droplet.
Create separate deployment, application, and sandbox-execution identities plus the persistent paths:
Current production host (verified 2026-09-03)
The Droplet was not provisioned with the separate veetbot-deploy
identity below. CircleCI logs in as the veetbot service account: its key
is in /home/veetbot/.ssh/authorized_keys, veetbot owns /opt/veetbot
and /opt/veetbot/shared/deploy.lock, and the sudo contract is installed
for veetbot. Because that account is also the User= of the API, worker,
and maintenance units, it carries the deploy identity's Docker-group
membership into every application process, which is the boundary ADR-0067
decision 1 draws to keep application services away from the Docker socket.
Until the host is reconciled, read every veetbot-deploy below as
veetbot when operating the current Droplet, and treat "create the
veetbot-deploy account, move the CircleCI key and sudo contract to it,
and remove veetbot from the docker group" as the open operations item
that restores the documented boundary. The account name is not visible to
CI: DEPLOY_USER in the veetbot-production context selects it.
sudo useradd --system --create-home --shell /bin/bash veetbot-deploy
sudo useradd --system --no-create-home --shell /usr/sbin/nologin veetbot
sudo useradd --system --no-create-home --shell /usr/sbin/nologin veetbot-exec
sudo usermod -g veetbot veetbot-exec
sudo usermod -aG veetbot veetbot-deploy
sudo usermod -aG docker veetbot-deploy
sudo usermod -aG docker veetbot-exec
sudo mkdir -p \
/opt/veetbot/releases \
/opt/veetbot/shared/docs/releases \
/opt/veetbot/shared/website/releases \
/opt/veetbot/shared/uv-cache \
/etc/veetbot \
/var/lib/veetbot/artifacts
sudo chown -R veetbot-deploy:veetbot /opt/veetbot
sudo chown -R veetbot:veetbot /var/lib/veetbot
sudo chmod 0700 /var/lib/veetbot/artifacts
veetbot-execution.service also sets DynamicUser=yes. A fully prepared host
uses the static veetbot-exec account above; on an older host that does not yet
have it, systemd allocates the same dedicated service identity so the atomic
release can complete without temporarily running the execution service as an
application user.
The veetbot-deploy account owns /opt/veetbot, including the documentation
releases. Nginx and the application group receive read-only traversal through
the ordinary directory modes. The API and worker identities are not members of
the Docker group and their units expose no Docker socket; application services
have no Docker-socket access. Only the credential-free veetbot-exec execution
service receives Docker access at application runtime and owns sandbox
lifecycle. The separate deploy identity retains delivery-time Docker access
because the audited release contract builds and tags both images, reconciles the
browser-profile service, and prunes stale release images and build cache. That
trusted operator
boundary is the explicit tradeoff in ADR-0067 decision 1; it is never an
application systemd identity. Consequently neither an application process nor
an application-launched container can modify the documentation release. On a
single host, workers reach the service at /run/veetbot/execution.sock. On a
multi-host deployment, move the service to the dedicated sandbox host described
by ADR-0008 and replace /run/veetbot/execution.sock with an authenticated
network transport while preserving the ExecutionEnvironment port.
The deploy key must log in as veetbot-deploy, so that account needs an executable
shell. Restrict that key in authorized_keys to the CircleCI source and disable
port, agent, and X11 forwarding. Install the committed sudo contract
deploy/sudoers/veetbot-deploy as /etc/sudoers.d/veetbot-deploy with mode
0440, replacing its placeholder user deploy with this account, and validate
with visudo -c -f /etc/sudoers.d/veetbot-deploy. The contract is the exact
union of the sudo commands deploy/app/release.sh and deploy/nginx/deploy.sh
invoke, and a repository test reconciles the scripts against it, so a script
gaining a sudo command without a rule fails CI rather than the production
release. That authority is effectively host administration; do not reuse the
key for application clients or interactive users.
Install stable runsc if it is not already registered. Preserve the existing
Docker configuration before the required daemon restart and verify all previous
containers afterward:
if sudo test -f /etc/docker/daemon.json; then
sudo cp -a /etc/docker/daemon.json /etc/docker/daemon.json.before-runsc
fi
sudo runsc install
sudo systemctl restart docker
sudo docker info --format '{{json .Runtimes}}'
sudo docker ps
docker run --rm --runtime=runsc hello-world
Copy deploy/veetbot.env.example to /etc/veetbot/veetbot.env, replace every
REQUIRED_ value, select the free PostgreSQL port in both locations, and add
VEETBOT_OPENAI_KEY for the shipped production astra policy. A reviewed
model-policy overlay may instead retarget astra to another configured
provider.
New production sessions default to astra, selecting OpenAI gpt-6-astra;
flagship and fable select Anthropic claude-fable-5-1. Existing sessions
retain their recorded agent version and model policy. balanced explicitly
selects gpt-5.6-sol (gpt-5.6 alias), and claude-opus-5 (opus alias)
remains registered for operator overlays.
The new profiles use the standard prices verified on 2026-09-11 in the
OpenAI model documentation
and Anthropic model documentation:
USD 10 input and USD 50 output per million tokens; cached input is USD 1
for Astra and USD 0.25 for Fable. Cache writes are USD 12.50 with the
Anthropic adapter's default five-minute TTL. Astra retains the 272,000-token
window required by ADR-0039's single-tier pricing rule; Fable uses 1,000,000
tokens, and both allow up to 128,000 output tokens.
Changing these profiles changes the registry version. Drain active and
suspended runs before deploying: existing provider pins fail closed when
their registry version is unavailable. Provider-assisted memory also requires
evaluation evidence for the exact selected model and model-policy name;
the GPT-5.6 Sol artifacts do not authorize Astra or Fable. Without matching
evidence, auto uses deterministic formation and required refuses startup.
Memory formation deliberately remains on GPT-5.6 Sol while chat uses the new
defaults (ADR-0093). formation.model_policy: balanced in
memory/profiles.yaml independently selects its evidenced tuple, so the
bundled formation@9 evidence still activates and formation@10 remains its
evidenced fallback. An AGENT_CONFIG_DIR overlay may change that reference,
but the replacement still needs its own exact evidence. Selection audits name
both chat_model_policy and the memory model_policy. The memory evaluators'
--model-policy astra continues to evaluate Astra explicitly. Memory formation
remains active Milestone 21 work: audit scorer semantics and run a controlled
Sol/Astra comparison before changing this temporary choice.
To enable the initial Keenable comparison, add all three provider credentials and route half of each capability to Keenable in that same root-owned file:
WEB_SEARCH_PROVIDERS=tavily:50,keenable:50
WEB_FETCH_PROVIDERS=firecrawl:50,keenable:50
TAVILY_API_KEY=<production Tavily key>
FIRECRAWL_API_KEY=<production Firecrawl key>
KEENABLE_API_KEY=<production Keenable key>
Do not put the real values in .env.example, the production template, CircleCI
configuration, a commit, or a PR. The worker reads this environment when the
release restarts its systemd unit. Plural selector entries must be unique
positive integer percentages summing to 100. The legacy singular selectors
remain valid for one-provider deployments, and both capabilities stay disabled
when neither form enables them.
To enable image and video generation (ADR-0140), provision TENSORSCALE_API_KEY
in the API and worker environment and grant media.generate plus
artifact.write in the owner's AUTH_SCOPES. Doppler's development key cache
does not update the production root-owned environment file. Permit SenseNova
U1.5 and LTX-2.5 Fast (ltx2-5-fast) on the key, restart the API and workers,
and open a new Chat: old sessions retain their pinned tool roster. No additional
feature flag is needed for generation. To upload reference pictures, enable
AGENT_ATTACHMENT_UPLOADS_ENABLED=1 and grant artifact.read for input reads.
Version 1.1.0 accepts PNG/JPEG/WebP references from the same chat: up to eight
for image edits (20 MiB each, 64 MiB total), or first/last frames for video
(10 MiB each). Each approved call creates one PNG or MP4 attachment;
video duration is bounded to 5 or 10 seconds. External provider charges are
separate from language-model usage accounting. There are no automatic retries
for failures or timeouts, since the provider may already have charged. Keep
the credential out of sandbox and scheduler-only environments.
To enable the typed-judgment provider (ADR-0110), add its selector and key to that same root-owned file:
The selector is the only switch: the key alone enables nothing, and the
selector alone enables no consumer. Each consumer has its own default-off knob
in its checked-in profile, such as proposals.judgment_matching_enabled in
folders/profiles.yaml. With the selector set and the key missing, the
services still start, log one judgment_credential_missing warning that names
the two variables, and every judgment call fails without dialing so each
consumer takes its deterministic fallback. Keep both variables out of
/etc/veetbot/veetbot-schedule.env and /etc/veetbot/veetbot-notify.env: those
workers refuse to start when their environment holds a provider key. The
adapter dials one fixed endpoint, api.typesafe.ai on port 443, and needs no
egress-policy entry.
The policy advisory layer (ADR-0111) has two switches, and they are not
equivalent. AGENT_POLICY_ADVISORY_OBSERVE_ENABLED=1 in that same file consults
the advisor on allowed web searches, page fetches and browser navigations and
records what it would have escalated; no decision changes and policy_version
does not move. Enforcing is the policy-profile value advisory.enabled: true,
set in an operator overlay at policy/default.yaml under AGENT_CONFIG_DIR.
That changes policy_version: a pending approval whose re-evaluation is not an
allow is voided, and the bundled memory-formation, People and email-People
release evidence no longer matches the running composition, so
provider-assisted formation falls back to deterministic until the evidence is
regenerated on the new version. Observe first, read the
agent.policy.advisory.* metrics, and enforce only when no approval is pending.
The client's model settings (ADR-0119) need settings.read,settings.write.
Deployment provisions both automatically and requires an authenticated HTTP
200 from GET /v1/settings/models before declaring success (ADR-0149).
The routes are always mounted. A saved chat model applies to new app chats;
the chat effort applies to later agent runs except typed email tasks, which
retain provider-default effort. The memory choice applies to later formation.
The template's AUTH_SCOPES is the reviewed owner permission set, including
optional Email, Persona, calling, media and delegation features. Every release
unions that set with the host's explicit grants, sorts and deduplicates it,
and writes only AUTH_SCOPES to the staged .owner-scopes.env. The API and
owner workers load it after the host environment; restricted service roles
never load it. Deployment does not rewrite /etc/veetbot/veetbot.env or copy
its credentials. Promotion activates the file with the release; rollback uses
the target's file, or the host grants for older targets without one.
New platform scopes must be classified in test_deployment_scopes.py and
added to the template when owner-facing, so adding a feature cannot silently
omit its deployment grants. Feature flags, provider credentials, evidence gates,
policy and approvals still govern activation and use. Scopes are exact strings,
with no wildcard; unknown scopes stop startup. MCP scopes continue to derive
from account configuration. Removing a managed scope only from the host file
does not revoke it: a deliberately restricted owner deployment needs a reviewed
change to the template. When running owner CLI commands directly, source the
current .owner-scopes.env after the host environment if the file exists.
The surface role's own environment carries surface.read and
surface.write.
The host exports no metrics, so the service log answers whether these consumers
are running. Every long-running service writes JSON lines, one object per
event, with first-party events from INFO and every other logger from
WARNING:
sudo journalctl -u veetbot-api -u veetbot-worker -u veetbot-async-worker \
--since '-1 hour' -o cat | grep policy_advisory
sudo journalctl -u veetbot-maintenance --since '-1 hour' -o cat | grep folder_proposal_pass
policy_advisory_consulted appears once per consultation with tool,
verdict, enforced, signals and seconds; a verdict other than
abstain is what enforcing would have sent for approval.
policy_advisory_abstained carries the cause: timeout, or a provider
reason code such as judgment.auth_failed or judgment.payment_required, the
latter meaning the vendor account has no credits. folder_proposal_pass
appears once per pass: judgment_requests above zero with
judgment_fallback_used false and judgment_error_class null is a working
matcher, and judgment_matched counts the conversations it proposed to file.
None of these lines carries a query, a URL, a title, or a probability.
Run docker compose ls before the first automated release. If an existing
Veetbot PostgreSQL container was created under a Compose project name other than
veetbot, set COMPOSE_PROJECT_NAME to that exact existing name. Changing it
silently selects a different named volume and therefore an empty database.
When scheduled runs are enabled, separately copy
deploy/veetbot-schedule.env.example to
/etc/veetbot/veetbot-schedule.env, fill its required database and identity
values, and protect it with the same ownership and mode. The schedule unit never
loads the shared environment because that file contains API, model-provider,
web-provider, and browser-profile credentials that the scheduler must not see.
If Gmail is enabled, mirror AGENT_EMAIL_ENABLED=1 into this schedule
environment. For named accounts also mirror the non-secret
GMAIL_ACCOUNTS_FILE path; for legacy single-account configuration leave that
path empty. Do not copy any GMAIL_*_CREDENTIAL_FILE variable. The schedule
loader reads the manifest's account ids to derive current Gmail scopes but
never opens the referenced OAuth credential documents.
The tenant-scoped database role must not have PostgreSQL BYPASSRLS authority:
schedule child-table isolation depends on the forced row-level-security policy
on schedules applying inside each child-policy lookup. Reserve BYPASSRLS
roles for explicit administration and never use one for API, worker, or
scheduler tenant access.
Provision the veetbot_schedule login independently with a generated password,
then grant only the tables its materialization transaction and schema-head check
use. The release runs scripts/check_schedule_database_permissions.py through
the protected schedule environment before promotion and refuses a role that is
administrative, inherits authority, can replicate or bypass row-level security,
can switch to any other role, is missing any privilege below, or has any other
effective table or column-level privilege on a table, view, materialized view or
foreign table in the public schema. The check is
an exact allowlist, including grants inherited from another role or PUBLIC,
rather than a presence-only checklist. Its privilege vocabulary intentionally
matches the deployed PostgreSQL 16 release; PostgreSQL 17's MAINTAIN
privilege is not accepted while production remains pinned to version 16. The
checker reads server_version_num from the connected server and fails before
the privilege comparison unless that server is PostgreSQL 16.
ALTER ROLE veetbot_schedule
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS;
-- Do not grant veetbot_schedule membership in any other role: NOINHERIT does
-- not prevent it from acquiring that role's authority with SET ROLE.
GRANT CONNECT ON DATABASE agent TO veetbot_schedule;
GRANT USAGE ON SCHEMA public TO veetbot_schedule;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM veetbot_schedule;
GRANT SELECT ON
agents,
alembic_version,
checkpoints,
derived_event_keys,
events,
notification_outbox,
process_events,
projection_watermarks,
runs,
schedule_occurrences,
schedule_revisions,
schedules,
session_history_items,
sessions
TO veetbot_schedule;
GRANT INSERT ON
checkpoints,
derived_event_keys,
events,
notification_outbox,
process_events,
projection_watermarks,
runs,
schedule_occurrences,
session_history_items,
sessions
TO veetbot_schedule;
GRANT UPDATE ON
projection_watermarks,
runs,
schedules,
sessions
TO veetbot_schedule;
GRANT DELETE ON
checkpoints,
projection_watermarks,
session_history_items
TO veetbot_schedule;
Run the revocation before reapplying the allowlist whenever this role is
repaired. If the validator still reports a surplus effective privilege, remove
the grant from the role membership, column grant, ownership, PUBLIC, or
default-privilege source that supplies it; revoking a direct table grant cannot
mask authority supplied by a different source.
The projection grants are part of checkpoint seeding, not optional reporting: without them a due schedule can retry until its misfire window expires without ever committing the session and run.
sudo chown root:veetbot /etc/veetbot/veetbot-schedule.env
sudo chmod 0640 /etc/veetbot/veetbot-schedule.env
Do not add VEETBOT_RELEASE_ID to that shared file. The release script writes
it and the fixed host-native execution-service socket to
/opt/veetbot/current/.release.env, which every credential-bearing application
unit loads after the shared environment. This release-local wiring lets an
existing host adopt the execution service without rewriting the protected
shared environment before the first compatible release.
When the Telegram surface is enabled, the surface role connects as its own
veetbot_surface login, never as the application login. The
Telegram surface runbook gives the one command
that creates the login with a generated password, applies the allowlist below,
and writes it into /etc/veetbot/veetbot-surface.env; rerunning it rotates the
password. Whenever AGENT_SURFACE_WORKER_ENABLED=1, the release runs
scripts/check_surface_database_permissions.py through that environment before
promotion and applies the same exact-allowlist rules as the schedule login
above: no administrative attribute, no inherited or settable role, and no
effective table or column privilege in public beyond this list.
ALTER ROLE veetbot_surface
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS;
-- Do not grant veetbot_surface membership in any other role.
GRANT CONNECT ON DATABASE agent TO veetbot_surface;
GRANT USAGE ON SCHEMA public TO veetbot_surface;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM veetbot_surface;
GRANT SELECT ON
agents,
alembic_version,
approvals,
checkpoints,
delegations,
derived_event_keys,
devices,
events,
export_consent,
notification_deliveries,
notification_outbox,
notification_run_receipts,
process_events,
projection_watermarks,
runs,
session_history_items,
sessions,
surface_inbound_receipts,
surface_pairing_codes,
surface_pairings,
surface_replies,
surface_sender_lockouts,
surface_sessions,
tool_invocations
TO veetbot_surface;
GRANT INSERT ON
checkpoints,
derived_event_keys,
devices,
events,
notification_deliveries,
process_events,
projection_watermarks,
runs,
session_history_items,
sessions,
surface_inbound_receipts,
surface_pairings,
surface_sender_lockouts,
surface_sessions
TO veetbot_surface;
GRANT UPDATE ON
approvals,
delegations,
devices,
events,
notification_outbox,
projection_watermarks,
runs,
sessions,
surface_inbound_receipts,
surface_pairing_codes,
surface_pairings,
surface_replies,
surface_sender_lockouts,
surface_sessions,
tool_invocations
TO veetbot_surface;
GRANT DELETE ON
surface_sender_lockouts
TO veetbot_surface;
The list was derived by running every surface path under a login holding only
these grants and then removing each grant in turn; every one is needed. The
surface integration test runs the same paths under it. Several UPDATE grants
are row locks rather than rewrites: SELECT ... FOR UPDATE on events and
runs serializes a message's history and run writes with People erasure, and
on approvals, surface_pairing_codes, surface_replies,
notification_outbox, and tool_invocations it takes the row being resolved or
claimed. The history projection grants are part of submitting a message to an
existing conversation, as they are for the scheduler. The role reads no memory,
People, email, knowledge, browser, or schedule table. Repair it as the schedule
login above: revoke, reapply, and remove any surplus the validator still
reports at its source.
Browser profile service host prerequisites¶
Reviewed site/account definitions are optional (ADR-0175). Set
BROWSER_PROFILE_VERIFICATION_FILE in the host environment to an absolute private
JSON file, owned by uid 65532 with mode 0600 and no symlink. The release validates
that path before replacing containers and adds
deploy/docker-compose.browser-verification.yml after the production overlay.
Only the profile service receives this read-only file. The service rejects
oversized or invalid catalogs at startup. Empty or unset host configuration keeps
the existing differential verification; the overlay must then be omitted.
Catalogs contain account identities and profile bindings: keep them outside Git,
use synthetic identities in fixtures, and qualify both the authenticated and
empty-session page before activation. A valid saved session must pass without a
prompt; an expired or wrong-account session must stop before a lease is issued.
Changing a bind-mounted file or this setting requires container recreation.
Preserve the same overlay when manually recreating a configured service. Rolling
application units back does not replace the browser-service container; an explicit
browser-image rollback must preserve a catalog supported by that image or disable
the catalog deliberately before recreation.
The reviewed morning X briefing has a separate default-off application switch,
BROWSER_FEED_WORKFLOW_ENABLED=1 (ADR-0176). Before enabling it in the application
environment shared by all roles, deploy matching application and browser-service
images and install the qualified private X account definition above. The pinned
observation tool must support version 1.8.0 article extraction; older pinned chats
retain ordinary execution. Confirm the existing schedule's complete instruction
still matches the reviewed catalog digest and its selected profile is the
qualified account. After activation, verify a real scheduled occurrence reports
observed personalized-feed evidence without scroll approvals. A feed that stays
empty must report insufficient evidence. Clearing the switch disables recipe
selection; an already suspended recipe fails closed instead of changing its
definition. No production schedule or profile is rewritten by deployment.
The browser service mounts /tmp as a 512 MiB, memory-backed filesystem with
noexec,nosuid,nodev, inside the container memory limit.
Playwright's Chromium places shared-memory files there. Device sign-in verifies
the supplied session against a second, signed-out browser concurrently; the
former 128 MiB mount filled during these loads and crashed the browser drivers.
Keep the two-browser capacity when changing container settings. Every hosted
browser is headed (ADR-0145): one used about 610 MiB and 181 processes and
threads on a light page, and a verification's two about 1.1 GiB and 370. The
service admits at most three browsers at once, and the container allows 3 GiB,
a gibibyte for each, and 1,024 processes; change the two together. The limit
is a ceiling, not a reservation: the host needs that much memory free beside
its other services.
The service still enforces one thirty-second deadline for verification,
including browser startup, page loading and session capture; a page load has
no shorter deadline.
A hosted browser asks the egress proxy for nothing on its own except one
connection to accounts.google.com as it starts (ADR-0146). The launch
switches that stop the rest are in the service's code, and the image carries
a Chromium managed policy for the one request no switch reaches. The switches
name Chromium features and repeat a list from Playwright. Hosted CI repeats
ADR-0146's measurement in the built image on x86_64 and arm64 whenever the
image, its limits, the Playwright version or the switches change, and release
packaging waits for it (ADR-0152). To measure locally before pushing, run
make test-browser-image.
The release script refuses to run until the browser-profile secrets exist,
even while BROWSER_PROVIDER=disabled, because the production compose file
always starts the hardened profile service and bind-mounts these paths
read-only. Provision them once:
umask 077
sudo install -d -m 0711 /etc/veetbot/secrets
# One token, two source files: the container refuses secrets it does not own
# (uid 65532), while systemd copies the deploy-owned control credential into
# each application unit's private credential directory.
credential="$(openssl rand -hex 32)"
printf '%s\n' "$credential" | sudo tee /etc/veetbot/secrets/browser-profile-service-auth >/dev/null
printf '%s\n' "$credential" | sudo tee /etc/veetbot/secrets/browser-control-plane-credential >/dev/null
unset credential
openssl rand -hex 32 | sudo tee /etc/veetbot/secrets/browser-profile-session-secret >/dev/null
# Keyring: the service does not generate keys; its mount is read-only.
sudo install -d -m 0700 /etc/veetbot/secrets/browser-profile-keys
openssl rand -base64 32 | sudo tee /etc/veetbot/secrets/browser-profile-keys/v1.key >/dev/null
printf 'v1\n' | sudo tee /etc/veetbot/secrets/browser-profile-keys/current >/dev/null
sudo chown 65532:65532 /etc/veetbot/secrets/browser-profile-service-auth \
/etc/veetbot/secrets/browser-profile-session-secret
sudo chown -R 65532:65532 /etc/veetbot/secrets/browser-profile-keys
sudo chown veetbot-deploy:veetbot-deploy \
/etc/veetbot/secrets/browser-control-plane-credential
sudo chmod 0600 /etc/veetbot/secrets/browser-profile-service-auth \
/etc/veetbot/secrets/browser-profile-session-secret \
/etc/veetbot/secrets/browser-control-plane-credential \
/etc/veetbot/secrets/browser-profile-keys/v1.key \
/etc/veetbot/secrets/browser-profile-keys/current
The service's fail-closed loader rejects any secret file with group or other
permission bits, a wrong owner, a symlink, or multi-line content; key files
must be base64 of exactly 32 bytes named <version>.key, with current
naming an existing version. The profile-service mount paths are in
deploy/veetbot.env.example. The application control credential is deliberately
absent from that shared environment: the checked-in application units use
systemd LoadCredential, while the deployment preflight reads the protected
source as veetbot-deploy.
The committed Nginx virtual hosts expect Let's Encrypt certificates at
/etc/letsencrypt/live/api.veetbot.com/,
/etc/letsencrypt/live/browser.veetbot.com/, and
/etc/letsencrypt/live/docs.veetbot.com/. The homepage certificate covering
both veetbot.com and www.veetbot.com lives under
/etc/letsencrypt/live/veetbot.com/. The Nginx installer changes only
Veetbot's sites-available and sites-enabled entries; it preserves other
virtual hosts.
Before merging a new static-host configuration to main:
- Confirm that
docs.veetbot.comresolves to the same address asapi.veetbot.com. For the public website, the apexArecord must target that address andwwwmust be a CNAME to the apex. Wait for public resolution. - Provision the dedicated certificate at the exact path above. Prefer
Certbot's DigitalOcean DNS plugin so issuance and renewal do not depend on a
temporary HTTP virtual host. Keep its API token in a root-owned
0600credentials file outside the repository and CircleCI. - Run
sudo nginx -twithout changing the existing production site.
The deployment deliberately does not bootstrap around a missing certificate: strict Nginx validation must fail rather than publish a plaintext or misidentified documentation endpoint.
The API virtual host keeps client_max_body_size 1m for every path except
the chat attachment upload (ADR-0120), whose location allows 33m and
streams the body to the application unbuffered. Activating attachments needs
three changes together: set AGENT_ATTACHMENT_UPLOADS_ENABLED=1, add
artifact.write and knowledge.write to the owner principal's
AUTH_SCOPES, and let deploy-nginx publish the location after the release.
Until the Nginx job lands, an upload above 1 MiB receives Nginx's own 413.
The browser.veetbot.com virtual host keeps client_max_body_size 64k
except on the device sign-in handoff (ADR-0128). That location allows 1m,
streams the body to the browser-profile service unbuffered through an
in-memory buffer, so it is never written to disk, allows ten seconds between
body reads, and waits sixty seconds while the service verifies the session.
Every TLS server block allows only TLS 1.2 and 1.3. Like every Nginx change,
these reach production through deploy-nginx after the release. Once it has
run, check the handoff location from any machine:
head -c 70000 /dev/zero | tr '\0' ' ' | curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' --data-binary @- https://browser.veetbot.com/authentication/00000000-0000-0000-0000-000000000000/handoff
The 70,000-byte body is over the virtual host's 64 KiB and within the
handoff's 1 MiB, and the service refuses a missing capability before it reads
any body. So 401 means the location is live, 413 means Nginx still lacks
it, and 404 means the browser-profile service predates device sign-in. A
small body cannot tell these apart: both locations pass it and it gets 401.
Then check the TLS floor:
openssl s_client -connect browser.veetbot.com:443 -servername browser.veetbot.com -tls1_1 -cipher 'DEFAULT@SECLEVEL=0' </dev/null 2>&1 | grep -Eo 'alert protocol version|Cipher is .*'
It should print alert protocol version and Cipher is (NONE): the server
refused TLS 1.1. A cipher name after Cipher is means the server accepted
TLS 1.1 and the floor is missing. No output means the local openssl could
not offer TLS 1.1, so the check did not run. OpenSSL 3 offers it only with
the lowered security level above, and macOS's LibreSSL rejects that option,
so use OpenSSL 1.1 or later, such as Homebrew's openssl@3.
When a handoff answers 409 tool.browser.provider_unavailable, the
browser-profile service's container log holds one line that says where the
verification stopped (ADR-0128, amendment of 2026-10-06), for example:
WARNING agent_core.browser_control_plane.sessions device verification did not finish (with_session=settle) after 30.0 s
It names the load that stopped the verification and the stage it was in, then
the seconds since the loads were started; docs/plan/browser-automation.md
lists the stages. A time at the thirty-second deadline means the deadline
stopped the named loads, and a shorter one means the named stage failed. Match
the line to a handoff by the time of the 409 in Nginx's access log. The line
holds no identifier and nothing about the website or the session.
BROWSER_PROFILE_DEVICE_SIGN_IN_ENABLED (true when unset) turns device
sign-in off in the browser-profile service without a release. Remote sign-in
is unaffected. Compose reads it from /etc/veetbot/veetbot.env only when it
creates the container, so docker restart and docker compose restart keep
the old value: the container must be recreated. Run the complete block below
as root on the production host, in one shell. It sets the value, takes the
deployment lock, recreates the service from the current release's image with
the release's own Compose invocation, and confirms the container's value. Set
value=true instead to turn device sign-in back on.
set -euo pipefail
value=false
environment_file=/etc/veetbot/veetbot.env
if grep -q '^BROWSER_PROFILE_DEVICE_SIGN_IN_ENABLED=' "$environment_file"; then
sed -i "s/^BROWSER_PROFILE_DEVICE_SIGN_IN_ENABLED=.*/BROWSER_PROFILE_DEVICE_SIGN_IN_ENABLED=$value/" \
"$environment_file"
else
printf 'BROWSER_PROFILE_DEVICE_SIGN_IN_ENABLED=%s\n' "$value" >>"$environment_file"
fi
exec 9>/opt/veetbot/shared/deploy.lock
flock -w 900 9
cd /opt/veetbot/current
set -a
. "$environment_file"
. ./.release.env
set +a
export BROWSER_PROFILE_SERVICE_IMAGE="veetbot-browser-profile-service:$VEETBOT_RELEASE_ID"
compose=(docker compose --env-file "$environment_file"
--project-name "${COMPOSE_PROJECT_NAME:-veetbot}"
-f docker-compose.yml -f deploy/docker-compose.production.yml)
if [[ -n "${BROWSER_PROFILE_VERIFICATION_FILE:-}" ]]; then
compose+=(-f deploy/docker-compose.browser-verification.yml)
fi
"${compose[@]}" up -d --no-build --wait --wait-timeout 60 browser-profile-service
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \
"$("${compose[@]}" ps -q browser-profile-service)" \
| grep -Fx "BROWSER_PROFILE_DEVICE_SIGN_IN_ENABLED=$value"
exec 9>&-
Browser task grants (ADR-0129) are off until the owner turns them on, and
only after every device the owner uses runs a client build that understands
approve_for_task. Turning them on takes three changes together in
/etc/veetbot/veetbot.env: add browser.grant.read and
browser.grant.write to the owner principal's AUTH_SCOPES, set
BROWSER_TASK_GRANT_SCOPES optionally to initial exact site scopes (one
public-HTTPS origin and one path segment each, comma-separated; production
initially used https://www.duolingo.com/lesson), and set
BROWSER_TASK_GRANTS_ENABLED=1. Then restart the API and the workers. Both
settings require BROWSER_PROVIDER=hosted.
After the ADR-0141 migration, the enabled server seeds the owner's persisted list only when no policy exists. Thereafter use Settings → Website Access → Task approval websites on any client to add or remove entries. Changes are shared across clients and workers immediately; no deployment, environment edit or restart is needed. Clearing the list persists an empty policy, so restarts never repopulate it from the seed. Removing an entry ends its active task permissions. Adding an entry enables a task offer; the owner still approves each task. Unsetting the feature flag removes both management routes and task-grant routes and offers.
CircleCI setup¶
Generate a Veetbot-only Ed25519 deploy key on a protected operator machine:
umask 077
ssh-keygen -t ed25519 -N '' -f veetbot-circleci -C veetbot-circleci-production
ssh-keygen -lf veetbot-circleci.pub
Install only the public key for the veetbot-deploy account, using the restricted
authorized_keys options described above. Add the private key to the Veetbot
CircleCI project's SSH keys and do not add the Mankunku key or any personal key
to this project. The deployment jobs intentionally load the project's attached
keys, so the dedicated Veetbot key must be the only one present. Store and
rotate it independently from every other application on the Droplet.
Create a restricted CircleCI context named veetbot-production with:
| Variable | Value |
|---|---|
DEPLOY_HOST |
SSH hostname or IP for the Droplet |
DEPLOY_USER |
Dedicated deploy account, veetbot-deploy once provisioned; veetbot on the current host |
DEPLOY_KNOWN_HOSTS |
Pinned OpenSSH known-hosts record verified out of band |
DEPLOY_PORT |
Optional SSH port; defaults to 22 |
PRODUCTION_URL |
Optional public origin; defaults to https://api.veetbot.com |
DOCS_URL |
Optional docs origin; defaults to https://docs.veetbot.com |
WEBSITE_URL |
Optional website origin; defaults to https://www.veetbot.com |
Obtain the public host-key record from the server or provider console and verify
its fingerprint through a second trusted channel before placing it in the
context. Do not treat an unverified ssh-keyscan response as identity proof.
The context does not contain the API bearer token, database password, or model
provider keys. Those stay in the protected server environment. Restrict context
use to the repository and protected main branch.
macOS TestFlight delivery¶
The apple-testflight job uses two Apple credential boundaries that are
deliberately separate from veetbot-production.
First, upload an active Apple distribution signing identity and the macOS App
Store provisioning profile for com.veetbot.apple to CircleCI's code-signing
store, then create a signing bundle named veetbot-app-store. The profile must
carry the production entitlements used by the Release target, including APNs.
CircleCI installs that identity and profile into its temporary managed
keychain. Do not add the installer identity to this bundle: CircleCI exposes
neither the keychain password nor the source PKCS#12, so productbuild cannot
receive non-interactive access to its private key.
The archive selects Apple Distribution for the app signature. For the
installer package, the job discovers the single installed
3rd Party Mac Developer Installer certificate and selects its SHA-1
fingerprint explicitly for productbuild. Keep those roles separate: the App
Store provisioning profile contains the app distribution certificate, not the
installer certificate.
Create a second restricted context named veetbot-apple-signing with:
| Variable | Value |
|---|---|
APPLE_INSTALLER_CERTIFICATE_BASE64 |
Single-line base64 of a password-protected PKCS#12 containing the Mac Installer Distribution private key, leaf certificate, and Apple WWDR G3 intermediate |
APPLE_INSTALLER_CERTIFICATE_PASSWORD |
The strong, unique PKCS#12 password |
Restrict this context to the Veetbot project and the expression
(pipeline.git.branch == "main" or pipeline.git.branch == "dev") and not
job.ssh.enabled and not (pipeline.config_source starts-with "api"). The job
decodes the PKCS#12 under a mode-restricted temporary directory, imports it into
a fresh random-password keychain, grants only that imported key the
apple-tool:, apple:, and codesign: partitions, passes the exact keychain to
productbuild, and deletes both files on every exit. Keep the original private
key in the owner's protected credential store only if certificate recovery is
required; never place the PKCS#12, password, or decoded key in the repository,
a project variable, cache, workspace, artifact, or log.
The apple-signing-smoke job proves this complete signing path in the
requested dev pipeline that precedes a merge to main (ADR-0107). It runs the same
repository-owned archive and package script as apple-testflight, including
the archived identity checks, application-signature verification,
productbuild, and pkgutil verification. It receives neither the
veetbot-apple-testflight context nor an App Store Connect API key, does not
invoke altool, persists no artifact, and therefore cannot publish its package.
It does receive veetbot-apple-signing, so access to dev and changes to the
CircleCI workflow are nevertheless release-signing security boundaries. Keep
dev restricted to trusted maintainers and require the smoke job to pass on the
exact revision proposed for main.
ADR-0219 also selects the isolated codex/recall-production-release branch.
Before its ordinary requested pipeline, the operator must retain the original
context restrictions and admit only the final CodeRabbit-reviewed revision.
Preserve project/group restrictions and the SSH/API-configuration denials; add
and verify each replacement expression before removing its predecessor. Restore
the original expression after the attempt, including failure or cancellation,
and retain readbacks. The workflow branch selector alone grants no signing
access. This route does not require a push to dev or permit publication from
the release branch.
Second, create a restricted context named veetbot-apple-testflight with:
| Variable | Value |
|---|---|
APP_STORE_CONNECT_API_KEY_ID |
Ten-character App Store Connect key identifier |
APP_STORE_CONNECT_ISSUER_ID |
App Store Connect issuer UUID |
APP_STORE_CONNECT_API_KEY_BASE64 |
Base64 encoding of the downloaded .p8 private key |
The non-secret Apple team identifier remains authoritative in the checked-in Xcode project. The archive uses that Release build setting; do not duplicate the identifier in the restricted context.
Encode the private key without creating another plaintext copy:
APP_STORE_CONNECT_API_KEY_ID=REPLACE_WITH_KEY_ID
base64 -i "AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" -o -
Paste that output as the context value. Add a CircleCI project restriction for
this repository to the context and restrict it to the protected main delivery
path. Treat the managed signing bundle, both Apple contexts, and any CircleCI
role that can reference or replace them as publication authority. The API key
needs only the App Store Connect role and resource access required to upload
Veetbot and manage its signing; it grants no server, model-provider, database,
or Veetbot API authority.
Keep the original .p8 in the owner's protected credential store because App
Store Connect does not offer it for download a second time.
Before enabling the job, confirm that CircleCI's next pipeline.number is
greater than the latest accepted macOS TestFlight build number for the current
marketing version. Then configure the intended TestFlight group for automatic
distribution and enable automatic updates in TestFlight on each Mac. Once CI
owns the build counter, do not make a manual upload with a build number ahead of
that counter.
Automatic delivery¶
A pipeline for any branch other than main starts no hosted workflow by
itself; such a branch is verified on the sidecar and, before it is proposed for
main, by one pipeline requested with run_verify: true (ADR-0107,
docs/plan/development-toolchain.md). main is protected: the seven
verification jobs are required status checks, so a pull request cannot merge
until a requested run reports them green on its head commit; the owner retains
an admin override. A requested dev pipeline also
runs the non-publishing Apple signing smoke described above; the isolated recall
release uses the exact-revision context admission in ADR-0219. On main, a
verification job other than public-site whose source tree already passed it,
normally in that requested run, reports success without rerunning; a merge
commit whose tree nothing verified is verified in full (ADR-0114). After all
seven required verification jobs pass:
public-siteinstalls the locked Node dependencies, builds, tests, and lints the static export, and exposes only that output to downstream packaging;package-releasearchives the exact tested commit, builds MkDocs in strict mode, stamps the website output, and records all three artifacts' SHA-256 values;deploy-appstages the archive, verifies the checksum, and executes the locked server release; anddeploy-nginxfollows a successful application release, takes the same host lock, atomically promotes/opt/veetbot/shared/docs/currentand/opt/veetbot/shared/website/current, and reconciles the versioned virtual hosts. If a newer pipeline has already promoted another application release, the older proxy job detects the release-identity mismatch, reports a distinct stale outcome, and exits without overwriting the newer sites or config. CircleCI skips that older job's documentation and website identity probes because the newer release remains authoritative; andapple-testflightfollows a successful application release in a separate serial group, archives and verifies the macOS application withpipeline.numberas its build number, creates the signed installer package directly withproductbuild, verifies the package signature, and uploads it to App Store Connect withaltooland progress logging. The upload is bound to fifteen minutes:altoolretries a rejected upload part without limit while still logging, so CircleCI's no-output timeout never fires, and on 2026-09-09 one such loop held the macOS runner for over forty minutes. A healthy upload finishes in about a minute; past the bound the step terminatesaltooland fails with exit 124, and a rerun is safe because Apple rejects a build number it has already accepted. This keeps signing and packaging separate from Apple API delivery.
Both deployment jobs use CircleCI's shared production serial group in addition
to the server lock. The release ID is created with the packaged artifact and
reused by failed-job reruns. A rerun of an already active release verifies it
without extracting over the live source directory. So when deploy-app fails
after promotion but https://api.veetbot.com/health/ready already returns the
new X-Veetbot-Release, rerun the workflow from failed: deploy-app verifies
without restarting anything, and deploy-nginx then ships the documentation,
website, and proxy configuration that the failure skipped.
The Nginx installer stores the prior Veetbot configuration under
/etc/nginx/veetbot-backups, runs nginx -t, and reloads Nginx. Validation or
reload failure restores the prior file. So does a failure while installing the
candidate file or activating its symlink. deploy/app/release.sh requires the
local readiness header to identify the staged release and the authenticated
session-index route to respond successfully; after it returns, CircleCI polls
the public readiness endpoint for that exact identity. The Nginx job then
requires both https://docs.veetbot.com/release.txt and
https://www.veetbot.com/release.txt to return the same identity. A public
probe failure is therefore a post-promotion CircleCI failure boundary.
The release verifies every application process through its /proc working
directory. The execution service instead relies on its checked-in ExecStart,
the completed restart, and an active main PID because DynamicUser=yes
deliberately prevents the deploy identity from dereferencing that process's
/proc working directory. A post-promotion failure prints bounded systemd
status for each managed unit before reporting the manual rollback target.
The manual and nightly live-model workflows remain tests; they do not deploy.
The TestFlight upload job does not claim that Apple's asynchronous processing or
device installation has completed; verify those external states in App Store
Connect after the first automated delivery.
Verification¶
After the first successful pipeline, verify the active revision and services.
DEPLOY_USER is the account CircleCI logs in as: veetbot on the current
host, veetbot-deploy once the host is reconciled:
DEPLOY_USER=veetbot
curl --fail --show-error --dump-header - --output /dev/null \
https://api.veetbot.com/health/ready
curl --fail --show-error https://docs.veetbot.com/release.txt
curl --fail --show-error https://www.veetbot.com/release.txt
ssh "$DEPLOY_USER@api.veetbot.com" '
set -eu
app_release="$(readlink -f /opt/veetbot/current)"
. "$app_release/.release.env"
docs_release="$(readlink -f /opt/veetbot/shared/docs/current)"
website_release="$(readlink -f /opt/veetbot/shared/website/current)"
test "$(cat "$docs_release/release.txt")" = "$VEETBOT_RELEASE_ID"
test "$(cat "$website_release/release.txt")" = "$VEETBOT_RELEASE_ID"
printf "%s\n%s\n" "$docs_release" "$website_release"
'
ssh "$DEPLOY_USER@api.veetbot.com" \
'systemctl is-active veetbot-execution veetbot-api veetbot-worker veetbot-async-worker veetbot-maintenance'
Then make an authenticated API request, submit a run, confirm the worker
completes it with a real provider, and run one generated-code task through
runsc. Reboot once and confirm PostgreSQL, Nginx, the execution service, and
all application units return.
The SMS capture ceremony¶
Milestone 24 (device-channel-and-sms.md) lets
the owner's iPhone act as a Veetbot SMS channel: outbound texts ride the
system compose sheet, and inbound texts reach Veetbot only because the owner
wires a Shortcuts personal automation to the app's "Forward Message to
Veetbot" App Intent. Nothing enables this by default. The owner completes the
following one-time, per-device ceremony after the app is installed and paired
(apple-client.md), on the Milestone 18 bootstrap-ceremony
precedent (plan/email-integration.md, "Credentials and the bootstrap
ceremony"): a documented, owner-run, one-time setup step with its own
verification, rather than something CI or the server can perform for them.
- Confirm prerequisites on the owner's iPhone: iOS 17 or later — the
Shortcuts "When I get a message" personal automation trigger requires it;
the device already paired to Veetbot and push-verified through
POST /v1/devicesand the authenticatedPOST /v1/devices/{device_id}/test-notificationroute; and, in the app's connection settings, the "SMS Integration" toggle (sms-integration.enabled) turned on. That one toggle both opens the compose-sheet send path and addsdevice.sms.sendto this device's registered capabilities — turning it off later removes the capability and the App Intent silently stops forwarding. - In the Shortcuts app: Automation tab → the add control → Create Personal Automation → "When I get a message" → set the automation to run immediately, not "Ask Before Running" (it must fire unattended) → add the "Forward Message to Veetbot" action → map the trigger's Sender variable to the action's Sender parameter and its Content variable to the action's Message parameter → finish and confirm the automation is enabled.
- Verify end to end: from a second phone, send the owner's number a short, distinctive test text, then confirm capture actually happened —
- In Shortcuts, open the automation's run history and confirm it ran without a logged failure. This is what catches iOS having silently disabled the automation before the message ever reached the app.
- In Veetbot, confirm the standing SMS triage session shows the forwarded
message (device-channel-and-sms.md, "SMS ingest," which routes each
message into one standing session per device and channel): a new
conversation is seeded, or the existing standing
(device, sms)session continues, with content matching the test text sent rather than an empty or truncated body.
Only a forwarded message with its real sender and body confirms, on this owner's specific iOS version, the design's two open questions: that the automation actually delivers both sender and body to the App Intent, and that the App Intent's Keychain read succeeds with the app backgrounded, which an immediately-run automation leaves it. Treat the ceremony as incomplete until this step passes.
Capture is deliberately best-effort, exactly as designed: iOS can silently disable a personal automation with no notice to the owner or to Veetbot, and the App Intent swallows every forwarding failure — the integration toggled off, the device unresolved, a network error — into a silent no-op rather than surfacing an error on the owner's phone. There is no retry and no alert when a text is silently dropped; the only signal is the absence of the triage session update step 3 above confirms. Re-run step 3 after an iOS upgrade, or whenever inbound capture seems to have gone quiet, to confirm it is still live. The send side has its own fragility: invocation expiry is judged against the device's own clock, so a phone whose clock is materially fast can silently expire a still-live request before the owner ever sees it. And a result the app could not post to the server survives only in that process's memory, so a crash or force-quit between the owner's Send tap and a successful result post can re-present an already-sent message the next time the app fetches, bounded by the server's five-minute invocation expiry.
Manual rollback¶
Schema-head equality is not enough for versioned JSONB discriminators: once any
schedule revision has a definition.cadence.kind of MONTHLY or YEARLY, a
release from before Milestone 20 cannot deserialize the database and is not a
valid code-only rollback target. The runbook below detects the target's cadence
support and checks the stored values while all schedule writers are stopped.
If it refuses the target, roll forward instead, or restore a database snapshot
from before the first such revision and then roll the code back. Never run an
automatic Alembic downgrade as part of rollback. Run the complete block in one
shell so file descriptor 9 holds the same deployment lock from before writer
quiescence through readiness verification.
set -euo pipefail
environment_file="${VEETBOT_ENV_FILE:-/etc/veetbot/veetbot.env}"
exec 9>/opt/veetbot/shared/deploy.lock
if ! flock -w 900 9; then
echo "Could not acquire /opt/veetbot/shared/deploy.lock" >&2
exit 1
fi
target_id=YYYYMMDD-HHMMSS-abcdef0
target="/opt/veetbot/releases/$target_id"
docs_target="/opt/veetbot/shared/docs/releases/$target_id"
website_target="/opt/veetbot/shared/website/releases/$target_id"
test -d "$target"
test -f "$target/.release.env"
test -x "$target/.venv/bin/python"
grep -Fqx "VEETBOT_RELEASE_ID=$target_id" "$target/.release.env"
test -d "$docs_target"
test -f "$docs_target/release.txt"
test "$(cat "$docs_target/release.txt")" = "$target_id"
test -d "$website_target"
test -f "$website_target/release.txt"
test "$(cat "$website_target/release.txt")" = "$target_id"
test -L /opt/veetbot/current
test -L /opt/veetbot/shared/docs/current
test -L /opt/veetbot/shared/website/current
previous_target="$(readlink -f /opt/veetbot/current)"
previous_docs_target="$(readlink -f /opt/veetbot/shared/docs/current)"
previous_website_target="$(readlink -f /opt/veetbot/shared/website/current)"
test -d "$previous_website_target"
test -f "$previous_website_target/release.txt"
test -f "$environment_file"
docker image inspect "agent-core-sandbox:$target_id" >/dev/null
previous_production_image="$(
docker image inspect --format '{{.Id}}' agent-core-sandbox:production
)"
test -n "$previous_production_image"
managed_units=(
veetbot-execution
veetbot-maintenance
veetbot-worker
veetbot-async-worker
veetbot-api
)
for optional_unit in veetbot-schedule veetbot-notify; do
if sudo systemctl is-enabled --quiet "$optional_unit"; then
managed_units+=("$optional_unit")
fi
done
target_calendar_compatibility="$({
cd "$target"
"$target/.venv/bin/python" - <<'PY'
from agent_core.domain.schedules import CadenceKind
required = {"MONTHLY", "YEARLY"}
available = {kind.value for kind in CadenceKind}
print("compatible" if required <= available else "incompatible")
PY
})"
case "$target_calendar_compatibility" in
compatible | incompatible) ;;
*)
echo "Could not determine the target's schedule cadence compatibility" >&2
exit 1
;;
esac
app_next="/opt/veetbot/.rollback-$target_id-$$"
docs_next="/opt/veetbot/shared/docs/.rollback-$target_id-$$"
website_next="/opt/veetbot/shared/website/.rollback-$target_id-$$"
app_restore="/opt/veetbot/.rollback-restore-$$"
docs_restore="/opt/veetbot/shared/docs/.rollback-restore-$$"
website_restore="/opt/veetbot/shared/website/.rollback-restore-$$"
health_headers=""
rollback_pending=0
rollback_on_exit() {
status=$?
trap - EXIT
if test "$status" -ne 0 && test "$rollback_pending" -eq 1; then
set +e
rm -f -- "$app_restore" "$docs_restore" "$website_restore"
ln -s "$previous_target" "$app_restore"
ln -s "$previous_docs_target" "$docs_restore"
ln -s "$previous_website_target" "$website_restore"
mv -Tf "$app_restore" /opt/veetbot/current
mv -Tf "$docs_restore" /opt/veetbot/shared/docs/current
mv -Tf "$website_restore" /opt/veetbot/shared/website/current
docker tag "$previous_production_image" agent-core-sandbox:production
sudo systemctl restart "${managed_units[@]}"
fi
if test -n "$health_headers"; then
rm -f -- "$health_headers"
fi
rm -f -- "$app_next" "$docs_next" "$website_next" \
"$app_restore" "$docs_restore" "$website_restore"
exit "$status"
}
trap rollback_on_exit EXIT
rollback_pending=1
sudo systemctl stop "${managed_units[@]}"
if test "$target_calendar_compatibility" = incompatible; then
incompatible_schedule_revisions="$({
set -a
# shellcheck disable=SC1090
. "$environment_file"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
timeout --signal=TERM --kill-after=5s 20s \
docker compose \
--env-file "$environment_file" \
--project-directory "$previous_target" \
--project-name "${COMPOSE_PROJECT_NAME:-veetbot}" \
-f "$previous_target/docker-compose.yml" \
-f "$previous_target/deploy/docker-compose.production.yml" \
exec -T \
--env PGCONNECT_TIMEOUT=5 \
--env 'PGOPTIONS=-c statement_timeout=5000' \
postgres psql \
--no-psqlrc --tuples-only --no-align \
--username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
--command "SELECT count(*) FROM schedule_revisions WHERE definition #>> '{cadence,kind}' IN ('MONTHLY','YEARLY');" \
| tr -d '[:space:]'
})"
case "$incompatible_schedule_revisions" in
'' | *[!0-9]*)
echo "Could not validate stored schedule cadence values" >&2
exit 1
;;
esac
if test "$incompatible_schedule_revisions" -ne 0; then
echo "Refusing a pre-Milestone-20 target: incompatible schedule revisions exist" >&2
exit 1
fi
fi
ln -s "$target" "$app_next"
ln -s "$docs_target" "$docs_next"
ln -s "$website_target" "$website_next"
docker tag "agent-core-sandbox:$target_id" agent-core-sandbox:production
mv -Tf "$app_next" /opt/veetbot/current
app_next=""
mv -Tf "$docs_next" /opt/veetbot/shared/docs/current
docs_next=""
mv -Tf "$website_next" /opt/veetbot/shared/website/current
website_next=""
sudo systemctl restart "${managed_units[@]}"
health_headers="$(mktemp /opt/veetbot/shared/rollback-health.XXXXXX)"
ready=0
for ((attempt = 1; attempt <= 180; attempt++)); do
: >"$health_headers"
if curl --fail --silent --show-error --connect-timeout 2 --max-time 5 \
--dump-header "$health_headers" --output /dev/null \
http://127.0.0.1:8000/health/ready \
&& awk -F ': *' -v expected="$target_id" '
tolower($1) == "x-veetbot-release" {
sub(/\r$/, "", $2)
if ($2 == expected) found = 1
}
END { exit found ? 0 : 1 }
' "$health_headers"; then
ready=1
break
fi
sleep 1
done
if test "$ready" -ne 1; then
echo "The restarted API did not report $target_id in 180 attempts" >&2
exit 1
fi
rm -f -- "$health_headers"
health_headers=""
test "$(cat /opt/veetbot/shared/docs/current/release.txt)" = "$target_id"
test "$(cat /opt/veetbot/shared/website/current/release.txt)" = "$target_id"
for unit in "${managed_units[@]}"; do
sudo systemctl is-active --quiet "$unit"
pid="$(sudo systemctl show --property MainPID --value "$unit")"
test "$pid" -gt 0
process_cwd="$(readlink -f "/proc/$pid/cwd")"
test "$process_cwd" = "$target"
done
rollback_pending=0
trap - EXIT
The returned X-Veetbot-Release and both public surfaces' release.txt files
must equal target_id, and each unit's
MainPID working directory must resolve to target. The readiness check waits
up to 180 one-second attempts, the same budget as release.sh, because the API
refuses connections until it has started. The matching documentation
releases are preconditions, so a missing or mismatched release.txt fails before
any symlink changes. Image validation and tagging also precede all three pointer
switches. If either public-surface switch, service restart, or readiness check
fails, the exit trap restores all three previous pointers and the previous production image tag,
then restarts the previous release; deploy/app/rollback.test.sh failure-injects
the second switch to verify that recovery. Image retention keeps the active
release's image and its predecessor, so the newest rollback target normally
still has its image; if an older release image was pruned, rebuild it from that
retained source tree before switching.
Nginx backups are independent. To recover one, copy the selected file from
/etc/nginx/veetbot-backups to /etc/nginx/sites-available/veetbot, run
sudo nginx -t, and reload Nginx.
Accepted limitations¶
Milestone 15 (operational-hardening.md, ADR-0065) specifies the backup, restore rehearsal, alerting, firewall, rollback, and watchdog controls that shrink this list to the residual single failure domain; until that milestone lands, the limitations below stand.
This is still a single-server failure domain with no required cloud firewall, off-host database, backup, restore rehearsal, monitoring, load balancer, rolling deployment, or high availability. Loss of the Droplet may mean unrecoverable data loss. The API binds port 8000 only on loopback, so remote clients must use the Nginx TLS hostname. A firewall is still recommended to contain any unrelated service that is accidentally bound to a public interface.
Optional Scale VP data access¶
ADR-0153 is off by default. To enable it:
- On a machine with a browser, from a checkout of the release, run the sign-in once and complete the Scale VP page it opens:
It prints how many read operations the API published to the new grant. If it fails, no file is written. 2. Move that file to the host; do not copy it. The grant has one holder, and a second copy in use ends it. Install it in a directory the service user owns:
sudo install -d -o veetbot -g veetbot -m 0700 /var/lib/veetbot/svp
sudo install -o veetbot -g veetbot -m 0600 credential.json /var/lib/veetbot/svp/credential.json
- Set
AGENT_SVP_ENABLED=1andSVP_CREDENTIAL_FILE=/var/lib/veetbot/svp/credential.jsonin/etc/veetbot/veetbot.envonly, then restart the API and the three workers. Their units allow writes to that directory when it exists.
New chats are then offered mcp.svp_read.list_operations,
describe_operation and call_operation; scheduled runs are not. On the
production roster the three are entries in the deferred tool index, reached
with tool.call, and the production-roster gates include the flag
(ADR-0124). A call that returns svp.credential_rejected after the grant
was working means the grant has ended: repeat steps 1 and 2 with a new file.
Optional Bland calling services¶
Bland setup defines the private configuration, distinct database
roles, separate ingress Linux user and required live verification. The release
script leaves both roles disabled by default. When enabled it validates matching
owner/profile bindings, installs veetbot-call.service and optionally
veetbot-call-ingress.service, then starts them after promotion. That preflight
runs as the deploy identity without sudo. The guide's file permissions let that
identity read both calling environment files and reach, but not read, their
credentials. The proxy adds only the exact /webhooks/bland route when the
active release enables intake.
Verify both units independently alongside the normal public release probes.
Before rolling back across calling support, stop both calling units and restore
the target release's environment and proxy flags; only restart roles that exist
in that release. Do not downgrade the call migration to roll back code: preserve
correspondence and erasure tombstones, following the normal expand-only policy.
The release tree is created with umask 027, and veetbot-call-ingress is
deliberately outside the veetbot group. When ingress is enabled, the release
therefore grants that user read access to the staged release with a recursive
ACL (setfacl -R -P -m u:veetbot-call-ingress:rX) as the last step before
promotion. It runs after the deployment check, whose uv run can reinstall the
project's entry points and would leave a reinstalled agent unreadable. Calling
units have no readiness probe: after the API checks, the release waits until
VEETBOT_CALL_SETTLE_SECS (default 90) have passed since it restarted them,
then requires each enabled calling unit to be active with a main process and
no automatic restart. Otherwise the release fails after promotion, like any
other unit failure. These probes use the calling rules in
deploy/sudoers/veetbot-deploy, so reinstall that contract on hosts
provisioned before calling support.
For ADR-0143 notification reconciliation, include notification.write in the
owner client's AUTH_SCOPES and bearer-token grants. Apply the migration before
starting the new API and dispatcher. The API database role needs SELECT and
INSERT on notification_run_receipts; the notify and surface dispatcher roles
need SELECT (the surface allowlist above includes it). Check these privileges for the deployed database roles and grant any missing
ones before restarting dispatch. Apple clients from before this change still receive
alerts; cleanup and viewed-result acknowledgement require the updated client.
Owner-reviewed dreaming rollout (ADR-0174)¶
Deploy the reviewed-dreaming build and run its normal migration to the current
schema head (daily review state was introduced in ea3210a1b00c). Apply these
same flags to every enabled application role:
AGENT_MEMORY_API_ENABLED=1
AGENT_MEMORY_RECONSOLIDATION_API_ENABLED=1
AGENT_MEMORY_DREAMING_REVIEW_ENABLED=1
AGENT_MEMORY_RECONSOLIDATION_ENABLED=0
AGENT_MEMORY_RECONSOLIDATION_RESIDENCY_PROVIDER=<configured-memory-provider>
The residency provider must match the resolved memory formation provider.
The provider must be configured and available; ordinary privacy/attribution
checks still apply to every source. Retain memory.read,memory.write in the
owner's existing token scopes. The API, interactive/asynchronous workers and
maintenance read /etc/veetbot/veetbot.env; schedule, notify, surface and call
workers read their own /etc/veetbot/veetbot-<role>.env files. Update only the
five flags in each enabled role's file, preserving its credentials and permissions;
do not copy the owner's bearer or database credentials between roles.
Restart the API, interactive and asynchronous run
workers, maintenance, and other enabled application roles so every process uses
the same reviewed-recall mode. Then open https://api.veetbot.com/dreaming and connect with the owner token. Do
not place a token in a URL. No additional proxy route or external web service
is needed: the existing API upstream serves the page and all requests.
The next maintenance sweep claims a daily slice; after that, the schedule is at least 24 hours from its previous claim. Check Run history for proposed, empty, blocked, failed or interrupted outcomes. No provider call is made by opening the page or approving an operation. Inspect the exact statement and supporting originals, select Approve and apply, and confirm. Applied merges can be undone here. Summaries/connections retain the existing memory browser rejection/deletion controls. The HTTPS review session lasts up to thirty days (ADR-0186); reloading reads saved server decisions without another token. Pause previews from Review settings to stop new daily claims; an already running bounded slice may finish.
An empty or blocked run can mean there are no eligible attributed memories; it does not authorize reconstructing lost source evidence or relaxing privacy. Unattended dreaming remains off and still needs the unchanged precision, answer-lift and release-evidence requirements. The review path needs no new blinded packet. To stop reviewed recall and proposals, disable the review flag and restart those same roles; retain the operation API for inspection and undo. Do not downgrade the database after review/scheduling state exists: the migration refuses to discard that history.
Forward-only cutover after legacy attribution loss (ADR-0202)¶
Deploy the current schema head, including migration ea3210a1b00d, before
activation. With the existing protected owner operator environment loaded, run:
Use the exact configured owner identity. The command refuses an active dreaming lease; retry after that bounded slice finishes. A successful retry returns the same cutover, never a later one. It retires queued legacy groups while retaining their records, excludes existing memories and source messages, and leaves the next daily opportunity and spend budgets unchanged. It restores no deleted text.
Verify that the authenticated review page shows the cutover date and that its current legacy blocker is absent. Retained blocked run receipts remain visible. The next daily run may legitimately report no change until enough suitable new evidence exists. Keep supervised review enabled and unattended application off; new proposals still require individual approval. Never remove an activated boundary or replace unknown legacy attribution with empty metadata to unblock work.
Owner-reviewed bounded recall rollout (ADR-0220)¶
The bounded exception uses recall-bounded-evidence@1 and
recall-bounded-admission@1; legacy passing-evidence admission is unchanged.
Do not retag the completed failed comparison as a passing M33 release. The
owner's approval of rollout scope is not a completed human failure review.
Before enabling it, retain and byte-verify the complete original collection, failed attempts and every ledger. Obtain the actual human review of difficult answers and all interruption/uncertain-cost items, including residual-risk findings. Recompute the separate comparative criteria and retain every original quality exception. Record exact measured/target file and dependency differences, their individual dispositions, and verification receipts on the final target. The compatibility claim must cover the actual release tree and effective values, including the PostgreSQL schema, reader, formation and verified local encoder. Unknown material differences require new matching evidence, not a copied hash.
Retain the previous effective configuration and a schema-compatible application, documentation and website rollback revision using the rollback procedure above. Rehearse evidence withdrawal and expiry against the release configuration before cutover. The manifest must name the final reviewed commit, evidence hash, complete target-input manifest, configured tenant/owner, rollback revision and a fixed activation/expiry window no longer than seven days. No wildcard or descendant inheritance is permitted. Supply the two files through the existing semantic index evidence/manifest settings, with the local encoder and query preparation enabled and unattended reconsolidation disabled. Existing request and USD 2 daily indexing ceilings remain unchanged.
Complete CodeRabbit and exact-head CI before merge. Follow application/docs/
website delivery and verify public revision identities, authenticated owner
recall, and sparse fallback. Expiry or observed file/source mutation withdraws
the current admission handle and its query cache; restoring a file does not
revive that handle. For a confirmed access/lifecycle breach or unacceptable
obsolete answer, immediately disable AGENT_MEMORY_SEMANTIC_INDEX_ENABLED,
restore the retained configuration and restart. If an application rollback is
needed, use the compatible retained release without downgrading the database.
Verify readiness and public identities after either action, retain the incident,
and record the rollout outcome before any separately authorized extension.
Application reliability diagnostics and optional alerts (ADR-0205)¶
After deploying the reliability changes, use the existing protected owner
operator environment to run agent run reliability --hours 24 --json.
It reports durable run outcomes, failure groups and current queue health without
selecting prompts, drafts, exception messages or tool result bodies. It does not
count API requests that failed before creating a run. A missing completion rate
means no observed outcomes, and completion latency includes approval waiting.
For a failed task, copy its native diagnostic reference and correlate it with
diagnostic_id in the appropriate service journal. API errors retain their
request ID; search journal request_id to find their diagnostic. First-party
locations, bounded cause classes, error_fingerprint, run/session/tool IDs and
release_id identify the failing code without recording exception text or local
variables. Group recurring errors by fingerprint within a release; compare code
locations across releases, since source movement can change fingerprints.
Keep journal access restricted to authorized operators.
To activate the optional application alarm after review and deployment:
- Verify the PostgreSQL schema is current and the existing notification dispatcher and approved delivery destination work. Run the read-only report.
- Install
deploy/systemd/veetbot-reliability.serviceanddeploy/systemd/veetbot-reliability.timerin/etc/systemd/system/as root. These are operator-managed host units; delivery does not enable them. - Install
deploy/ops/reliability.env.exampleas/etc/veetbot/reliability.env, owned by root and mode0600. Review its one-hour window, minimum ten outcomes, twenty-percent failure rate, three internal errors and 120-second ready-queue threshold. SetAGENT_RELIABILITY_MONITOR_ENABLED=1only when ready for notifications. - Run
sudo systemctl daemon-reload, thensudo systemctl start veetbot-reliability.service. Inspect the journal and outbox delivery. Exit 0 means no current threshold breach, 2 means a degraded observation was recorded, and 1 means the check could not complete. The unit accepts 2 as a successful measurement; timeouts remain failures. - Enable recurrence with
sudo systemctl enable --now veetbot-reliability.timer. Disable it withsudo systemctl disable --now veetbot-reliability.timerand reset the flag to0before rolling back to a release without this command.
--check alone is read-only. Adding --notify requires enabled notification
configuration and writes only alarm transitions and outbox records. Concurrent
checks do not duplicate alerts; recovery and a later new episode each produce
one transition. Quiet traffic does not clear an error-rate alarm. Threshold
changes apply at the next observation; review them as operational changes.
This monitor cannot deliver when PostgreSQL, the host or the notification path is down. Retain an independent external uptime/dead-man channel and complete Milestone 15's backup/restore and host-hardening work. Deployment and activation are separate from branch implementation; neither is implied by passing tests.