@abloatai/ablo 0.15.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Axis 3 — declare write-conflict behaviour in the schema (new).** A model can now
8
+ state what happens when a commit collides with a foreign claim or a stale snapshot —
9
+ per committer kind (`user` / `agent` / `system`) — right next to its fields, using the
10
+ same `overwrite | reject | notify` vocabulary as the `onStale` write guard. It is a
11
+ third axis, orthogonal to `policy` (read access) and `groups` (delta routing).
12
+
13
+ - **`conflict` on `model()`** — a plain, serializable disposition map. Pure data, so it
14
+ round-trips through the schema registry to the server; the generic engine interprets it
15
+ at the commit chokepoint (no per-model logic in the engine).
16
+
17
+ ```ts
18
+ // "a human's edit always wins (never blocked); an agent yields"
19
+ conflict: { user: 'overwrite', agent: 'reject' }
20
+ ```
21
+
22
+ - **Composable authoring helpers (new, from `@abloatai/ablo/schema`)** — disposition
23
+ functions plus a `cn`/`cx`-style combinator, so conflict policy reads like the rest of
24
+ the DSL (`relation.belongsTo()`) and like modern config (`plugins: [admin(), …]`):
25
+
26
+ ```ts
27
+ import { coordination, humansOverwrite, agentsReject } from '@abloatai/ablo/schema';
28
+
29
+ conflict: coordination(humansOverwrite(), agentsReject())
30
+ // → { user: 'overwrite', agent: 'reject' }
31
+ ```
32
+
33
+ Exports: `coordination`, `humansOverwrite` / `humansReject` / `humansNotify`,
34
+ `agentsOverwrite` / `agentsReject` / `agentsNotify`,
35
+ `systemOverwrite` / `systemReject` / `systemNotify`, and the `ConflictRule` type.
36
+
37
+ - An omitted committer kind falls through to the engine default (reject; honor
38
+ `onStale: 'notify'`), so this is fully additive — existing schemas are unchanged.
39
+ New public types `ConflictAxis` (also `Ablo.Conflict.Axis`) and the
40
+ `interpretConflictAxis` interpreter are exported for custom policy composition.
41
+
42
+ - **First-party shared schema for ephemeral keys (new).** `mintUserSessionKey` now accepts
43
+ `schemaProjectId` + `schemaOwnerOrgId`, binding the minted `ek_` to a schema owner-org +
44
+ project so **schema** resolves org-independently (one schema serves all of an integrator's
45
+ end-user orgs) while **data** stays scoped to `organizationId`. Requires the `sk_` to carry
46
+ `ephemeral:mint-any-org`; omit both for the existing per-org (BYO) behaviour.
47
+
48
+ ## 0.15.1
49
+
50
+ ### Patch Changes
51
+
52
+ - Loud 0-row writes: surface unmatched UPDATE/DELETE ids and add `AbloNotFoundError`
53
+
54
+ A commit now reports the ids of any UPDATE/DELETE that matched zero rows on
55
+ `CommitReceipt.missingIds`, and the new exported `AbloNotFoundError` lets typed
56
+ write wrappers throw instead of silently treating a missed write as success.
57
+ Additive and back-compatible (the field is omitted when nothing missed). This
58
+ unblocks the slides-sdk name-addressing / own-your-id work, which relies on a
59
+ loud failure when a stale id is written.
60
+
3
61
  ## 0.15.0
4
62
 
5
63
  ### Minor Changes
@@ -11,7 +69,7 @@
11
69
  **`onStale` redesigned — Stripe-aligned values (BREAKING).**
12
70
 
13
71
  The mode set is now `'reject' | 'overwrite' | 'notify'`. Each value names its outcome:
14
- - **`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.
72
+ - **`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.
15
73
  - **`overwrite`** (was `force`) — blind last-writer-wins, no signal.
16
74
  - **`reject`** (default, unchanged) — throws `AbloStaleContextError`.
17
75
 
@@ -33,10 +91,9 @@
33
91
  **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.
34
92
 
35
93
  - **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.
36
-
37
94
  - **`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`.
38
95
  - Server side: a connector registry + `/v1/source/listen` upgrade route bridge requests down / responses up, teed into `SourceClient` through the storage resolver.
39
- - **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.
96
+ - **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.
40
97
  - Opt-in per source via `reverse_channel_prod` (migration `20260622150000`); gated in `authorizeUpgrade`.
41
98
 
42
99
  ## 0.14.0
@@ -38,6 +38,13 @@ export interface MintUserSessionRequest {
38
38
  * `Stripe-Account` analogue. Requires the `sk_` to carry
39
39
  * `ephemeral:mint-any-org`; omit to mint into the key's own org. */
40
40
  readonly organizationId?: string;
41
+ /** FIRST-PARTY SHARED SCHEMA: bind the minted `ek_` to a schema owner-org +
42
+ * project so SCHEMA resolves org-independently (one schema for all the
43
+ * integrator's end-user orgs), while DATA stays scoped to `organizationId`.
44
+ * Requires the `sk_` to carry `ephemeral:mint-any-org`. Omit for the normal
45
+ * per-org behaviour (every external BYO customer). */
46
+ readonly schemaProjectId?: string;
47
+ readonly schemaOwnerOrgId?: string;
41
48
  readonly syncGroups?: readonly string[];
42
49
  readonly ttlSeconds: number;
43
50
  readonly label?: string;
@@ -108,6 +108,8 @@ export async function mintUserSessionKey(options) {
108
108
  body: JSON.stringify({
109
109
  user: { id: options.userId },
110
110
  ...(options.organizationId ? { organizationId: options.organizationId } : {}),
111
+ ...(options.schemaProjectId ? { schemaProjectId: options.schemaProjectId } : {}),
112
+ ...(options.schemaOwnerOrgId ? { schemaOwnerOrgId: options.schemaOwnerOrgId } : {}),
111
113
  ...(options.syncGroups ? { syncGroups: options.syncGroups } : {}),
112
114
  ttlSeconds: options.ttlSeconds,
113
115
  ...(options.label ? { label: options.label } : {}),
@@ -469,6 +469,13 @@ export interface CommitReceipt {
469
469
  * resolve. Also fires on `conflict:notified`.
470
470
  */
471
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[];
472
479
  }
473
480
  export interface CommitResource {
474
481
  create(options: CommitCreateOptions): Promise<CommitReceipt>;
@@ -1003,6 +1010,7 @@ export declare namespace Ablo {
1003
1010
  type Operation = _Policy.ConflictOperation;
1004
1011
  type Decision = _Policy.ConflictDecision;
1005
1012
  type Policy = _Policy.ConflictPolicy;
1013
+ type Axis = _Policy.ConflictAxis;
1006
1014
  }
1007
1015
  namespace Commit {
1008
1016
  type Wait = import('./Ablo.js').CommitWait;
@@ -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
  };
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';
package/dist/index.d.ts CHANGED
@@ -58,8 +58,8 @@ import { Ablo } from './client/Ablo.js';
58
58
  export default Ablo;
59
59
  export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
60
60
  export { createSourceConnector, type SourceConnector, type SourceConnectorOptions, type ConnectorStatus, } from './source/connector.js';
61
- export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
62
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
61
+ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from './policy/index.js';
62
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
63
63
  export type { CommitReceipt, RequiredCapability } from './errors.js';
64
64
  export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errors.js';
65
65
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
package/dist/index.js CHANGED
@@ -80,11 +80,11 @@ export { createSourceConnector, } from './source/connector.js';
80
80
  // (reject-on-stale) is already applied server-side, so you only import it
81
81
  // to COMPOSE a custom policy. Leave it alone and stale writes are rejected
82
82
  // safely by default. Type counterparts live under `Ablo.Conflict.*`.
83
- export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
83
+ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from './policy/index.js';
84
84
  // Typed error hierarchy — Stripe-style. One import gets every class
85
85
  // consumers need to discriminate failures (`e instanceof AbloX` or
86
86
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
87
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
87
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
88
88
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
89
89
  export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
90
90
  // THE write-options contract — the one Zod schema for the option bag every
@@ -148,6 +148,11 @@ export interface CommitResult {
148
148
  * receiving an `AbloStaleContextError`. See `StaleNotification`.
149
149
  */
150
150
  notifications?: StaleNotification[];
151
+ /**
152
+ * Ids of UPDATE/DELETE targets that matched ZERO rows (loud 0-row writes).
153
+ * Present (non-empty) only when a write missed.
154
+ */
155
+ missingIds?: string[];
151
156
  }
152
157
  /**
153
158
  * Per-call knobs attached to any mutation. Mirrors Stripe's options
@@ -15,5 +15,5 @@
15
15
  * };
16
16
  * ```
17
17
  */
18
- export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, ClaimHeldConflict, } from './types.js';
19
- export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
18
+ export type { Conflict, ConflictAxis, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, ClaimHeldConflict, } from './types.js';
19
+ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from './types.js';
@@ -15,4 +15,4 @@
15
15
  * };
16
16
  * ```
17
17
  */
18
- export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
18
+ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from './types.js';
@@ -7,6 +7,7 @@
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
9
  import type { ParticipantRef } from '../types/streams.js';
10
+ import type { OnStaleMode } from '../coordination/schema.js';
10
11
  export type ConflictKind = 'stale_context' | 'claim_held';
11
12
  /** Fields shared by every conflict shape. */
12
13
  interface ConflictBase {
@@ -134,7 +135,7 @@ export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<
134
135
  * detection. This preserves the legacy always-reject default for callers that
135
136
  * don't opt into `notify`.
136
137
  */
137
- export declare const defaultPolicy: ConflictPolicy;
138
+ export declare const defaultPolicy: (conflict: Conflict) => ConflictDecision;
138
139
  /**
139
140
  * Capability-gated preemption. An `claim_held` conflict is PREEMPTED when the
140
141
  * committer holds the `claim.preempt` operation in its capability allowlist
@@ -144,4 +145,45 @@ export declare const defaultPolicy: ConflictPolicy;
144
145
  * policy. The authorization is the capability, not an identity string.
145
146
  */
146
147
  export declare const capabilityPreemptPolicy: ConflictPolicy;
148
+ /**
149
+ * **Axis 3 — declared write-conflict disposition, per committer kind.**
150
+ *
151
+ * A model declares this in its schema (`conflict: { user: 'overwrite', agent:
152
+ * 'reject' }`); the generic engine interprets it at the commit chokepoint. It's
153
+ * pure data — the same `OnStaleMode` vocabulary used by write guards
154
+ * (`'reject' | 'overwrite' | 'notify'`) — so it serializes through the schema
155
+ * registry to the schema-agnostic server, which names no app model.
156
+ *
157
+ * Keys are the COMMITTER's participant kind (server-derived, forge-proof): an
158
+ * omitted kind falls through to the engine default. So `{ user: 'overwrite',
159
+ * agent: 'reject' }` reads "a human's write wins (never blocked); an agent's
160
+ * write yields", and `system` (unlisted) takes the default.
161
+ */
162
+ export interface ConflictAxis {
163
+ /** What happens when a human (`user` session) commits into a conflict. */
164
+ readonly user?: OnStaleMode;
165
+ /** What happens when an AI `agent` commits into a conflict. */
166
+ readonly agent?: OnStaleMode;
167
+ /** What happens when a `system` / automation actor commits into a conflict. */
168
+ readonly system?: OnStaleMode;
169
+ }
170
+ /**
171
+ * Interpret a declared {@link ConflictAxis} for a concrete conflict — pure and
172
+ * synchronous (no I/O), so it runs on either side of the schema-agnostic
173
+ * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
174
+ *
175
+ * - undefined → the engine default (`defaultPolicy`: reject; honor
176
+ * `onStale: 'notify'` on a stale write)
177
+ * - `overwrite` → `allow` — the write wins / the committer is never blocked
178
+ * - `reject` → `reject` — the committer yields
179
+ * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
180
+ * on `claim_held`: notify has no held op to reconcile against
181
+ * (see {@link ConflictDecision} `notify`), so degrade to
182
+ * `reject` rather than silently allowing a claimed-row write.
183
+ *
184
+ * NOTE: this is a generic interpretation only. Server-side invariants (e.g. an
185
+ * agent may never bypass a foreign claim) are enforced where the decision is
186
+ * applied, not here.
187
+ */
188
+ export declare function interpretConflictAxis(axis: ConflictAxis, conflict: Conflict): ConflictDecision;
147
189
  export {};
@@ -20,14 +20,19 @@
20
20
  * detection. This preserves the legacy always-reject default for callers that
21
21
  * don't opt into `notify`.
22
22
  */
23
- export const defaultPolicy = (conflict) => {
23
+ // Typed by its real SYNCHRONOUS shape (via `satisfies`) rather than the
24
+ // async-permissive `ConflictPolicy` alias, so sync callers like
25
+ // `interpretConflictAxis` and `capabilityPreemptPolicy` get a plain
26
+ // `ConflictDecision` back (not `… | Promise<…>`). Still assignable to
27
+ // `ConflictPolicy` everywhere it's used as a policy.
28
+ export const defaultPolicy = ((conflict) => {
24
29
  if (conflict.kind !== 'stale_context') {
25
30
  return { action: 'reject', reason: 'claim_conflict' };
26
31
  }
27
32
  return conflict.requestedMode === 'notify'
28
33
  ? { action: 'notify', reason: 'stale_notify_hold' }
29
34
  : { action: 'reject', reason: 'stale_context' };
30
- };
35
+ });
31
36
  /**
32
37
  * Capability-gated preemption. An `claim_held` conflict is PREEMPTED when the
33
38
  * committer holds the `claim.preempt` operation in its capability allowlist
@@ -43,3 +48,45 @@ export const capabilityPreemptPolicy = (conflict) => {
43
48
  }
44
49
  return defaultPolicy(conflict);
45
50
  };
51
+ /**
52
+ * Interpret a declared {@link ConflictAxis} for a concrete conflict — pure and
53
+ * synchronous (no I/O), so it runs on either side of the schema-agnostic
54
+ * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
55
+ *
56
+ * - undefined → the engine default (`defaultPolicy`: reject; honor
57
+ * `onStale: 'notify'` on a stale write)
58
+ * - `overwrite` → `allow` — the write wins / the committer is never blocked
59
+ * - `reject` → `reject` — the committer yields
60
+ * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
61
+ * on `claim_held`: notify has no held op to reconcile against
62
+ * (see {@link ConflictDecision} `notify`), so degrade to
63
+ * `reject` rather than silently allowing a claimed-row write.
64
+ *
65
+ * NOTE: this is a generic interpretation only. Server-side invariants (e.g. an
66
+ * agent may never bypass a foreign claim) are enforced where the decision is
67
+ * applied, not here.
68
+ */
69
+ export function interpretConflictAxis(axis, conflict) {
70
+ const mode = axis[conflict.committer.kind];
71
+ if (mode === undefined)
72
+ return defaultPolicy(conflict);
73
+ switch (mode) {
74
+ case 'overwrite':
75
+ return { action: 'allow', note: 'conflict:overwrite' };
76
+ case 'reject':
77
+ return {
78
+ action: 'reject',
79
+ reason: conflict.kind === 'claim_held' ? 'claim_conflict' : 'stale_context',
80
+ };
81
+ case 'notify':
82
+ return conflict.kind === 'stale_context'
83
+ ? { action: 'notify', reason: 'stale_notify_hold' }
84
+ : { action: 'reject', reason: 'claim_conflict' };
85
+ default: {
86
+ // Exhaustiveness backstop: a future `OnStaleMode` member surfaces as a
87
+ // localized compile error here, not a missing-return at the signature.
88
+ const _exhaustive = mode;
89
+ return _exhaustive;
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Coordination authoring helpers for the model `conflict` axis.
3
+ *
4
+ * Composable disposition functions + a `cn`/`cx`-style combinator, so a model
5
+ * declares conflict behaviour the way the rest of the DSL reads
6
+ * (`relation.belongsTo()`, `field.string()`) — and the way modern libraries
7
+ * compose config (Better Auth's `plugins: [admin(), twoFactor()]`, shadcn's
8
+ * `cx(a, b)`) — instead of a raw disposition map:
9
+ *
10
+ * ```ts
11
+ * import { coordination, humansOverwrite, agentsReject } from '@abloatai/ablo/schema';
12
+ *
13
+ * conflict: coordination(humansOverwrite(), agentsReject())
14
+ * // → { user: 'overwrite', agent: 'reject' } (a human's write wins, an agent's yields)
15
+ * ```
16
+ *
17
+ * Each helper is named for the exact disposition it applies — the same
18
+ * `overwrite | reject | notify` vocabulary used by write guards (`onStale`) —
19
+ * and returns a partial {@link ConflictAxis}. {@link coordination} merges them
20
+ * (later rules win on key collisions). The result is plain, serializable data —
21
+ * the engine interpreter and schema round-trip are unchanged; this is only a
22
+ * nicer authoring surface.
23
+ */
24
+ import type { ConflictAxis } from '../policy/types.js';
25
+ /**
26
+ * One coordination rule: a partial {@link ConflictAxis} produced by a
27
+ * disposition helper below. Compose with {@link coordination}.
28
+ */
29
+ export type ConflictRule = ConflictAxis;
30
+ /** A human's conflicting write OVERWRITES — wins; never blocked (LWW among humans). */
31
+ export declare const humansOverwrite: () => ConflictRule;
32
+ /** A human's conflicting write is REJECTED — yields to a held claim / stale snapshot. */
33
+ export declare const humansReject: () => ConflictRule;
34
+ /** A human's stale write NOTIFIES — re-reads & re-applies instead of clobbering. */
35
+ export declare const humansNotify: () => ConflictRule;
36
+ /** An agent's conflicting write OVERWRITES — wins (rarely wanted). */
37
+ export declare const agentsOverwrite: () => ConflictRule;
38
+ /** An agent's conflicting write is REJECTED — yields to a held claim / stale snapshot. */
39
+ export declare const agentsReject: () => ConflictRule;
40
+ /** An agent's stale write NOTIFIES — re-reads & re-applies instead of clobbering. */
41
+ export declare const agentsNotify: () => ConflictRule;
42
+ /** A system/automation conflicting write OVERWRITES. */
43
+ export declare const systemOverwrite: () => ConflictRule;
44
+ /** A system/automation conflicting write is REJECTED. */
45
+ export declare const systemReject: () => ConflictRule;
46
+ /** A system/automation stale write NOTIFIES (re-read & re-apply). */
47
+ export declare const systemNotify: () => ConflictRule;
48
+ /**
49
+ * Merge coordination rules into one {@link ConflictAxis} — the `cn`/`cx` of
50
+ * conflict policy. Later rules win on key collisions; an omitted committer kind
51
+ * falls through to the engine default at commit time.
52
+ *
53
+ * ```ts
54
+ * coordination(humansOverwrite(), agentsReject()) // → { user: 'overwrite', agent: 'reject' }
55
+ * ```
56
+ */
57
+ export declare function coordination(...rules: readonly ConflictRule[]): ConflictAxis;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Coordination authoring helpers for the model `conflict` axis.
3
+ *
4
+ * Composable disposition functions + a `cn`/`cx`-style combinator, so a model
5
+ * declares conflict behaviour the way the rest of the DSL reads
6
+ * (`relation.belongsTo()`, `field.string()`) — and the way modern libraries
7
+ * compose config (Better Auth's `plugins: [admin(), twoFactor()]`, shadcn's
8
+ * `cx(a, b)`) — instead of a raw disposition map:
9
+ *
10
+ * ```ts
11
+ * import { coordination, humansOverwrite, agentsReject } from '@abloatai/ablo/schema';
12
+ *
13
+ * conflict: coordination(humansOverwrite(), agentsReject())
14
+ * // → { user: 'overwrite', agent: 'reject' } (a human's write wins, an agent's yields)
15
+ * ```
16
+ *
17
+ * Each helper is named for the exact disposition it applies — the same
18
+ * `overwrite | reject | notify` vocabulary used by write guards (`onStale`) —
19
+ * and returns a partial {@link ConflictAxis}. {@link coordination} merges them
20
+ * (later rules win on key collisions). The result is plain, serializable data —
21
+ * the engine interpreter and schema round-trip are unchanged; this is only a
22
+ * nicer authoring surface.
23
+ */
24
+ // ── Humans (user sessions) ──────────────────────────────────────────────
25
+ /** A human's conflicting write OVERWRITES — wins; never blocked (LWW among humans). */
26
+ export const humansOverwrite = () => ({ user: 'overwrite' });
27
+ /** A human's conflicting write is REJECTED — yields to a held claim / stale snapshot. */
28
+ export const humansReject = () => ({ user: 'reject' });
29
+ /** A human's stale write NOTIFIES — re-reads & re-applies instead of clobbering. */
30
+ export const humansNotify = () => ({ user: 'notify' });
31
+ // ── Agents (AI) ─────────────────────────────────────────────────────────
32
+ /** An agent's conflicting write OVERWRITES — wins (rarely wanted). */
33
+ export const agentsOverwrite = () => ({ agent: 'overwrite' });
34
+ /** An agent's conflicting write is REJECTED — yields to a held claim / stale snapshot. */
35
+ export const agentsReject = () => ({ agent: 'reject' });
36
+ /** An agent's stale write NOTIFIES — re-reads & re-applies instead of clobbering. */
37
+ export const agentsNotify = () => ({ agent: 'notify' });
38
+ // ── System / automation ─────────────────────────────────────────────────
39
+ /** A system/automation conflicting write OVERWRITES. */
40
+ export const systemOverwrite = () => ({ system: 'overwrite' });
41
+ /** A system/automation conflicting write is REJECTED. */
42
+ export const systemReject = () => ({ system: 'reject' });
43
+ /** A system/automation stale write NOTIFIES (re-read & re-apply). */
44
+ export const systemNotify = () => ({ system: 'notify' });
45
+ /**
46
+ * Merge coordination rules into one {@link ConflictAxis} — the `cn`/`cx` of
47
+ * conflict policy. Later rules win on key collisions; an omitted committer kind
48
+ * falls through to the engine default at commit time.
49
+ *
50
+ * ```ts
51
+ * coordination(humansOverwrite(), agentsReject()) // → { user: 'overwrite', agent: 'reject' }
52
+ * ```
53
+ */
54
+ export function coordination(...rules) {
55
+ return Object.assign({}, ...rules);
56
+ }
@@ -27,7 +27,8 @@ export { tenancySchema, scopedViaRefSchema, policyInputSchema, resolvePolicy, re
27
27
  export { planeSchema, DEFAULT_PLANE, type SchemaPlane } from './plane.js';
28
28
  export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_PLANES, type SyncDeltaCore, type DeltaAttribution, type DeltaProvenance, type SyncDeltaRow, type ParticipantKind, type ConfirmationState, type BackfillProvenance, } from './sync-delta-row.js';
29
29
  export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, type SyncDeltaAction, type WireDeltaData, type ParticipantRef, type SyncDeltaWireCore, type ClientSyncDelta, type ServerSyncDelta, } from './sync-delta-wire.js';
30
- export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, } from './model.js';
30
+ export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, type ConflictAxis, } from './model.js';
31
+ export { coordination, humansOverwrite, humansReject, humansNotify, agentsOverwrite, agentsReject, agentsNotify, systemOverwrite, systemReject, systemNotify, type ConflictRule, } from './coordination.js';
31
32
  export { mutable, readOnly, type SugarOptions } from './sugar.js';
32
33
  export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, } from './schema.js';
33
34
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
@@ -41,6 +41,9 @@ export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syn
41
41
  export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, } from './sync-delta-wire.js';
42
42
  // Model builder
43
43
  export { model, scopeKindOf, } from './model.js';
44
+ // Axis 3 — coordination authoring helpers for the `conflict` axis (composable
45
+ // disposition fns + a `cn`-style combinator).
46
+ export { coordination, humansOverwrite, humansReject, humansNotify, agentsOverwrite, agentsReject, agentsNotify, systemOverwrite, systemReject, systemNotify, } from './coordination.js';
44
47
  // Claim-first shorthand: `mutable.lazy({...})` and friends. Read the
45
48
  // safety posture and load shape off the verb tokens; everything else
46
49
  // falls back to sensible defaults. See sugar.ts for the full pattern.
@@ -23,6 +23,8 @@ import { type FieldMeta } from './field.js';
23
23
  import { type Tenancy, type PolicyInput } from './tenancy.js';
24
24
  export type { ScopedViaRef, Tenancy, PolicyInput } from './tenancy.js';
25
25
  import { type SchemaPlane } from './plane.js';
26
+ import type { ConflictAxis } from '../policy/types.js';
27
+ export type { ConflictAxis } from '../policy/types.js';
26
28
  /**
27
29
  * Controls when model data is loaded from the server.
28
30
  *
@@ -147,6 +149,28 @@ export interface ModelOptions {
147
149
  * ```
148
150
  */
149
151
  groups?: GroupsInput;
152
+ /**
153
+ * **Axis 3 — write-conflict disposition, per committer kind.** Decides what
154
+ * happens when a commit collides with a foreign claim or a stale snapshot on
155
+ * this model — orthogonal to {@link policy} (read access) and {@link groups}
156
+ * (delta routing). A plain map keyed by the COMMITTER's participant kind
157
+ * (`user` / `agent` / `system`), with the `onStale` vocabulary as values:
158
+ *
159
+ * - `'overwrite'` — the write wins; that committer is never blocked.
160
+ * - `'reject'` — the write is refused; that committer yields.
161
+ * - `'notify'` — hold the write and hand back the current value so the
162
+ * committer re-reads and re-applies (stale writes only).
163
+ *
164
+ * An omitted kind falls through to the engine default (reject; honor
165
+ * `onStale: 'notify'`). It is pure data (serializes through the schema
166
+ * registry to the server) and the generic engine interprets it — so e.g.
167
+ *
168
+ * ```ts
169
+ * // "a human's edit always wins (never blocked); an agent yields"
170
+ * conflict: { user: 'overwrite', agent: 'reject' }
171
+ * ```
172
+ */
173
+ conflict?: ConflictAxis;
150
174
  /**
151
175
  * Whether clients may issue CREATE/UPDATE/DELETE mutations for this
152
176
  * model via the `commit` wire protocol. Default: **true** — declaring a
@@ -301,6 +325,9 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
301
325
  readonly grants?: GrantsRef;
302
326
  /** Explicit non-relational record→group roles (normalized to an array). See {@link ModelOptions.entityRoles}. */
303
327
  readonly entityRoles?: readonly EntityRole[];
328
+ /** Axis 3 — declared write-conflict disposition per committer kind. Already
329
+ * canonical pure data (unlike `policy`, no resolve step). See {@link ModelOptions.conflict}. */
330
+ readonly conflict?: ConflictAxis;
304
331
  /** Whether wire-level CREATE/UPDATE/DELETE is allowed. See {@link ModelOptions.mutable}. */
305
332
  readonly mutable?: boolean;
306
333
  /** Defer MobX setup until first observer access. See {@link ModelOptions.lazyObservable}. */
@@ -99,6 +99,9 @@ export function model(shape, relations, options) {
99
99
  scope: options?.groups?.root,
100
100
  grants: options?.groups?.grants,
101
101
  entityRoles: normalizeEntityRoles(options?.groups?.roles),
102
+ // Axis 3 — already canonical pure data (a per-kind disposition map), so it
103
+ // passes through verbatim; no resolve step like `resolvePolicy`.
104
+ conflict: options?.conflict,
102
105
  mutable: options?.mutable ?? true,
103
106
  lazyObservable: options?.lazyObservable,
104
107
  computed: options?.computed,
@@ -14,9 +14,11 @@
14
14
  * What round-trips:
15
15
  * - all model routing/scoping metadata (typename, tableName, load,
16
16
  * mutable, the canonical `tenancy` descriptor, bootstrap hints, scope,
17
- * grants, entityRoles, persist, autoFill, requiredFields, lazyObservable).
17
+ * grants, entityRoles, the `conflict` disposition map, persist, autoFill,
18
+ * requiredFields, lazyObservable).
18
19
  * NOTE: the authoring sugar (`policy`/`groups`) is normalized away at
19
- * `model()`-build; only the canonical wire fields cross here.
20
+ * `model()`-build; only the canonical wire fields cross here. `conflict`
21
+ * is already canonical pure data, so it crosses verbatim.
20
22
  * - relations (incl. resolved `foreignKeyColumn`)
21
23
  * - field metadata (names + type tags), from which validators are rebuilt
22
24
  * - identity roles (already pure data)
@@ -29,7 +31,7 @@
29
31
  import type { FieldMeta } from './field.js';
30
32
  import type { Tenancy } from './tenancy.js';
31
33
  import type { SchemaPlane } from './plane.js';
32
- import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
34
+ import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule, ConflictAxis } from './model.js';
33
35
  import type { RelationType } from './relation.js';
34
36
  import { type Schema, type SchemaRecord, type IdentityRole, type EntityRole } from './schema.js';
35
37
  /** Current schema-JSON envelope version. Bump on a breaking change to the
@@ -60,6 +62,9 @@ export interface ModelJSON {
60
62
  readonly scope?: boolean | string;
61
63
  readonly grants?: GrantsRef;
62
64
  readonly entityRoles?: readonly EntityRole[];
65
+ /** Axis 3 — declared write-conflict disposition per committer kind. Pure data;
66
+ * absent in pre-conflict-axis artifacts → undefined → engine default. */
67
+ readonly conflict?: ConflictAxis;
63
68
  readonly bootstrapLimit?: number;
64
69
  readonly bootstrapOrderBy?: string;
65
70
  readonly mutable?: boolean;
@@ -14,9 +14,11 @@
14
14
  * What round-trips:
15
15
  * - all model routing/scoping metadata (typename, tableName, load,
16
16
  * mutable, the canonical `tenancy` descriptor, bootstrap hints, scope,
17
- * grants, entityRoles, persist, autoFill, requiredFields, lazyObservable).
17
+ * grants, entityRoles, the `conflict` disposition map, persist, autoFill,
18
+ * requiredFields, lazyObservable).
18
19
  * NOTE: the authoring sugar (`policy`/`groups`) is normalized away at
19
- * `model()`-build; only the canonical wire fields cross here.
20
+ * `model()`-build; only the canonical wire fields cross here. `conflict`
21
+ * is already canonical pure data, so it crosses verbatim.
20
22
  * - relations (incl. resolved `foreignKeyColumn`)
21
23
  * - field metadata (names + type tags), from which validators are rebuilt
22
24
  * - identity roles (already pure data)
@@ -64,6 +66,7 @@ function modelToJSON(def) {
64
66
  scope: def.scope,
65
67
  grants: def.grants,
66
68
  entityRoles: def.entityRoles,
69
+ conflict: def.conflict,
67
70
  bootstrapLimit: def.bootstrapLimit,
68
71
  bootstrapOrderBy: def.bootstrapOrderBy,
69
72
  mutable: def.mutable,
@@ -171,6 +174,9 @@ function modelFromJSON(json) {
171
174
  scope: json.scope,
172
175
  grants: json.grants,
173
176
  entityRoles: json.entityRoles,
177
+ // Axis 3 — pure data; absent in pre-conflict-axis artifacts → undefined →
178
+ // the commit path falls through to the function registry / engine default.
179
+ conflict: json.conflict,
174
180
  mutable: json.mutable,
175
181
  lazyObservable: json.lazyObservable,
176
182
  // computed getters are closures and intentionally not serialized; a
@@ -114,4 +114,13 @@ export interface CommitResult {
114
114
  * `StaleNotification` in `coordination/schema.ts`.
115
115
  */
116
116
  notifications?: StaleNotification[];
117
+ /**
118
+ * Ids of UPDATE/DELETE targets that matched ZERO rows — the row doesn't
119
+ * exist (or is outside the caller's org). The engine has always detected
120
+ * this (and logged it); surfacing it here lets the client turn a silent
121
+ * no-op into a loud `AbloNotFoundError`. Present (non-empty) only when at
122
+ * least one op missed. Ids are globally-unique uuids, so a caller can match
123
+ * its own target id against this set without ambiguity.
124
+ */
125
+ missingIds?: string[];
117
126
  }
@@ -122,6 +122,13 @@ export interface MutationResultMessage {
122
122
  * `conflict:notified` event and the commit receipt instead of rejecting.
123
123
  */
124
124
  notifications?: StaleNotification[];
125
+ /**
126
+ * Ids of UPDATE/DELETE targets that matched ZERO rows (don't exist or are
127
+ * outside the org). Present (non-empty) only when a write missed. The
128
+ * client turns this into a loud `AbloNotFoundError` for the affected
129
+ * caller instead of treating the no-op as success.
130
+ */
131
+ missingIds?: string[];
125
132
  error?: {
126
133
  code: ErrorCode;
127
134
  message: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",