@homeflare/alchemy 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/proxmox/ceph-daemon-wire.d.ts +5 -7
  2. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -1
  3. package/dist/proxmox/ceph-daemon.d.ts +7 -8
  4. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  5. package/dist/proxmox/ceph-fs-distilled.d.ts +31 -5
  6. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
  7. package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
  8. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  9. package/dist/proxmox/ceph-fs.d.ts +5 -6
  10. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-pool-settle.d.ts +9 -8
  12. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  13. package/dist/proxmox/ceph-pool-wire.d.ts +11 -20
  14. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -1
  15. package/dist/proxmox/group-wire.d.ts +8 -13
  16. package/dist/proxmox/group-wire.d.ts.map +1 -1
  17. package/dist/proxmox/index.js +296 -326
  18. package/dist/proxmox/index.js.map +14 -14
  19. package/dist/proxmox/node-network-wire.d.ts +1 -1
  20. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  21. package/dist/proxmox/storage-wire.d.ts +8 -11
  22. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  23. package/dist/proxmox/unreadable-read.d.ts +7 -7
  24. package/dist/proxmox/user-wire.d.ts +8 -11
  25. package/dist/proxmox/user-wire.d.ts.map +1 -1
  26. package/dist/proxmox/user.d.ts +5 -5
  27. package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
  28. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
  29. package/docs/changelog/README.md +22 -0
  30. package/docs/changelog/archive-01.md +176 -0
  31. package/docs/changelog/archive-02.md +180 -0
  32. package/docs/changelog/archive-03.md +179 -0
  33. package/docs/changelog/archive-04.md +178 -0
  34. package/docs/changelog/archive-05.md +177 -0
  35. package/docs/changelog/archive-06.md +180 -0
  36. package/docs/changelog/archive-07.md +180 -0
  37. package/docs/changelog/archive-08.md +178 -0
  38. package/docs/changelog/archive-09.md +181 -0
  39. package/docs/changelog/archive-10.md +182 -0
  40. package/docs/changelog/archive-11.md +182 -0
  41. package/docs/changelog/archive-12.md +183 -0
  42. package/docs/changelog/archive-13.md +180 -0
  43. package/docs/changelog/archive-14.md +178 -0
  44. package/docs/changelog/archive-15.md +181 -0
  45. package/docs/changelog/archive-16.md +182 -0
  46. package/docs/changelog/archive-17.md +183 -0
  47. package/docs/changelog/archive-18.md +55 -0
  48. package/docs/distilled-interim.md +15 -3
  49. package/docs/provider-standard.md +24 -6
  50. package/docs/proxmox.md +26 -0
  51. package/docs/upstream-conformance-bun.md +67 -0
  52. package/docs/upstream-conformance-conforms.md +55 -0
  53. package/docs/upstream-conformance-families.md +95 -0
  54. package/docs/upstream-conformance-grafana.md +106 -0
  55. package/docs/upstream-conformance.md +20 -298
  56. package/package.json +3 -3
  57. package/src/proxmox/ceph-daemon-wire.ts +4 -7
  58. package/src/proxmox/ceph-daemon.ts +7 -8
  59. package/src/proxmox/ceph-fs-delete.test.ts +5 -8
  60. package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
  61. package/src/proxmox/ceph-fs-distilled.ts +45 -19
  62. package/src/proxmox/ceph-fs-wire.ts +5 -93
  63. package/src/proxmox/ceph-fs.ts +13 -15
  64. package/src/proxmox/ceph-pool-settle.ts +11 -10
  65. package/src/proxmox/ceph-pool-wire.ts +11 -20
  66. package/src/proxmox/ceph-pool-write.test.ts +9 -8
  67. package/src/proxmox/group-wire.ts +7 -12
  68. package/src/proxmox/read-failure-propagation.test.ts +5 -5
  69. package/src/proxmox/storage-wire.ts +9 -10
  70. package/src/proxmox/typed-absence.test.ts +146 -0
  71. package/src/proxmox/unreadable-read.ts +7 -7
  72. package/src/proxmox/user-wire.ts +7 -10
  73. package/src/proxmox/user.ts +5 -5
@@ -0,0 +1,179 @@
1
+ # Alchemy changelog archive 3
2
+
3
+ [Current changelog](../../CHANGELOG.md) · [Archive index](./README.md)
4
+
5
+ **The unit is the group, not the rule** — measured: `PUT /v1/provisioning/folder/{FolderUID}/
6
+ rule-groups/{Group}` takes the evaluation `interval` AND the full ordered `rules[]` together,
7
+ replacing the whole group in one call; the individual per-rule operations have no `interval` field
8
+ at all and cannot reorder rules within a group. Declaring rules one at a time would leave a group's
9
+ interval and rule order either undeclarable or racy across independent resources with no shared
10
+ source of truth. Mirrors Terraform's own `grafana_rule_group` resource.
11
+
12
+ Rule order is significant and never reordered — `subset-match.ts`'s existing position-significant
13
+ array comparison, proven by a dedicated test that declares the same two rules reversed and asserts
14
+ a mismatch. `uid` is required on every declared rule (this family's doctrine everywhere else).
15
+ Volatile fields (`id`, `updated`, `provenance`) are stripped before comparison by a new
16
+ `normalizeRule` (`alert-rule-group-model.ts`, mirroring `dashboard-model.ts`'s `normalizeModel`),
17
+ each measured against the read vs. write SDK shapes; `folderUID`/`ruleGroup` are forced to the
18
+ group's own canonical values and `orgID` defaults to `1` (not measured against a multi-org
19
+ instance — none exists in this house yet).
20
+
21
+ **Foreign-provenance refusal is per rule, whole-group refuse.** The `AlertRuleGroup` type itself
22
+ carries no top-level `provenance` field (measured) — each rule inside `rules[]` has its own, and
23
+ Grafana's docs say a new rule's provenance must match its group's, so this resource checks every
24
+ live rule via the shared `alerting-provenance.ts` (including the missing-means-foreign fix an
25
+ adversarial review of kit PR 250 established) and refuses the whole group's write if any rule is
26
+ foreign, naming the first one found. An empty live group has nothing to refuse on.
27
+
28
+ **A declared group whose folder does not exist refuses — it is never auto-created.** This resource
29
+ makes no folder-existence check of its own and never invokes `Grafana.Folder`'s create path.
30
+ Measured: `RoutePutAlertRuleGroupError` is `BadRequest | Forbidden | GrafanaOpError` with no
31
+ `NotFound` case, so a nonexistent `folderUid` fails the PUT as a typed, uncaught `BadRequest`
32
+ rather than being folded to absent or silently scaffolded — declare a `Grafana.Folder` first.
33
+
34
+ Delete defaults to `retain` (`defaultRemovalPolicy`) — deleting a group deletes every rule in it in
35
+ one call, the same multi-object-cascade class `Grafana.Folder` already guards this way.
36
+
37
+ ⛔ **Fixed after an adversarial review of this PR: a whole-group write silently dropped any live
38
+ rule not in the declaration.** A human adding a rule in the Grafana UI minutes before a deploy
39
+ (ordinary, writable provenance — nothing for the foreign-provenance refusal to catch) would have
40
+ had it removed with no error, and `diff` only ever said `"update"` with no hint why —
41
+ `alchemy/Diff`'s `Diff` type has no fourth action to carry a lossy update through as. Fixed with a
42
+ loud, non-blocking warning (`alert-rule-group-drop-warning.ts`'s `warnOnDroppedRules`, mirroring
43
+ `proxmox/unreadable-read.ts`'s established pattern): every dropped rule is named by title and uid
44
+ in an `Effect.logWarning`, logged from both a local reimplementation of `diff` (so `bun run plan`
45
+ shows it before anything is written) and from `update` itself. This does NOT refuse the write —
46
+ the group is still the unit, by design — only makes the loss visible.
47
+
48
+ Tests (`alert-rule-group.test.ts` + `alert-rule-group-model.test.ts` +
49
+ `alert-rule-group-provenance.test.ts`, split three ways to stay under the house's 250-line file cap,
50
+ sharing fixtures from `alert-rule-group-fixtures.ts`) use the family's existing `fake-grafana.ts`
51
+ harness and prove: a GET never carries a body; a nonexistent folder propagates `BadRequest`; a
52
+ foreign-provenance rule (explicit or missing) refuses the whole group while an explicit `""` is
53
+ writable; reordering the same rules shows `update`; and an unchanged declaration is a noop despite
54
+ every rule's `id`/`updated`/`provenance` differing.
55
+
56
+ ### Patch Changes
57
+
58
+ - [#251](https://github.com/taslabs-net/homeflare-kit/pull/251) [`4ef7909`](https://github.com/taslabs-net/homeflare-kit/commit/4ef790926bdcf5033aae1e31585e9490803aadf7) Thanks [@taslabs-net](https://github.com/taslabs-net)! - The `proxmox/*` family's `nodes/storage` sub-area, third resource (decision 43's serial proxmox
59
+ walk-down, after `access` in PR 231/239, `storage` in PR 243 and `ZfsPool` in PR 246): migrates
60
+ `Proxmox.NodeNetwork` off `client.ts`'s hand-rolled `pve()`/`pveHandlers` onto
61
+ `@distilled.cloud/proxmox`'s typed `nodes.getNodeNetwork`/`createNodeNetwork`/`putNodeNetwork2`/
62
+ `deleteNodeNetwork2`. Split into `node-network.ts` (the resource and its provider),
63
+ `node-network-wire.ts` (the read side — `readInterfaceOrFail`/`readInterface`, `matches`,
64
+ `attributesOf`) and `node-network-form.ts` (`NodeNetworkProps`, the write side, and the
65
+ coercion helpers both other files need) — the storage.ts/storage-form.ts seam.
66
+
67
+ **`Proxmox.NetworkApply` stays on `client.ts`, deliberately, not migrated with this PR.** Decision
68
+ 28/29/9 ("NetworkApply stays an operator step") plus two MEASURED reasons, checked directly
69
+ against `@distilled.cloud/proxmox`'s own source before deciding, not assumed:
70
+
71
+ 1. `network-apply-read.ts`'s `stagedDiff` reads the ONLY signal this family has for "is anything
72
+ staged" — `changes`, a SIBLING of `data` in PVE's own envelope
73
+ (`{"data":[...],"changes":"<unified diff>"}`), not inside it. `@distilled.cloud/proxmox`'s
74
+ `protocol.ts` `transformResponse` unconditionally returns `body.data ?? {}` for EVERY operation
75
+ on the protocol, discarding any sibling field before a generated operation's typed output is
76
+ even built. There is no typed call, current or future, that could see `changes` without a
77
+ protocol-level change upstream in the distilled package itself.
78
+ 2. `network-apply-read.ts`'s hand-rolled `awaitTask` deliberately collapses a failed poll to
79
+ "outcome unknown" rather than "task failed", because the member serving the poll can be the
80
+ very node whose reload just dropped the connection the poll rides on. `@distilled.cloud/
81
+ proxmox`'s own `Task.awaitTask` (`src/task.ts`) does not have this behaviour — it propagates a
82
+ poll failure as a genuine typed `GetNodeTaskStatusError`, which would fail `NetworkApply`'s
83
+ `reconcile` outright on the one failure mode it most needs to tolerate.
84
+
85
+ Both gaps are documented in `network-apply-read.ts`'s own header now, next to the functions they
86
+ affect. Revisit when distilled's protocol layer exposes the raw envelope, or its `awaitTask` grows
87
+ an option to treat a poll failure as unknown rather than fatal.
88
+
89
+ **A missing interface is a 400, not a 404 and not the 500 every other migrated family has** —
90
+ MEASURED against the live cluster (TB4 `n2`, read-role, read-only probe): `GET
91
+ /nodes/n2/network/vmbr9` answers `{"errors":{"iface":"interface does not exist"},"data":null,
92
+ "message":"Parameter verification failed.\n"}` at HTTP 400. `@distilled.cloud/proxmox` types PVE's
93
+ 400 as a genuine typed error, `ParameterVerificationFailed`, carrying the per-field `errors`
94
+ object rather than collapsing it to an opaque `BadRequest`. Only that exact signal —
95
+ `error.errors.iface === 'interface does not exist'` — means absent; a different 400 reason, a
96
+ genuine 500, or a network blip all propagate as real failures rather than being read as "gone".
97
+
98
+ **A precise absence signal does not remove the need for the dual-path read.** A first draft
99
+ reasoned that a parsed, specific absence signal made `readXOrFail`/folding-`readX` and `read`'s
100
+ `output`-branching unnecessary — every other migrated family carries that pair for the
101
+ `Drift.ts` gap, but this family's absence check already rejects anything that is not the exact
102
+ measured 400, so nothing wrongly propagates as absence. A Sonnet adversarial review caught the
103
+ flaw: precision of the absence signal and safety of folding at a given call site are separate
104
+ questions. Three of Alchemy's four `read` call sites (`Plan.ts`'s cold-start adoption probe,
105
+ `Plan.ts`'s interrupted-create recovery, `Apply.ts`'s delete recovery) call `provider.read` with
106
+ no state to compare against yet and no catch of their own around a typed failure — and `Plan.ts`
107
+ aggregates every resource's diff/probe effects fail-fast, so one resource's malformed or
108
+ transiently-failing declaration would abort the whole plan. Folding at those sites is what turns
109
+ that into a normal `create`/`noop` instead. Fixed by restoring the pair:
110
+ `readInterfaceOrFail` (non-folding, used by `diff` always and by `reconcile`'s write path) and
111
+ `readInterface` (folding, used by `read` when `output` is `undefined`, mirroring
112
+ user.ts/group.ts/storage.ts/zfs-pool.ts). `node-network-wire.ts`'s own header on both functions
113
+ has the full reasoning.
114
+
115
+ **TB4's fabric ports stay excluded, unchanged.** `tb0`/`tb1`/`dummy_c1` (the Thunderbolt mesh
116
+ SdnFabric owns, per decision 28/29/9) were never declared through this family and still are not —
117
+ this migration touches only how an interface this family DOES manage gets read and written, never
118
+ what gets declared through it. The header's own warning about them is carried forward verbatim,
119
+ now with an explicit pointer to decision 28/29/9.
120
+
121
+ Bugs caught before opening the PR, all fixed: an early draft's `diff` forgot to call
122
+ `unreadableWarning` on a refused-credential row (every OTHER migrated family logs it; this one
123
+ silently didn't) — self-caught, re-running the full test suite, fixed and now covered by
124
+ `node-network-read-failure.test.ts`'s own cries-wolf test. An unused import
125
+ (`ParameterVerificationFailed` imported as a value, needed only as the string tag
126
+ `Effect.catchTag` matches against) — caught by the pre-commit lint gate. And the dual-path
127
+ regression above — caught by the adversarial review, not self-caught.
128
+
129
+ `node-network-read-failure.test.ts` (new — this family had no dedicated test file before this
130
+ migration, only the create-form's vendor-constraint proof in `constraints-host-forms.test.ts`)
131
+ pins the cries-wolf fix, the precise absence signal (a genuinely new interface creates from
132
+ exactly that 400; a different 400 and a genuine 500 both propagate instead of creating), and the
133
+ `alchemy drift` fix. Each assertion was confirmed to fail against a deliberately weakened check
134
+ before landing (the "different 400 propagates" test specifically catches a version of the absence
135
+ check that matches any 400, not just the measured one).
136
+
137
+ **Expected after this releases and the consumer bumps:** no live plan change is expected for
138
+ `Proxmox.NodeNetwork` specifically — its `read`-role lease could always read the family's three
139
+ declared interfaces cleanly, so this is a transport migration plus a defensive fix for a bug class
140
+ not yet measured live for this family. `Proxmox.NetworkApply`'s own plan is unaffected, since it
141
+ is untouched by this PR.
142
+
143
+ ## 0.34.0
144
+
145
+ ### Minor Changes
146
+
147
+ - [#250](https://github.com/taslabs-net/homeflare-kit/pull/250) [`0e9c010`](https://github.com/taslabs-net/homeflare-kit/commit/0e9c0109ea21ffaf142db35f6a19bd424b23b9c2) Thanks [@taslabs-net](https://github.com/taslabs-net)! - `grafana/*` ships `Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate` — the
148
+ first of three stacked PRs bringing Grafana alerting provisioning under Alchemy (decision 40: every
149
+ internal alert lands in one place), on the same `@distilled.cloud/grafana@0.2.0` operations that
150
+ unblocked `Grafana.Folder`/`Grafana.Dashboard`. `Grafana.AlertRuleGroup` and
151
+ `Grafana.NotificationPolicy` follow in two later PRs. Full detail, including what was measured
152
+ against the SDK's generated types versus Grafana's own docs (no live call was made in this PR):
153
+ `docs/grafana-alerting.md`.
154
+
155
+ **Shared across the family:** a foreign-provenance refusal (`alerting-provenance.ts`) — every
156
+ resource reads Grafana's own `provenance` field and refuses a write when it names an owner other
157
+ than this API (an allowlist of `""`/`"api"`, not a denylist of the known foreign values, so an
158
+ unnamed future provenance source refuses by default). This is defense in depth: Grafana's alerting
159
+ provisioning API also enforces it server-side, unlike the classic folder/dashboard write API. No
160
+ resource in this family ever sets `X-Disable-Provenance` — an object this family declares stays
161
+ owned by the declaration.
162
+
163
+ ⛔ **Fixed after an adversarial review of this PR: the guard failed OPEN.** The first version
164
+ mapped `live.provenance ?? ''`, so a response that simply omitted the field was indistinguishable
165
+ from an explicit `""` (`ProvenanceNone`, writable) — a real risk for `Grafana.MuteTiming`
166
+ specifically, whose `provenance` isn't even in the SDK's declared type and survives only through an
167
+ unmodeled-key passthrough this house has never observed against a live response. `undefined` is now
168
+ kept as its own value all the way through `isForeignProvenance`/`refuseIfForeignProvenance`: ONLY a
169
+ field Grafana explicitly reports as `""` or `"api"` is writable; a missing field refuses, with a
170
+ message that says so. Every resource has a test pinning all three cases (missing refuses, explicit
171
+ `""` is writable, `"file"` refuses).
172
+
173
+ **`Grafana.ContactPoint`:** no by-uid GET route exists (measured), so `fetchLive` lists every
174
+ contact point and matches `uid` client-side rather than narrowing by the renameable `name` label. A
175
+ secure `settings` key is never a literal prop — `secureSettingsRefs` names it, resolved from an env
176
+ var fresh at write time and merged into `settings` only there (`secret-refs.ts`, extracted from
177
+ `Grafana.Datasource`'s identical `secureJsonDataRefs` seam so both share one implementation).
178
+ `matches` excludes every `secureSettingsRefs` key from comparison entirely, since Grafana's plain
179
+ GET always redacts a secure field.
@@ -0,0 +1,178 @@
1
+ # Alchemy changelog archive 4
2
+
3
+ [Current changelog](../../CHANGELOG.md) · [Archive index](./README.md)
4
+
5
+ **`Grafana.MuteTiming`:** a genuine SDK finding worth restating here — the generated
6
+ `MuteTimeInterval` type has no `provenance`/`version` fields, and its `TimeInterval` type is
7
+ missing the real Alertmanager time-interval fields (`weekdays`/`times`/`months`/etc.) entirely, even
8
+ though Grafana's real API sends and accepts all of them. Proven NOT to be a data-loss gap:
9
+ `@distilled.cloud/core`'s response decoding never runs a strict schema decode, only `JSON.parse`
10
+ plus a key-rename pass that leaves any key a type doesn't model verbatim — confirmed by driving the
11
+ real operation through this family's own fake-Grafana test harness. `mute-timing.ts` reads/writes
12
+ through a small locally-widened type instead of the SDK's own. Tracked in
13
+ `docs/upstream-conformance.md` as a real SDK type-generation gap (the same fix route as the other
14
+ named gaps there) — not attempted here, since `packages/distilled-grafana/src/` is vendored code
15
+ this kit never hand-edits.
16
+
17
+ **`Grafana.MessageTemplate`:** create and update are the same `PUT` wire call — measured, no
18
+ `routePostTemplate` exists — kept as two spec functions anyway so the shared engine's `diff` reports
19
+ a content change as `update`, not the `replace` it shows for any resource whose `spec.update` is
20
+ undefined.
21
+
22
+ Tests (`contact-point.test.ts` + `contact-point-secrets.test.ts` + `contact-point-provenance.test.ts`,
23
+ `mute-timing.test.ts`, `message-template.test.ts` — `ContactPoint`'s split three ways to stay under
24
+ the house's 250-line file cap) use the family's existing `fake-grafana.ts` harness and prove: a GET
25
+ never carries a body; a foreign-provenance object (explicit `"file"` or a MISSING field) refuses
26
+ update/destroy with no write sent, and an explicit `""` is writable; a transient failure (401/403)
27
+ propagates rather than folding to absent; an unchanged declaration is a noop despite an injected
28
+ field the declaration never mentioned; and, for `Grafana.ContactPoint`, that a secret value never
29
+ appears in a real `reconcile`'s returned attributes (not just in the declaration, which never held
30
+ it to begin with) — driven end to end through create, then the read-back `resource.ts` always does.
31
+ `subset-match.ts`'s array-length rule is flagged as unverified against a live mute timing and pinned
32
+ by its own test, per the same review.
33
+
34
+ ### Patch Changes
35
+
36
+ - [#248](https://github.com/taslabs-net/homeflare-kit/pull/248) [`4681702`](https://github.com/taslabs-net/homeflare-kit/commit/46817022ef49186139287d8706be30cb57dbd742) Thanks [@taslabs-net](https://github.com/taslabs-net)! - `@homeflare/alchemy/linux` now re-exports `renderContainerFile` and `containerPathFor` from
37
+ `Podman.Container`'s pure `.container` file renderer — the same kind of pure helper `renderUnit`/
38
+ `DEFAULT_UNIT_DIRECTORY` already are for `Systemd.Unit`. A consumer proving its own declared
39
+ props render into the directive set it expects (an equivalence/fixture test against a live host)
40
+ previously had no import path to the real renderer and had to reimplement the render contract by
41
+ hand to write that test at all (found: homeflare-ct100 PR [#1](https://github.com/taslabs-net/homeflare-kit/issues/1)).
42
+
43
+ ## 0.33.0
44
+
45
+ ### Minor Changes
46
+
47
+ - [#245](https://github.com/taslabs-net/homeflare-kit/pull/245) [`672f60f`](https://github.com/taslabs-net/homeflare-kit/commit/672f60f8ad43b02b1dd151540a2d5f13e716e4f9) Thanks [@taslabs-net](https://github.com/taslabs-net)! - `grafana/*` ships `Grafana.Folder` and `Grafana.Dashboard`, on top of the same
48
+ `@distilled.cloud/grafana@0.2.0` (aliased onto `@homeflare/distilled-grafana`) operations that
49
+ fixed the family's SDK gap: `createFolder`/`getFolderByUID`/`updateFolder`/`deleteFolder` and
50
+ `postDashboard`/`getDashboard`/`deleteDashboard`, plus the typed `PreconditionFailed` (412) for
51
+ dashboard version conflicts. Same file shape and uid-required doctrine as the existing
52
+ `Grafana.Datasource` (`resource.ts`'s shared `GrafanaSpec`/`grafanaOperations`/`grafanaHandlers`,
53
+ `grafanaProviders(target)` for credentials). Full detail, including which claims were measured
54
+ against the SDK's generated types versus inferred (no live call was made in this PR):
55
+ `docs/grafana-folder-dashboard.md`.
56
+
57
+ **`Grafana.Folder`:** `description` is write-only — MEASURED against the SDK's generated types,
58
+ `Folder`'s GET response has no `description` field at all though create/update both accept one —
59
+ so it is sent on every write that declares it but never diffed, the same treatment
60
+ `Grafana.Datasource` gives `secureJsonDataRefs`. Nesting (`parentUid`) is create-only — MEASURED,
61
+ `UpdateFolderRequest` has no `parentUid` field — so a declaration whose `parentUid` no longer
62
+ matches the live folder REFUSES with `GrafanaFolderReparentError` instead of silently doing
63
+ nothing or deleting-and-recreating under the new parent. `update` sends `version` for optimistic
64
+ concurrency, but the SDK's own doc comment on `UpdateFolderRequest.version` says "only used by the
65
+ legacy folder implementation" — unverified whether Grafana's newer unified-storage folder backend
66
+ honors it at all, flagged rather than presented as a verified 412-style guard. Delete defaults to
67
+ `defaultRemovalPolicy: 'retain'`: `deleteFolder` takes every dashboard and alert rule in the
68
+ folder with it, the same multi-object-cascade class `openbao/mount.ts` already guards this way
69
+ (verified there against Alchemy's own `Apply.ts`, not re-verified here — same engine, same
70
+ option). A stack that wants the cascade opts in with `.pipe(RemovalPolicy.destroy())`.
71
+
72
+ **`Grafana.Dashboard`:** the declared content is the dashboard JSON model, the same shape
73
+ Grafana's "Export as JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips
74
+ `id`/`version`/`iteration` and forces `uid` to the resource's own value before any comparison, so
75
+ a pasted export and a freshly-read live model compare equal and an unchanged dashboard plans as a
76
+ true noop despite those fields moving on every live save. `panels` and each panel's `targets` are
77
+ array-order-significant and compared positionally; nothing in a dashboard model needed UniFi's
78
+ `sortedSet` treatment. **`matches` compares "declaration is a subset of live", not plain
79
+ equality** (`declaredContentMatches`) — an adversarial review of this PR found plain structural
80
+ equality made an ordinary, never-edited dashboard show `update` forever, because Grafana's own
81
+ schema migration on save decorates any panel/target whose `datasource` is absent with an explicit
82
+ reference; a declaration silent about a field is now tolerated rather than compared, while a field
83
+ it DOES mention is still caught exactly as before (traded off: an omitted field can no longer
84
+ force-clear one Grafana or a prior declaration set — see the doc for the full reasoning). Version
85
+ conflicts (412) fail loudly and are never overwritten blindly: `update` injects the version this
86
+ resource just read into the model it sends and never sets `overwrite`; a genuine conflict
87
+ propagates `PreconditionFailed` uncaught and fails the whole reconcile, with the next
88
+ plan/deploy's own `fetchLive` serving as the re-read. Delete does **not** default to retain — a
89
+ single-object blast radius, the same class `Grafana.Datasource` already accepts.
90
+
91
+ **Both refuse writes to a provisioned (file-managed) object** — `Folder.managedBy` (non-empty) and
92
+ `DashboardMeta.provisioned`/`provisionedExternalId`, checked in `update`/`destroy` before any SDK
93
+ call, naming the owning file when Grafana reports one. `matches` still compares structurally
94
+ regardless, so a mismatched declaration against a provisioned object shows `update` in a plan
95
+ rather than a silently-hidden noop, and only fails at the point reconcile would actually write;
96
+ adopting a provisioned object read-only (a declaration matching what is live) never reaches the
97
+ refusal. ⚠️ `Folder.managedBy`'s exact values are INFERRED from the SDK's schema, not measured —
98
+ no live instance in this house currently has a file-provisioned folder to read (`teslamate-grafana`
99
+ has none at all).
100
+
101
+ Tests (`folder.test.ts`, `dashboard.test.ts`, `dashboard-model.test.ts`) use the family's existing
102
+ `fake-grafana.ts` harness — the real distilled protocol, only wire responses faked — and prove:
103
+ every request carries only its own declared fields (a GET's body is asserted `undefined`, the
104
+ exact "distilled JSON-encodes unknown keys as a body" trap this family's shared memory names);
105
+ provisioned objects refuse update/destroy with no write sent; a transient failure (401/403)
106
+ propagates rather than folding to absent; an unchanged declaration is a noop despite volatile
107
+ fields differing; and a 412 propagates uncaught.
108
+
109
+ ### Patch Changes
110
+
111
+ - [#246](https://github.com/taslabs-net/homeflare-kit/pull/246) [`953c361`](https://github.com/taslabs-net/homeflare-kit/commit/953c3617ac5b1cbd06d1c91f6562d2b4f3db346f) Thanks [@taslabs-net](https://github.com/taslabs-net)! - The `proxmox/*` family's `nodes/storage` sub-area, second resource (decision 43's serial proxmox
112
+ walk-down, after `access` in PR 231/239 and `storage` in PR 243): migrates `Proxmox.ZfsPool` off
113
+ `client.ts`'s hand-rolled `pve()`/`pveOperations` onto `@distilled.cloud/proxmox`'s typed
114
+ `nodes.getNodeDiskZfs`/`createNodeDiskZfs`/`deleteNodeDiskZfs`. Split into `zfs-pool.ts`
115
+ (resource + props), `zfs-pool-wire.ts` (the read side) and `zfs-pool-form.ts` (the write side) —
116
+ the api-token.ts/api-token-form.ts seam.
117
+
118
+ **Clears this family's own cries-wolf false updates.** `Proxmox.ZfsPool` reads with the `read`
119
+ role (`Sys.Audit` on `/` — unlike `Proxmox.Storage`/`Proxmox.Acl`, it never needed `provision` for
120
+ a GET), and the pre-migration `pveOperations.read` folded any REFUSED mint into "absent" the same
121
+ way every family did before its own fix — this family just hadn't been measured failing on the
122
+ agent lane yet, since its privilege requirement is looser. Now wired the same dual-path pattern
123
+ PR 239/243 established: `readPoolOrFail` (non-folding, used by `diff`, so a genuine transient
124
+ failure propagates and fails the plan loudly) and `readPool` (folding, used by `createPool`'s
125
+ settle-poll and by `read`/`reconcile`'s create-detection) — `read`'s own provider hook branches on
126
+ `output` from the start, so `alchemy drift` never reports a transient failure as a silent
127
+ `{action: 'missing'}` either.
128
+
129
+ **A missing pool is a 500, not a 404** — MEASURED against the live cluster (TB4 `n2`, read-role,
130
+ read-only probe): `GET /nodes/n2/disks/zfs/<missing>` answers a generic shelled-out `zpool status`
131
+ command failure at HTTP 500, the same class of measured 500 every other migrated family has.
132
+
133
+ **Decision 9's adopt-only mode is preserved exactly**, including the one nuance new to this
134
+ family: distilled's generated `CreateNodeDiskZfsRequest` marks `devices` and `raidlevel` as
135
+ REQUIRED fields (no `?`), unlike PVE's own runtime behaviour, which this resource's adopt-only
136
+ declarations depend on being able to omit. `createForm` stays a loosely-typed `Record<string,
137
+ string>` that can omit both — used for the vendor-constraint check (now `guardForm`, called
138
+ directly rather than through `guardWrite`, since this is the first migrated family whose
139
+ create-guard is CONDITIONAL: `guardWrite`'s own signature requires a real `EndpointKey`, but an
140
+ adopt-only declaration needs `undefined` some of the time) — and a separate `as unknown as` cast
141
+ builds the distilled-shaped request only at the point `createPool`'s own runtime guard has already
142
+ confirmed both fields are genuinely present, exactly preserving the pre-migration behaviour.
143
+
144
+ **One field needed distilled's hyphen-to-underscore rename** (`draid-config` -> `draid_config`,
145
+ the property `T.Body("draid-config")` maps back to the wire on the way out) — `storage.ts`/
146
+ `storage-form.ts`'s own measured finding from PR 243, here for a single field rather than a
147
+ free-form locator bag.
148
+
149
+ **`createPool`'s settle-poll (30 reads, 2s apart) is unchanged in behaviour** — still polling
150
+ `readPool`, not `@distilled.cloud/proxmox`'s own `Task.awaitTask`, which exists and would remove
151
+ the "not yet vs. forbidden" ambiguity the settle-poll's own comment already names as a known
152
+ weakness. Left as a documented follow-up rather than folded into this migration silently: swapping
153
+ it changes this resource's OBSERVABLE behaviour on a slow create (what a caller sees, and how),
154
+ which is an improvement to weigh on its own, not a transport swap.
155
+
156
+ **Two findings from adversarial review, both pre-existing (unchanged in shape from before this
157
+ migration, per `git blame`) but newly documented or fixed here:**
158
+
159
+ 1. A re-run of `deploy` after `createPool`'s give-up `die` can, in a narrow window, POST a SECOND
160
+ `zpool create` at the same devices while the first worker is still writing them — the same
161
+ "not yet vs. forbidden" ambiguity the settle-poll already has, just for a retry rather than the
162
+ first attempt. Not fixed (a real fix needs task-status tracking, not a transport swap); the
163
+ give-up message and `createPool`'s own header now say so explicitly, where before they only
164
+ described the ambiguity as a slow-create observability gap.
165
+ 2. `diff` (and its vendor-constraint check) never runs for a genuinely first-ever declaration —
166
+ upstream `Plan.ts` routes a brand-new resource straight to `create` without ever calling
167
+ `provider.diff` — so THIS WAS THE ONLY WRITE PATH IN THE FAMILY WITH NO VENDOR CHECK AT ALL,
168
+ before or after the migration started. Fixed: `createPool` now re-runs `guardForm` immediately
169
+ before its own POST, the same pattern `ceph-pool.ts`'s hand-written `reconcile` already uses
170
+ for the same reason.
171
+
172
+ **Also found and fixed while responding to that review** (not a finding from the review itself,
173
+ caught by re-running the full test suite after the changes above): an edit made to drop a dead
174
+ `matches()` export accidentally deleted the entire `live === undefined` branch of `diff` alongside
175
+ it, which would have made a genuinely-vanished pool plan `noop` forever instead of `update` —
176
+ caught immediately by `zfs-pool-adopt.test.ts`'s own pre-existing "an adopted pool that vanishes"
177
+ test failing, restored before this PR opened, left here for the record rather than silently
178
+ squashed into the diff.
@@ -0,0 +1,177 @@
1
+ # Alchemy changelog archive 5
2
+
3
+ [Current changelog](../../CHANGELOG.md) · [Archive index](./README.md)
4
+
5
+ `zfs-pool-adopt.test.ts` (the existing coverage — adopt-only noop/adopted, retain-vs-destroy, the
6
+ `ZfsRaidLevel` compile-time refusal) passes unchanged against the migrated code — `fake-pve.ts`'s
7
+ stub is transport-agnostic — plus one new case for finding 2 above (confirmed to reach a live
8
+ `POST` on the fake cluster without the fix, before timing out at the settle-poll). `zfs-pool-read-
9
+ failure.test.ts` (new) pins the cries-wolf fix, the transient-failure propagation, and the
10
+ `alchemy drift` fix, mirroring PR 243's `storage-read-failure.test.ts` — each assertion confirmed
11
+ to fail against the pre-fix code before landing.
12
+
13
+ **Scope note, not fixed by this PR:** `Proxmox.NodeNetwork` and `Proxmox.NetworkApply` (the rest
14
+ of the "ZFS pools + node network" sub-area) are deferred to a follow-up PR — see that PR's own
15
+ description for why they don't travel with this one.
16
+
17
+ **Expected after this releases and the consumer bumps:** no live plan change is expected — no
18
+ `Proxmox.ZfsPool` row was among the false-update rows PR 239/243 already fixed (this family's
19
+ `read`-role lease could always read it), so this PR is a transport migration plus a defensive fix
20
+ for a bug class not yet measured live for this specific family.
21
+
22
+ ## 0.32.0
23
+
24
+ ### Minor Changes
25
+
26
+ - [#236](https://github.com/taslabs-net/homeflare-kit/pull/236) [`3b4a5eb`](https://github.com/taslabs-net/homeflare-kit/commit/3b4a5eb37b1d136d1f899c48bccf50824c13f9bc) Thanks [@taslabs-net](https://github.com/taslabs-net)! - New `@homeflare/alchemy/opnsense` subpath, built on `@distilled.cloud/opnsense` (aliased onto
27
+ `@homeflare/distilled-opnsense@0.2.0`, generated against `opnsense/core`/`opnsense/plugins`
28
+ `26.7.2` — **measured live 2026-09-24** over the HTTPS API only, no SSH: the edge reports
29
+ `26.7.2_2`, one patch ahead of the generation tag, same series; see `docs/opnsense.md`).
30
+ `Opnsense.Firewall.Alias`, `.Category` and `.Group` (interface groups) are **READ-ONLY BY
31
+ DESIGN**: `reconcile` and `delete` always fail with a typed `OpnsenseWriteRefused`, naming the
32
+ policy ("read-only by Tim's rule, 2026-09-24; lifting it is a kit change") — neither handler
33
+ imports an `add`/`set`/`del`/`toggle` operation from the SDK, and `write-refusal.test.ts` proves
34
+ no write is reachable from any handler against a fake that fails on any non-GET request.
35
+
36
+ `read` follows upstream's `Snippet.ts` reference for a marker-less API exactly: a cold match is
37
+ `Unowned`, never a silent adopt, and each resource's convenience constructor
38
+ (`firewallAlias`/`firewallCategory`/`firewallGroup`) pipes `adopt(true)` on by default. All three
39
+ read through `get()` (the whole model tree, keyed by uuid) rather than a per-item endpoint —
40
+ `firewall_alias` has none, and `firewall_category`/`firewall_group`'s own `getCategory`/`getGroup`
41
+ carry no `uuid` in their generated request schema, a measured SDK generation gap this family works
42
+ around uniformly rather than relies on. Each resource also exports a pure declaration renderer
43
+ (`aliasPropsFromLive`/`categoryPropsFromLive`/`groupPropsFromLive`): given one live item, it
44
+ returns exactly the props a declaration needs to plan noop against it — what a later import
45
+ script will use to generate `alchemy.run.ts` rows from a live read.
46
+
47
+ This first import pass covers the three SDK modules that export both a `get` and a `search<Item>`
48
+ operation and are stable configuration (not runtime state): `firewall_alias`, `firewall_category`,
49
+ `firewall_group`. Skipped, and why, in `docs/opnsense.md`: `firewall_filter` (rules) and
50
+ `routing_settings` (gateways) have `get` but no `search*`; `quagga_general` is a settings
51
+ singleton with no uuid; `quagga_bgp` mixes several item types with no `search*` for any of them;
52
+ `quagga_service` is pure runtime control (start/stop/restart/status).
53
+
54
+ Credentials are `OPNSENSE_URL`/`OPNSENSE_API_KEY`/`OPNSENSE_API_SECRET`, read at call time through
55
+ the SDK's own `CredentialsFromEnv` (HTTP Basic, the vendor's own scheme), never a prop — today the
56
+ only key that exists is ROOT-level, one more reason writes stay refused unconditionally. This
57
+ family's own test suite makes no live call — every test runs against `fake-opnsense.ts`, a
58
+ loopback fake; the SDK's `26.7.2` version pin was measured separately, live and read-only
59
+ (`docs/opnsense.md`).
60
+
61
+ - [#238](https://github.com/taslabs-net/homeflare-kit/pull/238) [`d28861f`](https://github.com/taslabs-net/homeflare-kit/commit/d28861f00dce0d9dd17e5857e95332f8d009fa2c) Thanks [@taslabs-net](https://github.com/taslabs-net)! - New `Podman.Container` resource in `@homeflare/alchemy/linux` — one Podman Quadlet `.container`
62
+ file, declared, plus the systemd unit Podman's own generator turns it into. Built for Tim's
63
+ 2026-09-24 decision that CT100's containers (the two Grafanas, shared Postgres, shared Valkey,
64
+ later TeslaMate and NetBox) are owned by Alchemy through Quadlet rather than upstream Alchemy's
65
+ `Docker.Container`, which was measured and rejected: no host-network prop (every container here
66
+ runs `--network host`), `environment` puts `Redacted` secrets in Alchemy state, and systemd
67
+ already supervises the containers.
68
+
69
+ Reuses the `Systemd.Unit` family's lifecycle machinery directly (`renderUnit`, `isUnitRunning`,
70
+ `UNIT_WRITE`, `daemonReload`/`showUnit`/`startUnit`/`stopUnit`/`restartUnit` from `systemctl.ts`,
71
+ which also gains `SourcePath` to `SHOW_PROPERTIES` — additive for `Systemd.Unit`). `[Container]`
72
+ is typed and checked against `podman-systemd.unit(5)`, Podman 5.4 (CT100 runs 5.4.2): `Image`,
73
+ `ContainerName`, `Network` (supports `'host'`), `Volume`, `EnvironmentFile`, `Environment`
74
+ (never a secret — refused by name and value shape if it looks like one, `container-secrets.ts`),
75
+ `PublishPort`, `Exec`, `User`, `AutoUpdate`, `PodmanArgs`; `[Unit]`/`[Service]`/`[Install]` mirror
76
+ `Systemd.Unit`'s verbatim-lines approach with typed conveniences for `Description`/`Documentation`,
77
+ `Restart`/`RestartSec` and `WantedBy`/`RequiredBy`/`Alias` (the only three `[Install]` keys
78
+ Quadlet honours for a `.container` file, doc-confirmed).
79
+
80
+ The lifecycle differs from `Systemd.Unit` in the one place the generator forces it to: there is
81
+ no `enabled` prop and the resource never calls `systemctl enable`/`disable` — MEASURED on CT100
82
+ (`caddy.container`) that a generated unit is transient and doc-confirmed
83
+ (`podman-systemd.unit(5)`) that `systemctl enable` does not work on one; Quadlet applies
84
+ `[Install]` itself at every `daemon-reload`. A generator failure (a `.container` file Quadlet
85
+ refuses) surfaces as a typed `QuadletGeneratorError`, never "absent" — reasoned from
86
+ `systemd.generator(7)` (`daemon-reload` deletes and regenerates ALL generator output): an UPDATE
87
+ that fails verification restores the LAST-KNOWN-GOOD file and reloads again, so a container this
88
+ resource already promised running never loses its systemd unit to a bad update, rather than
89
+ leaving the new file for the next deploy to retry the way `Systemd.Unit` does.
90
+
91
+ `container-fixture.test.ts` renders the same directives as CT100's live `caddy.container`
92
+ (read read-only over `ssh ct100`), and documents why byte-for-byte is not the right bar for a
93
+ typed `[Container]` section (CT100's file interleaves prose comments between directives, which a
94
+ typed prop has nowhere to attach). Full guide: `docs/quadlet-container.md`.
95
+
96
+ Two rounds of review before opening/landing this PR — one foreground Sonnet adversarial pass
97
+ before opening it, aimed at restart-safety and secret leakage, and one inline coordinator
98
+ review after. All three findings were real and are fixed:
99
+
100
+ - **Restart-safety, interrupted UPDATE (high).** `reconcileContainer`'s reload gate (`wrote ||
101
+ preStatus.needDaemonReload`) could not see that the SOURCE `.container` file had already been
102
+ written by an apply that crashed before its `daemon-reload` ran: on retry, the file already held
103
+ the new content (`wrote: false`) and the still-stale GENERATED unit hadn't changed either
104
+ (`needDaemonReload: false`), so no reload ever happened, `settle` restarted the container onto
105
+ its OLD definition, and state recorded the new digest as if it had taken effect — permanently and
106
+ silently. Fixed by gating the reload on `stale` (state's digest disagreeing with what was just
107
+ rendered) too, the same signal `changed` already used to gate the restart.
108
+ - **Restart-safety, interrupted CREATE (high, found on the coordinator's inline re-review).** The
109
+ same bug for a resource with NO prior state: a create whose write landed but crashed before
110
+ `daemon-reload` retried with `wrote: false` and no `stale` to disagree with either, so
111
+ `verifyGenerated` threw on the still-missing generated unit forever — loud, but permanently
112
+ stuck, since nothing about a plain retry ever changed any of those signals. Fixed with
113
+ `needsVerificationReload` (`container-generator.ts`), sharing its predicate with
114
+ `verifyGenerated` itself so the reload gate and the check it is gating can never disagree; an
115
+ ordinary adoption whose generation was already proven fine still skips the extra reload.
116
+ - **Secret leakage (high).** `containerProblems` only ran `secretLikeEnvironment` over
117
+ `container.environment`; `exec`, `podmanArgs` (rendered verbatim) and the `unit.lines`/
118
+ `service.lines` escape hatches (which can spell a raw systemd `Environment=` outside the typed
119
+ map entirely) were unchecked. Fixed with `secretLikeLines` (the same value-shape and embedded
120
+ `KEY=VALUE` heuristics, run over every other rendered line) and an outright refusal of `-e`/
121
+ `--env` in `podmanArgs` (`podmanArgsProblems`), both in container-secrets.ts and wired into
122
+ `containerProblems`.
123
+
124
+ Regression tests for all three live in container-generator.test.ts ("an interrupted apply that
125
+ crashed before daemon-reload", "an interrupted CREATE that crashed before daemon-reload") and
126
+ container-secrets.test.ts (one per affected field); each was confirmed to fail without its fix.
127
+
128
+ - [#237](https://github.com/taslabs-net/homeflare-kit/pull/237) [`1c90063`](https://github.com/taslabs-net/homeflare-kit/commit/1c9006369dd623c522a0a1c0e747582800a4d981) Thanks [@taslabs-net](https://github.com/taslabs-net)! - New `@homeflare/alchemy/unifi` subpath: `Unifi.Network` and `Unifi.FirewallZone`,
129
+ generated from Ubiquiti's own UniFi Network Integration API **10.4.57** (OpenAPI 3.1.0)
130
+ via `@distilled.cloud/unifi-network` (aliased onto `@homeflare/distilled-unifi-network@0.2.0`,
131
+ `docs/distilled-interim.md`).
132
+
133
+ ⛔ **READ-ONLY, by Tim's rule (2026-09-24).** Neither resource has a create, an update body
134
+ or a working delete — `reconcile` and `delete` both fail with a typed `UnifiWriteRefused`
135
+ naming the policy, and no handler ever calls an SDK write operation (proved by fakes in
136
+ `resource.test.ts`, `network.test.ts` and `firewall-zone.test.ts` that record every request
137
+ sent and assert none is anything but `GET`). `read` answers `Unowned` on every match — never
138
+ a silent adopt — with `adopt(true)` piped on by default via the `network`/`firewallZone`
139
+ convenience constructors, so a first deploy against an existing object binds without a stack
140
+ needing `--adopt`. `list` answers `[]`; adoption stays explicit.
141
+
142
+ `declareNetwork(live, siteId)` and `declareFirewallZone(live, siteId)` are the declaration
143
+ renderers this PR ships alongside the resources: pure functions from one live read
144
+ (`getNetworkDetails`/`getFirewallZone`'s own response shape) to the `Props` a declaration
145
+ needs so its plan is a no-op — what a later import script will call to generate
146
+ `alchemy.run.ts` rows from a live site.
147
+
148
+ `Unifi.Network`/`Unifi.FirewallZone` were chosen as the first import set because both have a
149
+ list+get pair over genuine, stable configuration (not runtime state like a connected client,
150
+ a device statistic or a hotspot voucher) and a simple, non-discriminated wire shape. `Site`
151
+ (list-only, no `getSite`) and `Unifi.FirewallPolicy` (several converter-flattened
152
+ discriminator variants, and an ordering endpoint that replaces the whole rule list) are
153
+ deliberately out of scope for this PR — see `docs/unifi.md`.
154
+
155
+ `Unifi.Network`'s `matches` now normalizes the three fields the vendor document never says are
156
+ ordered — `dhcpGuarding.trustedDhcpServerIpAddresses`, `ipv6Configuration.additionalHostIpSubnets`,
157
+ `ipv6Configuration.dnsServerIpAddressesOverride` — before comparing, the same `sortedSet`
158
+ (dedupe + sort) fix `Unifi.FirewallZone` already applied to `networkIds`: alchemy's `deepEqual`
159
+ sorts object keys but not array elements, so an unchanged network whose console answered the same
160
+ set in a different order would otherwise plan a spurious `update` that the read-only reconcile
161
+ then refuses.
162
+
163
+ Auth was measured live, read-only, on 2026-09-24: `X-API-KEY` against the estate's local console
164
+ returns HTTP 200 on `GET /v1/info` and `GET /v1/sites`; the same key against the `api.ui.com`
165
+ cloud connector returns 401 (the wrong door, not a broken header) — see `docs/unifi.md`'s
166
+ "Credentials" section. This PR's own code still never calls the vendor API live.
167
+
168
+ ### Patch Changes
169
+
170
+ - [#243](https://github.com/taslabs-net/homeflare-kit/pull/243) [`823c1a8`](https://github.com/taslabs-net/homeflare-kit/commit/823c1a88fe1f8d34b90acb576231a18b7b04f0c3) Thanks [@taslabs-net](https://github.com/taslabs-net)! - The `proxmox/*` family's `nodes/storage` sub-area (decision 43's serial proxmox walk-down, PR 3
171
+ after `access` in PR 231/239) migrates `Proxmox.Storage` off `client.ts`'s hand-rolled `pve()`
172
+ (`resource.ts`'s `pveHandlers`) onto `@distilled.cloud/proxmox`'s typed `storage.getStorage`/
173
+ `createStorage`/`putStorage`/`deleteStorage` — the first resource of this sub-area; the rest of
174
+ `nodes`/`storage` (ZFS pools, Ceph, node network) follows in later PRs, split out because each is
175
+ independently substantial (see this PR's own description for why). `storage-wire.ts` (the read
176
+ side) and `storage-form.ts` (the write side) hold the pure wire-shape functions, the
177
+ api-token.ts/api-token-form.ts seam.