@isomorph.ai/cli 0.3.4 → 0.4.0-rc.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.
@@ -9,82 +9,70 @@
9
9
  * no-duplication rule are enforced by tests/cli-guide-budget.test.ts.
10
10
  */
11
11
  export const GUIDE_MODULES = {
12
- core: `# Isomorph kit — core rules
12
+ core: `# Isomorph kit rules
13
13
 
14
14
  ## The SDK is the only door
15
15
 
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/\`.
16
+ \`@isomorph.ai/app-sdk\` (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), \`isomorph.ai.chat\` (ai.md). No database, bucket or company URL from browser code; no backend, auth library, deployment config, provider key or other SDK; no secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/\`, never \`.isomorph/local/\`.
17
17
 
18
18
  ## Identity
19
19
 
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.
20
+ Isomorph SSO signs everyone in: no login forms, browser-trusted roles or public routes. \`isomorph.identity.current()\` answers \`{ id, email }\` only.
21
21
 
22
- ## Data (\`core.rls-owner-scoped\`)
22
+ ## Data
23
23
 
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:
24
+ SQL files in \`migrations/\`; applied migrations are frozen after the first deploy (add the next file). Every table: RLS on, one policy; ownership decided in SQL (the gate names any privilege a table still lacks):
25
25
 
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
-
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
-
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
-
33
- ## Files
34
-
35
- \`isomorph.files.upload(path, file)\`, \`list(prefix)\` and \`remove([path])\`.
26
+ \`\`\`sql
27
+ ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
28
+ CREATE POLICY notes_owner ON notes
29
+ USING (owner_subject = current_setting('harbour.user_id', true))
30
+ WITH CHECK (owner_subject = current_setting('harbour.user_id', true));
31
+ \`\`\`
36
32
 
37
- ## Realtime
33
+ Shared-read, owner-write: the same, plus \`CREATE POLICY posts_read ON posts FOR SELECT USING (true);\`.
38
34
 
39
- \`isomorph.realtime.channel(name).on("postgres_changes", { event: "*" | "INSERT" | "UPDATE" | "DELETE", schema: "public", table }, payload => …).subscribe()\`; the payload carries \`eventType\`, \`new\` and \`old\`.
35
+ \`.isomorph/checks/\` is generated by \`isomorph check\`, which adds and deletes files as the app changes; never write or edit it by hand.
40
36
 
41
- ## Retained checks (\`core.journey-fk\`)
37
+ ## Files and realtime
42
38
 
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.
39
+ \`isomorph.files.upload(path, file)\`, \`list(prefix)\`, \`remove([path])\`.
44
40
 
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.
41
+ \`isomorph.realtime.channel(name).on("postgres_changes", { event: "*" | "INSERT" | "UPDATE" | "DELETE", schema: "public", table }, payload => …).subscribe()\` (payload: \`eventType\`, \`new\`, \`old\`).
46
42
 
47
43
  ## Commands
48
44
 
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.
45
+ \`isomorph dev --app-root . --detach --json\` (run), \`isomorph check --app-root . --json\` (check), \`isomorph deploy --app-root . --json\` (private preview; \`isomorph promote\` makes it live).
52
46
 
53
47
  ## Error codes
54
48
 
55
49
  - \`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.
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.
59
- - \`INTEGRATIONS_NOT_READY\` / \`AI_NOT_READY\`: IT has not approved the connection (integrations.md) or enabled AI (ai.md).`,
50
+ - \`CHECKS_FAILED\`: fix what \`isomorph check\` reports, then rerun.
51
+ - \`DEPLOY_BLOCKED\`, \`INTEGRATIONS_NOT_READY\`, \`AI_NOT_READY\`: IT's step is in the refusal; the app works meanwhile.
52
+ - \`CLI_UPGRADE_REQUIRED\`: \`npm i -g @isomorph.ai/cli\`.
53
+ - \`APP_NOT_FOUND\`: clear \`appId\` and \`tenantId\` in \`.isomorph/kit.lock.json\` to relink.`,
60
54
  integrations: `# Company systems (Slack, Gmail, warehouse)
61
55
 
62
56
  ## Declare
63
57
 
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.
58
+ Company systems are reached through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\` by catalog identifier, with only the operations called. Closed set: \`slack.channel.history\` (user), \`slack.message.post\` (app or user), \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\` (user, \`inbox\`), \`warehouse.view.read\` (app). Resources are logical names, never IDs. Strict JSON.
65
59
 
66
60
  \`\`\`json
67
61
  "company-slack": { "kind": "saas", "operations": { "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } },
68
62
  "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
69
63
  \`\`\`
70
64
 
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
-
73
65
  ## Catalog
74
66
 
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.
67
+ Never guess a connection, channel, table, view, mailbox or column: a guess is refused before IT sees it. Before a warehouse query or field mapping, run \`isomorph integrations catalog --app-root . --json\`, pick a listed table or view and copy its column names exactly; report a gap rather than invent a name. Columns \`["*"]\`: read \`["*"]\` and inspect the row keys.
76
68
 
77
69
  ## Request
78
70
 
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.
80
-
81
- ## Status
82
-
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.
71
+ \`isomorph integrations request <connection> --reason "<why>" --app-root . --json\` files the one request IT approves once for every environment (\`isomorph dev\` and \`isomorph deploy\` file it too); submit it yourself, in the same turn. \`isomorph integrations status --app-root . --json\`: READY, use it; PENDING, say "IT has to approve this; the app works meanwhile". \`RESOURCE_NOT_APPROVED\`: IT adds the channel, view or mailbox in the console (Controls & integrations); then run the same request again.
84
72
 
85
73
  ## Call shape
86
74
 
87
- On the exported client (\`src/isomorph.client.ts\`), always this exact shape and never a wrapper function (nor a generic string/unknown wrapper): the kit gate derives what the app uses from the direct call.
75
+ Always this exact shape, never a wrapper function: the kit gate reads the direct call.
88
76
 
89
77
  \`\`\`ts
90
78
  const report = await isomorph.integrations.execute("sales-warehouse", {
@@ -92,19 +80,17 @@ const report = await isomorph.integrations.execute("sales-warehouse", {
92
80
  });
93
81
  \`\`\`
94
82
 
95
- Input bounds: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.list\` \`input.limit\` 1..15, \`warehouse.view.read\` \`input.limit\` 1..1000 (\`VIEW_READ_MAX_LIMIT\`); larger is refused with \`INPUT_INVALID\`, so page instead.
96
-
97
- ## Slack modes
98
-
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.
100
-
101
83
  ## Consent
102
84
 
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.
85
+ Only user-identity operations need it, built in unasked: the control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` on \`consent_required\`, and returns to a connected state. Never on page load; app-identity operations never call connect.
104
86
 
105
87
  ## Sends
106
88
 
107
- A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control — pressed by the person, or by you for an explicitly authorized test — with a fresh UUID \`idempotencyKey\` per press (reused only to retry it); never from an effect, a browser timer or a check. After a partial send, retry only the failed destination with its original key. Scheduled sends: jobs.md.`,
89
+ A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control the person presses, with a fresh UUID \`idempotencyKey\` per press (reused only to retry); never from an effect, a timer or a check. Scheduled sends: jobs.md.
90
+
91
+ ## Slack modes
92
+
93
+ \`"identity": "app"\` posts as the company's bot under IT's approved \`presentation.displayName\`; \`"identity": "user"\` posts as the person after consent (\`USER_RECONNECT_REQUIRED\`: offer Connect again). Name the \`mode\` when both are approved (\`MODE_REQUIRED\`); an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel; anyone there runs \`/invite @Isomorph AI\`.`,
108
94
  ai: `# Governed AI
109
95
 
110
96
  AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxTokens })\` call on the app's one client (\`src/isomorph.client.ts\`), never through a wrapper function (the gate reads only the direct call), behind a control the person presses — never on load, in an effect or a timer. Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the governed AI gateway holds the key and IT sees every call. Send \`messages\` and \`maxTokens\` and nothing else; a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has not enabled an AI provider yet: say so in one line and keep the app working without AI.
@@ -1,6 +1,7 @@
1
1
  import { basename } from "node:path";
2
2
  import { linkIdempotencyKey, OPERATIONS, readDeclaration, readKitLock, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
3
3
  import { CliError } from "./output.js";
4
+ import { CLI_VERSION } from "./version.js";
4
5
  export class GovernanceClient {
5
6
  apiUrl;
6
7
  token;
@@ -29,9 +30,14 @@ export class GovernanceClient {
29
30
  execute(appId, body) {
30
31
  return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integrations/execute`, body);
31
32
  }
32
- /** Would this app's deployment PLAN pass for the environment? Read-only; governance answers from the company's AI setup. */
33
- aiReadiness(appId, environment) {
34
- return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/ai/readiness`, { environment });
33
+ /**
34
+ * Every blocker of a deploy at once: the grants the declaration needs on the
35
+ * environment's lane (governance files the requests itself), the company's AI
36
+ * setup when the app calls governed AI, and the CLI version. One call, one
37
+ * refusal shape, instead of the sequential integrations → AI → pipeline cycles.
38
+ */
39
+ deployPreflight(appId, body) {
40
+ return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/deploy-preflight`, body);
35
41
  }
36
42
  /**
37
43
  * What the building agent says cost it time in Isomorph itself. Never blocks a
@@ -46,9 +52,9 @@ export class GovernanceClient {
46
52
  .catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
47
53
  const parsed = await response.json().catch(() => ({}));
48
54
  if (response.status === 401)
49
- throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.");
55
+ throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
50
56
  if (!response.ok)
51
- throw new CliError(parsed.error?.details?.code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message ?? "Isomorph governance rejected the request.");
57
+ throw new CliError(parsed.error?.details?.code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message ?? "Isomorph governance rejected the request.", undefined, undefined, undefined, { layer: "governance" });
52
58
  return (parsed.data ?? parsed);
53
59
  }
54
60
  }
@@ -105,7 +111,7 @@ function settled(requests) {
105
111
  }
106
112
  /**
107
113
  * Files the app-wide request for every connection the app declares, one per
108
- * identity mode — what `isomorph dev` (signed in) and `productionise` do before
114
+ * identity mode — what `isomorph dev` (signed in) and `deploy` do before
109
115
  * anything else, so IT's queue holds the ask from the first run and no command
110
116
  * stands between a builder and an approval. Idempotent: governance answers
111
117
  * READY for a scope IT has approved and PENDING with the existing request for
@@ -189,7 +195,7 @@ export function unregisteredResourceGuidance(error, declaration, connection, roo
189
195
  const place = declared?.kind === "database"
190
196
  ? `Controls & integrations → Databases → ${connection} → Data resources`
191
197
  : `Controls & integrations → API integrations → ${provider} → Configure → ${provider === "Gmail" ? "the mailbox" : "Channels"}`;
192
- return new CliError(error.code, `IT has to add ${resource} to the ${connection} connection first (in the Isomorph console: ${place}); the app works without it until then, and once it is added run \`isomorph integrations request ${connection} --app-root ${root}\` again (\`isomorph dev\` and \`isomorph productionise\` file the request too).`, error.operationRef);
198
+ return new CliError(error.code, `IT has to add ${resource} to the ${connection} connection first (in the Isomorph console: ${place}); the app works without it until then, and once it is added run \`isomorph integrations request ${connection} --app-root ${root}\` again (\`isomorph dev\` and \`isomorph deploy\` file the request too).`, error.operationRef, undefined, undefined, { layer: "governance" });
193
199
  }
194
200
  export function groupGrants(grants) {
195
201
  const groups = new Map();
@@ -222,17 +228,9 @@ const expired = (grant, nowMs) => grant.status === "GRANTED" && Boolean(grant.ex
222
228
  * reading that as IT's sent a builder off to report a permanently broken development lane.
223
229
  */
224
230
  const minting = (grant) => grant.readiness === "pending" && grant.provisioning === "PENDING";
225
- /**
226
- * Which readiness values can carry a deploy, as a total map so `npm run check` refuses a new
227
- * readiness value until this question has been answered for it. A person's consent is asked
228
- * inside the running app, so a consent lane deploys.
229
- */
230
- const DEPLOYABLE = { ready: true, consent_required: true, reconnect_required: true, pending: false, failed: false };
231
- /** 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. */
232
- const laneReady = (grant, nowMs) => grant.status === "GRANTED" && !expired(grant, nowMs) && DEPLOYABLE[grant.readiness] && !minting(grant);
233
231
  /** Builder-safe failure guidance as one sentence: what failed, then what to do. */
234
232
  export const failureSentence = (failure) => `${failure.message} ${failure.remediation}`;
235
- const failureError = (failure) => new CliError(failure.code, failureSentence(failure));
233
+ const failureError = (failure) => new CliError(failure.code, failureSentence(failure), undefined, undefined, undefined, { layer: "governance" });
236
234
  /**
237
235
  * One sentence for the three lanes: `ready in development, preview, production`
238
236
  * when they agree, otherwise the lanes that differ — `development ready
@@ -295,67 +293,42 @@ export function renderGrantGroup(group, nowMs = Date.now()) {
295
293
  return lines;
296
294
  }
297
295
  /**
298
- * `productionise` pre-check. Files the app-wide request for every declared
299
- * connection (idempotent: READY comes straight back once IT has approved), then
300
- * reads the preview lane — the one this deploy runs in — and refuses before any
301
- * operation exists, naming exactly what is still waiting. Links the app when
302
- * needed (the same key as `integrations request`) so both share one appId.
303
- * Returns the kit appId, or undefined for a non-kit app.
304
- */
305
- export async function assertPreviewIntegrationsReady(root, client, tenantId, bundle, output) {
306
- const filed = await fileDeclaredRequests(root, client, tenantId, bundle);
307
- if (!filed)
308
- return (await readKitLock(root))?.appId || undefined;
309
- const nowMs = Date.now();
310
- // `awaitsDecision` separates the refusals a person has to act on from a preview lane the worker is
311
- // simply still minting: that one refuses too (there are no credentials to deploy against yet), but
312
- // saying "Waiting for IT" over it is the same false blocker the minting predicate exists to end.
313
- const waiting = [];
314
- for (const item of settled(filed.requests)) {
315
- const group = findGroup(filed.groups, item.connection, item.identityMode);
316
- const preview = group.lanes.preview;
317
- // Guidance also rides on a lane that is still being retried; only a failed readiness is a refusal in its own words.
318
- if (preview?.failure && preview.readiness === "failed")
319
- throw failureError(preview.failure);
320
- if (preview && laneReady(preview, nowMs))
321
- continue;
322
- if (preview && minting(preview)) {
323
- 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.` });
324
- continue;
325
- }
326
- const requestId = item.outcome.requestId ?? openRequestId(group.lanes);
327
- waiting.push({ awaitsDecision: true, line: requestId
328
- ? `Waiting for IT: ${item.connection} ${IDENTITY_WORDS[item.identityMode]} (request ${requestId}).`
329
- : `${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.` });
330
- }
331
- if (!waiting.length)
332
- return filed.appId;
333
- output("Isomorph cannot deploy the preview yet:");
334
- for (const { line } of waiting)
335
- output(` ${line}`);
336
- const next = waiting.some(item => item.awaitsDecision)
337
- ? "IT approves a request once, for every environment; rerun productionise when it is approved."
338
- : "Run productionise again in a minute.";
339
- throw new CliError("INTEGRATIONS_NOT_READY", `${waiting.map(item => item.line).join(" ")} ${next}`);
340
- }
341
- /**
342
- * `productionise` / `promote` pre-check for an app that calls governed AI
343
- * (the local gate's inventory lists `ai` among its capabilities): the
344
- * pipeline will ask governance for a PLAN over the inventory the kit shipped,
345
- * and that PLAN blocks on the company's AI setup — no provider, no route, no
346
- * budget, observe mode before production. Ask the same question here, before
347
- * any build, and refuse with the one IT step, the way a missing grant is
348
- * refused. Observed without it (fourier app-f578bfc2, 2026-09-12): a green
349
- * check, a live preview, and a button that answered AI_UNAVAILABLE.
296
+ * `deploy` / `promote` pre-flight, one call. Links the app when needed (the
297
+ * same key as `integrations request`) so both share one appId, then asks
298
+ * governance whether this app can deploy to the environment: it files the
299
+ * declared access requests itself and answers with every blocker at once —
300
+ * the lanes IT still has to approve, the company's AI setup when the app calls
301
+ * governed AI, and a CLI older than the platform's minimum. Prints each
302
+ * blocker's sentence and refuses once, before any operation exists.
303
+ *
304
+ * The code is `DEPLOY_BLOCKED` when the blockers are of more than one kind;
305
+ * when they are all of one kind it is the code readers already know for that
306
+ * kind (`INTEGRATIONS_NOT_READY`, `AI_NOT_READY`, `CLI_UPGRADE_REQUIRED`).
307
+ * Observed before this: a builder was refused `INTEGRATIONS_NOT_READY`, fixed
308
+ * that, then `AI_NOT_READY`, then the pipeline refused `kit_bundle_incompatible`,
309
+ * each a separate cycle.
350
310
  */
351
- export async function assertAiReady(appId, environment, client, output) {
352
- const readiness = await client.aiReadiness(appId, environment);
353
- if (readiness.ready) {
354
- output(`Isomorph confirmed the company's AI setup for ${environment}.`);
355
- return;
311
+ export async function assertDeployReady(root, client, tenantId, bundle, environment, output, callsAi) {
312
+ const { declaration, errors } = await readDeclaration(root);
313
+ if (errors.length && !errors[0].startsWith(".isomorph/integrations.json is missing"))
314
+ throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
315
+ const appId = await ensureLinkedApp(root, client, tenantId, bundle);
316
+ const preflight = await client.deployPreflight(appId, { environment, callsAi, declaration, cliVersion: CLI_VERSION });
317
+ const blockers = Array.isArray(preflight.blockers) ? preflight.blockers.filter(blocker => blocker && typeof blocker.sentence === "string") : [];
318
+ if (preflight.ready && !blockers.length) {
319
+ output(`Isomorph confirmed the app can deploy to ${environment}.`);
320
+ return appId;
356
321
  }
357
- throw new CliError("AI_NOT_READY", readiness.plainEnglish);
322
+ output(`Isomorph cannot deploy to ${environment} yet:`);
323
+ for (const blocker of blockers)
324
+ output(` ${blocker.sentence}`);
325
+ const kinds = new Set(blockers.map(blocker => blocker.kind));
326
+ const code = kinds.size === 1 ? BLOCKER_CODES[[...kinds][0]] ?? "DEPLOY_BLOCKED" : "DEPLOY_BLOCKED";
327
+ const fixes = [...new Set(blockers.map(blocker => blocker.fix).filter(Boolean))].join(" ");
328
+ throw new CliError(code, blockers.map(blocker => blocker.sentence).join(" ") || `Isomorph cannot deploy this app to ${environment} yet.`, undefined, fixes || undefined, undefined, { layer: "governance" });
358
329
  }
330
+ /** The code readers already know for a refusal that is all of one kind. */
331
+ const BLOCKER_CODES = { integration: "INTEGRATIONS_NOT_READY", ai: "AI_NOT_READY", cli: "CLI_UPGRADE_REQUIRED" };
359
332
  /**
360
333
  * Splits the declared operations of one connection by identity mode — the
361
334
  * declaration's own `identity` (the gate has checked it against the closed
@@ -25,7 +25,6 @@ export function isKitBundle(value) {
25
25
  const record = value;
26
26
  return Boolean(record && typeof record === "object" && record.schema === "isomorph.kit-bundle/1.0" && typeof record.kitVersion === "string"
27
27
  && record.sdk && typeof record.sdk.package === "string" && typeof record.sdk.tarballSha256 === "string"
28
- && record.images && typeof record.images.appGateway === "string" && typeof record.images.sessionFixture === "string"
29
28
  && record.brief && typeof record.brief.fingerprint === "string" && record.declarationSchema === "isomorph.app-integrations/2.0");
30
29
  }
31
30
  /** Lines naming every digest that differs between two manifests, for `init --upgrade`. */
@@ -33,8 +32,6 @@ export function bundleDiff(before, after) {
33
32
  const fields = [
34
33
  ["kitVersion", before.kitVersion, after.kitVersion],
35
34
  ["sdk.tarballSha256", before.sdk.tarballSha256, after.sdk.tarballSha256],
36
- ["images.appGateway", before.images.appGateway, after.images.appGateway],
37
- ["images.sessionFixture", before.images.sessionFixture, after.images.sessionFixture],
38
35
  ["brief.fingerprint", before.brief.fingerprint, after.brief.fingerprint]
39
36
  ];
40
37
  return fields.filter(([, a, b]) => a !== b).map(([name, a, b]) => `${name}: ${a ?? "(none)"} -> ${b ?? "(none)"}`);
@@ -1,6 +1,6 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "isomorph.kit-bundle/1.0",
3
- "kitVersion": "0.3.1",
3
+ "kitVersion": "0.4.0",
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:af841461de984099c202b5d0c86321c5e5f51713f3bcd487f0fc904b17a30d3a",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:fe94fb879357520bc9b34db590a64b6c82cb63e59b97ca76d1cde470e84f7df3"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:9c7750897be5ed44af332069f463f0cfe9ca94694450b49ab469c019c97d7757",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:52bfe8ca4e40fd38e662ab13c32cc842721f0d48b494561e99ac1607e49e8c74"
13
13
  },
14
14
  "nativeRuntime": {
15
15
  "darwinArm64": {
16
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:56db6d0080e0f38401d8a4a6cba0dfd44fd66878d574e88df8b6ffe63551fa4e",
17
- "sha256": "56db6d0080e0f38401d8a4a6cba0dfd44fd66878d574e88df8b6ffe63551fa4e"
16
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:7f6377c465b46cfb45402ae12c3f05063bae7c780c1c2be3aab5bd75614e809a",
17
+ "sha256": "7f6377c465b46cfb45402ae12c3f05063bae7c780c1c2be3aab5bd75614e809a"
18
18
  },
19
19
  "linuxX64": {
20
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:082272dd1823d51e05d5f9497c5644dd62dd3996ffe83ae69f357471d7d492a0",
21
- "sha256": "082272dd1823d51e05d5f9497c5644dd62dd3996ffe83ae69f357471d7d492a0"
20
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:ea9fc7cab8b2ebf27fb39a59699cd3d76117d0f8fc00346f7d85cc1bab84778d",
21
+ "sha256": "ea9fc7cab8b2ebf27fb39a59699cd3d76117d0f8fc00346f7d85cc1bab84778d"
22
22
  },
23
23
  "windowsX64": {
24
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:5341eab77337713255fa3175f14038d702b07332bb0073b5bba333ba5adf5bf1",
25
- "sha256": "5341eab77337713255fa3175f14038d702b07332bb0073b5bba333ba5adf5bf1"
24
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:0e1ef627daae405f1043b4b434a446e3cee937c370cb64ea49d84dc07c484d21",
25
+ "sha256": "0e1ef627daae405f1043b4b434a446e3cee937c370cb64ea49d84dc07c484d21"
26
26
  }
27
27
  },
28
28
  "brief": {
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join, parse, resolve } from "node:path";
4
+ import { basename, dirname, join, parse, resolve } from "node:path";
5
5
  import { scanWorkspace } from "../../../src/analyzer.js";
6
6
  import { isKitBundle } from "./kit-bundle.js";
7
7
  import { CliError } from "./output.js";
@@ -111,23 +111,50 @@ export function appRoot(rootArg) {
111
111
  export function kitPaths(root) {
112
112
  const kit = join(root, KIT_DIRECTORY);
113
113
  const local = join(kit, "local");
114
- return { kit, local, declaration: join(kit, "integrations.json"), lock: join(kit, "kit.lock.json"), checks: join(kit, "checks"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json") };
114
+ return { kit, local, declaration: join(kit, "integrations.json"), app: join(kit, "app.json"), lock: join(kit, "kit.lock.json"), checks: join(kit, "checks"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json") };
115
115
  }
116
- /** The kit directory of an app set up by a CLI older than 0.2.0; `isomorph init --upgrade` moves it to `.isomorph/`. */
117
- export const LEGACY_KIT_DIRECTORY = ".harbour";
116
+ export function defaultAppProfile(root) {
117
+ return { schema: "isomorph.app/1.0", name: basename(resolve(root)), description: "", audience: [] };
118
+ }
119
+ /** `.isomorph/app.json` as `init` writes it. */
120
+ export function renderAppProfile(profile) {
121
+ return `${JSON.stringify(profile, null, 2)}\n`;
122
+ }
123
+ const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
118
124
  /**
119
- * Refuses an app that still carries the old kit directory and not the current
120
- * one. Nothing reads the old layout — not `dev`, not `check`, not the
121
- * deployment pipeline — so the one command that moves it is named here rather
122
- * than discovered as a missing declaration, a missing lock and an app deployed
123
- * without its kit.
125
+ * Reads the profile, refusing what `deploy` could not record: a missing file
126
+ * (an app set up before the file existed — `init` adds it), invalid JSON, a
127
+ * blank name, or an audience entry that is not an email address.
124
128
  */
125
- export async function assertKitCurrent(root) {
126
- const [legacy, current] = await Promise.all([isDirectory(join(root, LEGACY_KIT_DIRECTORY)), isDirectory(kitPaths(root).kit)]);
127
- if (legacy && !current)
128
- throw new CliError("KIT_UPGRADE_REQUIRED", "This app was set up by an older CLI and has not been moved to the current kit yet. Run `isomorph init --upgrade --app-root .` once, then run this again.");
129
+ export async function readAppProfile(root) {
130
+ let raw;
131
+ try {
132
+ raw = await readFile(kitPaths(root).app, "utf8");
133
+ }
134
+ catch {
135
+ throw new CliError("APP_PROFILE_REQUIRED", "This app has no .isomorph/app.json (its name, description and audience).", undefined, "Run `isomorph init --app-root .` to add it, then edit the three values and run this again.");
136
+ }
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ }
141
+ catch {
142
+ throw new CliError("APP_PROFILE_INVALID", ".isomorph/app.json is not valid JSON.", undefined, "Fix the file, then run this again.");
143
+ }
144
+ const record = (parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {});
145
+ const problems = [];
146
+ if (record.schema !== "isomorph.app/1.0")
147
+ problems.push("schema must be \"isomorph.app/1.0\"");
148
+ if (typeof record.name !== "string" || !record.name.trim())
149
+ problems.push("name must be a non-empty string");
150
+ if (record.description !== undefined && typeof record.description !== "string")
151
+ problems.push("description must be a string");
152
+ if (!Array.isArray(record.audience) || record.audience.some(entry => typeof entry !== "string" || !EMAIL_SHAPE.test(entry.trim())))
153
+ problems.push("audience must be a list of email addresses (an empty list means only you)");
154
+ if (problems.length)
155
+ throw new CliError("APP_PROFILE_INVALID", `.isomorph/app.json is invalid: ${problems.join("; ")}.`, undefined, "Fix the file, then run this again.");
156
+ return { schema: "isomorph.app/1.0", name: record.name.trim(), description: (record.description ?? "").trim(), audience: record.audience.map(entry => entry.trim()) };
129
157
  }
130
- const isDirectory = (path) => stat(path).then(info => info.isDirectory(), () => false);
131
158
  /** Stable per-project identity for the kit-managed local state. */
132
159
  export function projectName(root, suffix = "") {
133
160
  return `isomorph-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 12)}${suffix}`;
@@ -137,7 +164,7 @@ export function linkIdempotencyKey(tenantId, root) {
137
164
  return createHash("sha256").update(`${tenantId}${resolve(root)}`).digest("hex");
138
165
  }
139
166
  /**
140
- * sha256 over the tracked app files (same boundary as productionise), so any
167
+ * sha256 over the tracked app files (same boundary as deploy), so any
141
168
  * edit changes it — and, per file, the hash of its contents, so a stale check
142
169
  * can say WHICH path moved rather than only that something did.
143
170
  */
@@ -43,6 +43,11 @@ export const LOCAL = {
43
43
  bucket: "harbour-local",
44
44
  gatewayRole: "harbour_app_gateway"
45
45
  };
46
+ /** What the gateway role may do with every table and sequence a migration creates, set once per session (see `migrate`). */
47
+ export const DEFAULT_PRIVILEGES = [
48
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${LOCAL.gatewayRole};`,
49
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${LOCAL.gatewayRole};`
50
+ ];
46
51
  /**
47
52
  * App Gateway configuration in the shape cmd/appgateway/main.go decodes (unknown fields are rejected there).
48
53
  * `localSession` is what makes the gateway the whole local runtime: issuer = its own published origin (the
@@ -279,7 +284,19 @@ export class LocalRuntime {
279
284
  await this.down();
280
285
  await rm(kitPaths(this.root).state, { recursive: true, force: true });
281
286
  }
282
- /** Applies `migrations/*.sql` in name order through psql inside the postgres container, after ensuring the runtime role. */
287
+ /**
288
+ * Applies `migrations/*.sql` in name order as the session superuser, after
289
+ * ensuring the runtime role and its default privileges.
290
+ *
291
+ * The gateway logs in as `harbour_app_gateway`; before data plane 0.79.2.0
292
+ * every migration had to `GRANT` it every verb on every table and sequence
293
+ * by hand — two lines per table that said nothing about the app. The gate's
294
+ * replay and the hosted path now set default privileges once (#365), and this
295
+ * is the one remaining site: the same statements, in this session, so a table
296
+ * a migration creates is readable by the gateway with no GRANT of its own.
297
+ * Idempotent (default privileges are a set), no `FOR ROLE`: the replay and
298
+ * this step run as the same role, so the default applies to what they create.
299
+ */
283
300
  async migrate() {
284
301
  const dir = join(this.root, "migrations");
285
302
  const names = await migrationNames(this.root);
@@ -287,6 +304,11 @@ export class LocalRuntime {
287
304
  const role = await psql(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${LOCAL.gatewayRole}') THEN CREATE ROLE ${LOCAL.gatewayRole} LOGIN PASSWORD '${LOCAL.dbPassword}'; END IF; END $$;`);
288
305
  if (role.code !== 0)
289
306
  throw new CliError("MIGRATION_FAILED", "Could not prepare the local database role.");
307
+ for (const statement of DEFAULT_PRIVILEGES) {
308
+ const granted = await psql(statement);
309
+ if (granted.code !== 0)
310
+ throw new CliError("MIGRATION_FAILED", `Could not set the local database's default privileges: ${granted.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
311
+ }
290
312
  for (const name of names) {
291
313
  const result = await psql(await readFile(join(dir, name), "utf8"));
292
314
  if (result.code !== 0)