## 0.77.1 - **A bundle no longer fails a check its own startup caused.** `install.py` imports the verifier from beside it, which since the carriers slice is inside the bundle, and Python writes `__pycache__` next to an imported module. The `.pyc` landed there before the unlisted-file check read the directory, so every verb refused every bundle. 0.77.0 was tagged, its image pushed, and nothing else: no bundle was promoted and no package published at that version, because the release engineer's own first `verify` refused it. - **One install, three carriers.** A person with a shell, with Python or with Node now installs by one command, and the three differ only in how the command is typed. `pipx run anchorpoint-install` could previously only operate on a bundle somebody had already obtained, which made it the air-gap tool rather than a carrier; npm carried no installer at all. All three now check the same prerequisites, resolve the same `latest.txt`, fetch the same bundle, and take the same `try` verb. - **The verifier reaches a host by a road the bundle did not travel.** `deploy/install.py`'s trust half is now `deploy/anchorpoint_verify.py`, and each carrier ships it: the wheel and the npm package carry the file, and `install.sh`, which is served from the same host as the bundle and so cannot carry a copy worth more than the bundle's own, pins its digest instead. An operator who checks the script against the digest in `docs/INSTALL.md` has transitively established which verifier is acceptable. Install logic stays inside the bundle, so a carrier nobody has upgraded still installs a new release with that release's logic. - **The npm package runs the Python verifier rather than reimplementing it.** One implementation of the path where a bug means a forged bundle is accepted. The cost is that `npx anchorpoint-install` needs `python3`, which the shell carrier has always needed too. - **A release refuses to build when its packages disagree on the version.** That check existed and read `npm-connect` alone, so `npm-alias`, the wheel and the new npm package could each drift silently. Its green read as "the versions agree" while meaning "one of them agrees", and the estate was in exactly the state it was supposed to prevent: the artifact host served 0.76.1, npm served 0.68.1, and PyPI had never been published. - **The ceiling seed says which tenant is missing, instead of blaming the session.** `operator_put_ceiling` has checked `is_ancestor_of` since migration 0053, and that reads the tenant registry, so a tenant with no row there is refused however the session is opened, the platform tenant's included. The database answers "not an operator session", which names the session rather than the missing tenant. That is what a fresh deployment meets, because tenants are created in the console and `config/ceilings/` names one the registry has never heard of. `scripts/import-ceilings.py` now checks the registry before it writes anything and refuses the whole run naming the tenants to create, so a directory holding one registered tenant and one unregistered one no longer imports as far as the bad file and stops with half a seed written. - **A new host is given a deployment identity.** `config/deployment.yaml` names a single deployment, so the repository cannot carry it, and the rsync of `config/` excludes it to protect it from `--delete`. An exclude authors nothing, so a host that had never had the file came up without one: the worker logged "no deployment config", posted no heartbeat and closed no billing period, with nothing on any screen to say so. `deploy.sh` now writes it when the host has none and leaves an existing one exactly as it is, because the id keys every heartbeat row and every statement and reminting it would split one deployment's history in two. - **Anchorpoint runs on two hosts of its own.** Staging, the fleet receiver and the artifact host were three projects on a Contabo box shared with forty-seven unrelated vhosts, where every operation carried blast radius that had nothing to do with this product and one full disk was a shared problem. They now run on two Azure virtual machines: `anchorpoint-a` holds the staging data plane, `anchorpoint-b` holds the two client-facing surfaces, so a staging redeploy cannot disturb the receiver every deployment posts to. No Azure platform service is used. Managed Postgres was refused because `anchorpoint/tenancy/forceguard.py` requires an owning role that is superuser or holds BYPASSRLS, which a managed instance does not grant, and the staging line is meant to be a rehearsal of a real client install rather than a configuration nobody ships. - **The deploy no longer assumes it is root.** Azure's Ubuntu images refuse root ssh, so `deploy.sh` takes `sudo -n` for the two steps that need it and runs as an ordinary user for the rest. `sudo` is a no-op where the deploy user is already root, so one spelling is correct on any host. - **Contabo keeps the data and stays stopped.** It is the rollback until a soak period has seen a real heartbeat and a release publication land on the new hosts. Nothing there is deleted by this change. - **One publisher, three registries, from one signed bundle.** The bundle now carries every artifact any registry serves: `npm-connect.tgz`, `npm-install.tgz`, the wheel and the sdist, each digested in `MANIFEST` beside the rest of it. `make release-publish` replaces `make release-publish-npm`, which published one of three packages and left the other two to drift to whatever version they last happened to be. The order is enforced rather than remembered: a refusal checks the artifact host already serves the version before any package reaches a registry, because promotion can be undone and a registry publish cannot. A target that already serves the version is skipped rather than republished, so a run that npm's second factor interrupted halfway is finished by running the same command again. The PyPI token lives at `fikia/pypi/api-token` in `pass` and reaches `twine` through the environment; it never appears on a command line. - **Three deployment tiers become one switch.** The installer asked an operator to choose between `hosted`, `connected` and `air-gapped` and to supply a receiver URL, before the product had ever run. Neither was a question a first-time installer could answer: the tier was a fact about their network they had not thought about yet, and the URL was a value only we could give them, which the documentation papered over with an example address a reader could not obtain. There is now one decision, made after the deployment is standing rather than before: a switch in the console under Settings, admin only, on by default, saying whether this deployment reports its version and health. `--tier` and `--receiver-url` are gone from `install.sh` and `install.py`, `config/deployment.yaml` carries `deployment_id` alone, and the receiver address is compiled into the build. - **Off means off.** With the switch off the sender reads no spans, posts nothing and writes no file. It records what the deployment is running, so the client still sees their own version on their own Overview screen, and `release_states.source` reads `off` so the screen says which silence this is. - **Air-gapped is observed rather than declared.** A deployment that cannot reach the receiver writes the heartbeat to `ANCHORPOINT_HEARTBEAT_DIR` when one is configured, records `file` as the source, and exits 0, because on a sealed network the file is the delivery. With no heartbeat directory configured a failed post stays an `ERROR` and exit 1. - **A refused credential stays loud whatever the heartbeat directory says.** A 401 means the receiver was reached and refused this deployment's token, which is not the unreachability the file fallback exists for: the same binding conflict would meet an operator who carried the file in by hand. A 401 logs the diagnostic naming the console's Fleet screen, writes no file, and exits 1, with or without a heartbeat directory. - **`release_states.tier` is dropped**, and the console stops rendering it. Migration 0062 turns an air-gapped deployment's recorded tier into a disabled telemetry row before dropping the column, which is the only case where somebody had said out loud that nothing should leave. - **Upgrading needs nothing.** An existing `config/deployment.yaml` keeps its now-unused `tier` and `receiver_url` keys and loads unchanged; an existing `STATE` keeps its `tier` line and is read unchanged. Nothing rewrites either. - **The installation documents told a reader to run commands that do not work.** Six, found by checking `docs/INSTALL.md` against the tree and the live hosts rather than reading it. The air-gapped path said `python3 install.py verify`, and `verify`'s bundle is a required positional, so the one route that cannot fall back to the one-liner exited 2 at its first step; `docs/DOGFOODING.md` carried the same broken line, which is the one-arm shape again. `pipx install anchorpoint-install` is a 404: the package is built and version-synced in this tree and has never been published, and the whole upgrade section was written around the console script it would have provided, so the documented upgrade path was unreachable for everyone who installed from the one-liner. The registry bullet said the image digest is published in each release's notes, and no release note has ever carried one. The install section said the reporting choice is "a setting in the console afterwards", which is the unbuilt telemetry toggle; nothing in the console reads `deployment.yaml`, and `--tier` and `--receiver-url`, which are how a connected deployment is actually installed today, appeared nowhere. First sign in promised a forced password change that does not exist, because the installer takes the password during the install. And `install.py`'s own closing text still printed a runbook of YAML files and scripts, pointing at `docs/DEPLOY.md`, which does not travel in the bundle, while the console's Setup screen has owned those four steps since 0.69.0: the operator standing at the end of a fresh install was the one reader who could not open the document they were sent to. Every documented `install.py` command is now held against the installer's own parser by `tests/test_release_trust.py`, discovered across the operator-facing documents rather than hand-listed, because two of them carried the same defect and fixing the one that gets read left the other saying it. - **A deployment one patch behind was told it was unsupported.** `release_line` answered `current` for the newest release and handled the line immediately before it, then sent everything else to a branch commented "any older line". A version on `latest`'s OWN line but behind its newest patch is not an older line, so it fell through and was called `unsupported` while a deployment a whole line older was called `supported`. Measured on the live receiver the day 0.76.1 shipped: 0.76.0 `unsupported`, 0.75.4 `supported`. It now reads `supported` with no end date, as the current line has no announced end. `docs/RELEASES.md` never stated this case, which is why the catch-all was able to answer it; it states it now. The receiver computes every status, so this corrects what every deployment is told as soon as the receiver runs it, with no client upgrade.