Core Concepts
Agentix turns application boundaries into ordinary immutable TypeScript values. Those values drive runtime validation and can be projected into a machine index without decorators, reflection metadata, or a global registry.
Feature capsules
A feature capsule is a directory convention plus a FeatureDescriptor. It owns
one business capability and normally contains:
src/features/orders/
contract.ts public schemas, ports, and events
model.ts private domain types and pure rules, when needed
operations.ts commands and queries
invariants.ts executable policies
feature.ts feature descriptor and public dependencies
orders.test.ts associated tests
Other features may import only the public contract.ts surface. The consuming
feature also declares the imported contract in dependencies:
export const orders = defineFeature({
id: "orders",
contract: ordersContract,
dependencies: [customersContract, productsContract],
operations: [createOrder, getOrder],
invariants: [paidOrderHasPayment],
events: [OrderCreated],
ports: [OrderStore],
});
The compiler reports private cross-feature imports and missing dependency declarations. This is an architecture rule, not a security sandbox: hostile JavaScript or dependencies can bypass it.
Schemas
Every boundary uses a Schema<T> with two operations:
parse(value)returnsTor throwsSchemaValidationError.safeParse(value)returns a success/failure result with structured issues for normal validation.
A custom schema, hostile proxy, or throwing input getter can still throw while a
schema executes. Dispatch reports an operation-input schema exception as the
INPUT_VALIDATION_FAILED fault. Effect-input and event-payload schema or event
snapshot exceptions are latched as INVALID_EFFECT_INPUT and
INVALID_EVENT_PAYLOAD, so operation code cannot catch them and return success.
The built-in constructors are string, number, boolean, literal, array,
object, optional, union, refine, and id; the same functions are
available through the schema object.
const ProductId = schema.id("product");
const Product = schema.object({
id: ProductId,
name: schema.string(),
stock: schema.refine(schema.number(), Number.isSafeInteger, {
id: "safe-stock",
message: "Stock must be a safe integer",
}),
description: schema.optional(schema.string()),
});
type Product = Infer<typeof Product>;
Important behavior:
- object schemas reject unknown keys;
- number schemas reject
NaNand infinities; - optional means the property may be absent or
undefined; - an ID accepts any non-empty string and brands it at the type level—it does not trim or enforce a format; and
- schema parsing does not promise deep immutability of returned application data.
Commands and queries
An operation descriptor declares the full boundary around one action:
| Field | Meaning |
|---|---|
id |
Stable application-wide identifier |
input / output |
Runtime schemas and inferred TypeScript types |
errors |
Map of expected domain-error codes to detail schemas |
permissions |
Every permission the principal must have |
effects |
Exact port operations available to execute |
emits |
Exact event emitters available to a command |
invariants |
Policies the operation declares it preserves |
execute |
Ordinary synchronous or asynchronous TypeScript |
Commands may declare reads, writes, time, randomness, external calls, and
events. Queries cannot declare a write effect and cannot emit events; both the
types and application construction enforce this rule. EffectKind is not an
orthogonal mutability model: an external adapter can still have side effects,
so mutating external work belongs in commands.
Descriptor IDs and permission strings must be non-empty and contain no whitespace. IDs must be unique across the assembled application.
Outcomes, rejections, and faults
Domain code returns an Outcome:
return ok(order);
return err(domainError("ORDER_NOT_FOUND", { id: input.id }));
Expected business failure is data, not an exception. The dispatcher validates both the error code and its details schema.
The outer DispatchResult separates three classes of result:
| Dispatch kind | Meaning |
|---|---|
completed |
Execution returned a valid success or declared domain error |
rejected |
Unknown operation, missing permission, or invalid input |
fault |
Invalid boundary data or an unexpected exception |
A completed dispatch can therefore contain outcome.ok === false. Do not map
all non-successful business outcomes to framework faults.
Ports, effects, and adapters
A port describes infrastructure without implementing it:
export const Payments = definePort({
id: "payments",
operations: {
charge: portOperation({
id: "payments.charge",
kind: "external",
input: ChargeInput,
output: ChargeResult,
errors: { PAYMENT_DECLINED: schema.object({}) },
}),
},
});
The five effect kinds are read, write, time, random, and external.
They describe capability intent; they do not add transactions, retries,
scheduling, or a proof of adapter purity.
An adapter binds every operation in one port:
const payments = bindPort(Payments, {
charge(input) {
return gateway.approve(input)
? ok({ status: "approved" as const })
: err(domainError("PAYMENT_DECLINED", {}));
},
});
The execution context exposes effects by the aliases selected in the operation,
not by their port keys. Effect input, the Outcome envelope, and effect
success/error payloads are schema-validated in every runtime mode. Operation
output and domain-error details are likewise validated in every mode.
Dispatch closes the capability window as soon as execute settles, then drains
every effect already started during that window before completing. Draining does
not extend the window: a captured effect called from a later timer is rejected.
Boundary faults are fatal to the dispatch even when operation code catches the
thrown wrapper error; they cannot be converted into a successful outcome.
Permissions and principals
A Principal contains a stable id and a list or set of granted permissions.
The dispatcher requires every permission declared by the operation:
const actor = principal("user-42", ["orders:create", "orders:read"]);
Permission checks run before input parsing. This avoids exposing input-shape details or doing request-dependent work for an unauthorized actor. Identity and role resolution belong in an inbound adapter or application-owned port; Agentix has no global identity store.
Events
An event is a versioned, schema-backed descriptor:
const OrderCreated = defineEvent({
id: "orders.created",
version: 1,
payload: OrderCreatedPayload,
});
A command may emit only the aliases in its emits map. The runtime validates
the payload and returns ordered EmittedEvent values on every completed
dispatch, independently of optional tracing. At emission, payload graphs made
of arrays and plain objects are detached and deeply frozen, preserving cyclic or
shared references. Non-plain values returned by a custom Schema are opaque. A
fault exposes no completed-event list; an enabled trace may still show an
attempted emission before the fault.
Core does not provide an event bus, persistence, delivery, or an outbox. An
application that needs those guarantees models them through an explicit port
and transaction boundary.
Invariants
An invariant is a named pure predicate over schema-validated evidence:
const paidOrderHasPayment = defineInvariant({
id: "orders.paid-order-has-payment",
dependsOn: [ordersContract, paymentsContract],
evidence: PaidOrderEvidence,
check: ({ order, payment }) =>
order.status !== "paid" || payment?.orderId === order.id,
});
Listing an invariant on an operation creates dependency and verification
metadata. It does not install a hidden production hook. The operation or its
tests must call the predicate explicitly. @agentix/testing supplies assertion
and property-test helpers.
Application assembly
createApplication receives all features and adapters explicitly. Construction
validates:
- duplicate stable IDs;
- missing, duplicate, or self feature dependencies;
- query writes or event emission;
- duplicate adapters;
- adapter/port identity mismatches; and
- missing or incomplete adapters.
There is no container scan, startup hook, shutdown hook, automatic adapter disposal, transaction manager, or global application instance.
Runtime modes are production, development, and test. The default mode is
production. Tracing defaults on in development/test and off in production;
trace on the application or one dispatch can override that default.
Dispatch lifecycle
Every dispatch follows the same visible order:
- Resolve the registered operation by stable ID and, for descriptor dispatch, require the exact registered descriptor identity.
- Check all required permissions.
- Parse input; distinguish ordinary
INVALID_INPUTrejection from anINPUT_VALIDATION_FAILEDschema-execution fault. - Construct the declared effect and event contexts.
- Await
executeand every effect it started. - Fail if any effect/event boundary fault occurred, even if it was caught.
- Validate the returned outcome and its output/error data.
- Return
completedwith emitted events, orrejected/fault; include a trace only when tracing is enabled.
Core does not add timeouts, cancellation, concurrency serialization, rollback, or resource lifecycle. Those policies must be explicit in the application or host.
Generated index
The compiler projects statically recognizable descriptors into
.agentix/index.json. It includes source locations, dependencies, operations,
permissions, effects, events, invariants, tests, graph edges, diagnostics, and a
source-manifest digest.
The on-disk index is disposable and may be stale. The CLI reanalyzes TypeScript before every answer and writes a fresh projection instead of trusting that file. Operation inspection projects a bounded, source-digest-bound context artifact; agents do not need the full index. Runtime dispatch never reads the index. See CLI and generated index for the compiler’s static-analysis conventions and conservative widening rules.