@homeflare/alchemy 0.56.0 → 0.57.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.
Files changed (40) hide show
  1. package/dist/unifi/acl-rule-ordering.d.ts +1 -1
  2. package/dist/unifi/acl-rule.d.ts +1 -1
  3. package/dist/unifi/dns-policy.d.ts +1 -1
  4. package/dist/unifi/firewall-policy-create.d.ts +55 -0
  5. package/dist/unifi/firewall-policy-create.d.ts.map +1 -0
  6. package/dist/unifi/firewall-policy-form.d.ts +9 -2
  7. package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
  8. package/dist/unifi/firewall-policy-ordering.d.ts +1 -1
  9. package/dist/unifi/firewall-policy.d.ts +45 -12
  10. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  11. package/dist/unifi/firewall-zone.d.ts +1 -1
  12. package/dist/unifi/index.d.ts +1 -1
  13. package/dist/unifi/index.d.ts.map +1 -1
  14. package/dist/unifi/index.js +112 -21
  15. package/dist/unifi/index.js.map +10 -9
  16. package/dist/unifi/network.d.ts +1 -1
  17. package/dist/unifi/policy.d.ts +22 -0
  18. package/dist/unifi/policy.d.ts.map +1 -1
  19. package/dist/unifi/resource.d.ts +26 -7
  20. package/dist/unifi/resource.d.ts.map +1 -1
  21. package/dist/unifi/traffic-matching-list.d.ts +1 -1
  22. package/dist/unifi/update-reconcile.d.ts +36 -2
  23. package/dist/unifi/update-reconcile.d.ts.map +1 -1
  24. package/dist/unifi/wifi-broadcast.d.ts +1 -1
  25. package/dist/unifi/wire-guard.d.ts +1 -1
  26. package/dist/unifi/wire-guard.d.ts.map +1 -1
  27. package/docs/distilled-interim.md +4 -2
  28. package/package.json +1 -1
  29. package/src/unifi/firewall-policy-create.ts +94 -0
  30. package/src/unifi/firewall-policy-form.ts +9 -2
  31. package/src/unifi/firewall-policy.test.ts +188 -1
  32. package/src/unifi/firewall-policy.ts +108 -12
  33. package/src/unifi/index.ts +1 -0
  34. package/src/unifi/policy.ts +45 -12
  35. package/src/unifi/resource.test.ts +103 -0
  36. package/src/unifi/resource.ts +73 -21
  37. package/src/unifi/update-reconcile.ts +72 -2
  38. package/src/unifi/wire-guard.test.ts +86 -1
  39. package/src/unifi/wire-guard.ts +8 -1
  40. package/src/unifi/write-op-reference.test.ts +21 -7
@@ -1,9 +1,13 @@
1
1
  /**
2
- * `Unifi.FirewallPolicy` — one firewall policy on a UniFi Network site, READ-ONLY (`policy.ts`).
3
- * `Unifi.FirewallPolicyOrdering` (`firewall-policy-ordering.ts`) is the sibling resource for policy
4
- * ORDER within a zone pair (T5) — one per `(sourceFirewallZoneId, destinationFirewallZoneId)`, never
5
- * this file's concern. `docs/unifi.md`'s own header names this pair as "a bigger, separate PR";
6
- * this is that PR — see `docs/unifi-firewall-policy.md` for the full breakdown.
2
+ * `Unifi.FirewallPolicy` — one firewall policy on a UniFi Network site. READ/ADOPT-ONLY for every
3
+ * row (`policy.ts`), EXCEPT create for a row whose declared `name` starts `hf: `
4
+ * (`isHfPolicyName`, Tim 2026-10-19 ~16:20Z) — `update` and `delete` stay refused even for an
5
+ * `hf:` row. `Unifi.FirewallPolicyOrdering` (`firewall-policy-ordering.ts`) is the sibling
6
+ * resource for policy ORDER within a zone pair (T5) — one per `(sourceFirewallZoneId,
7
+ * destinationFirewallZoneId)`, still read-only; this lift does not touch it, so a created `hf:`
8
+ * row lands wherever the controller places a new policy, and placing it ahead of its zone pair's
9
+ * "Block All Traffic" (if the controller does not already do that) would need an ordering WRITE —
10
+ * a second, separate lift this PR does not make (see `docs/unifi-firewall-policy.md`).
7
11
  *
8
12
  * ★ T11 GATE, CHECKED 2026-09-26: the `Firewall` tag's 13 operations (5 already covered for
9
13
  * `Unifi.FirewallZone` in `docs/unifi.md`, 8 new here for `FirewallPolicy` — `getFirewallPolicies`,
@@ -14,25 +18,47 @@
14
18
  * that DID change elsewhere in the document. Corroborated independently by the A3 changeset's own
15
19
  * broader 25-operation/139-schema re-check (`.changeset/unifi-network-a3-typed-discriminator-filters.md`).
16
20
  * Full breakdown: `docs/unifi-firewall-policy.md`.
21
+ *
22
+ * ⛔ `fetchLive` LOCATES BY `name` WHEN `firewallPolicyId` IS ABSENT (an `hf:` creatable
23
+ * declaration never has the vendor id before its first successful create). A real site's policy
24
+ * count is in the hundreds (T9, 424 live policies), so this walks every page with `pageAll`
25
+ * rather than trusting any one page to hold the match — same reasoning netbox's `locateOne`
26
+ * gives for a server-side filter that cannot be trusted alone, except here there IS no
27
+ * server-side name filter at all, so every row is paged. More than one row sharing a name is a
28
+ * defect, not a reason to take the first: `policy.ts`'s naming convention is exactly `hf: <desc>`
29
+ * because a free-text description makes a collision astronomically unlikely, but if the console
30
+ * ever has one, this engine refuses to guess which row is "the" declared one.
17
31
  */
18
32
  import { Resource } from 'alchemy';
19
33
  import { adopt } from 'alchemy/AdoptPolicy';
20
34
  import * as Provider from 'alchemy/Provider';
21
35
  import * as firewall from '@distilled.cloud/unifi-network/firewall';
22
36
  import * as Effect from 'effect/Effect';
37
+ import {
38
+ checkHfCreateScope,
39
+ firewallPolicyCreateAllowedWrite,
40
+ writeFirewallPolicyCreate,
41
+ } from './firewall-policy-create.ts';
42
+ import { driftOf } from './firewall-policy-drift.ts';
23
43
  import {
24
44
  type FirewallPolicyAttributes,
25
45
  type FirewallPolicyProps,
26
46
  attributesOf,
27
47
  } from './firewall-policy-form.ts';
28
48
  import { matches } from './firewall-policy-drift.ts';
49
+ import { type UnifiPaginationInconsistent, pageAll } from './paginate.ts';
29
50
  import type { UnifiRequirements, UnifiSpec } from './resource.ts';
30
51
  import { unifiHandlers } from './resource.ts';
52
+ import type { UnifiWriteRefused } from './policy.ts';
31
53
 
32
54
  export type { FirewallPolicyAttributes, FirewallPolicyProps } from './firewall-policy-form.ts';
33
55
  export { declareFirewallPolicy } from './firewall-policy-form.ts';
34
56
  export { driftOf } from './firewall-policy-drift.ts';
35
57
 
58
+ /** `checkHfCreateScope`'s typed refusal, union'd with the real create call's own error, same
59
+ * shape `network.ts`'s own `UpdateError` gives its write half. */
60
+ type CreateError = UnifiWriteRefused | firewall.CreateFirewallPolicyError;
61
+
36
62
  export interface UnifiFirewallPolicy extends Resource<
37
63
  'Unifi.FirewallPolicy',
38
64
  FirewallPolicyProps,
@@ -45,26 +71,96 @@ export const UnifiFirewallPolicy = Resource<UnifiFirewallPolicy>('Unifi.Firewall
45
71
  defaultRemovalPolicy: 'retain',
46
72
  });
47
73
 
48
- /** `firewallPolicy('block-iot-wan', props)` — `adopt(true)` piped on by default (H5); see
49
- * `network.ts`. */
74
+ /** `firewallPolicy('block-iot-wan', props)` — for an IMPORTED, adopt-only row (always carries the
75
+ * vendor's `firewallPolicyId`). `adopt(true)` piped on by default (H5); see `network.ts`. */
50
76
  export const firewallPolicy = (id: string, props: FirewallPolicyProps) =>
51
77
  UnifiFirewallPolicy(id, props).pipe(adopt(true));
52
78
 
79
+ /**
80
+ * `hfFirewallPolicy('ct100-to-nas-https', props)` — for a NEW, kit-creatable row: `props` has no
81
+ * `firewallPolicyId` (the vendor assigns one), and `name` MUST start `hf: ` or this throws
82
+ * immediately, at declaration time, rather than waiting for `createReconcile`'s own typed refusal
83
+ * at deploy time. That typed refusal (`checkHfCreateScope`) is still the authoritative gate — this
84
+ * is a second, earlier check for the common case of a typo, not a replacement for it; a caller
85
+ * that builds `FirewallPolicyProps` by hand and calls `UnifiFirewallPolicy` directly still goes
86
+ * through `checkHfCreateScope` and is still refused the same way.
87
+ *
88
+ * ⛔ NO `adopt(true)` HERE. This is a create path, not an adoption: a cold read finding an
89
+ * existing row of this exact name is `Unowned`, same as any other family, and a plan that adopts
90
+ * one silently is not what "create" means for this lift.
91
+ */
92
+ export const hfFirewallPolicy = (
93
+ id: string,
94
+ props: Omit<FirewallPolicyProps, 'firewallPolicyId'>,
95
+ ) => {
96
+ // ⚠️ `Omit` only rejects an excess property on a FRESH object literal passed at the call site —
97
+ // a variable typed `FirewallPolicyProps` (which still has the field) passes the parameter type
98
+ // check silently (Opus red team, 2026-10-19, SECONDARY FINDING). Checked at runtime so this
99
+ // constructor cannot be the route past `checkHfCreateScope`'s own identity check either.
100
+ if (
101
+ 'firewallPolicyId' in props &&
102
+ (props as { firewallPolicyId?: unknown }).firewallPolicyId !== undefined
103
+ ) {
104
+ throw new Error(
105
+ `hfFirewallPolicy(${id}): props already carries a firewallPolicyId -- this constructor is ` +
106
+ 'for a row that does not exist yet. A row with a vendor id is adopted or updated, never ' +
107
+ 'created; use firewallPolicy() instead.',
108
+ );
109
+ }
110
+ if (!props.name.startsWith('hf: ')) {
111
+ throw new Error(
112
+ `hfFirewallPolicy(${id}): name "${props.name}" does not start "hf: " -- only hf:-named ` +
113
+ 'policies may be created by this kit (Tim 2026-10-19); rename the declaration.',
114
+ );
115
+ }
116
+ return UnifiFirewallPolicy(id, props as FirewallPolicyProps);
117
+ };
118
+
119
+ /** One page of `getFirewallPolicies`, walked end to end and narrowed to the sole row named
120
+ * `name` — the locate path `fetchLive` uses when `firewallPolicyId` is not yet known. */
121
+ const locateByName = (siteId: string, name: string) =>
122
+ pageAll(firewall.getFirewallPolicies, { siteId }).pipe(
123
+ Effect.map((rows) => {
124
+ const matching = rows.filter((row) => row.name === name);
125
+ if (matching.length > 1) {
126
+ throw new Error(
127
+ `Unifi.FirewallPolicy: ${String(matching.length)} live policies are named "${name}" on ` +
128
+ `site ${siteId}. A name this engine locates by must be unique; rename one on the console.`,
129
+ );
130
+ }
131
+ return matching[0];
132
+ }),
133
+ );
134
+
53
135
  /** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
54
136
  export const spec: UnifiSpec<
55
137
  FirewallPolicyProps,
56
138
  firewall.FirewallPolicy,
57
139
  FirewallPolicyAttributes,
58
- firewall.GetFirewallPolicyError
140
+ firewall.GetFirewallPolicyError | firewall.GetFirewallPoliciesError | UnifiPaginationInconsistent,
141
+ never,
142
+ CreateError
59
143
  > = {
60
144
  type: 'Unifi.FirewallPolicy',
61
- describe: (props) => `sites/${props.siteId}/firewall/policies/${props.firewallPolicyId}`,
145
+ describe: (props) =>
146
+ props.firewallPolicyId === undefined
147
+ ? `sites/${props.siteId}/firewall/policies/by-name/${props.name}`
148
+ : `sites/${props.siteId}/firewall/policies/${props.firewallPolicyId}`,
62
149
  fetchLive: (props) =>
63
- firewall
64
- .getFirewallPolicy({ siteId: props.siteId, firewallPolicyId: props.firewallPolicyId })
65
- .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
150
+ props.firewallPolicyId === undefined
151
+ ? locateByName(props.siteId, props.name)
152
+ : firewall
153
+ .getFirewallPolicy({ siteId: props.siteId, firewallPolicyId: props.firewallPolicyId })
154
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
66
155
  attributes: attributesOf,
67
156
  matches,
157
+ create: {
158
+ allowedWrite: firewallPolicyCreateAllowedWrite,
159
+ checkScope: checkHfCreateScope,
160
+ write: writeFirewallPolicyCreate,
161
+ identify: (props, written) => ({ ...props, firewallPolicyId: written.id }),
162
+ driftOf,
163
+ },
68
164
  };
69
165
 
70
166
  export const handlers = unifiHandlers(spec);
@@ -94,6 +94,7 @@ export {
94
94
  UnifiFirewallPolicyProvider,
95
95
  declareFirewallPolicy,
96
96
  firewallPolicy,
97
+ hfFirewallPolicy,
97
98
  type FirewallPolicyAttributes,
98
99
  type FirewallPolicyProps,
99
100
  driftOf as firewallPolicyDriftOf,
@@ -1,14 +1,17 @@
1
1
  /**
2
- * The one refusal every refused `Unifi.*` write returns. The family makes exactly one SDK write
3
- * call — `networks.updateNetwork`, through `network-update.ts` — and that is the only write that
4
- * never sees this error.
2
+ * The one refusal every refused `Unifi.*` write returns. The family makes exactly two SDK write
3
+ * calls — `networks.updateNetwork` (`network-update.ts`) and, as of the lift below,
4
+ * `firewall.createFirewallPolicy` (`firewall-policy-create.ts`) — and those are the only writes
5
+ * that do not see this error.
5
6
  *
6
- * ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY. LIFTED FOR EXACTLY ONE CASE on
7
- * 2026-10-10 (Tim): `Unifi.Network` update, through `update-reconcile.ts` + `network-update.ts`.
8
- * Every other write — create, delete, any other family's update — still fails with this typed
9
- * error, and `resource.test.ts`/`wire-guard.test.ts` prove it with a fake `HttpClient` that
10
- * records every request. Widening the policy again is a kit change (a new PR, reviewed as one),
11
- * not a flag a stack can pass.
7
+ * ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY. LIFTED FOR TWO CASES SINCE:
8
+ * 2026-10-10, `Unifi.Network` update, through `update-reconcile.ts` + `network-update.ts`; and
9
+ * 2026-10-19 ~16:20Z ("Allow kit to create hf: rules"), `Unifi.FirewallPolicy` CREATE, and ONLY
10
+ * when the declared `name` starts `hf: ` (`isHfPolicyName` below). Every other write — every
11
+ * other family's create, every update of an `hf:` row (or any other), every delete — still fails
12
+ * with this typed error, and `resource.test.ts`/`wire-guard.test.ts`/`firewall-policy.test.ts`
13
+ * prove it with a fake `HttpClient` that records every request. Widening the policy again is a
14
+ * kit change (a new PR, reviewed as one), not a flag a stack can pass.
12
15
  *
13
16
  * ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a
14
17
  * caller (a future import script, a test) branch on the refusal without parsing a message.
@@ -17,10 +20,22 @@ import * as Data from 'effect/Data';
17
20
  import * as Effect from 'effect/Effect';
18
21
 
19
22
  export const UNIFI_WRITE_POLICY =
20
- 'UniFi is adopt-only except Unifi.Network update: one whole-object PUT built from the live ' +
23
+ 'UniFi is adopt-only except (1) Unifi.Network update: one whole-object PUT built from the live ' +
21
24
  'object plus the declared field changes, only on a row with prior Alchemy state, only through ' +
22
- 'deploy-approve; create/delete and every other family stay refused -- widening is a kit change ' +
23
- '(Tim 2026-10-10 lifts the 2026-09-24 rule for this one case)';
25
+ 'deploy-approve; and (2) Unifi.FirewallPolicy create, only when the declared name starts "hf: ", ' +
26
+ 'one POST built from only the declared fields, read back and checked to match after; every ' +
27
+ 'other create, every update of an hf: row, every delete and every other family stay refused -- ' +
28
+ 'widening is a kit change (Tim 2026-10-10 lifts the 2026-09-24 rule for Network update, Tim ' +
29
+ '2026-10-19 lifts it again for hf:-named FirewallPolicy create)';
30
+
31
+ /**
32
+ * ⛔ THE ONE GATE THAT DECIDES "hf:" (Tim 2026-10-19, `nas-firewall`'s own naming:
33
+ * `hf: CT100 to NAS https`). The space after the colon is deliberate and checked: `"hf:foo"`
34
+ * (no space) is NOT an hf: name under this policy, so a typo can never silently slip past the
35
+ * create gate by omitting it. Exported so `firewall-policy-create.ts`'s `checkScope` and every
36
+ * test that proves the boundary use this ONE function, never a second regex hand-copied nearby.
37
+ */
38
+ export const isHfPolicyName = (name: string): boolean => name.startsWith('hf: ');
24
39
 
25
40
  /** @deprecated renamed `UNIFI_WRITE_POLICY`; kept so existing consumer imports do not break. */
26
41
  export const UNIFI_READ_ONLY_POLICY = UNIFI_WRITE_POLICY;
@@ -89,6 +104,24 @@ export class UnifiUpdateDidNotConverge extends Data.TaggedError('UnifiUpdateDidN
89
104
  }
90
105
  }
91
106
 
107
+ /** The POST answered, a read-back ran, yet the read-back object still differs from the
108
+ * declaration — the hf: create path's own convergence check (`create-reconcile.ts`'s sibling in
109
+ * `update-reconcile.ts`), same shape as `UnifiUpdateDidNotConverge` for the same reason: the
110
+ * object now exists on the console, unrolled-back, so the operator needs to know and re-import. */
111
+ export class UnifiCreateDidNotConverge extends Data.TaggedError('UnifiCreateDidNotConverge')<{
112
+ readonly type: string;
113
+ readonly identity: string;
114
+ readonly fields: ReadonlyArray<string>;
115
+ }> {
116
+ override get message(): string {
117
+ return (
118
+ `${this.type} ${this.identity}: the POST created an object, but the read-back differs from ` +
119
+ `the declaration on ${fieldList(this.fields)}. The object is on the console and is not ` +
120
+ 'rolled back; the next deploy will see this as drift. Recovery is a re-import of the live object.'
121
+ );
122
+ }
123
+ }
124
+
92
125
  // ⛔ THE FIVE ERRORS BELOW BOUND THE WRITE SCOPE (`network-scope.ts`, `update-reconcile.ts`): the
93
126
  // one intended consumer is a single VLAN's `ipv6Configuration`. Field names only, as above.
94
127
 
@@ -14,10 +14,12 @@ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Proto
14
14
  import * as Effect from 'effect/Effect';
15
15
  import { fakeUnifiLayer } from './fake-unifi.ts';
16
16
  import {
17
+ type UnifiCreateDidNotConverge,
17
18
  type UnifiIdentityChanged,
18
19
  type UnifiLiveDriftedSinceDeploy,
19
20
  type UnifiUpdateDidNotConverge,
20
21
  UnifiWriteRefused,
22
+ refuseWrite,
21
23
  } from './policy.ts';
22
24
  import { type UnifiSpec, unifiOperations } from './resource.ts';
23
25
 
@@ -62,6 +64,7 @@ const refusal = async (
62
64
  | UnifiWriteRefused
63
65
  | UnifiLiveDriftedSinceDeploy
64
66
  | UnifiUpdateDidNotConverge
67
+ | UnifiCreateDidNotConverge
65
68
  | UnifiIdentityChanged,
66
69
  UnifiNetworkOpContext
67
70
  >,
@@ -143,3 +146,103 @@ describe('unifiOperations.destroy', () => {
143
146
  expect(failure.action).toBe('delete');
144
147
  });
145
148
  });
149
+
150
+ /**
151
+ * The generic `create` branch (`resource.ts`'s `reconcile`, `update-reconcile.ts`'s
152
+ * `createReconcile`), against a synthetic spec -- mirrors `specFor` above, but models a family
153
+ * that DOES declare `create`, the same way `firewall-policy.test.ts` proves the real one. Every
154
+ * assertion here is about the ENGINE's own gating (no prior state, live absent, `checkScope`
155
+ * passes before `write` ever runs); `firewall-policy.test.ts` separately proves the real hf: name
156
+ * check and the real POST body.
157
+ */
158
+ /**
159
+ * `store` models "nothing live yet" (`undefined`) until `write` is called, at which point
160
+ * `fetchLive` (called again by `createReconcile`'s own read-back, never trusting `write`'s return
161
+ * value alone) starts answering the written row -- the same before/after a real `createFirewallPolicy`
162
+ * POST gives `getFirewallPolicy`.
163
+ */
164
+ const specWithCreate = (
165
+ initiallyLive: WidgetLive | undefined,
166
+ opts: { allow: boolean; writeCalls: WidgetProps[]; writtenMismatch?: boolean },
167
+ ): UnifiSpec<WidgetProps, WidgetLive, WidgetAttrs, never, never, UnifiWriteRefused> => {
168
+ let store = initiallyLive;
169
+ return {
170
+ type: 'Test.Widget',
171
+ describe: (props) => `widgets/${props.id}`,
172
+ fetchLive: () => Effect.succeed(store),
173
+ attributes: (l) => l,
174
+ matches: (attrs, props) => attrs.value === props.value,
175
+ create: {
176
+ allowedWrite: () => ({ method: 'POST', tail: '/widgets' }),
177
+ checkScope: (props) =>
178
+ opts.allow ? Effect.void : refuseWrite('Test.Widget', `widgets/${props.id}`, 'create'),
179
+ write: (props) => {
180
+ opts.writeCalls.push(props);
181
+ store = { ...props, value: opts.writtenMismatch ? props.value + 1 : props.value };
182
+ return Effect.succeed(store);
183
+ },
184
+ identify: (props) => props,
185
+ driftOf: (liveNow, props) =>
186
+ liveNow.value === props.value
187
+ ? []
188
+ : [{ field: 'value', live: liveNow.value, declared: props.value }],
189
+ },
190
+ };
191
+ };
192
+
193
+ describe('unifiOperations.reconcile -- the create branch', () => {
194
+ test('no prior state, live absent, scope allows: one write call, reads back and matches', async () => {
195
+ const writeCalls: WidgetProps[] = [];
196
+ const spec = specWithCreate(undefined, { allow: true, writeCalls });
197
+ const attrs = await run(unifiOperations(spec).reconcile(MATCH));
198
+ expect(attrs).toEqual(MATCH);
199
+ expect(writeCalls).toEqual([MATCH]);
200
+ });
201
+
202
+ test('no prior state, live absent, scope refuses: zero write calls, typed refusal', async () => {
203
+ const writeCalls: WidgetProps[] = [];
204
+ const spec = specWithCreate(undefined, { allow: false, writeCalls });
205
+ const failure = await refusal(unifiOperations(spec).reconcile(MATCH));
206
+ expect(failure.action).toBe('create');
207
+ expect(writeCalls).toEqual([]);
208
+ });
209
+
210
+ test('a read-back mismatch after a successful create fails typed, not silently', async () => {
211
+ const writeCalls: WidgetProps[] = [];
212
+ const spec = specWithCreate(undefined, { allow: true, writeCalls, writtenMismatch: true });
213
+ const failure = await run(
214
+ Effect.flip(
215
+ unifiOperations(spec).reconcile(MATCH) as Effect.Effect<
216
+ unknown,
217
+ UnifiCreateDidNotConverge,
218
+ UnifiNetworkOpContext
219
+ >,
220
+ ),
221
+ );
222
+ expect(failure._tag).toBe('UnifiCreateDidNotConverge');
223
+ expect(writeCalls).toEqual([MATCH]);
224
+ });
225
+
226
+ test('live already exists (first deploy, no state, but a row matches): no create attempted', async () => {
227
+ const writeCalls: WidgetProps[] = [];
228
+ const spec = specWithCreate(MATCH, { allow: true, writeCalls });
229
+ const attrs = await run(unifiOperations(spec).reconcile(MATCH));
230
+ expect(attrs).toEqual(MATCH);
231
+ expect(writeCalls).toEqual([]); // exact match -- zero writes, same H6 guarantee as every other family
232
+ });
233
+
234
+ test('prior state present (olds/output set): create is never attempted even if live is absent', async () => {
235
+ const writeCalls: WidgetProps[] = [];
236
+ const spec = specWithCreate(undefined, { allow: true, writeCalls });
237
+ const failure = await refusal(
238
+ unifiOperations(spec).reconcile(MATCH, { olds: MATCH, output: MATCH }),
239
+ );
240
+ expect(failure.action).toBe('create'); // falls through to updateReconcile's own "create" refusal
241
+ expect(writeCalls).toEqual([]);
242
+ });
243
+
244
+ test('a family with no create block behaves exactly as before (back-compat)', async () => {
245
+ const failure = await refusal(unifiOperations(specFor(undefined)).reconcile(MATCH));
246
+ expect(failure.action).toBe('create');
247
+ });
248
+ });
@@ -9,11 +9,15 @@
9
9
  * both cite. `adopt(true)`, piped on by each resource's convenience constructor (H5), turns
10
10
  * that into a silent one-time bind instead of an `OwnedBySomeoneElse` refusal.
11
11
  *
12
- * ⛔ THE POSTURE (`policy.ts`, Tim 2026-09-24, lifted for ONE case 2026-10-10): there is no
13
- * `create` and no `destroy` in this spec at all, and `update` exists only when a spec carries an
14
- * `update` block (`update-reconcile.ts`). `reconcile` is `updateReconcile`: refuse a create,
15
- * report an exact match with zero writes, and otherwise either refuse or run the three-way PUT.
16
- * A spec without `update` (every family but Network) can only ever refuse or report a match.
12
+ * ⛔ THE POSTURE (`policy.ts`, Tim 2026-09-24, lifted for TWO cases since): there is no `destroy`
13
+ * in this spec at all, ever. `update` exists only when a spec carries an `update` block
14
+ * (`update-reconcile.ts`'s `updateReconcile`, `Unifi.Network` only). `create` exists only when a
15
+ * spec carries a `create` block (`update-reconcile.ts`'s `createReconcile`, `Unifi.FirewallPolicy`
16
+ * only, and only for a row whose declared name passes `create.checkScope`). `reconcile`: an exact
17
+ * match reports with zero writes; otherwise a family with `create` and no prior state (`olds`/
18
+ * `output` both absent) AND an absent live read attempts the create branch; everything else
19
+ * either refuses or runs the three-way PUT. A spec with neither block can only ever refuse or
20
+ * report a match.
17
21
  *
18
22
  * ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.
19
23
  * Measured against `Plan.ts@v2.0.0-beta.81` (`forceUpdateAfterAdoption`, H6): beta.81 forces
@@ -42,7 +46,13 @@ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Proto
42
46
  import * as Effect from 'effect/Effect';
43
47
  import type * as HttpClient from 'effect/http/HttpClient';
44
48
  import { refuseWrite } from './policy.ts';
45
- import { type ReconcileArgs, type UnifiUpdate, updateReconcile } from './update-reconcile.ts';
49
+ import {
50
+ type ReconcileArgs,
51
+ type UnifiCreate,
52
+ type UnifiUpdate,
53
+ createReconcile,
54
+ updateReconcile,
55
+ } from './update-reconcile.ts';
46
56
  import { type AllowedWrite, guardedHttpClient } from './wire-guard.ts';
47
57
 
48
58
  /** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
@@ -54,7 +64,14 @@ export type UnifiRequirements = HttpClient.HttpClient;
54
64
  * union, with `NotFound` already folded away) so the precise per-operation error union flows
55
65
  * through instead of being widened by hand here. `E2` is the error union of the optional write.
56
66
  */
57
- export type UnifiSpec<Props extends object, Live, Attributes extends object, E, E2 = never> = {
67
+ export type UnifiSpec<
68
+ Props extends object,
69
+ Live,
70
+ Attributes extends object,
71
+ E,
72
+ E2 = never,
73
+ E3 = never,
74
+ > = {
58
75
  /** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
59
76
  readonly type: string;
60
77
  /** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
@@ -65,10 +82,13 @@ export type UnifiSpec<Props extends object, Live, Attributes extends object, E,
65
82
  readonly describe: (props: Props) => string;
66
83
  /** Present only on a family allowed to write (`Unifi.Network`); see `update-reconcile.ts`. */
67
84
  readonly update?: UnifiUpdate<Props, Live, E2>;
85
+ /** Present only on a family allowed to create (`Unifi.FirewallPolicy`, `hf:` rows only); see
86
+ * `update-reconcile.ts`'s `createReconcile`. */
87
+ readonly create?: UnifiCreate<Props, Live, E3>;
68
88
  };
69
89
 
70
- export const unifiOperations = <Props extends object, Live, Attributes extends object, E, E2>(
71
- spec: UnifiSpec<Props, Live, Attributes, E, E2>,
90
+ export const unifiOperations = <Props extends object, Live, Attributes extends object, E, E2, E3>(
91
+ spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
72
92
  ) => {
73
93
  const read = (props: Props) =>
74
94
  spec
@@ -98,14 +118,36 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
98
118
  : ({ action: 'update' } as const);
99
119
  }),
100
120
 
101
- /** Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset. */
102
- reconcile: (news: Props, rest: Partial<Omit<ReconcileArgs<Props>, 'news'>> = {}) =>
103
- updateReconcile(spec, {
104
- news,
105
- olds: rest.olds,
106
- output: rest.output,
107
- note: rest.note ?? (() => Effect.void),
108
- }),
121
+ /**
122
+ * Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset — the
123
+ * SAME calling convention a plain cold adoption already uses (H6), so the existence GET must
124
+ * run FIRST, for every name, exactly as it always has: a row that already exists is adopted
125
+ * (exact match, zero writes) regardless of its name, and ONLY an absent row ever reaches
126
+ * `create.checkScope`. "A non-hf: create sends nothing" therefore means no WRITE (no
127
+ * POST/PUT/DELETE) — the same guarantee every other refusal in this engine gives, and the one
128
+ * `wire-guard.ts`'s allow list enforces at the wire; the existence GET is not a write.
129
+ *
130
+ * ⛔ THE CREATE BRANCH ONLY RUNS WHEN THERE IS NO PRIOR STATE AT ALL (`olds`/`output` both
131
+ * absent — a genuine first deploy, never a re-declare of an adopted row) AND the family
132
+ * declares `create` AND the live read comes back absent. A row that already exists (someone
133
+ * else's object with a colliding identity, or a retried deploy that already created it)
134
+ * takes the ordinary `updateReconcile` path instead — exact match reports with zero writes,
135
+ * anything else refuses (there is no `olds` to PUT a patch against, so it can never silently
136
+ * fall through to an update of an existing row).
137
+ */
138
+ reconcile: (news: Props, rest: Partial<Omit<ReconcileArgs<Props>, 'news'>> = {}) => {
139
+ const note = rest.note ?? (() => Effect.void);
140
+ const create = spec.create;
141
+ if (create !== undefined && rest.olds === undefined && rest.output === undefined) {
142
+ return Effect.gen(function* () {
143
+ const live = yield* spec.fetchLive(news);
144
+ return live === undefined
145
+ ? yield* createReconcile(spec, news, note)
146
+ : yield* updateReconcile(spec, { news, olds: undefined, output: undefined, note });
147
+ });
148
+ }
149
+ return updateReconcile(spec, { news, olds: rest.olds, output: rest.output, note });
150
+ },
109
151
 
110
152
  destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),
111
153
  };
@@ -117,8 +159,8 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
117
159
  * ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
118
160
  * adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
119
161
  */
120
- export const unifiHandlers = <Props extends object, Live, Attributes extends object, E, E2>(
121
- spec: UnifiSpec<Props, Live, Attributes, E, E2>,
162
+ export const unifiHandlers = <Props extends object, Live, Attributes extends object, E, E2, E3>(
163
+ spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
122
164
  ) => {
123
165
  const ops = unifiOperations(spec);
124
166
  const withCredentials = <A, Err>(
@@ -152,8 +194,18 @@ export const unifiHandlers = <Props extends object, Live, Attributes extends obj
152
194
  note: (message) => args.session.note(message),
153
195
  }),
154
196
  // ⛔ From `news` (the ids the write will use), never `olds`; reconcile also refuses when
155
- // `olds` names another object (`update-reconcile.ts`).
156
- spec.update ? [spec.update.allowedWrite(args.news)] : [],
197
+ // `olds` names another object (`update-reconcile.ts`). The create entry (a POST to the
198
+ // site's fixed collection route, never a per-row path) is listed ONLY when there is no
199
+ // prior state at all — the exact same condition `ops.reconcile` itself gates the create
200
+ // branch on, so the wire guard never allows a POST wider than what the engine could ever
201
+ // attempt. The hf: NAME check is NOT this list's job (`create.checkScope`'s, before the
202
+ // POST): this only bounds METHOD and PATH.
203
+ [
204
+ ...(spec.update ? [spec.update.allowedWrite(args.news)] : []),
205
+ ...(spec.create && args.olds === undefined && args.output === undefined
206
+ ? [spec.create.allowedWrite(args.news)]
207
+ : []),
208
+ ],
157
209
  ),
158
210
  delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),
159
211
  };
@@ -29,6 +29,7 @@ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Proto
29
29
  import * as Effect from 'effect/Effect';
30
30
  import type { FieldDrift } from './drift.ts';
31
31
  import {
32
+ UnifiCreateDidNotConverge,
32
33
  UnifiIdentityChanged,
33
34
  UnifiLiveDriftedSinceDeploy,
34
35
  UnifiUpdateDidNotConverge,
@@ -78,8 +79,8 @@ export type ReconcileArgs<Props extends object> = {
78
79
  readonly note: (message: string) => Effect.Effect<void>;
79
80
  };
80
81
 
81
- export const updateReconcile = <Props extends object, Live, Attributes extends object, E, E2>(
82
- spec: UnifiSpec<Props, Live, Attributes, E, E2>,
82
+ export const updateReconcile = <Props extends object, Live, Attributes extends object, E, E2, E3>(
83
+ spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
83
84
  { news, olds, output, note }: ReconcileArgs<Props>,
84
85
  ) =>
85
86
  Effect.gen(function* () {
@@ -124,3 +125,72 @@ export const updateReconcile = <Props extends object, Live, Attributes extends o
124
125
  fields: update.driftOf(written, news).map((d) => d.field),
125
126
  });
126
127
  });
128
+
129
+ /**
130
+ * The write half of a CREATE-capable family: today, only `Unifi.FirewallPolicy`, and only for an
131
+ * `hf:`-named row (`policy.ts`'s `isHfPolicyName`). Deliberately NOT a merge-with-live update —
132
+ * there is no live object yet — so there is no `patchKeys`/`checkScope(live, patch, …)` here: a
133
+ * create sends exactly the fields the declaration owns, nothing echoed from elsewhere, nothing to
134
+ * accidentally reset.
135
+ *
136
+ * ⛔ `checkScope` RUNS BEFORE THE POST (same ordering `update.checkScope` already has): a
137
+ * non-`hf:` name is refused here, typed, never reaching the wire at all.
138
+ * ★ `identify` CLOSES THE LOOP FROM THE POST'S RESPONSE TO A FRESH GET. The vendor assigns the id
139
+ * on create; `write`'s response carries it, but a response body is not proof the object is
140
+ * actually readable back (`createReconcile` re-fetches with the id `identify` folds in, rather
141
+ * than trusting the POST's own echo — same reasoning `network-update.ts`'s header gives for
142
+ * never trusting a write's own response over a real read).
143
+ */
144
+ export type UnifiCreate<Props extends object, Live, E3> = {
145
+ /** The one POST the wire guard may pass for this family (the site's fixed collection route,
146
+ * built from `props` the same way `UnifiUpdate.allowedWrite` reads a row's ids from `props`). */
147
+ readonly allowedWrite: (props: Props) => AllowedWrite;
148
+ /** Refuses (typed, before any note or request) a create outside this family's write scope. */
149
+ readonly checkScope: (props: Props) => Effect.Effect<void, E3>;
150
+ readonly write: (props: Props) => Effect.Effect<Live, E3, UnifiNetworkOpContext>;
151
+ /** `props` plus the vendor id the POST's response assigned, for the read-back `fetchLive`. */
152
+ readonly identify: (props: Props, written: Live) => Props;
153
+ readonly driftOf: (live: Live, props: Props) => ReadonlyArray<FieldDrift>;
154
+ };
155
+
156
+ /**
157
+ * CREATE for a family whose live read came back absent AND which has no prior Alchemy state
158
+ * (`resource.ts`'s `unifiOperations.reconcile` is the only caller, and only under that same
159
+ * condition — see its own header). Refuses (untyped-to-typed, same `refuseWrite` every other
160
+ * refusal uses) when the family declares no `create` at all; `create.checkScope` then refuses
161
+ * again, more specifically, for a declared row the create scope itself does not allow.
162
+ */
163
+ export const createReconcile = <Props extends object, Live, Attributes extends object, E, E2, E3>(
164
+ spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
165
+ news: Props,
166
+ note: (message: string) => Effect.Effect<void, never, never>,
167
+ ) =>
168
+ Effect.gen(function* () {
169
+ const identity = spec.describe(news);
170
+ const create = spec.create;
171
+ if (create === undefined) return yield* refuseWrite(spec.type, identity, 'create');
172
+ yield* create.checkScope(news);
173
+ yield* note(`${spec.type} ${identity}: POST create`);
174
+ const written = yield* create.write(news);
175
+ const withId = create.identify(news, written);
176
+ // ⛔ A FRESH GET, NOT THE POST'S OWN RESPONSE (see the header): `fetchLive` is the same read
177
+ // every adoption and every later plan uses, so the convergence check and every future read
178
+ // of this row go through one path.
179
+ const confirmed = yield* spec.fetchLive(withId);
180
+ if (confirmed === undefined) {
181
+ return yield* Effect.die(
182
+ new Error(
183
+ `${spec.type} ${identity}: the create POST answered, but the read-back found nothing ` +
184
+ 'at the id it assigned. The object may or may not exist on the console; re-import ' +
185
+ 'before trying again rather than retrying this create blindly.',
186
+ ),
187
+ );
188
+ }
189
+ const attrs = spec.attributes(confirmed, withId);
190
+ if (spec.matches(attrs, withId)) return attrs;
191
+ return yield* new UnifiCreateDidNotConverge({
192
+ type: spec.type,
193
+ identity,
194
+ fields: create.driftOf(confirmed, withId).map((d) => d.field),
195
+ });
196
+ });