Triggers — three valid entry paths
Every domain mutation enters via one of three call sites into invokeAction. There are no others.
Triggers
Every domain mutation in the platform flows through invokeAction (packages/api/modules/platform/lib/invoke-action.ts). There are exactly three valid call paths to it. Anything else is a bug.
The three paths
1. Authenticated session
A logged-in human in the SaaS UI calls platform.actions.invoke (packages/api/modules/platform/procedures/invoke-action.ts). The procedure:
- Hard-codes
actorType: "natural_person". Clients cannot override. - Verifies the tenant
Memberrow. - Checks
requiredPermissions/requiredRoleson the action. - Checks module entitlement.
This is the only path with a permission check. The other two are pre-authorized.
2. Verified external token
External callers (e.g. a borrower clicking an offer-acceptance link) call a public oRPC procedure that first verifies a signed token (borrowerPortal.offer.accept, borrowerPortal.offer.decline). Only after the token is verified does the procedure call invokeAction with:
invokeAction({
actionId: "lending.accept_offer",
actorType: "external_system",
actorId: tokenPayload.partyId,
parameters: { offerId, acceptanceSource: "borrower_portal_token", ... },
});The permission check is skipped because the verified token is the proof of authorization. The signed-token verification must happen before invokeAction — the procedure cannot legitimately pass external_system without it.
3. System / scheduled / agent
Internal callers (Temporal-scheduled workflows, retry workers, the offer-expiration sweep, the MCP agent middleware) pass actorType: "system" or "agent":
invokeAction({
actionId: "lending.expire_stale_offer",
actorType: "system",
actorId: "system:offer-expiration-sweep",
parameters: { offerId },
});The permission check is skipped because the worker is inside the trust boundary. The actorId is stamped with a stable identifier for traceability — auditors see "the offer-expiration sweep did this" rather than an opaque service account.
For agents, the upstream agentProcedure middleware validates the credential and the action-scope list before calling invokeAction. See agents within policy bounds.
Forbidden patterns
The boundary tests in packages/api/modules/{your-application,borrower-portal}/boundary.test.ts enforce these prohibitions:
- No direct handler imports.
import { acceptOfferHandler } from "@repo/lending/actions/handlers/accept-offer"from a procedure is rejected. The runtime is the only legal way to invoke a handler. - No domain mutations outside
invokeAction. Nodb.X.create | update | delete | upsertin API procedures or UI code. Read queries (findFirst,findMany) are fine. - No
external_systemwithout a verified token. Any caller passingexternal_systemmust first verify a signed proof. Public callers without a verified token must use the authenticated path.
The seed scripts in tooling/scripts/ are the only documented exception — they bypass the runtime by design for fixture creation.
Dispatching is asynchronous
invokeAction returns immediately with { status: "pending", actionInvocationId, workflowId }. The work runs in the worker (Temporal). For example, this means returning a "Processing…" interstitial and polling borrowerPortal.offer.get until the offer reaches its terminal state.
See also
- Stages — what happens after
invokeActionreturns. - Audit metadata pattern — passing acceptance source / decline reason as action parameters, not post-update writes.
- Agents within policy — the agent path in detail.