@abloatai/ablo 0.11.2 → 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 (70) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +3 -3
  4. package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
  5. package/dist/ai-sdk/claim-broadcast.js +2 -2
  6. package/dist/ai-sdk/wrap.d.ts +5 -4
  7. package/dist/ai-sdk/wrap.js +3 -3
  8. package/dist/cli.cjs +152 -41
  9. package/dist/client/Ablo.d.ts +25 -3
  10. package/dist/client/Ablo.js +5 -5
  11. package/dist/client/ApiClient.js +26 -11
  12. package/dist/client/createModelProxy.d.ts +15 -7
  13. package/dist/client/createModelProxy.js +12 -12
  14. package/dist/coordination/schema.d.ts +1 -1
  15. package/dist/coordination/schema.js +3 -1
  16. package/dist/errors.d.ts +3 -1
  17. package/dist/errors.js +6 -1
  18. package/dist/react/AbloProvider.d.ts +11 -7
  19. package/dist/react/AbloProvider.js +9 -5
  20. package/dist/react/context.d.ts +9 -14
  21. package/dist/react/context.js +10 -15
  22. package/dist/react/index.d.ts +8 -4
  23. package/dist/react/index.js +8 -4
  24. package/dist/react/useMutators.js +3 -2
  25. package/dist/react/useUndoScope.js +3 -2
  26. package/dist/schema/index.d.ts +2 -2
  27. package/dist/schema/index.js +2 -2
  28. package/dist/schema/model.d.ts +38 -77
  29. package/dist/schema/model.js +12 -12
  30. package/dist/schema/roles.d.ts +49 -0
  31. package/dist/schema/roles.js +21 -0
  32. package/dist/schema/schema.d.ts +1 -1
  33. package/dist/schema/schema.js +1 -1
  34. package/dist/schema/serialize.d.ts +4 -2
  35. package/dist/schema/serialize.js +4 -2
  36. package/dist/schema/sugar.d.ts +7 -28
  37. package/dist/schema/sugar.js +2 -7
  38. package/dist/schema/sync-delta-row.d.ts +2 -0
  39. package/dist/schema/sync-delta-row.js +2 -1
  40. package/dist/schema/tenancy.d.ts +67 -28
  41. package/dist/schema/tenancy.js +93 -23
  42. package/dist/server/commit.d.ts +8 -3
  43. package/dist/sync/createClaimStream.js +5 -4
  44. package/dist/sync/participants.js +1 -1
  45. package/dist/types/streams.d.ts +17 -7
  46. package/docs/api.md +1 -1
  47. package/docs/cli.md +43 -4
  48. package/docs/client-behavior.md +2 -2
  49. package/docs/coordination.md +1 -1
  50. package/docs/examples/agent-human.md +6 -6
  51. package/docs/examples/ai-sdk-tool.md +1 -1
  52. package/docs/examples/existing-python-backend.md +0 -2
  53. package/docs/examples/nextjs.md +2 -2
  54. package/docs/examples/scoped-agent.md +3 -3
  55. package/docs/examples/server-agent.md +4 -4
  56. package/docs/identity.md +27 -20
  57. package/docs/index.md +0 -1
  58. package/docs/integration-guide.md +12 -9
  59. package/docs/interaction-model.md +1 -1
  60. package/docs/mcp.md +17 -5
  61. package/docs/migration.md +2 -1
  62. package/docs/quickstart.md +3 -3
  63. package/llms.txt +2 -3
  64. package/package.json +3 -2
  65. package/docs/mcp/claude-code.md +0 -35
  66. package/docs/mcp/cursor.md +0 -35
  67. package/docs/mcp/windsurf.md +0 -33
  68. package/docs/roadmap.md +0 -55
  69. package/docs/the-loop.md +0 -21
  70. package/llms-full.txt +0 -396
package/AGENTS.md CHANGED
@@ -31,7 +31,7 @@ Every model verb takes ONE options object. The common loop:
31
31
 
32
32
  1. **Read** the row — `await ablo.<model>.retrieve({ id })` (async; from the server) or `await ablo.<model>.list({ where })` for many. In React render, read synchronously with `useAblo((a) => a.<model>.get(id))`.
33
33
  2. **See who's active** (optional) — `ablo.<model>.claim.state({ id })` (synchronous; never blocks).
34
- 3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, action?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
34
+ 3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, reason?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
35
35
  4. **Write** — `await ablo.<model>.update({ id: claim.data.id, data })`. Because you hold the claim, the write is rejected if the row changed underneath you.
36
36
 
37
37
  Keep coding assistants on this schema-backed path.
@@ -59,7 +59,7 @@ if (!report) throw new Error('Report not found');
59
59
  // row before resolving. Auto-released at the end of this scope (`await using`).
60
60
  await using claim = await ablo.weatherReports.claim({
61
61
  id: 'report_stockholm',
62
- action: 'forecasting',
62
+ reason: 'forecasting',
63
63
  ttl: '2m',
64
64
  });
65
65
  const claimed = claim.data;
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Schema authoring: split model routing into two orthogonal axes — `policy` (row access) and `groups` (sync-group routing).
8
+
9
+ **Breaking (schema authoring).** The flat, collision-prone model options are replaced by two namespaced ones:
10
+ - **`policy`** — row-access / tenant isolation (named after Postgres/Supabase RLS policies: the rule that scopes which rows a tenant may read). A discriminated union on `by` replaces the old `orgScoped` / `scopedVia` / `orgColumn` trio:
11
+ - `{ by: 'column' }` — row-local tenancy column (the default when omitted; column name still overridable).
12
+ - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when the table has no tenancy column of its own (e.g. `slide_layers` → `slides`).
13
+ - Type `TenancyInput` is renamed `PolicyInput`; `policyInputSchema` / `resolvePolicy` are now exported.
14
+ - **`groups: { root, grants, roles }`** — which delta channels a row fans into (orthogonal to `policy`, which governs read access). One namespaced object replaces the old flat `scope` / `grants` / `entityRoles`:
15
+ - `root` (was `scope`) — mark a model a scope root; its records form the group `<kind>:<id>`. Renamed so it no longer collides with the old `scopedVia` tenancy sugar or the inner `grants.scope` relation name.
16
+ - `grants` — a membership edge granting an identity access to a scope root.
17
+ - `roles` (was `entityRoles`) — explicit non-relational record→group roles; accepts one role or an array.
18
+ - `groupsInputSchema` / `GroupsInput` are now exported.
19
+
20
+ **CLI.** `config.json` now stores per-project profile key pairs (`profiles: Record<string, ProfileKeys>`) instead of a single top-level pair; older flat layouts are folded into the active profile automatically on read, so existing logins keep working. `login` / `projects` updated to the profile model.
21
+
22
+ ## 0.12.0
23
+
24
+ ### Minor Changes
25
+
26
+ - Canonicalize the claim API to one vocabulary, plus DX fixes (breaking).
27
+ - BREAKING: claim phase field `action` → `reason` on every claim surface
28
+ (`Claim`, `ClaimHandle`, `ClaimCreateOptions`, `ModelClaim`, ...). The wire
29
+ is unchanged (still `action`, healed on read) — no server redeploy needed.
30
+ - BREAKING: claim contention flag `wait` → `queue` (one word everywhere).
31
+ - BREAKING: React hook `useParticipant` → `useWatch` (aligns with `ablo.<model>.watch`).
32
+ - `ClaimDeclaration.ttlSeconds` is now `number` (was a `Duration`).
33
+ - Docs: `retrieve` HTTP envelope (`.data`/`.stamp`) called out; `syncGroups`
34
+ reworded (provisional, not deprecated); `orgScoped` cross-tenant security
35
+ warning; React error strings point at `<AbloProvider>`.
36
+
3
37
  ## 0.11.2
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -274,7 +274,7 @@ ablo.weatherReports.claim.state({ id: 'report_stockholm' });
274
274
  ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
275
275
 
276
276
  {
277
- await using claim = await ablo.weatherReports.claim({ id, wait: false });
277
+ await using claim = await ablo.weatherReports.claim({ id, queue: false });
278
278
  /* do the held work */
279
279
  }
280
280
 
@@ -285,11 +285,11 @@ ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
285
285
  ```
286
286
 
287
287
  `claim.state` returns the holder (or `null`); `claim.queue` returns the line waiting
288
- behind it. `wait: false` skips rather than waiting when the row is held;
288
+ behind it. `queue: false` skips rather than waiting when the row is held;
289
289
  `maxQueueDepth: 2` bails when two or more are already ahead.
290
290
 
291
291
  Default reads keep working while a row is claimed. Server reads that need claimed
292
- semantics can opt in with `ifClaimed: 'return' | 'wait' | 'fail'`.
292
+ semantics can opt in with `ifClaimed: 'return' | 'fail'`.
293
293
 
294
294
  Even an unclaimed write can't land on stale reasoning — the commit is guarded:
295
295
 
@@ -58,10 +58,11 @@ export interface ClaimBroadcastMiddlewareOptions<R extends SchemaRecord = Schema
58
58
  /** Target entity. Null skips the broadcast (purely conversational). */
59
59
  readonly target: ClaimTarget | null;
60
60
  /**
61
- * Action verb describing what the agent is doing. Convention:
62
- * `'edit'`, `'read'`, `'review'`, `'generate'`. Default `'edit'`.
61
+ * Human-readable phase describing what the agent is doing. Convention:
62
+ * `'edit'`, `'read'`, `'review'`, `'generate'`. Default `'edit'`. The same
63
+ * `reason` field used on every claim surface.
63
64
  */
64
- readonly action?: string;
65
+ readonly reason?: string;
65
66
  /**
66
67
  * Peer-visible explanation of the specific work this model call is about to
67
68
  * perform. Surfaces to other agents through `ActiveClaim.description`.
@@ -29,7 +29,7 @@
29
29
  */
30
30
  export function claimBroadcastMiddleware(options) {
31
31
  const { agent, target } = options;
32
- const action = options.action ?? 'edit';
32
+ const reason = options.reason ?? 'edit';
33
33
  const description = options.description;
34
34
  const openClaim = () => {
35
35
  if (!agent || !target)
@@ -42,7 +42,7 @@ export function claimBroadcastMiddleware(options) {
42
42
  field: target.field,
43
43
  meta: target.meta,
44
44
  }, {
45
- reason: action,
45
+ reason,
46
46
  description,
47
47
  ttl: target.estimatedMs ?? 60_000,
48
48
  });
@@ -14,7 +14,7 @@
14
14
  * model: anthropic('claude-opus-4-7'),
15
15
  * agent,
16
16
  * target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
17
- * action: 'renaming',
17
+ * reason: 'renaming',
18
18
  * description: 'Renaming the deck title to match the project brief.',
19
19
  * });
20
20
  *
@@ -40,10 +40,11 @@ export interface WrapWithMultiplayerOptions {
40
40
  /** Target entity. Null = pass-through wrap. */
41
41
  readonly target: ClaimTarget | null;
42
42
  /**
43
- * Optional action verb for the broadcast. Default `'edit'`.
44
- * Convention: `'edit'`, `'read'`, `'review'`, `'generate'`.
43
+ * Optional human-readable phase for the broadcast. Default `'edit'`.
44
+ * Convention: `'edit'`, `'read'`, `'review'`, `'generate'`. The same
45
+ * `reason` field used on every claim surface.
45
46
  */
46
- readonly action?: string;
47
+ readonly reason?: string;
47
48
  /**
48
49
  * Peer-visible explanation of the specific work this model call is about to
49
50
  * perform. Other agents receive it in their coordination context.
@@ -14,7 +14,7 @@
14
14
  * model: anthropic('claude-opus-4-7'),
15
15
  * agent,
16
16
  * target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
17
- * action: 'renaming',
17
+ * reason: 'renaming',
18
18
  * description: 'Renaming the deck title to match the project brief.',
19
19
  * });
20
20
  *
@@ -31,12 +31,12 @@ import { wrapLanguageModel } from 'ai';
31
31
  import { claimBroadcastMiddleware, } from './claim-broadcast.js';
32
32
  import { coordinationContextMiddleware } from './coordination-context.js';
33
33
  export function wrapWithMultiplayer(options) {
34
- const { model, agent, target, action, description, excludeClaimIds, extraMiddleware } = options;
34
+ const { model, agent, target, reason, description, excludeClaimIds, extraMiddleware } = options;
35
35
  return wrapLanguageModel({
36
36
  model,
37
37
  middleware: [
38
38
  coordinationContextMiddleware({ agent, target, excludeClaimIds }),
39
- claimBroadcastMiddleware({ agent, target, action, description }),
39
+ claimBroadcastMiddleware({ agent, target, reason, description }),
40
40
  ...(extraMiddleware ?? []),
41
41
  ],
42
42
  });
package/dist/cli.cjs CHANGED
@@ -277023,6 +277023,7 @@ var roleSchema = import_zod2.z.object({
277023
277023
  kind: import_zod2.z.string().regex(/^[a-z][a-z0-9_]*$/, 'kind must be a lowercase identifier, e.g. "deck"'),
277024
277024
  source: roleSourceSchema
277025
277025
  });
277026
+ var entityRoleSchema = roleSchema;
277026
277027
  var scopeSchema = import_zod2.z.union([
277027
277028
  import_zod2.z.boolean(),
277028
277029
  import_zod2.z.string().regex(/^[a-z][a-z0-9_]*$/, 'scope kind must be a lowercase identifier, e.g. "dataroom"')
@@ -277031,6 +277032,11 @@ var grantsRefSchema = import_zod2.z.object({
277031
277032
  subject: import_zod2.z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "grants.subject must name a relation"),
277032
277033
  scope: import_zod2.z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "grants.scope must name a relation")
277033
277034
  });
277035
+ var groupsInputSchema = import_zod2.z.object({
277036
+ root: scopeSchema.optional(),
277037
+ grants: grantsRefSchema.optional(),
277038
+ roles: import_zod2.z.union([entityRoleSchema, import_zod2.z.array(entityRoleSchema)]).optional()
277039
+ });
277034
277040
 
277035
277041
  // src/coordination/schema.ts
277036
277042
  var targetRangeSchema = import_zod3.z.object({
@@ -277121,7 +277127,9 @@ var modelClaimSchema = import_zod3.z.object({
277121
277127
  id: import_zod3.z.string(),
277122
277128
  actor: import_zod3.z.string(),
277123
277129
  participantKind: wireParticipantKindSchema,
277124
- action: import_zod3.z.string(),
277130
+ /** Human-readable phase (`'editing'`). The public SDK field; the WS/HTTP
277131
+ * wire carries the same value as `action` (healed on read). */
277132
+ reason: import_zod3.z.string(),
277125
277133
  description: import_zod3.z.string().optional(),
277126
277134
  field: import_zod3.z.string().optional(),
277127
277135
  status: import_zod3.z.enum(["active", "queued"]).optional(),
@@ -279644,6 +279652,7 @@ init_cjs_shims();
279644
279652
  var import_os2 = require("os");
279645
279653
  var import_path2 = require("path");
279646
279654
  var import_fs3 = require("fs");
279655
+ var DEFAULT_PROFILE = "default";
279647
279656
  function configDir() {
279648
279657
  if (process.env.ABLO_CONFIG_DIR) return process.env.ABLO_CONFIG_DIR;
279649
279658
  const xdg = process.env.XDG_CONFIG_HOME;
@@ -279655,12 +279664,32 @@ function configPath() {
279655
279664
  function credentialsPath() {
279656
279665
  return (0, import_path2.join)(configDir(), "credentials.json");
279657
279666
  }
279667
+ function activeProfileName(cfg) {
279668
+ return cfg.activeProject?.slug ?? DEFAULT_PROFILE;
279669
+ }
279658
279670
  function asKeyEntry(value) {
279659
279671
  if (value && typeof value === "object" && typeof value.apiKey === "string") {
279660
279672
  return value;
279661
279673
  }
279662
279674
  return void 0;
279663
279675
  }
279676
+ function asProfileKeys(value) {
279677
+ if (!value || typeof value !== "object") return void 0;
279678
+ const v = value;
279679
+ const sandbox = asKeyEntry(v.sandbox);
279680
+ const production = asKeyEntry(v.production);
279681
+ if (!sandbox && !production) return void 0;
279682
+ return { ...sandbox ? { sandbox } : {}, ...production ? { production } : {} };
279683
+ }
279684
+ function asProfileMap(value) {
279685
+ if (!value || typeof value !== "object") return {};
279686
+ const out = {};
279687
+ for (const [name, v] of Object.entries(value)) {
279688
+ const keys = asProfileKeys(v);
279689
+ if (keys) out[name] = keys;
279690
+ }
279691
+ return out;
279692
+ }
279664
279693
  function readJson(path) {
279665
279694
  if (!(0, import_fs3.existsSync)(path)) return null;
279666
279695
  try {
@@ -279681,7 +279710,7 @@ function normalizeStoredMode(value) {
279681
279710
  if (value === "sandbox" || value === "production") return value;
279682
279711
  return void 0;
279683
279712
  }
279684
- function extractEntries(obj) {
279713
+ function extractLegacyEntries(obj) {
279685
279714
  const sandbox = asKeyEntry(obj.sandbox);
279686
279715
  const production = asKeyEntry(obj.production);
279687
279716
  if (sandbox || production) {
@@ -279690,24 +279719,33 @@ function extractEntries(obj) {
279690
279719
  const flat = asKeyEntry(obj);
279691
279720
  return flat ? { sandbox: flat } : {};
279692
279721
  }
279722
+ function hasKey(keys) {
279723
+ return !!(keys?.sandbox || keys?.production);
279724
+ }
279693
279725
  function readConfig() {
279694
279726
  const cfgObj = readJson(configPath());
279695
279727
  const credObj = readJson(credentialsPath());
279696
279728
  const mode2 = normalizeStoredMode(cfgObj?.mode) ?? normalizeStoredMode(credObj?.mode);
279697
279729
  const activeProject = asActiveProject(cfgObj?.activeProject);
279698
- const cfgEntries = cfgObj ? extractEntries(cfgObj) : {};
279699
- const entries = {
279700
- ...cfgEntries,
279701
- ...credObj ? extractEntries(credObj) : {}
279702
- // credentials file wins
279730
+ const activeName = activeProject?.slug ?? DEFAULT_PROFILE;
279731
+ const profiles = {
279732
+ ...asProfileMap(credObj?.profiles),
279733
+ ...asProfileMap(cfgObj?.profiles)
279703
279734
  };
279704
- if (!mode2 && !entries.sandbox && !entries.production) return null;
279735
+ const legacyCfg = cfgObj ? extractLegacyEntries(cfgObj) : {};
279736
+ const legacyCred = credObj ? extractLegacyEntries(credObj) : {};
279737
+ const legacy = { ...legacyCfg, ...legacyCred };
279738
+ const migratedLegacy = hasKey(legacy) && !hasKey(profiles[activeName]);
279739
+ if (migratedLegacy) profiles[activeName] = legacy;
279740
+ const anyKey = Object.values(profiles).some(hasKey);
279741
+ if (!mode2 && !anyKey) return null;
279705
279742
  const config = {
279706
279743
  mode: mode2 ?? "sandbox",
279707
279744
  ...activeProject ? { activeProject } : {},
279708
- ...entries
279745
+ profiles
279709
279746
  };
279710
- if (cfgEntries.sandbox || cfgEntries.production) writeConfig(config);
279747
+ const secretsInConfig = hasKey(legacyCfg);
279748
+ if (secretsInConfig || migratedLegacy) writeConfig(config);
279711
279749
  return config;
279712
279750
  }
279713
279751
  function writeConfig(cfg) {
@@ -279723,16 +279761,34 @@ function writeConfig(cfg) {
279723
279761
  `,
279724
279762
  { mode: 384 }
279725
279763
  );
279726
- const credentials = {
279727
- ...cfg.sandbox ? { sandbox: cfg.sandbox } : {},
279728
- ...cfg.production ? { production: cfg.production } : {}
279729
- };
279730
- (0, import_fs3.writeFileSync)(credentialsPath(), `${JSON.stringify(credentials, null, 2)}
279764
+ const profiles = {};
279765
+ for (const [name, keys] of Object.entries(cfg.profiles)) {
279766
+ if (!hasKey(keys)) continue;
279767
+ profiles[name] = {
279768
+ ...keys.sandbox ? { sandbox: keys.sandbox } : {},
279769
+ ...keys.production ? { production: keys.production } : {}
279770
+ };
279771
+ }
279772
+ (0, import_fs3.writeFileSync)(credentialsPath(), `${JSON.stringify({ profiles }, null, 2)}
279731
279773
  `, { mode: 384 });
279732
279774
  return credentialsPath();
279733
279775
  }
279776
+ function emptyConfig(mode2 = "sandbox") {
279777
+ return { mode: mode2, profiles: {} };
279778
+ }
279779
+ function setProfileKeys(profileName, keys, opts) {
279780
+ const cfg = readConfig() ?? emptyConfig(opts.mode);
279781
+ cfg.mode = opts.mode;
279782
+ cfg.profiles[profileName] = {
279783
+ ...keys.sandbox ? { sandbox: keys.sandbox } : {},
279784
+ ...keys.production ? { production: keys.production } : {}
279785
+ };
279786
+ if (opts.activeProject) cfg.activeProject = opts.activeProject;
279787
+ else delete cfg.activeProject;
279788
+ return writeConfig(cfg);
279789
+ }
279734
279790
  function setMode(mode2) {
279735
- const cfg = readConfig() ?? { mode: mode2 };
279791
+ const cfg = readConfig() ?? emptyConfig(mode2);
279736
279792
  cfg.mode = mode2;
279737
279793
  return writeConfig(cfg);
279738
279794
  }
@@ -279743,13 +279799,15 @@ function getActiveProject() {
279743
279799
  return readConfig()?.activeProject;
279744
279800
  }
279745
279801
  function setActiveProject(project) {
279746
- const cfg = readConfig() ?? { mode: "sandbox" };
279802
+ const cfg = readConfig() ?? emptyConfig("sandbox");
279747
279803
  if (project) cfg.activeProject = project;
279748
279804
  else delete cfg.activeProject;
279749
279805
  return writeConfig(cfg);
279750
279806
  }
279751
279807
  function getKeyEntry(mode2) {
279752
- return readConfig()?.[mode2];
279808
+ const cfg = readConfig();
279809
+ if (!cfg) return void 0;
279810
+ return cfg.profiles[activeProfileName(cfg)]?.[mode2];
279753
279811
  }
279754
279812
  function modeFromKey(key) {
279755
279813
  if (/^(sk|rk)_test_/.test(key)) return "sandbox";
@@ -279773,11 +279831,21 @@ function resolveApiKey(modeOverride) {
279773
279831
  if (process.env.ABLO_API_KEY) return process.env.ABLO_API_KEY;
279774
279832
  const cfg = readConfig();
279775
279833
  if (!cfg) return void 0;
279776
- const entry = cfg[modeOverride ?? cfg.mode];
279834
+ const entry = cfg.profiles[activeProfileName(cfg)]?.[modeOverride ?? cfg.mode];
279777
279835
  if (!entry) return void 0;
279778
279836
  if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) return void 0;
279779
279837
  return entry.apiKey;
279780
279838
  }
279839
+ function guardActiveProjectKey() {
279840
+ if (process.env.ABLO_API_KEY) {
279841
+ return { ok: true, activeProfile: DEFAULT_PROFILE, available: [] };
279842
+ }
279843
+ const cfg = readConfig();
279844
+ const activeProfile = cfg ? activeProfileName(cfg) : DEFAULT_PROFILE;
279845
+ const profiles = cfg?.profiles ?? {};
279846
+ const available = Object.entries(profiles).filter(([, keys]) => hasKey(keys)).map(([name]) => name);
279847
+ return { ok: hasKey(profiles[activeProfile]), activeProfile, available };
279848
+ }
279781
279849
  function resolvePushPlan() {
279782
279850
  const envKey = process.env.ABLO_API_KEY;
279783
279851
  if (envKey) return { flow: modeFromKey(envKey) ?? getMode(), apiKey: envKey, source: "env" };
@@ -280423,8 +280491,19 @@ function openBrowser(url) {
280423
280491
  } catch {
280424
280492
  }
280425
280493
  }
280426
- async function deviceLogin() {
280494
+ function parseProjectFlag(argv) {
280495
+ const i = argv.indexOf("--project");
280496
+ if (i >= 0) {
280497
+ const slug = argv[i + 1];
280498
+ if (slug && !slug.startsWith("-")) return slug;
280499
+ }
280500
+ const eq = argv.find((a) => a.startsWith("--project="));
280501
+ return eq ? eq.slice("--project=".length) || void 0 : void 0;
280502
+ }
280503
+ async function deviceLogin(argv) {
280427
280504
  Ie(`${brand("ablo")} login`);
280505
+ const requested = parseProjectFlag(argv) ?? getActiveProject()?.slug;
280506
+ const targetProject = requested === DEFAULT_PROFILE ? void 0 : requested;
280428
280507
  const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
280429
280508
  let account = "login";
280430
280509
  if (interactive) {
@@ -280502,13 +280581,19 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
280502
280581
  s.stop("Timed out waiting for approval.");
280503
280582
  process.exit(1);
280504
280583
  }
280505
- s.message("Provisioning a sandbox key\u2026");
280584
+ s.message(
280585
+ targetProject ? `Provisioning keys for ${targetProject}\u2026` : "Provisioning a sandbox key\u2026"
280586
+ );
280506
280587
  const provRes = await fetch(`${AUTH_URL}/api/cli/provision-key`, {
280507
280588
  method: "POST",
280508
280589
  headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
280509
- // Pass the device_code so the server can scope the minted keys to the
280510
- // project the user picked at /cli (login project picker). Harmless if none.
280511
- body: JSON.stringify({ device_code: code.device_code })
280590
+ // Scope the minted keys to the chosen project (`--project`/active), with
280591
+ // the device_code as a legacy fallback for the /cli picker. Both harmless
280592
+ // if absent → org-default keys.
280593
+ body: JSON.stringify({
280594
+ device_code: code.device_code,
280595
+ ...targetProject ? { project_slug: targetProject } : {}
280596
+ })
280512
280597
  }).catch(() => null);
280513
280598
  if (!provRes || !provRes.ok) {
280514
280599
  s.stop("Could not provision a key.");
@@ -280526,16 +280611,23 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
280526
280611
  ...prov.organizationId ? { organizationId: prov.organizationId } : {},
280527
280612
  ...k3.expiresAt ? { expiresAt: k3.expiresAt } : {}
280528
280613
  });
280529
- const path = writeConfig({
280530
- mode: "sandbox",
280531
- sandbox: entry(prov.test),
280532
- ...prov.live ? { production: entry(prov.live) } : {}
280533
- });
280614
+ const profileName = prov.project?.slug ?? DEFAULT_PROFILE;
280615
+ const path = setProfileKeys(
280616
+ profileName,
280617
+ {
280618
+ sandbox: entry(prov.test),
280619
+ ...prov.live ? { production: entry(prov.live) } : {}
280620
+ },
280621
+ { mode: "sandbox", activeProject: prov.project ?? void 0 }
280622
+ );
280534
280623
  s.stop(`Saved keys to ${path}`);
280535
- Se(`${import_picocolors7.default.green("\u2713")} Logged in ${import_picocolors7.default.dim("(sandbox)")}. Run ${import_picocolors7.default.bold("npx ablo push")} to push your schema.`);
280624
+ const where = prov.project ? ` ${import_picocolors7.default.dim(`(project ${prov.project.slug})`)}` : "";
280625
+ Se(
280626
+ `${import_picocolors7.default.green("\u2713")} Logged in ${import_picocolors7.default.dim("(sandbox)")}${where}. Run ${import_picocolors7.default.bold("npx ablo push")} to push your schema.`
280627
+ );
280536
280628
  }
280537
- async function login() {
280538
- await deviceLogin();
280629
+ async function login(argv = []) {
280630
+ await deviceLogin(argv);
280539
280631
  }
280540
280632
  function logout() {
280541
280633
  const removed = clearCredential();
@@ -280788,11 +280880,13 @@ async function projects(argv) {
280788
280880
  setActiveProject({ id: target.id, slug: target.slug });
280789
280881
  console.log(` ${import_picocolors9.default.green("\u2713")} now targeting project ${import_picocolors9.default.bold(target.slug)} ${import_picocolors9.default.dim(`(${target.id})`)}`);
280790
280882
  }
280791
- console.log(
280792
- import_picocolors9.default.dim(
280793
- " Note: a key\u2019s project scope is fixed at mint \u2014 switch keys (or mint one for this project) to act in it."
280794
- )
280795
- );
280883
+ const guard = guardActiveProjectKey();
280884
+ if (!guard.ok) {
280885
+ const loginCmd = guard.activeProfile === DEFAULT_PROFILE ? "ablo login" : `ablo login --project ${guard.activeProfile}`;
280886
+ console.log(
280887
+ import_picocolors9.default.dim(` No key stored for this project yet \u2014 run ${import_picocolors9.default.bold(loginCmd)} to mint one.`)
280888
+ );
280889
+ }
280796
280890
  return;
280797
280891
  }
280798
280892
  console.error(
@@ -281368,7 +281462,7 @@ function spreadOpts(optsArg) {
281368
281462
  }
281369
281463
  return `, ...${optsArg.getText()}`;
281370
281464
  }
281371
- function hasKey(obj, key) {
281465
+ function hasKey2(obj, key) {
281372
281466
  return obj.getProperties().some(
281373
281467
  (p2) => (import_ts_morph.Node.isPropertyAssignment(p2) || import_ts_morph.Node.isShorthandPropertyAssignment(p2)) && p2.getName() === key
281374
281468
  );
@@ -281381,7 +281475,7 @@ function verbRewrite(call, verb) {
281381
281475
  const first = args[0];
281382
281476
  const calleeText = call.getExpression().getText();
281383
281477
  if (verb === "create") {
281384
- if (import_ts_morph.Node.isObjectLiteralExpression(first) && hasKey(first, "data")) return null;
281478
+ if (import_ts_morph.Node.isObjectLiteralExpression(first) && hasKey2(first, "data")) return null;
281385
281479
  return `${calleeText}({ data: ${first.getText()}${spreadOpts(args[1])} })`;
281386
281480
  }
281387
281481
  if (import_ts_morph.Node.isObjectLiteralExpression(first)) return null;
@@ -282210,7 +282304,7 @@ async function main() {
282210
282304
  if (command === "init") {
282211
282305
  await init(process.argv.slice(3));
282212
282306
  } else if (command === "login") {
282213
- await login();
282307
+ await login(process.argv.slice(3));
282214
282308
  } else if (command === "logout") {
282215
282309
  logout();
282216
282310
  } else if (command === "mode") {
@@ -282249,6 +282343,22 @@ async function main() {
282249
282343
  const rest = process.argv.slice(3);
282250
282344
  const advanced = rest.some((a) => ["--force", "--rename", "--backfill", "--url"].includes(a));
282251
282345
  const watching = rest.includes("--watch");
282346
+ const guard = guardActiveProjectKey();
282347
+ if (!guard.ok && guard.available.length > 0 && !rest.includes("--url")) {
282348
+ console.error(
282349
+ ` ${import_picocolors18.default.yellow("\u26A0")} active project ${import_picocolors18.default.bold(guard.activeProfile)} has no stored key ${import_picocolors18.default.dim(
282350
+ `(you have keys for: ${guard.available.join(", ")})`
282351
+ )}`
282352
+ );
282353
+ const loginCmd = guard.activeProfile === "default" ? "ablo login" : `ablo login --project ${guard.activeProfile}`;
282354
+ console.error(
282355
+ import_picocolors18.default.dim(
282356
+ ` Mint one with ${import_picocolors18.default.bold(loginCmd)}, or switch with ${import_picocolors18.default.bold("ablo projects use <slug>")}.`
282357
+ )
282358
+ );
282359
+ process.exitCode = 1;
282360
+ return;
282361
+ }
282252
282362
  const plan = resolvePushPlan();
282253
282363
  if (advanced || plan.flow === "production" && !watching) {
282254
282364
  await push(rest);
@@ -282273,11 +282383,12 @@ async function main() {
282273
282383
  console.log(` [--auth apikey] [--storage direct|endpoint] [--project <slug>] [--no-project]`);
282274
282384
  console.log(` [--no-agent] [--no-pull] [--no-install] [--no-login]`);
282275
282385
  console.log(` npx ablo login Authorize in your browser (provisions sandbox + production keys)`);
282386
+ console.log(` npx ablo login --project <slug> Same, scoped to a project (mints its keys, makes it active)`);
282276
282387
  console.log(` npx ablo logout Remove the stored API key`);
282277
282388
  console.log(` npx ablo mode [sandbox|production] Switch active environment, like Stripe`);
282278
282389
  console.log(` npx ablo projects list List the org's projects (default + your own)`);
282279
282390
  console.log(` npx ablo projects create <slug> Create a project (its keys/schema/data are isolated)`);
282280
- console.log(` npx ablo projects use <slug|default> Set the active project for new key mints`);
282391
+ console.log(` npx ablo projects use <slug|default> Switch the active project (run login --project to mint its keys)`);
282281
282392
  console.log(` npx ablo status Show org, mode, keys, and server health`);
282282
282393
  console.log(` npx ablo status --json Same, machine-readable (mode, key prefix, org id, api host)`);
282283
282394
  console.log(` npx ablo logs [-n N] [--since 15m] Tail commit activity (follows; --no-follow to exit)`);
@@ -332,8 +332,14 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
332
332
  */
333
333
  configOverrides?: Partial<SyncEngineConfig>;
334
334
  /**
335
- * @deprecated Server derives sync groups from the apiKey's scope.
336
- * Required today as a runtime holdover; removed once Phase 3 ships.
335
+ * Sync groups (entity scopes) this client subscribes to. **Provisional, not
336
+ * deprecated** pick the right lane: normally the server derives these from
337
+ * the apiKey's scope, but passing them is still REQUIRED today in any config
338
+ * where the key doesn't resolve them (omitting yields a `degenerate
339
+ * syncGroups` warning and a zero-fan-out client). Keep passing it explicitly
340
+ * until the server-derived path ships in Phase 3, at which point it becomes a
341
+ * true no-op and is removed. Build values with `syncGroup(kind, id)` from
342
+ * `@abloatai/ablo/schema`.
337
343
  */
338
344
  syncGroups?: string[];
339
345
  /**
@@ -388,7 +394,9 @@ export interface ModelReadOptions extends ClaimedOptions {
388
394
  }
389
395
  export interface ClaimCreateOptions {
390
396
  readonly target: ModelTarget;
391
- readonly action: string;
397
+ /** Human-readable phase shown to peers — `'editing'`, `'writing'`. The same
398
+ * word on every claim surface; serialized on the wire as `action`. */
399
+ readonly reason: string;
392
400
  readonly ttl?: Duration;
393
401
  /**
394
402
  * Join the server's fair FIFO queue when the target is already claimed,
@@ -490,6 +498,20 @@ export interface HttpClaimApi<T> {
490
498
  reorder(params: ClaimReorderParams<T>): Promise<void>;
491
499
  }
492
500
  export interface ModelClient<T = Record<string, unknown>> {
501
+ /**
502
+ * Single-row read over HTTP. **Returns an envelope, not the bare row** — the
503
+ * row is on `.data`, alongside the `.stamp` watermark (for stale-context
504
+ * guards on the following write) and any active `.claims`. A stateless HTTP
505
+ * client can't synthesize the watermark from a local snapshot, so the
506
+ * envelope is load-bearing here (the WebSocket client's `retrieve` returns
507
+ * `T | undefined` because it reads from the hydrated pool).
508
+ *
509
+ * ```ts
510
+ * const deal = await ablo.deals.retrieve({ id });
511
+ * deal.data?.recommendation; // ← the row is on .data
512
+ * deal.stamp; // watermark — pass to the next write's readAt
513
+ * ```
514
+ */
493
515
  retrieve(params: ModelReadOptions & {
494
516
  readonly id: string;
495
517
  }): Promise<ModelRead<T>>;
@@ -1124,7 +1124,7 @@ export function Ablo(options) {
1124
1124
  id: claim.id,
1125
1125
  actor: claim.heldBy,
1126
1126
  participantKind: claim.participantKind,
1127
- action: claim.reason,
1127
+ reason: claim.reason,
1128
1128
  ...(description ? { description } : {}),
1129
1129
  field: claim.target.field,
1130
1130
  status: 'active',
@@ -1144,7 +1144,7 @@ export function Ablo(options) {
1144
1144
  id: claim.id,
1145
1145
  actor: claim.heldBy,
1146
1146
  participantKind: claim.participantKind,
1147
- action: claim.action,
1147
+ reason: claim.reason,
1148
1148
  ...(claim.description ? { description: claim.description } : {}),
1149
1149
  field: claim.target.field,
1150
1150
  status: 'queued',
@@ -1246,7 +1246,7 @@ export function Ablo(options) {
1246
1246
  return {
1247
1247
  object: 'claim',
1248
1248
  claimId: claim.claimId,
1249
- action: claim.action,
1249
+ reason: claim.reason,
1250
1250
  target: claim.target,
1251
1251
  waited,
1252
1252
  release,
@@ -1265,7 +1265,7 @@ export function Ablo(options) {
1265
1265
  field: claimOptions.target.field,
1266
1266
  meta: claimOptions.target.meta,
1267
1267
  }, {
1268
- reason: claimOptions.action,
1268
+ reason: claimOptions.reason,
1269
1269
  ttl: claimOptions.ttl,
1270
1270
  queue: claimOptions.queue,
1271
1271
  });
@@ -1348,7 +1348,7 @@ export function Ablo(options) {
1348
1348
  ...(held.target.field ? { field: held.target.field } : {}),
1349
1349
  ...(held.target.meta ? { meta: held.target.meta } : {}),
1350
1350
  },
1351
- action: held.action,
1351
+ reason: held.reason,
1352
1352
  heldBy: held.actor,
1353
1353
  participantKind: held.participantKind,
1354
1354
  expiresAt: held.expiresAt,