@house-rules/application
v0.1.0
Published
The application layer for Effect apps: contracts, capabilities, gates, composition, and HTTP and MCP delivery.
Readme
@house-rules/application
The application kit: it builds an app's application layer on top of the domain modules. A capability is one named action an app offers, such as "show a booking". It has a contract and one handler. The contract holds the name, a description, Effect Schemas for the input, the output and the failure, two flags, readOnly and destructive, a permission, needsApproval, and transactional. The handler is an Effect that takes the decoded input and gets its services from Layers. An MCP tool is built from the contract with toTool, and HTTP or CLI adapters can later be built the same way, so each action is written once.
adapter HTTP route MCP tool CLI command decides who calls, maps errors
\ | /
gates Grant, then Approval may this caller, does a human mean it
|
capability showBooking contract + handler one action, typed in and out
|
service Bookings (a module's interface) rules, checks of one object, dataThis package and @house-rules/domain replace @house-rules/capability, which stays at 0.16.0 on npm and gets no new versions. Domain defines modules: defineModule, Actor, permissions and policy, and the unit of work. Application defines everything about one call: contracts and capabilities, CurrentActor, the gates, the call watch, composition and delivery, and sqlUnitOfWork. Application depends on domain, never the other way.
@house-rules/application
actions defineContract, implement, Capability
request CurrentActor, Caller, Incoming, admit, Refused, decodeInput, InputInvalid
gates Grant, Approval, Forbidden, ApprovalDenied, PermissionDeclaration
watch CallWatch, CallChannel, call facts, request id helpers
app composition, CompositionClosed, call, views, problem, defaultViews
http endpoint, route, toFetchHandler, toHttpClient, HttpFailure, toOpenApi
mcp toTool, tool, toMcpHandler, elicitationApproval
data sqlUnitOfWork
/contract defineContract, endpoint, toHttpClient, HttpFailure, gate errors, InputInvalid, Refused| Slot | Filled by |
| --- | --- |
| Caller, which sets CurrentActor | the app, per request |
| Grant | a permission list, per request or once at the root |
| Approval | MCP elicitation; the click in the app |
| CallWatch | @house-rules/call-audit, or nothing |
| UnitOfWork, defined in domain | memoryUnitOfWork (domain) or sqlUnitOfWork (here) |
Grant is action-level authorization: may this caller run this action at all? Approval is consent: does a human mean this call, now? Both are fixed steps of implement; only their answers are slots. Object-level authorization, whether this caller may act on this one object, is the module's policy, inside the domain. CallWatch is the only middleware: it wraps every call and never changes the result.
A module is a deep piece of behavior: an interface plus a private implementation. Its service, a Context.Service class, is that interface. A capability is one action offered on top of the service; the module knows nothing about it. The contract declares its permission, and implement checks it before the handler runs. Checks of one object, such as whether this user owns this trip, stay inside the service. The readOnly and destructive flags tell an adapter a read from a write.
Request identity: CurrentActor
A package declares its root module with defineModule from @house-rules/domain. Its methods take the Actor as an explicit argument. CurrentActor is the per-request slot that holds that Actor; yielding it returns the Actor directly:
import { Actor, ActorId } from "@house-rules/domain";
import { CurrentActor } from "@house-rules/application";
const actor = Actor.make({ id: ActorId.make("user-1") });
const request = Effect.gen(function* () {
const current = yield* CurrentActor;
const greetings = yield* Greetings;
return yield* greetings.greet(current);
}).pipe(Effect.provideService(CurrentActor, actor));Delivery sets CurrentActor per request, through the composition's Caller, and a use-case passes the Actor into module methods. CurrentActor carries domain's PerRequest marker, so defineModule fails tsc for a make that requires it, in either form. A module never stores request identity at build time, and its writes still require the use-case's open unit of work.
The template's Bookings module uses create(actor, input: NewBooking) to store the actor's id as ownerId. relation(actor, id) answers "owner" or "none" from that stored fact, in the domain rather than the storage port. Migration 0002 adds nullable owner_id; unchanged legacy rows have no owner and answer "none" for everyone. Public get remains visitor-readable. Its HTTP contract returns only id and guestName, never ownerId.
Usage
import { expect, it } from "@effect/vitest";
import { Effect, Schema } from "effect";
import { defineModule } from "@house-rules/domain";
import { defineContract, implement } from "@house-rules/application";
class Greetings extends defineModule<
Greetings,
{ readonly greet: (name: string) => Effect.Effect<string> }
>()("app/Greetings", {
make: Effect.succeed({
greet: (name: string) => Effect.succeed(`Hello, ${name}`),
}),
}) {}
class NameIsEmpty extends Schema.TaggedError<NameIsEmpty>()("NameIsEmpty", {}) {}
const greetContract = defineContract("greet", {
description: "Greet someone by name.",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
failure: NameIsEmpty,
permission: "public",
annotations: { readOnly: true },
});
const greet = implement(greetContract, ({ name }) =>
Effect.gen(function* greetHandler() {
if (name === "") {
return yield* new NameIsEmpty();
}
const greetings = yield* Greetings;
return yield* greetings.greet(name);
}),
);
it.layer(Greetings.layer)("greet", (test) => {
test.effect("greets by name", () =>
Effect.gen(function* greetsByName() {
expect(yield* greet.handler({ name: "Ada" })).toBe("Hello, Ada");
}),
);
});greet.handler has the type (input: { readonly name: string }) => Effect<string, NameIsEmpty, Greetings>. The Greetings requirement comes from the handler itself. Provide it with a Layer, as the test does. A handler that fails with an error the contract does not declare, or returns a value the output schema does not allow, is a type error.
permission is required. "public" says, on purpose, that any caller may run the action, and adds nothing to the handler's type. annotations is optional. Each flag you leave out is false.
Permissions and approval
Each call runs two gates before the handler. They are Context.Service slots that the adapter provides for each request, the way it provides CurrentActor.
handler(input)
1 Grant permission held? no -> Forbidden({ capabilityName, permission })
2 Approval human says yes? no -> ApprovalDenied({ capabilityName, reason })
3 handler the use-case itself- [ ] Permission. A
resource:actionstring, such astrips:delete, or"public". The module that owns the resource names its permissions, typed as domain'sPermission. The type rejects a contract without one, and a string without a colon. - [ ] Grant.
holds(permission) => Effect<boolean>: does this caller hold the permission at all? It runs before any data is read, so it cannot leak whether a record exists. A non-public contract addsGrantto the handler's requirements andForbiddento its failures. - [ ] Approval.
approve(capabilityName, input) => Effect<void, ApprovalDenied>: does a human mean this call, now?needsApproval: trueaddsApprovalto the requirements andApprovalDeniedto the failures.false, the default, adds nothing.
import { Approval, defineContract, Grant, implement } from "@house-rules/application";
const removeTripContract = defineContract("remove_trip", {
description: "Delete one trip the caller owns.",
input: Schema.Struct({ tripId: Schema.String }),
output: Schema.Void,
failure: TripNotFound,
permission: "trips:delete",
needsApproval: true,
annotations: { destructive: true },
});
const removeTrip = implement(removeTripContract, ({ tripId }) => /* ... */);
// removeTrip.handler: (input) =>
// Effect<void, TripNotFound | Forbidden | ApprovalDenied, Trips | CurrentActor | Grant | Approval>
removeTrip.handler({ tripId }).pipe(
Effect.provideService(Grant, Grant.fromPermissions(memberPermissions)),
Effect.provide(Approval.allowAll),
);| Cartridge | Type | Use |
| --- | --- | --- |
| Grant.allowAll, Grant.denyAll | Layer<Grant> | tests, or a surface that trusts every caller |
| Grant.fromPermissions(list) | GrantService value | Effect.provideService(Grant, ...) per request |
| Grant.layerFromPermissions(list) | Layer<Grant> | the same, as a layer |
| Approval.allowAll, Approval.denyAll | Layer<Approval> | the web, where the click is the yes; tests |
| elicitationApproval | Layer<Approval> | MCP: ask the human through the client |
elicitationApproval asks the human through the MCP client with a field-less form: ApprovalForm is the requested schema { type: "object", properties: {} }, so the human sees the message and the client's Accept and Decline buttons, with no checkbox. The action is the answer. Accept approves, whatever the content (missing or {}). Decline fails with ApprovalDenied and the reason "Approval was declined". Cancel fails with "Approval was cancelled". It sends the request itself through the McpServerClient instead of McpServer.elicit, because McpServer.elicit derives the schema from a Schema.Struct, and an empty struct has no type or properties, so it cannot send a field-less form. The message names the capability and shows its input as JSON data, never as instructions (approvalMessage). It reads the McpServerClient of the current request when approve runs, so it works whether you provide it per request or once at startup. It fails closed with ApprovalDenied on anything that is not a clean accept: a decline, a cancel, an unexpected answer ("Approval answer was malformed"), a client that does not advertise form elicitation or no MCP client in context ("The MCP client cannot ask a human for approval"), and a failed request, such as a transport error, a JSON-RPC error, or a timeout. A failed request reads "Approval request failed ()", so it is not mistaken for a decline. <Tag> is the error's _tag, or the defect's class name, and only when it is a short identifier (letters, digits, underscore, at most 40). The reason never carries the error's message, reason, or any other text, because that text can echo user data. With no safe tag it reads "Approval request failed". A result that is null, not an object, or has no known action is "Approval answer was malformed", with its decision log line. An agent cannot answer its own approval: the client shows the form to the human. Whether each agent client supports elicitation is not checked here.
Each decision writes one log line with Effect logging: Effect.logInfo "Approval granted" or Effect.logWarning "Approval denied". The line is annotated with capability, outcome (approved or denied), reason (denials only), clientName and clientVersion from the client's initialize info, and formElicitation, whether the client advertised form elicitation. It never logs the input or any user data.
A wide flag, such as a needsApproval typed only as boolean, gets both gates in the type. The safe side is to provide more.
Watching every call
CallWatch is a hook around every call, on every surface. implement runs each call as watch.around(contract, input, run, facts), where facts is optional (a caller that passes three arguments gets unknownCallFacts(contract): the kind from the contract, no target, channel "unknown", no request id) and run is the Grant check, then Approval, then the handler. So the watch also sees Forbidden and ApprovalDenied, and it can measure the whole call. input is the decoded input.
CallWatch is a Context.Reference with a pass-through default. Provide nothing and the call behaves as before. It adds no requirement and no failure to any capability. A watch is trusted code. It must return the exit of run unchanged: no Effect.orDie, and no skipping run. The Around type keeps the channels, but it cannot enforce that behaviour.
import { CallWatch } from "@house-rules/application";
import { Clock, Effect, Layer } from "effect";
const loggingWatch = Layer.succeed(CallWatch, {
around: (contract, _input, run) =>
Effect.gen(function* logCall() {
const start = yield* Clock.currentTimeMillis;
return yield* run.pipe(
Effect.onExit((exit) =>
Effect.gen(function* logExit() {
const end = yield* Clock.currentTimeMillis;
yield* Effect.logInfo("capability call").pipe(
Effect.annotateLogs({
capability: contract.name,
permission: contract.permission,
outcome: exit._tag,
ms: end - start,
}),
);
}),
),
);
}),
});The example logs no input values, because input is often user data. A watch that wants the caller reads CurrentActor with Effect.serviceOption, so it adds no requirement either.
You do not have to write the watch yourself. @house-rules/call-audit (packages/call-audit, see its README) is one that writes a log line per call, or keeps the entries in memory for tests.
Call facts: kind, target, channel, request id
The watch gets a fourth argument, facts, a CallFacts. A watch that takes three arguments still type-checks.
| Fact | Where it comes from |
| --- | --- |
| kind | "read" when the contract has annotations: { readOnly: true }, else "write" |
| targetId | the input field the contract names in audit: { target: "tripId" }, only when its value is a string, capped at 256 characters; else null |
| channel | "mcp" or "mcp:<client name>" during an MCP tool call; else the CallChannel reference, "unknown" by default |
| requestId | the CallRequestId reference, which withRequestId sets; null by default |
audit.target must name a key of the input whose type can be a string. Naming a number field or a missing key is a type error. It is a field name, not a function, so no computed value can reach the audit.
Review rule: audit.target must name an id field, such as tripId, never free text such as a title or a note. The type only checks that the value is a string. It cannot prove the string is an id, so audit: { target: "note" } compiles and stores the note. Reviewers check each audit.target by hand.
The MCP channel needs no wiring. The Effect McpServer provides McpRequestContext to every tool call, including the tools toTool builds, and the facts read the client name from it. The name keeps only letters, digits, ., _, - and spaces, at most 40 characters. An MCP call wins over any CallChannel the app set. The app sets the other channels itself, such as Effect.provideService(CallChannel, "web") in its web runner. The request id is wired at the app's edge too; see the next section.
Request id
One id per incoming request ties its audit rows and its log lines together, so a bug report can quote it. The package ships the pieces; the app wires them at its edge:
| Export | What it does |
| --- | --- |
| requestIdHeader | "x-request-id", the header the app reads, forwards and echoes |
| requestIdOf(value) | the value when it is a safe id (^[A-Za-z0-9._:-]{1,128}$), else null |
| newRequestId | an Effect<string>: 32 random lowercase hex characters from Effect's Random, so a test can seed it. It is a log key, not a secret. |
| withRequestId(id) | wraps an effect: provides CallRequestId and adds the log annotation requestId, so every log line inside carries it, not only the audit line |
client ──► proxy / server middleware
id = requestIdOf(incoming x-request-id) ?? fresh id
forwards it on the request, echoes it on the response
──► route / page / action / MCP route
effect.pipe(withRequestId(id))
──► capability ──► audit row request_id = id
log lines {requestId: id}const id = requestIdOf(request.headers.get(requestIdHeader)) ?? (yield* newRequestId);
return yield* capability.handler(input).pipe(withRequestId(id));A valid incoming id is kept as sent, so an id set by a proxy or a client in front can be followed through. Anything else is replaced. The id is not trimmed first: abc is replaced, not kept as abc. The id only traces a request: it grants nothing and never decides access, so a client that sends its own id can only confuse its own trail. Free text never reaches the audit, because requestIdOf lets only id characters through. withRequestId is dual: withRequestId(effect, id) works too. An inner withRequestId wins over an outer one.
Units of work
A use-case owns the transaction, not the module. One unit of work may cover writes in several modules. UnitOfWork is a slot like Grant. The domain kit defines it, with CurrentUnit, NoOpenUnit, UnitOfWorkFailed and the memory adapter; this package adds the SQL adapter:
| Adapter | Package | Type | Use |
| --- | --- | --- | --- |
| sqlUnitOfWork | application | Layer<UnitOfWork, never, SqlClient> | Postgres: one SqlClient.withTransaction per unit |
| memoryUnitOfWork | domain | Layer<UnitOfWork> | memory stores: runs the effect, and on failure runs the rollback hooks the stores registered; one unit at a time |
A use-case opens a unit in one of two ways:
- [ ] The contract sets
transactional: true.implementruns Grant, then Approval, then the handler insideUnitOfWork.atomic, all insideCallWatch. A refused call opens no unit. The flag addsUnitOfWorkto the requirements andUnitOfWorkFailedto the failures. It removesNoOpenUnitfrom the implemented handler's error type and turns that invariant failure into a defect.false, the default, adds nothing. - [ ] The handler calls
UnitOfWork.atomic(effect)around only the part that must be atomic. That addsUnitOfWorkandUnitOfWorkFailedto that effect.
A nested atomic joins the open unit: no new transaction, no savepoint. A failure that leaves the outermost unit rolls everything back. UnitOfWorkFailed({ reason }) means the SQL unit could not begin, commit or roll back. The work's own errors and defects pass through unchanged. When a rollback fails, the cause holds UnitOfWorkFailed first, then the work's own failure.
Memory stores have no row locks, so memoryUnitOfWork runs top-level units one at a time, per layer instance. A second top-level atomic waits until the open unit has committed or run its rollback hooks. Without that wait, a unit whose undo puts back a whole earlier state could wipe out another unit's commit. A nested atomic, in the same fiber or in one it forked, joins the open unit and never waits, so it cannot deadlock. A unit is open only while its work runs. It closes when the work ends, before COMMIT, ROLLBACK and the undo hooks. A fiber that outlives or races that end, such as a daemon forked inside it, gets NoOpenUnit from required and onRollback, and should open its own unit with atomic: that waits for the permit here, which the closing unit holds until its undo hooks finish, and starts a new transaction in the SQL adapter. On rollback every undo hook runs, newest first, even when one fails or dies, and the unit's cause keeps the work's cause plus each hook's failure. The memory adapter is for tests and scripts; it does not model concurrent transactions.
A module never opens a unit. Its write path checks for one, with no requirement added to the service method:
// In a module's Postgres store: fails with NoOpenUnit when no unit is open.
put: (booking) => UnitOfWork.required.pipe(Effect.andThen(sql`insert into booking ...`)),
// In a module's memory store: register the undo, then change the map.
put: (booking) =>
UnitOfWork.onRollback(Effect.sync(() => rows.delete(booking.id))).pipe(
Effect.andThen(Effect.sync(() => rows.set(booking.id, booking))),
),CurrentUnit is a Context.Reference holding the open unit, undefined by default, so reading it adds no requirement. The SQL adapter puts the transaction connection in the fiber's context, so every statement on the same SqlClient inside the unit joins it. Build the module stores and sqlUnitOfWork from one SqlClient layer. Scripts, tests, and the app's server process open a unit themselves, such as a background loop that opens one unit per item: memoryUnitOfWork with memory stores, or sqlUnitOfWork with Postgres stores. UnitOfWork.make(transaction) builds another adapter from a function that wraps an effect in a transaction.
A CallWatch runs outside the unit, so an audit row written there survives a rollback.
Relations, policy and plans
A relation is how the caller stands to one object, such as owner or shared. The module checks it against its own data with definePolicy from the domain kit; see Permissions and policy. A plan, such as free or paid, is a fact about the caller, so it feeds the Grant: the app builds a Grant for each request from the caller's plan with Grant.fromPermissions.
An action with no input
Some actions take no input, such as "list my trips". Give them NoInput as the input schema:
import { Effect, Schema } from "effect";
import { defineContract, implement, NoInput } from "@house-rules/application";
const pingContract = defineContract("ping", {
description: "Answer pong.",
input: NoInput,
output: Schema.String,
failure: Schema.Never,
permission: "public",
annotations: { readOnly: true },
});
const ping = implement(pingContract, () => Effect.succeed("pong"));
const pong = ping.handler({}); // Effect<string>: the handler takes an empty objectDo not use Schema.Struct({}) for this. Effect renders it to JSON Schema as {"not":{"type":"null"}}, with no type. An MCP tool's input schema must have "type": "object", so Effect's McpServer fails to list every tool with SchemaError: Missing key at ["type"]. NoInput is Schema.Record(Schema.String, Schema.Never), the same shape as Effect's Tool.EmptyParams. It renders as {"type":"object","additionalProperties":false}, so an MCP tool can use contract.input as its parameters as is. Checked with effect 4.0.3.
An input with several shapes
Some actions take one of a few inputs, such as "find a booking by its id or by its guest". Give them a Schema.Union of Structs, one per shape, with a tag field each:
import { Effect, Schema } from "effect";
import { defineContract, implement } from "@house-rules/application";
const FindInput = Schema.Union([
Schema.Struct({ by: Schema.Literal("id"), bookingId: Schema.String }),
Schema.Struct({ by: Schema.Literal("guest"), guest: Schema.String }),
]);
const findContract = defineContract("find_booking", {
description: "Find a booking by its id or by its guest.",
input: FindInput,
output: Schema.String,
failure: Schema.Never,
permission: "bookings:read",
});
const find = implement(findContract, (input) =>
Effect.succeed(input.by === "id" ? input.bookingId : input.guest),
);The handler gets one decoded member, so input.by narrows it. Every member must be a Struct. audit.target may name only a string field that every member has.
An MCP tool cannot take a Union: Effect's McpServer needs an object at the root of a tool's input schema. So toTool splits the tool's parameters into two jobs.
- The gate is what
McpServerdecodes with. It checks only the outer shape: the input is an object, and every key belongs to some member. A key no member takes fails withInvalidParams. The gate checks no value and requires no key, and it hands the raw arguments on unchanged. They then decode once through the real Union, which alone decides what is valid, which keys are required, and what each value becomes. So for MCP arguments that are JSON objects using only declared keys, the gate never refuses an input the Union takes and never changes one, whatever codecs, defaults or getters the members use. A key no member declares is always refused, even when a loose member such as an emptySchema.Struct({})would take it. A value one member would trim or parse never reaches another member changed. The promise covers JSON objects with declared keys only. Effect's emptySchema.Struct({})takes any value butnullandundefined, such as1or[], and the gate still refuses those, because MCP arguments are never anything but an object. - The advertisement is the JSON Schema the model sees: one merged object. Each key shows its wire type, the encoded side, and a key two members type differently shows the Union of both. The tag becomes an enum, here
"id" | "guest". The object is closed:"additionalProperties": false. A key's requiredness errs strict: a key is optional only when some member leaves it out, or some member is optional on both sides for that key with nothing in its decoding, its Struct or the Union that could refuse a missing key.
So a key a member marks optionalKey(Schema.String) or optional(Schema.FiniteFromString) is optional. Every other key every member has is required, even when the action would take it missing: a decoding default such as withDecodingDefaultKey, OptionFromOptionalKey, a transformOptional or SchemaGetter.required() getter, a Schema.suspend field, and a check on the member Struct or on the Union all make it required. The advertisement may therefore ask for more than the Union needs. A key some member leaves out stays optional, because one JSON object cannot tie a key to a tag. Any other key is never called optional when the Union refuses to go without it, and for a JSON object with declared keys the gate never refuses what the Union takes, because the Union decides. So { "by": "guest" } passes the gate, fits no member, and fails like any other invalid input: the MCP client gets an InvalidParams error, and the handler does not run.
The gate is Schema.Any with one filter. The filter's toJsonSchema hook returns the advertisement, compiled from its representation.schemas, the way Schema.isPropertyNames does. Schema.Any keeps the filter through Schema.toCodecJson, where Schema.Unknown would not. Checked with effect 4.0.3.
Install in another app
Install it from npm at an exact version:
{
"dependencies": {
"@house-rules/application": "0.1.0",
"@house-rules/domain": "0.1.0",
"effect": "4.0.3"
}
}- [ ] Pin
@house-rules/domainandeffectto the exact versions this package names inpeerDependencies. It is an exact peer, and the handler types come from that copy ofeffect. If the app installs@effect/platform-node, also declare@effect/platform-node-sharedas an exact direct dependency at the same version, now4.0.3. - [ ] Stable 4.0.3 needs no release-age exception. For a future version under a day old, wait or get approval for an exact-version
minimumReleaseAgeExcludeentry. Remove the old RC entries. - [ ] The package ships built JavaScript and
.d.tsfiles indist/. Plainnodeloads it, and a bundler such as Next's needs notranspilePackagesentry for it. Browser code imports@house-rules/application/contractand@house-rules/domain/contract. - [ ] Use TypeScript 7 or 6 with
moduleandmoduleResolutionset toNodeNext, orBundlerfor UI code, andskipLibCheck: true. It is tested with TypeScript 7.0.2. Or extend@house-rules/rules/tsconfig/effect.json, installed as the root README shows. Its Effect diagnostics run only afterpnpm exec effect-tsgo patchfrom@effect/tsgo, as this repo'spreparescript does. - [ ] The usage example is a test, so it needs two dev dependencies, pinned exactly:
"@effect/vitest": "4.0.3"and"vitest": "5.0.1"(the versions this repo uses). - [ ] To upgrade, change the version and run
pnpm install. An app that still pins a Git SHA moves as the root README shows.
An MCP tool from a contract
toTool(contract, options) builds an effect/ai Tool from a contract, so an MCP adapter does not write the tool by hand. The contract gives the name, the description, the parameters (contract.input, or the merged object of a Union input, as An input with several shapes shows), and the Tool.Readonly and Tool.Destructive annotations. Every tool is Tool.Strict, so McpServer rejects a key the input does not take with InvalidParams instead of dropping it. This is new in 0.11.0 and breaks callers that sent extra keys; see Upgrading to 0.11.0. The options carry what only MCP needs:
| Option | Required | Becomes |
| --- | --- | --- |
| title | yes | Tool.Title |
| idempotent | yes | Tool.Idempotent |
| openWorld | yes | Tool.OpenWorld |
| success | no | the tool's success schema; defaults to contract.output |
| failure | no | the tool's failure schema; defaults to failureSchemaOf(contract), the contract's failure plus Forbidden, ApprovalDenied and UnitOfWorkFailed when its gates and transactional add them |
import { Toolkit } from "effect/ai";
import { toTool } from "@house-rules/application";
export const Greet = toTool(greet.contract, {
title: "Greet someone",
idempotent: true,
openWorld: false,
});
export const tools = Toolkit.make(Greet);
export const toolHandlers = tools.toLayer({ greet: (input) => greet.handler(input) });idempotent and openWorld are required because Effect defaults openWorld to true, and an agent client reads the hint. Each MCP adapter decides them. Pass success when the tool returns a view of the output, and failure when the adapter maps the contract's errors to its own error, such as a ToolProblem. The handler you pass to toLayer then maps to those schemas. A failure override replaces the gate errors too, so that mapper maps Forbidden and ApprovalDenied as well.
The tool type keeps the contract's exact types: Tool<"greet", { parameters: typeof GreetInput; success: ...; failure: ... }>. toTool is typed over Contract<Name, Input, Output, Failure>, not over C extends AnyContract. The constraint form widens the input to InputSchema, so the tool loses its exact parameter type. An override the options type marks optional, such as success?: typeof View, types the schema as typeof View | typeof Output, because at run time it may be either one. A NoInput contract renders its parameters as {"type":"object","additionalProperties":false}. A Union contract types its parameters as Schema.decodeTo<Input, MergedParameters<Members>>: its Type is the Union's, so the handler you pass to toLayer gets one member, and its Encoded is the merged wire shape, built from the members' encoded types. A key in it is optional when some member lacks it or leaves it optional on the wire. This static type is not the JSON advertisement: the advertisement has its own, stricter requiredness, so a key with a decoding default is optional in Encoded but required in the JSON. At run time the gate takes any value per key, so this type describes what a caller may send, not what the gate checks. ToolParameters<Input> names that type, and is Input itself for a Struct or NoInput.
The house plugin's no-hand-rolled-surface rule fails on Tool.make outside this package, so every MCP tool comes from a contract.
Delivery
Delivery mounts capabilities on a host. The seam is the Web fetch standard: the host hands over a Request and gets a Response. Next route files, the House Rules Node Host, Bun, Hono and Workers all speak it. For Node apps, install @house-rules/node-host and call serve(api, { port, onClose: () => api.dispose().then(() => app.dispose()), handleSignals: true }) in src/main.ts, an appEntryFiles entry. src/server/ holds background loops, not the Host call. Its guide is packages/node-host/README.md in the House Rules repo. It streams the fetch handler without an app import of node:http. A Next app already has a Host and does not need it. React server code skips HTTP and calls the capability in-process with call.
host: Next route | Node Host | Bun | Worker React server component
| Request | headers(), input
v v
toFetchHandler / toMcpHandler call(app, capability, input, incoming)
1 request id (x-request-id, echoed)
2 CSRF: a write with a Cookie from another origin -> 403
3 caller(incoming) -> Context<CurrentActor | Grant | ...> or Refused -> its status + challenge
4 decode input with contract.input or InputInvalid -> 400
v
composition: the app layer (built on first use, again after a failed build), the caller, the views
v
capability.handler: CallWatch -> Grant -> Approval -> UnitOfWork -> handler
v value | typed failure
encode output | views[tag](error, caller) -> Response, MCP tool result, or Outcomeimport { composition, route, toFetchHandler, toMcpHandler, tool, call, once } from "@house-rules/application";
// The one composition root: the layer is built once and shared by every adapter.
export const app = composition({ layer: appLayer, caller: sessionCaller, views });
export const api = toFetchHandler(app, {
prefix: "/api",
caller: bearerCaller,
routes: [
route(showBooking, "GET /bookings/:id"), // path params and query, decoded from strings
route(findBookings), // default: POST /api/find_bookings, JSON body
],
});
// MCP sessions live in memory, so make the handler once per process, even across Next dev reloads.
export const mcp = once("mcp", () =>
toMcpHandler(app, {
path: "/mcp",
name: "hosti",
version: "1.0.0",
caller: oauthCaller,
tools: [tool(showBooking, { title: "Show a booking", idempotent: true, openWorld: false })],
}),
);
// React server code: no HTTP hop.
const result = await call(app, showBooking, { id }, { headers: await headers(), url });
// { ok: true, value } | { ok: false, error, view }- [ ] Composition.
composition({ layer, caller, views })builds oneManagedRuntimeover the layer.call, every fetch handler and every MCP handler read the same built services, so they share one pool.app.dispose()closes it. The layer's error type stays in the composition,Composition<A, C, Views, E>, so a layer that fails with a tagged error, such as a database that is down, answers that error's view on every adapter:toFetchHandlersends its status and body,toMcpHandlerrefuses the HTTP request with them beforeMcpServerruns, andcallresolves to{ ok: false, error, view }. None of them rejects. A layer that dies, or fails with an error that has no tag, is the fixed 500. Each failure is logged with the request id. A failed build is not kept: the next use builds the layer again, so a 503 clears when the database comes back, with no restart. Concurrent first requests share one build, and a build that succeeds is kept.app.runtimeis the current runtime, a fresh one after a failed build. Each request runs as a fiber of that runtime once the layer is built.app.dispose()is final: it interrupts every request still running, then closes the layer's resources, so no request runs on a closed pool. After it, nothing builds the layer again, not even after a failed build. A request that arrives later, or waits on a build when dispose runs, answersCompositionClosed, whose default view is 503unavailableThe app is shutting down., on all three adapters. A seconddispose()returns the same finished shutdown. The layer starts cold: the first request builds it, even one that a refusal then turns away, such as a 401 from the caller. Only the CSRF check runs before the build. To keep a refused request from doing startup work, warm the app at startup, for example withapp.runtime.runPromise(Effect.void). - [ ] Caller.
Caller<C, R>is(incoming: { headers, url }) => Effect<Context<C>, Refused, R>. It runs with the app's services, so it can ask an identity module who this is. It returns the per-request services, usuallyCurrentActorandGrant.Refusedcarries astatus, acode, amessageand an optionalchallenge, which goes out asWWW-Authenticate. Each adapter may take its own caller; the composition's is the default. - [ ] Input. The adapter decodes the whole input once, with
contract.input, and the handler gets the decoded type. A path param on a write, and an MCP call's arguments, pass a field or toolkit decode first, so a schema filter may run more than once. A GET takes path params plus query, decoded with the StringTree codec, so"5"becomes5. A write (POST, PUT, PATCH, DELETE) takes the JSON body plus path params; a path param is decoded through its field's codec first. A key given twice, a JSON member given twice (even spelled with an escape), a key the input does not take (__proto__too), or a query key on a write is a 400. An array field in a GET query is the one exception to "once": see The query. A write body must be valid UTF-8; a stray byte is a 400The body is not valid UTF-8., never a replacement character.InputInvalidnames the capability, the field and the issue, and never quotes the value.calldecodes its input too, because a server action's input is untrusted. - [ ] Views. A view is
{ status, body, headers?, code? };codeis the public failure code it opts in to send, see Single-page apps. The app keeps one table, failure tag to(error, caller) => View. Defaults cover House Rules' own tags; an app or adapter view replaces one by tag. A defect, or an error with no view, is a 500internal_error, logged with the request id. So is a view that throws, an output the contract cannot encode, and any other defect while the adapter answers. Status 500 is kept for defects.problemandproblemViewrefuse a literal 500 at compile time. A view that answers 500 anyway, say from a status held in a variable, sends the sameinternal_errorbody, and its own body goes to the log. A view may still pick another 5xx, such as 503unavailable, with the body its author wrote. MCP uses the same table: the body becomes anisErrortool result and the status is dropped. - [ ] Approval per channel.
toFetchHandlerfails closed (Approval.denyAll),toMcpHandlerasks the human withelicitationApproval, andcallusesApproval.allowAll, because a click in the app is the human's yes. Each takes anapprovaloption. The adapter's Approval always wins over one a caller returns, so a caller cannot switch off the human's yes by mistake. To let the caller's Approval decide, say so by name:approval: "fromCaller". The adapter's default still applies when the caller returns none. - [ ] Body limit. A write body over 1 MB (
defaultMaxBodyBytes) is a 413body_too_large, checked oncontent-lengthand again while it streams. SetmaxBodyBytesto change it. - [ ] Success body. The success body is the contract's output, encoded with its JSON codec, with
content-type: application/jsonand no charset (JSON has none). There is no success view. Keys the output schema does not declare are dropped, so a handler cannot leak them. The status is the route's:route(cap, at, { status: 201 }). Paging goes in the body, such as{ items, next }, not in aLinkheader. MCP encodes the output the same way and sends it asstructuredContent.calldoes not encode: it returns the handler's typed value as is, undeclared keys included, because it never leaves the process.
| Tag | Default view |
| --- | --- |
| Refused | its status and code; WWW-Authenticate when it has a challenge |
| InputInvalid | 400 invalid_input, with field; the error also carries capabilityName and issue for an app view |
| Forbidden | 403 forbidden |
| ApprovalDenied | 403 approval_denied |
| UnitOfWorkFailed | 503 unavailable |
| CompositionClosed | 503 unavailable, The app is shutting down. |
| NoOpenUnit, a defect, no view | 500 internal_error |
Admit before parsing
A custom delivery may need to refuse a read token's write before it parses its own query dialect or body. admit(app, incoming, { caller?, channel? }) runs only the caller, with the app's services and request tracing, and returns Promise<Outcome<Admitted, Refused | E>>. The composition's caller is the default; the channel defaults to "web".
import { Approval, admit, call } from "@house-rules/application";
async function handle(request: Request) {
const incoming = { headers: request.headers, url: request.url };
const auth = await admit(app, incoming, { caller: bearerWrite, channel: "api" });
if (!auth.ok) {
return Response.json(auth.view.body, { status: auth.view.status, headers: auth.view.headers });
}
const input = await request.json();
return call(app, updateBooking, input, incoming, {
admitted: auth.value,
approval: Approval.denyAll,
});
}Admitted is opaque. App code cannot construct it or read its context. Admitted has no expiry and holds the resolved caller for the composition's lifetime, so delivery must use it for one request only and never cache it, since a cached value would outlive token revocation. Pass the same value to one or more call()s on the composition that issued it. Each skips the caller and keeps the admission's request id, including a generated id when no safe header was supplied. It also keeps the admitted channel unless call sets its own channel. caller and admitted cannot be combined. Supplying both at runtime, or using a value from another composition, returns the fixed 500 internal_error, logged with that id, without running a caller or capability.
admit maps Refused through the composition's views over the defaults, preserving the refusal's status, code and message in the default view. Layer failures and a closed composition use the same views as call; a caller defect returns the fixed internal error and is logged. No hand-run Effect or provided CallRequestId is needed. Only caller resolution is reused: input decoding and capability gates still run on each call. An explicit approval still wins; "fromCaller" reads Approval in the resolved context, or falls back to call's default when absent.
Input errors
InputInvalid has four fields. An app view can word its own message from them:
| Field | Value |
| --- | --- |
| capabilityName | the contract's name, such as search_tasks |
| field | the field's path, such as status or positions.0.x; body when the body itself is wrong; input when the key cannot be named safely |
| issue | unexpected (a key the input does not take, or a query key on a write), missing, invalid (a value the field refuses, or a body that is not JSON, not an object, not UTF-8, or unreadable), repeated (a key given twice) |
| message | the default text, such as status is not valid. or Give q once. |
const views = {
InputInvalid: (e: InputInvalid) =>
problem(422, "invalid_input",
e.issue === "invalid" || e.issue === "missing"
? (MESSAGES[e.capabilityName]?.[e.field] ?? e.message)
: e.message),
};The default view's body stays { code, message, field }. decodeInput(contract, raw, source) names contract.name too.
The query
A GET reads its input from the path and the query, decoded with the StringTree codec. The field's StringTree side decides how a key is read:
| Field | Query | Notes |
| --- | --- | --- |
| a string, number or literal | k=v, once | 5 decodes to 5 |
| Schema.Boolean | k=true or k=false | 1, 0 and an empty value are invalid |
| an array of scalars | k=a&k=b, one key per item, in order; k=a is [a] | OpenAPI style: form, explode: true |
| optional | leave the key out | an empty array sends no key |
| with a default | leave the key out | write the default in the contract: Schema.withDecodingDefaultKey(Effect.succeed(v)); it works in the JSON codec too |
| anything else, such as a struct or a nullable array | not a GET field | give the contract a write route |
A key is a list only when every Union member that has it reads a list. Still refused: a repeat on a field that is not a list (Give x once.), a key the input does not take, a path key in the query, and a comma list. A comma is data, so status=a,b is one item, "a,b".
A missing view is a type error
toFetchHandler, toMcpHandler and call check each mounted capability against the app at compile time. Every failure tag needs a view in the defaults, the composition's views, or the adapter's views. So does every tag in the layer's error type: composition() fails tsc with missingViews when the layer can fail with a tag that has no view there, and each adapter checks it again against its own views. A view keyed by a tag must take that tag's error. Every service the handler needs must come from the app layer, the caller or the adapter. A gap fails tsc and names it:
Property 'missingViews' is missing in type '{ routes: ... }'
but required in type '{ readonly missingViews: "BookingNotFound"; }'.The same check reports wrongViews and missingServices.
CSRF
A browser sends cookies with a cross-site write by itself, whoever the caller is. So toFetchHandler checks every write (POST, PUT, PATCH, DELETE) that carries a Cookie header, even an empty one, and even one that also has a bearer token. A write whose Origin does not match the app's origin is a 403 cross_origin before the app layer builds or the caller runs. Without Origin, the Referer's origin decides; with neither, the write is refused. An opaque source, Origin: null or a data: Referer, is always refused. The app's origin is the request URL's origin, or the origins list you pass to toFetchHandler. Set origins when a proxy changes the host or scheme. Set it for local and internal apps too, and restrict Host at their proxy or listener to guard against DNS rebinding. Each entry must be a plain http(s) origin, such as https://app.example, with no path; anything else, "null" included, throws when the handler is built. A write with no Cookie is not checked: no session rides along cross-site. A route opts out only with a written reason:
route(stripeWebhook, "POST /hooks/stripe", {
csrf: { skip: true, reason: "Stripe signs the body; no browser sends it." },
});An empty reason throws when the route is built.
One router per handler
Each toFetchHandler and toMcpHandler builds its own router. A fetch handler answers only its own routes, so it never serves MCP, and two handlers on one composition may mount the same path. They still share the app's services through the composition.
Request ids
Every adapter takes x-request-id when it is safe, or makes a fresh one of 32 lowercase hex characters, and echoes it on the response. MCP keeps one id per HTTP request: the caller, the tool's CallRequestId, the logs and the response header all carry it.
Single-page apps
A single-page app calls the same routes over HTTP. @house-rules/application/contract is the browser-safe entry: defineContract, endpoint, toHttpClient, the gate errors and the types, with no SQL, no MCP server and no router. @house-rules/domain/contract adds Actor, ActorId, Permission and UnitOfWorkFailed. A module puts the contracts a browser needs in its own contract entry, src/contract.ts, exported as "./contract". The rule contract-entry-is-browser-safe fails the check when that file reaches an adapter, a facade, a storage port, a node: module or a driver.
// packages/bookings/src/booking/show-booking.ts, re-exported by src/contract.ts
export const showBookingContract = defineContract("show_booking", { ... });
// apps/api/src/delivery/http/endpoints.ts: read by the server and the browser
export const bookingEndpoints = {
showBooking: endpoint(showBookingContract, "GET /bookings/:id"),
} as const;
// server: the route takes the endpoint, and throws when it names another contract
route(showBooking, bookingEndpoints.showBooking);
// browser
const client = toHttpClient([bookingEndpoints.showBooking], { prefix: "/api" });
const booking = yield* client.show_booking({ id }); // Effect<Booking, HttpFailure | HttpClientError, HttpClient>- Same wire rules. A GET sends the path values in the path and every other field as one query value, encoded with the StringTree codec the fetch handler decodes with. An array field goes out as one key per item,
status=a&status=b, and an empty array as no key, as The query says. A write sends the JSON body without the path keys, because the handler refuses a key given twice. A path value, or a GET field that encodes to neither one string nor a list of strings, such as a struct, is a defect: give that contract a write route. - Types from the contract. The client's keys are the contract names. Each call takes the input's
Type, returns the output'sType, and needsHttpClientfromeffect/http. A wrong input, a wrong output, or a failure code the endpoint does not declare is atscerror. In the browser, provideFetchHttpClient.layer. Outside one, prepend an origin withHttpClient.mapRequest(HttpClientRequest.prependUrl(origin)). - Failures send nothing by default. The server sends the view's status and body, never the error. A view may hide one failure behind another, such as
Forbiddenbehind a 404not_found, and nothing on the wire tells them apart. To give the client a code, two sides opt in. The view exposes it:problemView(404, "not_found", message, { exposeCode: true }), or a hand-written view that returnscode. The endpoint declares it:endpoint(contract, "GET /bookings/:id", { codes: ["not_found"] }). Only then does a 4xx answer carryx-failure-code: not_found. A code the endpoint does not declare, a 5xx, or a code on a route mounted from a string never goes out asx-failure-code, and a view's ownx-failure-codeheader is dropped. The double opt-in gates only that typed header, the one the client turns into a typed failure. The body is the view's: a code its author writes into a 4xx body goes out as written, so write only what the client may see. - The client checks codes. It fails with
HttpFailure { code, status, body }.codeis typed as the endpoint's declared codes plusUnknown. A 4xx whose header names a declared code gets that code. Anything else isUnknown: no header, a code the endpoint does not declare, any 5xx, or an output that does not decode.statusandbodystill say what the view sent, such as a 401session_invalid. A network error stays anHttpClientError. The client never decodes the real error, because its fields may hold what the view hides, such as a defect cause or a permission name. - Cookies and CSRF.
FetchHttpClientusesfetch, which sends same-origin cookies by default. SetFetchHttpClient.RequestInitfor anything else. The browser addsOriginto every POST, PUT, PATCH and DELETE, and to cross-origin requests, so the CSRF check passes from the app's own origin and refuses another site's write with a 403. Script cannot setOrigin, so the client never does. - OpenAPI.
toOpenApi(routes, { title, version, prefix, views, security })returns an OpenAPI 3.1 document. Inputs and outputs come from the contracts through Effect's JSON Schema, with shared, named schemas undercomponents.schemas. A named input is followed before it is split. A GET Struct input becomes path and query parameters, an array field astyle: form,explode: trueparameter, and a GET Union input becomes one explodedinputquery object, required when every member requires a field. A write gets a JSON body without the path fields. A failure goes under the status its view always answers: aproblemView(404, "not_found", message)view, or a default view, says it. A view that picks its status per error goes underdefault, as do refusals and the 500. The document names no internal tag. A response lists thex-failure-codeheader only for codes the endpoint declares: under the status of theproblemViewthat exposes it, or underdefault. TheoperationIdis the contract's name; a contract on two endpoints gets its method and path added, and a clash fails the build.security: { schemes, require? }adds the schemes; a"public"contract drops them. Effect'sOpenApi.fromApineeds anHttpApi, which wants the HTTP status on the error schema and four input schemas, so House Rules writes the document itself. The tests check its shape; no OpenAPI 3.1 validator runs on it yet.
Upgrading from @house-rules/capability
@house-rules/capability 0.16.0 is its last version. Its planned 0.17.0 was never published. Replace it with two packages, pinned exactly, and move @house-rules/call-audit to 0.6.0 in the same change. Effect stays at 4.0.3. There are no re-exports or aliases, so every import moves:
@house-rules/capability @house-rules/domain @house-rules/application
defineModule, Actor, ActorId -> yes
Permission, definePolicy -> yes
UnitOfWork, CurrentUnit, -> yes
NoOpenUnit, UnitOfWorkFailed,
memoryUnitOfWork
sqlUnitOfWork -> yes
contracts, gates, CallWatch, -> yes
composition, delivery, toTool
/contract -> Actor, ActorId, Permission, everything else
UnitOfWorkFailed- [ ] Replace the dependency:
"@house-rules/domain": "0.1.0"and"@house-rules/application": "0.1.0". - [ ] Move each import to the kit that owns the name, as the table shows. A package's facade imports only domain; a package's contract entry imports
@house-rules/application/contractand@house-rules/domain/contract. - [ ] Rename the request slot to
CurrentActor, from application, in callers, use-cases and tests.yield* CurrentActorreturns the Actor, as before. - [ ] Service keys moved from
@house-rules/capability/*to@house-rules/application/*and@house-rules/domain/*. Only code that matched a key by string notices. - [ ] Move
@house-rules/rulesto 0.17.0. Its checks now recognise the new names; see its README.
History: @house-rules/capability
These notes are for apps that upgrade older @house-rules/capability versions first. They keep the old package name.
Upgrading to 0.16.0
Pin capability to 0.16.0 and call-audit to 0.5.0, whose exact capability peer moves with it. Effect stays at 4.0.3. Existing call, HTTP and MCP callers keep their behavior. For a delivery that resolves its caller by hand, replace that runtime code with admit, return auth.view on failure, and pass { admitted: auth.value } to later calls instead of supplying a caller that returns the saved context.
Upgrading to 0.15.0
Pin effect, @effect/platform-node, @effect/platform-node-shared, @effect/sql-pg and @effect/vitest to 4.0.3. Apps that install @effect/platform-node declare the shared package directly too. The Effect peer is exact. Move migrations to 0.3.0, call-audit to 0.4.0 and rules to 0.16.2 in the same change. Remove the old RC release-age exceptions. Add an exact-version exception only for a release under a day old.
Stable Effect drops the unstable/ import prefix. Use effect/ai, effect/http, effect/http-api, effect/rpc and effect/sql. Encoding.encodeHex is now Hex.encode from effect/encoding. Schema.Int advertises safe-integer bounds in JSON Schema, so exact OpenAPI assertions include its minimum and maximum.
A contract with transactional: true no longer exposes NoOpenUnit in its implemented handler's error type. Module writes may still fail with it; implement turns that impossible failure into a defect inside the unit, and rolls back the work. Delete hand-written catchTag("NoOpenUnit", ...) catches inside transactional handlers. A catchTag("NoOpenUnit", ...) on the transactional capability's .handler(...) result now fails to compile, so delete that catch too. This failure is a defect, not a checked error: HTTP answers 500 internal_error, and call-audit records outcome defect. Remove NoOpenUnit from that contract's failure schema too if it was listed only for those writes. A non-transactional handler, or a direct UnitOfWork.atomic call, keeps the typed error.
A fresh install of Node platform rc.117 could pull stable platform-node-shared, whose process imports do not exist in RC Effect. The packed-consumer check now installs all tarballs plus stable Node platform without a lockfile, then type-checks and runs Node services with a transactional handler.
Upgrading to 0.14.0
0.14.0 breaks five things:
- [ ]
InputInvalidhas two new required fields,capabilityNameandissue. Code that builds one by hand adds both. A view that reads onlyfieldandmessagekeeps working, and the default body is unchanged. - [ ] A layer error goes through the views.
Compositionhas a fourth type parameter, the layer's error type, which defaults tonever. A layer that can fail with a tagged error now needs a view for that tag atcomposition(), ortscfails withmissingViews. Add one, such asDbUnavailable: problemView(503, "unavailable", () => "Try again shortly."). At run time a failed build answers that view where it answered 500,callresolves where it rejected, and the MCP handler answers where it rejected.call'sOutcomeerror type now includes the layer error. Code that caught a rejectedcallcan drop the catch. - [ ] A failed build is retried. The next use builds the layer again, where before the failure stayed until a restart.
app.runtimeis a getter: read it each time, do not keep it. - [ ] A GET reads arrays. An array field now takes a repeated key, and one key is a one-item list; before, both were 400s.
toHttpClientsends such a field as repeated keys, where it died. OpenAPI marks the parameterstyle: form,explode: true. - [ ]
dispose()is final. Afterapp.dispose(),callresolves to{ ok: false, error: CompositionClosed, view }where it rejected, and both handlers answer its 503 where they answered 500. Nothing rebuilds the layer after dispose.Outcome's error type now includesCompositionClosed, anddefaultViewshas its view, so an exhaustive switch over the error adds a case.
A fresh request id still comes from the app: on a built app, call and MCP make it inside the app runtime, so a Random the layer provides seeds it, as in 0.13. When the layer did not build, or after dispose, the id comes from Effect's default Random. toFetchHandler makes its id before it reads the layer, with the default Random, as before.
Two stricter checks: a write body that is not valid UTF-8 is a 400, where it decoded with replacement characters; and an MCP server that rejects, such as for a tool schema it cannot list, is the fixed 500 with the request id, where the handler rejected. @house-rules/call-audit 0.3.0 peers on this version.
Upgrading to 0.13.0
Nothing breaks for a route written as a string. route(capability, at) now also takes an endpoint, and it throws at mount on an unknown method. Route gained contract and codes, so code that builds a Route by hand, not through route(), must add both. A failure response sends no tag; a view and an endpoint opt in to an x-failure-code together. defaultViews entries with a fixed status carry it as .status, and a problemView with exposeCode carries .code, for OpenAPI. The stack's example moved showBookingContract from its use-case to @hosti/bookings/contract, so a browser can import it.
Upgrading to 0.11.0
0.11.0 is a breaking change for MCP tools. Every toTool tool, a Struct one too, is now Tool.Strict:
- An MCP call with a key the input does not take, at the root or inside a nested Struct, now fails with
InvalidParams. Before,McpServerdropped the key and ran the handler. A client that sends extra metadata, or resends a whole saved record with fields the input lacks, now gets an error. Send only the keys the input takes. - A tool's JSON schema now says
"additionalProperties": falsewhere it saidtrue. A test that compares a tool's advertised schema exactly must update that value. Trippy has three such assertions.
A Union input is new and changes nothing for a Struct input's types.
Limits
It has request identity through CurrentActor, defineContract, implement, NoInput, toTool, the Grant and Approval gates with their cartridges, the CallWatch hook with its call facts, the SQL adapter for domain's UnitOfWork slot, composition and delivery, and nothing else. A token scope does not narrow the Grant yet, and there is no CLI --yes or web confirm for approval. There is no registry of capabilities and no CLI command from a contract. The typed SPA client and OpenAPI come from House Rules' own projection, not Effect's HttpApi. Delivery has no streaming, file upload or rate limit, and plain HTTP has no confirm flow for a write that needs approval. The handler takes the decoded input. Decoding raw input with the contract's schema is the adapter's job; the delivery adapters do it for you.
Exports
Request identity: CurrentActor. defineModule, Actor, ActorId, Permission, definePolicy, UnitOfWork, CurrentUnit, NoOpenUnit, UnitOfWorkFailed and memoryUnitOfWork come from @house-rules/domain.
Delivery: composition, once, admit, call, toFetchHandler, route, toMcpHandler, tool, decodeInput, defaultViews, defaultMaxBodyBytes, problem, problemView, toOpenApi, the Refused, InputInvalid and CompositionClosed errors, and the types Composition, Caller, Incoming, View, ViewFn, ViewTable, ViewStatus, CallerContext, DefaultViewTag, DefectStatusReserved, ViewsCheck, MountCheck, ServicesCheck, Mountable, Outcome, ApprovalOption, Admitted, AdmitOptions, CallOptions, Route, RouteOptions, CsrfOptOut, Method, FetchHandler, FetchHandlerOptions, McpTool, McpToolOptions, McpHandlerOptions, InputSource, InputIssue, ProblemViewOptions, StaticStatus, OpenApiDocument, OpenApiOptions, OpenApiSecurity, and OpenApiSecurityScheme.
Client, also from @house-rules/application/contract: endpoint, toHttpClient, failureCodeHeader, the HttpFailure error, and the types Endpoint, EndpointOptions, EndpointShape, HttpClientOf, HttpClientOptions, ClientCall, ClientFailureCode and HttpFailureOf. The contract entry also exports defineContract, failureSchemaOf, NoInput, the Forbidden, ApprovalDenied, InputInvalid and Refused errors, the InputIssue type, the contract types and PermissionDeclaration.
The rest: defineContract, implement, toTool, failureSchemaOf, the NoInput schema, the Grant, Approval and CallWatch services, the CallChannel and CallRequestId references, withRequestId, requestIdOf, newRequestId, requestIdHeader, mcpChannel, isMcpChannel, unknownCallFacts, maxClientNameLength, maxTargetIdLength, sqlUnitOfWork, the Forbidden and ApprovalDenied errors, elicitationApproval, ApprovalForm, approvalMessage, and the types Contract, AnyContract, Annotations, InputSchema, StructInput, UnionInput, PlainSchema, DefineContractOptions, PermissionDeclaration, GrantService, ApprovalService, CallWatchService, Around, CallFacts, CallKind, AuditDeclaration, AuditOptions, StringKeyOf, GrantRequirement, ApprovalRequirement, UnitOfWorkRequirement, GateRequirements, FailureSchemaOf, FailureOf, Capability, HandlerOf, ContractTool, ToToolOptions, ToolParameters, and MergedParameters.
