
Give business actions their own rules
“Approve”, “assign” and “publish” carry more meaning than “update this record”. They describe an intention, who may carry it out and what must be true before it succeeds.
Make that intention an operation on the resource. Give it the inputs and access rules it needs. Wildo connects the declared action to resource contracts and API routing; your application supplies the rules and processing that make it useful.
Some actions can use an existing core operation with a narrower contract. Others need a handler to calculate a result, coordinate services or perform a specific business process.
Example — Assign a lead, not just a user ID
The person making the assignment must be an administrator. The selected lead must also be an active administrator in the appropriate organization. Those are two different rules: permission to assign someone does not make every person an eligible lead.
For engineers
Express the action in its own contract
Wonder Todos declares ASSIGN_LEAD alongside its standard resource operations. This excerpt comes from todos.resources-config.ts:
[Todos_Operations.ASSIGN_LEAD]: {
variants: [
{
variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_ORG_ROLES.ORG_ADMIN],
riskLevel: ResourceOperationRiskLevel.MEDIUM,
resourceOperationLike: CoreResourceOperation.UPDATE,
requestDto: z.object({
assignedToUserId: z.string().min(1),
}),
referenceConstraints: {
assignedToUserId: {
qualifyingStatuses: [OrganizationMemberStatus.ACTIVE],
requiredRoles: { scope: ResourcePrimaryScope.ORGANIZATIONS, roles: [CORE_ORG_ROLES.ORG_ADMIN] },
},
},
}
],
},
resourceOperationLike: UPDATE gives this action the shape of a record update. Its request accepts assignedToUserId, so the caller expresses an assignment rather than receiving a general edit contract.
roles governs who may call the operation. referenceConstraints governs the person selected by that call: active membership and the required organization role. The relationship supplies the organization connection, and role matching respects the role hierarchy. An owner can satisfy an administrator requirement.
This action can use the standard update machinery because its input names the field to change. Declaring a custom verb does not inherently require a custom handler.
Add code when the action needs its own processing
A different operation in Wonder Todos, ASK, retrieves passages from the organization’s knowledge documents. Its request and response are authored on the operation configuration. The backend resolves those contracts from the registry and connects an implementation to that exact operation variant.
Selected implementation from knowledge-documents.ask.operation.ts. Imports, explanatory comments and diagnostic logging are omitted; projectAnswer is the same file’s response-mapping helper:
const askKnowledgeBaseCustomImpl: ResourceCustomServiceImplementationFactory = (
resourcesRegistryService: ResourcesRegistryBackendService,
container: InversifyContainer,
) => {
const operationPath = {
resourceIdentifier: TasksManager_ResourceType.KNOWLEDGE_DOCUMENTS,
operationIdentifier: KnowledgeDocuments_Operations.ASK,
variantType: ResourceOperationVariantType.API_CALL,
isOperationDefault: true,
};
const requestDtoSchema = resourcesRegistryService.getRequestDtoFromPath(operationPath);
const responseDtoSchema = resourcesRegistryService.getResponseDtoFromPath(operationPath);
let retrievalService: RagRetrievalBackendService | undefined;
return createResourceCustomServiceImplementation<typeof requestDtoSchema, typeof responseDtoSchema>({
operationPath,
requestDtoSchema,
responseDtoSchema,
handlers: {
overrideAll: async (_id, input, executionContext, _operationPath, utils) => {
retrievalService ??= container.get<RagRetrievalBackendService>(SAAS_SERVICE_TYPES.RagRetrievalService);
const { question, limit, maxContextCharacters } = input as AskKnowledgeBase;
const result = await retrievalService.retrieve({
query: question,
executionContext,
resourceTypes: [TasksManager_ResourceType.KNOWLEDGE_DOCUMENTS],
...(limit !== undefined && { limit }),
...(maxContextCharacters !== undefined && { maxContextCharacters }),
});
return projectAnswer(question, result);
},
},
});
};
export default askKnowledgeBaseCustomImpl;
operationPath identifies the resource, action and API variant. isOperationDefault matches this handler to the default variant declared in the configuration. The two registry lookups reuse that operation’s schemas, so the handler is attached to its declared input and output rather than maintaining a second contract.
The focused retrieval call passes the caller’s executionContext unchanged and narrows the corpus to knowledge documents. The retrieval service applies its own access checks using that context. projectAnswer shapes the result into the application’s response: passages and their sources, ready for the caller to use.
The service is resolved lazily from the container when a request arrives. The default export is discovered by the module’s configured operation-file scan and registered as an implementation.
Choose how much of the execution you own
Use prefixCoreOperations to validate or prepare input before core persistence. Use postfixCoreOperations to transform the result after the core operation. A custom action that changes ordinary resource fields can keep that shared persistence path.
overrideAll, used in the retrieval example, owns the service pipeline and returns its result directly. It is appropriate here because asking for passages is a service call, not a resource write. The handler deliberately uses a service that enforces the caller’s access; this mode does not supply the usual persistence and notification stages for it.
replaceCoreOperations supplies the authoritative core result within the surrounding pipeline. For ordinary targeted UPDATE and DELETE operations, core persistence can run before the replacement callback. CREATE-borrowing and targetless mutation operations using REPLACE skip that core persistence. Choose the mode against the declared operation: replacement does not universally mean that no write has happened.
Put a business change inside the right boundary
When a prefix, core write and postfix must succeed together, declare transactionalCore: true and its transactionParticipants. The operation’s own resource is included automatically; name additional participating resources and forward utils.serviceOptions on nested calls so they join the same transaction.
For server-owned values injected by a prefix, declare authoritativeFields together with the hook that computes them. For external work that belongs after a successful mutation, use the durable post-commit mechanism. A postfix inside a transaction is still before commit; an email or external API call cannot be undone by a database rollback.
Example: compute a role change, then let the core save it
The engine’s ownership-grant operation demonstrates a mutation whose request and stored change are different. Its request asks for a justification; the server computes the role set. It retains existing roles and adds ownership only when the member and underlying user are usable.
This is a privileged administrative operation, not a general-purpose permission pattern. Its configured variant requires the application super-administrator role and explicitly admits cross-tenant administration. The excerpts below explain its mutation mechanism; they do not replace that authorization configuration.
The registration in organization-member-custom-implementation.backend.service.ts connects the action to its write authority and transaction participants. Selected properties from its createResourceCustomServiceImplementation(...) call:
operationPath: {
resourceIdentifier: CoreResourceType.ORGANIZATION_MEMBERS,
operationIdentifier: OrganizationMembers_Operations.GRANT_OWNERSHIP,
variantType: ResourceOperationVariantType.API_CALL,
isOperationDefault: true,
},
debugLabel: 'organization-members.grant-ownership.api',
authoritativeFields: ['roles'],
transactionalCore: true,
transactionParticipants: { membershipOwner: CoreResourceType.USERS },
authoritativeFields makes the prefix’s computed roles authoritative even if an internal caller supplies a role set. It is not a permission grant to the caller. The membership resource participates automatically; USERS is named because the prefix reads the user behind that membership. Participant declarations let the framework check persistence compatibility; they are not a substitute for forwarding transaction options.
Inside the prefix, the operation first checks the addressed membership. It then loads the corresponding user with the transaction-bearing options. This is the actual nested read; the surrounding membership and user-status refusal branches are omitted here:
const systemEc = await authEcFactory.createForSystemAuthOperation(authEcFactory.getUsersReadOperationPath());
const user = await utils.servicesRegistry.read<{ status?: string }>(
CoreResourceType.USERS,
systemEc,
{ _id: currentObject!.userId },
utils.serviceOptions,
);
The special system context belongs to this engine-owned administrative operation. Ordinary application handlers should retain their authorized caller context. The important transaction connection is the final argument: a nested call that drops utils.serviceOptions does not acquire the transaction merely because its resource was listed.
After both usability checks pass, the same prefix computes its patch:
const currentRoles = normalizeRoleArray(currentObject!.roles);
if (rolesConferOrganizationOwner(currentRoles, organizationOwnerConferralResolver())) {
return { roles: currentRoles };
}
return { roles: [...currentRoles, CORE_ORG_ROLES.ORG_OWNER] };
That return value is input to the remaining mutation pipeline. The prefix does not call update itself. The action borrows UPDATE, and the core persists the computed role set on the addressed membership. An already-owning member keeps its roles; a newly promoted member retains its other roles. The operation’s response is produced after the core write, not by treating the prefix patch as proof that storage changed.
| Part of the change | Responsibility |
|---|---|
| Request | Carries a justification, not an arbitrary replacement role set |
| Prefix | Refuses unusable members or users and computes the role patch |
| Authority declaration | Preserves the server’s computed roles through mutation processing |
| Transaction boundary | Encloses prefix, core persistence and any configured postfix with compatible participants |
| Core | Applies the prepared update to the addressed membership |
| External effects | Need their own post-commit delivery mechanism; a database transaction cannot roll them back |
Distinguish atomic work from work after commit
transactionalCore is an opt-in. It does not make every custom operation transactional, and it does not make incompatible adapters share one transaction. A caller-supplied transaction has a caller-owned commit boundary; returning from the nested operation does not mean that caller has committed.
Keep validation and participating database changes inside the atomic phases. Treat a postfix as pre-commit work when transactional core is enabled. Schedule durable external work through the post-commit mechanism rather than sending email or calling a provider from that postfix. Inspect the configured phases and the ownership of the commit before deciding where an effect belongs.
Reject business-rule failures through utils.errorBuilder, using an appropriate error type and localized message reference. Give the action its frontend presentation and labels, then exercise the allowed path, a refused call and the business-rule rejection. The operation is complete when its contract, implementation and interaction describe the same action.
The knowledge-base response mapper also preserves searchStatus and embeddingStatus. Its assistant tool distinguishes unavailable search from a healthy empty result, while keeping citations and permission refusals separate.