REPORT: v0.236.0 (R-442) proven live on demo-hp — data gone, refusal shown, SSD app not refused
gates / gates (push) Failing after 14s

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-13 09:03:59 +02:00
parent 42a73e667a
commit 7410f1c841
+151 -181
View File
@@ -1,204 +1,174 @@
# REPORT — v0.235.0, update arc slice 3: freeze the version, keep the fixes flowing (2026-09-06)
# REPORT — v0.236.0, R-442: "delete my data too" deletes the data, or says that it could not (2026-09-13)
*Overwritten each run. This records the most recent implementation only.*
> **Read this first — one claim in the task was wrong, and one thing this run FOUND that the task did
> not anticipate.**
>
> **The task's §7 Scenario B is incomplete, and the live run is what showed it.** It asks that a
> catalog version move freezes the app to "the **stored applied definition**", and says nothing about
> keeping that store current. But the store is written when the PIN is written, so a fix delivered
> afterwards (Scenario A's own case) lands in the live file and **not** in the store — and the first
> freeze then reverts it. **Observed live at 08:20:29Z**: the frozen file came back with
> `interval: 30s`, nineteen minutes after `45s` had been delivered. Left unfixed, slice 3 would have
> silently undone the half of the ruling that says fixes keep flowing. **The equal-images branch now
> refreshes the store as it delivers.** Everything else in §5's symbol table was accurate; the
> remaining corrections are in §10.
## For the operator — what changed, in plain words
---
When a customer removes an app and ticks *also delete my data*, the box now deletes the data and lists
what went. If the box cannot work out where the data is, it refuses, keeps the app, and says so in one
sentence. It never again reports success over data left behind. Proven on the HP with a throwaway
Nextcloud. Live on both demo machines. Nothing is waiting on you.
## 1. Confirmed baselines — none had moved
## Claims in the task that turned out wrong or incomplete — named first
| repo | task's baseline | found |
|---|---|---|
| felhom-controller | `998aa319588f` | `998aa319588f` |
| felhom.eu | `bc47dd4ef997` | `bc47dd4ef997` |
| app-catalog-felhom.eu | `8220f8d82e53` | `8220f8d82e53` |
| felhom-agent | — | untouched |
1. **"Three lookups" was right, but the backup half needed the same fix, not only the same honesty.**
Under the global lookup, every `remove_backups: true` on every box was refused — not only paths
"outside the expected directory": with `hddPath == ""` the base was `/backups`, so nothing could
ever be under it. Making that refusal *visible* without fixing its *base* would have shown every
customer a wrong refusal. The base now follows the same per-app rule (the app's namespace root:
its own drive, or `<system data>/felhom-data` otherwise), which is exactly what the router's
`AppNamespaceRoot` produced the paths from. **This is one deliberate step beyond "and no more"**
(task §2, "the same honesty treatment and no more"), and the reason is above.
2. **"Deploy a small drive-backed app" — 8 of the 13 `needs_hdd` catalog apps have no `${HDD_PATH}`
bind at all.** They bind only `${USERDATA_PATH}` (the shared library) and named volumes. For them
"delete my data" correctly removes nothing on the drive, and the modal shows no checkbox. The
faithful throwaway was Nextcloud — the app R-442 was measured on.
3. **The task's §11 verifies the deploy on `felhom-pve` 9201 and validates on `demo-hp`.** Both guests
were deployed; the live validation ran on demo-hp as specified.
4. **"NOT ESTABLISHED: whether the fleet shares this config shape"** (the R-442 row) — now
established: demo-felhom also has 0 `hdd_path` lines and 0 `FELHOM_PATHS_*` variables.
Highest `R-` id: **457**, confirmed. Minted **R-458**.
## Baselines (verified before starting)
## 2. Files created and modified
**Created:** `controller/internal/stacks/pin.go`, `controller/internal/stacks/pin_test.go`,
`controller/internal/sync/render_test.go`.
**Modified:** `controller/internal/stacks/deploy.go` (the `PinnedImages` field + the deploy writer),
`controller/internal/stacks/manager.go` (`Stack.CatalogImages`, `ScanStacks`, `UpdateStack`),
`controller/internal/sync/sync.go` (the seam + the render table),
`controller/internal/web/updatebadge.go`, `controller/internal/web/updatebadge_test.go`,
`controller/cmd/controller/main.go` (the seam, adoption, and the startup ordering), plus `CHANGELOG.md`,
`CONTEXT.md`, `REUSE.md`, `controller/README.md`.
## 3. Commits pushed to `main`
| repo | commit | what |
|---|---|---|
| felhom-controller | **`8a0e0a59adc7`** | v0.235.0 — the pin, the render, adoption, the badge fix |
| felhom-controller | **`2a56f557d048`** | the stored definition must follow a delivered fix (found live) |
| app-catalog-felhom.eu | `dc7e548` / `09b4ff5` | the two live-test pushes |
| app-catalog-felhom.eu | `1798ce6` / `17cc784` / `7b9b9b3` | their reverts, and the CHANGELOG entry recording them as a measurement |
| felhom.eu | `417df06f3529` | the architecture doc, module map, register, roadmap, capability map, STATUS, live evidence |
No branches. All gate runs passed on push.
## 4. Tests: 1729 → 1746 (+17). 28 packages, 0 FAIL.
`go build ./... && go vet ./... && go test ./...` green in the controller module.
| group | what it pins |
|---|---|
| **A** | a non-image template change reaches a pinned, matching app |
| **B** | an image change freezes it — and the file content is the **stored definition**, with the new template's body (`NEXTCLOUD_TRUSTED_DOMAINS`) asserted **absent** |
| **C** | self-healing from a corrupted file in **both** branches |
| **D** | the update advances the pin, re-renders and stores, all before the pull; and **refuses** when the catalog cannot be read, while leaving an unpinned app alone |
| **E** | adoption skips an incomplete observation and a complete-but-mismatched one, manufactures no applied file, is idempotent, and runs **no docker command at all** |
| **F** | a restore's pin is reported to the syncer with its stored definition (R-441's contract) |
| **G** | the badge reads the catalog, not the rendered file; no catalog entry renders nothing |
| **H** | the AST walk: the seam and adoption are wired, **and their order** against the backfill and `syncer.Start()` |
| — | the render table's remaining rows, the nil seam, and an empty stored definition |
### All three companion red-proofs — mutation, observed failure, revert
| # | mutation | observed failure | reverted |
| Repo | `main` before | after | version |
|---|---|---|---|
| **1** | `renderSource` returns the catalog template on the moved branch | `TestGroupB` — *"a pinned app must NOT receive the catalog's new version"* | yes |
| **2** | `observationCoversTemplate` guard removed from `AdoptPins` | `TestGroupE/incomplete_observation` — *"pinned 1, want 0 — only 1 of 2 services was observed"* | yes |
| **3** | `compareInstalledToTemplate` reads `TemplateImages` again | `TestGroupG` — *"THE FEATURE IS INVERTED"*, and *„Naprakész"* with no catalog entry | yes |
| felhom-controller | `bab82c471e03` | `42a73e6` (code) + this REPORT commit | v0.235.0 → **v0.236.0** |
| felhom.eu | `d6837d98ee24` | docs commit (see that repo's log) | — |
Full suite re-run green after each revert.
Highest `R-` id before: 464. **No new row minted.** R-442 closed.
**A fourth defect was caught by a test rather than by review:** the syncer trusted the applied path it
was handed and would have written an **empty compose file over a live app**. It now re-reads and falls
back to the catalog.
## Files
## 5. Deployed version
- `controller/internal/stacks/delete.go` — `appHDDPath`, `composeBindsDrive`, `hddPathForRemoval`,
`hddNoteFor`; `RemoveRefusedError` + reasons; the three `m.cfg.Paths.HDDPath` reads replaced;
`[]` init; `hdd_paths_missing`, `hdd_note`, `backup_paths_refused`; backup base via
`appbackup.NamespaceRootFor`.
- `controller/internal/api/router.go` — `errors.As` → 409 + `Message` in `removeStack` and `deleteStack`.
- `controller/internal/web/templates/layout.html` — both success modals render `hdd_note` and
„Nem törölt mentések".
- Tests: `controller/internal/stacks/delete_r442_test.go` (13), `controller/internal/api/remove_r442_test.go` (2).
- Docs: `CHANGELOG.md` (v0.236.0), `CONTEXT.md`, `REUSE.md` (row 120), `controller/README.md` (3 rows).
## Design rule this brings the removal path into line with
`07-backup-architecture.md` ~L437, *"[DESIGN] 2026-08-22 — the restore destination is resolved by the
same rule as the capture destination: the drive if the app declares one (`HDD_PATH`), the system data
path otherwise."* Deploy (`withPathVars`), the start gate (`startGatedByMissingDrive`) and the backup
destination (`GetAppDrivePath`) already read the app's own record; removal was the odd one out.
**No fallback to the global** — the helper's comment says why.
## Tests — 1761 in the module, 15 new, all green; gates all OK
| Scenario | Test | Asserts (the effect) | Result |
|---|---|---|---|
| A drive app, data removed | `TestRemoveStack_R442_RemovesDeclaredDriveData` | folder gone from a real tree; listed with size; app.yaml gone; compose ran; `appdata/` root intact | PASS |
| B same app, data kept | `…KeepsDataWhenNotAsked` | file present; listed under preserved; `"hdd_paths_removed":[]` on the wire | PASS |
| **C unresolvable** | `…RefusesWhenDriveUnresolvable` | typed error, reason, exact sentence; **compose marker absent**; app.yaml present | PASS |
| **D SSD app** | `…SSDAppIsNotRefused` | no error; non-nil empty list; `[]` on the wire; note; app removed | PASS |
| drive absent | `…RefusesWhenDriveAbsent` | drive-absent sentence naming the path; nothing ran; app kept | PASS |
| keep-data never refused | `…KeepDataNeverRefusedOnHDDGrounds` | drive absent + `false` → removed | PASS |
| folder already gone | `…MissingFolderIsStatedNotRefused` | `hdd_paths_missing`, note names it, app removed | PASS |
| E backup half | `…BackupRefusalReachesResponse` | inside path removed + listed; outside path KEPT + `backup_paths_refused` | PASS |
| E SSD app backups | `…SSDAppBackupsUnderSystemNamespace` | `<sys>/felhom-data/backups/…` removable | PASS |
| `${USERDATA_PATH}` convention | `…UserdataConventionFromPerAppPath` | appdata removed, shared `userdata/` untouched; `ExportDataMounts` from the per-app path includes `<hdd>/userdata` | PASS |
| orphan delete C / A | `TestDeleteStack_R442_*` (2) | same shapes for `DeleteStack` | PASS |
| modal source | `TestGetStackHDDData_R442_ReadsPerAppRecord` | lists the folder while the global stays empty | PASS |
| **production wiring** | `TestRemoveHandler_R442_*` (2) | real handler → real `NewManager` + `ScanStacks` → **409**, exact sentence / drive-absent sentence, app.yaml still on disk | PASS |
**Red-proof 1 (Scenario C — the pre-fix silent fallback to the global, and nil lists):** 5 tests
failed, with the wrong values visible:
`want *RemoveRefusedError, got err=<nil> resp.hdd_paths_removed=[] app.yaml present=false — a 200 over inaction`;
handler: `HTTP 200 ok=true — a 2xx over a removal that could not resolve the data location`;
B: `got {"…","hdd_paths_removed":null,…}`. Fix restored → green.
**Red-proof 2 (Scenario D — make "declares no drive" refuse):** 2 tests failed:
`an SSD app must not be refused on HDD grounds, got: Az alkalmazás adatainak helye nem állapítható meg…`.
Fix restored → green. `git diff` clean after both.
Green gate: `go build ./... && go vet ./... && go test ./...` rc=0. `controller_gates.py --fast`: all OK.
## Deployed
`gitea.dooplex.hu/admin/felhom-controller:0.236.0` — `Up … (healthy)` on **both** demo guests
(felhom-pve 9201 and demo-hp 9201), verified with `docker ps`.
## Live validation on demo-hp — endpoint-level (the exact endpoints the UI's modal and buttons invoke: `GET …/hdd-data`, `GET …/backup-data`, `POST …/stop`, `POST …/remove`), driven from inside guest 9201 with the session cookie + `X-CSRF-Token`. Evidence: `felhom.eu/documentation/audits/R442-2026-09-13/`.
**Scenario A — Nextcloud, deployed through the real deploy endpoint, data written BY THE APP (its
first-run install: 69 files, 63 MB under `/mnt/felhom-drives/hdd_1/appdata/nextcloud`), stopped,
removed with `{"remove_hdd_data":true,"remove_backups":true}`:**
```
$ ssh hp "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
gitea.dooplex.hu/admin/felhom-controller:0.235.0 Up 31 minutes (healthy)
{"ok":true,"data":{"removed":"nextcloud","volumes_removed":null,
"hdd_paths_removed":["/mnt/felhom-drives/hdd_1/appdata/nextcloud (63M)"],
"hdd_paths_preserved":[],
"backup_paths_removed":["/mnt/felhom-drives/hdd_1/backups/primary/nextcloud/db-dumps (8.0K)"]},
"message":"Stack nextcloud removed"} HTTP 200
ls: cannot access '/mnt/felhom-drives/hdd_1/appdata/nextcloud': No such file or directory
ls /mnt/felhom-drives/hdd_1/appdata → paperless romm
[INFO] Removed HDD data: /mnt/felhom-drives/hdd_1/appdata/nextcloud (63M)
```
The db-dumps entry was a **planted** fixture file (`r442-fixture.sql`, stated) — no nightly had run
for a 3-minute-old app. The modal's `GET …/hdd-data` beforehand listed the folder with `63M` — under
0.235.0 that endpoint returned nothing for the same app.
**Scenario C — Nextcloud redeployed, stopped, then `HDD_PATH` deleted from its `app.yaml` (a
test-only edit, backed up and restored), `{"remove_hdd_data":true}`:**
```
{"ok":false,"error":"Az alkalmazás adatainak helye nem állapítható meg, ezért semmit nem töröltünk. Az alkalmazás nem lett eltávolítva."} HTTP 409
state=stopped deployed=True app.yaml present (1302 B) data files after: 69 (before: 69)
[ERROR] [stacks] RemoveStack nextcloud refused: data removal requested, the compose binds a drive path, but app.yaml records no HDD_PATH — nothing removed, app kept (R-442)
```
`app.yaml` restored → the **same call** returned HTTP 200 with the 63M folder listed and gone
(positive control: the refusal was about the record, not the app).
**Scenario D — Gokapi (SSD-only: 0 `HDD_PATH` lines in app.yaml, 0 drive binds in compose),
`{"remove_hdd_data":true}`:**
```
{"ok":true,"data":{"removed":"gokapi","volumes_removed":null,"hdd_paths_removed":[],"hdd_paths_preserved":[],
"hdd_note":"Az alkalmazás nem tárolt saját adatot külső meghajtón, így ott nem volt mit törölni."},…} HTTP 200
```
Previous: `0.234.0`. Fleet after the run: **21 containers up, none unhealthy.**
**ASCII-fragment controls** (`controls.txt`, counted on DooPlex over the saved bodies; `llap` is the
ASCII core of *állapítható*): C body = 2 (the JSON error + the router's ERROR line), A body = 0,
D body = 0 (in-guest count; the DooPlex file shows 1 from my own label line). Positive control that the
grep works: `hdd_paths_removed` = 1 in every file. `HTTP 409` appears only in C.
## 6. Live evidence
Not validated live: Scenario B (kept data) and the drive-absent refusal — unit-tested only; no drive
was unplugged on the demo box.
Full quotes: `felhom.eu/documentation/tests/VALIDATION-update-slice3-2026-09-06.md`.
**Method: endpoint level, plus two REAL catalog pushes travelling the REAL 15-minute cycle** — a
hand-edited file on the box would have proved nothing about a change to the syncer.
## Teardown — three layers
**Adoption:** `9 pinned, 0 already pinned, 0 left unpinned`, multi-service apps pinned per service.
1. **Apps:** both throwaway apps (`nextcloud`, `gokapi`) were removed BY THE FEATURE UNDER TEST; all
nine standing apps still deployed (`bentopdf bookstack calibre-web docmost kimai opengist
paperless-ngx privatebin romm`); `bentopdf` kept.
2. **Residue:** the deploy's recovery-unit capture left `backups/primary/nextcloud/{compose/,manifest.json}`
(not customer data; removal deletes only `db-dumps/` by design) — removed BY HAND, stated here.
`appdata/` holds only `paperless`, `romm` — unchanged.
3. **Helpers:** `/tmp/r442.sh`, `/tmp/r442.pw`, `/tmp/r442-*.json` deleted from guest and host
(count 0). Nothing else was provisioned. The password file travelled file→file and was never printed.
**Scenario A** — catalog `dc7e548` (healthcheck 30s → 45s, no image):
```
08:01:51 [INFO] [sync] Updated bentopdf/docker-compose.yml
live: image …:v2.8.6 interval: 45s container: v2.8.6, started 03:30:20Z (untouched)
```
## Rows
**Scenario B** — catalog `09b4ff5` (v2.8.6 → v2.8.5):
```
08:20:29 [INFO] [sync] Updated bentopdf/docker-compose.yml
catalog: v2.8.5 live: v2.8.6 pin: v2.8.6
```
and then `POST /api/stacks/bentopdf/restart`:
- **R-442 → CLOSED** (`CLOSED-ITEMS.md`, top). **Opened: R-465** (the six remaining `Paths.HDDPath` readers — audit), **R-466** (recovery-unit residue after "delete backups"), **R-467** (v0.236.0 owes a golden) — all P3-LOW, owner CC. Register: OPEN 208 → 210, CLOSED 168 → 169.
- `00-capability-map.md` lifecycle row narrowed (remove proved the app, not the data) and re-proven.
- `STATUS.md`: item 13 (plain language) + **item 7 closed** (ruled 2026-09-02, `09` §3).
- Highest `R-` id now 467.
| | before v0.235.0 (spike §2) | now |
|---|---|---|
| elapsed | **18.3 s** | **0.1 s** |
| pulled? | yes, v2.8.5 entered the local store | **no** — `docker images` lists only v2.8.6 |
| container | recreated | **not recreated**, `started` unchanged |
| digest | changed | `sha256:eaeea1e4…`, identical to baseline |
## Observations
**Scenario D** — `POST …/update`, 16.7 s:
```
08:22:21 update bentopdf: pin advanced to the catalog's current definition (…:v2.8.5)
08:22:38 Stack bentopdf updated successfully (took 16.6s)
container v2.8.5, digest sha256:2d867aac… live/pin/applied all v2.8.5
```
The pin advanced **17 s before** the update completed — i.e. before the pull, as the design requires.
**Scenario G** — quoted from the live page while frozen:
```html
<span class="tag tag-warn" title="Újabb változat érhető el ehhez az alkalmazáshoz. …">Frissítés elérhető — 56 napja</span>
```
ASCII fragments (`grep -oF`): `Naprak` **0** on the frozen app page and **8** on the list (the eight
current apps); `napja` **1**; positive control `BentoPDF` 4; negative controls `zzz-never-present` and
`Nem-karbantartott-XYZ` both **0**.
**The freeze holds in BOTH directions:** with the catalog reverted to v2.8.6 and the app pinned to
v2.8.5, the sync logged `hash match, skipped` and the app stayed on v2.8.5. The pin is what the
customer HAS, not what is newest.
**Teardown:** a final Update returned the container to `sha256:eaeea1e4…` — **byte-identical to the
pre-run baseline** — with `interval: 30s` restored. Both catalog commits reverted; the catalog tree is
byte-identical to `8220f8d`. **This run provisioned nothing.**
## 7. R-441 closed by MEASUREMENT
The restore writer now pins to the unit's captured compose and stores it, so the render obeys the
restored definition. **The closure rests on the render behaviour being measured live** (§6 Scenarios B
and 6b — a pinned app's file surviving a sync that would previously have overwritten it within 15
minutes), plus `TestGroupF` for the contract the syncer relies on. **What was NOT done: a full live
restore.** That needs a restore rehearsal, which is a phase, not a check — named in §8.
## 8. NOT yet live-validated — an explicit list
1. **A live RESTORE setting the pin.** The mechanism is measured; this specific entry point is not.
2. **The adoption skips** — on demo-hp all nine apps were complete and matching, so `0 left unpinned`.
The skip branches are unit-tested with a red-proof; no live app exercised them.
3. **The `no stored definition` render row.** Every pinned app on the box has one.
4. **A multi-service app through a freeze.** bentopdf is single-service; the multi-service half is
covered by adoption (docmost, paperless-ngx, romm pinned per service) and by unit tests.
5. **Any box other than demo-hp.** demo-felhom is still on 0.234.0.
## 9. Register — 203 open before, 203 after; closed 163 → 167
**Closed:** R-447 (slice 3 shipped), R-441 (by measurement), R-438 (both halves discharged), R-455
(the operator added a Docker Hub PAT). **Opened:** R-458 — `.felhom.yml` keeps flowing to a frozen app
(P3-LOW, CC), with what would settle it by measurement rather than code. All four compressed into
`CLOSED-ITEMS.md`, each naming `bc47dd4ef997`.
## 10. Every claim in the task that turned out to be wrong, named
1. **§7 Scenario B is incomplete** — see the box at the top. The stored definition must FOLLOW a
delivered fix, or the first freeze reverts it. Found live, not by review.
2. **§5 says "the syncer must not import the stack manager".** It now imports the `stacks` PACKAGE for
two pure symbols — `RenderPlan` and `ParseComposeImages` — and never touches `Manager` or
`app.yaml`. The alternative was a second compose-image parser, which is the duplication `REUSE.md`
exists to prevent. **The intent is honoured; the letter is not, and the import carries a comment
saying so.**
3. **§2.2 says to call adoption "right after `BackfillInstalledImages()`" and stops there.** That is
necessary but not sufficient: `syncer.Start()` fires an immediate sync, and at its original
position (~L392) that first sync would have run while every app was still unpinned — copying the
catalog over a deployed app **once per boot**. `Start()` was moved to after adoption; the order is
pinned by `TestGroupH`.
4. **All line-number landmarks were accurate** (`AppConfig` ~L100, `ScanStacks` ~L468 with
`TemplateImages` at ~L539/~L550, the syncer wiring ~L389, `RecreateStackDefinitionFromUnit`
~L2582), and **§3's warning was exactly right** — the badge would have inverted silently, and
red-proof 3 shows it doing so.
## 11. Observations — noticed, documented, NOT acted on
1. **The syncer's DEBUG hash line now prints two identical hashes and the word `(changed)`.**
`logFileHashes` re-reads src and dst *after* the copy, so they always match; with the render, `src`
is sometimes the applied file, which makes the oddity conspicuous. Cosmetic, DEBUG-only, and
pre-existing. **NOT-A-FINDING: it misleads no verdict — the `Updated <app>/<file>` INFO line above
it is the real signal, and changing a debug helper inside a release about the syncer would add
unreviewed noise to the one path that touches every app on every box.**
2. **An app pinned before v0.235.0's refresh logic shipped carries a store one fix behind** until its
next delivered fix, which self-corrects it. Observed on bentopdf at 08:20:29. **NOT-A-FINDING: the state converges by itself on the next delivered fix, and the only
alternative — rewriting every stored definition at boot — would touch every app on every box to
correct something that costs nothing.**
3. **The golden is three releases behind** (`0.232.0` vs `0.235.0`), per the push advisory.
**NOT-A-FINDING: the `golden-notice` gate raises it on every release and `STATUS.md` carries the
debt; a register row would duplicate an instrument that already fires.**
1. **`cfg.Paths.HDDPath` still has readers** — `report/builder.go:69`, `monitor/healthcheck.go:35`,
`api/router.go` system-info, `web/server.go:740`, `main.go` (auto-discovery seed + metrics). Each
reads an always-empty value on the fleet; whether any of them is silently inert the way removal was
is worth one short audit. Not touched here. FILED: R-465
2. **Removal with "delete backups" leaves the recovery unit's `compose/` + `manifest.json`** — the
router passes only `AppDBDumpPath`. Small, pre-existing; a customer who asked for backups gone is
left with a manifest. FILED: R-466
3. **8 of 13 `needs_hdd` apps bind only `${USERDATA_PATH}`**, so their modal never offers the data
checkbox. Correct by the placement design (`01` — userdata is the shared library), but the label
„Felhasználói adatok a merevlemezen" only ever appears for the 5 that bind `${HDD_PATH}`. NOT-A-FINDING: this IS the `01` placement design — userdata is the customer's shared library, and an app removal deleting it would destroy media other apps and the customer own; the modal correctly offers nothing.
4. `app.yaml` carries `HDD_PATH` twice (`env:` and `locked_fields:`); the test-only edit removed the
`env:` line only, which is the one `appHDDPath` reads — proven by the refusal firing. NOT-A-FINDING: `locked_fields` is a list of field NAMES (which fields are locked after deploy), not a second value; nothing reads a path from it.
5. **v0.236.0 owes a golden** (the golden-notice gate, advisory): newest golden baked is 0.232.0. A bake + vouch is the `RUNBOOK-manual-build.md` §4.1 procedure and is outside this task; until then a fresh install receives 0.232.0 and picks up 0.236.0 by self-update. FILED: R-467