@githolon/dsl 0.8.0 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -612,6 +612,13 @@ function aggFieldSpec(
612
612
  }
613
613
  case "int":
614
614
  return { name, wireName, kind: field.kind, dartType: "int", optional };
615
+ // A PN-COUNTER reads as its net count and a POSITION as its order-key — the read model collapses
616
+ // them (kernel `observed_value`) to a scalar Int / Str, so codegen treats them AS `int` / `string`
617
+ // (the spec carries the NORMALIZED kind so every downstream reader path is unchanged).
618
+ case "counter":
619
+ return { name, wireName, kind: "int", dartType: "int", optional };
620
+ case "position":
621
+ return { name, wireName, kind: "string", dartType: valueClass ?? "String", optional, ...(valueClass !== undefined ? { valueClass } : {}) };
615
622
  case "bool":
616
623
  return { name, wireName, kind: field.kind, dartType: "bool", optional };
617
624
  case "enum": {
@@ -1195,6 +1202,8 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1195
1202
  ? `() { final v = ${reader}; return v == null ? null : ${s.refIdClass}.fromRow(v); }()`
1196
1203
  : `${s.refIdClass}.fromRow(${reader})`;
1197
1204
  }
1205
+ // `position` normalizes to `string`, `counter` to `int` in `aggFieldSpec` — unreachable here.
1206
+ case "position":
1198
1207
  case "string":
1199
1208
  if (s.valueClass !== undefined) {
1200
1209
  const reader = opt
@@ -1217,6 +1226,7 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1217
1226
  // The projection decodes a bool field to a real JSON boolean — read it directly (no runtime
1218
1227
  // helper). Optional → nullable cast; required → a plain cast (absent ⇒ throws, like readInt).
1219
1228
  return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
1229
+ case "counter":
1220
1230
  case "int":
1221
1231
  return opt
1222
1232
  ? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
@@ -1277,8 +1287,12 @@ function readModelDartType(s: AggFieldSpec): string {
1277
1287
  // leaf — the kernel `Value` is Str|Int only), so it is surfaced as the permissive
1278
1288
  // `Object?` (the read engine's decoded value as-is — never coerced/thrown).
1279
1289
  return isJsonObjectLeaf(s) ? "Map<String, Object?>" : "Object?";
1290
+ // `position`/`counter` are normalized to `string`/`int` in `aggFieldSpec` (read-collapsed scalars),
1291
+ // so these labels are unreachable at runtime — present for FieldKind exhaustiveness.
1292
+ case "position":
1280
1293
  case "string":
1281
1294
  return s.valueClass ?? "String";
1295
+ case "counter":
1282
1296
  case "int":
1283
1297
  return "int";
1284
1298
  case "bool":
@@ -1327,6 +1341,7 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
1327
1341
  ? `() { final v = ${reader}; return v == null ? null : ${s.refIdClass}.fromRow(v); }()`
1328
1342
  : `${s.refIdClass}.fromRow(${reader})`;
1329
1343
  }
1344
+ case "position":
1330
1345
  case "string":
1331
1346
  if (s.valueClass !== undefined) {
1332
1347
  const reader = opt
@@ -1355,6 +1370,7 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
1355
1370
  : `readJsonObject(data, '${aggregate}', '${s.wireName}')`;
1356
1371
  case "bool":
1357
1372
  return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
1373
+ case "counter":
1358
1374
  case "int":
1359
1375
  return opt
1360
1376
  ? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
package/src/codegen_ts.ts CHANGED
@@ -229,6 +229,12 @@ function tsTypeOfField(field: Field): string {
229
229
  return "string";
230
230
  case "int":
231
231
  return "number";
232
+ // STRUCTURAL CRDT scalars (read-collapsed): a counter reads as its net number, a position as its
233
+ // order-key string (kernel `observed_value` → the projection surfaces a plain scalar).
234
+ case "counter":
235
+ return "number";
236
+ case "position":
237
+ return "string";
232
238
  case "bool":
233
239
  return "boolean";
234
240
  case "enum":
package/src/directive.ts CHANGED
@@ -53,11 +53,45 @@ export interface Directive<P = unknown> {
53
53
  * directive, and the OBJECT it is checked on. The ONE gate runs `engine.check(schema, object,
54
54
  * relation, principal)` over the pre-apply folded tuple store — refusing an author who does not
55
55
  * hold it (the side-door fix made real). `object` is SELF-RELATIVE by default (`"workspace:self"`,
56
- * the governance convention); a payload-derived per-record object is the follow-on. DISTINCT from
57
- * the legacy `requiresCapability` (RBAC) and `declaredRelations` (cross-workspace) axes. Emitted
58
- * into the `nomosRelationGate` USD era key; enforced IFF the law also declares a relation schema.
56
+ * the governance convention). A PER-RECORD object (home_holons §6) is set via `objectFrom`: the gate
57
+ * then checks the relation on `<recordType>:<validated_payload[idField]>` — the specific record this
58
+ * directive writes, not the whole workspace (`record ⊂ object`; the engine is already object-generic).
59
+ * DISTINCT from the legacy `requiresCapability` (RBAC) and `declaredRelations` (cross-workspace) axes.
60
+ * Emitted into the `nomosRelationGate` USD era key; enforced IFF the law also declares a relation schema.
59
61
  */
60
- readonly requiresRelationGate?: { readonly relation: string; readonly object: string };
62
+ readonly requiresRelationGate?: {
63
+ readonly relation: string;
64
+ readonly object: string;
65
+ /** PER-RECORD (§6): when set, the gate object is `<recordType>:<payload[idField]>` (the static
66
+ * `object` is the fallback). `undefined` ⇒ the static object — era-ruled, byte-identical replay. */
67
+ readonly objectFrom?: { readonly recordType: string; readonly idField: string };
68
+ };
69
+ /**
70
+ * CROSS-CUSTODY ATTESTATION-AUTHORIZED (home_holons §3/§5). When `true`, this directive's authorization
71
+ * is a carried §3 SIGNED QUOTE (the asserter's pubkey + a signature over the edge), NOT an author block —
72
+ * so a key-less bailiff can deliver it host-free to an OFFLINE subject. Lowered into the
73
+ * `nomosAttestationGate` USD era key: the one gate then re-verifies the quote (bound to the home's warrant
74
+ * owner) AND pins the captured principal to the proven, unprivileged attestation key, and the signed-author
75
+ * doors exempt it. The directive's payload MUST carry the framework-convention fields the gate reads
76
+ * (`asserterWorkspace` / `object` / `relation` / `attestedAt` / `publicKey` / `signature` — the
77
+ * `recordShare` shape). FRAMEWORK-ONLY today (the home law's `recordShare`); a true E2E authority-free fact.
78
+ */
79
+ readonly attestationAuthorizedGate?: {
80
+ /** The singleton aggregate INSTANCE id whose field holds the home owner (e.g. `home-owner:self`). */
81
+ readonly ownerObject: string;
82
+ /** The field on that singleton holding the owner principal (e.g. `principal`). */
83
+ readonly ownerField: string;
84
+ /** The payload field names the §3 quote rides in — the law's declared wire shape (defaulted to the
85
+ * framework `recordShare` convention by `.attestationAuthorized()`, so the kernel hardcodes none). */
86
+ readonly fields: {
87
+ readonly asserterWorkspace: string;
88
+ readonly object: string;
89
+ readonly relation: string;
90
+ readonly attestedAt: string;
91
+ readonly publicKey: string;
92
+ readonly signature: string;
93
+ };
94
+ };
61
95
  /**
62
96
  * The directive's DECLARED read boundary: the ref types its `plan` may READ (the
63
97
  * "IR for law boundaries" decision — the boundary is part of the domain identity).
@@ -135,7 +169,17 @@ export interface RequirableDirective<P> extends Directive<P> {
135
169
  * to the directive id) at the scope `scopeFrom(payload)` derives (defaults to root).
136
170
  * Returns a new `Directive` carrying `requiresCapability` + `scopeFrom`.
137
171
  */
138
- requires(relation: string, opts?: { object?: string }): RequirableDirective<P>;
172
+ requires(
173
+ relation: string,
174
+ opts?: { object?: string; objectFrom?: { recordType: string; idField: string } },
175
+ ): RequirableDirective<P>;
176
+ /**
177
+ * Declare this directive ATTESTATION-AUTHORIZED (home_holons §3/§5): its authorization is a carried §3
178
+ * signed quote rather than an author block — lowered into the `nomosAttestationGate` era key so the one
179
+ * gate verifies the quote + pins the principal to the attestation key, and the bailiff delivers it
180
+ * host-free. Framework-only (the home law's `recordShare`). Returns a NEW requirable directive.
181
+ */
182
+ attestationAuthorized(owner: { ownerObject: string; ownerField: string }): RequirableDirective<P>;
139
183
  /**
140
184
  * Declare the directive's READ boundary. TWO argument kinds, one declaration site:
141
185
  * * a STRING declares a ref TYPE its `plan` may read (the original boundary —
@@ -260,6 +304,19 @@ class DirectivePlanStep<Id extends string, P> {
260
304
  }
261
305
  }
262
306
 
307
+ /**
308
+ * Build a PER-RECORD authz object template (home_holons §6) for `.requires(rel, { objectFrom })`. The
309
+ * gate then checks the relation on `<Agg>:<validated_payload[idField]>` — the specific record this
310
+ * directive writes (e.g. `recordObject(Entry, "entryId")` ⇒ `Entry:<payload.entryId>`), not the whole
311
+ * workspace. `record ⊂ object`: the same engine, pointed at one record.
312
+ */
313
+ export function recordObject(
314
+ agg: AggregateHandle,
315
+ idField: string,
316
+ ): { recordType: string; idField: string } {
317
+ return { recordType: agg.id, idField };
318
+ }
319
+
263
320
  /**
264
321
  * Wrap a `Directive` with the chainable `.requires(...)` declaration. `.requires`
265
322
  * is non-destructive: it returns a NEW requirable directive carrying the resolved
@@ -269,14 +326,46 @@ class DirectivePlanStep<Id extends string, P> {
269
326
  function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
270
327
  return {
271
328
  ...base,
272
- requires(relation: string, opts?: { object?: string }): RequirableDirective<P> {
329
+ requires(
330
+ relation: string,
331
+ opts?: { object?: string; objectFrom?: { recordType: string; idField: string } },
332
+ ): RequirableDirective<P> {
273
333
  // THE ONE write-authz declaration (full-native Zanzibar, no mixed systems): the captured author
274
- // must HOLD `relation` on `object` (self-relative `"workspace:self"` by default) for the one gate
275
- // to admit. Lowers to the `nomosRelationGate` era key; `admit` runs `engine.check`. (Replaces the
276
- // deleted RBAC capability axis — a capability WAS just a relation.)
334
+ // must HOLD `relation` on the object for the one gate to admit. Default object is self-relative
335
+ // (`"workspace:self"`, the governance convention); `objectFrom` (home_holons §6) gates on the
336
+ // PER-RECORD object the payload names (`recordObject(Agg, "idField")`) instead. Lowers to the
337
+ // `nomosRelationGate` era key; `admit` runs `engine.check`. (Replaces the deleted RBAC capability
338
+ // axis — a capability WAS just a relation.)
339
+ return makeRequirable({
340
+ ...base,
341
+ requiresRelationGate: {
342
+ relation,
343
+ object: opts?.object ?? "workspace:self",
344
+ ...(opts?.objectFrom !== undefined ? { objectFrom: opts.objectFrom } : {}),
345
+ },
346
+ });
347
+ },
348
+ attestationAuthorized(owner: { ownerObject: string; ownerField: string }): RequirableDirective<P> {
349
+ // CROSS-CUSTODY ATTESTATION-AUTHORIZED (home_holons §3/§5): this directive's authorization is a
350
+ // carried §3 signed quote, not an author block — lowered into the `nomosAttestationGate` era key, so
351
+ // the one gate verifies the quote (binding its subject to the LAW-DECLARED `owner`), pins the principal
352
+ // to the attestation key, and the signed-author doors exempt it. `owner` names the singleton + field
353
+ // holding the home owner; the QUOTE FIELD NAMES default to the framework `recordShare` convention HERE
354
+ // (in the DSL — the kernel reads them from the law's declaration, hardcoding nothing).
277
355
  return makeRequirable({
278
356
  ...base,
279
- requiresRelationGate: { relation, object: opts?.object ?? "workspace:self" },
357
+ attestationAuthorizedGate: {
358
+ ownerObject: owner.ownerObject,
359
+ ownerField: owner.ownerField,
360
+ fields: {
361
+ asserterWorkspace: "asserterWorkspace",
362
+ object: "object",
363
+ relation: "relation",
364
+ attestedAt: "attestedAt",
365
+ publicKey: "publicKey",
366
+ signature: "signature",
367
+ },
368
+ },
280
369
  });
281
370
  },
282
371
  reads(...reads: (string | QueryDecl)[]): RequirableDirective<P> {
package/src/fields.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * the value type (via Zod) is preserved.
16
16
  */
17
17
  import { z } from "zod";
18
- import { Conflict, Lww, type Driver } from "./drivers.js";
18
+ import { Conflict, Counter, LastPosition, Lww, type Driver } from "./drivers.js";
19
19
  import type { AggregateHandle } from "./aggregate.js";
20
20
 
21
21
  export type FieldKind =
@@ -35,7 +35,14 @@ export type FieldKind =
35
35
  // evidence.md §6/§9.2 — a content-addressed EvidenceRef. The migration target of the
36
36
  // hand-wired `blobRef` `t.jsonObject()` string (thing_structures.ts). The authored
37
37
  // value is the typed EvidenceRef shape; it encodes to a kernel `Value::Evidence`.
38
- | "evidence";
38
+ | "evidence"
39
+ // ── STRUCTURAL CRDT scalar kinds (the read-collapsible drivers) ──
40
+ // `t.counter()` — a PN-counter (`Driver::Counter`): driven by `increment`/`decrement`, read as the
41
+ // net `Int` (Σpos − Σneg). `t.position()` — an LWW order-key (`Driver::LastPosition`): driven by
42
+ // `set`, read as the `String` key. Both collapse to a scalar at read time (kernel `observed_value`),
43
+ // so the read model + codegen treat them as `number` / `string`.
44
+ | "counter"
45
+ | "position";
39
46
 
40
47
  /**
41
48
  * The authored shape of an `EvidenceRef` (`evidence.md` §1) — a content-addressed
@@ -224,6 +231,21 @@ export const t = {
224
231
  bool(): Field<boolean, "bool"> {
225
232
  return makeField("bool", z.boolean(), Lww);
226
233
  },
234
+ /** A PN-COUNTER (`Driver::Counter`) — a number that CONVERGES under concurrent edits: two replicas
235
+ * that each `increment` while offline both land (the merge is the per-replica sum), so nobody's count
236
+ * is lost. Drive it with `increment(handle, "field", n)` / `decrement(...)`, NOT `set()` (set would
237
+ * clobber the convergent state). Reads back as the net `number` (Σincrements − Σdecrements; may be
238
+ * negative). The kernel owns the CRDT; the field tag picks it. */
239
+ counter(): Field<number, "counter"> {
240
+ return makeField<number, "counter">("counter", z.number().int(), Counter);
241
+ },
242
+ /** An LWW ORDER-KEY POSITION (`Driver::LastPosition`) — an opaque fractional/index string (e.g. `"a0"`,
243
+ * `"a0V"`) that orders an item among siblings, resolved last-writer-wins by HLC. Drive it with
244
+ * `set(handle, "pos", key)`; concurrent moves converge to one position (the later HLC). Reads back as
245
+ * the `string` key. (Compute the key client-side — fractional indexing — and ride it in the payload.) */
246
+ position(): Field<string, "position"> {
247
+ return makeField<string, "position">("position", z.string(), LastPosition);
248
+ },
227
249
  /** Enum via `as const` literal union — value typos squeal at compile time. */
228
250
  enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
229
251
  return makeField<E[number], "enum">(
@@ -0,0 +1,183 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/home.ts — THE HOME HOLON (the identity role; `architecture/home_holons_and_cross_custody.md`
8
+ * §4). One holon, three roles — "workspace," "platform," "home" are the SAME githolon wearing the role
9
+ * its installed LAW gives it. A HOME holon runs THIS law: an identity's subject-side root.
10
+ *
11
+ * It holds only PUBLIC material + signed facts (private keys never leave the client, so the cloud hosting
12
+ * it stays operator-blind):
13
+ * - KEY DIRECTORY — the identity's public device keys: the `signer` relation
14
+ * (`identity:<principal>#signer@key:<hash>`, `framework/signing_identity.ts`). Re-exported here so a
15
+ * home holon's law carries it: "wrap the scope key to U's device" becomes "read U's home key roster."
16
+ * - SHARED-WITH-ME index (§5) — one record per verified cross-custody attestation delivered here, so
17
+ * "show me everything shared with me" is a LOCAL query, no fan-out.
18
+ * - INBOX — grants/invites/notifications/dead-letters addressed to the identity.
19
+ *
20
+ * THE FORWARD-CHECK DOCTRINE (§5): the index is a DISCOVERY HINT, not the authority. When the identity
21
+ * actually opens the asserting workspace, THAT workspace's gate does the live `Check` — so a stale share
22
+ * (the asserter revoked it) is fail-safe: the identity sees a workspace it can no longer open, the forward
23
+ * check denies, the hint is pruned. (The cross-custody ATTESTATION that proves a delivered share is signed
24
+ * + verifiable is §3; the host DELIVERY lane that fills the index is §5. This file is the law that holds
25
+ * the result, and the owner-authored lane to maintain it.)
26
+ */
27
+ import { z } from "zod";
28
+ import { aggregate, instance } from "../aggregate.js";
29
+ import { t } from "../fields.js";
30
+ import { Lww } from "../drivers.js";
31
+ import { directive } from "../directive.js";
32
+ import { create } from "../authoring.js";
33
+ import { set, type PlannedOp } from "../ops.js";
34
+ import { query } from "../query.js";
35
+ import { workspaceInvariant, refAs } from "./workspace_invariant.js";
36
+ import { identityRelations } from "./signing_identity.js";
37
+
38
+ // `nomos.sha256` — the TRUSTLESS, pure hash computed IN the githolon (Rust sha2 bound in the sealed engine;
39
+ // see deterministic-rquickjs). Available as a global to plans + invariant predicates (like `nomos.mint`); it
40
+ // lets the home law re-derive `home-<hash(owner)>` itself, so the SQUAT invariant runs in-law on every peer.
41
+ declare const nomos: { sha256(input: string): string };
42
+
43
+ /** The KEY DIRECTORY is the `signer` relation — re-exported so a home holon's law composes it (the
44
+ * identity's public device keys, enrolled via `enrollSigner`, checked by the gate). */
45
+ export { identityRelations };
46
+
47
+ /**
48
+ * THE HOME OWNER (home_holons §5 — the owner stamp). A home holon is born UNWARRANTED (the subject may
49
+ * never have logged in — a share can be delivered to a stranger's lazily-born home, before they hold any
50
+ * device key, so there is no signed-author regime yet). This singleton records WHO the home belongs to: the
51
+ * §3 cross-custody gate binds a delivered quote's subject to THIS principal (`executor::HOME_OWNER_ID`), so
52
+ * a quote signed for anyone else fails closed. Seeded ONCE at birth by the host (the control-plane
53
+ * `seedHomeOwner`, kept off the open session lane), so a tenant cannot forge ownership.
54
+ */
55
+ export const HOME_OWNER_ID = "home-owner:self";
56
+ /** A home holon's SELF-COMMITTED NAME prefix — `home-<sha256(subject)>` (the client's `homeNameOf` + the
57
+ * squat invariant both derive it). The home's chain commits to its own routing name, so the bailiff can
58
+ * bind the name to the CONTENT (custody) while the LAW guarantees the name hashes its owner. */
59
+ export const HOME_NAME_PREFIX = "home-";
60
+ export const HOME_NAME_HASH_LEN = 40;
61
+ export const HomeOwner = aggregate("HomeOwner", {
62
+ /** The identity this home belongs to — the canonical `user:<uid>` subject the §3 gate binds quotes to. */
63
+ principal: t.string().merge(Lww),
64
+ /** THE HOME'S SELF-COMMITTED NAME (`home-<sha256(principal)>`) — the squat invariant asserts it hashes the
65
+ * owner, so the chain's claimed name can ONLY be its rightful owner's slot (home_holons §5). */
66
+ homeName: t.string().merge(Lww),
67
+ /** ISO-8601, caller-stamped (determinism — no clock in the plan). */
68
+ bornAt: t.string().merge(Lww),
69
+ });
70
+
71
+ /** The framework PRIMITIVE plan: stamp the home's owner + its self-committed name (singleton at the fixed id). */
72
+ export function writeHomeOwner(p: { principal: string; homeName: string; bornAt: string }): PlannedOp[] {
73
+ const h = instance(HomeOwner, HOME_OWNER_ID);
74
+ return [set(h, "principal", p.principal), set(h, "homeName", p.homeName), set(h, "bornAt", p.bornAt)];
75
+ }
76
+
77
+ /**
78
+ * `seedHomeOwner` — the birth-door directive that stamps the home's owner + its self-committed `homeName` at
79
+ * the fixed singleton id. A CONTROL-PLANE write: the host authors it once at lazy birth; the open session
80
+ * lane structurally REFUSES it (by directive id). `.ensures` (a deterministic-keyed singleton). The `homeName`
81
+ * is caller-supplied but the {@link homeNameBindsOwner} invariant proves it `== home-<sha256(principal)>`,
82
+ * so a squatter cannot claim a name slot that doesn't hash to ITS own owner.
83
+ */
84
+ export const seedHomeOwner = directive("seedHomeOwner")
85
+ .ensures(HomeOwner)
86
+ .payload(z.object({ principal: z.string().min(1), homeName: z.string().min(1), bornAt: z.string().min(1) }))
87
+ .plan((p) => writeHomeOwner(p));
88
+
89
+ /**
90
+ * THE SQUAT INVARIANT (home_holons §5) — a home holon's self-committed `homeName` MUST be the hash of its
91
+ * owner: `homeName == home-<sha256(principal)>`. Declared by the home law, it EXECUTES at the one gate — and
92
+ * crucially at `verify_chain` (the bailiff's upload-birth): a squatter's chain (claiming `home-<hash(bob)>`
93
+ * but owned by `mallory`) FAILS the home law's OWN gate during adoption — refused IN THE GITHOLON, BY THE LAW,
94
+ * never by a host check. The host only routes the chain to the name it commits to (custody). `nomos.sha256`
95
+ * re-derives the hash in-law on every peer, so the verdict is identical everywhere.
96
+ */
97
+ export const homeNameBindsOwner = workspaceInvariant("HomeNameBindsOwner")
98
+ .on("seedHomeOwner")
99
+ .reads(() => [refAs("owner", "HomeOwner", HOME_OWNER_ID)])
100
+ .assert(({ owner }) => {
101
+ const o = (owner ?? {}) as { principal?: string; homeName?: string };
102
+ const expected = HOME_NAME_PREFIX + nomos.sha256(String(o.principal ?? "")).slice(0, HOME_NAME_HASH_LEN);
103
+ return o.homeName === expected
104
+ ? { accept: true }
105
+ : { reject: "a home's name must hash its owner (home-<sha256(principal)>) — refusing a squatted home" };
106
+ });
107
+
108
+ /**
109
+ * THE SHARED-WITH-ME INDEX (§5) — one record per cross-custody attestation delivered to this home holon:
110
+ * a signed edge `<asserterWorkspace> : <object> # <relation> @ this-identity`, indexed LOCALLY so the
111
+ * access list is a local query (no fan-out across every resource holon). A DISCOVERY HINT — the live authz
112
+ * stays the forward `Check` on the asserting holon when the identity opens it.
113
+ */
114
+ export const SharedWithMe = aggregate("SharedWithMe", {
115
+ /** The holon that signed + delivered this edge — WHERE the live forward `Check` runs when opened. */
116
+ asserterWorkspace: t.string().merge(Lww),
117
+ /** The shared OBJECT — the whole workspace (`workspace:self`) or a specific record (`Entry:<id>`). */
118
+ object: t.string().merge(Lww),
119
+ /** The relation granted (`reader` / `editor` / a read cap). */
120
+ relation: t.string().merge(Lww),
121
+ /** The asserter's OWNER principal — whose key signed the attestation (verified at §3 admission). */
122
+ asserterPrincipal: t.string().merge(Lww),
123
+ /** ISO-8601, caller-stamped (determinism — no clock in the plan). */
124
+ attestedAt: t.string().merge(Lww),
125
+ /** PRUNED (forward-check fail-safe): set when the live `Check` on the asserter denies — the hint is
126
+ * dropped from the list but the audit row stays. `""` = live. */
127
+ prunedAt: t.string().merge(Lww),
128
+ });
129
+
130
+ /**
131
+ * `recordShare` — index a cross-custody share into the home holon's access list. AUTHORED on the home
132
+ * holon (the identity's own subject-side root). The payload carries the §3 CROSS-CUSTODY ATTESTATION — a
133
+ * SIGNED QUOTE: the asserter's device `publicKey` + a `signature` over the edge
134
+ * `<asserterWorkspace>:<object>#<relation>@<this-home-owner>`. The home holon's OWN gate verifies it
135
+ * (`executor::record_share_attestation_violation`, bound to the warrant owner), so the BAILIFF can deliver
136
+ * it HOST-FREE (the host carries bytes, vouches nothing — the gate vouches). The owner lane authors the
137
+ * SAME shape (the owner holds the quote the asserter gave it). The signature is a verification input, not
138
+ * stored — only the edge facts fold into the index. The live authority stays the forward `Check` on the
139
+ * asserter when the identity opens it (§5 fail-safe); a stale/forged quote can't enter the list at all.
140
+ */
141
+ export const recordShare = directive("recordShare")
142
+ .creates(SharedWithMe)
143
+ .payload(
144
+ z.object({
145
+ asserterWorkspace: z.string().min(1),
146
+ object: z.string().min(1),
147
+ relation: z.string().min(1),
148
+ asserterPrincipal: z.string().min(1),
149
+ attestedAt: z.string().min(1),
150
+ /** The asserter's device PUBLIC KEY (base64) — what the §3 gate verifies the quote against. */
151
+ publicKey: z.string().min(1),
152
+ /** The asserter's SIGNATURE (base64) over the edge bytes, subject = this home's owner. */
153
+ signature: z.string().min(1),
154
+ }),
155
+ )
156
+ .plan((p) => {
157
+ create(SharedWithMe)
158
+ .set("asserterWorkspace", p.asserterWorkspace)
159
+ .set("object", p.object)
160
+ .set("relation", p.relation)
161
+ .set("asserterPrincipal", p.asserterPrincipal)
162
+ .set("attestedAt", p.attestedAt)
163
+ .set("prunedAt", "");
164
+ return [];
165
+ })
166
+ // CROSS-CUSTODY ATTESTATION-AUTHORIZED (§3/§5): the payload's signed quote IS the authorization (lowered
167
+ // into the `nomosAttestationGate` era key) — the one gate verifies it against the home's owner (the
168
+ // LAW-DECLARED owner-source below, NOT a kernel-hardcoded id) + pins the principal to the attestation key,
169
+ // so the bailiff delivers it host-free to an OFFLINE subject (no device key, no author block).
170
+ .attestationAuthorized({ ownerObject: HOME_OWNER_ID, ownerField: "principal" });
171
+
172
+ /**
173
+ * `pruneShare` — fail-safe drop of a hint whose forward `Check` denied (the asserter revoked it). The row
174
+ * stays for audit; `prunedAt` removes it from the live list. (The forward check itself is the resource
175
+ * holon's job — this records the verdict locally.)
176
+ */
177
+ export const pruneShare = directive("pruneShare")
178
+ .mutates(SharedWithMe)
179
+ .payload(z.object({ shareId: z.string().min(1), prunedAt: z.string().min(1) }))
180
+ .plan((p) => [set(instance(SharedWithMe, p.shareId), "prunedAt", p.prunedAt)]);
181
+
182
+ /** "Show me everything <asserter> shared with me" — an indexed probe over the local access list. */
183
+ export const sharedByWorkspace = query("sharedByWorkspace").key("asserterWorkspace").returns(SharedWithMe);
package/src/index.ts CHANGED
@@ -51,6 +51,9 @@ export {
51
51
  set,
52
52
  addToSet,
53
53
  setEntry,
54
+ increment,
55
+ decrement,
56
+ removeFromSet,
54
57
  withMarker,
55
58
  strike,
56
59
  type PlannedOp,
@@ -58,7 +61,7 @@ export {
58
61
  type PlannedEventMarker,
59
62
  type StrikeOp,
60
63
  } from "./ops.js";
61
- export { directive, type Directive, type ReferentialMarker } from "./directive.js";
64
+ export { directive, recordObject, type Directive, type ReferentialMarker } from "./directive.js";
62
65
  // FIRST-CLASS WORKSPACE TYPES (sharding §10 RATIFIED, slice 1) live on the SUBPATH
63
66
  // `@githolon/dsl/workspace-type` (`workspaceType("estate").root(Estate).hasMany(...)`),
64
67
  // NOT this runtime barrel — the barrel is bundled into EVERY tenant's engine lump, and
@@ -231,6 +234,21 @@ export {
231
234
  SIGNER_RELATION,
232
235
  enrollSigner,
233
236
  } from "./framework/signing_identity.js";
237
+ // THE HOME HOLON (home_holons_and_cross_custody.md §4) — the identity-role law: the shared-with-me access
238
+ // index + the owner lane to maintain it (the key directory is the `signer` relation above).
239
+ export {
240
+ SharedWithMe,
241
+ recordShare,
242
+ pruneShare,
243
+ sharedByWorkspace,
244
+ HomeOwner,
245
+ HOME_OWNER_ID,
246
+ HOME_NAME_PREFIX,
247
+ HOME_NAME_HASH_LEN,
248
+ seedHomeOwner,
249
+ writeHomeOwner,
250
+ homeNameBindsOwner,
251
+ } from "./framework/home.js";
234
252
  // THE GENESIS WARRANT (identity_keys.md step 0) — the in-history birth fact that lets
235
253
  // the gate's genesis clause admit a founding control-plane grant on a virgin chain.
236
254
  export {
package/src/ops.ts CHANGED
@@ -37,7 +37,7 @@ export interface FieldOp {
37
37
  */
38
38
  readonly marker?: PlannedEventMarker;
39
39
  readonly field: string;
40
- readonly kind: "set" | "addToSet" | "setEntry";
40
+ readonly kind: "set" | "addToSet" | "setEntry" | "increment" | "decrement" | "removeFromSet";
41
41
  readonly value?: unknown;
42
42
  readonly items?: readonly string[];
43
43
  readonly entryKey?: string;
@@ -147,6 +147,55 @@ export function addToSet<
147
147
  return { ...addressOf(agg), field, kind: "addToSet", items };
148
148
  }
149
149
 
150
+ /**
151
+ * `increment(handle, "field", amount)` — add to a PN-COUNTER field (`t.counter()`). The ONLY way to
152
+ * advance a counter (set() would clobber the convergent CRDT state). Concurrent increments on different
153
+ * replicas all land (the merge sums the per-replica totals), so nobody's count is lost. `amount` is a
154
+ * non-negative integer; the field must be `kind:"counter"`. `decrement` is the symmetric subtract.
155
+ */
156
+ function counterDelta<A extends Target, K extends CounterKey<A>>(
157
+ agg: A,
158
+ field: K,
159
+ amount: number,
160
+ kind: "increment" | "decrement",
161
+ ): FieldOp {
162
+ if (!Number.isInteger(amount) || amount < 0) {
163
+ throw new Error(`${kind}('${String(field)}', ${amount}): amount must be a non-negative integer.`);
164
+ }
165
+ return { ...addressOf(agg), field: field as string, kind, value: amount };
166
+ }
167
+ /** A counter field key — `kind:"counter"` only (an `int` is NOT a counter; that would not converge). */
168
+ type CounterKey<A> = {
169
+ [P in keyof Fields<A>]: Fields<A>[P] extends { kind: "counter" } ? P : never;
170
+ }[keyof Fields<A>] &
171
+ string;
172
+
173
+ export function increment<A extends Target, K extends CounterKey<A>>(agg: A, field: K, amount = 1): FieldOp {
174
+ return counterDelta(agg, field, amount, "increment");
175
+ }
176
+ /** `decrement(handle, "field", amount)` — subtract from a PN-counter field. See {@link increment}. */
177
+ export function decrement<A extends Target, K extends CounterKey<A>>(agg: A, field: K, amount = 1): FieldOp {
178
+ return counterDelta(agg, field, amount, "decrement");
179
+ }
180
+
181
+ /**
182
+ * `removeFromSet(handle, "field", items)` — tombstone items in a REMOVE-WINS set (`t.set(...).merge(
183
+ * RemoveWins)`). The remove permanently WINS over any concurrent or later add of the same element
184
+ * (2-phase-set semantics), so a concurrent add+remove converges to "removed". The additive partner is
185
+ * {@link addToSet}; on a RemoveWins field both are encoded to the kernel's 2-phase ops. (On a plain
186
+ * AddWins set there is no remove — that's the whole point of AddWins — so this targets set fields and
187
+ * the encoder refuses a non-RemoveWins set at compile output.)
188
+ */
189
+ export function removeFromSet<
190
+ A extends Target,
191
+ K extends {
192
+ [P in keyof Fields<A>]: FieldValue<Fields<A>[P]> extends string[] ? P : never;
193
+ }[keyof Fields<A>] &
194
+ string,
195
+ >(agg: A, field: K, items: readonly string[]): FieldOp {
196
+ return { ...addressOf(agg), field, kind: "removeFromSet", items };
197
+ }
198
+
150
199
  /** Set one entry of a map field. The map's value type is checked. */
151
200
  export function setEntry<
152
201
  A extends Target,
package/src/usd.ts CHANGED
@@ -221,6 +221,31 @@ export interface UsdLayer {
221
221
  readonly v: 1;
222
222
  readonly requires: Record<string, { readonly relation: string; readonly object: string }>;
223
223
  };
224
+ /**
225
+ * THE CROSS-CUSTODY ATTESTATION GATE (home_holons §3/§5). Present IFF ≥1 directive declares
226
+ * `.attestationAuthorized()`: the SORTED set of their ids. A directive named here is authorized by a
227
+ * carried §3 signed quote rather than an author block — the one gate verifies the quote (bound to the
228
+ * home's warrant owner) + pins the captured principal to the proven attestation key, and the signed-author
229
+ * doors exempt it. OMITTED for law with no such directive (the era rule — byte-identical hash).
230
+ */
231
+ readonly nomosAttestationGate?: {
232
+ readonly v: 1;
233
+ readonly directives: Record<
234
+ string,
235
+ {
236
+ readonly ownerObject: string;
237
+ readonly ownerField: string;
238
+ readonly fields: {
239
+ readonly asserterWorkspace: string;
240
+ readonly object: string;
241
+ readonly relation: string;
242
+ readonly attestedAt: string;
243
+ readonly publicKey: string;
244
+ readonly signature: string;
245
+ };
246
+ }
247
+ >;
248
+ };
224
249
  /**
225
250
  * STABLE IDENTIFIERS (#58 — names are labels, identity is minted). The layer's
226
251
  * domain/aggregate/field sids + label lineages, carried verbatim from the canonical
@@ -448,14 +473,48 @@ export function emitUsd(
448
473
  ]);
449
474
  // THE RELATION GATE (P4): directives declaring `.requiresRelation` → directiveId → {relation,
450
475
  // object}. SORTED keys; omit-when-empty (era rule — a relation-gate-free layer is byte-identical).
451
- const relationGateReq: Record<string, { relation: string; object: string }> = {};
476
+ const relationGateReq: Record<
477
+ string,
478
+ { relation: string; object: string; objectFrom?: { recordType: string; idField: string } }
479
+ > = {};
452
480
  for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
453
481
  if (d.requiresRelationGate !== undefined) {
454
- relationGateReq[d.id] = { relation: d.requiresRelationGate.relation, object: d.requiresRelationGate.object };
482
+ relationGateReq[d.id] = {
483
+ relation: d.requiresRelationGate.relation,
484
+ object: d.requiresRelationGate.object,
485
+ // PER-RECORD object (home_holons §6): carried through to the wasm gate; omitted when absent
486
+ // so a self-relative directive's USD is byte-identical (the era rule).
487
+ ...(d.requiresRelationGate.objectFrom !== undefined
488
+ ? { objectFrom: d.requiresRelationGate.objectFrom }
489
+ : {}),
490
+ };
455
491
  }
456
492
  }
457
493
  const nomosRelationGate =
458
494
  Object.keys(relationGateReq).length > 0 ? { v: 1 as const, requires: relationGateReq } : undefined;
495
+ // THE CROSS-CUSTODY ATTESTATION GATE (home_holons §3/§5): directives declaring `.attestationAuthorized()`
496
+ // → directiveId → its LAW-DECLARED owner-source {ownerObject, ownerField}. SORTED keys (byte-
497
+ // determinism). Omit-when-empty (era rule). The one gate verifies the carried §3 quote (binding its
498
+ // subject to the named owner) + pins the principal to the attestation key — the kernel hardcodes no id.
499
+ const attestationReq: Record<
500
+ string,
501
+ {
502
+ ownerObject: string;
503
+ ownerField: string;
504
+ fields: { asserterWorkspace: string; object: string; relation: string; attestedAt: string; publicKey: string; signature: string };
505
+ }
506
+ > = {};
507
+ for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
508
+ if (d.attestationAuthorizedGate !== undefined) {
509
+ attestationReq[d.id] = {
510
+ ownerObject: d.attestationAuthorizedGate.ownerObject,
511
+ ownerField: d.attestationAuthorizedGate.ownerField,
512
+ fields: { ...d.attestationAuthorizedGate.fields },
513
+ };
514
+ }
515
+ }
516
+ const nomosAttestationGate =
517
+ Object.keys(attestationReq).length > 0 ? { v: 1 as const, directives: attestationReq } : undefined;
459
518
  // THE CAPTURED-READ GATE DECLARATION (#58): collected from the canonical
460
519
  // manifest's per-directive `capturedReads` (omit-when-empty — read-free law's
461
520
  // USD is byte-identical apart from the always-on stableIds key).
@@ -515,6 +574,7 @@ export function emitUsd(
515
574
  : {}),
516
575
  ...(nomosRelations !== undefined ? { nomosRelations } : {}),
517
576
  ...(nomosRelationGate !== undefined ? { nomosRelationGate } : {}),
577
+ ...(nomosAttestationGate !== undefined ? { nomosAttestationGate } : {}),
518
578
  ...(Object.keys(visAggregates).length > 0
519
579
  ? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
520
580
  : {}),
package/src/usd_layers.ts CHANGED
@@ -371,6 +371,9 @@ export function usdaLayerText(
371
371
  ...(layer.nomosRelationGate !== undefined
372
372
  ? [` custom string nomos:relationGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosRelationGate)))}`]
373
373
  : []),
374
+ ...(layer.nomosAttestationGate !== undefined
375
+ ? [` custom string nomos:attestationGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosAttestationGate)))}`]
376
+ : []),
374
377
  ``,
375
378
  ...body,
376
379
  ` }`,
@@ -584,6 +587,22 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
584
587
  relationGate = { v: 1, requires: requiresRec };
585
588
  }
586
589
  }
590
+ // THE ATTESTATION GATE (home_holons §3/§5): directiveId → owner-source merged later-wins across layers,
591
+ // SORTED keys. Omit-when-empty (era rule).
592
+ let attestationGate: UsdLayer["nomosAttestationGate"];
593
+ {
594
+ type AttSrc = NonNullable<UsdLayer["nomosAttestationGate"]>["directives"][string];
595
+ const a = new Map<string, AttSrc>();
596
+ for (const l of stack) {
597
+ if (l.nomosAttestationGate === undefined) continue;
598
+ for (const [id, src] of Object.entries(l.nomosAttestationGate.directives)) a.set(id, src);
599
+ }
600
+ if (a.size > 0) {
601
+ const rec: Record<string, AttSrc> = {};
602
+ for (const k of [...a.keys()].sort()) rec[k] = a.get(k)!;
603
+ attestationGate = { v: 1, directives: rec };
604
+ }
605
+ }
587
606
  return {
588
607
  path,
589
608
  prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
@@ -596,6 +615,7 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
596
615
  ...(encryption !== undefined ? { nomosEncryption: encryption } : {}),
597
616
  ...(relations !== undefined ? { nomosRelations: relations } : {}),
598
617
  ...(relationGate !== undefined ? { nomosRelationGate: relationGate } : {}),
618
+ ...(attestationGate !== undefined ? { nomosAttestationGate: attestationGate } : {}),
599
619
  };
600
620
  });
601
621
 
@@ -719,6 +739,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
719
739
  const encryptionByDomain = new Map<string, UsdLayer["nomosEncryption"]>();
720
740
  const relationsByDomain = new Map<string, UsdLayer["nomosRelations"]>();
721
741
  const relationGateByDomain = new Map<string, UsdLayer["nomosRelationGate"]>();
742
+ const attestationGateByDomain = new Map<string, UsdLayer["nomosAttestationGate"]>();
722
743
  for (const p of prims) {
723
744
  if (p.attrs.get("nomos:kind") === "domain") {
724
745
  const domain = attrString(p, "nomos:domain");
@@ -752,6 +773,11 @@ export function parseUsdaDocument(text: string): UsdDocument {
752
773
  if (typeof relGateHex === "string") {
753
774
  relationGateByDomain.set(domain, JSON.parse(unhexUtf8(relGateHex)) as UsdLayer["nomosRelationGate"]);
754
775
  }
776
+ // THE ATTESTATION GATE (home_holons §3/§5): the hex-carried attestation-authorized directive set.
777
+ const attGateHex = p.attrs.get("nomos:attestationGateHex");
778
+ if (typeof attGateHex === "string") {
779
+ attestationGateByDomain.set(domain, JSON.parse(unhexUtf8(attGateHex)) as UsdLayer["nomosAttestationGate"]);
780
+ }
755
781
  }
756
782
  }
757
783
 
@@ -845,6 +871,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
845
871
  const enc = encryptionByDomain.get(domain);
846
872
  const rel = relationsByDomain.get(domain);
847
873
  const relGate = relationGateByDomain.get(domain);
874
+ const attGate = attestationGateByDomain.get(domain);
848
875
  return {
849
876
  path: `/Nomos/${domain}`,
850
877
  prims: [...b.prims].sort(byPath),
@@ -857,6 +884,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
857
884
  ...(enc !== undefined ? { nomosEncryption: enc } : {}),
858
885
  ...(rel !== undefined ? { nomosRelations: rel } : {}),
859
886
  ...(relGate !== undefined ? { nomosRelationGate: relGate } : {}),
887
+ ...(attGate !== undefined ? { nomosAttestationGate: attGate } : {}),
860
888
  };
861
889
  })
862
890
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
package/src/wire.ts CHANGED
@@ -39,7 +39,12 @@ export type WireValue =
39
39
  | { Int: number }
40
40
  | { Set: string[] }
41
41
  | { Map: Record<string, WireField> }
42
- | { Evidence: WireEvidenceRef };
42
+ | { Evidence: WireEvidenceRef }
43
+ // ── STRUCTURAL CRDT state values (only seen when reading RAW folded state back; the read MODEL
44
+ // collapses them via the kernel `observed_value` to Int/Str/Set, so typed reads stay scalar) ──
45
+ | { Counter: Record<string, { pos: number; neg: number }> }
46
+ | { Position: string }
47
+ | { TwoPhaseSet: { adds: string[]; removes: string[] } };
43
48
 
44
49
  /** `EvidenceRef { hash: ContentHash, media_type, byte_length, storage_class }` — the
45
50
  * kernel serde shape. `ContentHash(String)` serializes transparently as a bare string
@@ -57,11 +62,21 @@ export interface WireField {
57
62
  stamp: WireHlc;
58
63
  }
59
64
 
60
- /** `Op = Set(Value) | AddToSet(BTreeSet<String>) | SetEntry { key, value }`. */
65
+ /**
66
+ * `Op` — the kernel's externally-tagged serde shape (`nomos2/kernel/src/lib.rs::Op`). The originals
67
+ * (Set / AddToSet / SetEntry) plus the STRUCTURAL CRDT ops: `AddToTwoPhase`/`RemoveFromTwoPhase`
68
+ * (the `removeWins` 2-phase set), `IncrementCounter`/`DecrementCounter` (the `counter` PN-counter,
69
+ * carrying a bare `u64` amount), and `SetPosition` (the `lastPosition` LWW order-key, a bare string).
70
+ */
61
71
  export type WireOp =
62
72
  | { Set: WireValue }
63
73
  | { AddToSet: string[] }
64
- | { SetEntry: { key: string; value: WireValue } };
74
+ | { SetEntry: { key: string; value: WireValue } }
75
+ | { AddToTwoPhase: string[] }
76
+ | { RemoveFromTwoPhase: string[] }
77
+ | { IncrementCounter: number }
78
+ | { DecrementCounter: number }
79
+ | { SetPosition: string };
65
80
 
66
81
  /** `FieldOp { field: FieldId, op: Op }`. */
67
82
  export interface WireFieldOp {
@@ -100,13 +100,39 @@ function encodeKernelValue(field: Field | undefined, value: unknown): WireValue
100
100
  }
101
101
 
102
102
  function encodeKernelFieldOp(op: FieldOp, field: Field | undefined): WireFieldOp {
103
+ // The field's DRIVER selects the kernel op for the structural CRDTs — a `set`/`addToSet` on a
104
+ // position / remove-wins field lowers to `SetPosition` / `AddToTwoPhase`, never the scalar/AddWins op.
105
+ const driver = field?.driver?.kind;
103
106
  let wireOp: WireOp;
104
107
  switch (op.kind) {
105
108
  case "set":
106
- wireOp = { Set: encodeKernelValue(field, op.value) };
109
+ // A `lastPosition` field's `set` is the kernel `SetPosition` (LWW order-key, a bare string).
110
+ wireOp =
111
+ driver === "LastPosition"
112
+ ? { SetPosition: String(op.value) }
113
+ : { Set: encodeKernelValue(field, op.value) };
107
114
  break;
108
115
  case "addToSet":
109
- wireOp = { AddToSet: [...(op.items ?? [])] };
116
+ // On a remove-wins set the add grows the 2-phase set's `adds`; on AddWins it is the plain union.
117
+ wireOp =
118
+ driver === "RemoveWins"
119
+ ? { AddToTwoPhase: [...(op.items ?? [])] }
120
+ : { AddToSet: [...(op.items ?? [])] };
121
+ break;
122
+ case "removeFromSet":
123
+ if (driver !== "RemoveWins") {
124
+ throw new Error(
125
+ `removeFromSet('${op.field}'): only a RemoveWins set has a remove (an AddWins set is ` +
126
+ `add-only by design). Declare the field \`t.set(...).merge(RemoveWins)\`.`,
127
+ );
128
+ }
129
+ wireOp = { RemoveFromTwoPhase: [...(op.items ?? [])] };
130
+ break;
131
+ case "increment":
132
+ wireOp = { IncrementCounter: Number(op.value) };
133
+ break;
134
+ case "decrement":
135
+ wireOp = { DecrementCounter: Number(op.value) };
110
136
  break;
111
137
  case "setEntry":
112
138
  wireOp = {