@homeflare/alchemy 0.19.1 → 0.20.0

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.
@@ -0,0 +1,164 @@
1
+ # Upstream conformance, family by family
2
+
3
+ Status: open ledger. Verified 2026-09-22 by reading `origin/main` `925454b` against
4
+ [provider-standard.md](./provider-standard.md) (`alchemy@2.0.0-beta.79`). Nothing in any
5
+ family was changed by this audit. Each row names the rule it breaks and the upstream
6
+ replacement or fix. A row is crossed off in the PR that fixes it, with that PR's evidence.
7
+ A decision marked **maintainer** is not an agent's to make.
8
+
9
+ ## Ranked findings
10
+
11
+ Findings are ranked by what they can break: runtime safety first, then contributability,
12
+ then tidiness.
13
+
14
+ 1. **`cloudflare/R2BucketLock` diverges on four rules.**
15
+ - `readLock` ends in `Effect.orDie` (S20).
16
+ - `reconcileLock` and `deleteLock` use `Effect.promise` (S19).
17
+ - `reconcileLock` skips the PUT whenever `output.rules` equals the declaration, without
18
+ reading the live lock (S10). So neither `--force` nor `drift --repair` can restore
19
+ lock rules that were removed out of band. ⚠️ Reasoned from the source, not measured
20
+ live.
21
+ - `accountId` is a prop (S15).
22
+
23
+ It is built on a second Cloudflare SDK, `cloudflare@4.5.0` (S23). Upstream
24
+ `Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is a real gap.
25
+ **Fix:** move to `@distilled.cloud/cloudflare/r2` `getBucketLock`/`putBucketLock`,
26
+ observe before the PUT, and resolve the account from `CloudflareEnvironment`. Then drop
27
+ the `cloudflare` peer. That makes it contributable as `lockRules` on `R2.Bucket`, or as
28
+ `Cloudflare.R2.BucketLock`. **Decision:** maintainer, on the upstream shape.
29
+
30
+ 2. **`forgejo/*` duplicates an upstream building block.** `client.ts` is a hand-rolled
31
+ Effect `HttpClient` client with a status-carrying `ForgejoError`, while
32
+ `@distilled.cloud/forgejo@1.0.0-rc.12` exists. That package's Smithy service version is
33
+ 16.0.3, the version this family measured (S21, S23). Credentials come from
34
+ `FORGEJO_TOKEN` at call time instead of an Auth provider (S24). `read` never answers
35
+ `Unowned` (H1). **Replacement:** the distilled SDK plus an `alchemy/Auth` provider.
36
+ Untyped errors become distilled patches. **Decision:** maintainer, because it changes
37
+ the error surface.
38
+ 3. **`cloudflare/MeshNode` duplicates `Cloudflare.Tunnel.WarpConnector`.** The divergence is
39
+ deliberate and documented in [mesh-node.md](./mesh-node.md): upstream persists the
40
+ connector token in state as a `Redacted` attribute, and it cannot create an HA node. The
41
+ type string `Cloudflare.MeshNode` sits inside upstream's own namespace (H14).
42
+ **Options:** contribute `ha` upstream and raise the token-in-state concern, then switch;
43
+ or keep the house resource as a recorded divergence. **Decision:** maintainer.
44
+ 4. **Unguarded `Bun.*`, and the `node:*` modules upstream names, in shipped provider code**
45
+ (S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
46
+ under Node, which is the runtime this package promises its consumers. The `node:*` paths
47
+ load on Node but break the Effect-only rule.
48
+ 5. **`launchd`, `linux` and `caddy` have promise-based seams (H3).** `HostRunner` and
49
+ `CaddyAdmin` are plain `async` interfaces, with `node:child_process`, `node:fs/promises`
50
+ and `node:http` behind them, and failures are untyped `Error` (S19, S21). The stated
51
+ reason is that a consumer implements plain async functions. Upstream meets the same need
52
+ by letting a caller provide `FileSystem`, `ChildProcessSpawner` or `CommandExecutor`
53
+ layers, and `alchemy/Util/AtomicFile.writeFileAtomic` covers the temp-and-rename write.
54
+ ⚠️ Only in part. `local-runner.ts` creates its temp file with `O_EXCL`, mode 0600 and
55
+ `chown` before `chmod`, and it refuses a planted symlink. `writeFileAtomic` writes with
56
+ default flags and mode, `chmod`s afterwards, and never `chown`s.
57
+ **Decision:** maintainer. These families are also host-specific, so "house-only" is a
58
+ valid answer.
59
+ 6. **`openbao/*` has no upstream equivalent, and it conforms on the contract** (Effect
60
+ `HttpClient`, strict `Unowned`, retain). It diverges in three ways:
61
+ - 30 `Effect.die` sites in non-test source, several of them on `diff` and
62
+ `reconcile` paths (S20);
63
+ - 42 test files on `node:test` (S43);
64
+ - `Bao.*` type strings, where the vendor name is `OpenBao` (H14).
65
+
66
+ No distilled SDK exists. OpenBao serves an OpenAPI document, so a generated SDK in
67
+ distilled's shape is the S23 path. **Decision:** maintainer, on whether the kit authors
68
+ a distilled package.
69
+
70
+ 7. **`proxmox/*` is the model for S38.** Its types and constraints are generated from the
71
+ vendor schema, and a manifest records the version and sha256. It diverges in four ways:
72
+ - the generic factory's `read` returns plain attributes, so every family except `Lxc`
73
+ adopts silently, against the house's own H1;
74
+ - 27 `Effect.die` sites in non-test source (S20);
75
+ - 7 test files on `node:test`;
76
+ - `Pbs.*` type strings (H14).
77
+ 8. **`talos/*`.** Its `talosctl` calls go through `ChildProcessSpawner`, as upstream's Docker
78
+ provider does, which conforms. Its file handling uses `Bun.file`, `Bun.write`,
79
+ `Bun.YAML` and `node:fs` (S42), and it uses `Effect.promise` in 3 files (S19). The
80
+ kubeconfig it writes is meant to be consumed through upstream's
81
+ `Kubernetes.KubeConfig({ path })`.
82
+ 9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
83
+ OpenAPI, and it uses Effect `HttpClient`. Adopting first is documented as deliberate.
84
+ It diverges in its credentials: `NETBOX_TOKEN` is read at call time (S24).
85
+ 10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
86
+ `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
87
+ (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
88
+ where nuke must never reach the object.
89
+ 11. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
90
+ `**Example:**` or `### Section` (S31, H10). **Decision:** maintainer, because it moves
91
+ the house's glyph rationale into `//` comments.
92
+ 12. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
93
+ fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
94
+
95
+ ## Conforms
96
+
97
+ - **`github/declareRepoPolicy`** composes upstream `GitHub.Repository` and
98
+ `GitHub.Ruleset`. Its one hazard is upstream's own: `Ruleset.read` returns `undefined`
99
+ without prior output, so a first deploy creates a duplicate
100
+ ([repo-policy.md](./repo-policy.md)). The fix belongs upstream: read by name and answer
101
+ `Unowned`.
102
+ - **`cloudflare/website.ts`** sets house defaults over upstream `Website.Astro` and
103
+ `Website.Vite`.
104
+ - **`ownership/*`** is built only on `AdoptPolicy`, `Stack`, `State` and `Artifacts`. Its
105
+ apply-time re-check (H2) exists because beta.79 skips the adoption probe while props hold
106
+ an Output. That fix also belongs in the engine upstream.
107
+ - **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
108
+ labels every cold adoption as an update (H6).
109
+
110
+ ## The Bun line
111
+
112
+ These are non-test files under `src/` that the standard (S42) says must be portable. "Exp"
113
+ marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds 23 non-test
114
+ files under `src/` that call `Bun.*` or import `node:*`/`bun:*`. Six of them are outside
115
+ S42's scope:
116
+
117
+ - four loopback fakes (`caddy/fake-caddy.ts`, `openbao/fake-bao.ts`, `proxmox/fake-pve.ts`,
118
+ `proxmox/fake-pve-lxc.ts`), which are test-side under S44;
119
+ - `proxmox/provision-cli-fake.ts`, which is used only by tests;
120
+ - `verify/args.ts`, which belongs to the CLI and so is tooling under S43.
121
+
122
+ That leaves 17 files. The table lists them, plus `provision-cli-fake.ts`, which should move
123
+ out of `src/`. The six out-of-scope files still ship in the tarball's `src/`, but no export
124
+ reaches them.
125
+ ⚠️ The first version of this table said 16 files and left out `launchd/job-form.ts`.
126
+ ⚠️ Corrected 2026-09-22: 14 of the 17 break upstream's rule. `launchd/job-form.ts`,
127
+ `proxmox/write-only.ts` and `proxmox/pbs-notification-target-wire.ts` use only synchronous
128
+ `node:crypto` or `Buffer`. Upstream allows those inside `Effect.sync`
129
+ (`AGENTS.md@v2.0.0-beta.79#Workflow`), and 40 upstream `src/` files import `node:crypto`,
130
+ among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a blanket
131
+ `node:*` ban that upstream does not have.
132
+
133
+ | file | API | exp | portable replacement |
134
+ | ---------------------------------------------------------- | -------------------------------------------- | --- | -------------------------------------------- |
135
+ | `openbao/cloudflare-roles-config.ts` | `Bun.YAML` | yes | parse on the tooling side; pass data in |
136
+ | `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher` | — | `Crypto.digest` + hex; keep `canonical()` ⚠️ |
137
+ | `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob` | — | `FileSystem` + `Path` |
138
+ | `openbao/approle-login-form.ts` | `Bun.file` | — | `FileSystem.readFileString` |
139
+ | `openbao/approle-login-result.ts` | `Bun.inspect` | — | a plain formatter |
140
+ | `openbao/forgejo-bootstrap.ts` | `Bun.spawn` | — | unreferenced; `ChildProcessSpawner` or drop |
141
+ | `talos/credentials.ts` | `Bun.write/file/env/randomUUIDv7`, `node:fs` | — | `FileSystem.makeTempFileScoped` |
142
+ | `talos/kubeconfig.ts` | `Bun.file` | yes | `FileSystem` |
143
+ | `talos/talos-machine-config.ts` | `Bun.file` | yes | `FileSystem` |
144
+ | `talos/values.ts` | `Bun.YAML`, `node:crypto` | — | tooling-side parse; hash may stay |
145
+ | `launchd/local-runner.ts` | `node:child_process`, `node:fs/promises` | yes | H3 above |
146
+ | `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
147
+ | `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
148
+ | `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
149
+ | `caddy/local-admin.ts` | `node:http` (unix socket) | yes | H3 above |
150
+ | `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
151
+ | `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
152
+
153
+ The ⚠️ rows:
154
+
155
+ - `openbao/digest.ts`: every `Bao.*` family persists this digest in state, so a swap must
156
+ hash the same bytes (`canonical()`, lowercase hex), or every row plans an update.
157
+ `Crypto.digest` from `effect/Crypto` is in every stack's services and fails with a typed
158
+ error; `alchemy/Util/sha256` wraps WebCrypto in `Effect.promise`.
159
+ - `proxmox/write-only.ts`: a salted scrypt seal with `timingSafeEqual`. No Alchemy helper
160
+ replaces it. A plain `sha256` would make a write-only secret cheap to brute-force from
161
+ state, and it would change the persisted seal format.
162
+
163
+ Test runners: 51 files import `node:test`/`node:assert` (openbao 42, proxmox 7, forgejo 1,
164
+ talos 1), and 126 import `bun:test`. S43 says `bun:test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -34,6 +34,21 @@ const tokens = (spec: RegionSpec) => {
34
34
  };
35
35
  };
36
36
 
37
+ /**
38
+ * Whether two declarations name the SAME block.
39
+ *
40
+ * ⛔ THE COMMENT TOKEN IS PART OF THE IDENTITY, not a formatting preference. The markers are
41
+ * matched as literal whole lines, so `# BEGIN pf` and `// BEGIN pf` are two different blocks:
42
+ * changing the token leaves the old block invisible to every later read, exactly as renaming
43
+ * does. A resource that treated either as a mere content change would splice a second block in
44
+ * and never be able to find the first one again.
45
+ * ★ Whole-file mode is its own identity: `undefined` equals only `undefined`.
46
+ */
47
+ export const sameRegion = (a: RegionSpec | undefined, b: RegionSpec | undefined): boolean =>
48
+ a === undefined || b === undefined
49
+ ? a === undefined && b === undefined
50
+ : a.name === b.name && (a.comment ?? DEFAULT_COMMENT) === (b.comment ?? DEFAULT_COMMENT);
51
+
37
52
  export const regionProblems = (spec: RegionSpec): string[] => {
38
53
  const found: string[] = [];
39
54
  const comment = spec.comment ?? DEFAULT_COMMENT;
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test';
6
6
  import { fakeLinuxHost } from './fake-linux-host.ts';
7
- import { fileProblems } from './remote-file-form.ts';
8
7
  import {
9
8
  deleteFile,
10
9
  diffFile,
@@ -89,93 +88,6 @@ describe('whole-file mode', () => {
89
88
  });
90
89
  });
91
90
 
92
- describe('region mode', () => {
93
- const VENDOR = '# vendor ruleset\nanchor "system"\nrule one\n';
94
- const region = { name: 'homeflare' };
95
- const block = { content: 'anchor "declared"\n', path: '/etc/vendor.conf', region };
96
-
97
- const withVendor = () => {
98
- const fake = host();
99
- fake.files.set('/etc/vendor.conf', {
100
- bytes: new TextEncoder().encode(VENDOR),
101
- gid: 0,
102
- kind: 'file',
103
- mode: 0o600,
104
- uid: 0,
105
- });
106
- return fake;
107
- };
108
-
109
- test('editing the block leaves the rest of the file byte-identical', async () => {
110
- const fake = withVendor();
111
- const output = await reconcileFile(fake.runner, block);
112
- const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
113
- expect(after.startsWith(VENDOR)).toBe(true);
114
- await reconcileFile(fake.runner, { ...block, content: 'anchor "changed"\n' }, output);
115
- expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
116
- });
117
-
118
- test("the other owner's mode and uid are preserved, never re-declared", async () => {
119
- const fake = withVendor();
120
- await reconcileFile(fake.runner, block);
121
- expect(fake.files.get('/etc/vendor.conf')?.mode).toBe(0o600);
122
- });
123
-
124
- test('a change anywhere else in the file is not this resource’s drift', async () => {
125
- const fake = withVendor();
126
- const output = await reconcileFile(fake.runner, block);
127
- const edited = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}rule two\n`;
128
- fake.files.set('/etc/vendor.conf', {
129
- bytes: new TextEncoder().encode(edited),
130
- gid: 0,
131
- kind: 'file',
132
- mode: 0o600,
133
- uid: 0,
134
- });
135
- expect(await diffFile(fake.runner, block, output)).toEqual({ action: 'noop' });
136
- });
137
-
138
- test('delete removes only the block', async () => {
139
- const fake = withVendor();
140
- const output = await reconcileFile(fake.runner, block);
141
- await deleteFile(fake.runner, output);
142
- expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
143
- });
144
-
145
- test('a missing file is a refusal, because a typo is likelier than an intention', async () => {
146
- const fake = host();
147
- await expect(
148
- reconcileFile(fake.runner, { ...block, path: '/etc/example/absent.conf' }),
149
- ).rejects.toThrow(/does not create the file it lives in/);
150
- const created = await reconcileFile(fake.runner, {
151
- ...block,
152
- create: true,
153
- path: '/etc/example/absent.conf',
154
- });
155
- expect(created.region).toEqual(region);
156
- });
157
-
158
- test('mode and owner without create are refused at validation', () => {
159
- expect(fileProblems({ ...block, mode: 0o600 })).toHaveLength(1);
160
- expect(fileProblems({ ...block, create: true, mode: 0o600 })).toEqual([]);
161
- });
162
-
163
- test('a block already there with other content needs --adopt', async () => {
164
- const fake = withVendor();
165
- fake.files.set('/etc/vendor.conf', {
166
- bytes: new TextEncoder().encode(
167
- `${VENDOR}# BEGIN homeflare\nsomething else\n# END homeflare\n`,
168
- ),
169
- gid: 0,
170
- kind: 'file',
171
- mode: 0o600,
172
- uid: 0,
173
- });
174
- await expect(reconcileFile(fake.runner, block)).rejects.toThrow(/managed region/);
175
- await expect(reconcileFile(fake.runner, block, undefined, true)).resolves.toBeDefined();
176
- });
177
- });
178
-
179
91
  describe('a failed read refuses', () => {
180
92
  test('a stat that throws is never read as “nothing is there”', async () => {
181
93
  const fake = host();
@@ -28,6 +28,7 @@ import {
28
28
  planWrite,
29
29
  readFileAttributes,
30
30
  refuse,
31
+ relocation,
31
32
  } from './remote-file-plan.ts';
32
33
 
33
34
  export { readFileAttributes } from './remote-file-plan.ts';
@@ -42,7 +43,8 @@ export const diffFile = async (
42
43
  output: RemoteFileAttributes,
43
44
  ): Promise<Diff> => {
44
45
  const want = await identity(runner, news);
45
- if (news.path !== output.path) {
46
+ // ⛔ A mode flip throws HERE, at plan time, before anything is written (remote-file-plan.ts).
47
+ if (relocation(news, output) === 'path') {
46
48
  // ★ Create-before-delete: two paths hold two files at once, so nothing forces deleteFirst.
47
49
  await runner.checkWrite?.(news.path, { mode: news.mode ?? DEFAULT_MODE, ...want });
48
50
  return { action: 'replace' };
@@ -80,8 +82,13 @@ export const reconcileFile = async (
80
82
  'a root destination or a privileged HostRunner. This provider never calls sudo itself.',
81
83
  );
82
84
  }
83
- const moved = output !== undefined && output.path !== props.path;
84
- const prior = moved ? undefined : output;
85
+ /**
86
+ * ⛔ WHAT WE OWNED LAST TIME MAY NOT BE WHAT WE OWN NOW. A new path, or a new region name or
87
+ * comment token, means the old block or file is still out there and must be taken back — and
88
+ * a flip between the two modes is refused outright (remote-file-plan.ts relocation).
89
+ */
90
+ const move = output === undefined ? 'none' : relocation(props, output);
91
+ const prior = move === 'none' ? output : undefined;
85
92
  const planned = await planWrite(runner, props, want);
86
93
  const live = ownedDigest(planned.before, props.region);
87
94
  const declared = digestOf(props.content);
@@ -127,10 +134,21 @@ export const reconcileFile = async (
127
134
  'the write returned but the file on disk does not match the declaration',
128
135
  );
129
136
  }
130
- // ★ Create-before-delete, as the replace would have been: the old path goes once the new one is
131
- // written and verified.
132
- if (moved && output !== undefined) await deleteFile(runner, output);
133
- return after;
137
+ // ★ Create-before-delete, as the replace would have been: the old path — or, for a rename inside
138
+ // one file, the old block — goes once the new one is written and verified.
139
+ if (move === 'none' || output === undefined) return after;
140
+ await deleteFile(runner, output);
141
+ if (move === 'path') return after;
142
+ /**
143
+ * ⛔ A REGION RENAME TOOK THE OLD BLOCK OUT OF THIS SAME FILE, so `after` — read before that
144
+ * removal — describes bytes that no longer exist. Storing it would make the whole-file digest
145
+ * wrong from the moment it was written and every later plan would read it as drift.
146
+ */
147
+ const settled = await readFileAttributes(runner, props.path, props.region);
148
+ if (settled === undefined || settled.contentSha256 !== declared) {
149
+ throw refuse(props.path, 'removing the previous managed block did not leave ours intact');
150
+ }
151
+ return settled;
134
152
  };
135
153
 
136
154
  /**
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { sha256Hex } from '../launchd/job-form.ts';
10
10
  import type { FileStat, HostRunner, WriteOptions } from '../launchd/runner.ts';
11
- import { type RegionSpec, readRegion, spliceRegion } from './region.ts';
11
+ import { type RegionSpec, readRegion, sameRegion, spliceRegion } from './region.ts';
12
12
  import {
13
13
  DEFAULT_MODE,
14
14
  type RemoteFileAttributes,
@@ -46,6 +46,42 @@ export const identity = async (runner: HostRunner, props: RemoteFileProps): Prom
46
46
 
47
47
  export const digestOf = (text: string): string => sha256Hex(encoder.encode(text));
48
48
 
49
+ /** What this declaration now owns, compared with what the last deploy owned. */
50
+ export type Move = 'none' | 'path' | 'region';
51
+
52
+ /**
53
+ * Has the thing this resource owns MOVED since the last deploy — and if so, how?
54
+ *
55
+ * 🔴 WHY THIS EXISTS (found by review, reproduced against the fake host, 2026-09-22). Without it,
56
+ * only `path` was identity and both of these read as a routine `update`:
57
+ * - RENAMING THE REGION left the old block in the file FOREVER. The new markers were spliced in,
58
+ * the old ones were never touched, and `delete` — which can only look for the name in state,
59
+ * now the new one — could never remove them. Two `anchor` lines in a packet filter, two
60
+ * entries in a host table, and nothing in the stack able to take either back.
61
+ * - DROPPING `region` TOOK OVER THE WHOLE FILE. The plan said `update`; the apply replaced every
62
+ * byte of a file belonging to a vendor package with this resource's four lines. That is the
63
+ * one thing the managed-region design exists to make impossible.
64
+ * ⛔ A FLIP BETWEEN THE TWO MODES IS A REFUSAL, NOT A MOVE. Neither order is safe: writing the
65
+ * whole file first destroys the other owner's bytes before anything can be undone, and removing
66
+ * the block first destroys our own claim and then refuses. The operator destroys the resource
67
+ * and declares a new one, which is the only sequence with a reviewable diff at each step.
68
+ * ★ A region-to-region rename IS safe as a move, because both blocks can exist at once: write the
69
+ * new one, verify it, then take the old one out (remote-file-lifecycle.ts reconcileFile).
70
+ */
71
+ export const relocation = (props: RemoteFileProps, output: RemoteFileAttributes): Move => {
72
+ if (props.path !== output.path) return 'path';
73
+ if (sameRegion(props.region, output.region)) return 'none';
74
+ if (props.region === undefined || output.region === undefined) {
75
+ throw refuse(
76
+ props.path,
77
+ `changes between owning the whole file and owning the managed region ` +
78
+ `${JSON.stringify((props.region ?? output.region)?.name ?? '')} at the same path. Those ` +
79
+ 'are two different claims on one file: destroy this resource and declare a new one.',
80
+ );
81
+ }
82
+ return 'region';
83
+ };
84
+
49
85
  /** The digest of the part this resource owns, or `undefined` when that part is not there. */
50
86
  export const ownedDigest = (
51
87
  file: string | undefined,
@@ -0,0 +1,181 @@
1
+ /**
2
+ * RemoteFile in MANAGED REGION mode against the fake Linux host: the promise that every byte
3
+ * outside the markers survives, and the identity rules that keep it true when a declaration
4
+ * changes. Split from remote-file-lifecycle.test.ts, which covers whole-file mode.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { fakeLinuxHost } from './fake-linux-host.ts';
8
+ import { fileProblems } from './remote-file-form.ts';
9
+ import {
10
+ deleteFile,
11
+ diffFile,
12
+ readFileAttributes,
13
+ reconcileFile,
14
+ } from './remote-file-lifecycle.ts';
15
+
16
+ const decode = (bytes: Uint8Array | undefined) => new TextDecoder().decode(bytes);
17
+
18
+ const host = (euid = 0) =>
19
+ fakeLinuxHost({
20
+ dirs: { '/etc': 0, '/etc/example': 0, '/home/someone': 1001 },
21
+ euid,
22
+ groups: { root: 0, staff: 50 },
23
+ users: {
24
+ root: { gid: 0, home: '/root', uid: 0 },
25
+ someone: { gid: 1001, home: '/home/someone', uid: 1001 },
26
+ },
27
+ });
28
+
29
+ describe('region mode', () => {
30
+ const VENDOR = '# vendor ruleset\nanchor "system"\nrule one\n';
31
+ const region = { name: 'homeflare' };
32
+ const block = { content: 'anchor "declared"\n', path: '/etc/vendor.conf', region };
33
+
34
+ const withVendor = () => {
35
+ const fake = host();
36
+ fake.files.set('/etc/vendor.conf', {
37
+ bytes: new TextEncoder().encode(VENDOR),
38
+ gid: 0,
39
+ kind: 'file',
40
+ mode: 0o600,
41
+ uid: 0,
42
+ });
43
+ return fake;
44
+ };
45
+
46
+ test('editing the block leaves the rest of the file byte-identical', async () => {
47
+ const fake = withVendor();
48
+ const output = await reconcileFile(fake.runner, block);
49
+ const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
50
+ expect(after.startsWith(VENDOR)).toBe(true);
51
+ await reconcileFile(fake.runner, { ...block, content: 'anchor "changed"\n' }, output);
52
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
53
+ });
54
+
55
+ test("the other owner's mode and uid are preserved, never re-declared", async () => {
56
+ const fake = withVendor();
57
+ await reconcileFile(fake.runner, block);
58
+ expect(fake.files.get('/etc/vendor.conf')?.mode).toBe(0o600);
59
+ });
60
+
61
+ test('a change anywhere else in the file is not this resource’s drift', async () => {
62
+ const fake = withVendor();
63
+ const output = await reconcileFile(fake.runner, block);
64
+ const edited = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}rule two\n`;
65
+ fake.files.set('/etc/vendor.conf', {
66
+ bytes: new TextEncoder().encode(edited),
67
+ gid: 0,
68
+ kind: 'file',
69
+ mode: 0o600,
70
+ uid: 0,
71
+ });
72
+ expect(await diffFile(fake.runner, block, output)).toEqual({ action: 'noop' });
73
+ });
74
+
75
+ test('delete removes only the block', async () => {
76
+ const fake = withVendor();
77
+ const output = await reconcileFile(fake.runner, block);
78
+ await deleteFile(fake.runner, output);
79
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
80
+ });
81
+
82
+ test('a missing file is a refusal, because a typo is likelier than an intention', async () => {
83
+ const fake = host();
84
+ await expect(
85
+ reconcileFile(fake.runner, { ...block, path: '/etc/example/absent.conf' }),
86
+ ).rejects.toThrow(/does not create the file it lives in/);
87
+ const created = await reconcileFile(fake.runner, {
88
+ ...block,
89
+ create: true,
90
+ path: '/etc/example/absent.conf',
91
+ });
92
+ expect(created.region).toEqual(region);
93
+ });
94
+
95
+ /**
96
+ * 🔴 BOTH OF THESE SHIPPED AS `update` AND WERE FOUND BY REVIEW (2026-09-22). They are the two
97
+ * ways the region promise could be broken from an ordinary edit to a stack file.
98
+ */
99
+ test('renaming the region moves the block; the old markers do not survive', async () => {
100
+ const fake = withVendor();
101
+ const output = await reconcileFile(fake.runner, block);
102
+ const renamed = { ...block, region: { name: 'homeflare-mcp' } };
103
+ // ⛔ An update, not a noop: the new block is not in the file yet.
104
+ expect(await diffFile(fake.runner, renamed, output)).toEqual({ action: 'update' });
105
+ const moved = await reconcileFile(fake.runner, renamed, output);
106
+ const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
107
+ expect(after.startsWith(VENDOR)).toBe(true);
108
+ expect(after).not.toContain('BEGIN homeflare\n');
109
+ expect(after).toContain('BEGIN homeflare-mcp');
110
+ // ⛔ The stored digest must describe the file AFTER the old block was taken out.
111
+ const live = await readFileAttributes(fake.runner, '/etc/vendor.conf', renamed.region);
112
+ expect(moved.sha256).toBe(live?.sha256 ?? '');
113
+ await deleteFile(fake.runner, moved);
114
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
115
+ });
116
+
117
+ test('changing the comment token is a rename, not a reformat', async () => {
118
+ const fake = withVendor();
119
+ const output = await reconcileFile(fake.runner, block);
120
+ const retokened = { ...block, region: { comment: '//', name: 'homeflare' } };
121
+ await reconcileFile(fake.runner, retokened, output);
122
+ const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
123
+ expect(after).toContain('// BEGIN homeflare');
124
+ expect(after).not.toContain('# BEGIN homeflare');
125
+ });
126
+
127
+ test('a file this resource borrowed is never taken over by dropping the region', async () => {
128
+ const fake = withVendor();
129
+ const output = await reconcileFile(fake.runner, block);
130
+ const whole = { content: block.content, path: block.path };
131
+ // ⛔ At plan time, before a single byte moves.
132
+ await expect(diffFile(fake.runner, whole, output)).rejects.toThrow(/two different claims/);
133
+ await expect(reconcileFile(fake.runner, whole, output)).rejects.toThrow(/two different claims/);
134
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
135
+ });
136
+
137
+ test('adding a region to a file this resource owned whole is refused the same way', async () => {
138
+ const fake = host();
139
+ const whole = { content: 'a = 1\n', path: '/etc/example/whole.conf' };
140
+ const output = await reconcileFile(fake.runner, whole);
141
+ await expect(
142
+ diffFile(fake.runner, { ...whole, region: { name: 'homeflare' } }, output),
143
+ ).rejects.toThrow(/two different claims/);
144
+ });
145
+
146
+ test('a rename onto a block someone else already owns is refused', async () => {
147
+ const fake = withVendor();
148
+ const output = await reconcileFile(fake.runner, block);
149
+ const taken = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}# BEGIN other\nnot ours\n# END other\n`;
150
+ fake.files.set('/etc/vendor.conf', {
151
+ bytes: new TextEncoder().encode(taken),
152
+ gid: 0,
153
+ kind: 'file',
154
+ mode: 0o600,
155
+ uid: 0,
156
+ });
157
+ await expect(
158
+ reconcileFile(fake.runner, { ...block, region: { name: 'other' } }, output),
159
+ ).rejects.toThrow(/already carries that managed region/);
160
+ });
161
+
162
+ test('mode and owner without create are refused at validation', () => {
163
+ expect(fileProblems({ ...block, mode: 0o600 })).toHaveLength(1);
164
+ expect(fileProblems({ ...block, create: true, mode: 0o600 })).toEqual([]);
165
+ });
166
+
167
+ test('a block already there with other content needs --adopt', async () => {
168
+ const fake = withVendor();
169
+ fake.files.set('/etc/vendor.conf', {
170
+ bytes: new TextEncoder().encode(
171
+ `${VENDOR}# BEGIN homeflare\nsomething else\n# END homeflare\n`,
172
+ ),
173
+ gid: 0,
174
+ kind: 'file',
175
+ mode: 0o600,
176
+ uid: 0,
177
+ });
178
+ await expect(reconcileFile(fake.runner, block)).rejects.toThrow(/managed region/);
179
+ await expect(reconcileFile(fake.runner, block, undefined, true)).resolves.toBeDefined();
180
+ });
181
+ });
@@ -6,10 +6,13 @@
6
6
  * the host already says what we declare; then read back and compared.
7
7
  * - read — lstat plus the SHA-256 of the file and of the part this resource owns.
8
8
  * - diff — the declared digest against the stored AND the on-disk one, plus mode and owner.
9
- * - replace — only when `path` changes; create-before-delete.
9
+ * - replace — only when `path` changes; create-before-delete. A region renamed inside one file
10
+ * is an update that writes the new block, verifies it, then takes the old one out.
10
11
  * - delete — removes the file, or in region mode only the block.
11
12
  *
12
13
  * ⛔ NEVER A SECRET — see remote-file-form.ts.
14
+ * ⛔ THE REGION IS PART OF THE IDENTITY. Renaming it, or changing its comment token, moves the block;
15
+ * swapping between owning the whole file and owning a block is refused (remote-file-plan.ts).
13
16
  * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt` (docs/ownership.md): a file already at the path, or a
14
17
  * block already carrying the markers, reads as `Unowned`.
15
18
  * ★ IT IS A SEPARATE RESOURCE FROM `Host.File`, not a flag on it: the two have different attributes
@@ -17,6 +17,7 @@ import { adoptsAtApply } from '../ownership/adopt.ts';
17
17
  import { noteUnfinished } from '../ownership/resume.ts';
18
18
  import type { SystemdUnitAttributes, SystemdUnitProps } from './unit-form.ts';
19
19
  import { deleteUnit, diffUnit, readUnit, reconcileUnit } from './unit-lifecycle.ts';
20
+ import { assertRenameTarget } from './unit-preflight.ts';
20
21
 
21
22
  export const readHandler = (
22
23
  runner: HostRunner,
@@ -46,11 +47,17 @@ export const diffHandler = (
46
47
  * when a rendered config file's digest is templated into the same deploy.
47
48
  */
48
49
  const name = resolvedString(news, 'name');
49
- return Effect.succeed(
50
- name !== undefined && name !== output.name
51
- ? { action: 'replace' as const, deleteFirst: true }
52
- : undefined,
53
- );
50
+ if (name === undefined || name === output.name) return Effect.succeed(undefined);
51
+ /**
52
+ * ⛔ THE NAME IS ENOUGH TO REFUSE A MASKED TARGET, and the refusal has to happen here: the old
53
+ * unit is removed before the new one is reconciled. The unit file cannot be compared while
54
+ * `content` is still an Output, so this is the name-only half of assertReplaceable
55
+ * (unit-preflight.ts).
56
+ */
57
+ return lift(async () => {
58
+ await assertRenameTarget(runner, output, name);
59
+ return { action: 'replace' as const, deleteFirst: true };
60
+ });
54
61
  };
55
62
 
56
63
  export const reconcileHandler = (