@abloatai/ablo 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +3 -3
  4. package/dist/BaseSyncedStore.js +39 -32
  5. package/dist/batching/index.d.ts +57 -0
  6. package/dist/batching/index.js +150 -0
  7. package/dist/cli.cjs +158 -40
  8. package/dist/client/Ablo.d.ts +16 -25
  9. package/dist/client/Ablo.js +1 -1
  10. package/dist/client/auth.js +11 -0
  11. package/dist/client/createModelProxy.d.ts +33 -8
  12. package/dist/client/createModelProxy.js +4 -4
  13. package/dist/errorCodes.d.ts +3 -1
  14. package/dist/errorCodes.js +10 -1
  15. package/dist/schema/index.d.ts +2 -2
  16. package/dist/schema/index.js +2 -2
  17. package/dist/schema/model.d.ts +38 -84
  18. package/dist/schema/model.js +12 -12
  19. package/dist/schema/roles.d.ts +49 -0
  20. package/dist/schema/roles.js +21 -0
  21. package/dist/schema/schema.d.ts +1 -1
  22. package/dist/schema/schema.js +1 -1
  23. package/dist/schema/serialize.d.ts +4 -2
  24. package/dist/schema/serialize.js +4 -2
  25. package/dist/schema/sugar.d.ts +7 -28
  26. package/dist/schema/sugar.js +2 -7
  27. package/dist/schema/sync-delta-row.d.ts +2 -0
  28. package/dist/schema/sync-delta-row.js +2 -1
  29. package/dist/schema/tenancy.d.ts +67 -28
  30. package/dist/schema/tenancy.js +93 -23
  31. package/dist/server/commit.d.ts +8 -3
  32. package/docs/api.md +7 -6
  33. package/docs/cli.md +43 -4
  34. package/docs/client-behavior.md +2 -2
  35. package/docs/coordination.md +12 -12
  36. package/docs/examples/agent-human.md +6 -6
  37. package/docs/examples/ai-sdk-tool.md +1 -1
  38. package/docs/examples/existing-python-backend.md +0 -2
  39. package/docs/examples/nextjs.md +2 -2
  40. package/docs/examples/scoped-agent.md +3 -3
  41. package/docs/examples/server-agent.md +4 -4
  42. package/docs/identity.md +27 -20
  43. package/docs/index.md +0 -1
  44. package/docs/integration-guide.md +12 -9
  45. package/docs/interaction-model.md +1 -1
  46. package/docs/mcp.md +17 -5
  47. package/docs/quickstart.md +3 -3
  48. package/docs/react.md +69 -0
  49. package/llms.txt +2 -3
  50. package/package.json +8 -2
  51. package/docs/mcp/claude-code.md +0 -35
  52. package/docs/mcp/cursor.md +0 -35
  53. package/docs/mcp/windsurf.md +0 -33
  54. package/docs/roadmap.md +0 -55
  55. package/docs/the-loop.md +0 -21
  56. package/llms-full.txt +0 -396
@@ -27,7 +27,7 @@ export { field, indexed, getFieldMeta } from './field.js';
27
27
  // Relation builders
28
28
  export { relation } from './relation.js';
29
29
  // Tenancy — the single source of truth for how a model's rows are tenant-scoped.
30
- export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
30
+ export { tenancySchema, scopedViaRefSchema, policyInputSchema, resolvePolicy, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
31
31
  // Database plane — which DB a model's rows live in (`tenant` portable to a BYO
32
32
  // customer DB, `control` = Ablo's own). Sibling axis to `tenancy`.
33
33
  export { planeSchema, DEFAULT_PLANE } from './plane.js';
@@ -46,7 +46,7 @@ export { model, scopeKindOf, } from './model.js';
46
46
  // falls back to sensible defaults. See sugar.ts for the full pattern.
47
47
  export { mutable, readOnly } from './sugar.js';
48
48
  // Schema definition + type inference
49
- export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
49
+ export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, } from './schema.js';
50
50
  // Schema ⇄ JSON (control-plane transport for hosted multi-tenant)
51
51
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
52
52
  // Schema projection — derive an app's subset from one canonical schema.
@@ -18,10 +18,10 @@
18
18
  */
19
19
  import { z } from 'zod';
20
20
  import type { RelationDef } from './relation.js';
21
- import type { EntityRole } from './roles.js';
21
+ import type { EntityRole, GroupsInput } from './roles.js';
22
22
  import { type FieldMeta } from './field.js';
23
- import { type Tenancy, type ScopedViaRef } from './tenancy.js';
24
- export type { ScopedViaRef, Tenancy } from './tenancy.js';
23
+ import { type Tenancy, type PolicyInput } from './tenancy.js';
24
+ export type { ScopedViaRef, Tenancy, PolicyInput } from './tenancy.js';
25
25
  import { type SchemaPlane } from './plane.js';
26
26
  /**
27
27
  * Controls when model data is loaded from the server.
@@ -95,49 +95,27 @@ export interface ModelOptions {
95
95
  */
96
96
  tableName?: string;
97
97
  /**
98
- * Whether this model's table has an `organization_id` column. Default: true.
99
- * When false, the bootstrap/read query omits the `WHERE organization_id = $1`
100
- * tenant filter for this model.
98
+ * **Axis 1 row-access policy (tenant isolation / RLS).** Decides who may
99
+ * *read* a row at all. Named after Postgres/Supabase, where a `policy` is the
100
+ * rule that scopes which rows a tenant sees. A discriminated union on `by` —
101
+ * one option replacing the old `orgScoped`/`scopedVia`/`orgColumn` trio:
101
102
  *
102
- * SECURITY — `orgScoped: false` makes the table GLOBALLY READABLE: every
103
- * client of every tenant sees every row. It is ONLY correct for genuinely
104
- * tenant-less tables (the `organizations` table itself, global lookups). If
105
- * rows belong to a tenant through a foreign key but this table has no
106
- * `organization_id` of its own, use {@link scopedVia} INSTEAD reaching for
107
- * `orgScoped: false` there silently exposes the entire table cross-tenant.
108
- */
109
- orgScoped?: boolean;
110
- /**
111
- * Scope rows via a parent table when THIS table has no
112
- * `organization_id` column of its own, but rows still belong to a
113
- * tenant via a foreign key (e.g. `memberRoles.member_id member.id`,
114
- * `teamMember.team_id → team.id`, `users.id ← member.user_id`).
115
- *
116
- * Emits, in place of the missing `organization_id = $1` clause:
117
- *
118
- * WHERE <table>.<localKey> IN
119
- * (SELECT <parentKey> FROM <parentTable> WHERE <parentOrgColumn> = $1)
120
- *
121
- * Use this INSTEAD of `orgScoped: false` for any `load: 'instant'`
122
- * model whose rows would otherwise leak cross-tenant on bootstrap —
123
- * dropping the filter entirely exposes the entire DB to every client.
103
+ * - `{ by: 'column' }` row-local tenancy column (the DEFAULT when omitted).
104
+ * `column` overrides the name (default `organization_id`).
105
+ * - `{ by: 'parent', fk, parent }` inherit tenancy through a foreign key
106
+ * when THIS table has no tenancy column of its own (e.g. `slide_layers`
107
+ * slide deck org). Emits, in place of `organization_id = $1`:
108
+ * `WHERE <table>.<fk> IN (SELECT <parentKey> FROM <parent> WHERE
109
+ * <parentTenantColumn> = $1)`. Use this for any `load: 'instant'` child
110
+ * table that would otherwise leak cross-tenant on bootstrap.
111
+ * - `{ by: 'none' }` — genuinely global / reference data (the `organizations`
112
+ * table itself, global lookups). Makes the whole table readable
113
+ * cross-tenant only correct for tenant-less tables. Because it's an
114
+ * explicit, named branch (not a falsy flag) it can't be reached by accident.
124
115
  *
125
- * Identifiers must match the regular `[a-zA-Z_][a-zA-Z0-9_]*` shape;
126
- * the SQL compiler validates them to keep this away from injection
127
- * paths.
116
+ * Normalized into the canonical {@link Tenancy} by `resolvePolicy` at build.
128
117
  */
129
- scopedVia?: ScopedViaRef;
130
- /**
131
- * Override the physical tenancy column name (default `organization_id`).
132
- * Authoring sugar — normalized into the canonical {@link tenancy} descriptor.
133
- */
134
- orgColumn?: string;
135
- /**
136
- * Canonical tenancy descriptor. You normally don't set this — `orgScoped`,
137
- * `scopedVia`, and `orgColumn` are normalized into it at build time. Set it
138
- * directly only to author the union form explicitly.
139
- */
140
- tenancy?: Tenancy;
118
+ policy?: PolicyInput;
141
119
  /**
142
120
  * Which database plane this model's rows live in. `tenant` (default) =
143
121
  * the tenant data plane, emitted into a customer's BYO/dedicated DB by
@@ -146,53 +124,29 @@ export interface ModelOptions {
146
124
  */
147
125
  plane?: SchemaPlane;
148
126
  /**
149
- * Marks this model as a **scope root** a model that forms a sync group of
150
- * its own. A scope-root record lives in the group `<kind>:<id>`, where `kind`
151
- * defaults to the lowercased `typename` (so a `Deck` → `deck:<id>`). Pass a
152
- * string to override the kind explicitly (`scope: 'matter'`).
153
- *
154
- * Replaces the old `syncGroupFormat` template string: there is no `{id}`
155
- * placeholder to author — the engine mints the branded {@link SyncGroup} from
156
- * `(kind, id)`. Child models inherit a root's group via their `belongsTo`
157
- * relations (a `document` with `dataroomId` fans into `dataroom:<id>`), so
158
- * only the root declares anything.
159
- */
160
- scope?: boolean | string;
161
- /**
162
- * Declares this model as a **membership edge** that grants an identity access
163
- * to a scope root — the relation-driven equivalent of "this user can see that
164
- * dataroom." Both values are *relation names* already declared on this model:
165
- * `subject` is the `belongsTo` to the identity (e.g. a `user`), `scope` is the
166
- * `belongsTo` to the scope-root entity (e.g. a `dataroom`).
127
+ * **Axis 2 sync-group routing.** Decides which delta *channels* a row fans
128
+ * into. Orthogonal to {@link policy} (read access). One namespaced object
129
+ * replacing the old flat `scope`/`grants`/`entityRoles`:
167
130
  *
168
- * The server's membership resolver reads this at connect time for identity
169
- * `U`, it queries `WHERE <subject FK> = U` and adds `<scopeKind>:<scope FK>`
170
- * to the identity's subscribed groups (Linear's `/sync/user_sync_groups`).
131
+ * - `root` mark this model a scope root; its records form the group
132
+ * `<kind>:<id>` (kind defaults from the lowercased typename, e.g. `Deck`
133
+ * `deck:<id>`; pass a string to override, `root: 'matter'`). Child models
134
+ * inherit a root's group via their `belongsTo` relations. Was `scope` —
135
+ * renamed so it no longer collides with the old `scopedVia` tenancy sugar.
136
+ * - `grants` — a membership edge granting an identity access to a scope root.
137
+ * Both values name `belongsTo` relations on this model (`subject` → identity,
138
+ * `scope` → scope root). Only needed for sub-org sharing.
139
+ * - `roles` — explicit non-relational record→group roles (the inbox-fan-out
140
+ * escape hatch, keyed on a plain field). Was `entityRoles`. One or many.
171
141
  *
172
142
  * ```ts
173
143
  * // dataroomMember: { userId, dataroomId }
174
- * grants: { subject: 'user', scope: 'dataroom' } // both are relation names
175
- * ```
176
- *
177
- * Not needed for org-level access — a `dataroom.organizationId` already routes
178
- * via the `org:<id>` group. `grants` is only for sub-org membership.
179
- */
180
- grants?: GrantsRef;
181
- /**
182
- * Explicit record→group roles for routing that isn't relational — a group
183
- * keyed on a plain field rather than the record's own id or a `belongsTo`
184
- * scope root. The escape hatch for cases like per-recipient inbox fan-out:
185
- *
186
- * ```ts
187
- * // a message routes to its addressee's inbox, keyed on `toId`
188
- * entityRoles: [entityRole({ kind: 'inbox', source: 'toId' })]
144
+ * groups: { grants: { subject: 'user', scope: 'dataroom' } }
145
+ * // a message → its addressee's inbox, keyed on `toId`
146
+ * groups: { roles: [entityRole({ kind: 'inbox', source: 'toId' })] }
189
147
  * ```
190
- *
191
- * Prefer {@link scope} (self group) + `belongsTo` relations (parent groups)
192
- * when the routing follows the relation graph — reach for `entityRoles` only
193
- * when it genuinely doesn't.
194
148
  */
195
- entityRoles?: EntityRole | readonly EntityRole[];
149
+ groups?: GroupsInput;
196
150
  /**
197
151
  * Whether clients may issue CREATE/UPDATE/DELETE mutations for this
198
152
  * model via the `commit` wire protocol. Default: **true** — declaring a
@@ -20,8 +20,10 @@ import { z } from 'zod';
20
20
  import { getFieldMeta, inferFieldMetaFromZod } from './field.js';
21
21
  // Tenancy is owned by `tenancy.ts` (single source of truth). `ScopedViaRef` is
22
22
  // re-exported so existing `import { ScopedViaRef } from './model'` call sites
23
- // keep resolving while consumers migrate.
24
- import { resolveTenancy } from './tenancy.js';
23
+ // keep resolving. Authoring uses the `policy` option (`PolicyInput`, named for
24
+ // Postgres/Supabase RLS), normalized to the canonical `Tenancy` by
25
+ // `resolvePolicy` at build time.
26
+ import { resolvePolicy } from './tenancy.js';
25
27
  import { DEFAULT_PLANE } from './plane.js';
26
28
  /** Normalize the `entityRoles` option (single | array | undefined) to an array. */
27
29
  function normalizeEntityRoles(input) {
@@ -88,17 +90,15 @@ export function model(shape, relations, options) {
88
90
  typename: options?.typename,
89
91
  persist: options?.persist,
90
92
  tableName: options?.tableName,
91
- // Normalize all tenancy authoring sugar into the one canonical descriptor.
92
- tenancy: resolveTenancy({
93
- tenancy: options?.tenancy,
94
- orgScoped: options?.orgScoped,
95
- scopedVia: options?.scopedVia,
96
- orgColumn: options?.orgColumn,
97
- }),
93
+ // Axis 1 normalize the `policy` authoring option into the one canonical
94
+ // tenancy descriptor (defaults to a row-local org column).
95
+ tenancy: resolvePolicy(options?.policy),
98
96
  plane: options?.plane ?? DEFAULT_PLANE,
99
- scope: options?.scope,
100
- grants: options?.grants,
101
- entityRoles: normalizeEntityRoles(options?.entityRoles),
97
+ // Axis 2 — unpack the `groups` routing namespace into the wire fields the
98
+ // server reads (`scope`/`grants`/`entityRoles` on ModelDef/ModelJSON).
99
+ scope: options?.groups?.root,
100
+ grants: options?.groups?.grants,
101
+ entityRoles: normalizeEntityRoles(options?.groups?.roles),
102
102
  mutable: options?.mutable ?? true,
103
103
  lazyObservable: options?.lazyObservable,
104
104
  computed: options?.computed,
@@ -137,6 +137,55 @@ export declare const grantsRefSchema: z.ZodObject<{
137
137
  subject: z.ZodString;
138
138
  scope: z.ZodString;
139
139
  }, z.core.$strip>;
140
+ /**
141
+ * The AUTHORING form of a model's sync-group routing — the `groups: { ... }`
142
+ * option. One namespaced object collects the three independent routing knobs
143
+ * that used to be flat, collision-prone model options (`scope` / `grants` /
144
+ * `entityRoles`). Distinct axis from `policy` (tenant isolation): the policy
145
+ * decides who may *read* a row, `groups` decides which delta *channels* a row
146
+ * fans into.
147
+ *
148
+ * - `root` — mark this model a scope root; its records form `<kind>:<id>`
149
+ * (kind defaults from typename). Was the flat `scope` option — renamed to
150
+ * `root` so it no longer collides with the (now removed) `scopedVia` tenancy
151
+ * sugar or the inner `grants.scope` relation name.
152
+ * - `grants` — a membership edge granting an identity access to a scope root.
153
+ * - `roles` — explicit non-relational record→group roles (e.g. inbox fan-out
154
+ * keyed on a plain field). Was `entityRoles`. Accepts one role or an array.
155
+ */
156
+ export declare const groupsInputSchema: z.ZodObject<{
157
+ root: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>;
158
+ grants: z.ZodOptional<z.ZodObject<{
159
+ subject: z.ZodString;
160
+ scope: z.ZodString;
161
+ }, z.core.$strip>>;
162
+ roles: z.ZodOptional<z.ZodUnion<readonly [z.ZodType<{
163
+ kind: string;
164
+ source: {
165
+ field: string;
166
+ multi: boolean;
167
+ };
168
+ }, unknown, z.core.$ZodTypeInternals<{
169
+ kind: string;
170
+ source: {
171
+ field: string;
172
+ multi: boolean;
173
+ };
174
+ }, unknown>>, z.ZodArray<z.ZodType<{
175
+ kind: string;
176
+ source: {
177
+ field: string;
178
+ multi: boolean;
179
+ };
180
+ }, unknown, z.core.$ZodTypeInternals<{
181
+ kind: string;
182
+ source: {
183
+ field: string;
184
+ multi: boolean;
185
+ };
186
+ }, unknown>>>]>>;
187
+ }, z.core.$strip>;
188
+ export type GroupsInput = z.infer<typeof groupsInputSchema>;
140
189
  /** Build an identity-anchored role. `multi` defaults to `false`. */
141
190
  export declare function identityRole(spec: {
142
191
  readonly kind: string;
@@ -112,6 +112,27 @@ export const grantsRefSchema = z.object({
112
112
  subject: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, 'grants.subject must name a relation'),
113
113
  scope: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, 'grants.scope must name a relation'),
114
114
  });
115
+ /**
116
+ * The AUTHORING form of a model's sync-group routing — the `groups: { ... }`
117
+ * option. One namespaced object collects the three independent routing knobs
118
+ * that used to be flat, collision-prone model options (`scope` / `grants` /
119
+ * `entityRoles`). Distinct axis from `policy` (tenant isolation): the policy
120
+ * decides who may *read* a row, `groups` decides which delta *channels* a row
121
+ * fans into.
122
+ *
123
+ * - `root` — mark this model a scope root; its records form `<kind>:<id>`
124
+ * (kind defaults from typename). Was the flat `scope` option — renamed to
125
+ * `root` so it no longer collides with the (now removed) `scopedVia` tenancy
126
+ * sugar or the inner `grants.scope` relation name.
127
+ * - `grants` — a membership edge granting an identity access to a scope root.
128
+ * - `roles` — explicit non-relational record→group roles (e.g. inbox fan-out
129
+ * keyed on a plain field). Was `entityRoles`. Accepts one role or an array.
130
+ */
131
+ export const groupsInputSchema = z.object({
132
+ root: scopeSchema.optional(),
133
+ grants: grantsRefSchema.optional(),
134
+ roles: z.union([entityRoleSchema, z.array(entityRoleSchema)]).optional(),
135
+ });
115
136
  // ── Factories ───────────────────────────────────────────────────────────────
116
137
  function makeRole(spec) {
117
138
  return { kind: spec.kind, source: { field: spec.source, multi: spec.multi ?? false } };
@@ -23,7 +23,7 @@ import { z } from 'zod';
23
23
  import type { ModelDef, RelationRecord } from './model.js';
24
24
  import type { RelationDef } from './relation.js';
25
25
  import type { IdentityRole } from './roles.js';
26
- export { type IdentityRole, type IdentityRoleSource, type IdentityContext, type EntityRole, type EntityRoleSource, type EntityContext, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, composeIdentitySyncGroups, composeEntitySyncGroups, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './roles.js';
26
+ export { type IdentityRole, type IdentityRoleSource, type IdentityContext, type EntityRole, type EntityRoleSource, type EntityContext, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, composeIdentitySyncGroups, composeEntitySyncGroups, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, } from './roles.js';
27
27
  /** The set of built-in casing conventions supported by `defineSchema`. */
28
28
  export type CasingConvention = 'snake_case' | 'camelCase';
29
29
  /** Plug point for custom conventions (e.g. mixed legacy databases). */
@@ -25,7 +25,7 @@ import { scopeSchema, grantsRefSchema } from './roles.js';
25
25
  // Sync-group roles (identity + entity) live in `./roles.js`. Re-exported here
26
26
  // so the long-standing `@ablo/schema` / `./schema.js` import paths keep working
27
27
  // after the rehome — see roles.ts for the full vocabulary.
28
- export { identityRole, entityRole, extractIdentityIds, extractEntityIds, composeIdentitySyncGroups, composeEntitySyncGroups, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './roles.js';
28
+ export { identityRole, entityRole, extractIdentityIds, extractEntityIds, composeIdentitySyncGroups, composeEntitySyncGroups, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, } from './roles.js';
29
29
  function resolveCasing(fn) {
30
30
  if (fn === undefined)
31
31
  return (x) => x;
@@ -13,8 +13,10 @@
13
13
  *
14
14
  * What round-trips:
15
15
  * - all model routing/scoping metadata (typename, tableName, load,
16
- * mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
17
- * entityRoles, persist, autoFill, requiredFields, lazyObservable)
16
+ * mutable, the canonical `tenancy` descriptor, bootstrap hints, scope,
17
+ * grants, entityRoles, persist, autoFill, requiredFields, lazyObservable).
18
+ * NOTE: the authoring sugar (`policy`/`groups`) is normalized away at
19
+ * `model()`-build; only the canonical wire fields cross here.
18
20
  * - relations (incl. resolved `foreignKeyColumn`)
19
21
  * - field metadata (names + type tags), from which validators are rebuilt
20
22
  * - identity roles (already pure data)
@@ -13,8 +13,10 @@
13
13
  *
14
14
  * What round-trips:
15
15
  * - all model routing/scoping metadata (typename, tableName, load,
16
- * mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
17
- * entityRoles, persist, autoFill, requiredFields, lazyObservable)
16
+ * mutable, the canonical `tenancy` descriptor, bootstrap hints, scope,
17
+ * grants, entityRoles, persist, autoFill, requiredFields, lazyObservable).
18
+ * NOTE: the authoring sugar (`policy`/`groups`) is normalized away at
19
+ * `model()`-build; only the canonical wire fields cross here.
18
20
  * - relations (incl. resolved `foreignKeyColumn`)
19
21
  * - field metadata (names + type tags), from which validators are rebuilt
20
22
  * - identity roles (already pure data)
@@ -63,37 +63,16 @@ export interface SugarOptions<R extends RelationRecord = RelationRecord, C exten
63
63
  */
64
64
  tableName?: string;
65
65
  /**
66
- * Whether the table has an `organization_id` column. Default: `true`.
67
- * Set `false` for system-scoped tables (subscriptions, teams, etc.).
66
+ * Row-access policy (tenant isolation / RLS) who may *read* a row.
67
+ * Discriminated union on `by` (`column` | `parent` | `none`). See
68
+ * {@link ModelOptions.policy}.
68
69
  */
69
- orgScoped?: boolean;
70
+ policy?: ModelOptions['policy'];
70
71
  /**
71
- * Scope rows via a parent table when this table has no
72
- * `organization_id` column. See {@link ModelOptions.scopedVia}.
72
+ * Sync-group routing which delta *channels* a row fans into
73
+ * (`root` / `grants` / `roles`). See {@link ModelOptions.groups}.
73
74
  */
74
- scopedVia?: ModelOptions['scopedVia'];
75
- /**
76
- * Override the row-local tenancy column name. See
77
- * {@link ModelOptions.orgColumn}.
78
- */
79
- orgColumn?: ModelOptions['orgColumn'];
80
- /** Canonical tenancy descriptor. See {@link ModelOptions.tenancy}. */
81
- tenancy?: ModelOptions['tenancy'];
82
- /**
83
- * Mark this model a scope root — its records form the group `<kind>:<id>`
84
- * (kind defaults from typename). See {@link ModelOptions.scope}.
85
- */
86
- scope?: ModelOptions['scope'];
87
- /**
88
- * Membership edge granting identity → scope-root access. Both fields are
89
- * relation names on this model. See {@link ModelOptions.grants}.
90
- */
91
- grants?: ModelOptions['grants'];
92
- /**
93
- * Explicit non-relational record→group roles (e.g. inbox fan-out keyed on a
94
- * field). See {@link ModelOptions.entityRoles}.
95
- */
96
- entityRoles?: ModelOptions['entityRoles'];
75
+ groups?: ModelOptions['groups'];
97
76
  /** Max rows loaded during bootstrap. Only applies to `.instant`. */
98
77
  bootstrapLimit?: number;
99
78
  /** Bootstrap sort order (e.g. `'created_at DESC'`). */
@@ -46,13 +46,8 @@ function build(shape, opts, baseline) {
46
46
  lazyObservable: opts?.lazyObservable ?? baseline.lazyObservable,
47
47
  typename: opts?.typename,
48
48
  tableName: opts?.tableName,
49
- orgScoped: opts?.orgScoped,
50
- scopedVia: opts?.scopedVia,
51
- orgColumn: opts?.orgColumn,
52
- tenancy: opts?.tenancy,
53
- scope: opts?.scope,
54
- grants: opts?.grants,
55
- entityRoles: opts?.entityRoles,
49
+ policy: opts?.policy,
50
+ groups: opts?.groups,
56
51
  bootstrapLimit: opts?.bootstrapLimit,
57
52
  bootstrapOrderBy: opts?.bootstrapOrderBy,
58
53
  persist: opts?.persist,
@@ -85,6 +85,7 @@ export declare const deltaAttributionSchema: z.ZodObject<{
85
85
  system: "system";
86
86
  }>>;
87
87
  capabilityId: z.ZodNullable<z.ZodString>;
88
+ delegationChainRootUserId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
88
89
  confirmationState: z.ZodNullable<z.ZodEnum<{
89
90
  auto: "auto";
90
91
  previewed: "previewed";
@@ -129,6 +130,7 @@ export declare const syncDeltaRowSchema: z.ZodObject<{
129
130
  system: "system";
130
131
  }>>;
131
132
  capabilityId: z.ZodNullable<z.ZodString>;
133
+ delegationChainRootUserId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
132
134
  confirmationState: z.ZodNullable<z.ZodEnum<{
133
135
  auto: "auto";
134
136
  previewed: "previewed";
@@ -67,7 +67,7 @@ export const syncDeltaCoreSchema = z.object({
67
67
  createdAt: z.string().optional(),
68
68
  transactionId: z.string().nullable(),
69
69
  });
70
- // ── Attribution — `control` plane (→ agent_capability_roots) ──────────────────
70
+ // ── Attribution — `control` plane ─────────────────────────────────────────────
71
71
  export const deltaAttributionSchema = z.object({
72
72
  /** Legacy single-actor column, derived during the dual-write window. */
73
73
  createdBy: z.string().nullable(),
@@ -76,6 +76,7 @@ export const deltaAttributionSchema = z.object({
76
76
  onBehalfOfId: z.string().nullable(),
77
77
  onBehalfOfKind: participantKindSchema.nullable(),
78
78
  capabilityId: z.string().nullable(),
79
+ delegationChainRootUserId: z.string().nullable().optional(),
79
80
  confirmationState: confirmationStateSchema.nullable(),
80
81
  backfillProvenance: backfillProvenanceSchema.nullable(),
81
82
  });
@@ -1,22 +1,36 @@
1
1
  /**
2
2
  * Tenancy — the single source of truth for how a model's rows are scoped to a
3
- * tenant. This replaces three scattered mechanisms (a hardcoded
4
- * `organization_id` literal, an `orgScoped` boolean, and a `scopedVia` ref) with
5
- * one Zod discriminated union, resolved in one place and consumed everywhere
6
- * (provision/RLS, introspection, runtime, CLI).
3
+ * tenant. There are exactly two layers here, and keeping them separate is the
4
+ * whole point:
7
5
  *
8
- * Why a union: every consumer used to re-derive "how is this table scoped?" from
9
- * a flag plus a literal a missed branch was a silent cross-tenant scoping bug.
10
- * A discriminated union makes the `switch` exhaustive, so the type system holds
11
- * the isolation boundary, and the physical column name lives in exactly one
12
- * place (the `column` variant) instead of being hardcoded across the codebase.
6
+ * 1. The CANONICAL form {@link Tenancy}, a Zod discriminated union. This is
7
+ * what every consumer (provision/RLS, introspection, runtime, CLI) reads
8
+ * and what crosses the wire in `ModelJSON`. One shape, exhaustively
9
+ * switchable, so the type system holds the isolation boundary.
10
+ * 2. The AUTHORING form {@link PolicyInput}, the `policy: { by }` option a
11
+ * schema author writes. The name follows Postgres/Supabase RLS vocabulary:
12
+ * a `policy` is the rule that decides which rows a tenant may read.
13
+ * {@link resolvePolicy} maps it to the canonical {@link Tenancy} at
14
+ * `model()`-build time (much as Supabase's `create policy` compiles to a
15
+ * `pg_policy` row), so the authoring vocabulary never reaches the wire or
16
+ * any consumer.
17
+ *
18
+ * Why one authoring option (`policy`) instead of the old
19
+ * `orgScoped`/`scopedVia`/`orgColumn` trio: those three were synonyms for one
20
+ * decision ("how is this row scoped?"), and the most dangerous of them
21
+ * (`orgScoped: false`) silently exposed a whole table cross-tenant. Collapsing
22
+ * them into a single discriminated union makes the opt-out (`{ by: 'none' }`) a
23
+ * loud, deliberate branch instead of a falsy flag — one concept, one name.
13
24
  */
14
25
  import { z } from 'zod';
15
26
  /** Default physical tenancy column. The ONLY place this literal is canonical. */
16
27
  export declare const DEFAULT_ORG_COLUMN = "organization_id";
17
28
  /**
18
29
  * Scope a table's rows through a parent table (for rows that carry no tenancy
19
- * column of their own — e.g. `slide_layers` → slide → deck → org).
30
+ * column of their own — e.g. `slide_layers` → slide → deck → org). This is the
31
+ * CANONICAL `parent` payload; authors write the friendlier {@link PolicyInput}
32
+ * `{ by: 'parent', fk, parent }` shape, normalized into this by
33
+ * {@link resolvePolicy}.
20
34
  */
21
35
  export declare const scopedViaRefSchema: z.ZodObject<{
22
36
  localKey: z.ZodString;
@@ -25,7 +39,7 @@ export declare const scopedViaRefSchema: z.ZodObject<{
25
39
  parentOrgColumn: z.ZodOptional<z.ZodString>;
26
40
  }, z.core.$strip>;
27
41
  export type ScopedViaRef = z.infer<typeof scopedViaRefSchema>;
28
- /** How a model's rows are scoped to a tenant. */
42
+ /** How a model's rows are scoped to a tenant — the CANONICAL, wire-facing form. */
29
43
  export declare const tenancySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
30
44
  kind: z.ZodLiteral<"column">;
31
45
  column: z.ZodString;
@@ -42,25 +56,50 @@ export declare const tenancySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
42
56
  }, z.core.$strip>], "kind">;
43
57
  export type Tenancy = z.infer<typeof tenancySchema>;
44
58
  /**
45
- * Ergonomic authoring shortcuts accepted on a model. These are *input only*
46
- * `resolveTenancy` normalizes them into the canonical {@link Tenancy} at
47
- * model-build time, so they never reach the serialized JSON or any consumer.
59
+ * The AUTHORING form of tenancy — what a schema author writes as the model's
60
+ * `policy` option (Postgres/Supabase RLS vocabulary: a policy is the rule that
61
+ * scopes which rows a tenant may read). A Zod discriminated union on `by`, so
62
+ * the three branches are mutually exclusive and the dangerous opt-out
63
+ * (`{ by: 'none' }`) is an explicit, named choice rather than a falsy flag.
64
+ *
65
+ * - `{ by: 'column' }` — row-local tenancy column (the default).
66
+ * `column` overrides the name (default {@link DEFAULT_ORG_COLUMN}).
67
+ * - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when
68
+ * this table has no tenancy column of its own. `parentKey` (default `'id'`)
69
+ * and `parentTenantColumn` (default {@link DEFAULT_ORG_COLUMN}) are overrides.
70
+ * - `{ by: 'none' }` — genuinely global / reference data. ⚠ Makes
71
+ * the whole table readable cross-tenant — only correct for tenant-less tables.
48
72
  */
49
- export interface TenancyInput {
50
- tenancy?: Tenancy;
51
- /** `false` → not tenant-scoped. */
52
- orgScoped?: boolean;
53
- /** Scope through a parent table. */
54
- scopedVia?: ScopedViaRef;
55
- /** Override the column name for a column-scoped model. */
56
- orgColumn?: string;
57
- }
73
+ export declare const policyInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
74
+ by: z.ZodLiteral<"column">;
75
+ column: z.ZodOptional<z.ZodString>;
76
+ }, z.core.$strip>, z.ZodObject<{
77
+ by: z.ZodLiteral<"parent">;
78
+ fk: z.ZodString;
79
+ parent: z.ZodString;
80
+ parentKey: z.ZodOptional<z.ZodString>;
81
+ parentTenantColumn: z.ZodOptional<z.ZodString>;
82
+ }, z.core.$strip>, z.ZodObject<{
83
+ by: z.ZodLiteral<"none">;
84
+ }, z.core.$strip>], "by">;
85
+ export type PolicyInput = z.infer<typeof policyInputSchema>;
58
86
  /**
59
- * Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
60
- * at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
61
- * `tenancy`. Precedence: explicit `tenancy` `scopedVia` `orgScoped:false`
62
- * column (default or `orgColumn`).
87
+ * Normalize the authoring {@link PolicyInput} into the one canonical
88
+ * {@link Tenancy}. Called once, at `model()`-build, so `ModelDef`/`ModelJSON`
89
+ * and every consumer see only the canonical union. Omitting `policy` defaults
90
+ * to a row-local `organization_id` column.
63
91
  */
64
- export declare function resolveTenancy(input: TenancyInput): Tenancy;
92
+ export declare function resolvePolicy(input?: PolicyInput): Tenancy;
93
+ /**
94
+ * Read the canonical {@link Tenancy} off an already-built model def (or parsed
95
+ * `ModelJSON`), defaulting to a row-local `organization_id` column when absent.
96
+ *
97
+ * This is the READ-side helper — consumers (provision/RLS, membership resolver,
98
+ * DDL, CLI) call it to get a model's tenancy without re-deriving the default in
99
+ * each place. It is NOT the authoring normalizer; that's {@link resolveIsolation}.
100
+ */
101
+ export declare function resolveTenancy(def: {
102
+ tenancy?: Tenancy;
103
+ }): Tenancy;
65
104
  /** The physical tenancy column for a column-scoped model, else `null`. */
66
105
  export declare function tenancyColumn(t: Tenancy): string | null;