@abloatai/ablo 0.12.0 → 0.13.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 (45) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +3 -3
  4. package/dist/cli.cjs +149 -40
  5. package/dist/schema/index.d.ts +2 -2
  6. package/dist/schema/index.js +2 -2
  7. package/dist/schema/model.d.ts +38 -84
  8. package/dist/schema/model.js +12 -12
  9. package/dist/schema/roles.d.ts +49 -0
  10. package/dist/schema/roles.js +21 -0
  11. package/dist/schema/schema.d.ts +1 -1
  12. package/dist/schema/schema.js +1 -1
  13. package/dist/schema/serialize.d.ts +4 -2
  14. package/dist/schema/serialize.js +4 -2
  15. package/dist/schema/sugar.d.ts +7 -28
  16. package/dist/schema/sugar.js +2 -7
  17. package/dist/schema/sync-delta-row.d.ts +2 -0
  18. package/dist/schema/sync-delta-row.js +2 -1
  19. package/dist/schema/tenancy.d.ts +67 -28
  20. package/dist/schema/tenancy.js +93 -23
  21. package/dist/server/commit.d.ts +8 -3
  22. package/docs/api.md +1 -1
  23. package/docs/cli.md +43 -4
  24. package/docs/client-behavior.md +2 -2
  25. package/docs/coordination.md +1 -1
  26. package/docs/examples/agent-human.md +6 -6
  27. package/docs/examples/ai-sdk-tool.md +1 -1
  28. package/docs/examples/existing-python-backend.md +0 -2
  29. package/docs/examples/nextjs.md +2 -2
  30. package/docs/examples/scoped-agent.md +3 -3
  31. package/docs/examples/server-agent.md +4 -4
  32. package/docs/identity.md +27 -20
  33. package/docs/index.md +0 -1
  34. package/docs/integration-guide.md +12 -9
  35. package/docs/interaction-model.md +1 -1
  36. package/docs/mcp.md +17 -5
  37. package/docs/quickstart.md +3 -3
  38. package/llms.txt +2 -3
  39. package/package.json +3 -2
  40. package/docs/mcp/claude-code.md +0 -35
  41. package/docs/mcp/cursor.md +0 -35
  42. package/docs/mcp/windsurf.md +0 -33
  43. package/docs/roadmap.md +0 -55
  44. package/docs/the-loop.md +0 -21
  45. package/llms-full.txt +0 -396
@@ -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;
@@ -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 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 const scopedViaRefSchema = z.object({
22
36
  /** Column on THIS table pointing at the parent (e.g. `'team_id'`). */
@@ -28,7 +42,7 @@ export const scopedViaRefSchema = z.object({
28
42
  /** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
29
43
  parentOrgColumn: z.string().min(1).optional(),
30
44
  });
31
- /** How a model's rows are scoped to a tenant. */
45
+ /** How a model's rows are scoped to a tenant — the CANONICAL, wire-facing form. */
32
46
  export const tenancySchema = z.discriminatedUnion('kind', [
33
47
  /** Row-local tenancy column (default name `organization_id`, overridable). */
34
48
  z.object({ kind: z.literal('column'), column: z.string().min(1) }),
@@ -38,19 +52,75 @@ export const tenancySchema = z.discriminatedUnion('kind', [
38
52
  z.object({ kind: z.literal('none') }),
39
53
  ]);
40
54
  /**
41
- * Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
42
- * at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
43
- * `tenancy`. Precedence: explicit `tenancy` `scopedVia` `orgScoped:false`
44
- * column (default or `orgColumn`).
55
+ * The AUTHORING form of tenancy what a schema author writes as the model's
56
+ * `policy` option (Postgres/Supabase RLS vocabulary: a policy is the rule that
57
+ * scopes which rows a tenant may read). A Zod discriminated union on `by`, so
58
+ * the three branches are mutually exclusive and the dangerous opt-out
59
+ * (`{ by: 'none' }`) is an explicit, named choice rather than a falsy flag.
60
+ *
61
+ * - `{ by: 'column' }` — row-local tenancy column (the default).
62
+ * `column` overrides the name (default {@link DEFAULT_ORG_COLUMN}).
63
+ * - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when
64
+ * this table has no tenancy column of its own. `parentKey` (default `'id'`)
65
+ * and `parentTenantColumn` (default {@link DEFAULT_ORG_COLUMN}) are overrides.
66
+ * - `{ by: 'none' }` — genuinely global / reference data. ⚠ Makes
67
+ * the whole table readable cross-tenant — only correct for tenant-less tables.
68
+ */
69
+ export const policyInputSchema = z.discriminatedUnion('by', [
70
+ z.object({
71
+ by: z.literal('column'),
72
+ /** Override the physical tenancy column name. Default {@link DEFAULT_ORG_COLUMN}. */
73
+ column: z.string().min(1).optional(),
74
+ }),
75
+ z.object({
76
+ by: z.literal('parent'),
77
+ /** Column on THIS table pointing at the parent (e.g. `'slideId'`). */
78
+ fk: z.string().min(1),
79
+ /** Parent table name (e.g. `'slides'`). */
80
+ parent: z.string().min(1),
81
+ /** Column on the parent that `fk` references. Default `'id'`. */
82
+ parentKey: z.string().min(1).optional(),
83
+ /** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
84
+ parentTenantColumn: z.string().min(1).optional(),
85
+ }),
86
+ z.object({ by: z.literal('none') }),
87
+ ]);
88
+ /**
89
+ * Normalize the authoring {@link PolicyInput} into the one canonical
90
+ * {@link Tenancy}. Called once, at `model()`-build, so `ModelDef`/`ModelJSON`
91
+ * and every consumer see only the canonical union. Omitting `policy` defaults
92
+ * to a row-local `organization_id` column.
93
+ */
94
+ export function resolvePolicy(input) {
95
+ if (!input)
96
+ return { kind: 'column', column: DEFAULT_ORG_COLUMN };
97
+ switch (input.by) {
98
+ case 'column':
99
+ return { kind: 'column', column: input.column ?? DEFAULT_ORG_COLUMN };
100
+ case 'parent':
101
+ return {
102
+ kind: 'parent',
103
+ via: {
104
+ localKey: input.fk,
105
+ parentTable: input.parent,
106
+ parentKey: input.parentKey,
107
+ parentOrgColumn: input.parentTenantColumn,
108
+ },
109
+ };
110
+ case 'none':
111
+ return { kind: 'none' };
112
+ }
113
+ }
114
+ /**
115
+ * Read the canonical {@link Tenancy} off an already-built model def (or parsed
116
+ * `ModelJSON`), defaulting to a row-local `organization_id` column when absent.
117
+ *
118
+ * This is the READ-side helper — consumers (provision/RLS, membership resolver,
119
+ * DDL, CLI) call it to get a model's tenancy without re-deriving the default in
120
+ * each place. It is NOT the authoring normalizer; that's {@link resolveIsolation}.
45
121
  */
46
- export function resolveTenancy(input) {
47
- if (input.tenancy)
48
- return input.tenancy;
49
- if (input.scopedVia)
50
- return { kind: 'parent', via: input.scopedVia };
51
- if (input.orgScoped === false)
52
- return { kind: 'none' };
53
- return { kind: 'column', column: input.orgColumn ?? DEFAULT_ORG_COLUMN };
122
+ export function resolveTenancy(def) {
123
+ return def.tenancy ?? { kind: 'column', column: DEFAULT_ORG_COLUMN };
54
124
  }
55
125
  /** The physical tenancy column for a column-scoped model, else `null`. */
56
126
  export function tenancyColumn(t) {
@@ -58,11 +58,16 @@ export interface CommitContext {
58
58
  */
59
59
  onBehalfOf?: ParticipantRef | null;
60
60
  /**
61
- * FK to AgentCapabilityRoot.capabilityId. Non-null for agent / system commits
62
- * authorized by a Biscuit; null for human-direct commits. Embedded in every
63
- * delta so the audit chain "delta → capability → human" is one FK hop.
61
+ * Scoped credential id. Non-null for agent / system commits when the
62
+ * authorizing credential is known; null for human-direct commits.
64
63
  */
65
64
  capabilityId?: string | null;
65
+ /**
66
+ * Human user id at the root of the delegated authority chain. Stored directly
67
+ * on `sync_deltas` so audit triggers never need to join mutable credential
68
+ * tables while appending the hash chain.
69
+ */
70
+ delegationChainRootUserId?: string | null;
66
71
  /**
67
72
  * ApiKey row id when the caller authenticated with an API key. Used by the
68
73
  * idempotency cache and usage attribution. Null for session / capability
package/docs/api.md CHANGED
@@ -180,7 +180,7 @@ if (claim) {
180
180
 
181
181
  const handle = await ablo.weatherReports.claim({
182
182
  id: 'report_stockholm',
183
- action: 'editing',
183
+ reason: 'editing',
184
184
  ttl: '2m',
185
185
  });
186
186
  await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });