Installing¶
Audience: whoever stands the system up — on a laptop for development, or on a host for a demonstration.
What works today. Everything below runs. The stack starts with one command and the ten-minute demonstration runs against it headlessly. The AWS deployment in §5 is written and validated but has not yet been applied against a live account — that line will go when it has.
1. Development environment¶
Requirements¶
| Tool | Version | Why |
|---|---|---|
| uv | ≥ 0.8 | Manages Python and every dependency |
| Docker + Compose | ≥ 29 / v5 | Runs the stack |
| Git | any recent | — |
| Node | ≥ 22 | Only to develop the console; the image builds it |
You do not need to install Python. uv fetches the pinned version (3.12, from
.python-version). The system Python is usually too old — on current macOS it is
3.9 — and is used for nothing except the boundary check, deliberately.
Setup¶
That is the whole installation. uv sync creates the virtual environment, installs
the locked dependencies, and installs each service package in editable mode.
Verify¶
uv run pytest # unit, contract, integration, property
uv run ruff check . # lint
uv run mypy # strict type checking
python3 tools/boundary-check/boundary_check.py
The fast tier runs with no Docker, no network and no model, and finishes in a few seconds. If it does not, something is wrong with the installation rather than with the tests.
The boundary check is run with the system Python on purpose: it must work before any toolchain exists, so it is dependency-free and stays compatible with old interpreters.
Test markers¶
uv run pytest # everything except acceptance
uv run pytest -m unit # pure functions only
uv run pytest -m property # Hypothesis properties
uv run pytest -m acceptance # needs the stack running
uv run pytest -k a4 # "is acceptance criterion A4 passing?"
Acceptance tests skip cleanly when the stack is down, so the fast suite never depends on Docker.
2. Running the stack¶
Then:
| Console | http://localhost:8080 |
| API | http://localhost:8000 |
The console is a client of that same API — there is no endpoint reserved for it, and a contract test asserts so. Anything the console does, a script can do.
Every command¶
./deploy/bin/harness up # start the stack
./deploy/bin/harness up-airgap # start it with no route off the host at all
./deploy/bin/harness down # stop it, keeping data
./deploy/bin/harness reset # stop it, discard data, start clean
./deploy/bin/harness verify # prove the network topology from inside it
./deploy/bin/harness verify-chain # re-walk every audit chain
./deploy/bin/harness criteria # which acceptance criteria are proven, from a real run
./deploy/bin/harness demo # the ten-minute script, headless
./deploy/bin/harness dry-run # three clean runs from reset
./deploy/bin/harness pull-model # fetch local model weights
./deploy/bin/harness logs [svc] # follow logs
./deploy/bin/harness ps # what is running
./deploy/bin/harness shell <svc> # a shell in a service
dry-run is the gate before showing anyone: it resets and runs the whole
demonstration three times, and fails if any claim stops holding.
Signing in¶
Identity comes from Keycloak, running in the stack (ADR 0012). Open http://localhost:8080 and sign in; the console redirects to Keycloak and back.
Four lab accounts, defined in deploy/keycloak/realm-harness.json:
| Username | Password | Role | Organisation |
|---|---|---|---|
nw.analyst |
analyst |
analyst | first |
nw.tradecraft |
tradecraft |
tradecraft owner | first |
nw.admin |
admin |
administrator | first |
co.analyst |
analyst |
analyst | second |
The passwords above are obvious because they protect nothing on a laptop. They are
also only the starting state: that file seeds a new database and is not
authoritative afterwards, so a password you change stays changed, a user you add
stays added, and a session survives a restart. ./deploy/bin/harness reset keeps all
of it; reset-id discards it and re-imports the realm from the file. §6a of the
operating guide has the table.
Identity has a database of its own, on a network with Keycloak and nothing else — it is the only store here holding anything about people, and the services that handle incidents have no route to it.
Automation signs in the same way, with the direct grant rather than a redirect:
TOKEN=$(curl -s -X POST \
http://localhost:8080/auth/realms/harness/protocol/openid-connect/token \
-d client_id=harness-console -d username=nw.analyst \
-d password=analyst -d grant_type=password \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/v1/status
The Keycloak admin console is at
http://localhost:8080/auth/admin
(admin / admin by default; override with KEYCLOAK_ADMIN_PASSWORD).
Statically issued tokens still exist for the in-process test suite, which runs
with no provider at all. A deployed stack sets HARNESS_AUTH_MODE=oidc and then
refuses them on every route — there is deliberately no mode that accepts both, since
that would keep standing credentials behind a login page implying they are gone.
Containers¶
| Container | Role |
|---|---|
ingress |
The only inbound route. Serves the console and proxies the API. |
console |
The analyst UI — static files, no credential of its own |
api |
Public API, orchestration, agents, policy |
audit-store |
Sole writer of every hash chain |
tool-gateway |
The only path to the estate and the only path outward |
model-router |
Chooses a backend; enforces air-gap mode |
local-model |
Locally hosted open-weight model |
egress-proxy |
The only container with a route to the internet |
sim-it, sim-jumphost, sim-ot |
The synthetic estate |
Two more run only on demand: netprobe (profile tools) for the topology proofs,
and model-puller (profile provision) for fetching weights.
Networks¶
Most networks are internal, meaning containers on them have no route off the
host at all. That is what makes the air-gap claim checkable rather than asserted:
there is no egress to block. deploy/topology.expected.yaml writes the whole layout
down and a test asserts the compose file matches it.
3. The local model¶
The local model sits on an internal network and cannot fetch its own weights — that is the property air-gap mode depends on, so it is not worked around. Pulling them is a separate step:
./deploy/bin/harness pull-model # qwen2.5:3b, about 2 GB
./deploy/bin/harness pull-model llama3.2:1b # or another
Weights live in their own volume, so reset does not cost a re-download. Measured
on a development machine: ~400 MB on disk, ~676 MB resident, 1–2 s per call.
4. Configuration¶
Environment variables, read by the services. No secret is baked into an image.
| Variable | Purpose | Default |
|---|---|---|
HARNESS_MODEL_MODE |
stub, replay, hosted or local |
stub |
HARNESS_AIRGAP |
Blocks all egress, forces the local model | false |
ANTHROPIC_API_KEY |
Only needed for hosted |
unset |
HARNESS_LOCAL_MODEL |
Which local model to serve | qwen2.5:3b |
HARNESS_TRADECRAFT_ROOT |
Where tenant playbooks are mounted | /tradecraft |
HARNESS_PRINCIPALS |
The identity roster | /etc/harness/principals.yaml |
HARNESS_AUDIT_ENDPOINT |
The audit store; unset keeps chains in process | set in compose |
HARNESS_IT_TARGETS |
Targets the inventory classifies as IT | set in compose |
HARNESS_OT_TARGETS |
Targets it classifies as OT | set in compose |
The system runs with no credentials at all. The default stub backend is deterministic, which is what lets the whole test suite and the demonstration run offline and for free. A model key is only needed to show a hosted model being swapped for a local one.
A target in neither HARNESS_IT_TARGETS nor HARNESS_OT_TARGETS is unknown, and
unknown is treated as OT. Not knowing what something is must not be safer than
knowing.
5. Deploying to AWS¶
One instance, one region, created and destroyed with one command. The reasoning is in ADR 0013; this is the procedure.
Before the first apply¶
Two things that are easier to do now than to regret later.
- A budget. AWS → Billing → Budgets → a cost budget with an email alert. A stopped instance still bills for its disk, and an instance nobody remembers is the most common way a proof of concept becomes expensive.
- Bedrock model access. AWS → Bedrock → Model access, in the region you are deploying to, and request the Claude models. It is usually granted in minutes, but it is a human step and nothing here can do it for you. Without it the stack comes up and model calls fail with an access error that reads like a bug.
Credentials¶
With IAM Identity Center, which is the better option because nothing long-lived ends up on disk:
Create the instance¶
cd infra
tofu init
tofu plan -var="allowed_cidr=$(curl -s ifconfig.me)/32" \
-var="hostname=harness.example.com"
tofu apply -var="allowed_cidr=$(curl -s ifconfig.me)/32" \
-var="hostname=harness.example.com"
Read the plan before the first apply. allowed_cidr is the only address allowed to
reach SSH and HTTPS, and it refuses 0.0.0.0/0 outright.
Then point DNS at the address it prints:
Configure and start¶
tofu -chdir=infra output -raw inventory > ansible/inventory.ini
ansible-galaxy collection install community.docker
ansible-playbook -i ansible/inventory.ini ansible/site.yml
It waits for Docker, clones the repository, builds the images on the host, fetches the local model weights, starts the stack behind a Let's Encrypt certificate, and then checks that the token issuer matches the address people will use — a mismatch there means login succeeds and every call afterwards is refused, which looks like a broken console rather than a configuration error.
The console is then at https://harness.example.com, signed into exactly as
locally.
Afterwards¶
ssh ubuntu@<ip> 'cat /opt/harness/DEPLOYED' # what is actually running
aws ec2 stop-instances --instance-ids <id> # stop it between sessions
tofu -chdir=infra destroy # remove everything
Stop it when you are not using it. Roughly $48/month running, $3/month stopped
(the disk), nothing destroyed. destroy is the real protection against a forgotten
instance.
What runs differently there¶
| Locally | On AWS | |
|---|---|---|
| Model | qwen2.5:3b in a container |
Claude on Bedrock, in your own account, via an instance role — no API key anywhere |
| Front door | :8080 plain HTTP |
:443 with a real certificate; :80 open only for ACME |
| Identity | Keycloak on localhost |
Keycloak on the hostname, with the issuer following it |
| Air-gap | Still works | Still works — Bedrock is off-host, so air-gap forces the local model |
6. Versioning¶
Calendar versioning: YYYY.M.D.N — year, month, day, and a counter that
increments once per push. 2026.10.15.3 is the third thing pushed on 15 October
2026.
Unpadded on purpose: PEP 440 strips leading zeros, so a padded 2026.01.05.1 would
be reported by pip and uv as 2026.1.5.1, and the file would disagree with the
tooling.
./deploy/bin/version show # what the repository says
./deploy/bin/version next # what a bump would produce
./deploy/bin/version bump # write it everywhere and stage the changes
./deploy/bin/version check # fail if any manifest disagrees
Run bump before committing. It updates VERSION, every service manifest and the
console's package.json together, and stages them. CI then tags v<version> on
main, so a tag always records something that actually reached the remote.
CI fails the push if any manifest disagrees with VERSION, so a forgotten bump is
caught rather than silently shipping a stale number. The running version is on the
console's status bar, in GET /health, and in GET /v1/status.
This is not a compatibility signal
A calendar version says when a build was made, not what changed. The playbook
schema keeps its own semantic schema_version precisely because customers write
files against it and need to know whether theirs will still load. The two are
different things and must not be conflated.
7. Troubleshooting¶
| Symptom | Cause |
|---|---|
uv sync picks the wrong Python |
.python-version pins 3.12. Run uv python install 3.12. |
ModuleNotFoundError for a service package |
Run uv sync again — packages install in editable mode. |
| Acceptance tests all skip | The stack is not running. ./deploy/bin/harness up. |
The local model answers unreachable |
Weights were never pulled, or reset -v removed them. ./deploy/bin/harness pull-model. |
| Boundary check fails after editing a playbook | Expected. See below. |
| CI fails on formatting but local passes | uv run ruff format . |
"The boundary check failed and I only edited a playbook"¶
The check derives its forbidden words from the committed playbooks — organisation ids, playbook ids, rule ids, context set names. Name a rule something the platform's own source also uses and that word becomes forbidden everywhere, so an unrelated file starts failing.
A dedicated test catches this in the playbook change itself and names the collision. The fix is to rename the rule, not to add an exception.
Related¶
- Glossary — any term here that did not land the first time
- Operating the harness — administrator reference
- Using the harness — analyst reference
- Authoring tradecraft — writing playbooks