Amends 0294 (rules R2, R3,
R6, R7, R8, R11 and R12; decisions 13, 14, 16, 19, 20 and 23; items 1 and 4 of its 2026-10-23
amendment), 0265 (decision 1 names and decision 3
implementers) and 0146
(decisions 1 and 5). Each amended page carries a note that says what changed and which of its
decisions stand.
Context
The v3 template conversion showed that the programming model still offered two or three ways to do one thing. A partner class was passed as a value (Policy(OrderPermissions)), a host override carried the host
name in front of the base name (ConsoleAnalyticsConfig), a file held an action and its input and output
together, and a root module that only declared an empty class was required in every member. Components were
required to be functions. Those forms make gen, lint and a reader each guess which rule applies. This ADR
settles one form for each case and records the one change of direction that the review asked for: class
components are adopted now, and gen compiles them.
The naming, partner, file and module rules here are the v3 grammar of ADR 0294 with its gaps closed. The
override tree is ADR 0306, the panel and its tables are ADR 0307, integrations are ADR 0308 and icons are
ADR 0309. The migration rules are a note on ADR 0159.
Decision
Names and kinds (N1 to N3).- An override keeps the plain name of what it overrides. The base class is imported under the alias
Base<Name>, soAnalyticsConfigin a host extendsBaseAnalyticsConfigfrom the package. Gen fails a second override of the same base in one member (override_duplicate) and two classes that derive the same key (identity_collision). There is no last-one-wins rule. - A kind with no options is written bare (
extends Schema,extends Signal). A kind with options is called (extends Refusal({ kind }),extends Action({ key })). A kind whose options are all optional allows both forms, and its kind row says so. - A kind base is named
PascalCase(kind). Kind factories take nodefineprefix: the call already returns a class, soDriver(...)is the form anddefineDriver(...)is not. The worddefineis reserved for the four functions ofstackra.config.ts:defineApp,defineService,definePackageanddefineWorker. - Kind base names are reserved (ADR 0294, 2026-10-23 item 11). A model may not take the name of a kind base.
- The subject is the type argument:
Policy<Order>,Resource<Order>({ ... }),Factory<Order>,CrudService<Order>,StateMachine<Order>({ field }),Facts<Order>,Widget<OrderKpisQuery>({ ... }),Connection<ResendIntegration>,Extension<OrderExtensionPoint["adjustTotals"]>({ order }),Change<Subject>({ from, sunset })andProjection<Source>({ ... }). Where a resource’s list row differs from its model, the form isResource<Order, OrderView>({ ... })(rule 1 of 0307). This replaces the value form of each of those kinds, and it reverses theConnection(SomeIntegration, ...)form of ADR 0294’s 2026-10-23 item 1. - A sibling of the same stem is found by name.
OrderPolicyreadsOrderPermissions, andResendConnectionreadsResendIntegration. The type argument must name that class, or gen fails withpartner_mismatch. The same code covers a binding whose type argument is of the wrong kind for its base, such as aWebhookEventargument that is not an Integration (ADR 0308 rule 15). - When the stem differs, the sibling is an option:
Policy<Order>({ permissions: SalesPermissions }). - A value argument stays only where the new class inherits members from it:
Driver(MailTransport), so that TypeScript enforces the port’s abstract members;Widget.place(BaseWidget, { ... }); and every override, which extends its base. - Class references inside options stay, because they list collaborators and not the subject (
operations,preconditions,refreshOn,startOn,actions). Relation decorators keep their thunks (ADR 0294 R11).Model.with(...traits)is unchanged: traits are mixins, not partners.
- Every file exports exactly one declaration. An enum’s
constand its same-namedtypecount as one. An interface counts as one, and so does a type alias on its own. - Input and Output schemas leave the action, query and prompt files for
schemas/. The files areschemas/<name>-input.schema.tsandschemas/<name>-output.schema.ts, also when only one file uses them. A union input is two schema files and a base. The companion rule of ADR 0294 (R2’s list of companions and R8’s companion types) is retired for these kinds. - A component’s Props are
components/<name>/<name>.props.ts(blocks/<name>/<name>.props.tsfor a block), exporting<Name>Props. The component’sindex.tsre-exports the component and its Props. A notification’sDatafollows the same rule. - Gen fails with
two_declarations_in_file, and lint carries aone-declaration-per-filerule. - Shapes stay type-first classes with field decorators, compiled to Zod (ADR 0294 R4). Shape inheritance (ADR 0294 2026-10-23 item 13) is unchanged.
- A module is a folder.
<key>.module.tsis written only when it states something gen cannot derive: anexportslist, rootimportsthat narrow or add to the derived set, or awrap. An empty module file fails lint (empty_module). This reverses the always-present root module of ADR 0294 R12. - With no root module, a member is one module keyed by its folder name. It imports every workspace or
@stackra/*dependency that ships a module. A package with no root module gets a generated<Key>Modulein itsnodeentry, so a host importsLoyaltyKitModuleas it would any other. - A consumed module is imported as a named class from its service’s client subpath:
import { OrdersModule } from "#stackra/clients/commerce". The generated class is<Module>Module. The per-service subpath keeps two services’ modules apart. TheCommerce.modules.Ordersnamespace walk is retired. - There is no authored
index.tsbeside a root module. Gen writes the entries (ADR 0295).
@stackra/eventsexports the four concrete bases:DomainEvent(in process, inside one transaction and one module),IntegrationEvent(outbox, cross-service, MCP-visible, optional broadcast, versioned as.v<n>),WebhookEvent<I>(inbound, verified and routed by its Integration, ADR 0308) andAnalyticsEvent(client product analytics through the consent queue, ADR 0298).Eventis the abstract shared base. Writingextends Eventfails gen withevent_base_abstract.AnalyticsEventmoves from@stackra/analyticsto@stackra/events.@stackra/analyticskeeps delivery, consent gating and the pre-consent queue.@stackra/eventsimports nothing from analytics or webhooks; they import it.- A domain event reaches another service only through a listener that records an integration event through the outbox. It is never sent directly.
- A point is
extensions/<name>.extension-point.ts, and an extension isextensions/<name>.extension.ts. A sibling module may import a point (ADR 0294 2026-10-23 item 8). - A point has two slot kinds.
ExtensionPoint.before<Args>()runs before the owner’s step, in ascendingorder, and may refuse by throwing a Refusal or return replacement arguments of the same type.ExtensionPoint.after<Value, Args>()runs after the owner computed a value, in ascendingorder, and each one sees the previous result. - Wrapping an action call is an interceptor.
extends Interceptorwithintercept(ctx, next)runs around one action call, between idempotency andhandle. A point has noaroundslot. ADR 0265 decision 1 stands on method-level around, and an interceptor is the printed, whole-call form of it. - Each extension implements one slot, and it is one class per slot:
class RedeemPointsExtension extends Extension<OrderExtensionPoint["adjustTotals"]>({ order: 200 }), with onehandlemethod. A method named after the slot is not used, because TypeScript refuses every correct method of that shape (TS2425): members that a generic base takes from a mapped type are properties. The owner calls the point explicitly, so the call is greppable, and gen prints every chain in the manifest. - Every action keeps the implicit
beforeandafterof ADR 0265 decision 2: the input is transformed after validation, and the output before it is returned. - The words keep one meaning (ADR 0294 decision 23). The pair is extension point and extension, and the
dictionary entry says that the point is the seam and the extension is one implementer. Magento’s
pluginis not used, because the word already means a tool plugin.
endpointis on when the actors include a user or an agent, andendpoint: falseremoves it. Its route is the key-derived route of ADR 0294 decision 21.toolis on for queries (ADR 0155) and off for actions. Its MCP name is derived, for exampleorders_order_create, and its risk and approval come from the permission level.jobis off by default. Its name is the key, and its queue is typed (Queues.*).intentis off by default. Its phrases are per locale in<module>::actions.<name>.intents, so the phrases live in lang files and not in code.- The
uxoption is retired (ADR 0137 and ADR 0098 name it). Placement and confirmation are derived by the resource that places the action (ADR 0307). - Gen checks each combination:
endpoint_method_mismatch(a PUT is required for an idempotent transition),tool_without_agent_actor,job_without_system_actor,cron_input_invalid(a cron’s input is checked against the action’s input schema) andintent_phrases_missing.
- A cached read is the query’s
cache:option. It declares a time to live and the events that invalidate it, for examplecache: { ttl: "5m", invalidateOn: [OrderPaid, OrderRefunded] }. A cache key is per principal by default, andcache.per: CacheScope.Tenantis an explicit opt-in. The public key of decision 3 of ADR 0297 (query, input, site, locale, market and version) iscache.per: CacheScope.Site. Its key is not per principal, so a Site-scoped query must not read the caller: gen fails withsite_cache_reads_callerwhen a Site-scoped query reads the actor, a principal or a tenant from its context or its input. Tenant-wide keys need theexpectTenantCacheSafetest helper. - A cached value inside a service, driver or listener is the injected
Cacheport, with an explicit key built byCacheKey.of(Model, ...)and an explicit lifetime. The port scopes keys to the tenant and single-flights a key. - There is no
@Cachedmethod facet and noctx.cache. The class is the token, so no module import is needed to reach the port.
- A configuration is
class X extends Config({ schema, env }). The class is the token and the key is derived from it.schemais the shape class, inschemas/, with its fields, types and defaults.envmaps a field to its environment variable, for exampleenv: { driver: "STACKRA_ANALYTICS_DRIVER" }. This retires the@Envdecorator of ADR 0294 R6 and decision 19. A secret stays a field decorator,@Secret("NAME"), because a connection has no options map and the secret name is the address of the value. The field sits on the schema class, because the schema declares every field of a configuration and the Config class holds only overrides, so@Secretis written on a field of the schema class. Duplicate keys and a double override fail gen. The olderdefineConfigform of ADR 0146 is retired with thedefinerule above. A schema bound to a Config is never emitted to a wire surface: not tostackra.manifest.json, not toopenapi.json, not to the generated clients, and not as a request or response type. Gen fails withsecret_on_wirewhen an Input, an Output, an event or a view references a schema that has an@Secretfield.
@stackra/navigationowns the root error boundary. Gen wires it into the app, its UI comes from@stackra/ui, and it reports through the reporter port of the error package. Apps ship no root boundary. A route adds<name>.error.tsxonly to customise its own failure view. The rootapp.error.tsxandcomponents/error-boundaryare removed from the app groups (ADR 0294 2026-10-23 item 4 is superseded in part).
stackra.manifest.jsonstays a committed JSON file at the member root..stackra/is fully ignored.stackra.lock.yamlstays the version lock. This ADR adds no manifest entries of its own. The panel and integration rules addui.resourcesand theListRowsmap (ADR 0307 rules 1 and 5), and the migration and integration entries are in ADR 0159 and ADR 0308.
- A component is written as
class X extends Component<P>, withComponentimported from#stackra/ui. Its props type is<Name>Propsin its own<name>.props.tsfile (rule 12). - Gen compiles the class into a function component. The mapping is:
- a field becomes state, and its initialiser runs once;
- a field that starts with
useand holds a hook call becomes a hook; - a method becomes a function on the component, which keeps the method’s own name;
mount()andunmount()become one effect with an empty dependency list, and they read the latest state through an effect-event;render()becomes the body of the function component.
- A function component stays valid. Nothing that is written as a function is changed by this rule.
- The productised compiler is a gen work item. It starts now, alongside the foundation packages and gen. Its
exit gates are: lint findings mapped back to the class line, the TypeScript gaps closed (
Parameters<typeof X>and refs), and a run on Metro and a device. The spike that proved the mapping (a single Babel plugin with eight checks, React Compiler with no bailout, Fast Refresh keeping state, DevTools names mapped to the class line) ran React Native only on react-native-web. The device run is the gap. - The cost is accepted and recorded. Hooks lint runs on the compiled output (
stackra lint). TypeScript sees a class while the runtime is a function, so three views must stay consistent. Babel stays in the toolchain. A snapshot read surprises class authors: afterthis.count++,this.countstill holds the old value until the next render. Every change to React hooks is ongoing cost.
- Gen reads a partner binding from source, from the class’s heritage clause, through one adapter over the TypeScript compiler API. It never reads a binding from a
.d.tsfile, because a declaration erases the binding of a called form (ADR 0294 decision 25). - Every kind base class is exported. A called form whose base is not exported fails declaration emit with TS4094 under TypeScript 7.
- Leaving out the type argument is a compile error. A bare form of a kind that needs a partner, and a called form without one, both fail with a readable hint.
- Rules 42 to 44 hold on both TypeScript 6.0.3 and 7.0.2.
- A member typed from the partner is an abstract method on the generic base class, never a member of a mapped or intersected type, because TypeScript then treats it as a property and refuses a method that overrides it (TS2425).
Template and runtime conventions
These conventions are used by the template and the apps. They are decided here, in an ADR body, and not only in the notes of the older pages.- Lang files. A catalogue is
assets/lang/<locale>/<namespace>.json, one file for each namespace (for exampleconsole,shortcuts,manifest,pagesanddocuments). A regional overlay, such asar-SAoren-US, holds only the keys that differ, and it falls back to its base language (aroren). Folder names use BCP 47 case, and URL prefixes stay lowercase (ADR 0297). This amends ADR 0080 and ADR 0234. - Offline page. The offline page of an app is a React route,
routes/offline/, built from HeroUI through@stackra/uiand the app’s hooks; Pro components where the member holds a HeroUI Pro licence (ADR 0197 as amended). Gen prerenders it once for each locale. The generatedoffline.htmlandoffline.cssare not used. This amends ADR 0249 and ADR 0297. - Electron entitlements. Gen writes the macOS entitlements from the device capabilities that the app declares (camera, microphone, USB, Bluetooth and serial). Each capability has a reason, taken from the lang files, so no entitlements file is kept by hand. This amends ADR 0249.
- Migration ids. A migration’s id is
<module>.<NNNN>_<snake(stem)>, whereNNNNis its number within the module andstemis the file’s stem, for exampleloyalty_kit.0001_create_loyalty_tables. Gen locks the id instackra.manifest.json. This amends ADR 0159 and ADR 0294 decision 20.
Consequences
- One form exists for each binding, naming and file rule, so a reader and gen agree without a table.
- The template and every package convert to the type-argument and one-declaration forms in one pass (STA-344), not piecemeal.
- A class component is authored now, but it does not run until the gen compiler ships. The template therefore carries components that are not yet executable. The owner accepted that order. Until the compiler ships, a reviewer must not treat a class component as tested.
- Root modules become optional, so a member has fewer files, and a reader must read the folder to see its module.
- The
uxoption, the value form of partners and the@Envdecorator are removed from new code. Existing code keeps them until STA-344 converts it.
Enforcement
- planned — STA-338:
base-matches-suffix, and the validator’skind-pathcheck of the group tables (rules 1 to 3 and 10 to 14). - planned — STA-339:
member-root(rule 15 to 18), with theentriescheck. - planned — STA-340:
site_cache_reads_caller(rule 31), so that a public cache key never depends on the caller. - planned — STA-340:
secret_on_wire(rule 34), so that no schema with a secret reaches a wire surface. - planned — STA-340: the webhook checks of ADR 0308 rules 15 to 17, which are
partner_mismatch(aWebhookEventargument that is not an Integration) andintegration_placeholder_unknown(a webhook secret placeholder that names no field). - planned — STA-340:
two_declarations_in_file,partner_mismatch,override_duplicate,identity_collision,empty_module,event_base_abstract, the action surface checks,secret_name_missing(ADR 0294 2026-10-23 item 2), and theone-declaration-per-filelint rule. - planned — STA-348: the kind base signatures, with the partner parameter defaulting to
neverbehind a readable hint, exported bases and abstract partner members (rules 42 to 46). - planned — the gen class-component compiler and its lint mapping. The work item is not yet open in Linear, so no check exists today.