@isomorph.ai/cli 0.3.1-rc.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -144,3 +144,7 @@ export function numericMinimum(checks, column) {
144
144
  }
145
145
  return minimum;
146
146
  }
147
+ /** Whether `col <= n` / `col < n` caps the column: the mirror of `numericMinimum`, asked so no invented value is written into a range the app's own CHECK does not admit. */
148
+ export function hasNumericMaximum(checks, column) {
149
+ return checks.some(check => new RegExp(String.raw `\b${column}\b[^<>=]*(?:<=|<)\s*-?\d+(?:\.\d+)?`, "i").test(check));
150
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFile, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
3
4
  import { readAppSchema } from "./app-schema.js";
4
5
  import { describeRetainedChecks, syncRetainedChecks } from "./retained-checks.js";
5
6
  import { CliError } from "./output.js";
@@ -158,7 +159,7 @@ export async function runKitGate(root, options) {
158
159
  output("Running the retained journeys against the gate's App Gateway.");
159
160
  }
160
161
  const name = state.current;
161
- const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { ...state.environment, ISOMORPH_SDK_MODULE: join(root, "node_modules", ...bundle.sdk.package.split("/"), "dist", "index.js"), NODE_NO_WARNINGS: "1" } });
162
+ const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { ...state.environment, ISOMORPH_SDK_MODULE: sdkModuleSpecifier(root, bundle), NODE_NO_WARNINGS: "1" } });
162
163
  const posted = await post(`/_gate/journeys/${encodeURIComponent(name)}`, { code: result.code, output: `${result.stdout}${result.stderr}`.slice(-4000) });
163
164
  if (posted?.status !== 200)
164
165
  throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate did not accept the result of ${name}.`);
@@ -175,6 +176,21 @@ export async function runKitGate(root, options) {
175
176
  await runtime.down();
176
177
  }
177
178
  }
179
+ /**
180
+ * The SDK a generated journey imports, as a `file://` URL rather than a path.
181
+ *
182
+ * The journey does `await import(process.env.ISOMORPH_SDK_MODULE)`, and on
183
+ * Windows a bare absolute path is not a specifier `import()` accepts: Node
184
+ * reads the drive letter in `D:\\app\\node_modules\\…` as a URL scheme and
185
+ * refuses it with ERR_UNSUPPORTED_ESM_URL_SCHEME. Every journey on Windows
186
+ * therefore failed before it reached its first assertion. It went unseen
187
+ * because `isomorph check` reported those failures and still exited 0 (a
188
+ * dependency's `beforeExit` hook overwrote `process.exitCode`, fixed in
189
+ * 0.3.1-rc.1), so the Windows lane read the run as a pass.
190
+ */
191
+ export function sdkModuleSpecifier(root, bundle) {
192
+ return pathToFileURL(join(root, "node_modules", ...bundle.sdk.package.split("/"), "dist", "index.js")).href;
193
+ }
178
194
  /**
179
195
  * `.isomorph/checks/` is generated from what the app declares — the tables the
180
196
  * gate's replayed database holds, the capabilities the gate derived from the
@@ -13,21 +13,23 @@ export const GUIDE_MODULES = {
13
13
 
14
14
  ## The SDK is the only door
15
15
 
16
- \`@isomorph.ai/app-sdk\`, on the client exported by \`src/isomorph.client.ts\`, is the whole path: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`, \`isomorph.realtime.*\`, \`isomorph.integrations.execute\` (integrations.md) and \`isomorph.ai.chat\` (ai.md). Never open a database, bucket or company URL from browser code; never add another backend, auth library, deployment config, provider key or SDK. No secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/integrations.json\` and \`.isomorph/kit.lock.json\`, never \`.isomorph/local/\`.
16
+ \`@isomorph.ai/app-sdk\`, on the client \`src/isomorph.client.ts\` exports, is the whole path: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`, \`isomorph.realtime.*\`, \`isomorph.integrations.execute\` (integrations.md), \`isomorph.ai.chat\` (ai.md). Never open a database, bucket or company URL from browser code; never add a backend, auth library, deployment config, provider key or SDK. No secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/integrations.json\` and \`.isomorph/kit.lock.json\`, never \`.isomorph/local/\`.
17
17
 
18
18
  ## Identity
19
19
 
20
- Isomorph SSO signs everyone in: no login forms, no roles or ids trusted from the browser, no public routes or wildcard exceptions. \`isomorph.identity.current()\` answers \`{ id, email }\` only — no display name; derive one from the email.
20
+ Isomorph SSO signs everyone in: no login forms, no browser-trusted roles or ids, no public routes. \`isomorph.identity.current()\` answers \`{ id, email }\` only; derive a display name from the address.
21
21
 
22
22
  ## Data (\`core.rls-owner-scoped\`)
23
23
 
24
- Schema changes are SQL files in \`migrations/\`, applied by \`isomorph dev\` and \`isomorph check\`. Every table: \`ENABLE ROW LEVEL SECURITY\`, a policy, and \`GRANT\` of every verb a policy allows to \`harbour_app_gateway\` and no other role; the database gate names whatever breaks this. Row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\` (set by the gateway per request, with \`harbour.user_email\`), never in the browser. Two patterns:
24
+ Schema changes are SQL files in \`migrations/\`. Every table: \`ENABLE ROW LEVEL SECURITY\`, a policy, and \`GRANT\` of every verb it allows to \`harbour_app_gateway\` and no other role. Row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\` (set by the gateway per request, with \`harbour.user_email\`), never in the browser. Two patterns:
25
25
 
26
- - **Private table** — every policy scoped to \`current_setting('harbour.user_id', true)\`; the gate then asserts a second signed-in person cannot read, update or delete another person's row.
27
- - **Shared-read, owner-write** — \`USING (true)\` for SELECT; INSERT, UPDATE and DELETE scoped to the identity setting.
26
+ - **Private table** — every policy scoped to that setting; the gate asserts another signed-in person cannot read, update or delete the row.
27
+ - **Shared-read, owner-write** — \`USING (true)\` for SELECT; INSERT, UPDATE and DELETE scoped to that setting.
28
28
 
29
29
  The gate treats a table as private when any of its policies mentions \`harbour.user_id\` (or \`harbour.user_email\`), so a shared-read table must keep that setting out of its SELECT policy, or its cross-user read check fails.
30
30
 
31
+ Iterating on the schema needs no restart: every \`isomorph check\` replays \`migrations/*.sql\` into a fresh, empty scratch database. \`isomorph dev --reset\` only refreshes the running app's own data, so its UI shows the new schema; stopping, resetting and waiting before each check costs minutes and changes nothing.
32
+
31
33
  ## Files
32
34
 
33
35
  \`isomorph.files.upload(path, file)\`, \`list(prefix)\` and \`remove([path])\`.
@@ -38,45 +40,47 @@ The gate treats a table as private when any of its policies mentions \`harbour.u
38
40
 
39
41
  ## Retained checks (\`core.journey-fk\`)
40
42
 
41
- \`.isomorph/checks/\` holds one journey per capability the app's code uses plus a cross-user denial per private table. \`isomorph check\` generates them from \`migrations/\` and \`src/\` and deletes the ones the app no longer needs, so run it in the same edit that changes the app; never write or remove these files by hand. A generated check starts with \`// isomorph:generated\` and a digest of its body; edit one and it is yours — kept, no longer updated, never deleted — so delete it yourself in the same edit that removes the feature it covers. The pairing is two-way: the \`flow\` gate refuses a capability no check exercises and a check exercising something the code no longer does (\`flow.check-failed\`) — the most common reason a first deploy is refused. What cannot be generated (\`actions\`, \`realtime\`, a table with no migration) is named for you to write. No journey is generated for a table whose row needs a parent (a foreign key): write that table's journey yourself.
43
+ \`.isomorph/checks/\` holds one journey per capability the app uses, plus a cross-user denial per private table. \`isomorph check\` generates them from \`migrations/\` and \`src/\` and deletes what the app dropped, so run it in the edit that changes the app; never write or remove them by hand. A generated check starts with \`// isomorph:generated\` and a digest of its body; edit one and it is yours: kept, never updated or deleted, so delete it in the same edit that drops the feature. Two-way (\`flow.check-failed\`): the \`flow\` gate refuses a capability no check exercises, and a check exercising what the code dropped — the most common first-deploy refusal. What cannot be generated (\`actions\`, \`realtime\`, a table with no migration) is named for you to write. No journey is generated for a table whose row needs a parent (a foreign key): write that table's journey yourself.
44
+
45
+ A journey failing on a CHECK constraint: generation marks the row by the first free text or number column with no foreign key, no allowed-values CHECK and no ceiling on a number, so that column's other CHECKs must admit an arbitrary unique value. Only the generated file names the column.
42
46
 
43
47
  ## Commands
44
48
 
45
- - \`isomorph dev --app-root .\` — local Postgres, storage, gateway and Vite behind one loopback origin; company calls use the \`isomorph login\` account.
46
- - \`isomorph check --app-root . --json\` — types, build, then the pipeline's own kit gate locally (declaration, database gate, write probe with cross-user denial, journeys, operation coverage); report in \`.isomorph/local/check-report.json\`.
47
- - \`isomorph productionise --app-root . …\` — saves and deploys the private preview; \`isomorph promote\` after the person has tried it.
49
+ - \`isomorph dev --app-root .\` — local Postgres, storage, gateway and Vite on one origin; company calls use the \`isomorph login\` account.
50
+ - \`isomorph check --app-root . --json\` — types, build, then the pipeline's kit gate locally (declaration, database, write probe, journeys, coverage).
51
+ - \`isomorph productionise --app-root . …\` — private preview; \`isomorph promote\` makes it live.
48
52
 
49
53
  ## Error codes
50
54
 
51
- - \`CONFIG_REQUIRED\`: no company connected — \`isomorph connect <work-email>\`.
52
- - \`AUTH_REQUIRED\`: sign in — \`isomorph login\`.
53
- - \`NOT_A_MEMBER\` / \`TENANT_AMBIGUOUS\`: no company, or more than one, admits that email domain — use the command it printed or IT's link.
54
- - \`CHECKS_FAILED\` / \`CHECKS_STALE\`: fix what \`isomorph check\` reports and run it again on this exact code.
55
+ - \`CONFIG_REQUIRED\`: \`isomorph connect <work-email>\`; \`AUTH_REQUIRED\`: \`isomorph login\`.
56
+ - \`NOT_A_MEMBER\` / \`TENANT_AMBIGUOUS\`: use the command it printed or IT's link.
57
+ - \`CHECKS_FAILED\` / \`CHECKS_STALE\`: fix what \`isomorph check\` reports, then rerun on this code.
55
58
  - \`APP_NOT_FOUND\`: the app is linked to another company — connect back to it with its start link, or clear \`appId\` and \`tenantId\` in \`.isomorph/kit.lock.json\` to relink.
56
- - \`INTEGRATIONS_NOT_READY\`: IT has not approved a declared connection yet (integrations.md).
57
- - \`AI_NOT_READY\`: IT has not enabled the company's AI setup yet (ai.md).`,
59
+ - \`INTEGRATIONS_NOT_READY\` / \`AI_NOT_READY\`: IT has not approved the connection (integrations.md) or enabled AI (ai.md).`,
58
60
  integrations: `# Company systems (Slack, Gmail, warehouse)
59
61
 
60
62
  ## Declare
61
63
 
62
- Reach company systems only through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\`. Operations, a closed set: \`slack.channel.history\` (user identity), \`slack.message.post\` (app or user identity, declared per app), \`gmail.thread.list\`, \`gmail.message.read\` and \`gmail.message.send\` (user identity, resource \`inbox\`, plain-text send), \`warehouse.view.read\` (app identity). Resources are logical names, never IDs, URLs or tokens. The file starts as \`"connections": {}\`: declare a connection only when the app really calls it, by the identifier the catalog lists, with only the operations it calls — each declared connection blocks the deploy until IT grants it, and an undeclared one cannot be requested (\`CONNECTION_NOT_DECLARED\`). Strict JSON, no comments.
64
+ Reach company systems only through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\`. Operations, a closed set: \`slack.channel.history\` (user identity), \`slack.message.post\` (app or user identity, declared per app), \`gmail.thread.list\`, \`gmail.message.read\` and \`gmail.message.send\` (user identity, resource \`inbox\`, plain-text send), \`warehouse.view.read\` (app identity). Resources are logical names, never IDs, URLs or tokens. The file starts as \`"connections": {}\`: declare one only when the app calls it, by the catalog's identifier, with only the operations it calls — each declared connection blocks the deploy until IT grants it; an undeclared one cannot be requested (\`CONNECTION_NOT_DECLARED\`). Strict JSON, no comments.
63
65
 
64
66
  \`\`\`json
65
67
  "company-slack": { "kind": "saas", "operations": { "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } },
66
68
  "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
67
69
  \`\`\`
68
70
 
71
+ A connection that was READY reporting PENDING again: widening an approved declaration (another column on a declared resource, or another operation) re-files the request, and a database connection's development lane re-enters provisioning while access is re-minted, so the widened read fails until it republishes. Nothing is revoked: the record stays GRANTED with the resources it had, and the window closes on its own — re-check the status rather than treat it as a blocker.
72
+
69
73
  ## Catalog
70
74
 
71
- Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`isomorph integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly; if the object or column is absent, report that catalog gap instead of substituting a plausible name. A resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`: inspect the returned row keys; do not invent a schema or ask IT for one.
75
+ Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`isomorph integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly; if either is absent, report that catalog gap instead of substituting a plausible name. A resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`: inspect the returned row keys; never invent a schema.
72
76
 
73
77
  ## Request
74
78
 
75
- One request per connection; IT approves it once for every environment. \`isomorph dev\` and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` files it now — submit the request yourself in the same turn, and never tell the person to ask IT before you have submitted the request.
79
+ One request per connection; IT approves it once for every environment. \`isomorph dev\` and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<why the app needs it>" --app-root . --json\` files it now — submit the request yourself in the same turn, and never tell the person to ask IT before you have submitted the request.
76
80
 
77
81
  ## Status
78
82
 
79
- READY: use it now. PENDING: say "IT has to approve this; the app works without it until then" and check later with \`isomorph integrations status --app-root . --json\`. Before a real integration test, read that status; show pending IT approval and missing consent separately, and test ready destinations independently. Retained checks that call \`isomorph.integrations.execute\` get the gate's fixture, locally and in the pipeline alike (nothing sent or read; the report says so); only \`isomorph check --integrations\` (reads) exercises real access.
83
+ READY: use it now. PENDING: say "IT has to approve this; the app works without it until then" and check later with \`isomorph integrations status --app-root . --json\`. Before a real integration test read that status: show pending approval and missing consent separately. Retained checks calling \`isomorph.integrations.execute\` get the gate's fixture, locally and in the pipeline (nothing sent or read); only \`isomorph check --integrations\` (reads) exercises real access.
80
84
 
81
85
  ## Call shape
82
86
 
@@ -92,11 +96,11 @@ Input bounds: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.li
92
96
 
93
97
  ## Slack modes
94
98
 
95
- \`"identity": "app"\` posts as the company's Slack bot, Isomorph AI, under the connection's \`presentation.displayName\` (optional \`iconEmoji\`), which IT approves — leave it out to post as Isomorph AI itself; every app-mode post ends "Posted by <app> on Isomorph". \`"identity": "user"\` posts as the person after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\` — offer Connect again. Never pretend one is the other. The declaration is the mode requested; the call names the approved mode it runs under (\`mode: "app"\` or \`"user"\`): optional with one approved mode, required with both (\`MODE_REQUIRED\`), and an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel — say "IT (or anyone in the channel) has to run \`/invite @Isomorph AI\` in #<channel> first". On a request: the resource is not on the connection yet — IT adds it in the console under Controls & integrations, then you run the same request again.
99
+ \`"identity": "app"\` posts as the company's Slack bot, Isomorph AI, under the connection's IT-approved \`presentation.displayName\` (optional \`iconEmoji\`); omit it to post as Isomorph AI itself. \`"identity": "user"\` posts as the person after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\`: offer Connect again. Never pretend one is the other. The call names the approved mode it runs under (\`mode: "app"\` or \`"user"\`): optional with one approved mode, required with both (\`MODE_REQUIRED\`); an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel — say "IT (or anyone in the channel) runs \`/invite @Isomorph AI\` in #<channel>". On a request: the resource is not on the connection yet — IT adds it in the console under Controls & integrations, then run the same request again.
96
100
 
97
101
  ## Consent
98
102
 
99
- Only user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`) need it; build the flow in unasked, during the original implementation: the operation's control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state and lets the person continue. Consent starts only from that person's control, never on page load; missing consent never falls back to another account. App-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect — it is refused as unapproved user access and puts nothing in IT's queue.
103
+ Only user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`) need it; build the flow in unasked, during the original implementation: the operation's control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state and lets the person continue. Consent starts from that control, never on page load; missing consent never falls back to another account. App-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect — it is refused and puts nothing in IT's queue.
100
104
 
101
105
  ## Sends
102
106
 
@@ -205,8 +205,24 @@ export function openRequestId(lanes) {
205
205
  }
206
206
  /** A granted lane past IT's expiry (the server reads it as failed; the sentence says why). */
207
207
  const expired = (grant, nowMs) => grant.status === "GRANTED" && Boolean(grant.expiresAt && Date.parse(grant.expiresAt) <= nowMs);
208
- /** A lane a deploy can run on: granted, unexpired, not still provisioning or failed (a person's consent is asked in the app, at runtime). */
209
- const laneReady = (grant, nowMs) => grant.status === "GRANTED" && !expired(grant, nowMs) && grant.readiness !== "pending" && grant.readiness !== "failed";
208
+ /**
209
+ * The worker is minting this lane's credentials right now. `readiness` cannot say so on its own —
210
+ * the server folds both waits into `pending` — but the grant summary has always carried the
211
+ * provisioning status beside it, and the two together separate seconds from days: `REQUESTED` waits
212
+ * on a person at IT, while `PENDING` provisioning follows a decision already taken (IT's, the anchor
213
+ * rule's, or the connection's development preapproval) and clears itself. Observed 2026-09-14: one
214
+ * added column re-files an approved warehouse request, the still-GRANTED lanes are re-minted, and
215
+ * reading that as IT's sent a builder off to report a permanently broken development lane.
216
+ */
217
+ const minting = (grant) => grant.readiness === "pending" && grant.provisioning === "PENDING";
218
+ /**
219
+ * Which readiness values can carry a deploy, as a total map so `npm run check` refuses a new
220
+ * readiness value until this question has been answered for it. A person's consent is asked
221
+ * inside the running app, so a consent lane deploys.
222
+ */
223
+ const DEPLOYABLE = { ready: true, consent_required: true, reconnect_required: true, pending: false, failed: false };
224
+ /** A lane a deploy can run on: granted, unexpired, on a readiness that can carry one, and not still being minted — implied by `pending` today, and asked anyway so the gate fails closed if that map ever changes. */
225
+ const laneReady = (grant, nowMs) => grant.status === "GRANTED" && !expired(grant, nowMs) && DEPLOYABLE[grant.readiness] && !minting(grant);
210
226
  /** Builder-safe failure guidance as one sentence: what failed, then what to do. */
211
227
  export const failureSentence = (failure) => `${failure.message} ${failure.remediation}`;
212
228
  const failureError = (failure) => new CliError(failure.code, failureSentence(failure));
@@ -218,12 +234,17 @@ const failureError = (failure) => new CliError(failure.code, failureSentence(fai
218
234
  * read right after filing may not carry the anchor yet, so the filing's own
219
235
  * request id is the fallback). Development ready while another lane waits is
220
236
  * the connection's development preapproval: the server writes that lane at
221
- * filing time and keeps the request open for IT.
237
+ * filing time and keeps the request open for IT. A lane the worker is still
238
+ * minting reads `being set up`: it carries the request it was re-filed under,
239
+ * but nobody has to decide anything and it clears itself in seconds.
222
240
  */
223
241
  export function laneSummary(group, requestHint, nowMs = Date.now()) {
224
242
  const { lanes } = group;
225
243
  const open = openRequestId(lanes) ?? requestHint;
226
- const waitingOn = (lane) => { const grant = lanes[lane]; return grant ? (grant.readiness === "pending" ? grant.pendingRequestId ?? requestHint : undefined) : open; };
244
+ // Only a lane a person still has to decide is named as IT's. A lane being minted is `pending` too and always
245
+ // carries the request id it was re-filed under, so naming it here was the whole bug — and it also turned a
246
+ // sibling `ready` development lane into a "(preapproved)" one, as if something were being held back from it.
247
+ const waitingOn = (lane) => { const grant = lanes[lane]; return grant ? (grant.readiness === "pending" && !minting(grant) ? grant.pendingRequestId ?? requestHint : undefined) : open; };
227
248
  const word = (lane) => {
228
249
  const grant = lanes[lane];
229
250
  const request = waitingOn(lane);
@@ -236,6 +257,8 @@ export function laneSummary(group, requestHint, nowMs = Date.now()) {
236
257
  case "ready": return { base: "ready", suffix: lane === "development" && ENVIRONMENTS.some(other => other !== lane && waitingOn(other)) ? ` (preapproved${until})` : until };
237
258
  case "consent_required": return { base: "approved", suffix: " (connect your account in the app)" };
238
259
  case "reconnect_required": return { base: "approved", suffix: " (reconnect your account in the app)" };
260
+ // `being set up` is reachable at last: a minting lane is `pending` here too, and `waitingOn` no longer hands
261
+ // it a request id, so it falls through to the words that were always the right ones for it.
239
262
  case "pending": return { base: request ? `waiting for IT (request ${request})` : "being set up", suffix: "" };
240
263
  case "failed": return { base: grant.status === "DENIED" ? "denied" : grant.status === "REVOKED" ? "revoked" : "failed", suffix: "" };
241
264
  }
@@ -277,6 +300,9 @@ export async function assertPreviewIntegrationsReady(root, client, tenantId, bun
277
300
  if (!filed)
278
301
  return (await readKitLock(root))?.appId || undefined;
279
302
  const nowMs = Date.now();
303
+ // `awaitsDecision` separates the refusals a person has to act on from a preview lane the worker is
304
+ // simply still minting: that one refuses too (there are no credentials to deploy against yet), but
305
+ // saying "Waiting for IT" over it is the same false blocker the minting predicate exists to end.
280
306
  const waiting = [];
281
307
  for (const item of settled(filed.requests)) {
282
308
  const group = findGroup(filed.groups, item.connection, item.identityMode);
@@ -286,17 +312,24 @@ export async function assertPreviewIntegrationsReady(root, client, tenantId, bun
286
312
  throw failureError(preview.failure);
287
313
  if (preview && laneReady(preview, nowMs))
288
314
  continue;
315
+ if (preview && minting(preview)) {
316
+ waiting.push({ awaitsDecision: false, line: `${item.connection} ${IDENTITY_WORDS[item.identityMode]} is being set up for the preview; nobody has to approve anything and it finishes on its own.` });
317
+ continue;
318
+ }
289
319
  const requestId = item.outcome.requestId ?? openRequestId(group.lanes);
290
- waiting.push(requestId
291
- ? `Waiting for IT: ${item.connection} ${IDENTITY_WORDS[item.identityMode]} (request ${requestId}).`
292
- : `${item.connection} ${IDENTITY_WORDS[item.identityMode]} is not ready for the preview (${laneSummary(group, undefined, nowMs)}); ask IT to review this app's access in the Isomorph console.`);
320
+ waiting.push({ awaitsDecision: true, line: requestId
321
+ ? `Waiting for IT: ${item.connection} ${IDENTITY_WORDS[item.identityMode]} (request ${requestId}).`
322
+ : `${item.connection} ${IDENTITY_WORDS[item.identityMode]} is not ready for the preview (${laneSummary(group, undefined, nowMs)}); ask IT to review this app's access in the Isomorph console.` });
293
323
  }
294
324
  if (!waiting.length)
295
325
  return filed.appId;
296
326
  output("Isomorph cannot deploy the preview yet:");
297
- for (const line of waiting)
327
+ for (const { line } of waiting)
298
328
  output(` ${line}`);
299
- throw new CliError("INTEGRATIONS_NOT_READY", `${waiting.join(" ")} IT approves a request once, for every environment; rerun productionise when it is approved.`);
329
+ const next = waiting.some(item => item.awaitsDecision)
330
+ ? "IT approves a request once, for every environment; rerun productionise when it is approved."
331
+ : "Run productionise again in a minute.";
332
+ throw new CliError("INTEGRATIONS_NOT_READY", `${waiting.map(item => item.line).join(" ")} ${next}`);
300
333
  }
301
334
  /**
302
335
  * `productionise` / `promote` pre-check for an app that calls governed AI
@@ -386,5 +419,5 @@ export async function integrationsStatus(root, client) {
386
419
  if (!lock?.appId)
387
420
  return { linked: false, grants: [], requests: [] };
388
421
  const listed = await client.list(lock.appId);
389
- return { appId: lock.appId, linked: true, grants: listed.grants.map(grant => ({ connection: grant.connection, environment: grant.environment, identityMode: grant.identityMode, status: grant.status, operations: grant.operations, resources: grant.resources, expiresAt: grant.expiresAt, readiness: grant.readiness, pendingRequestId: grant.pendingRequestId, failure: grant.failure })), requests: listed.requests };
422
+ return { appId: lock.appId, linked: true, grants: listed.grants.map(grant => ({ connection: grant.connection, environment: grant.environment, identityMode: grant.identityMode, status: grant.status, operations: grant.operations, resources: grant.resources, expiresAt: grant.expiresAt, readiness: grant.readiness, provisioning: grant.provisioning, pendingRequestId: grant.pendingRequestId, failure: grant.failure })), requests: listed.requests };
390
423
  }
@@ -1,6 +1,6 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "isomorph.kit-bundle/1.0",
3
- "kitVersion": "0.3.0",
3
+ "kitVersion": "0.3.1",
4
4
  "sdk": {
5
5
  "package": "@isomorph.ai/app-sdk",
6
6
  "version": "1.2.0",
@@ -8,21 +8,21 @@ export const PUBLISHED_KIT_BUNDLE = {
8
8
  "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:6b41494883215527a73e05a1e07d022552dfe3402915e23950e357e5e9381c49"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:cdb494a98ddcab21a42b9cb8089d077007da1d4f178458cef94b3aa9ad6e1a11",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:34d5e175a6346636a25fe8c6f9b730e93d67ef1e3361b0349c31f2a2da3322a8"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:af841461de984099c202b5d0c86321c5e5f51713f3bcd487f0fc904b17a30d3a",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:fe94fb879357520bc9b34db590a64b6c82cb63e59b97ca76d1cde470e84f7df3"
13
13
  },
14
14
  "nativeRuntime": {
15
15
  "darwinArm64": {
16
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:a07e18b61ca9b5db8077fd37ced21ca29bc4e37049f10724c61cea5a68d46077",
17
- "sha256": "a07e18b61ca9b5db8077fd37ced21ca29bc4e37049f10724c61cea5a68d46077"
16
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:56db6d0080e0f38401d8a4a6cba0dfd44fd66878d574e88df8b6ffe63551fa4e",
17
+ "sha256": "56db6d0080e0f38401d8a4a6cba0dfd44fd66878d574e88df8b6ffe63551fa4e"
18
18
  },
19
19
  "linuxX64": {
20
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:43b80fbe2153c79ccca6b663d79e32e100f56b6ada896f0bb192cfd23bbfc1cc",
21
- "sha256": "43b80fbe2153c79ccca6b663d79e32e100f56b6ada896f0bb192cfd23bbfc1cc"
20
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:082272dd1823d51e05d5f9497c5644dd62dd3996ffe83ae69f357471d7d492a0",
21
+ "sha256": "082272dd1823d51e05d5f9497c5644dd62dd3996ffe83ae69f357471d7d492a0"
22
22
  },
23
23
  "windowsX64": {
24
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:540020f44f874d20c28fbd19b8879aab9f3e41aa2d228f9b5b7bc301ac428afd",
25
- "sha256": "540020f44f874d20c28fbd19b8879aab9f3e41aa2d228f9b5b7bc301ac428afd"
24
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:5341eab77337713255fa3175f14038d702b07332bb0073b5bba333ba5adf5bf1",
25
+ "sha256": "5341eab77337713255fa3175f14038d702b07332bb0073b5bba333ba5adf5bf1"
26
26
  }
27
27
  },
28
28
  "brief": {
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { allowedLiterals, numericMinimum, textLengthBounds } from "./app-schema.js";
4
+ import { allowedLiterals, hasNumericMaximum, numericMinimum, textLengthBounds } from "./app-schema.js";
5
5
  import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./source-inventory.js";
6
6
  import { kitPaths } from "./kit.js";
7
7
  /**
@@ -334,7 +334,7 @@ function rowShape(schema, needsUpdate) {
334
334
  return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent — rule core.journey-fk (core.md in the isomorph skill)" : "has a type Isomorph cannot invent a value for"}` };
335
335
  const marker = writable.find(column => isMarker(column) && column.notNull && !column.hasDefault) ?? writable.find(isMarker);
336
336
  if (!marker)
337
- return { blocked: "it has no free text or number column a check could write a unique value into and find the row by" };
337
+ return { blocked: markerless(writable) };
338
338
  const markerExpression = marker.type === "text" ? textMarker(marker) : "Date.now() % 2000000000";
339
339
  if (!markerExpression)
340
340
  return { blocked: `\`${marker.name}\` is the only column a check could find a row by, and its CHECK constraint leaves no room for a unique value` };
@@ -345,6 +345,12 @@ function rowShape(schema, needsUpdate) {
345
345
  return { blocked: "the app updates it, but every column Isomorph could change is the one the check finds the row by" };
346
346
  return { values: [`${marker.name}: markerValue`, ...fill], marker, markerExpression, locator, uuid: fill.some(value => value.includes("randomUUID(")), update };
347
347
  }
348
+ /** Why nothing on the table can carry the unique value a journey finds its row by — naming the capped number when that is what ruled the last candidate out, so the answer is a column to add rather than a CHECK to weaken. */
349
+ function markerless(writable) {
350
+ const none = "it has no free text or number column a check could write a unique value into and find the row by";
351
+ const capped = writable.find(cappedNumber);
352
+ return capped ? `${none}: \`${capped.name} ${capped.declaredType}\` is a number its own CHECK caps, and the unique value a journey writes is a timestamp` : none;
353
+ }
348
354
  /** What a journey writes into a free-text column it updates, when the column's length bounds admit it. */
349
355
  const UPDATED_TEXT = "isomorph check updated";
350
356
  /** The column a journey changes, and what it changes it to: a boolean is flipped, a closed set moves to its other value, free text gets a fixed one. */
@@ -366,8 +372,10 @@ function updateStep(writable, marker, locator) {
366
372
  }
367
373
  return undefined;
368
374
  }
375
+ /** A number the app's own CHECK caps from above: the unique value a journey marks a row with is a timestamp, which no ordinary ceiling admits, so such a column is never the marker. */
376
+ const cappedNumber = (column) => (column.type === "integer" || column.type === "number") && hasNumericMaximum(column.checks, column.name);
369
377
  /** A column a check can write a unique value into and then filter on. */
370
- const isMarker = (column) => (column.type === "text" || column.type === "integer" || column.type === "number") && !column.references && !allowedLiterals(column.checks, column.name).length;
378
+ const isMarker = (column) => (column.type === "text" || column.type === "integer" || column.type === "number") && !column.references && !cappedNumber(column) && !allowedLiterals(column.checks, column.name).length;
371
379
  /** A unique marker satisfying the column's own length constraints, or `undefined` when they leave no room for one. */
372
380
  function textMarker(column) {
373
381
  const { min, max } = textLengthBounds(column.checks, column.name);
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.3.1-rc.1";
1
+ export const CLI_VERSION = "0.3.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.3.1-rc.1",
3
+ "version": "0.3.1",
4
4
  "description": "Isomorph development kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "harbour": {
38
38
  "kitBundle": {
39
39
  "repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
40
- "version": "0.3.0"
40
+ "version": "0.3.1"
41
41
  }
42
42
  }
43
43
  }