@abloatai/ablo 0.14.0 → 0.15.1

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 (38) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/Database.d.ts +1 -1
  3. package/dist/auth/index.d.ts +4 -0
  4. package/dist/auth/index.js +1 -0
  5. package/dist/cli.cjs +54 -1
  6. package/dist/client/Ablo.d.ts +34 -3
  7. package/dist/client/Ablo.js +11 -4
  8. package/dist/client/ApiClient.js +3 -0
  9. package/dist/client/sessionMint.js +1 -0
  10. package/dist/client/writeOptionsSchema.d.ts +4 -6
  11. package/dist/client/writeOptionsSchema.js +1 -1
  12. package/dist/coordination/schema.d.ts +90 -12
  13. package/dist/coordination/schema.js +99 -4
  14. package/dist/errors.d.ts +19 -0
  15. package/dist/errors.js +21 -0
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +10 -1
  18. package/dist/interfaces/index.d.ts +23 -2
  19. package/dist/policy/types.d.ts +35 -3
  20. package/dist/policy/types.js +20 -7
  21. package/dist/server/commit.d.ts +26 -0
  22. package/dist/source/connector-protocol.d.ts +159 -0
  23. package/dist/source/connector-protocol.js +161 -0
  24. package/dist/source/connector.d.ts +96 -0
  25. package/dist/source/connector.js +264 -0
  26. package/dist/source/contract.d.ts +4 -6
  27. package/dist/source/contract.js +1 -1
  28. package/dist/source/index.d.ts +3 -1
  29. package/dist/source/index.js +6 -0
  30. package/dist/sync/SyncWebSocket.d.ts +32 -5
  31. package/dist/sync/SyncWebSocket.js +40 -6
  32. package/dist/transactions/TransactionQueue.d.ts +7 -1
  33. package/dist/transactions/TransactionQueue.js +43 -2
  34. package/dist/wire/frames.d.ts +28 -4
  35. package/docs/concurrency-convention.md +222 -0
  36. package/docs/coordination.md +5 -0
  37. package/docs/data-sources.md +41 -0
  38. package/package.json +6 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Loud 0-row writes: surface unmatched UPDATE/DELETE ids and add `AbloNotFoundError`
8
+
9
+ A commit now reports the ids of any UPDATE/DELETE that matched zero rows on
10
+ `CommitReceipt.missingIds`, and the new exported `AbloNotFoundError` lets typed
11
+ write wrappers throw instead of silently treating a missed write as success.
12
+ Additive and back-compatible (the field is omitted when nothing missed). This
13
+ unblocks the slides-sdk name-addressing / own-your-id work, which relies on a
14
+ loud failure when a stale id is written.
15
+
16
+ ## 0.15.0
17
+
18
+ ### Minor Changes
19
+
20
+ - **Notify-instead-of-abort: non-coercive conflict handling + read-set (the "did anything I looked at change?" layer).**
21
+
22
+ The principle: on a stale-context conflict the engine now **surfaces the current state and lets the actor — agent or human — resolve it**, instead of forcing an outcome. See `docs/concurrency-convention.md`.
23
+
24
+ **`onStale` redesigned — Stripe-aligned values (BREAKING).**
25
+
26
+ The mode set is now `'reject' | 'overwrite' | 'notify'`. Each value names its outcome:
27
+ - **`notify` (new, non-coercive)** — the conflicting write is **held** (not applied) and the commit returns a `StaleNotification` carrying the conflicting field's _current_ value, so the actor reconciles and re-commits rather than losing work. The rest of the batch still commits.
28
+ - **`overwrite`** (was `force`) — blind last-writer-wins, no signal.
29
+ - **`reject`** (default, unchanged) — throws `AbloStaleContextError`.
30
+
31
+ Migration:
32
+ - `onStale: 'force'` → `onStale: 'overwrite'`.
33
+ - `onStale: 'flag'` / `onStale: 'merge'` → `onStale: 'notify'` (both removed; `notify` is the single hold-and-surface mode).
34
+
35
+ **`StaleNotification` — the new advisory signal.** New public type + `staleNotificationSchema`:
36
+ `{ object: 'stale_notification', model, id, readAt, observedSyncId, conflictingFields, currentValues, writtenBy, group? }`. Delivered two ways:
37
+ - on the receipt — `CommitReceipt.notifications` (and `CommitResult.notifications`);
38
+ - on a new SDK event — **`conflict:notified`** `{ clientTxId, notifications }` (mirrors `reconciliation:needed` / `sync:rollback`).
39
+
40
+ **Read-set (`reads[]`) — declare what you looked at, not just what you write (new).** A commit may carry batch-level read dependencies; a moved premise fires that entry's `onStale` over the whole batch (`notify` holds every write + notifies, `reject` aborts, `overwrite` proceeds). Two granularities:
41
+ - **Row** — `{ model, id, readAt, fields? }`: did this row (optionally these fields) change?
42
+ - **Group** — `{ group, readAt }`: did anything in this sync group (`deck:abc`, `org:X`) change? — the same unit a participant watches and claims.
43
+
44
+ New public type `ReadDependency` + `readDependencySchema`; available on `ablo.commits.create({ operations, reads })` and the lower-level write options. This closes the gap the write-target check alone could not: a premise that changed without the written row changing.
45
+
46
+ **Conflict policy.** `ConflictDecision` gains `{ action: 'notify' }`; `defaultPolicy` maps `onStale: 'notify'` → notify-and-hold, everything else → reject. `StaleContextConflict.requestedMode` is added so custom policies can honor the caller's declared intent.
47
+
48
+ - **Data Source reverse-channel connector (new).** A customer Data Source can now **dial out** to the engine over a single outbound WebSocket (`ablo.source.v1` subprotocol) instead of exposing an inbound HTTP endpoint — the deployment shape private/VPC stores need.
49
+ - **`createSourceConnector({ apiKey, handler, baseURL? })`** (new public API, exported from the root and `/source`) — opens one outbound socket (Node global `WebSocket`, no new dependency), with reconnect/backoff, and serves the customer's existing Data Source `handler`.
50
+ - Server side: a connector registry + `/v1/source/listen` upgrade route bridge requests down / responses up, teed into `SourceClient` through the storage resolver.
51
+ - **Trust model unchanged:** the Standard-Webhooks HMAC is signed _above_ the transport, so the socket carries the signed envelope byte-for-byte and the customer's `verifyAbloSourceRequest` is untouched. Transport changes, trust model doesn't.
52
+ - Opt-in per source via `reverse_channel_prod` (migration `20260622150000`); gated in `authorizeUpgrade`.
53
+
3
54
  ## 0.14.0
4
55
 
5
56
  ### Minor Changes
@@ -17,7 +17,7 @@ interface PersistedMutation {
17
17
  timestamp: string;
18
18
  writeOptions?: {
19
19
  readAt?: number | null;
20
- onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
20
+ onStale?: 'reject' | 'overwrite' | 'notify' | null;
21
21
  };
22
22
  }
23
23
  /** Persisted transaction for offline/retry support.
@@ -34,6 +34,10 @@ export interface MintUserSessionRequest {
34
34
  readonly baseUrl: string;
35
35
  /** The end user's external IdP id — becomes the session's `participantId`. */
36
36
  readonly userId: string;
37
+ /** Target org for a cross-org (platform) mint — the Stripe-Connect
38
+ * `Stripe-Account` analogue. Requires the `sk_` to carry
39
+ * `ephemeral:mint-any-org`; omit to mint into the key's own org. */
40
+ readonly organizationId?: string;
37
41
  readonly syncGroups?: readonly string[];
38
42
  readonly ttlSeconds: number;
39
43
  readonly label?: string;
@@ -107,6 +107,7 @@ export async function mintUserSessionKey(options) {
107
107
  },
108
108
  body: JSON.stringify({
109
109
  user: { id: options.userId },
110
+ ...(options.organizationId ? { organizationId: options.organizationId } : {}),
110
111
  ...(options.syncGroups ? { syncGroups: options.syncGroups } : {}),
111
112
  ttlSeconds: options.ttlSeconds,
112
113
  ...(options.label ? { label: options.label } : {}),
package/dist/cli.cjs CHANGED
@@ -277067,12 +277067,65 @@ var targetRefSchema = import_zod3.z.object({
277067
277067
  field: import_zod3.z.string().optional(),
277068
277068
  meta: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional()
277069
277069
  });
277070
- var onStaleModeSchema = import_zod3.z.enum(["reject", "force", "flag", "merge"]);
277070
+ var onStaleModeSchema = import_zod3.z.enum(["reject", "overwrite", "notify"]);
277071
277071
  var writeGuardSchema = import_zod3.z.object({
277072
277072
  readAt: import_zod3.z.number().nullish(),
277073
277073
  onStale: onStaleModeSchema.nullish(),
277074
277074
  bypass: import_zod3.z.boolean().optional()
277075
277075
  });
277076
+ var staleNotificationSchema = import_zod3.z.object({
277077
+ /** Stripe-style object tag — every returned object names its type. */
277078
+ object: import_zod3.z.literal("stale_notification").optional(),
277079
+ /** Model name of the conflicting row. */
277080
+ model: import_zod3.z.string(),
277081
+ /** Row id. */
277082
+ id: import_zod3.z.string(),
277083
+ /** The watermark the committer reasoned against (its `readAt`). */
277084
+ readAt: import_zod3.z.number(),
277085
+ /**
277086
+ * Newest delta id on the row — the committer's new watermark. Re-capture
277087
+ * context at/after this id to reconcile.
277088
+ */
277089
+ observedSyncId: import_zod3.z.number(),
277090
+ /**
277091
+ * Fields whose concurrent change collided with this write (intersection of
277092
+ * the committer's written columns and a newer delta's `changed_fields`).
277093
+ * Empty ⇒ a whole-entity change (CREATE/DELETE/legacy delta).
277094
+ */
277095
+ conflictingFields: import_zod3.z.array(import_zod3.z.string()),
277096
+ /**
277097
+ * Post-conflict live values of `conflictingFields` — the part a plain stale
277098
+ * error never carried. Lets the LLM self-heal without a round-trip read.
277099
+ */
277100
+ currentValues: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()),
277101
+ /** Who wrote the conflicting delta. */
277102
+ writtenBy: import_zod3.z.object({
277103
+ kind: participantKindSchema,
277104
+ id: import_zod3.z.string()
277105
+ }),
277106
+ /**
277107
+ * Set when this notification is for a GROUP read-dependency (e.g. `deck:abc`,
277108
+ * `slide:s1`) rather than a single row — "something in the group you read
277109
+ * changed." For a group notification `conflictingFields`/`currentValues` are
277110
+ * empty (the change could span many rows); re-read the group at
277111
+ * `observedSyncId` to reconcile. Absent ⇒ a row-scoped notification.
277112
+ */
277113
+ group: import_zod3.z.string().optional()
277114
+ });
277115
+ var readDependencySchema = import_zod3.z.union([
277116
+ import_zod3.z.object({
277117
+ model: import_zod3.z.string(),
277118
+ id: import_zod3.z.string(),
277119
+ readAt: import_zod3.z.number(),
277120
+ fields: import_zod3.z.array(import_zod3.z.string()).optional(),
277121
+ onStale: onStaleModeSchema.optional()
277122
+ }),
277123
+ import_zod3.z.object({
277124
+ group: import_zod3.z.string(),
277125
+ readAt: import_zod3.z.number(),
277126
+ onStale: onStaleModeSchema.optional()
277127
+ })
277128
+ ]);
277076
277129
  var claimStatusSchema = import_zod3.z.enum([
277077
277130
  "active",
277078
277131
  "committed",
@@ -18,6 +18,7 @@
18
18
  * });
19
19
  * await sync.reports.delete({ id: reportId });
20
20
  */
21
+ import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
21
22
  import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
22
23
  import type { SyncEngineConfig, SyncLogger, MutationExecutor, MutationDispatcher, SyncObservabilityProvider, SyncAnalytics, SessionErrorDetector, OnlineStatusProvider } from '../interfaces/index.js';
23
24
  import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
@@ -424,7 +425,7 @@ export interface CommitOperationInput {
424
425
  readonly data?: Record<string, unknown> | null;
425
426
  readonly transactionId?: string | null;
426
427
  readonly readAt?: number | null;
427
- readonly onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
428
+ readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
428
429
  }
429
430
  export interface CommitCreateOptions {
430
431
  readonly claimRef?: string | {
@@ -432,7 +433,7 @@ export interface CommitCreateOptions {
432
433
  } | null;
433
434
  readonly idempotencyKey?: string | null;
434
435
  readonly readAt?: number | null;
435
- readonly onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
436
+ readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
436
437
  /**
437
438
  * A claim handle from `ablo.<model>.claim({ id })` (or the HTTP claim
438
439
  * surface). Same vocabulary as the per-model writes: the handle's
@@ -445,11 +446,36 @@ export interface CommitCreateOptions {
445
446
  readonly operation?: CommitOperationInput;
446
447
  readonly operations?: readonly CommitOperationInput[];
447
448
  readonly wait?: CommitWait;
449
+ /**
450
+ * Batch-level read dependencies (the STORM "did anything I looked at change?"
451
+ * layer). Declare the rows (`{model,id,readAt,fields?}`) or sync groups
452
+ * (`{group,readAt}`, e.g. `deck:abc`) this batch was premised on; the server
453
+ * validates none moved since `readAt` and fires the entry's `onStale` over the
454
+ * batch. Distinct from the write-target `readAt` — this guards what you READ,
455
+ * not what you write.
456
+ */
457
+ readonly reads?: readonly ReadDependency[] | null;
448
458
  }
449
459
  export interface CommitReceipt {
450
460
  readonly id: string;
451
461
  readonly status: CommitWait;
452
462
  readonly lastSyncId?: number;
463
+ /**
464
+ * Stale-context notifications (notify-instead-of-abort, non-coercion). Present
465
+ * only when this commit guarded a write with `onStale: 'notify' and
466
+ * the premise moved concurrently — the conflicting field's current value,
467
+ * handed back as data instead of a forced `AbloStaleContextError`. The engine
468
+ * surfaces state; the intelligent actor (agent or human) decides how to
469
+ * resolve. Also fires on `conflict:notified`.
470
+ */
471
+ readonly notifications?: readonly StaleNotification[];
472
+ /**
473
+ * Ids of UPDATE/DELETE targets in this commit that matched ZERO rows (the row
474
+ * doesn't exist, or is outside the caller's org). Present (non-empty) only
475
+ * when a write missed. Typed resource wrappers turn this into a loud
476
+ * `AbloNotFoundError`; a raw `commits.create` caller can inspect it directly.
477
+ */
478
+ readonly missingIds?: readonly string[];
453
479
  }
454
480
  export interface CommitResource {
455
481
  create(options: CommitCreateOptions): Promise<CommitReceipt>;
@@ -465,7 +491,7 @@ export interface ModelMutationOptions extends ClaimedOptions {
465
491
  } | null;
466
492
  readonly idempotencyKey?: string | null;
467
493
  readonly readAt?: number | null;
468
- readonly onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
494
+ readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
469
495
  readonly wait?: CommitWait;
470
496
  readonly claim?: ClaimHandle | ClaimOptions | null;
471
497
  }
@@ -543,6 +569,11 @@ export interface CreateUserSessionParams {
543
569
  user: {
544
570
  id: string;
545
571
  };
572
+ /** Mint the session into THIS organization instead of the key's own org — the
573
+ * Stripe Connect `Stripe-Account` pattern, for a platform serving many tenants
574
+ * from one backend. Requires the `sk_` to carry the `ephemeral:mint-any-org`
575
+ * scope; omit for the normal single-tenant case. */
576
+ organizationId?: string;
546
577
  /** Sync groups this session may subscribe to — typed (`'default'` or
547
578
  * `<namespace>:<id>`; build with `syncGroup(kind, id)` from
548
579
  * `@abloatai/ablo/schema`). Omit for the server default:
@@ -606,7 +606,7 @@ function createDefaultMutationExecutor(getWs) {
606
606
  : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
607
607
  try {
608
608
  return await ws.sendCommit(operations, clientTxId, undefined, // use sendCommit's built-in 15s default; no per-call override
609
- options?.causedByTaskId);
609
+ options?.causedByTaskId, options?.reads);
610
610
  }
611
611
  catch (err) {
612
612
  // Wrap transport-level failures as connection errors so the
@@ -1412,12 +1412,19 @@ export function Ablo(options) {
1412
1412
  // SyncClient we already hold from createInternalComponents —
1413
1413
  // no need to leak an accessor through BaseSyncedStore.
1414
1414
  const queue = syncClient.getTransactionQueue();
1415
- queue.enqueueCommit(clientTxId, operations);
1415
+ queue.enqueueCommit(clientTxId, operations, {
1416
+ ...(commitOptions.reads ? { reads: [...commitOptions.reads] } : {}),
1417
+ });
1416
1418
  if (wait === 'queued') {
1417
1419
  return { id: clientTxId, status: 'queued' };
1418
1420
  }
1419
- const { lastSyncId } = await queue.waitForCommitReceipt(clientTxId);
1420
- return { id: clientTxId, status: 'confirmed', lastSyncId };
1421
+ const { lastSyncId, notifications } = await queue.waitForCommitReceipt(clientTxId);
1422
+ return {
1423
+ id: clientTxId,
1424
+ status: 'confirmed',
1425
+ lastSyncId,
1426
+ ...(notifications && notifications.length > 0 ? { notifications } : {}),
1427
+ };
1421
1428
  },
1422
1429
  };
1423
1430
  async function retrieveModel(modelName, id, options) {
@@ -285,6 +285,9 @@ export function createProtocolClient(options) {
285
285
  id: body.id ?? body.clientTxId ?? clientTxId,
286
286
  status,
287
287
  lastSyncId: body.lastSyncId,
288
+ ...(body.missingIds && body.missingIds.length > 0
289
+ ? { missingIds: body.missingIds }
290
+ : {}),
288
291
  };
289
292
  },
290
293
  };
@@ -40,6 +40,7 @@ export async function mintSession(params, ctx) {
40
40
  apiKey,
41
41
  baseUrl,
42
42
  userId: params.user.id,
43
+ ...(params.organizationId ? { organizationId: params.organizationId } : {}),
43
44
  ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
44
45
  ttlSeconds: params.ttlSeconds ?? 900,
45
46
  ...(ctx.fetch ? { fetch: ctx.fetch } : {}),
@@ -17,9 +17,8 @@
17
17
  import { z } from 'zod';
18
18
  export declare const onStaleModeSchema: z.ZodEnum<{
19
19
  reject: "reject";
20
- force: "force";
21
- flag: "flag";
22
- merge: "merge";
20
+ overwrite: "overwrite";
21
+ notify: "notify";
23
22
  }>;
24
23
  export declare const writeOptionsSchema: z.ZodObject<{
25
24
  idempotencyKey: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -31,9 +30,8 @@ export declare const writeOptionsSchema: z.ZodObject<{
31
30
  readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
32
31
  onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
33
32
  reject: "reject";
34
- force: "force";
35
- flag: "flag";
36
- merge: "merge";
33
+ overwrite: "overwrite";
34
+ notify: "notify";
37
35
  }>>>;
38
36
  claim: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
39
37
  id: z.ZodString;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { z } from 'zod';
18
18
  import { AbloValidationError } from '../errors.js';
19
- export const onStaleModeSchema = z.enum(['reject', 'force', 'flag', 'merge']);
19
+ export const onStaleModeSchema = z.enum(['reject', 'overwrite', 'notify']);
20
20
  export const writeOptionsSchema = z.object({
21
21
  /** Server-side mutation_log cache key; `null` opts out of retry-safety. */
22
22
  idempotencyKey: z.string().min(1).max(255).nullish(),
@@ -82,15 +82,19 @@ export declare const targetRefSchema: z.ZodObject<{
82
82
  export type TargetRef = z.infer<typeof targetRefSchema>;
83
83
  /**
84
84
  * Mode applied when a write's snapshot watermark (`readAt`) is older than the
85
- * target row's latest delta. `'reject'` is the default whenever `readAt` is
86
- * present. `'flag'` and `'merge'` are reserved — the wire accepts them, the
87
- * server does not yet enforce them.
85
+ * target row's latest delta. Three dispositions, split by the non-coercion
86
+ * convention (see docs/concurrency-convention.md):
87
+ * `notify` — NON-COERCIVE: hold the write, return a `StaleNotification`
88
+ * with the current value; the actor (agent or human) resolves.
89
+ * • `reject` — coercive escape hatch: throw `AbloStaleContextError`
90
+ * (default when `readAt` is present).
91
+ * • `overwrite` — coercive escape hatch: apply blindly last-writer-wins, no
92
+ * signal.
88
93
  */
89
94
  export declare const onStaleModeSchema: z.ZodEnum<{
90
95
  reject: "reject";
91
- force: "force";
92
- flag: "flag";
93
- merge: "merge";
96
+ overwrite: "overwrite";
97
+ notify: "notify";
94
98
  }>;
95
99
  export type OnStaleMode = z.infer<typeof onStaleModeSchema>;
96
100
  /**
@@ -103,13 +107,88 @@ export declare const writeGuardSchema: z.ZodObject<{
103
107
  readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
104
108
  onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
105
109
  reject: "reject";
106
- force: "force";
107
- flag: "flag";
108
- merge: "merge";
110
+ overwrite: "overwrite";
111
+ notify: "notify";
109
112
  }>>>;
110
113
  bypass: z.ZodOptional<z.ZodBoolean>;
111
114
  }, z.core.$strip>;
112
115
  export type WriteGuard = z.infer<typeof writeGuardSchema>;
116
+ /**
117
+ * The advisory signal returned to a committer whose write hit a stale-context
118
+ * conflict under `onStale: 'notify'` — the engine's answer to "the typed value
119
+ * you reasoned against changed while you were away."
120
+ *
121
+ * Philosophy: NON-COERCION. The engine's job is to surface the truthful current
122
+ * state and let the intelligent actor — agent OR human — decide what to do; it
123
+ * does NOT force an outcome. The two *forcing* dispositions are `reject`
124
+ * (force-abort, discards the work) and `overwrite` (force-clobber). This
125
+ * notification is the non-coercive path: instead of throwing, the server hands
126
+ * back the conflicting field's *current* value as data so the actor can solve
127
+ * it. The CLAIM is the prospective form of the same principle (coordinate
128
+ * before acting); this notification is the in-flight form (here's what changed,
129
+ * you resolve). Both an agent reasoning over the change and a human watching the
130
+ * row are valid resolvers. (Cf. CoAgent/MTPO, arXiv:2606.15376, which bets the
131
+ * resolver is specifically an LLM; Ablo's bet is the same non-coercion, actor
132
+ * left to agent or human.) Rides on the commit ack alongside `lastSyncId`; an
133
+ * empty/absent array means no premise moved.
134
+ *
135
+ * Only `notify` produces this: the conflicting op was HELD (not written), and
136
+ * the actor reconciles against `currentValues` and re-commits. (`reject` throws,
137
+ * `overwrite` is silent — neither notifies.)
138
+ */
139
+ export declare const staleNotificationSchema: z.ZodObject<{
140
+ object: z.ZodOptional<z.ZodLiteral<"stale_notification">>;
141
+ model: z.ZodString;
142
+ id: z.ZodString;
143
+ readAt: z.ZodNumber;
144
+ observedSyncId: z.ZodNumber;
145
+ conflictingFields: z.ZodArray<z.ZodString>;
146
+ currentValues: z.ZodRecord<z.ZodString, z.ZodUnknown>;
147
+ writtenBy: z.ZodObject<{
148
+ kind: z.ZodEnum<{
149
+ user: "user";
150
+ agent: "agent";
151
+ system: "system";
152
+ }>;
153
+ id: z.ZodString;
154
+ }, z.core.$strip>;
155
+ group: z.ZodOptional<z.ZodString>;
156
+ }, z.core.$strip>;
157
+ export type StaleNotification = z.infer<typeof staleNotificationSchema>;
158
+ /**
159
+ * A read DEPENDENCY declared on a commit — the STORM "did anything I looked at
160
+ * change?" layer (vs. the write-target check that only validates the rows being
161
+ * written). The server re-runs stale detection against each declared read at
162
+ * `readAt`; a moved premise fires the entry's `onStale` disposition (default
163
+ * `reject`) over the WHOLE batch (`notify` holds every write + notifies;
164
+ * `reject` aborts; `overwrite` proceeds silently). Two granularities, choice:
165
+ *
166
+ * • ROW — `{ model, id, readAt, fields? }`: did this specific row (optionally
167
+ * these fields) change? The literal STORM/per-object premise.
168
+ * • GROUP — `{ group, readAt }`: did ANYTHING in this sync group change? `group`
169
+ * is a sync-group key like `deck:abc` or `slide:s1` — the same unit a
170
+ * human/agent watches and claims. Coarser, and more Ablo-native.
171
+ */
172
+ export declare const readDependencySchema: z.ZodUnion<readonly [z.ZodObject<{
173
+ model: z.ZodString;
174
+ id: z.ZodString;
175
+ readAt: z.ZodNumber;
176
+ fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
177
+ onStale: z.ZodOptional<z.ZodEnum<{
178
+ reject: "reject";
179
+ overwrite: "overwrite";
180
+ notify: "notify";
181
+ }>>;
182
+ }, z.core.$strip>, z.ZodObject<{
183
+ group: z.ZodString;
184
+ readAt: z.ZodNumber;
185
+ onStale: z.ZodOptional<z.ZodEnum<{
186
+ reject: "reject";
187
+ overwrite: "overwrite";
188
+ notify: "notify";
189
+ }>>;
190
+ }, z.core.$strip>]>;
191
+ export type ReadDependency = z.infer<typeof readDependencySchema>;
113
192
  /**
114
193
  * Lifecycle of an claim — the Stripe `PaymentIntent.status` shape. Absent on
115
194
  * the wire ⇒ `'active'` (additive back-compat). The server stamps `'active'`
@@ -407,9 +486,8 @@ export declare const commitOperationSchema: z.ZodObject<{
407
486
  readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
408
487
  onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
409
488
  reject: "reject";
410
- force: "force";
411
- flag: "flag";
412
- merge: "merge";
489
+ overwrite: "overwrite";
490
+ notify: "notify";
413
491
  }>>>;
414
492
  bypass: z.ZodOptional<z.ZodBoolean>;
415
493
  type: z.ZodEnum<{
@@ -80,11 +80,16 @@ export const targetRefSchema = z.object({
80
80
  // ─────────────────────────────────────────────────────────────────────────
81
81
  /**
82
82
  * Mode applied when a write's snapshot watermark (`readAt`) is older than the
83
- * target row's latest delta. `'reject'` is the default whenever `readAt` is
84
- * present. `'flag'` and `'merge'` are reserved — the wire accepts them, the
85
- * server does not yet enforce them.
83
+ * target row's latest delta. Three dispositions, split by the non-coercion
84
+ * convention (see docs/concurrency-convention.md):
85
+ * `notify` — NON-COERCIVE: hold the write, return a `StaleNotification`
86
+ * with the current value; the actor (agent or human) resolves.
87
+ * • `reject` — coercive escape hatch: throw `AbloStaleContextError`
88
+ * (default when `readAt` is present).
89
+ * • `overwrite` — coercive escape hatch: apply blindly last-writer-wins, no
90
+ * signal.
86
91
  */
87
- export const onStaleModeSchema = z.enum(['reject', 'force', 'flag', 'merge']);
92
+ export const onStaleModeSchema = z.enum(['reject', 'overwrite', 'notify']);
88
93
  /**
89
94
  * The optimistic guard carried on a commit operation. `readAt` is the
90
95
  * snapshot watermark from `context.capture` (null/absent ⇒ unguarded write).
@@ -96,6 +101,96 @@ export const writeGuardSchema = z.object({
96
101
  onStale: onStaleModeSchema.nullish(),
97
102
  bypass: z.boolean().optional(),
98
103
  });
104
+ /**
105
+ * The advisory signal returned to a committer whose write hit a stale-context
106
+ * conflict under `onStale: 'notify'` — the engine's answer to "the typed value
107
+ * you reasoned against changed while you were away."
108
+ *
109
+ * Philosophy: NON-COERCION. The engine's job is to surface the truthful current
110
+ * state and let the intelligent actor — agent OR human — decide what to do; it
111
+ * does NOT force an outcome. The two *forcing* dispositions are `reject`
112
+ * (force-abort, discards the work) and `overwrite` (force-clobber). This
113
+ * notification is the non-coercive path: instead of throwing, the server hands
114
+ * back the conflicting field's *current* value as data so the actor can solve
115
+ * it. The CLAIM is the prospective form of the same principle (coordinate
116
+ * before acting); this notification is the in-flight form (here's what changed,
117
+ * you resolve). Both an agent reasoning over the change and a human watching the
118
+ * row are valid resolvers. (Cf. CoAgent/MTPO, arXiv:2606.15376, which bets the
119
+ * resolver is specifically an LLM; Ablo's bet is the same non-coercion, actor
120
+ * left to agent or human.) Rides on the commit ack alongside `lastSyncId`; an
121
+ * empty/absent array means no premise moved.
122
+ *
123
+ * Only `notify` produces this: the conflicting op was HELD (not written), and
124
+ * the actor reconciles against `currentValues` and re-commits. (`reject` throws,
125
+ * `overwrite` is silent — neither notifies.)
126
+ */
127
+ export const staleNotificationSchema = z.object({
128
+ /** Stripe-style object tag — every returned object names its type. */
129
+ object: z.literal('stale_notification').optional(),
130
+ /** Model name of the conflicting row. */
131
+ model: z.string(),
132
+ /** Row id. */
133
+ id: z.string(),
134
+ /** The watermark the committer reasoned against (its `readAt`). */
135
+ readAt: z.number(),
136
+ /**
137
+ * Newest delta id on the row — the committer's new watermark. Re-capture
138
+ * context at/after this id to reconcile.
139
+ */
140
+ observedSyncId: z.number(),
141
+ /**
142
+ * Fields whose concurrent change collided with this write (intersection of
143
+ * the committer's written columns and a newer delta's `changed_fields`).
144
+ * Empty ⇒ a whole-entity change (CREATE/DELETE/legacy delta).
145
+ */
146
+ conflictingFields: z.array(z.string()),
147
+ /**
148
+ * Post-conflict live values of `conflictingFields` — the part a plain stale
149
+ * error never carried. Lets the LLM self-heal without a round-trip read.
150
+ */
151
+ currentValues: z.record(z.string(), z.unknown()),
152
+ /** Who wrote the conflicting delta. */
153
+ writtenBy: z.object({
154
+ kind: participantKindSchema,
155
+ id: z.string(),
156
+ }),
157
+ /**
158
+ * Set when this notification is for a GROUP read-dependency (e.g. `deck:abc`,
159
+ * `slide:s1`) rather than a single row — "something in the group you read
160
+ * changed." For a group notification `conflictingFields`/`currentValues` are
161
+ * empty (the change could span many rows); re-read the group at
162
+ * `observedSyncId` to reconcile. Absent ⇒ a row-scoped notification.
163
+ */
164
+ group: z.string().optional(),
165
+ });
166
+ /**
167
+ * A read DEPENDENCY declared on a commit — the STORM "did anything I looked at
168
+ * change?" layer (vs. the write-target check that only validates the rows being
169
+ * written). The server re-runs stale detection against each declared read at
170
+ * `readAt`; a moved premise fires the entry's `onStale` disposition (default
171
+ * `reject`) over the WHOLE batch (`notify` holds every write + notifies;
172
+ * `reject` aborts; `overwrite` proceeds silently). Two granularities, choice:
173
+ *
174
+ * • ROW — `{ model, id, readAt, fields? }`: did this specific row (optionally
175
+ * these fields) change? The literal STORM/per-object premise.
176
+ * • GROUP — `{ group, readAt }`: did ANYTHING in this sync group change? `group`
177
+ * is a sync-group key like `deck:abc` or `slide:s1` — the same unit a
178
+ * human/agent watches and claims. Coarser, and more Ablo-native.
179
+ */
180
+ export const readDependencySchema = z.union([
181
+ z.object({
182
+ model: z.string(),
183
+ id: z.string(),
184
+ readAt: z.number(),
185
+ fields: z.array(z.string()).optional(),
186
+ onStale: onStaleModeSchema.optional(),
187
+ }),
188
+ z.object({
189
+ group: z.string(),
190
+ readAt: z.number(),
191
+ onStale: onStaleModeSchema.optional(),
192
+ }),
193
+ ]);
99
194
  // ─────────────────────────────────────────────────────────────────────────
100
195
  // Layer 2 — PESSIMISTIC claim / claim-lease
101
196
  // ─────────────────────────────────────────────────────────────────────────
package/dist/errors.d.ts CHANGED
@@ -118,6 +118,21 @@ export declare class AbloConnectionError extends AbloError {
118
118
  export declare class AbloValidationError extends AbloError {
119
119
  readonly type: "AbloValidationError";
120
120
  }
121
+ /**
122
+ * 404 — an UPDATE/DELETE addressed a row that doesn't exist (or is outside the
123
+ * caller's org). The engine reports such targets on `CommitReceipt.missingIds`;
124
+ * the typed resource wrappers raise this instead of returning a success receipt
125
+ * for a write that quietly matched zero rows. Carries the offending ids so a
126
+ * caller can see exactly which targets were absent.
127
+ */
128
+ export declare class AbloNotFoundError extends AbloError {
129
+ readonly type: "AbloNotFoundError";
130
+ /** The id(s) that matched no row. */
131
+ readonly missingIds: readonly string[];
132
+ constructor(message: string, missingIds: readonly string[], options?: {
133
+ requestId?: string;
134
+ });
135
+ }
121
136
  /** 5xx — server-side error. Usually retryable with backoff. */
122
137
  export declare class AbloServerError extends AbloError {
123
138
  readonly type: "AbloServerError";
@@ -272,6 +287,10 @@ export interface CommitReceipt {
272
287
  /** Number of operations metered. Reported on both success and
273
288
  * rejection so quota systems see attempted work. */
274
289
  readonly ops?: number;
290
+ /** Ids of UPDATE/DELETE targets that matched ZERO rows (loud 0-row writes).
291
+ * Present (non-empty) only when a write missed; typed wrappers raise
292
+ * `AbloNotFoundError` from it. */
293
+ readonly missingIds?: readonly string[];
275
294
  /** Populated on rejection. `requiredCapability` (when present)
276
295
  * carries the x402-style structured retry hint. */
277
296
  readonly error?: {
package/dist/errors.js CHANGED
@@ -132,6 +132,27 @@ export class AbloConnectionError extends AbloError {
132
132
  export class AbloValidationError extends AbloError {
133
133
  type = 'AbloValidationError';
134
134
  }
135
+ /**
136
+ * 404 — an UPDATE/DELETE addressed a row that doesn't exist (or is outside the
137
+ * caller's org). The engine reports such targets on `CommitReceipt.missingIds`;
138
+ * the typed resource wrappers raise this instead of returning a success receipt
139
+ * for a write that quietly matched zero rows. Carries the offending ids so a
140
+ * caller can see exactly which targets were absent.
141
+ */
142
+ export class AbloNotFoundError extends AbloError {
143
+ type = 'AbloNotFoundError';
144
+ /** The id(s) that matched no row. */
145
+ missingIds;
146
+ constructor(message, missingIds, options) {
147
+ super(message, {
148
+ code: 'mutate_update_entity_not_found',
149
+ httpStatus: 404,
150
+ details: { missingIds },
151
+ ...(options?.requestId !== undefined ? { requestId: options.requestId } : {}),
152
+ });
153
+ this.missingIds = missingIds;
154
+ }
155
+ }
135
156
  /** 5xx — server-side error. Usually retryable with backoff. */
136
157
  export class AbloServerError extends AbloError {
137
158
  type = 'AbloServerError';