Decides whether the Hermes platform handoff is fit to release, and refuses to round an absence of evidence up to a pass. The harness is read-only by default and classifies 71 checks PASS / FAIL / NOT_RUN / NOT_APPLICABLE. Any mandatory FAIL or NOT_RUN is NO_GO, and so is a harness-level problem: an unreachable vantage, a catalog entry whose evidence no longer exists, an expired deadline, or an evaluator that raised. Evidence comes from two vantages that cannot cover for each other: an external read-only operator kubeconfig, and the Hermes agent probing itself from inside its own pod. Before any check runs, the harness asks each vantage who it is and stops if they are the same principal, because dual-vantage evidence from one identity is a restatement rather than a corroboration. `--as` is rejected for every operator-side command and reachable only as the inner command of a `kubectl exec`, so impersonation can never stand in for a real self-probe. A deny check needs a live refused request, not only an authorization review. Two safety properties are structural rather than conventional, enforced where an argv becomes a subprocess: the default mode mutates nothing (mutating verbs require a server dry run; there is deliberately no live TokenRequest probe, because a successful one would mint a real credential), and no probe can pull a credential value into a report (no vault/sops/curl, secrets readable only with -o name, environment probes list names, shell only through frozen reviewed templates). Captures are bounded before they are screened, and the rendered report is re-screened before it is written. Mutation lives behind a separate arming flag with an exact confirmation phrase, a caller-supplied unique ref, a preflight that refuses a protected push target before any network call, and a cleanup whose verification is itself mandatory. A default run reports those four checks NOT_RUN. The catalog is declarative so a reviewer reads what is asserted rather than how it is plumbed, and so structural properties can be proven over every entry before a run. Catalog drift surfaces as NOT_RUN, which stops the release. docs/hermes_full_handoff_acceptance.md carries the merge order for PRs #14-#18 on top of the merged #13 baseline, the image build and Flux rollout, the rollback point for each step, the go/no-go checklist, and the limits that are asserted rather than exercised. Validation: 295 handoff tests pass with 100% line coverage on all 15 new modules; the full unit suite is 647 passed with two failures that reproduce unchanged on origin/main; Ruff, py_compile, kustomize render, and a diff credential screen are clean; a live read-only run against Atlas returns NO_GO for the pre-merge cluster with no unscreened fields in the report.
324 lines
19 KiB
Markdown
324 lines
19 KiB
Markdown
# Hermes full-handoff acceptance harness and release runbook
|
||
|
||
`scripts/ops/hermes_handoff_acceptance.py` decides whether the Hermes platform
|
||
handoff is fit to release. It is read-only by default, it classifies every check
|
||
`PASS` / `FAIL` / `NOT_RUN` / `NOT_APPLICABLE`, and **any mandatory `FAIL` or
|
||
`NOT_RUN` makes the whole run `NO_GO`**. Absence of evidence is never rounded up
|
||
to evidence of absence: a probe that could not run, a catalog entry whose
|
||
evidence no longer exists, an expired deadline, or a harness bug all land in
|
||
`NOT_RUN` and stop the release.
|
||
|
||
```
|
||
scripts/ops/hermes_handoff_acceptance.py \
|
||
--context atlas-operator \
|
||
--node-count 21 \
|
||
--expected-suspension flux-system/bstein-dev-home-migrations \
|
||
--output build/hermes-handoff-acceptance.json
|
||
```
|
||
|
||
Exit status is `0` for `GO` and `1` for `NO_GO`. The JSON report goes to
|
||
`--output` (or stdout); a short human summary goes to stderr unless
|
||
`--json-only` is passed.
|
||
|
||
## Why two vantages
|
||
|
||
Every authority claim is checked from two places that cannot cover for each
|
||
other:
|
||
|
||
| Vantage | What it is | What it proves |
|
||
| --- | --- | --- |
|
||
| `operator` | An external, read-only kubeconfig | Desired state: bindings, manifests, workloads, Flux health, forge state |
|
||
| `self` | `kubectl exec` into the running `hermes-agent` container | Enforced state: what the agent's own service-account token can actually do |
|
||
| `switchyard`, `node`, `chat` | `kubectl exec` into the Switchyard, node-hardening, and chat pods | Evidence that only exists inside those workloads |
|
||
|
||
Two rules keep this honest:
|
||
|
||
* **The harness cannot impersonate from outside.** `--as`, `--as-group`, and
|
||
`--as-uid` are rejected by policy for any operator-side command. They are
|
||
reachable only as the *inner* command of a `kubectl exec`, where the identity
|
||
is testing its own refusal to impersonate rather than borrowing authority.
|
||
`kubectl auth can-i --as` is therefore not available as a substitute for a
|
||
real self-probe.
|
||
* **The two vantages must be different principals.** Before any check runs, the
|
||
harness asks each vantage `kubectl auth whoami` and compares. If the operator
|
||
and the in-pod probe authenticate as the same principal, the dual-vantage
|
||
evidence is a restatement rather than a corroboration, and the run is `NO_GO`
|
||
with `the operator and in-pod vantages authenticate as the same principal`.
|
||
Run the harness from a genuinely external kubeconfig — not from inside the
|
||
Hermes owner pod.
|
||
|
||
Deny checks additionally require a **real refused request**, not only an
|
||
authorization review. `kubectl auth can-i` reports what RBAC says; only an
|
||
attempt proves the API server enforces it. Mutating attempts go through
|
||
`--dry-run=server`: the API server authorises the request and then discards it,
|
||
so a refusal is genuine and nothing is written either way.
|
||
|
||
## Safety properties
|
||
|
||
These hold structurally, in `hermes_handoff_policy.py`, so a catalog mistake is
|
||
a policy error before a run rather than a mutation or a leak during one.
|
||
|
||
* **No mutation in the default mode.** Only read subcommands are reachable;
|
||
`apply`, `create`, `delete`, `patch`, `replace`, and `scale` require a dry-run
|
||
flag. `git push` and every non-`GET` forge method are refused outside the
|
||
armed mode. There is deliberately no live TokenRequest probe — a successful
|
||
one would mint a real credential.
|
||
* **No credential is ever read.** `vault`, `sops`, `age`, `gpg`, `openssl`,
|
||
`curl`, and `wget` are not on the binary allowlist. Secrets may only be read
|
||
with `-o name`. Environment probes list variable *names* and never values.
|
||
Provider health files are read through a frozen template that projects an
|
||
allow-listed set of non-secret status fields. The node account's lock state is
|
||
reported without reading its password hash; its authorized keys are counted,
|
||
never printed.
|
||
* **Shell is frozen.** `sh -c` is reachable only through the reviewed,
|
||
parameter-validated templates in `SHELL_TEMPLATES`. Parameters may not contain
|
||
shell metacharacters.
|
||
* **Output is bounded and screened.** Each capture is truncated to a byte budget
|
||
*before* it is screened, so a large dump cannot outrun the screen. Bulk
|
||
evidence (the routing-log tail, the Kanban listing) is parsed but replaced with
|
||
`[output not recorded]` in the artifact. The screen preserves JSON quoting, so
|
||
screened structured output is still parsable.
|
||
* **The report is re-checked before it is published.** `unscreened_fields` walks
|
||
the rendered payload; if anything still looks credential-shaped the report is
|
||
replaced with a `NO_GO` stub naming the offending pointers, and the file that
|
||
would have leaked is never written.
|
||
|
||
## What the catalog asserts
|
||
|
||
The catalog is declarative (`hermes_handoff_catalog.py` plus the four
|
||
`hermes_handoff_checks_*.py` modules) so a reviewer reads what is asserted, not
|
||
how it is plumbed. 71 checks in 14 groups:
|
||
|
||
| Group | Assertions |
|
||
| --- | --- |
|
||
| `baseline` | `origin/main` descends from the merged worker-isolation baseline; every dependency pull request is merged into `main`; the exact open pull-request heads are recorded |
|
||
| `identity` | Codex authenticates as `codex-chatgpt-subscription`; Claude as `firstParty` / `claude.ai` / `claude-code-cli-subscription`; both evidence files are fresh; no provider API-key variable exists in the running process **or** in the manifest |
|
||
| `routing` | Switchyard has a ready replica; the routing log covers both providers, the `route` and `worker` lanes, medium/high/xhigh effort, and at least one real fallback; the newest record is fresh; both providers record a measured latency |
|
||
| `scopes` | Chat, agent, and triage each declare their own `HERMES_AUTO_ROUTER_PROFILE`, on distinct hostnames |
|
||
| `access-denied` | No cluster-admin binding for the agent; Secrets, service-account tokens, impersonation, workload mutation, arbitrary pod exec, attach, port-forward, and control-plane writes are all refused |
|
||
| `access-allowed` | get/list/watch on core objects, pod logs, and Flux/Helm status still work; the operator and in-pod namespace inventories agree |
|
||
| `gitops` | No unexpected Kustomization or HelmRelease suspension, and nothing unhealthy |
|
||
| `forge` | No forge credential in the worker environment or home; the forge identity is not an administrator; repository authority is pull/push only; an administrative route is live-refused; clone/fetch still works |
|
||
| `nodes` | The dedicated `hermes-agent` account is locked, has the expected uid/gid/home/shell, joins no supplementary group, has no sudoers fragment, holds exactly one authorized key; K3s credentials, K3s state, kubelet state, and the container runtime are root-only; the audit covers every node |
|
||
| `build` | The image builder service account mounts no token; the build pod is unprivileged with exactly `CHOWN, FOWNER, DAC_OVERRIDE, SETGID, SETUID`; the Harbor immutable-tag job succeeded; the running image is digest-pinned |
|
||
| `reliability` | The finalization/replay/session patches are running in the pod; the regression suites are present on `main` |
|
||
| `pool` | Three ready workers, on distinct nodes, with distinct volumes, mounting no API token and none of the coordinator's claims |
|
||
| `surfaces` | Kanban activity and durable sessions are visible and render without blank rows; the agent and chat session patches are deployed; Telegram topic state is on durable storage |
|
||
| `ephemeral` | Feature-branch push, draft pull request, protected-branch refusal, and verified cleanup — `NOT_RUN` unless armed |
|
||
|
||
### Site configuration
|
||
|
||
A few checks are emitted only when the operator declares what to expect,
|
||
because a `names_present` assertion over an empty expectation would pass on any
|
||
workload at all — which reads as coverage without being any:
|
||
|
||
| Flag | Enables |
|
||
| --- | --- |
|
||
| `--pool-worker-env NAME` (repeatable) | `pool.assignment-safety-knobs-are-configured` |
|
||
| `--chat-config-revision REV` | `surfaces.chat-runs-the-telegram-topic-revision` |
|
||
| `--expect-telegram-sessions` with `--chat-ordinal N` | `surfaces.chat-telegram-sessions-are-continuous` |
|
||
|
||
`--node-count` must be the real node count; the default of `1` makes the
|
||
fleet-coverage check vacuous.
|
||
|
||
### Catalog drift is a NO_GO, not a pass
|
||
|
||
If a workload, field, or manifest is renamed, the affected checks report
|
||
`NOT_RUN` because the evidence they name is no longer there. That is the
|
||
intended behaviour: the release stops until either the platform or the catalog
|
||
is corrected. Do not "fix" a `NOT_RUN` by deleting the check.
|
||
|
||
## Ephemeral mutation mode
|
||
|
||
Proving that a worker can still open a draft pull request means actually opening
|
||
one. That is the only part of the harness that writes anything, and it is a
|
||
separate mode:
|
||
|
||
```
|
||
scripts/ops/hermes_handoff_acceptance.py \
|
||
--context atlas-operator \
|
||
--arm-ephemeral-push \
|
||
--confirm "ARM EPHEMERAL HERMES HANDOFF PUSH" \
|
||
--ephemeral-token acceptance-20260817a \
|
||
--output build/hermes-handoff-acceptance-armed.json
|
||
```
|
||
|
||
* The confirmation phrase must match exactly.
|
||
* `--ephemeral-token` is 8–64 lowercase alphanumeric/dash characters and must be
|
||
unique per run; the ref is always
|
||
`ephemeral/hermes-handoff-acceptance/<token>`.
|
||
* **Preflight runs before any network call**: wrong phrase, wrong repository,
|
||
malformed token, or a protected push target all abort with `arming refused`
|
||
and a `NO_GO` report. `main`, `master`, `HEAD`, `develop`, `release`,
|
||
`production`, `stable`, `trunk`, `prod`, and `default` are refused by name
|
||
(case- and `refs/heads/`-insensitive), and anything that is not an ephemeral
|
||
acceptance ref is refused by pattern.
|
||
* The guard is **exercised on every armed run** by
|
||
`ephemeral.protected-branch-refusal`, rather than trusting that it was checked
|
||
once.
|
||
* The push target is guarded; the pull-request *base* is not. Opening a draft
|
||
pull request against `main` writes nothing to `main`. The draft title always
|
||
starts with `WIP: `.
|
||
* Cleanup closes the pull request, deletes the ref, and then **re-reads both** to
|
||
confirm they are gone. `ephemeral.cleanup-verified` is mandatory: a half-removed
|
||
branch fails the run rather than passing it. Cleanup runs even when an earlier
|
||
step failed.
|
||
* `git push --force`, `--force-with-lease`, `--mirror`, `--all`, and `--tags`
|
||
are refused even in armed mode.
|
||
|
||
A default run reports all four ephemeral checks `NOT_RUN` and non-mandatory, so
|
||
a read-only sweep can still reach `GO`.
|
||
|
||
## Release order
|
||
|
||
### Baseline, already merged and deployed
|
||
|
||
PR #13 (`hermes: harden worker isolation and blocked-task semantics`) merged as
|
||
`ab346f55509d584e457fe26cf90be3078f7a375c`. Every check below assumes it is
|
||
present; `baseline.origin-main-descends-merged-work` asserts it. Override with
|
||
`--baseline-commit` only when a later merge legitimately replaces it.
|
||
|
||
### Pending work, observed 2026-08-17T10:07Z
|
||
|
||
These heads move. The harness re-fetches them at run time via
|
||
`baseline.pending-pull-requests-recorded`; the table is the snapshot this
|
||
document was written against, not an input.
|
||
|
||
| PR | Branch | Head at observation | State | Delivers |
|
||
| --- | --- | --- | --- | --- |
|
||
| #14 | `feature/hermes-safe-gitea-pr-client` | `3cd8a17e772978fb554d88dd1c59777786be3991` | draft | Bounded forge authority, observer RBAC (removes `hermes-agent-cluster-admin`), node account hardening |
|
||
| #15 | `fix/hermes-result-decomposition-reliability` | `7609cd77e87a64e332b2f0d670c1d7a4f62b3d9e` | draft | Result preservation, duplicate-fanout prevention, execution-safety patch |
|
||
| #16 | `feature/hermes-unprivileged-image-builder` | `5ab0eba619779b3dfed2eb09d5c02f0a9c92dd0a` | ready | Daemonless Kaniko build lane, tokenless builder, Harbor immutable-tag rule |
|
||
| #17 | `feature/hermes-three-lane-placement` | `48cbe13ee50ce3fcb07cea3fe8d088cfed349e0c` | draft | Three concurrent CLI lanes on the larger ARM64 nodes |
|
||
| #18 | `feature/hermes-distributed-worker-pool` | `20002527512235b29054f3636b02418168373009` | draft | Three-node fenced execution pool with coordinator-only state ownership |
|
||
|
||
Base at observation: `30259b526be5b2e226620aeebd9fb5f57dc88b7d`.
|
||
|
||
### Merge order and why
|
||
|
||
1. **#14 first.** It is the only change that *reduces* authority: it deletes
|
||
`services/hermes/agent-rbac.yaml` (the `cluster-admin` binding) and replaces
|
||
it with the namespaced/cluster observer roles, and it hardens the node
|
||
account. Merging it last would mean every earlier acceptance run measured a
|
||
cluster-admin agent. Every `access-denied` and `nodes` check depends on it.
|
||
2. **#16 second.** The image lane must exist before anything that needs a new
|
||
agent image is rolled out. It also touches `testing/quality_contract.json`,
|
||
so merging it early keeps later rebases small. `build.*` depends on it.
|
||
3. **#15 third.** It changes `dockerfiles/Dockerfile.hermes-agent`, which #16
|
||
also touches — take #16's shape and rebase #15 onto it. `reliability.*`
|
||
depends on it.
|
||
4. **#17 fourth.** Placement only; it edits `services/hermes/agent-deployment.yaml`
|
||
and `testing/tests/test_hermes_cli_lanes.py`, both of which #14, #15, and #18
|
||
also touch. Merging it after them avoids resolving the same conflict twice.
|
||
5. **#18 last.** The pool depends on the bounded forge client from #14, the
|
||
execution-safety patch from #15, and the agent image from #16. `pool.*`
|
||
depends on it.
|
||
|
||
Overlapping files to expect (rebase, do not merge blind): `services/hermes/agent-deployment.yaml`
|
||
(#12, #14, #16, #17, #18), `services/hermes/kustomization.yaml` (#14, #16, #18),
|
||
`services/hermes/scripts/stage_runtime_access.py` (#14, #16, #18),
|
||
`services/vault/scripts/vault_k8s_auth_configure.sh` (#14, #16, #18),
|
||
`dockerfiles/Dockerfile.hermes-agent` (#15, #16),
|
||
`testing/tests/test_hermes_cli_lanes.py` (#14, #15, #17, #18),
|
||
`testing/tests/test_hermes_runtime_access.py` (#16, #18).
|
||
|
||
PR #12 (AI usage exporter) is independent and can merge at any point; it touches
|
||
`services/hermes/agent-deployment.yaml`, so land it before #17 if convenient.
|
||
|
||
### Image build and Flux rollout
|
||
|
||
1. Merge in the order above; observe the default-branch Jenkins build to a
|
||
terminal result after each merge
|
||
(`/opt/coordinator/jenkins_build_evidence.py titan-iac --branch main --wait`).
|
||
2. After #16 is merged, run the `hermes-agent-image` pipeline. It publishes an
|
||
immutable `git-<sha>-build-<n>` tag and emits
|
||
`build/hermes-agent-release/hermes-image-update.patch`.
|
||
3. Apply that patch to `services/hermes/kustomization.yaml` on a branch, review,
|
||
and merge. The digest is what pins the deployment;
|
||
`build.deployed-image-is-digest-pinned` asserts it.
|
||
4. Let Flux reconcile. Confirm with `flux get kustomizations -A` that `hermes`
|
||
and `hermes-chat` are `Ready`, then wait for the rollout to settle.
|
||
5. Re-run the harness. Do not run it mid-rollout: `gitops.*` tolerates an
|
||
in-flight reconcile (`Reconciling=True` with `Ready=False`) but a half-rolled
|
||
deployment will legitimately fail the `identity`, `routing`, and `surfaces`
|
||
groups.
|
||
|
||
### Rollback points
|
||
|
||
| After | Roll back by | Restores |
|
||
| --- | --- | --- |
|
||
| #14 merge | Revert the merge commit; Flux restores `hermes-agent-cluster-admin` | Cluster-admin agent, previous node account state |
|
||
| #16 merge | Revert the merge; the previously pinned digest is unchanged in `kustomization.yaml` | Previous build lane; published immutable tags are not deleted |
|
||
| Image digest bump | Revert the `kustomization.yaml` commit to the prior digest | Previous agent image; Harbor immutability means the old tag is still exactly what it was |
|
||
| #15 merge | Revert the merge; the init-container patch disappears on the next rollout | Previous finalization/replay behaviour |
|
||
| #17 merge | Revert the merge | Previous lane concurrency and node placement |
|
||
| #18 merge | Revert the merge; the StatefulSet and its PVCs are removed by Flux pruning | Single-coordinator execution |
|
||
|
||
Every rollback is a Git revert reconciled by Flux. Do not roll back by direct
|
||
`kubectl` edit; the next reconcile would undo it.
|
||
|
||
## Go / no-go checklist
|
||
|
||
Run in order. Stop at the first item that does not hold.
|
||
|
||
1. `origin/main` is `ab346f55509d584e457fe26cf90be3078f7a375c` or a descendant.
|
||
2. PRs #14, #15, #16, #17, #18 are all merged into `main`, in that order, each
|
||
with a green default-branch build.
|
||
3. The agent image has been built by the #16 lane, is pinned by digest in
|
||
`services/hermes/kustomization.yaml`, and Flux has finished reconciling.
|
||
4. The harness is run from an **external operator kubeconfig**, not from inside
|
||
the Hermes owner pod, with `--node-count` set to the real node count and every
|
||
deliberate Flux suspension declared via `--expected-suspension`.
|
||
5. The default read-only run reports `GO` — no mandatory `FAIL`, no mandatory
|
||
`NOT_RUN`, no `harness_errors`.
|
||
6. The armed run reports `PASS` for all four `ephemeral.*` checks, including
|
||
`ephemeral.cleanup-verified`, and the ephemeral ref and pull request are
|
||
confirmed gone.
|
||
7. The JSON report is archived with the exact `origin/main` commit and the
|
||
recorded pull-request heads from `baseline.pending-pull-requests-recorded`.
|
||
|
||
Only then is the handoff releasable.
|
||
|
||
## Known limits
|
||
|
||
Stated so nobody mistakes a proxy for the thing itself.
|
||
|
||
* **Merge, approve, and close authority is asserted, not exercised.** The
|
||
harness never attempts a merge. It proves the forge identity holds
|
||
`permissions.admin: false` and is live-refused on an administrative route;
|
||
the route-level allowlist is covered by PR #14's own regression suite.
|
||
* **Attach and port-forward have no side-effect-free live attempt.** Their
|
||
authorization reviews ride alongside a real, refused control-plane write in the
|
||
same check, and pod exec — the one subresource with a harmless real attempt —
|
||
has its own check.
|
||
* **TokenRequest is asserted by review.** A successful live attempt would mint a
|
||
real credential, so the check pairs the `serviceaccounts/token` review with a
|
||
refused `serviceaccount` create in the same RBAC family.
|
||
* **Branch protection is not read.** The harness identity is not a repository
|
||
administrator, so `GET /branch_protections` is refused — which is itself
|
||
recorded as evidence. Protected-push refusal is guaranteed by the harness's own
|
||
preflight, exercised on every armed run, not by reading the forge's config.
|
||
* **Stale/duplicate assignment safety is asserted as deployed configuration.**
|
||
The behaviour itself is covered by the pool's regression suite; the harness
|
||
checks that the workers carry the configuration and that the coordinator keeps
|
||
sole ownership of its claims.
|
||
* **Telegram session continuity is per tenant** and depends on there having been
|
||
Telegram traffic, so it is off by default. The always-on check is that topic
|
||
and tenant state live on a persistent claim rather than in the pod.
|
||
* **The routing window is a tail**, 600 records by default. A property that did
|
||
not occur inside that window is reported as missing rather than absent; widen
|
||
the window or wait for traffic rather than removing the assertion.
|
||
|
||
## Tests
|
||
|
||
```
|
||
python3 -m pytest testing/tests/ -k handoff
|
||
python3 -m coverage run --source=scripts/ops --include="*/hermes_handoff_*.py" \
|
||
-m pytest testing/tests/ -k handoff && python3 -m coverage report
|
||
```
|
||
|
||
The suite covers parsing, redaction, timeouts and deadlines, partial failure,
|
||
zero state, dual-vantage mismatch, mandatory skips, catalog structure, and
|
||
ephemeral arming and cleanup — including the cases where cleanup cannot be
|
||
verified.
|