@appport/core
The capability runtime: registry, dispatch pipeline, validation, sessions, events, streams, operations and telemetry.
Core knows nothing about HTTP, WebSocket, IPC, or any storage engine.
import { createApplication, defineCapability, defineEvent } from "@appport/core";
import { s } from "@appport/schema";
const application = createApplication({
application: { id: "com.example.docs", name: "Docs", version: "1.0.0" },
capabilities: [documentsSave],
events: [documentsChanged],
authorizer: permissionAuthorizer()
});
const response = await application.handleRequest(envelope, { session, transport: "http" });
The pipeline
receive -> validate -> authorize -> execute -> serialize -> respond
Authorization always runs before privileged logic. Input is validated before authorization, so policies see well-formed input. Output is validated too, so a handler cannot silently break its published contract.
What the runtime provides
- Registry and manifest — the application's contract, rendered as JSON Schema.
- Application registration — define an application, verify it, and register for durable tracking with identity, manifest, fingerprint, and verification state.
- Events — at-most-once publication, with payload validation.
- Streams — async iterables or
context.createStream(), with cancellation. - Sessions — transport-neutral, pluggable storage.
- Idempotency — replayed responses for capabilities that declare support, with a durable in-progress claim written before the handler runs (see below).
- Concurrency —
parallel,serialized, orkeyed(field). - Timeouts — per capability and per request, clamped by the server.
- Operations —
operations.status,operations.cancel,operations.eventsfor work that outlives a request. - Telemetry — identifiers and outcomes, never credentials or payloads.
- Sanitization — production errors carry no stack traces, paths or secrets.
The reserved appport.manifest and appport.ping capabilities are registered
automatically, so discovery works identically on transports that have no URLs.
Idempotency (§13.1)
For a capability with idempotency: "supported", a request with an idempotencyKey is
scoped to the caller (session, or principal:<id>) and bound to its request digest.
- Claim. Before the handler runs, the runtime writes an
IdempotencyClaim(key,requestId,requestDigest, scope,claimedAt,expiresAt,status: "in-progress") withstore.claim(), an atomic create-if-absent. - Record. On success it writes the
IdempotencyRecordwithstore.set(), which replaces the claim. Identical replays get the stored response. - Known failure. When the handler throws (or never started), the claim is
released with
store.release()and a retry may run: failures are not recorded, as before. - Unknown outcome. On a timeout or cancellation while the handler runs, a failure
after it returned (output validation, state commit) or a record that cannot be
written, the claim is kept. A replay then fails with
CONFLICT,details.reason: "IDEMPOTENCY_OUTCOME_UNKNOWN"(IdempotencyOutcomeUnknownError, with the originalrequestId), and the handler is not run again. The same answer follows a process crash between the effect and the record, provided the store is durable. The claim lasts untilexpiresAt(24 h) or until an operator resolves it (store.set(record)orstore.release({ key, requestId });store.findByRequestId(requestId)finds it).
A different request under a claim or record is CONFLICT /
IDEMPOTENCY_KEY_REUSED. A key sent to a capability without idempotency support
is refused in development (INVALID_REQUEST / IDEMPOTENCY_KEY_UNSUPPORTED) and
logged once per capability version in production; set
unsupportedIdempotencyKey: "reject" | "warn" to choose.
Store contract. get and set are required. claim and release are optional
but come together (an application refuses a store with only one). A store without
them keeps the pre-1.4 guarantee: duplicates are deduplicated within one process
only, and a crash between the effect and the record lets a replay execute again.
findByRequestId is optional and used only by operators. MemoryIdempotencyStore
implements all of it; durable stores belong to the application and can be checked
with runIdempotencyStoreConformance from @appport/testing.
Application Registration (PR20)
Define, verify, and register applications for durable tracking:
import { defineApplication, ApplicationRegistry, MemoryApplicationStateStore } from "@appport/core";
// 1. Define an application (PR18)
const application = defineApplication({
identity: { id: "invoicing", name: "Invoicing", version: "1.0.0" },
provides: [invoiceCreate, invoiceSend],
requires: [{ name: "payments.charge", version: 1 }],
implementations: { "invoice.create@1": createImpl, "invoice.send@1": sendImpl }
});
// 2. Verify the application (PR19)
const verification = application.verify();
if (!verification.ready) throw new Error("Application verification failed");
// 3. Get the canonical manifest and fingerprint (PR19)
const manifest = application.manifest();
const fingerprint = application.fingerprint();
// 4. Register for durable tracking (PR20)
const store = new MemoryApplicationStateStore();
const registry = new ApplicationRegistry(store);
const registered = await registry.register({
identity: application.identity,
manifest,
fingerprint,
verification
});
// Tracked application state with identity, manifest, fingerprint, verification
console.log(registered.id, registered.status, registered.revision);
The registered application state is portable and contains no implementation details — only the contract, identity, verification state, and lifecycle.
Provider lifecycle and handover
Resolution follows provider lifecycle state. Among the providers compatible with a requirement, an active (promoted)
one excludes those that are only registered, so a prepared replacement never displaces or makes ambiguous the provider
in service. With no active provider, registered providers resolve as before; two active providers are ambiguous; retired
providers are never candidates, even when pinned by id.
To replace a provider, prepare the replacement under its own id and grants, then hand over in one atomic step:
import { ApplicationLifecycleOperations, ApplicationRegistryLifecycleGateway, MemoryApplicationLifecycleStore } from "@appport/core";
const lifecycle = new ApplicationLifecycleOperations(
new MemoryApplicationLifecycleStore(),
new ApplicationRegistryLifecycleGateway(registry, verifyReplacement) // validation and promotion checks
);
await lifecycle.define(replacement.manifest());
await lifecycle.validate("payments.v2"); // verifyReplacement runs here
await lifecycle.register("payments.v2"); // registered: does not compete with the active provider
await grants.grant({ consumerId: "shop", providerId: "payments.v2", capabilityName: "payments.charge", capabilityVersion: 1 });
const { handover } = await lifecycle.handover("payments.v1", "payments.v2", {
capability: { name: "payments.charge", version: 1 },
authorization: grants // every consumer granted payments.v1 must hold its own grant for payments.v2
});
// payments.v1 is retired and payments.v2 active, in one conditional store write.
ApplicationRegistry.handover() is the registry primitive underneath: it checks both registrations are the expected
ones, in the expected states, with the same capability contract, then retires one and activates the other through the
store's optional replaceIfCurrent (implemented by MemoryApplicationStateStore; a store without it is refused with
atomic-store-unsupported). Every rejection is a ProviderHandoverRejected with a reason and changes nothing.
A binding issued against the former provider is refused at invocation, before the implementation runs, including when
the handover lands after the binding's authority check. Work already handed to the former provider completes there; the
handover does not drain or cancel it. Guarantees and limits: docs/architecture/provider-handover.md.
Canonical capabilities (1.3.0)
A business capability can have an identity of its own: a canonical contract, published independently of whether anything implements
it. Applications implement it under their own names and claim to fulfil it; a requirement on the canonical capability resolves only
through a claim that evaluates as valid. Specification: spec/fulfillment-1.md in the repository.
import { CanonicalContractRegistry, ApplicationDependencyResolver, CapabilityBinder, defineCapability, resolvedDependency } from "@appport/core";
import { claimFor } from "@appport/protocol";
const contracts = new CanonicalContractRegistry();
await contracts.register(ordersContract); // a manifest advertising appport.canonical-contract/1, serving no transport
await contracts.activate("org.example.orders");
// A provider implements order.hold@1 under its own name, and claims it (the claim pins the canonical revision by digest).
const hold = defineCapability({ name: "oms.hold", version: 1, authorization: ["orders.hold"], fulfills: [claimFor(ordersContract, "order.hold", 1)], /* … */ });
const resolver = new ApplicationDependencyResolver(registry, { contracts, authorityDecisions });
const candidates = await resolver.discoverCanonical({ contract: "org.example.orders", capability: "order.hold", versionConstraint: "^1" });
const result = await resolver.resolveRequirement({ name: "order.hold", version: 1, versionConstraint: "^1", canonical: { contract: "org.example.orders" } });
if (result.kind === "resolved") {
result.capability.name; // "oms.hold": invocation targets the provider's own capability
resolvedDependency(result); // lock-ready: contract revision and digests, provider registration, claim outcome
await binder.bindResolution(consumer, result); // authorizes oms.hold; result.canonical travels beside it
}
- Contract versus provider. The contract lives in a
CanonicalContractRegistry, never in theApplicationRegistry: it can be discovered but never resolved, bound or invoked. The provider's capability is what runs. - Claims are evaluated, not trusted.
discoverCanonicalevaluates every claim against the contract's current revision withevaluateFulfillment. Onlyvalidclaims are eligible;unverified,staleandinvalidones are returned with their findings. A mapping from a canonical permission to a provider permission counts only when the provider enforces the canonical permission itself or an explicitAuthorityEquivalencedecision establishes it. - No name matching. A canonical requirement never resolves to a capability because it has the same name, and a canonical and a name-only
declaration never satisfy each other. Two eligible providers are reported as
ambiguous. - Three identities. Capability identity (contract, capability, major version, digests), provider identity (application, registration,
fingerprint, its own capability) and authorization are separate. A claim grants nothing: the binding policy sees the provider's
capability (
capabilityName,capabilityVersion), which a grant must name, with the canonical identity incontext.canonical. - Binding evidence. A canonical binding requires the evidence the resolver attached (
result.canonical): contract id, revision andcontractDigest, canonicalcapabilityDigest, provider registration and fingerprint, the claim and itsvalidstatus, and the required and enforced permissions. Missing, forged or mismatched evidence is refused (provider_not_authorized). - What invalidates a binding. Any new contract revision (a different
contractDigest, even if the claimed capability is unchanged), a retired contract, or a re-registered or retired provider. The binding is refused at the invocation fence, before the implementation runs; resolve again to evaluate the current revision.
Limits: authority equivalence decisions are supplied to the resolver, not stored, so a binding is not revalidated when they change; the synchronous withdrawal fence is per registry instance.