Skip to content

v0.1.0 composability: region nursery, blob hoist, AI integration - #6

Merged
yukikurage merged 101 commits into
mainfrom
feature/v0.1.0-composability
Jul 20, 2026
Merged

v0.1.0 composability: region nursery, blob hoist, AI integration#6
yukikurage merged 101 commits into
mainfrom
feature/v0.1.0-composability

Conversation

@yukikurage

Copy link
Copy Markdown
Member

v0.1.0 composability

v0.1.0 の composability feature 一式。

主要な変更

構造化並行 nursery (region)region.provide[marker, E] + fork / join / watch / cancel。fiber は detached delegation(run の一般化)、watch は fiber の escalation を外側 handler に湧き上がらせる「ホワイトホール」(mailbox re-emit + quiescence hook で registration race を解決)。region 脱出で全 fiber を cancel(構造化並行)。region reactor は ExternalCallReactor のサブクラスで escalation relay と cancel cascade を基底から継承(基底変更は behavior-preserving のみ)。playground e2e で検証(14/14)。

blob 所有権の hoist — blob は上向きイベント(delegateAck/escalate)ごとに1段上の instance へ所有権が climb(値到達性でなく)。files.from_base64 / free / gonehttp.fetch_file(body → blob)、mcp decode の無条件化。hostile wire が actor を poison する DoS を settle-seam の全域化で塞ぐ。

AI 統合ai.infer_with_region(region 内蔵の AI ループ)。監視 tool を fork し observation を watch で受けて AI turn に注入、cancel で停止。observation は text + files で画像も運ぶ。

wire markers — json/mcp の wire marker を \$katari_ 名前空間化、escape 廃止。mcp scope は marker で discharge。http body が file を handle で運び wire で materialize。

file → files rename — 組み込み file 型キーワードと module qualifier の衝突を解消(files.gone が型位置で書ける)。

その他 — K3024/K3025(parallel で var 禁止)、migration の 0000 squash、namespace 強制、private agent の runtime 拒否。

検証

typecheck / lint / test(両言語)全 green、region の playground e2e 14/14。

🤖 Generated with Claude Code

https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H

yukikurage and others added 30 commits July 9, 2026 10:39
…oints

Run traces routinely blew past the 1000-event cap with no way to reach the
rest. The events endpoint now offset-paginates with server-side filters —
`kind`, and a case-insensitive substring over each event that matches its
ids, delegate targets, request names, and any public payload text (sealed
private values never match) — and returns the filtered `total`. The
console's new TracePanel drives it (search box, kind filter, order toggle,
First/Prev/Next/Last), and the CLI's `katari status` gains --search /
--kind that pass straight through to the same params.

The other growing lists gain paging too: runs / snapshots / files take
`offset` (+ a name/agent/id search on runs, a message search on
snapshots). Their `data` stays a bare array so the CLI keeps decoding it,
and the filtered total rides on an X-Total-Count header that the console's
shared Pagination component reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n, MCP, webhook.inbound

The v0.1.0 composability milestone (see
docs/2026-07-07-composability-reflection-webhook.md): one-shot call-provider
`use f(args…)`, a reflection prelude (get_metadata / call_agent) with dynamic
dispatch, an MCP provider (reactor + transport, `mcp.toolbox`), and
`webhook.inbound` (webhook reactor + public `/inbound` routes). Includes the
supporting compiler (lowering / typechecker), engine, actor, and value changes,
the drizzle migrations, and the reworked playground examples.

Also carries the trace-pagination CORS change (expose `X-Total-Count`), which
was entangled in app.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rims + MCP image bridge

Two runtime pieces that let programs (and AI tool loops) handle blobs
explicitly:

- `prelude.file`: read_base64 / content_type / size primitives over a `file`
  value's blob. PrimContext already carried the BlobStore, so a pure-Katari
  provider can now inline an image into a multimodal request (base64 +
  MIME) without any FFI helper.

- MCP image bridge: a tool result's binary content blocks (image / audio)
  no longer collapse to a "(image content)" placeholder — the SDK transport
  stores each as a project blob through an injected producer and returns
  `{ text, files }`, whose `$ref` handles lift into real `file` values at
  the reactor's decode. Ownership mirrors an FFI handler's mid-call upload
  exactly: `registerProducedBlob` moves from FfiReactor to the shared
  ExternalCallReactor base, the blob is owned by the mcp call's instance,
  and the result's delegateAck ascends it to the caller. No producer wired
  (stub / tests) keeps the old placeholder behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecode_error + honest file schema

Field incident: the model called view_image with a bare {"$ref": "..."}
(no size / hash / semanticKind). The file schema advertised only $ref as
required — the model followed it faithfully — and json.decode's handle
reconstruction then threw a bare "expected a number leaf", which became a
panic that escalated to the app root and killed the whole serve loop.

Three-part fix:

- json.decode / json.parse_as wrap reconstruction failures into the typed,
  declared decode_error (never a panic), and a partial file handle's
  message names the fix: replay the FULL handle object. An AI loop feeds
  that straight back to the model.

- fileReferenceSchema now tells the truth: $ref / semanticKind / size /
  hash required, typed properties, contentType optional — what the runtime
  codec actually needs. Unlike callables, an AI DOES write file handles
  (replaying them into tool calls), so "loose by design" no longer holds.
  Value-side validation is unaffected (referenceKeyOf tolerates the extra
  required keys).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…only

A `file` value carried hash / size / contentType: caches of its blobs-table
row, exposed verbatim as the wire form an AI reads and replays. That made
an untrusted narrator responsible for copying metadata (yesterday's
incident), and let a mistyped copy corrupt silently (a forged size / hash
was believed). See docs/2026-07-09-slim-blob-ref.md.

The ref is now { kind, semanticKind, blobId } and the wire form
{ "$ref": id, "semanticKind": kind } — a bare { "$ref" } lifts fine (what a
model replays; extra fields are ignored, so nothing on a handle can be
forged or stale). The blobs row is the single source of truth:

- `==` on refs is blob identity; a future large-string promotion must mint
  content-addressed ids so promoted-string equality stays structural
  (decision recorded in the doc).
- file.size / file.content_type read the warm blob catalog, newly exposed
  on PrimContext (ProjectStore.blobs — already loaded per actor); a
  dangling / made-up id fails loudly there.
- The port's KatariFile serves size() / contentType() from the download's
  response metadata (async now; hash dropped — no consumer). context.file
  seeds the cache with what it just uploaded.
- fileReferenceSchema returns to required: ["$ref"] — yesterday's
  full-handle requirement is superseded by not needing the fields at all.
- The console's FileChip already tolerated absent metadata; unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eaf inlining)

Every named call was a full delegation: a child instance summoned through
the outbox, its lifecycle persisted, both legs journaled — so one stdlib
call (`record.get`, `json_object`, `==`) cost ~4 commits and a dozen row
writes, and a single AI reply journaled ~10k events (measured: 3,223
delegations, mostly `prelude.*`).

`enterDelegate` now probes a named core callee's body (sync — the snapshot
is preloaded): a `construct` body completes in place (the tagged record,
exactly `createConstruct`'s semantics), and a `primitive` body spawns an
in-instance leaf thread carrying the resolved prim name / argument /
call-site generics on the thread itself (the callee's block lives in a
foreign module this instance cannot read). Born and completed within one
turn, the leaf never persists and emits nothing — no delegation, no
outbox round trip, no journal rows.

Fallbacks keep semantics exact: argument defaults, unloadable foreign
snapshots, and non-leaf bodies all take the ordinary delegation path;
failure taint / typed throws bubble from the leaf exactly like an
in-module primitive body's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing + net-coalescing)

One turn = one transaction meant a burst of core→core work — an AI reply's
tool-building chatter — paid a commit per hop: thousands of transactions
for one message. The substrate now folds consecutive mailbox turns into a
BATCH (bounded at 256) and commits once at quiescence:

- an event produced AND consumed within the batch never touches the outbox
  table (the produce/consume pair cancels to zero rows);
- an instance / delegation born and torn down within the batch nets to a
  single no-op delete (the reactors' last-write-wins dirty maps now span
  the batch);
- the journal still records EVERY hop — the trace stays complete even for
  events the outbox never saw — as one bulk append;
- a run that completes within its launch batch commits exactly once,
  writing only the permanent record (the `runs` outcome + the trace).

Crash semantics are unchanged in shape, just at batch granularity: reacts
mutate warm state before the commit, so any failure poisons — reject the
batch's awaiters, drop warm, replay from the durable inputs (the batch's
inbound rows stay unconsumed). A mid-batch deterministic failure replays
the good prefix as its own batch first, isolating the offender at position
0 where the precise single-turn policies (dead-event consumption, bounded
retries) already apply; a batch commit failure retries with backoff
against its first durable input.

With leaf inlining this takes the observed "one short conversation =
10,641 events / thousands of commits" to tens of journal rows per external
interaction and one commit per quiescent burst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecent diff

Review pass over the v0.1.0-composability work: every "general path A,
except when X" seam surfaced by the review is now a discriminated union
dispatched in one place.

runtime:
- substrate: one retryable failure policy (a single turn is a batch of
  size 1); BatchOutcome union replaces the string outcome and two mutable
  one-shot side channels
- engine: a delegate op resolves to a DelegatePlan union (raise/construct/
  primitive/delegate) so leaf inlining is structural dispatch, not a
  bolted-on fast path; Thread.inline? became a required invocation variant
- resolveCallee drops the static-name special case and the duplicated peel
- mcp: listTools/callTool/recovered payload union decided once at
  openPayload; the MCP_TOOLS_KEY sentinel no longer leaks past it; the
  identity-default transformResult hook is gone; the recovery dummy payload
  is a typed variant
- persistence: the envelope-join boilerplate is factored once per layer;
  http and mcp share the status-only instance types
- ToolValue.reactor narrowed to a validated union; an unknown external
  reactor marker now fails instead of silently routing to ffi
- modules: the run-trace listing is a tail/browse cursor union with an
  honest optional total; shared lib/paging.ts and a pagedList helper
  replace four copies of the paging plumbing; the webhook outcome gains a
  "rejected" variant replacing the badRequest boolean; a file's missing
  Content-Type travels as absence instead of an octet-stream sentinel
- port/types: DelegateCallee/DelegateOutcome live once in
  @katari-lang/types; dead wire keys and stale variant tables cleaned up

compiler/cli:
- use providers type and lower through one pipeline (closed argument
  object, inferred generics stamped, CalleeName for named providers), with
  a dedicated K3019 error for malformed use statements
- stdlib: post_json takes headers like fetch, dropping the three-parameter
  auth special case; tools_of iterates entries so the impossible-null
  throw disappears; the json tree readers adopt the target naming
  convention; webhook.inbound's doc drops a dangling reference
- cli: trace rendering dispatches on a TraceSelection sum instead of a
  re-derived boolean; queryString percent-encodes every value uniformly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…drop op

Lowering a `let x = f(a)` mints internal temporaries (the argument
record, the delegate output) that stay bound for the scope's whole
lifetime, and a scope's entire values map is re-persisted every turn.
A new `drop` operation releases variables the compiler proves dead, so
the persisted scope stops carrying them.

- IR: OperationDrop { variables }, wire kind "drop"; schemaVersion 2
  (mirrored in typescript/types/src/ir.ts)
- compiler: a conservative post-lowering liveness pass
  (Katari.Lowering.Drop) — module-wide mention walk (total-case over
  every Block/Operation/Pattern constructor), per-sequence backward
  last-mention scan; anything mentioned outside its own sequence
  (match arms, closures, parameters, results) is never dropped, and
  the scope-level GC remains the backstop
- runtime: dropVariable beside writeVariable (local scope only, the
  mirror of write locality); the GC soundness note now rests on
  "reachability can only shrink" instead of "bindings are never
  dropped"
- tests: lowering specs for the let shape, nested-block captures, and
  a module-wide no-mention-after-drop oracle over the lowered stdlib;
  engine tests for dropped bindings and scope-chain locality
- docs/2026-07-10-ir-drop.md records the design

Compiling examples/playground now emits 303 drops releasing 385
variables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auth is now a sum on every connection — mcp.tools(url, auth) with
data headers(values: record[string of private]) | data oauth(name) —
instead of a bare headers parameter, so a connection always carries
exactly one auth mode and OAuth is not an optional bolt-on.

- stdlib: the auth union, plus data auth_error(message) — thrown when
  a stored credential is missing or refresh fails; distinct from
  server_error because the fix is a human re-running login, not a retry
- runtime: the descriptor decodes the $constructor-tagged auth once at
  the transport boundary and dispatches on it; oauth descriptors build
  a non-interactive OAuthClientProvider that serves stored tokens,
  writes refreshed tokens back, and refuses interactive steps with a
  typed auth_error; credentials live in the project env store
  (AES-GCM at rest) under mcp.oauth.<name>, wired per project like the
  blob producer; the client cache keys on the credential name, never
  token material
- typescript/mcp: a katari-mcp helper bin whose login subcommand runs
  the OAuth 2.1 authorization-code + PKCE flow with dynamic client
  registration on a loopback redirect and emits the credential JSON
- cli: katari mcp login --url --name stores that credential via the
  runtime env API; helper resolution is factored into
  resolveNodeHelperInvocation and shared with the bundler spawn
- docs/2026-07-10-mcp-oauth.md records the design

Known baseline issue surfaced by e2e (not from this change, fixed
separately): turn batching can fold a delegation's creation and its
instance's spawn into one commit, violating the instances->delegations
foreign key on insert order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turn batching (ee920f6) can fold a whole causal chain — the caller
instance, the delegation it issued, and the callee instance that
delegation summoned — into one atomic commit. The two foreign keys
(instances.delegation_id -> delegations.id and
delegations.caller_instance_id -> instances.id) form a cycle, so no
fixed per-table insert order inside the transaction can satisfy both
edges across reactors; runs that suspend mid-batch (ffi, mcp, webhook,
escalations) failed to launch with an FK violation.

Both constraints become DEFERRABLE INITIALLY DEFERRED (migration 0003):
the existence checks move to the commit boundary, where the batch is
consistent by construction; the cascade / set-null actions are
unaffected. The e2e suite goes from 4 failures back to 11/11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mcp.serve(tools, subscriber) mirrors webhook.inbound in the outbound
direction: the runtime mints an unguessable capability URL (possession
is the key — the auth mode sanctioned for now), serves the record's
agents as MCP tools at POST /mcp/:token while the subscriber runs, and
settles the whole call with the subscriber's outcome; cancelling the
run deactivates the URL. toolbox/tool already name the top type of
self-contained callables, so a user's typed agents publish without
ceremony and the record key is the tool name.

- reactor: a serve variant on the McpPayload union (decided once at
  openPayload); token registry, subscriber start, per-call waiters,
  release on every path, and restart re-registration follow the
  webhook reactor's shape, with the shared subscriber-outcome
  completion factored into the base
- wire: stateless JSON-RPC handling (initialize / ping / tools/list /
  tools/call, notifications 202, GET/DELETE 405) mapped directly onto
  Hono instead of bridging the SDK's node-req/res transport; wire
  compatibility is pinned by driving the real SDK client against a
  live loopback server in the tests
- listing metadata comes from the same callableMetadata that backs
  reflection.get_metadata; results cross a user-facing boundary and
  redact secrets like the webhook reply path
- persistence: mcp_instances gains nullable serve columns (snapshot
  pin, unique token, sealed tools) via migration 0004; transport rows
  keep at-most-once recovery
- webhookBaseUrl generalizes to publicBaseUrl (one config knob)
- docs/2026-07-10-mcp-serve.md records the design and the deliberate
  omission of inbound OAuth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idual agent

A lone underscore in a call-argument value position marks a hole; the
call evaluates the supplied arguments now and produces a closure whose
parameters are exactly the holed ones, with the callee's effect moved
to the residual call.

- AST: a call argument's payload is the sum ArgumentHole |
  ArgumentExpression — a hole is not an Expression, so every existing
  expression walker stays hole-free by construction; the parser
  recognizes the hole with the same lone-underscore discipline as the
  pattern wildcard
- checker: the residual type restricts the callee's parameter object
  to the hole labels (optionality preserved); supplied types and
  missing required parameters report through the existing closed-object
  machinery via one probe object; unknown hole labels get K3020;
  generics infer from the supplied arguments only, so a hole-only
  generic keeps firing K3016 with f[T](x = _) as the escape hatch;
  use providers reject holes through K3019
- lowering: the site resolves the callee once, evaluates supplied
  arguments in written order, captures them as one record, and emits a
  closure whose body merges {incoming, captured} via prelude.record.merge
  before delegating — merging (not per-field reads) preserves the
  ABSENCE of an omitted optional hole so the callee's runtime defaults
  and input schema still apply; the call site's inferred generics are
  stamped on the inner delegate; zero runtime changes
- the residual schema flows through get_metadata and delegate
  validation like any agent's

583 compiler / 56 project / 28 cli example tests pass; playground
gains a worked example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One command table registered in a loop: check / build / apply, MCP
login (validated URL + credential-name prompts, ignoreFocusOut for the
browser round-trip) and MCP tool-binding generation (save dialog into
src/, opens the generated .ktr when pull writes it), plus a language
server restart that rebuilds the client so a changed server path takes
effect.

Commands run in one shared "Katari" terminal (recreated when closed,
arguments shell-quoted per platform) so interactive flows work; every
invocation targets the nearest katari.toml via -C instead of relying
on the terminal cwd. Binary resolution is factored into one ladder
shared by the LSP and the new katari.cli.path setting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… live server

Three layers, each reusable on its own:

- stdlib/runtime: mcp.call(url, auth, tool, arguments) -> json, the
  schema-blind static counterpart of a minted tool call — a directCall
  variant on the McpPayload union that lowers the arguments tree with
  the json.stringify machinery, ships the exact callTool transport
  operation (shared cache/auth/typed errors), and lifts the server's
  raw reply literally into a json tree (structuredContent, text,
  $ref-bearing blobs) so hostile $constructor-shaped JSON can never
  confuse the wire decoder
- typescript/mcp: a list-tools verb (headers or ephemeral in-memory
  OAuth via the shared login flow) printing the tool listing as JSON
- cli: mcp pull spawns the helper, decodes the listing, and generates
  a self-contained module — one connect(url ?= "<pulled>", auth)
  returning typed local-agent closures, one wrapper per tool

The codegen contract is explicit: JSON Schema maps all-or-nothing per
parameter and per output (the json.encode/decode wire form is not the
server's raw fragment, so anything unmappable stays json.json and
rides as-is); optional parameters fold through match so absence never
becomes JSON null; names mangle deterministically with the original
riding in tool = "...". Golden tests compile the generated text
against the real stdlib, and an end-to-end smoke pulls from a live
loopback server and checks a consuming program.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-hoc seams

Adversarial review of the drop pass, partial application, MCP
OAuth/serve/pull, and the CLI/VSCode surfaces, with every confirmed
finding fixed:

compiler
- partial application's supplied-argument check now routes through the
  same lift-aware core as a full call (checkArgumentShape) — a private
  value bakes into a residual exactly as it flows through a full call,
  instead of being rejected by a second, drifted shape check
- the record.merge labels lowering emits are constants guarded by a
  StdlibSpec assertion; the merge's left/right mapping is pinned by a
  lowering test (a swap would let callers override baked-in arguments)
- basics gains a defaulted-parameter partial application whose value
  the e2e suite asserts end to end

runtime
- a blob produced for an mcp.call reply survives to the caller: calls
  track their produced blobs and any not carried out as real refs are
  adopted by the permanent run instance after the ack — one uniform
  rule across ffi/callTool/directCall (previously the directCall tree's
  literal $ref meant the blob died with the call instance)
- result shaping unified into a single decodeAck seam at the wire
  boundary (the post-decode hook and the complete override are gone);
  a directCall lowering failure throws typed at its own site, so a
  bare error completion is a panic uniformly
- McpPayload is a two-level sum (serve | transport{...}) — the
  serve-vs-transport axis is a type fact instead of seven one-vs-rest
  checks
- OAuth credentials read through on every use and write back
  compare-and-set on a content generation, so a re-login can no longer
  be clobbered by a stale cached refresh
- capability tokens are redacted from request logs (/mcp, /inbound —
  one redaction rule); both capability surfaces share one body limit
- serve state moves to its own mcp_serve_instances extension table
  (migration 0005), restoring the no-nullable-subtype-columns doctrine

surfaces
- --header and --oauth are rejected together at both the helper and
  the CLI (auth is a sum at every layer)
- pull --name deleted (it only rewrote a doc comment); helper flow
  failures exit 1 and usage errors 2, preserving the helper's split
- mcp.tools_of deleted in favor of the generic record.values(target)

Known follow-ups recorded in the review: a delivery-scoped resource
owner for long-lived serve/webhook endpoints, and residual default
literals in get_metadata metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…izers

finally { ... } arms its block as a finalizer of the current agent
instance (stack discipline: reverse arming order, a loop arms per
iteration). Armed finalizers run right before the instance
acknowledges its terminal — a normal completion's delegateAck or a
cancellation's cancelAck — and never on a panic.

The static rule that makes this deadlock-free: a finalizer's residual
effect row must stay within io (K3021 otherwise). io effects route to
sibling reactors, never through the parent — so a finalizer can run
while the parent is already awaiting the cancelAck, and the class of
"parent can only answer an escalation with a cancel that must not
enter the finalizer" deadlocks cannot form. A locally-handled request
discharges from the row and is fine; a panic inside a finalizer is the
instance's panic.

- IR: OperationDefer { block } (wire kind "defer", schemaVersion 3)
- compiler: finally is a reserved statement; the body lowers to its
  own sequence block and the checker joins its (io-only) effect into
  the enclosing row — the instance genuinely performs that io
- runtime: the instance carries a finalizers stack and a phase sum
  (running | finalizing{completed value | cancelled} | failed); all
  terminal paths route through one beginTerminal seam that drains the
  stack before emitting the deferred ack; threads carry a structural
  origin (user | finalizer) so the cancel cascade skips the finalizer
  subtree while a cancel arriving mid-drain just flips the disposition
  to cancelled (result discarded); a mid-drain restart resumes from
  persisted state; a parent-proxying escalation from a finalizer
  panics with a named message as the runtime backstop
- docs/2026-07-10-finally.md records the semantics table, including
  the accepted tradeoff that a hung io finalizer cannot be interrupted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pitch grounded in the implementation, a compile-verified sample
(agents, parallel for, an escalating request), feature bullets checked
against the stdlib and CLI, the monorepo layout, and a getting-started
that only shows commands that exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The VS Marketplace rejects semver prerelease suffixes at publish time
(verified in @vscode/vsce 3.9.2's publish.js), so the repo's -rcN tags
cannot ship there as-is; the supported mechanism is the pre-release
CHANNEL with a plain x.y.z version. The new workflow_dispatch-only
workflow encodes exactly that: a preflight rejects non-plain tags and
gates the publish job on the VSCE_PAT secret (inert until a maintainer
adds it), then publishes per-platform VSIXes with the bundled LSP
server using --pre-release, mirroring release-vsix.yml.

Left deliberately for the maintainer: creating the Marketplace
publisher (the manifest says yukikurage; the npm scope is
@katari-lang — pick one), the VSCE_PAT secret, and an icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er effects

The type-system groundwork for scope-gated capabilities (the upcoming
provide-based MCP surface), landed as four general features with no
MCP-specific code anywhere:

- string literal singleton types: "x" is a type, a subtype of string;
  the normalized string layer generalizes from a boolean to a
  literals-or-top slot mirroring the boolean value-set, so unions,
  match narrowing, and inhabitation all fall out of the existing
  lattice; type-position literals parse with the lexer's own string
  rules and emit const JSON schemas (the runtime already validates
  const)
- a string literal EXPRESSION still synthesizes string — singletons
  enter checking only where the parameter side asks for them, so
  literal-free programs are byte-identical
- literal-binding generic parameters (the const type-parameter
  analog): agent f[literal T](x: T) binds T at a syntactically-literal
  argument's singleton; unmarked generics never bind singletons
  implicitly; explicit f["x"] instantiation composes; the stamped
  runtime schema is the const schema
- request-parameter variance was already inferred cross-module
  (covariant inputs, contravariant results, invariant on conflict);
  the missing rule lands here: a phantom (bivariant) parameter is
  compared covariantly in row subsumption instead of not at all, so a
  phantom tag is never erased — req["x"] fits req[string], never
  req["y"]
- marker effect declarations: effect name[generics] binds only the
  type namespace (unperformable by construction), rides rows as a
  zero-operation request entry (one representation, so arity/bounds/
  variance/tails need no fork), is rejected as a handler target, and
  vanishes at lowering

playground gains scoped.ktr demonstrating a with_resource shape whose
tools are gated by a scoped["db"] marker row. 48 new tests; 646
compiler examples green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MCP client surface is now delimited: a phantom marker effect
scope[URL] (covariant, phantom-inferred) gates every tool call to the
provide scope that minted it, so a tool cannot outlive its connection
by construction.

- mcp.ktr: effect scope[URL]; tool[URL] / toolbox[URL];
  provide[literal URL, R, effect E](url, auth, continuation) — the
  use-provider protocol, so a scope reads
  `let tools : mcp.toolbox["https://…"] = use mcp.provide(…)`;
  call[literal URL] carries the same scope row; tools is deleted; a
  literal url gives per-URL scoping and a dynamic string degrades to
  scope[string], with the covariance consequence documented
- row spelling: a scope-discharging continuation row uses the
  overwrite form {...E, scope[URL]} — the union spelling trips the
  conservative tail-lacks check (the solver subtracts scope from the
  provider's effect generic); recorded in the design doc as the
  canonical form
- runtime: the payload union gains provide (side listing delegation,
  tool minting with {descriptor, scope} context, continuation
  dispatched with {value: toolbox}, settling with its outcome); scope
  identities register live, every callTool checks liveness, the last
  scope on a descriptor evicts the transport client, and closed-scope
  or scope-less calls fail with a typed server_error before any
  transport dispatch — the runtime backstop for the covariance hole;
  provide state persists in its own mcp_provide_instances extension
  (migration 0006) and restarts resume durably
- codegen: generated modules expose with_tools[R, effect E](auth,
  continuation) wrapping provide with the pulled url baked as a
  literal; wrapper rows gain mcp.scope["<url>"]; callers write
  `use github.with_tools(auth = …)` with no explicit generics
- examples/docs migrated; docs/2026-07-11-mcp-provide.md records the
  design, the literal/dynamic split, and the backstop rule

646 compiler examples, 296 runtime tests, and the docker e2e suite
(11/11) are green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shape-check record's fields were constructed positionally, so
their names were never referenced and GHC flagged them unused; named
construction states the shape at the one place it is asserted. Also
drops the redundant OutputContext import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…te retired

Nesting two provides was impossible: the continuation row's overwrite
spelling {...E, scope[URL]} pins the whole scope entry, so a context
already carrying scope["a"] could not open scope["b"] (rows are keyed
by request name and merge their arguments by union — the second
provide saw scope["a" | "b"] where the overwrite demanded exactly
scope["b"]).

The union spelling E | scope[URL] is the semantically correct row, and
it is now typable under inference: solving a provider's effect generic
no longer blanket-subtracts concrete requests BY NAME into an
invisible tail lacks-set (which the dispose check then rejected
against the rigid declared tail, printing two identical-looking rows).
Instead the subtraction is variance-directed and argument-granular —
a fully-covered entry cancels (E | scope[u] against E2 | scope[u]
solves E2 := E), a partially-covered merged entry keeps its uncovered
remainder via covariant literal difference (scope["a"|"b"] minus
scope["b"] leaves scope["a"]), and the subtraction defers to solve
time because the provider's URL binds from the sibling literal
argument. Soundness: this is the error-free propose step and only ever
enlarges the covariant lower bound; the trusted subtype re-checks at
dispose. The overwrite spelling remains for its real use (handler
re-provision) — it just stops standing in for union.

- mcp.provide and the generated with_tools flip back to the union
  spelling; the codegen template needed no other change (inference now
  handles the generated shape with no explicit instantiation)
- the tail-lacks K3001 names the requests the expected effect
  additionally excludes instead of rendering two identical rows
- tests: nested two-server provides calling tools from both scopes
  (and an outer tool inside the inner scope), the rigid-E wrapper via
  pure inference, the scoped["a"]-never-satisfies-scoped["b"] negative,
  and a playground compose() locking the composition in katari check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mcp.call returned a raw json tree, so a server-produced file arrived
as an inert $ref wire-form object — invisible to the ownership walk,
mitigated by adopting unclaimed blobs onto the distant run instance
with run-long lifetimes. The call is now schema-directed like
json.decode:

  call[literal URL, T](url, auth, tool, arguments) -> T
    with scope[URL] | throw[server_error | auth_error | decode_error]

- runtime: the external's ambient generics ride to the reactor; the
  directCall payload captures T's schema and decodes with the same
  conformValue + wire-form codec + typed decode_error machinery
  json.decode uses — conform-tree-first, so T = json.json keeps
  today's inert-tree behavior exactly, while a typed T reconstructs
  real file handles whose lifetime is value-reachability; the
  run-adoption rule survives only as the backstop for blobs left
  inert inside a json-typed result
- explicit instantiation is all-or-nothing on arity (a literal generic
  still counts), so callers write call["<url>", T](...) — generated
  wrappers always do, and T is K3016-explicit by design
- codegen: wrapper bodies collapse to the single typed call; the
  wire-form asymmetry note is parameter-side only now

652 compiler / 35 cli / 56 project / 300 runtime tests and the docker
e2e suite (11/11) are green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A private value may now leave the runtime toward the request's
destination server through BOTH deliberate submission surfaces —
headers and body — revealed at the single transport boundary; the URL
and method stay public because URLs leak into logs, caches, proxies,
and Referer headers. This is the uniform rule replacing "headers
only", decided so credentials that APIs demand in a form body
(Google's OAuth refresh_token has no header form) can live as real
encrypted secrets instead of plain env entries.

fetch and post_json declare body: string of private (public strings
still flow in — Public <: Private); the runtime needed no functional
change because dispatch already revealed the whole argument at one
point, so the change is type-level plus the negative that keeps the
rule honest: a private url is still rejected (K3001), tested against
the real stdlib and a synthetic mirror. The http path logs nothing,
so no new redaction was needed. docs/2026-07-12-private-body-sink.md
records the rule and rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…entials

The generated module no longer hands a record of closures to a
continuation. It now follows the package provider idiom: one
`request credentials() -> mcp.auth`, one `connect` use-provider that
opens the mcp.provide scope and handles credentials for the rest of
the block, and one TOP-LEVEL typed agent per tool that reads
`auth = credentials()` ambiently. The caller story collapses to

  use github.connect(auth = mcp.oauth(name = "github"))
  let issue = github.get_issue(owner = ..., repo = ...)

— a bare use (no binder, so K3013 never applies) and direct calls;
tools pass as values to row-generic AI loops unchanged.

Empirically settled row spelling, now locked by tests: a phantom
capability that flows through stays UNION (so two servers' scopes
merge to scope["a" | "b"] and nesting composes), while a request the
provider HANDLES is pinned by overwrite (a handler must exclude its
request from the shared generic to discharge it) — the continuation
row reads {...(E | mcp.scope["<url>"]), credentials}. Golden, caller
story, two-generated-modules composition, and mangling-seed tests all
compile against the real stdlib; connect's provide-listing overhead
for static bindings is recorded with a future listing-free mcp.open as
the noted fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds `prelude.time` (now, sleep, sleep_until, watch) as a new in-runtime
reactor (`time`), the sibling of http/webhook/mcp. The whole design turns on
durability under replay: the substrate replays a batch from its durable inputs
on a commit failure or restart, so a prim reading `Date.now()` would disagree
with itself. Routing `now` through a reactor records the instant on the
`delegateAck` (journaled in the durable outbox), so replay reads the recorded
value — that is WHY now is external, not an optimization.

Surface (verbatim in stdlib/prelude/time.ktr):
  external agent now() -> number from "time"
  external agent sleep(milliseconds: number) -> null from "time"
  external agent sleep_until(time: number) -> null from "time"
  data interval(milliseconds: number)
  data cron(expression: string, timezone: string)   -- IANA timezone required, no default
  type schedule = interval | cron
  external agent watch[effect E](
    schedule: schedule,
    deliver_to: agent (time: number) -> null with E,
  ) -> never with E | io from "time"

Durability semantics:
- sleep/sleep_until collapse to one absolute `deadline` decided at the call's
  entry boundary (no relative/absolute flag downstream); a restart re-arms the
  same instant, and a passed deadline resolves immediately.
- watch delivers one tick per occurrence via an inner delegation (the
  discord-watch shape), serialized (the next occurrence arms only when the
  current delivery settles). The persisted `nextTick` cursor is the single
  source of truth: a restart across missed occurrences fires EXACTLY ONE
  catch-up (the earliest missed one) then continues on the original phase — no
  backfill. Ticks are at-least-once (the scheduled instant is passed for
  deduping).
- watch has NO built-in retry: a deliver_to throw/panic propagates and kills
  the watch. Resilience is composed at the call site (the retry provider),
  never baked in.
- A malformed schedule (bad cron/timezone, non-positive interval) is hoisted to
  an `invalid` operation variant at entry and panics at dispatch — watch
  declares no throw, so a bad schedule means the program is broken.

Implementation:
- TimeReactor extends ExternalCallReactor; its payload is a TimeOperation sum
  (now|sleep|watch|invalid) decided once at openPayload from the compiled
  external key, dispatched structurally by every lifecycle method.
- Clock (external/clock.ts): SystemClock in production, ManualClock in tests —
  the same DI seam as the http/ffi/mcp transports, so durable time is tested
  deterministically with no real waits.
- Durable state: one sealed jsonb `operation` column on `time_instances`
  (no capability token to index, so no split columns / subtype tables),
  migration 0007_time_instances; reloaded and re-armed on boot like webhook.
- cron-parser v5 (luxon-backed tz) for correct IANA-zone occurrences, 5- and
  6-field cron; justified over hand-rolling in the design doc.

Wiring: "time" added to ReactorName, InstanceKind, ExternalReactorName, the
compiler's externalReactorNames whitelist, the ProjectActor registry, and the
reactivate() reset/load list. The whole persistence port (tx/loader/in-memory
twin/db) gains a `time` surface.

Tests: 9 fast reactor tests (now records the clock; sleep resolves at deadline;
sleep re-arms after restart; past-deadline resolves immediately; watch delivers
interval + cron ticks; single missed-tick catch-up; cancel teardown; deliver
failure kills the watch). Playground `time.ktr` (a ~1s durable sleep +
bounded interval-watch demo) with an e2e run; design doc
docs/2026-07-12-time.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ow payload

A retry provider names its throw payload only inside the continuation's effect
row (`agent (…) -> R with {...E, prelude.throw[Error]}`) — never in a value
position — so `Error` was reported un-inferrable (K3016) and the whole surface
would have needed explicit type arguments at every `use` site, or would have had
to erase the throw to `throw[unknown]` (collapsing typed-error composition).

`collectEffectConstraints` bounded only the flexible effect TAILS (which requests
a continuation adds to `E`), never the payload of a concrete row entry. Add one
step to `collectConstraints.goFunction`, symmetric with the existing `goData` for
data-type arguments: a request shared by the actual and parameter rows relates its
args at the request's row variance, so `throw[Error]` against `throw[fetch_error]`
proposes `Error ≥ fetch_error`. "Inference proposes, checking disposes" — the
dispose pass re-verifies, so this only makes more generics solvable and changes
nothing already solved.

The full compiler suite is unchanged (656 → 656) plus two new InferenceSpec cases
pinning the retry shape: Error inferred from the throw row, and the inferred type
kept exact (a handler for the wrong payload still fails to discharge, K3001).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three `use` providers that wrap the rest of a block and RE-INVOKE the continuation
to recover from failure — the composition `prelude.time` deliberately left out of
`watch` ("resilience is composed at the call site, never baked in"). Pure Katari
over durable `time.sleep` and the two error channels; no new reactor.

  use retry.exponential(initial_delay_milliseconds, factor, max_attempts)
  use retry.forever(initial_delay_milliseconds, factor, max_delay_milliseconds)
  use retry.attended()

`attempt` is the single boundary where a caught `throw` and a caught `panic` are
folded into one `outcome` sum (`succeeded | failed`), so each provider is just a
`match` over that sum — no branch on a counter anywhere. The throw stays TYPED: the
catch discharges `throw[Error]` for a generic Error (inferred via the sibling
typechecker change), so a caller keeps its typed error end to end rather than an
erased `throw[unknown]`.

- exponential: `for` over the catchable attempts (growing delay threaded as state);
  a success breaks with the value, exhaustion re-raises by running the FINAL attempt
  UNCAUGHT in `then` — so throw/panic propagate unchanged (a caught panic cannot be
  re-raised from Katari, and this needs no "is this the last attempt?" test).
- forever: self-recursion whose depth is the number of failures, not ticks — the
  daemon shape (keep a `time.watch` alive across transient failures and restarts).
- attended: waits by performing `attention` (the normalized failure); unhandled it
  escalates to a durable open question the run parks on until `katari answer`, and an
  app can intercept it with its own handler (the Discord re-auth story). Same
  mechanism, chosen by whether a handler is in scope.

Design: three providers sharing one normalization helper (not a generic policy
core) — the wait actions' differing effects (io vs the attention request) and the
un-re-raisable panic make one mechanism uglier than three small ones; see
docs/2026-07-12-retry.md.

Continuation re-invocation (first use anywhere) was proven against the in-memory
ProjectActor: the engine re-enters the continuation fresh, ambient handler state
outside the provider persists across re-invocations, and exhaustion re-raises the
typed error — no engine change needed. Playground `retry_demo` (main succeeds on
attempt 3; exhausting re-raises typed; daemon composes forever over watch) plus an
e2e case are the standing net.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node coerces a setTimeout delay past 2^31-1 ms (~24.8 days) to 1 ms, so a
sleep or sparse watch armed raw past the ceiling fired immediately — a
30-day sleep resolved in milliseconds, and a yearly cron became a runaway
immediate-fire loop. The reactor's arm() now hops in bounded chunks (a
wake short of the persisted deadline re-arms the remainder; only a wake at
the deadline fires), and the Clock contract itself rejects an over-ceiling
delay in BOTH implementations — ManualClock included — so the
deterministic tests exercise the same rule production runs and a
regression to raw arming fails loudly instead of misfiring only in
production. A non-finite sleep deadline is rejected at open as an invalid
operation (it previously hung a manual clock and fired at once on the
system clock, silently divergent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
yukikurage and others added 28 commits July 18, 2026 23:44
The default output was src/mcp-tools.ktr — a hyphenated module outside
any namespace, invalid twice over. The extension now reads [package].name
from katari.toml and defaults to src/<name>/mcp_tools.ktr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…cluded

hono to 4.12.30 (the CORS wildcard-credentials high and four mediums),
esbuild to 0.28.1. The last alert pins esbuild 0.18.20 inside
drizzle-kit's deprecated esbuild-kit: dev-only, never serves, no upstream
fix path — left in place deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
json.json and its seven constructors retire; the JSON document currency
is the plain value. Every unmarked verb is literal and key-verbatim —
parse, stringify (documents only, stringify(parse(s)) = s, non-documents
raise stringify_error), parse_as, and the readers — while to_text alone
speaks wire, unescaped, as the model's read channel for files and data.
Blind wire decoding leaves the user surface entirely: the call_agent
boundary revives a replayed {$ref} into a real file only where the
schema expects one, and the $$ escape survives solely inside the codec's
persisted bytes — no exit surface shows it. post_json takes a value tree
in and hands parsed unknown back; mcp.call and agent_metadata follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
Writing JSON is one verb: documents round-trip key-verbatim, and a file,
a data value or an agent prints as its canonical $ form — the same text
the model reads, so the write channel and the AI read channel are one.
Whether an external API understands the $ shapes is the caller's call;
stringify_error retires with the rejection. The $$ escape now lives
nowhere but the codec's persisted bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
The reserved wire keys become $katari_ref / $katari_constructor /
$katari_agent (and their kin) — a namespace no real JSON uses — so a
record key can never collide with a marker and the $$ escape retires
entirely. wire<->value is one unconditional, schema-free codec: a
$katari_ref is always a file, a $katari_constructor always a data value.
Persistence stores the Value structurally (its kind is out-of-band), so
it round-trips exactly without touching the wire. A user type reaches the
runtime in exactly two places now: a call_agent dispatch and validate[T],
which checks and never rewrites — the schema-directed revive is deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
Every reserved wire key now shares the $katari_ prefix; the encrypted
private subtree collapses to $katari_sealed at rest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…onal codec

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
Blob ownership now ascends by an unconditional per-event hoist instead of the
value-driven reachability drag that scopes still use. Every observable upward
event (a delegateAck result, an escalate's carried ask) reassigns ALL of the
sending instance's remaining blobs one delegation step up, onto the caller
instance — value-carried or not.

Why: a blob's id travels the text plane (an AI transcript, a stringify'd JSON
tree where a $katari_ref rides as an inert string), which carries the id but
not ownership. The old value-driven ascent lifted only resources a value
reached, so a blob an id-only text remembered was FK-cascaded away the moment
its producer instance completed. The hoist keeps it alive one caller at a
time; scopes are unchanged (reachability drag only).

The seam is send-side (the callee reactor's Reactor.send), forced by the FK
cascade: a blob row cascade-deletes with its owner instance, so a completing
instance's teardown drops its still-owned blobs in the very commit that emits
its final delegateAck — a receive-side reassign would always observe an
already-gone blob. To reach the caller INSTANCE cross-reactor (only the caller
reactor owns the delegation row), the delegate event now carries `caller`,
stamped by the base send and recorded in the callee's received edge; the
reassign commits with the event that justifies it.

Boundaries: run->api stays purely value-driven (the run instance is permanent,
so hoisting would pin every blob for the run's life); scopes untouched; a
cancel / failure (raisePanic/raiseThrow bypass send) does not hoist, so
reclaimBlobsOwnedBy at teardown is the sole implicit reclaim — it prunes
exactly the blobs still below the cut. adoptDetachedProducedBlobs is kept as a
reload backstop (a no-op on the live path now the hoist runs first); it is
removed next wave. A blobsByOwner index is added symmetric to scopesByOwner.

No existing test pinned the old adopt->run owner (a blob's location is
transparent to readability within a run), so all 428 prior runtime tests pass
unchanged; 6 new tests cover the chain climb, the escalate hoist surviving a
later cancel, the external-call producer path, the cancel-before-ack reclaim,
the run->api boundary, and the generalized reassign + index.

Docs: docs/2026-07-19-ownership-hoist.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…e blob backstop

The wire, not the declared T, decides what a value MEANS. decodeDirectReply now
always reconstructs the reply through the value codec (jsonToValue), so a
$katari_ref is a real file and a $katari_constructor a data value at every T —
the same principle codec.ts already obeys: interpretation is the wire's business,
validation a separate pass. T becomes a pure validation gate: unknown accepts the
decode as-is, a constrained T conforms it, and a marker the wire cannot
reconstruct (a non-string $katari_ref, a withheld $katari_redacted) is a
json.validation_error at any T, unknown included. This aligns the code with
mcp.ktr, which already declared the conversion unconditional and never
schema-directed.

With markers always interpreted, the per-call produced-blob adoption backstop is
dead weight: a produced blob's id no longer rides as an inert string, and —
independently — the ce0d033 ownership hoist already reassigns every blob a call
still owns onto its core caller on the upward delegateAck, so a produced blob
reaches the caller whether or not the decoded value happens to carry its ref.
Drop producedBlobs, adoptDetachedProducedBlobs, and their bookkeeping;
registerProducedBlob keeps registering the blob as owned by the call's instance
(the ownership the hoist ascends). resource-pool's reassignOwnedBlobs keeps its
now-unused blobIds arg — a concurrent wave owns that file.

Tests: the old "unknown -> inert $katari_ref record, run-adopted" pins are
replaced. An unknown reply now decodes a $katari_ref to a real file and a
$katari_constructor to a data value; a malformed marker throws
json.validation_error even at unknown; the produced blob survives to the caller
via decode + hoist (renamed from the backstop pin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…, read-after-free is file.gone

Adds the first program-facing blob producer and reclaimer to `prelude.file`,
on top of the hoist ownership model:

- `from_base64(content, content_type) -> file` lifts a model's / API's embedded
  base64 (a gemini `inline_data` image) OFF the value plane INTO a real blob,
  owned by the running instance so it ascends with the instance's next upward
  event exactly like an FFI-produced blob.
- `free(value) -> null` releases a blob the current run owns, freeing its bytes
  now rather than at run teardown.
- The three readers (`read_base64` / `content_type` / `size`) now throw the
  catchable `file.gone` (a typed `throw`) instead of panicking when a handle no
  longer names a live blob — so a freed / hallucinated handle is recoverable in
  program (an AI loop's guard, or a `replay` converter that selects it).

The engine reaches the actor's `ResourcePool` through two turn-scoped closures
threaded onto `StepContext` / `PrimContext` (`produceBlob` / `freeBlobInRun`),
so the pool stays an actor-layer concept the engine never imports.
`deleteBlobOwnedInRun` is the run-scoped counterpart of `deleteBlobOwnedBy`:
because a produced blob HOISTS up its call chain, ownership is checked at the
RUN (the owner must be a core instance whose `runId` matches), which naturally
refuses a user-uploaded file (owned by the api root, not a core instance) and
another run's blob.

Why a fresh UUID id, NOT a content hash: a `file` is a resource, not a literal
(codec `valueEquals`), so two `from_base64`s of the same bytes must be DIFFERENT
files — same-bytes-same-id would silently make them `==` AND share one blob row
across two custody chains, so one chain's cancel or `free` would dangle the
other's ref. A retry re-running `from_base64` mints a new file per attempt,
which is the same "two productions are two files" rule; Katari's replay is plain
re-execution (not event-sourced recomputation), so no id needs to be stable.

Why `free` is idempotent and silent (no throw, no observable result): a retried
block that frees the same handle across attempts — or a handle the run does not
own — must behave identically each attempt, so the pool no-ops a missing / freed
/ foreign blob rather than reporting it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
The send side is already ref-clean (a `file` in an http request body rides
to the transport as its handle and its bytes are spliced in only at the send
boundary), but the receive side had no way to keep a downloaded payload's
bytes off the value plane: a plain `fetch` reads the whole body into the
response `body` STRING, dragging that text (and its base64) through every
value, event, and trace it flows into.

`http.fetch_file` is the receive-side twin of a `binary` request body: it
takes `fetch`'s request unchanged but stores the response body as a project
blob and returns only the slim `file` HANDLE, so the raw bytes never touch
the value plane, the durable call record, or the trace. It is scoped to a
WHOLE response body (an image / PDF / export by URL); a base64 field buried
inside a JSON reply stays `file.from_base64`'s job. Its error contract
matches `fetch` exactly — a non-2xx reply's body is still captured to a file
(branch on `status`), and a request that never completes throws `fetch_error`.

Runtime: the reactor picks the file-capture response shape from the external's
dispatch key and carries it as the call's `responseKind`; the transport reads
the response bytes at the receive boundary and stores them through a wired
producer (the twin of the send-side blob resolver) that hashes them, registers
the blob as owned by the call's instance — so the reply's `delegateAck` hoists
it to the caller like any produced ffi / mcp blob — and records the response's
Content-Type (falling back to `application/octet-stream`). The ownership row
commits, via a serial command turn, before the completion is processed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
… poison the actor

An external call's result is decoded at the settle seam (`maybeSettle`), but the
base wire decoder (`jsonToValue`) ran without a guard: a hostile / malformed
`$katari_*` marker (a `$katari_redacted` header, a non-string `$katari_ref`) or an
over-deep tree (a RangeError) made it throw, and the uncaught throw reached the
substrate's non-transient failure path — dropping every warm run of the whole
project and losing the in-flight completion. Any URL (http.fetch / fetch_file) or
hostile MCP server (callTool) reached it remotely and cheaply.

Harden the seam in ONE place rather than per reactor: the base wraps its decode
in a try/catch and folds a failure into the reactor's declared escalation
(`escalateResultDecodeFailure`) — the general form of what an mcp direct call
already did in its own `complete`. The default is a panic (an undecodable result
on a TRUSTED boundary — an ffi sidecar, a webhook subscriber's engine value, an
oauth token — is an engine-invariant break); the reactors whose result crosses an
UNTRUSTED boundary override it with their typed throw: http → `fetch_error` (both
fetch and fetch_file), mcp `callTool` → `server_error`. The mcp direct call keeps
its existing `validation_error` (pre-folded before the seam), and the seam is now
a backstop for it too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
… they release + hoist

`prelude.throw` at the program level flows through `send` (release + hoist), but
the reactor-level twins `raiseThrow` / `raisePanic` assembled the escalate and
pushed it onto the send buffer directly — a "send with the release and hoist cut
out". That was invisible until a typed throw's payload could carry a REAL blob
ref (bfcaae0's unconditional decode reconstructs one): the ref's blob stayed owned
by the failing call instance, and the throw's resolving teardown reclaimed the
bytes — dangling the catcher's ref (file.gone). The text-plane case (an id carried
only in the payload's text) was the same shape, missing the hoist.

Route both through the one `send` path. Now the escalate releases the payload's
carried ref to in-transit for a catching handler to reown, and hoists the raiser's
remaining blobs onto the caller — uniformly with every other upward event. A
pre-instance panic has no received edge, so the hoist is skipped naturally; the
run→api boundary skips it as before; a panic's `{ message }` captures nothing, so
its release is a no-op. No double-open (the id is fresh and `send` opens the row).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…from the delegations SoT

On reload, core re-derived each instance's caller INSTANCE (the blob-hoist target)
with `callerInstanceOf`, which reads only the caller-side delegation rows core
ITSELF issued (`from = core`). An instance summoned by a webhook subscriber, an
mcp.serve continuation, or an ffi inner delegation has its caller-side row owned by
THAT reactor, so its hoist target reloaded as `undefined` — and every later upward
event silently skipped the hoist, letting completion teardown reclaim a produced
blob out from under a durable core callee (a dangling ref across a restart).

The SoT is already durable: `delegations.caller_instance_id` is recorded for every
delegation. Core now reads the delegations addressed to it (`to = core`, whoever
issued them) via a new `delegationsTo` / `loader.core.summoningDelegations()` and
re-derives the caller instance by delegation id — uniformly for its own sub-calls
and cross-reactor summons alike. Corrected the `HandledDelegation` comment, whose
claim that the `undefined` case "only suppresses the hoist for a reloaded external
call" (and that core re-derives only from rows it owns) was doubly wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…not only a core one

`deleteBlobOwnedInRun` resolved a blob owner's run through `store.instances`, which
holds only `core` engine instances. But a serve/webhook delivery's residual blob
HOISTS onto its endpoint call instance — a long-lived external-call instance, not a
core one — so `file.free` could never reclaim it (the owner was absent from the
store, so it looked like a foreign / api-root blob and was refused), and the blob
lingered on the permanent endpoint until it closed.

Broaden the check to "any instance of the run" (owner-approved): the pool takes a
run resolver the actor composes over every reactor's received edge (a new
`Reactor.runOfInstance`), so a core instance resolves from the store and a non-core
endpoint call instance from its owning reactor. The api root is summoned by no
delegation, so it belongs to no run and resolves to `undefined` — a program still
cannot free a user-uploaded file; another run's blob and an in-transit blob miss
the same way. Silent / idempotent behaviour unchanged; a bare pool (a unit test)
keeps the store-only default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…ct the hoist / blob-ref docs

`reassignOwnedBlobs` kept a `blobIds?` parameter for the external-call produced-blob
adoption, whose only caller bfcaae0 removed — so the explicit-ids branch was dead
in src (the surviving caller is the whole-holding hoist). Drop the parameter and
the false comment ("kept while the hoist subsumes it").

Docs, to the implemented truth:
- 2026-07-19-ownership-hoist.md: the claim that failure (raisePanic / raiseThrow)
  does NOT hoist and that a throw payload carries no id was doubly wrong — both
  program-level and reactor-level throw/panic now escalate through `send`, so they
  release the payload's carried ref and hoist the raiser's blobs (required for a
  caught throw's ref to survive). Also refreshed the caller-instance durability note
  (core re-derives cross-reactor summons from the delegations SoT) and the removed
  adoption backstop / signature.
- 2026-07-09-slim-blob-ref.md: a dangling handle now raises a catchable
  throw[prelude.file.gone] (e044b80), not a panic; wire markers namespaced to
  $katari_*.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
… discharged scope

Structured concurrency as ordinary agent values, built on the same scoped-marker
discipline as `mcp.provide`. This is the TYPE surface only (wave 1); the `region`
runtime reactor lands later, so `from "region"` externals type-check but do not yet run.

Five externals plus a fiber handle and a scope marker:
  - `provide[Scope, E]` opens the nursery (runST shape) and DISCHARGES `Scope` from its
    own row, so no fiber outlives the block.
  - `fork` spawns a child bounded by the ceiling `E`; a child that raises more than `E`
    is a type error.
  - `join` / `cancel` operate on a `fiber[Scope, T]`.
  - `watch` is the white hole: it re-emits the fibers' escalations as the FULL `E`, so a
    handler covering `E` covers every request any fiber can raise.

Soundness rests on two variance choices, verified end to end:
  - `fiber[Scope, T]` carries `Scope` in an AGENT effect row (like `mcp`'s `tool[Scope]`),
    not as a data phantom — a data phantom compares bivariantly and would silently let a
    fiber cross regions.
  - the `nursery[Scope, E]` handle carries BOTH `Scope` and `E` INVARIANTLY, so `join`
    pins the fiber's scope to the nursery's (rejecting a foreign-region fiber, rather than
    inferring the mere union of the two scopes) and `fork`/`watch` see `E` as exactly the
    ceiling `provide` fixed.

`region` is added to the external / stdlib-only reactor name sets. `fork`/`join`/`watch`/
`cancel`/`region` are NOT reserved words: they are qualified agent names (`region.fork`),
exactly like `mcp.provide`, so reserving them would break their own declarations.

Tests cover the nursery usage example, fork+join+cancel+watch composition, and three
rejections: a cross-region join, a child exceeding the ceiling, and an escaped fiber
re-joined in a foreign region.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…continuation

Wave 2 of structured concurrency: the `region` reactor's `provide` skeleton, a
concurrency specialisation of `mcp.provide`. `region.provide` mints a nursery
scope identity, hands the continuation a `nursery` handle carrying it, dispatches
the continuation as one inner delegation with `{ value: nursery }`, and settles
the whole call with the continuation's outcome — no listing, no transport, so the
continuation dispatches directly on the first post-commit turn. The scope is
registered while the provide is live and closed at drop, the seam later waves'
`fork` / `join` / `cancel` gate on. A provide survives a restart completely (like
`webhook` / `time`): its scope re-registers and its continuation resumes as
durable core work.

`fork` / `join` / `watch` / `cancel` are not implemented yet — their keys reach
`openPayload` only defensively (the continuation never calls them), folded into an
`operation` payload variant that fails the call with a clear "not yet implemented"
completion. Wave 3 turns the `fork` key into a real fiber spawn.

`region` joins the reactor-name unions (ReactorName / ExternalReactorName /
InstanceKind, the `instances_kind_check` and its 0000 migration) — the TypeScript
mirror of the compiler's `externalReactorNames`, which already listed it (wave 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…utcome

`prelude.region.fork` becomes a real fiber spawn. A `fork` is its own call,
but the fiber it starts opens as an inner delegation of the nursery's PROVIDE
call — not the fork's — so the base supplies the whole structured-concurrency
story for free: the fiber's escalations relay up through the provide into the
enclosing program, and the fiber is cancelled by the provide's own cancel
cascade when the block returns (no fiber outlives its nursery). `fork` itself
settles at once with an opaque `fiber[Scope, T]` handle (scope id + fiber id,
under $katari_ marker fields), so it returns immediately as its signature
promises. A fork whose scope is not live is refused as a panic (fork's row
declares no throw, and a dead-scope fork is a checker-prevented invariant).

A fiber that settles before it is joined has its outcome buffered on the
provide's durable `fiberBuffer` (join drains it in wave 4); the scope registry
grows from a bare Set to a per-scope Map holding the provide call and its
running fibers. A fork persists its (task + argument) re-dispatch and simply
re-spawns on reload, at-most-once-safe because its only effect is opening an
internal delegation. join/watch/cancel stay unimplemented `operation` stubs.

Tests drive the whole path through the ProjectActor: fork delivers the argument
to a separate fiber, independent forks run concurrently, a fiber's escalation
relays to the run root and its answer returns, a settled fiber's outcome buffers
durably across a restart, a dead-scope fork panics, and a fiber left running
when the provide returns leaks nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…aiter

`prelude.region.join` becomes a real await. A `join` is its own call, routed
by the fiber HANDLE it is handed (its own scope names the nursery that spawned
the fiber, so a fiber is awaited where it lives even under nested same-marker
scopes). If the fiber already SETTLED, its outcome is in the provide's durable
`fiberBuffer`: `join` removes it (single-consumer) and settles at once. If the
fiber is still RUNNING, `join` holds its call open and parks an in-memory
waiter (keyed by fiber id, like mcp's served-call waiters) that
`bufferFiberOutcome` settles directly — instead of buffering — when that fiber
later lands. A handle that is neither buffered nor running (a stale handle, a
fiber already joined, or one lost to the buffer's post-commit durability
window) is not joinable, so it PANICS: `join`'s row declares no throw and
region has no error sum, the same backstop as a dead-scope fork.

A join hands the fiber's result RESOURCES across: they were re-owned onto the
PROVIDE instance when the fiber settled, so `settleJoin` releases them from the
provide and claims them onto the join's own instance, and the join's delegateAck
then reowns them to the join's caller — ending the reown at the core that called
`join`, with no leak or dangling ref.

A join persists its (scope + fiber), so a join left waiting by a restart re-runs
its drain / re-parks its waiter. The waiter's durable twin is the join's own
running row plus the fiber's inner-call bridge, so a reloaded provide rebuilds
its running-fiber set from those bridges (`repopulateRunning`) before any
scheduled `startJoin` reads it. For that reload rebuild the base's
`innerCallRowsOf` becomes `protected` — the one minimal base change, additive
visibility only (no reactor's behaviour changes). watch/cancel stay unimplemented
`operation` stubs.

Tests drive the whole path through the ProjectActor: join drains a buffered
outcome (held deterministically until the fiber buffered), a join parked before
the fiber settles is resumed by its completion, a fiber's returned value
round-trips through join, a waiting join re-parks across a restart and resumes,
a double join panics on the second, and a scope-capturing closure a fiber
returns survives the join hand-off and leaks nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…minate

`cancel[Scope,T](nursery, handle) -> null` reaches `openPayload` as its own
routed call, told from `provide`/`fork`/`join` by its qualified key and routed
on the fiber HANDLE's own scope (exactly like `join`). It sends ONE `terminate`
to the fiber's inner delegation — the single-fiber form of the base's
whole-nursery `terminateChildren` cascade, using the existing `issuedRowOf`
seam — and settles with `null` once the teardown confirms in
`bufferFiberOutcome` (a cancel waiter, the durable twin of a join waiter).

A cancelled fiber has no joinable outcome, so `cancel` makes the fiber UNKNOWN:
a later `join` panics (symmetric to a double-join), a join already PARKED on it
is panicked (cancel and join are exclusive intents), and an already-settled
fiber's buffered outcome is dropped so the post-condition holds regardless of a
completion/cancel race. A forged / dead-scope handle names no live nursery, so
it panics — the same backstop as `join`/`fork`, which automatically rejects a
hostile-wire handle (its random scope matches no live scope, so no separate
decode gate is needed). A concurrent double-cancel is idempotent. `cancel`
persists and re-runs its idempotent teardown on recovery; `watch` stays a
not-yet-implemented `operation` stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…the handler

`region.watch` intercepts a fiber's escalation (which a watch-less nursery relays
UP through its provide) and re-emits it at the WATCH call's own position, so a
handler installed around the watch — a spot that still holds the nursery handle —
services the fibers' requests and can fork into the same nursery. The answer
descends the base relay bridge back to the fiber. Re-emission is FIFO and serial
(one outstanding at a time); escalations that beat their watch's registration wait
in the nursery's durable mailbox and drain when `startWatch` lands; a cancelled
fiber's not-yet-emitted escalations are dropped.

Backward compatibility is preserved at GLOBAL QUIESCENCE: a watch drains its scope
eagerly, so a mailbox still full when every run is blocked belongs to a genuinely
watch-less nursery and flushes up to the run root — the one point where "a watch
that was going to register already has" holds, so it cannot race a late watch.

Base: extract `relayAskUnder` / `hasOpenRelay` from `onEscalate` (behaviour-
preserving) so the reactor can re-route a child ask to a different call; add a
default-noop `onQuiesce` hook fired by the substrate when the mailbox drains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…ption

A playground tour of the structured-concurrency nursery: `fan_out` forks
three squarers and joins each (contrasted with the fixed `parallel for`),
and `subscribe` drives the white hole — an emitter fiber's `on_message`
escalations well up at `region.watch` to a handler that forks a second
emitter (composition) and, after four messages, throws `enough` to tear
the nursery down. Compiles under `katari check` and runs end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
Deploys and runs `playground.region.main` against the compose-backed
server: fan-out fork/join returns [4,9,16], the parallel contrast agrees,
and the white-hole subscription reports four messages across two emitters.
This is the wire-compatibility net for region between the Haskell
compiler's output and the TypeScript runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
Records the region design: motivation (structured-concurrency nursery for
multi-agent), scope marker as escape barrier, E as the fiber-effect
ceiling, watch as the mailbox-re-emit white hole, fork = detached
delegation parented on the provide, join fan-out, cancel, ownership /
cascade, and the reactor as an ExternalCallReactor subclass. Confirms the
wave-2 nursery-conform question (no fix — internal dispatch skips the
acceptance boundary), the blob-through-watch behavior (readable), and
sketches the out-of-repo AI integration (ai.infer embeds region.provide).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…ailbox restart

The white hole (`region.watch`) had two unverified blob edges. Both already
behave correctly, so pin them rather than change the runtime:

- Answer direction: a handler that mints a blob with `file.from_base64` and
  answers with it descends the watch bridge to the fiber, which reads the
  content back. The blob is owned by the continuation instance the whole
  handle+watch lives in — an ancestor of every fiber, kept alive by the
  held-open watch — so it outlives the reader with no answer-direction owner
  lift needed.
- Restart across a mailboxed escalation: a fiber's blob carried on an
  escalation held in the provide's durable mailbox reloads intact (ref +
  bytes + owner) across a restart, and the handler reads it after the watch
  re-emits it post-recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…hemselves in type position

The `file` sub-module lived under `prelude.file`, but `file` is also the built-in
value-type keyword — and the keyword shadows the module qualifier in type position,
so `prelude.throw[file.gone]` / `case ... file.gone ->` could not parse. A program
that wanted to catch a specific file error had to fall back to catching `unknown`.

Rename the module qualifier only: `prelude.file` -> `prelude.files`. The `file` type
keyword (`x: file`, `array[file]`, `-> file`) is untouched — it is a built-in type,
unrelated to the module. Member references become `files.gone`, `files.from_base64`,
`files.content_type`, etc., which are nameable in every position because `files` is
not a keyword. The runtime interop prim registrations follow (`prelude.files.*`), and
the file's internal representation (`ref` / `semanticKind: "file"` / `$katari_ref`
wire) stays as-is, being unrelated to the module name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
…s read as first-design

The playground README's module table had drifted to eight of the thirteen
`.ktr` modules — add the five missing rows (time, replay_demo, region,
mcp_demo, mcp_serve_demo) and correct the intro count, so the table matches
what ships. region.ktr (wave 7's fan-out fork/join and white-hole
subscription) gets its own row and a standalone run example.

In the region reactor, strip the development-phase residue so the comments
read as if the design was always so: drop the "(wave 4)" tags, describe the
RegionPayload sum's `watch` variant (it was still framed as the unimplemented
operation) and `operation` as the wire-drift fallback, rewrite openScope's
reload note around the live repopulateRunning, and replace the
"backward-compatible / before watch existed" framing of the watch-less
flush-up path with plain present-tense design description. Comments only —
no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTwMSahmQCMNFzVsjjCM9H
@yukikurage
yukikurage merged commit 65f5fd9 into main Jul 20, 2026
2 checks passed
@yukikurage
yukikurage deleted the feature/v0.1.0-composability branch July 20, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant