v0.1.0 composability: region nursery, blob hoist, AI integration - #6
Merged
Conversation
…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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/gone、http.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