# Soot on an owned VM

Deploy a pinned Linux Soot runtime and an independently reviewed C1 config bundle
as a named app. The runtime owns validation, source transactions, activation,
deployment acknowledgments and config rollback. 2server stages verified inputs
and supervises the process through the native `sourceDeployment` capability.
It does not rebuild Soot, fetch packs, infer a singleton or copy live databases.

The [example](https://github.com/2found/2server/blob/88879734d602decb309ca11bc3a3132bfffd659e/examples/soot/app.yaml) uses placeholder registry and DNS names.
Its receipt describes runtime commit `afb1e0d568071d59fe7d4ff7e3cf88e872915b4d`,
version `0.1.0-ac-config-review`, Linux amd64, and OCI manifest
`sha256:35c9af6ab29b4ffea7374a1233b677afef1a79188b8188eba1d0642451f24f23`.
That local producer receipt does not establish registry availability. The example
AI policy is unconfigured; choose a real provider/model and credentials before
source activation, then review the bundle digest with the producer's tooling.
No example is an authorized deployment target.

## Source and mount contract

```sh
2srv init app assistant --template soot -o platform/assistant.yaml
2srv validate -f platform/assistant.yaml
2srv secret set --app assistant --env-file /private/assistant.env --apply
2srv plan -f platform/assistant.yaml --plan-output /private/assistant-plan.json
```

Set `spec.image` to a registry repository with an exact `@sha256:` pin and
`spec.runtimeReceipt` to the matching producer receipt. `spec.bundle` is
optional: omit it for a bare runtime whose Soots arrive as `kind: SootSource`
slices (below), or set it to an already materialized offline C1 directory
containing `soot.bundle.json` for the legacy single-bundle deploy.
Locators resolve relative to the App file. `tokenEnv` names an app-scoped VM
secret containing at least 32 URL-safe characters; no secret value enters a
document, displayed diff or process argument. Named instances cannot use global
or local `.env` values. Every instance has its own container, credentials and
`/opt/2server/data/NAME` store. Changing state/auth identity requires an explicit
operator migration, not a source update.

`spec.secretEnv` lists further app-scoped VM secrets the runtime reads from its
environment, such as the Turnstile secret named by
`public_web.challenge_secret_env` (which admission requires to be listed). Each
must also be declared under the document's `secrets:`. 2server writes them to a
private `credentials/app.env` (0600, replaced atomically) before every
initialize, handoff and restart, so `secret set` followed by `restart` rotates a
value; removing a key from `secretEnv` deletes the file at the next start.
Values are limited to letters, digits and `_.:+/=-`, and `tokenEnv` cannot be
repeated.

The operator token (`tokenEnv`) is different: it is written to
`credentials/operator.env` once, at initialize, and `restart` checks the runtime
with the VM secret *before* restarting, so after `secret set` it answers
`Soot unauthorized: … rotate-token --apply`. Rotate it in two steps:

```sh
2srv secret set --app assistant --env-file /private/new-token.env --apply
2srv app assistant rotate-token --apply   # replaces operator.env, restarts, waits for /readyz
```

The old token stops working at once; every connected Nest must enter the new one.

The candidate must use `listen: 0.0.0.0:7788`, a relative inventoried `ai_config`,
the matching `token_env`, and `data_dir: /state` (portable `data`/`state` is also
accepted before C3 normalizes it). Shared AI owns `credentials_dir: /credentials`.
All config, prompt, skill, pack, archive and installed receipt inputs remain
bounded and checked. Links, hardlinks, traversal, case collisions, unknown C1
fields, private/compiled content, mismatched digests and unsupported executable
closures fail closed. The measured image supplies `/bin/sh` and `/bin/timeout`;
selected programs must fit that closure. Runtime admission repeats on prepare
and commit. Full runtime config semantics are enforced by Soot.

Installed pack receipts must match the local absolute materialization directory;
2server relocates only their installation paths for the fixed VM inbox. Git
packs require an already obtained archive at
`BUNDLE/obtained-archives/ARCHIVE_SHA256.tar`; its bytes and installed tree are
verified offline, then staged into the instance's private runtime archive cache.
Plans never install, fetch or refresh a pack. No source digest is recomputed to
silently accept changed operator inputs.

| Container path | VM storage | Lifetime |
| --- | --- | --- |
| `/config` | `extensions/NAME/config` | Writable active config; runtime-owned saves |
| `/transactions` | `extensions/NAME/transactions` | Persistent C3 fences, acknowledgments, snapshots and inbox |
| `/state` | `/opt/2server/data/NAME` | Persistent conversations, receipts and monitoring |
| `/credentials` | `extensions/NAME/credentials` | Persistent token file, vault and private per-instance home |
| `/release` | `extensions/NAME/releases/UUID` | Read-only reviewed source, runtime receipt and supervisor definition |

Paths under `extensions/` are beneath `/opt/2server/`. Reviewed source is retained
under `releases/UUID/source`, separate from `/config`; C3 receives a private
inbox staging tree. Both trees must match the reviewed file/directory inventory
before commit and after a code handoff, including installed receipt bytes.
Restart never copies source defaults. Bind mounts forbid Docker from creating missing host paths.
The runtime runs as `65532:65532` with a read-only root and no capabilities.
Before each start the host adapter hands the four writable mounts to the user
the release's supervisor names, without following links or crossing mounts;
`.2server-owner`, `operator.env` and `app.env` stay root-owned. The mounted
release stays root's; the runtime's group may only traverse it and read
`receipt.json`, which it verifies at startup. Restoring an older release that
names no user hands the mounts back to root.
Foreign ownership, overlapping state roots and altered mounts block effects.

## Two explicit reviewed phases

On a fresh instance, plan inspects absence and creates a private local artifact.
Review its server, instance, control revision and source/image/receipt pins;
set `reviewed_initialization: true` in that exact artifact, then apply:

```sh
2srv deploy -f platform/assistant.yaml --plan-file /private/assistant-plan.json --apply
```

Initialization is create-only. It installs an inert management definition with
no AI provider because Soot requires one definition even in management mode.
It establishes authenticated management and persistent mounts; it does not
apply the authored source or publish traffic. A nonempty/partially initialized
root is a recovery condition, never permission to reset it. Plan again after
initialization and use a new artifact path.

Without `--plan-file`, `deploy -f FILE --apply` plans and applies in one step:
initialization, a package-only upgrade and an unchanged redeploy are approved as
planned; a source replacement asks exactly as for a SootSource below.

The source phase reads `GET /v1/settings/deploy/state`. Its `DeployPlan` binds
the request, runtime identity, baseline D ID/generation/digest, persisted P and
active A revisions, checked fingerprints F, candidate source/package/compatibility,
staging UUID/digest and exact difference digest. Review only this safe scope and
digest output; mission contents and credential values are not displayed.
If there is no D, add `plan.reviewed_replacement` with
`kind: initial_source_review` and an exact copy of `plan.bindings`. If P/A/F
has drifted since D, use `kind: reviewed_replacement` with those exact bindings.
An operator must explicitly make this decision after review. An agent must not
invent it merely because `--apply` was requested. `--apply` alone cannot approve
source replacement, and refreshing current revisions does not approve edits
since the last deployment. Raw changes require runtime `source.review` first.

Apply holds the normal VM control lock, compares local/control/host/runtime
facts again, stages exact bytes, and submits C3 prepare/commit. Any stale binding
requires abort/replan/review. The runtime lease independently excludes settings
writers and detects changes before writes and activation. Async activation is
polled through the same request's sanitized transaction receipt, including brief
listener handoffs. No ambiguous POST is replayed with a new ID.

Only acknowledged source and package plus `/readyz` permit supervisor pointer
publication. Shared domain/DNS/TLS reconciliation then publishes the declared
Domain. HTTPS `/readyz`, missing-bearer denial and authenticated active receipt
verification must pass. A runtime acknowledgment followed by edge/control failure
is reported as an unresolved later phase; inspect retained private recovery and
the runtime receipt before retrying. Readiness does not prove provider delivery
or a model smoke test.

A `public_web` listener (for example `0.0.0.0:7790`) serves only `/public/*`
and has no `/readyz` or bearer API, so publish it as a separate `kind: Domain`
targeting `CONTAINER:7790` with `healthPath: /public/widget/loader.js`, not
under the App's `domains:`. Operator verification (source apply and restore)
covers only domains routed to `:7788`; purge still refuses while either routes
to the container. The public listener takes the visitor address from
`X-Forwarded-For` only from `public_web.trusted_proxies`.

## Bare runtime and SootSource slices

A bare runtime App (no `spec.bundle`) installs the runtime and nothing else.
Providers, tiers, the vault, `default_soot`, routes and public-web policy are
host settings managed in Nest; no deploy ever replaces them. Each repository
then owns one source slice (`sources.d/<id>/` on the runtime) and deploys it
independently. The recommended repository layout keeps the bundle and its
deploy document in one directory:

```sh
soot spawn --source lohi2 soot-source            # creates soot-source/soot/source.json
2srv init soot-source lohi2 --runtime soot -o soot-source/deploy.yaml
# edit soot-source/soot/soots/... then
soot bundle --refresh soot-source
2srv deploy -f soot-source/deploy.yaml --apply
```

```yaml
apiVersion: 2server.app/v1
kind: SootSource
metadata: {name: lohi2}        # source id; spec.source overrides it (e.g. 2found)
spec:
  runtime: soot                # the installed Soot App on this server
  bundle: "."                  # this directory
```

A repository needs neither the runtime App file nor its secrets: `spec.runtime`
names the App already installed on the server. In a checkout with no
`.2server/connection.yaml` of its own, run `2srv connect` once (the file is
gitignored); later deploys find it.

`init soot-source` validates the id and, when it is not a valid 2server name
(`2found`), writes `metadata.name: source-2found` with `spec.source: 2found`.

The bundle entrypoint is `<root>/source.json`; its `id` must equal the
document's source id and it may not set listener, state, AI or credential
fields. Soots pick a `tier`, never a provider. Plan reads
`GET /v1/settings/deploy/state?source=<id>`: fingerprint, baseline and drift
cover only that slice, so a provider change or another repository's deploy is
never drift.

`deploy --apply` plans, prints the decision and applies in one step:

| Runtime state of the slice | Decision | One-step deploy |
| --- | --- | --- |
| Deployed before, no Nest edits since | `none` | applies |
| Never deployed and empty (`source_files: 0`) | `safe` (`initial_source_review`) | applies |
| Never deployed but holding files | `replace` (`initial_source_review`) | asks |
| Edited in Nest since its last deploy | `replace` (`reviewed_replacement`) | asks |

A `replace` decision asks the operator to type the source id on a terminal; a
non-interactive run refuses unless the operator passed `--replace-edits`. An
agent must not pass `--replace-edits` on its own: the edits it discards were made
by a person in Nest. The two-step `plan --plan-output` / `deploy --plan-file`
review stays available and takes the same `reviewed_replacement` field as
above. Apply stages into the runtime inbox and runs C3
prepare/commit with `bindings.source`; it never hands off the process, publishes
a release pointer or changes VM control state. A source deploy rides the package
already running: a different package is a runtime App upgrade.

Planning the bare runtime App again reports `none` when the running package
matches, or `upgrade` when `spec.image`/`runtimeReceipt` changed. An upgrade is a
reviewed package-only handoff that keeps config, sources, state and vault; if the
new package does not come up with the same active configuration, the previous
release is restored. A runtime with deployed sources refuses a legacy complete
bundle deploy (`sources_present`), which would delete every slice.

## Restart, handoff and guarded restore

```sh
2srv app assistant release-status
2srv app assistant restart --apply
2srv app assistant rotate-token --apply
2srv app assistant restore-release --plan-output /private/restore.json
# Review scope and exact request; set reviewed_restore: true in this artifact.
2srv app assistant restore-release --plan-file /private/restore.json --apply
```

Restart uses the acknowledged installed release and retains saved/active edits;
it never resolves local bundle locators. A package replacement retains the C3
lease, disables old supervisor restart, sends TERM and waits for exit/store close
before creating the replacement on the same mounts. An uncertain stop or timeout
blocks replacement; there is no forced concurrent bbolt opener or live DB copy.
Compatible prior runtime receipts/supervisor files are retained for restore.
If replacement readiness or staged-byte verification fails before source commit,
the adapter joins the candidate and starts the retained supervisor while keeping
the C3 lease, then aborts that same lease through the recovered runtime. A blocked
close or recovery retains the fence and reports the request for inspection.

Restore binds current P/A/F, exact prior revision/package and `soot/api1-store1`.
Concurrent edits, unavailable snapshots/packages and incompatible stores refuse
restoration. The runtime restores config through its journal and acknowledges a
new active generation; vault, operator credentials, conversations, accepted-run
history, receipts and monitoring are not restored to old values. Different-code
restore uses the same stop/join handoff and repeats the same fenced request.
The source App remains the desired configuration; inspect active release status
after restore before planning another source deployment. Core `rollback` is not
the Soot restore workflow.

Removal (`app NAME delete --apply`) explicitly stops only the owned process and
retains roots, secrets, releases and snapshots. Retire/reroute declared domains
first; DNS retirement is separate.
To copy the stopped store before removing it, purge afterwards from the
source file: `2srv delete -f soot.yaml --purge --apply`.

`app NAME delete --purge` prints the removal plan; with `--apply` it removes
the instance completely. It uses the same guards as retirement: the
app-scoped operator token must still be present, no declared Domain may route
to the container, and under the instance and edge locks the host refuses a
live edge route, then TERM-joins a running owner exactly like retire. Only
after that does it `docker rm` the stopped container (never forced), delete
`extensions/NAME` (config, transactions, vault/credentials, releases and the
reviewed sources) and `/opt/2server/data/NAME` after verifying both ownership
markers, remove release and spec images no container uses (in-use images are
reported, not forced) and unlink the instance lock. The CLI then drops the
app's VM secret namespace, its source deployment records and the manifest
entry. A foreign marker, foreign container labels or altered mounts refuse
the purge with nothing removed; a retry after a partial failure is safe. Old
control revisions and edge releases are counted in the summary, not rewritten;
see [extension purge](https://2found.dev/docs/2server/extensions/index.md#removing-an-app-completely) for the audit
commands. Purge is irreversible: take any data you still need first. Interrupted initialization, prepared leases, source conflicts and
missing mounts require inspection, not recursive cleanup or force unlock.
No database backup/DR contract is claimed: encrypted control backup excludes
Soot databases and volumes. A future data backup requires a runtime-supported
snapshot or stopped-store procedure; never copy a live bbolt file.

## Verification boundaries

Offline schema/digest checks, mocked adapter races and an opted-in isolated
Linux runtime fixture are separate evidence levels. Use
`DOCKER_TESTS=1 SOOT_RUNTIME_IMAGE=VERIFIED_LOCAL_IMAGE bun test tests/soot-runtime.test.ts`
after obtaining the exact package. The fixture uses temporary mounts, loopback
ports and a local trusted TLS certificate, exercises C3/edit conflicts/restart/
restore and reads retained history through the owner API. It never publishes an
image or copies a live DB. It is not public DNS, registry or staging VM evidence.
Live acceptance requires a dedicated consuming-project target, provider/registry
availability, owned DNS and trusted public HTTPS evidence. Keep that fixture and
its credentials outside this product repository.

From a checkout, `bun scripts/test-soot-vm.ts --fixture PRIVATE_FILE` validates two
named sources and inspects a pre-existing dedicated server before planning. The
strict private JSON fixture has `version: 1`, `nonproduction: true`, `server`,
`connectionFile`, an independently reviewed `providerIdentityDigest`, and exactly
two distinct `instances` with `name`, `sourceFile`, `planOutput`, and optional
`reviewedPlan`, `restoreOutput` and `reviewedRestore` paths. Paths resolve relative
to the private fixture. A mismatched target prints only its inspected identity
digest and stops; do not automatically accept that digest. `--step` selects
`plan`, `deploy`, `status`, `restart`, `restore` or `retire`; `--instance NAME`
narrows the scope and remote effects require `--apply`. This driver never creates
a VM, imports/publishes a registry image, sets secrets or fabricates review.
