Skip to main content
Amends these pages, each of which carries a note that says what changed and which of its decisions stand. Authoring and injection: 0094 (decision 1 superseded, decision 3 amended), 0005, 0143, 0144, 0145, 0146, 0247, 0263 and 0264; naming and layout: 0006, 0128, 0130, 0231 and 0233; members, gen and contracts: 0010, 0090, 0091, 0117, 0126, 0127 (package names), 0158, 0177, 0259, 0260, 0261, 0265 and 0268; actions, events and workflows: 0078, 0098, 0099, 0101, 0118, 0138, 0140, 0141, 0151, 0152, 0155, 0217 and 0225; data and access: 0095, 0096, 0104, 0105, 0108, 0132, 0133, 0159, 0161, 0190, 0208, 0213 and 0273; interface and ai: 0079, 0110, 0164, 0165, 0223, 0232, 0234, 0245, 0249, 0251 and 0270. 0053 is accepted in the base-class form of this record.

Context

Stackra’s authoring rules grew one record at a time. A class stated its kind twice, once in a decorator and once in its base class. The same word meant several things (guard, hook, provider, command). The folder a file lived in was a judgement call, and identities such as module keys, permission names and event names were typed by hand, so they could drift from the folder or file that held them. Zod schemas repeated types that TypeScript already stated. This record sets one grammar for every member, one home for every suffix, identities derived from names and locked in stackra.manifest.json, and one meaning for each word. Build and entry shape, runway and the web runtime are decided in the separate records of this wave: 0295 (build and entry), 0296 (runway) and 0297 (web runtime).

Decision

Grammar

  1. The path says the kind (R1). A file is <scope>/<group>/<name>.<kind>.ts(x). A folder kind (routes, blocks, components) is <scope>/<group>/<name>/<name>.<kind>.ts(x). The scope is a module folder (src/modules/<key>/ in a service, src/node/ in a package) or the member’s src/. Each suffix has exactly one home group per tier, and a group may hold several related suffixes. The one exception is .change.ts, which sits beside its subject: in events/ for an event version, in schemas/ for a dated response change. Tests sit in __tests__/<name>.<kind>.test.ts beside their subject (0022).
  2. One primary export per file, named from the file (R2). The class is PascalCase(stem) + PascalCase(kind), so cancel-order.action.ts exports CancelOrderAction. Noun kinds (model, schema, enum, event, signal, analytics event, type) take no suffix in the class name, and their files keep the file suffix. Error classes end in Error. These companions may share the file: <Name>Input and <Name>Output in an action, query or prompt; <Name>Props in a component; <Name>Data in a notification.
  3. The base class is the kind; its options are the argument (R3). Examples: extends Action({ key }), extends Workflow({ timeout }), or the bare extends Signal when there are no options. A class extends exactly one kind base exported by a @stackra/* package, and that base’s identifier is PascalCase(kind), with these aliases: .event.ts takes DomainEvent, IntegrationEvent or WebhookEvent; .error.ts takes Refusal; .service.ts takes Service or CrudService; .model.ts takes Model.with(...). An override or a placement extends the class it overrides, as in Widget.place(...). Options never hold a fact that gen derives. A member that replaces a base member with a default, such as a resource’s table() or a route’s meta, is marked override; only an abstract member (handle, definition, check) goes without it, because the presets keep noImplicitOverride on.
  4. The TypeScript type gives the shape (R4). A field’s type is its rule, its column type and its wire type. The initializer is the default; ?: means optional and | null means nullable. A number field carries @Int or @Num. A literal union, any or an unknown alias fails gen with a message that asks for an enum or a schema class. Gen compiles each shape class to one Zod schema in #stackra/schemas and each model to MikroORM EntitySchema metadata, so authors do not write Zod. A listener and a notification react to the type of their event parameter. A workflow starts on an event only through startOn:. A driver choice is a config field typed DriverName<Port>. Decisions 25 to 28 say how kind bases, the shape compiler, models and tables work in detail.
  5. Names give identity (R5). An author writes each wire name once: an action or query key, an event name, an analytics event name, a route path and a custom endpoint path. Every other identity is derived (decision 20). stackra.manifest.json locks every identity, derived or written, and a change fails stackra gen --check unless the class or field carries @Renamed(old).
  6. Decorators are of three categories, and nothing else (R6).
    • Field: constraints (@Str, @Int, @Num, @List), transforms (@Trim, @Lowercase), serializers (@Hidden, @Visible), relations (@ManyToOne, @OneToMany, @ManyToMany, @OneToOne), sources (@Env, @Secret), @Setting(options), @Renamed(old) and the escape hatch @Field(zod). A field decorator constrains, disambiguates, transforms, serializes, relates or adds a source. It never restates a type. It records metadata only and never installs an accessor, because a class field defined on the instance hides a prototype accessor. Each field decorator is typed against its field, so the type checker refuses @Int on a string, @List on a field that is not an array, and a @Field(zod) whose output type differs from the field.
    • Method: @Step(options) and @Transition(options), for workflow steps and state transitions only.
    • Class facet: @Index, @Searchable, @Auditable, @AccessLogged, @Lifetime, @Replaces and @Renamed, for a table fact or a capability another package reads.
    Banned: kind decorators of every name, parameter decorators, empty decorators and string tokens. The retired kind decorators are @Model @Action @Query @Resource @Module @App @Service @Settings @Config @Event @Listener @OnEvent @Workflow @Widget @Route @Layout @Panel @Menu @Block @Document @Flag @Store @Shortcut @Bridge @Policy @Permissions @Permission @Guard @Link @Hook @HookPoint @Override @Provider @ContextProvider @Injectable @Inject @Crud @Mail @Migration @Seeder @Factory @AsEndpoint @AsTool @AsJob @Cron @DriverOf @Driver @MoneyColumn @HasState @Json @Controller @Projection. A raw endpoint and a read model are base-class kinds like every other: Controller and Projection in the kind table (decision 16). Every decorator is made with createDecorator from @stackra/container and is registered with the kinds it may target. The decorator test: if the folder, the suffix, the base class, a field type, a method name or a derived name already says it, write no decorator.
  7. Function kinds are never classes (R7). Components (.tsx), React hooks (use-<name>.ts), plain functions (functions/<name>.function.ts), server-only helpers (<name>.server.ts) and mail templates (.mail.tsx) are functions. There are no class components: a route’s class is its page class.
  8. Companion types (R8). A type that serves one declaration stays in its file under a fixed suffix (Props, State, Options, Input, Output, Data). When a second file imports it, it moves to types/<name>.type.ts, or to src/contracts/types/ when it is a package’s public type. Enums always get their own file. An exported signature has no anonymous object type with two or more members.
  9. Formatting (R9). Lint autofixes one blank line between class members, and one property per line for an object literal with two or more properties inside decorator or base-factory arguments. Every file starts with the four-field header (Purpose, Boundary, Runtime, Governing contract), and every member carries a one-line TSDoc.
  10. Generated output is never authored (R10). .stackra/ is fully gitignored. The committed generated contracts sit at the member root: stackra.manifest.json and openapi.json. A tool that reads only the member root reads a gitignored shim there.
  11. Model references are thunks (R11). Inside a decorator a model is a thunk, as in @ManyToOne(() => Variant). A model is passed directly only as the first argument of a base factory: Resource(Order), Factory(Order), CrudService(Order), StateMachine(Order) and Facts(Order). A relation to a model of the same module is typed Ref<T> for one target or Collection<T> for many, and has a foreign key. A relation to a model of another module is typed Link<T> (decision 28).
  12. One member shape (R12). Every member authors stackra.config.ts and one root module, src/<key>.module.ts (src/node/<key>.module.ts in a package). Nobody writes main.ts or an entry barrel; the generated entries are described in 0295. Two kinds of member are exempt and have neither file: the docs app (apps/docs, a Mintlify site with no TypeScript runtime) and the tier:config presets in packages/tooling/, which are tool configuration and hold no module.

Group folders and the kind table

  1. Module groups. A module (src/modules/<key>/ in a service, src/node/ in a package) holds the groups below. A group folder appears with its first file. resources/<model>/ names a model the module owns or may reference. Only an owned model has a <model>.resource.ts. database/ belongs to the module, like every other group: a service has one per module (src/modules/<key>/database/) and a package has one in src/node/. Migrations are numbered per module, from 0001 with no gaps, and a module’s migrations change only its own Postgres schema (decision 20). Gen merges the modules’ streams, and those of the packages a host uses, into the ordered SQL of 0159 decision 2: modules in dependency order, then each module’s migrations by number.
  2. App groups. An app’s src/ holds the groups below.
  3. Package entry folders. A package’s src/ holds contracts/ (the leaf: ports/, enums/, types/), config/ (the config classes), core/ (isomorphic code: services, drivers, errors, functions, schemas and blocks), node/ (the module folder, with the module groups above), react/<name>/ and native/<name>/ (renderers), testing/ (fakes/ and matchers/), and <entry>/<pkg>.explorer.ts only in a package that ships decorators. Dependencies run one way: core imports contracts, node, react and native import core, and testing imports them all. No symbol is exported from two entries, and the entry barrels are generated, never authored.
  4. The kind table. Every kind is declared by its base class. The last column says whether the class is a binding in the container (yes), a value (no), an abstract port (token), or depends on the kind (per kind). A menu builds its items in items(m) from a closed set of item verbs and options, with no CSS (0295 decision 12).
  5. Classes are the only tokens. A constructor parameter names its token by its type, so there is no @Inject, @Injectable or string token. @Lifetime(Lifetime.Request) and @Lifetime(Lifetime.Transient) are the only scope markers; a class without one is a singleton, and the word “scope” is kept for settings and IAM. A named instance is the type Named<Port, "x">.
  6. Steps, actions and jobs. A workflow’s steps are its public async methods, run in declaration order. @Step takes only options: after (for a graph that is not a line), when, heartbeat, retry, waitFor, correlate and timeout. undo<Step> compensates a step, and output() returns the run’s result. The run’s type is Run<W>, whose conditional types test the bare workflow type W, not this, so run.results.<step> resolves inside a subclass and includes inherited steps. A job is an action run from a queue or a cron schedule through its job: option; it is not a kind.
  7. Configuration is a class. A configuration is class X extends Config in src/config/<name>.config.ts, with its key derived. A new configuration class extends a picked, omitted, partial or intersected class, or stands alone. A host overrides a package’s configuration with a <Host><Base> subclass that declares override members only and no new field. An override of a field typed as a literal union, such as DriverName<Port> or an enum, restates the base type (override ledger: DriverName<LoyaltyLedger> = "memory"), because a bare literal does not type-check against the base, and as const is not used on an override. A custom driver class is bound with @Replaces(Port). An environment value is an @Env("NAME") field and a secret is a @Secret("NAME") field; the environment and runway layers apply after the class is constructed. A nested object is replaced whole by an override, and a host merges it explicitly. Retired: defineConfig, registerAs, extendConfig, the override() and hostConfig(...) helpers, @Config(key) and @DriverOf.

Derived identities

  1. Identities are derived from names. Gen derives every identity below and locks it in stackra.manifest.json. Each module’s tables live in a Postgres schema named after the module key, inside the service’s one logical database (0238). Two modules of one service may therefore each own a Tag model: their tables are orders.tags and catalogue.tags, where a table name derived from the class alone would collide. Constraint names follow 0231: idx_ for indexes, fk_ for foreign keys and ck_ for check constraints.
  2. Endpoints follow a convention. Gen maps a key to a route the way Rails resource routing does. endpoint: { method, path } overrides the route, and endpoint: false removes it. An action whose actors include neither a user nor an agent gets no public endpoint.
  3. The manifest lock. stackra.manifest.json records every identity. A changed identity fails stackra gen --check unless the class or field carries @Renamed(old). Gen also fails when a derived i18n key is missing in any locale; stackra gen --fix-lang adds missing keys to the default locale.

Naming dictionary

  1. One meaning per word. Each word below means only what its first column says. Where the right column says “retired”, the word is not used for that thing.
  2. Sibling modules import only public groups. A module imports a sibling only through the sibling’s exported classes, schemas/, enums/, events/, errors/ and access/*.permissions.ts, and models/ from models/ and database/ only, for relations. A package’s contracts/ is a leaf: it imports nothing else from the package.

Kind bases, the shape compiler and models

Three spikes (STA-335) built a kind base usable both bare and called, the type-to-schema compiler on template shapes, and MikroORM EntitySchema emission from model classes. All three worked once the rules below were added.
  1. The kind-package contract. A kind package builds each kind base so that it works both as extends X and as extends X({ ... }), and the declared type decides which forms a kind allows.
    • Each factory returns an exported, named class type (ActionClass, WorkflowClass<I>, ResourceClass<M>), and each instance base carries a type-only ~kind brand, so one kind cannot stand in for another and a consumer’s declaration emit works. A parameter that takes a class of some kind, such as operations.create or startOn, is typed abstract new (...args: any[]) => XBase, because a class with injected constructor parameters does not fit a zero-argument constructor type.
    • A kind base assigns no fields in its constructor and keeps no state made there. The presets set useDefineForClassFields: true (0300 decision 6), and under that setting a subclass field declaration resets what a base constructor assigned.
    • Declaration files erase kind options, decorators and initializers. Gen therefore reads a kind’s options from source for its own member, and from a dependency’s committed stackra.manifest.json or generated output, never from the dependency’s .d.ts. A member that authors kinds never turns on isolatedDeclarations, which refuses an expression such as Action({ ... }) in an extends clause.
  2. The shape compiler. Gen compiles shape classes to Zod on the TypeScript 7 compiler API behind a small host seam, with a TypeScript 6 host as the fallback; it does not use ts-morph. The rules are these:
    • Gen sorts source files by path before it reads them, so its output does not change when the import graph does.
    • ?: compiles to .exactOptional(), because the presets set exactOptionalPropertyTypes. A Date field compiles to a codec: ISO 8601 with an offset on the wire, a Date in code.
    • A number[] or a map of numbers carries @Int({ each: true }) or @Num({ each: true }).
    • A default is a constant: a literal, an enum case, a literal-typed constant, or a value built by one of a closed set of platform constructors that gen evaluates (Duration.*, Money.of). Any other initializer fails gen. A field initialized with an enum case states the enum type (status: OrderStatus = OrderStatus.PendingPayment), or gen sees only one literal and refuses it with the fix. Decorator arguments are literals or literal-typed constants.
    • A union of schema classes compiles to a discriminated union, with an enum case as the discriminant. Any other union beyond T | null needs the @Field(zod) hatch, which carries a reason.
    • For each shape, gen also emits a type X$Check that asserts the compiled schema’s output type equals the class’s fields, so the type checker proves the compiler right at no runtime cost.
    • Generated schema names are qualified by module, so a class name used in two modules does not collide.
    • A package ships its own generated schemas (its X$) and manifest entries. A host builds on them, with Base$.extend(...) for a configuration override, CursorInput and Page.of(...), and never compiles a dependency’s shapes again.
    • The registry behind DriverName<Port> is keyed by the port’s key, so two ports with the same members never share driver names.
    • The generated code uses zod 4 (4.1 or later is needed for codecs and .exactOptional()), pinned exactly at 4.6.7 in this repository’s catalog and the template’s.
  3. Models hold no constructor-made state. The ORM builds a loaded row without running its constructor, so a model or trait has no #private field and no transient initializer, and a domain event that order.record() collects is kept outside the instance. A model that narrows a trait field uses declare, because a redeclared field would reset the trait’s initializer. Models are built through em.create, factories or CrudService, never with new. Gen’s EntitySchema output indexes every many-to-one and link column (the Postgres platform does not index foreign keys), maps a string with no @Str({ max }) to text, writes defaults and enum items explicitly, emits the optional-properties augmentation the ORM’s create typing needs, and turns off the ORM’s default-value inference. Money is stored in minor units in a Postgres bigint column, read as a JavaScript number; the money type refuses a value above Number.MAX_SAFE_INTEGER.
  4. A cross-module relation is a Link<T>. A relation to a model of another module is typed Link<T>. Gen stores the target’s public id in an indexed <name>_id column with no foreign key, no join and no target metadata. link.id needs no load, a filter takes a raw id, and link.load() runs the owning module’s generated query. A Link has no unwrap(), so the ORM never treats it as a loadable reference. Gen derives whether a relation is a link from module ownership, and fails with a fix when the annotation disagrees (Ref<T> across modules, Link<T> inside one).

Consequences

  • A class states its kind once, in its extends clause, and a file’s kind is read from its path. A reader and an agent find the kind of any file without reading its body.
  • Placement needs no judgement, because each suffix has one home group. A file in the wrong group fails the check that STA-338 adds.
  • A rename is a @Renamed and a reviewable manifest diff. A folder move no longer changes a wire name, but a changed key or name does, and the manifest shows it.
  • Gen does more work: it compiles types to Zod and MikroORM metadata, and it writes the manifest and the wiring. The spikes of STA-335 showed that the kind bases, the type compiler and the EntitySchema emission work, with the rules of decisions 25 to 28; the spike code stays outside this repository and is not a package.
  • Kind authors give up a few TypeScript shortcuts: no isolatedDeclarations, override on every member that replaces a default, and no constructor-made state in models or kind bases.
  • The template and the members of this repository still use the pre-v3 shapes until STA-337 (the template) and STA-344 (this repository) convert them, and the new checks of STA-338 do not fail on them until those checks land.
  • Authors write @Int or @Num on every number. A plain number fails gen.
  • Derived names are less visible in source. stackra ls <kind>, which lists declarations from the manifest, is the way to see them; it is planned.

Enforcement

Nothing enforces this grammar yet. The folder tables in tools/scripts/structure.mjs (KIND_FOLDERS and BANNED_FOLDERS) still hold the pre-v3 list, and pnpm validate has no kind-path check.
  • planned — STA-338: the day-one lint rules lines-between-class-members, multiline-decorator-options, no-empty-decorator and base-matches-suffix, and the validator’s kind-path check of the tables in this record.
  • planned — STA-340: the decorator-registry checks no-kind-decorator, facet-target and no-parameter-decorator; no-restricted-imports for defineToken, defineConfig, Injectable, Inject, Crud, Override and Provider imported from a @stackra/* package (that source only, so the defineConfig of a vite, vitest/config, tsdown or @playwright/test config stays allowed), and for Link used as a decorator (the Link<T> type stays); a no-restricted-imports pattern that refuses a *.server module in a file that is not a server file (0301 decision 6); override-only (an override declares no new field and uses no as const); no-new-shape extended to models; a required declare when a model narrows a trait field; the sibling-module boundary rules; and structure.mjs rebuilt as the single source of the tables in this record.
  • planned — STA-340: the gen checks kind_suffix_mismatch, kind_class_name, two_kinds_in_file, kind_unregistered_base, shape_number_kind, shape_unknown_type, shape_default_not_constant, shape_rule_not_constant, shape_hatch_reason_missing, shape_decorator_type_mismatch, action_handle_signature, identity_changed (with no @Renamed), identity_collision and i18n_key_missing; the generated X$Check assertions; the link check of decision 28; and the zod 4.6.7 entry in the pnpm-workspace.yaml catalog, which does not hold zod yet.
  • planned — STA-343: useDefineForClassFields: true set explicitly in the TypeScript presets (0300 decision 6).
  • The spikes of STA-335 ran outside this repository; their results are decisions 25 to 28. The conversion of the template is STA-337, and the conversion of this repository’s members is STA-344.

Supersedes and sources

Supersedes: none as a whole. Decision 1 of 0094 is superseded, and decision 3 of 0094 is amended, by the notes on the pages listed above. The other pages in the list are amended, and each one keeps its own decisions that this record does not touch. Related: 0003, 0005, 0022, 0090, 0238, 0091, 0095, 0098, 0126, 0132, 0137, 0138, 0140, 0141, 0161, 0208, 0227, 0237, 0281, 0300 and 0301. The build and entry shape, the runway and the web runtime are in 0295, 0296 and 0297. Sources: the programming-model v3 wave of 2026-10-22, Linear STA-334.