okengine 0.23.0 → 0.23.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.
Files changed (87) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +1 -1
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/index.mdx +15 -14
  8. package/site/content/docs/plugins/otp.mdx +3 -3
  9. package/site/content/docs/reference/fx.mdx +12 -4
  10. package/src/auth/tenants.ts +17 -0
  11. package/src/cli/doctor-diff.test.ts +29 -1
  12. package/src/cli/doctor-diff.ts +35 -0
  13. package/src/cli/manifest-pr-diff.ts +16 -0
  14. package/src/compiler/effects-fetch.test.ts +77 -0
  15. package/src/compiler/effects-infer.ts +87 -16
  16. package/src/compiler/effects-join.test.ts +48 -0
  17. package/src/compiler/extract.test.ts +28 -0
  18. package/src/compiler/extract.ts +11 -1
  19. package/src/compiler/fx-index.ts +58 -4
  20. package/src/console/index.ts +1 -1
  21. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
  32. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
  40. package/src/console/ui-next/dist/index.html +1 -1
  41. package/src/drivers/journal-postgres.test.ts +22 -1
  42. package/src/drivers/journal-postgres.ts +223 -12
  43. package/src/drivers/signal-compete.test.ts +234 -0
  44. package/src/drivers/signal-postgres.ts +39 -19
  45. package/src/drivers/signal-redis.ts +167 -11
  46. package/src/drivers/signal-types.ts +12 -0
  47. package/src/elements/ai/mcp-protocol.ts +5 -6
  48. package/src/elements/ai/runtime.ts +20 -19
  49. package/src/elements/channel/runtime.ts +11 -0
  50. package/src/elements/channel/sql-ledger.ts +251 -0
  51. package/src/elements/signal/runtime.ts +9 -0
  52. package/src/elements/signal.test.ts +1 -0
  53. package/src/elements/store/cache.test.ts +120 -15
  54. package/src/elements/store/cache.ts +200 -39
  55. package/src/elements/store/prepare-row.test.ts +6 -2
  56. package/src/elements/store/sql-session.test.ts +2 -0
  57. package/src/elements/store/sql-session.ts +95 -9
  58. package/src/elements/store.ts +1 -0
  59. package/src/kernel/app.ts +32 -16
  60. package/src/kernel/auto-cache.test.ts +228 -0
  61. package/src/kernel/boot-bind/channel.ts +63 -2
  62. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  63. package/src/kernel/boot-bind/signal.ts +61 -25
  64. package/src/kernel/boot.ts +16 -2
  65. package/src/kernel/errors-compiler.ts +11 -0
  66. package/src/kernel/errors-text.ts +16 -0
  67. package/src/kernel/errors.ts +8 -0
  68. package/src/kernel/flow.ts +33 -3
  69. package/src/kernel/fx-call-types.test.ts +32 -0
  70. package/src/kernel/fx-decide.ts +11 -7
  71. package/src/kernel/fx-sql-handle.ts +46 -7
  72. package/src/kernel/fx.test.ts +1 -3
  73. package/src/kernel/fx.ts +27 -16
  74. package/src/kernel/idempotency.test.ts +2 -1
  75. package/src/kernel/journal.test.ts +114 -0
  76. package/src/kernel/journal.ts +369 -53
  77. package/src/kernel/pipeline-tenant.ts +5 -1
  78. package/src/kernel/pipeline.ts +5 -0
  79. package/src/kernel/signal-tx.ts +28 -0
  80. package/src/kernel/store-transaction.test.ts +95 -0
  81. package/src/mcp/docs-server.ts +19 -2
  82. package/src/mcp/mcp.test.ts +83 -0
  83. package/src/mcp/protocol.ts +8 -2
  84. package/src/mcp/server.ts +23 -2
  85. package/src/mcp/versions.ts +60 -0
  86. package/src/plugins/index.ts +1 -0
  87. package/src/plugins/mena.ts +56 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -16,7 +16,7 @@ Two genuinely distinct things share the protocol in OKE:
16
16
 
17
17
  OKE does not create separate security models for users, operators, and agents. They enter the same execution model through different triggers and planes.
18
18
 
19
- The server on **6535** speaks JSON-RPC over HTTP (MCP protocol `2024-11-05`), requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead.
19
+ The server on **6535** speaks JSON-RPC over HTTP. It implements `initialize`, `ping`, `tools/list`, and `tools/call`, and advertises protocol `2024-11-05`. A client that sends another `protocolVersion` is rejected. An omitted version gets `2024-11-05`. It requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead. The outbound client that calls external MCP servers probes `2026-07-28` (`_meta` on each request) and falls back to a `2024-11-05` `initialize`. That probe is not what this server advertises.
20
20
 
21
21
  <Callout title="The one rule">
22
22
  MCP inherits the operator's capability and can never exceed it. Server-level controls alone are
@@ -247,7 +247,7 @@ overrides). Manifest stays free of copy.
247
247
  | Method | Capability / `sends` | Meaning |
248
248
  | -------------------------- | -------------------- | ------------------------------------------- |
249
249
  | `fx.send(template, opts?)` | template name | Deliver through the medium’s driver chain |
250
- | `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP (Taqnyat Verify) |
250
+ | `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP. |
251
251
  | `fx.verifyOtp(opts)` | `"sms-otp"` | Check a provider OTP code |
252
252
  | `fx.deliverOtp(opts)` | `"auth-otp"` | App-owned OTP across email / SMS / WhatsApp |
253
253
 
@@ -351,9 +351,9 @@ Env knobs: [Environment variables](/docs/reference/environment-variables). Local
351
351
  </Accordion>
352
352
 
353
353
  <Accordion title="Process-local suppression / receipts warning">
354
- Boot warns: `Channel suppression/consent/receipts default to process-local memory…` Opt-out on one
355
- instance is invisible to others until you inject shared stores or run a single Channel consumer.
356
- See [Receipts](/docs/elements/channel/receipts).
354
+ Boot warns: `Channel suppression/consent/receipts default to process-local memory…` That is
355
+ `test`, or a boot with no `DATABASE_URL`. Otherwise consent, suppression, and receipts are the
356
+ Postgres ledger. See [Receipts](/docs/elements/channel/receipts).
357
357
  </Accordion>
358
358
 
359
359
  </Accordions>
@@ -1,21 +1,23 @@
1
1
  ---
2
2
  title: "Receipts"
3
- description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression — process-local by default."
3
+ description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression."
4
4
  icon: "ReceiptText"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
8
  Every Channel send records a **receipt** — success (`sent` / `fallback`), suppression, or a
9
9
  classified failure. Provider bounces and complaints update that ledger through normalized
10
- outcomes. There is no `oke_receipts` SQL table and no `fx.channel.getReceipt` helper.
10
+ outcomes. There is no `fx.channel.getReceipt` helper. Outside `test`, boot stores consent,
11
+ suppression, and receipts in Postgres (`oke_channel_consent`, `oke_channel_bounce`,
12
+ `oke_channel_receipt`) when `DATABASE_URL` is set. `test` stays in memory.
11
13
 
12
14
  For operators watching deliverability — Console projects the ledger; Flows only see send
13
15
  results via `fx.send`’s `{ ok: true }` gate.
14
16
 
15
17
  <Callout title="The one rule">
16
- Consent and prior hard bounces suppress **before** any driver runs. Default suppression / consent
17
- / receipts stores are **process-local memory** — inject shared stores for multi-instance, or run a
18
- single Channel consumer, until a durable driver ships.
18
+ Consent and prior hard bounces suppress **before** any driver runs. With `DATABASE_URL` outside
19
+ `test`, those rows are shared across instances. `test`, and any boot without a SQL URL, keep
20
+ process-local memory and still warn.
19
21
  </Callout>
20
22
 
21
23
  ## Smallest Example
@@ -7,7 +7,8 @@ source: "docs/spec/unified-theory.md"
7
7
 
8
8
  SMS (`channel.sms`) delivers short text and provider-managed one-time codes. Pin an SMS driver
9
9
  (no default in any env), declare templates for app-owned messages, or call `fx.sendOtp` when the
10
- vendor owns the code.
10
+ vendor owns the code. `fx.sendOtp` and `fx.verifyOtp` stay on `fx`. The `mena` plugin opens the
11
+ same providers when you want that packaging.
11
12
 
12
13
  For developers verifying phones — choose raw Channel OTP vs the [`otp`](/docs/plugins/otp) plugin.
13
14
 
@@ -220,17 +220,17 @@ Second argument to `flow(name, options)` — or the only argument to a nameless
220
220
  Invoke contracts (`in` / `out` / `errors` / `breaking`) belong on the exposure — see
221
221
  [Contracts](#contracts) below.
222
222
 
223
- | Option | Type | Default | Meaning |
224
- | -------------- | -------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- |
225
- | `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
226
- | `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls |
227
- | `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable) |
228
- | `cache` | `boolean \| string` | omitted (auto) | Read-only Flows cache automatically; `false` opts out; `"30s"` adds TTL |
229
- | `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed` |
230
- | `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator |
231
- | `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body |
232
- | `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs) |
233
- | `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union |
223
+ | Option | Type | Default | Meaning |
224
+ | -------------- | -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
225
+ | `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
226
+ | `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls |
227
+ | `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable) |
228
+ | `cache` | `boolean \| string` | omitted (on when pure) | Pure store reads cache; `false` disables; `"30s"` sets a TTL; app `cache: { auto: false }` turns the default off |
229
+ | `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed` |
230
+ | `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator |
231
+ | `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body |
232
+ | `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs) |
233
+ | `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union |
234
234
 
235
235
  **Consequence:** `durable: true` disables automatic read-cache for that Flow.
236
236
 
@@ -438,12 +438,12 @@ durable Flow, or split with `fx.emit`. See [Workflows](/docs/elements/flow/workf
438
438
 
439
439
  <Tab value="Cache">
440
440
 
441
- Read-only Flows (Store `reads`, no `writes`, no `asks`, not durable) cache automatically.
442
- No `cache:` option required. Mutations and `durable: true` stay uncached:
441
+ A pure store-read Flow caches on its own. A send, emit, fetch, vault read, ask,
442
+ decide, or `fx.call` is never cached. The key includes the flow, the input, the
443
+ user, the tenant, the locale, and the caller's scopes and membership roles.
443
444
 
444
445
  ```typescript
445
446
  flow("catalog.get", {
446
- cache: "30s",
447
447
  do: async ({ id }, fx) => {
448
448
  const [row] = await fx.store(db).select().from(products).where(eq(products.id, id));
449
449
  return row;
@@ -451,7 +451,10 @@ flow("catalog.get", {
451
451
  });
452
452
  ```
453
453
 
454
- `cache: false` opts out. A duration string adds TTL on top of write invalidation.
454
+ `cache: false` always disables. A duration string adds TTL on top of write
455
+ invalidation. `oke({ cache: { auto: false } })` turns the default off; a Flow
456
+ can still set `cache: true` or `"30s"`. Cross-instance invalidation and writes
457
+ that skip `fx` are not covered.
455
458
 
456
459
  </Tab>
457
460
 
@@ -570,9 +573,10 @@ Default is `true` once `gate.auth.tenant` is on.
570
573
  </Accordion>
571
574
 
572
575
  <Accordion title="Read-only Flow never cache-hits">
573
- Auto-cache needs Store `reads`, no `writes`, no `asks`, and `durable` off. Empty effect sets stay
574
- uncached. Opt in with a duration (`cache: "30s"`) only after a real read is inferred — or pass
575
- `cache: false` to disable.
576
+ A pure Store read caches unless the Flow sets `cache: false` or the app sets
577
+ `cache: { auto: false }`. A send, emit, fetch, secret, ask, decide, call, or write
578
+ is never cached, and `durable: true` is never cached. The key includes tenant,
579
+ locale, scopes, and roles, so a hit for one caller is a miss for another.
576
580
  </Accordion>
577
581
 
578
582
  <Accordion title='cross-plane call: user flow "…" calls operator flow "…"'>
@@ -331,11 +331,11 @@ Omit `key` for a pure competing pool with no ordering.
331
331
  Protocol ids: `memory` · `redis` · `postgres` · `nats`. Defaults (when `oke.config.ts` omits
332
332
  `drivers.signal`):
333
333
 
334
- | Env | Default | Boot today |
335
- | ------ | -------- | ----------------------------------------------------------------------- |
336
- | `dev` | `redis` | Emit relays to Redis; consume / live / drain use a process-local outbox |
337
- | `test` | `memory` | In-process bus |
338
- | `prod` | `redis` | Same redis honesty as `dev` |
334
+ | Env | Default | Boot today |
335
+ | ------ | -------- | -------------------------------------------------------------------------------- |
336
+ | `dev` | `redis` | Emit relays to Redis. `once` consume is a consumer group (`XREADGROUP` / `XACK`) |
337
+ | `test` | `memory` | In-process bus |
338
+ | `prod` | `redis` | Same redis path as `dev` |
339
339
 
340
340
  ```typescript title="oke.config.ts"
341
341
  export default defineConfig({
@@ -345,8 +345,10 @@ export default defineConfig({
345
345
  });
346
346
  ```
347
347
 
348
- `postgres` and `nats` fail loud at boot until a native bind ships — never silently fall back to
349
- `memory`. Prefer `memory` for tests; pin `redis` when Compose provides Redis.
348
+ `postgres` binds Bun.SQL and the scheduler polls `drain` (`FOR UPDATE SKIP LOCKED`). Bun.SQL has no
349
+ `LISTEN` / `NOTIFY`. `nats` still fails loud at boot — never silently fall back to `memory`. Prefer
350
+ `memory` for tests; pin `redis` when Compose provides Redis. Live history stays on the emitting
351
+ process; `once` is the competing path.
350
352
 
351
353
  ## Troubleshooting
352
354
 
@@ -383,15 +385,14 @@ export default defineConfig({
383
385
  consumers](#competing-consumers-once-vs-broadcast).
384
386
  </Accordion>
385
387
 
386
- <Accordion title='oke boot: signal driver "postgres" / "nats"'>
387
- Those ids are reserved but not bound for production yet. Use `"memory"` or `"redis"`, or inject a
388
- custom `elements.signal` runtime.
388
+ <Accordion title='oke boot: signal driver "postgres" needs DATABASE_URL'>
389
+ `drivers.signal` is `postgres` and neither `DATABASE_URL` nor `OKE_STORE_SQL_URL` is set. Boot
390
+ polls `drain` on that connection. There is no `LISTEN` / `NOTIFY` requirement.
389
391
  </Accordion>
390
392
 
391
- <Accordion title="redis Signal — process-local consume">
392
- Boot warns that redis emit relays to Redis while consume / live / drain stay process-local.
393
- Multi-instance competing consumers need a shared durable outbox path (or a single consumer
394
- instance) until Redis Streams consume ships.
393
+ <Accordion title='oke boot: signal driver "nats"'>
394
+ `nats` has no production client yet. Use `"memory"`, `"redis"`, or `"postgres"`, or inject a
395
+ custom `elements.signal` runtime.
395
396
  </Accordion>
396
397
 
397
398
  </Accordions>
@@ -22,9 +22,9 @@ app-owned delivery across the channels you declare.
22
22
  </Callout>
23
23
 
24
24
  <Callout type="info" title="fx.sendOtp vs otp()">
25
- `fx.sendOtp` / `fx.verifyOtp` are raw Channel capabilities — no `.plug()`. Direct use means you
26
- build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path; skip
27
- it and call the raw methods in your own flow without losing the provider connection.
25
+ `fx.sendOtp` / `fx.verifyOtp` / `fx.deliverOtp` are supported Channel methods. Direct use means
26
+ you build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path.
27
+ The `mena` plugin (`menaChannels`) is an additional way to open the same providers.
28
28
  </Callout>
29
29
 
30
30
  ## Quick start
@@ -158,7 +158,13 @@ on(
158
158
  | `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })` | `write` `auth:tenants` | Application scopes only — `console:*` is unknown_scope |
159
159
 
160
160
  `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed) and propagates `fx.tenant.id`.
161
- Read `fx.principal` for audit — it does not copy into `fx.auth`.
161
+ Read `fx.principal` for audit — it does not copy into `fx.auth`. Passing a Flow handle types
162
+ `input` and the result. A string name stays `Promise<unknown>`.
163
+
164
+ `fx.store(db).transaction(async (tx) => { … })` pins one SQL connection. `fx.emit` inside the
165
+ callback stages on the signal outbox and publishes after commit. A throw rolls that batch back.
166
+ `fx.store(db).run(builder)` runs a Drizzle builder through `toSQL()`. Joins stay on that builder;
167
+ the hand-rolled chain does not grow a join API. Inference still records every joined table.
162
168
 
163
169
  <Callout>
164
170
  Gates never consult `fx.principal`. `return fx.fail(...)` is a `FlowFailure`; an unhandled `throw`
@@ -281,9 +287,11 @@ Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400
281
287
 
282
288
  ## Cache
283
289
 
284
- Read-only flows cache automatically from inferred or ledgered `reads` — no
285
- `fx.cache` call and no `cache:` default on the flow. Writes invalidate those
286
- keys. Use `cache: false` to opt out, or `cache: "30s"` for a TTL.
290
+ Pure store-read flows cache by default. A send, emit, fetch, vault read, ask,
291
+ decide, or `fx.call` is never cached. The key includes tenant, locale, scopes,
292
+ and membership roles. Writes through `fx` invalidate those keys. `cache: false`
293
+ always disables. `oke({ cache: { auto: false } })` turns the default off.
294
+ Cross-instance invalidation and out-of-band writes are not covered.
287
295
 
288
296
  `fx.cache` is the manual (tier-3) surface:
289
297
 
@@ -359,3 +359,20 @@ export function tenantScopesForMember(
359
359
  const role = store.roles.get(roleKey(tenantId, member.role));
360
360
  return role?.scopes ?? [];
361
361
  }
362
+
363
+ /**
364
+ * Membership role name for the tier-1 cache key.
365
+ *
366
+ * Same member lookup as {@link tenantScopesForMember}. Not an authorization input.
367
+ *
368
+ * @param store - Tenant store
369
+ * @param tenantId - Tenant id
370
+ * @param userId - User id
371
+ */
372
+ export function tenantRoleForMember(
373
+ store: TenantStore,
374
+ tenantId: string,
375
+ userId: string,
376
+ ): string | null {
377
+ return getMember(store, tenantId, userId)?.role ?? null;
378
+ }
@@ -7,7 +7,7 @@ import { mkdir, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import type { Manifest } from "../manifest/types.ts";
9
9
  import { parseManifest } from "../manifest/validate.ts";
10
- import { runDoctorDiff } from "./doctor-diff.ts";
10
+ import { formatManifestDiffComment, runDoctorDiff } from "./doctor-diff.ts";
11
11
 
12
12
  const baseUrl = new URL("../manifest/fixtures/base.manifest.json", import.meta.url);
13
13
 
@@ -84,4 +84,32 @@ describe("oke doctor --diff", () => {
84
84
  await rm(dir, { recursive: true, force: true });
85
85
  }
86
86
  });
87
+
88
+ test("comment lists contract, permission, and effect changes", () => {
89
+ const body = formatManifestDiffComment([
90
+ {
91
+ path: "flows.a",
92
+ category: "contract-breaking",
93
+ kind: "changed",
94
+ summary: "out schema changed",
95
+ },
96
+ {
97
+ path: "flows.b",
98
+ category: "permission-widening",
99
+ kind: "changed",
100
+ summary: "gate removed",
101
+ },
102
+ {
103
+ path: "flows.c",
104
+ category: "effect-widening",
105
+ kind: "changed",
106
+ summary: "writes grew",
107
+ },
108
+ ]);
109
+ expect(body).toContain("Contract breaks");
110
+ expect(body).toContain("Permission widening");
111
+ expect(body).toContain("Effect widening");
112
+ expect(body).toContain("out schema changed");
113
+ expect(body).toContain("widening are comments only");
114
+ });
87
115
  });
@@ -55,6 +55,41 @@ export interface DoctorDiffResult {
55
55
  readonly allChanges: readonly ManifestChange[];
56
56
  }
57
57
 
58
+ const COMMENT_CATEGORIES = ["contract-breaking", "permission-widening", "effect-widening"] as const;
59
+
60
+ const CATEGORY_LABEL: Record<(typeof COMMENT_CATEGORIES)[number], string> = {
61
+ "contract-breaking": "Contract breaks",
62
+ "permission-widening": "Permission widening",
63
+ "effect-widening": "Effect widening",
64
+ };
65
+
66
+ /**
67
+ * Plain-language pull-request comment for the three behavioural categories.
68
+ * Widening is informational. Contract breaks are the lines that fail CI
69
+ * when they are undeclared.
70
+ *
71
+ * @param changes - {@link diffManifest} changes
72
+ */
73
+ export function formatManifestDiffComment(changes: readonly ManifestChange[]): string {
74
+ const lines = ["## Manifest diff", ""];
75
+ for (const category of COMMENT_CATEGORIES) {
76
+ const rows = changes.filter((change) => change.category === category);
77
+ lines.push(`### ${CATEGORY_LABEL[category]}`);
78
+ if (rows.length === 0) {
79
+ lines.push("None.");
80
+ } else {
81
+ for (const row of rows) {
82
+ lines.push(`- \`${row.path}\` — ${row.summary}`);
83
+ }
84
+ }
85
+ lines.push("");
86
+ }
87
+ lines.push(
88
+ "Undeclared contract breaks fail the check. Permission and effect widening are comments only. Acknowledge an intentional break with `breaking: true` on the HTTP, call, MCP, or resource contract.",
89
+ );
90
+ return lines.join("\n");
91
+ }
92
+
58
93
  /**
59
94
  * Diff two manifests and fail on undeclared contract breaks.
60
95
  *
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pull-request manifest diff.
3
+ *
4
+ * Writes the comment body, then exits non-zero only for undeclared
5
+ * contract breaks. Widening changes stay in the comment.
6
+ *
7
+ * bun src/cli/manifest-pr-diff.ts comment.md
8
+ */
9
+
10
+ import { writeFileSync } from "node:fs";
11
+ import { formatManifestDiffComment, runDoctorDiff } from "./doctor-diff.ts";
12
+
13
+ const out = process.argv[2] ?? "manifest-diff.md";
14
+ const result = await runDoctorDiff({ write: () => {} });
15
+ writeFileSync(out, formatManifestDiffComment(result.allChanges));
16
+ process.exit(result.code);
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
+ import { extractFromSources } from "./extract.ts";
6
7
  import { inferEffects, type AstNode, type InferBinding } from "./effects-infer.ts";
7
8
 
8
9
  function parseDo(src: string): AstNode {
@@ -39,5 +40,81 @@ describe("inferEffects — fx.fetch → effects.fetches", () => {
39
40
  expect(inferred.effects.fetches).toEqual(["api.stripe.com"]);
40
41
  expect(inferred.effects.calls).toBeUndefined();
41
42
  expect(inferred.effects.sends).toBeUndefined();
43
+ expect(inferred.bareIrreversible).toEqual(["fetch"]);
44
+ });
45
+
46
+ test("fx.fetch inside fx.step is not a bare irreversible call", () => {
47
+ const stepBody = {
48
+ type: "ArrowFunctionExpression",
49
+ params: [],
50
+ body: {
51
+ type: "BlockStatement",
52
+ body: [
53
+ {
54
+ type: "ExpressionStatement",
55
+ expression: {
56
+ type: "CallExpression",
57
+ callee: {
58
+ type: "MemberExpression",
59
+ object: { type: "Identifier", name: "fx" },
60
+ property: { type: "Identifier", name: "fetch" },
61
+ computed: false,
62
+ },
63
+ arguments: [{ type: "Literal", value: "https://example.com/data" }],
64
+ },
65
+ },
66
+ ],
67
+ },
68
+ };
69
+ const inferred = inferEffects({
70
+ doNode: {
71
+ type: "ArrowFunctionExpression",
72
+ params: [
73
+ { type: "Identifier", name: "_input" },
74
+ { type: "Identifier", name: "fx" },
75
+ ],
76
+ body: {
77
+ type: "BlockStatement",
78
+ body: [
79
+ {
80
+ type: "ExpressionStatement",
81
+ expression: {
82
+ type: "CallExpression",
83
+ callee: {
84
+ type: "MemberExpression",
85
+ object: { type: "Identifier", name: "fx" },
86
+ property: { type: "Identifier", name: "step" },
87
+ computed: false,
88
+ },
89
+ arguments: [{ type: "Literal", value: "load" }, stepBody],
90
+ },
91
+ },
92
+ ],
93
+ },
94
+ } as AstNode,
95
+ bindings: new Map<string, InferBinding>(),
96
+ hasExplicitEffects: false,
97
+ });
98
+ expect(inferred.bareIrreversible).toEqual([]);
99
+ expect(inferred.steps).toEqual(["load"]);
100
+ expect(inferred.effects.fetches).toEqual(["example.com"]);
101
+ });
102
+ });
103
+
104
+ describe("extract — durable fx.fetch outside fx.step", () => {
105
+ test("OKE1901 when a durable flow calls fx.fetch directly", async () => {
106
+ await expect(
107
+ extractFromSources({
108
+ "src/flows/load.ts": `
109
+ import { flow, on, http } from "okengine";
110
+ export const load = on(http.get("/load"), flow("load", {
111
+ durable: true,
112
+ do: async (_input, fx) => {
113
+ return fx.fetch("https://example.com/data");
114
+ },
115
+ }));
116
+ `,
117
+ }),
118
+ ).rejects.toThrow(/OKE1901/);
42
119
  });
43
120
  });