Context
Other modules need to change a value in an action, or to refuse it, without editing the owner’s code. Three mechanisms were weighed. Magento-style plugins can wrap any public method witharound, which hides control flow and makes every method an API. Medusa-style
workflow hooks are named and owner-declared, but they belong to workflows only. Laravel-style events cannot change a value. The owner
settled on 2026-10-21 that a hook point is declared by the module that runs it, has one of two kinds, and is printed by gen.
Decision
- Hook points are declared and owned.
defineHooks(key, { ... })in the owning module’shooks/folder declares named points. Each point has one of two kinds:- transform: receives a value and a context, and returns the value. Implementers run in
order, and each one receives the previous one’s output; - verify: receives a context, and returns nothing or throws a
StackraErrorwith a snake_case refusal code (0140). There is noaroundand no observe point. Observing is an event listener (0264).
- transform: receives a value and a context, and returns the value. Implementers run in
- Implicit points on every action. Every
@ActionhasBefore(ActionClass), which transforms the validated input, andAfter(ActionClass), which transforms the output before it is returned. This replaces the open “before and after hooks” of 0101 decision 4 with named, typed points. - Implementers live in the extender’s folder. A hook class is decorated
@Hook(Point, { order, extends })and sits in thehooks/folder of the module that extends the point. Equalordervalues on one point are a gen error. - The owner calls the point explicitly. The owner’s code calls
this.hooks.transform(OrderHooks.totals.adjust, base, draft, ctx), so the call site is greppable. - Typed extensions. A hook may declare
extends: { <namespace>: ZodSchema }. Gen mergesextensions.<namespace>into the owning action’s input schema, the manifest, the OpenAPI projection and every generated client. An output extension works the same way onAfter(...). A namespace used by two hooks is a gen error. - Rules that keep hooks safe. A hook runs inside the owner’s transaction and pipeline. It makes no network call (a lint), and it
writes only the module’s own data. Anything else uses
ctx.afterCommitor an event. Hooks are in-process only. Another service never hooks into a module; it listens to events, or the module exposes a port. - The chain is printed. Gen writes
.stackra/gen/hooks.ts, which lists every point with its implementers in order and their file paths. - A hook point is part of its module’s contract. Changing its types follows the deprecation rule of 0131.
Consequences
- A reviewer answers “who changes the order total?” by reading one generated file.
- A transform cannot hide control flow, because the order is printed and the call is explicit.
- Extension of an action needs no workflow, and the extended input reaches the console and the clients with its type.
- A module must declare its points before a customer can extend them, which is more work for the owner.
Enforcement
planned — gen fails on equalorder values on one point, on an extends namespace collision, and on a hook point with no declaration; the
house lint fails a network call inside a hook. Nothing checks these yet.