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 instackra.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
-
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’ssrc/. 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: inevents/for an event version, inschemas/for a dated response change. Tests sit in__tests__/<name>.<kind>.test.tsbeside their subject (0022). -
One primary export per file, named from the file (R2). The class is
PascalCase(stem) + PascalCase(kind), socancel-order.action.tsexportsCancelOrderAction. 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 inError. These companions may share the file:<Name>Inputand<Name>Outputin an action, query or prompt;<Name>Propsin a component;<Name>Datain a notification. -
The base class is the kind; its options are the argument (R3). Examples:
extends Action({ key }),extends Workflow({ timeout }), or the bareextends Signalwhen there are no options. A class extends exactly one kind base exported by a@stackra/*package, and that base’s identifier isPascalCase(kind), with these aliases:.event.tstakesDomainEvent,IntegrationEventorWebhookEvent;.error.tstakesRefusal;.service.tstakesServiceorCrudService;.model.tstakesModel.with(...). An override or a placement extends the class it overrides, as inWidget.place(...). Options never hold a fact that gen derives. A member that replaces a base member with a default, such as a resource’stable()or a route’smeta, is markedoverride; only an abstract member (handle,definition,check) goes without it, because the presets keepnoImplicitOverrideon. -
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| nullmeans nullable. Anumberfield carries@Intor@Num. A literal union,anyor 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/schemasand each model to MikroORMEntitySchemametadata, 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 throughstartOn:. A driver choice is a config field typedDriverName<Port>. Decisions 25 to 28 say how kind bases, the shape compiler, models and tables work in detail. -
Names give identity (R5). An author writes each wire name once: an action or query
key, an eventname, an analytics eventname, a routepathand a custom endpoint path. Every other identity is derived (decision 20).stackra.manifest.jsonlocks every identity, derived or written, and a change failsstackra gen --checkunless the class or field carries@Renamed(old). -
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@Inton a string,@Liston 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,@Replacesand@Renamed, for a table fact or a capability another package reads.
@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:ControllerandProjectionin the kind table (decision 16). Every decorator is made withcreateDecoratorfrom@stackra/containerand 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. - Field: constraints (
-
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. -
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 tosrc/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. - 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.
-
Generated output is never authored (R10).
.stackra/is fully gitignored. The committed generated contracts sit at the member root:stackra.manifest.jsonandopenapi.json. A tool that reads only the member root reads a gitignored shim there. -
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)andFacts(Order). A relation to a model of the same module is typedRef<T>for one target orCollection<T>for many, and has a foreign key. A relation to a model of another module is typedLink<T>(decision 28). -
One member shape (R12). Every member authors
stackra.config.tsand one root module,src/<key>.module.ts(src/node/<key>.module.tsin a package). Nobody writesmain.tsor 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 thetier:configpresets inpackages/tooling/, which are tool configuration and hold no module.
Group folders and the kind table
-
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 insrc/node/. Migrations are numbered per module, from0001with 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. -
App groups. An app’s
src/holds the groups below. -
Package entry folders. A package’s
src/holdscontracts/(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>/andnative/<name>/(renderers),testing/(fakes/andmatchers/), and<entry>/<pkg>.explorer.tsonly 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. -
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 initems(m)from a closed set of item verbs and options, with no CSS (0295 decision 12). -
Classes are the only tokens. A constructor parameter names its token by its type, so there is no
@Inject,@Injectableor 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 typeNamed<Port, "x">. -
Steps, actions and jobs. A workflow’s steps are its public async methods, run in declaration order.
@Steptakes only options:after(for a graph that is not a line),when,heartbeat,retry,waitFor,correlateandtimeout.undo<Step>compensates a step, andoutput()returns the run’s result. The run’s type isRun<W>, whose conditional types test the bare workflow typeW, notthis, sorun.results.<step>resolves inside a subclass and includes inherited steps. A job is an action run from a queue or a cron schedule through itsjob:option; it is not a kind. -
Configuration is a class. A configuration is
class X extends Configinsrc/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 declaresoverridemembers only and no new field. An override of a field typed as a literal union, such asDriverName<Port>or an enum, restates the base type (override ledger: DriverName<LoyaltyLedger> = "memory"), because a bare literal does not type-check against the base, andas constis 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, theoverride()andhostConfig(...)helpers,@Config(key)and@DriverOf.
Derived identities
-
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 aTagmodel: their tables areorders.tagsandcatalogue.tags, where a table name derived from the class alone would collide. Constraint names follow 0231:idx_for indexes,fk_for foreign keys andck_for check constraints. -
Endpoints follow a convention. Gen maps a key to a route the way Rails resource routing does.
endpoint: { method, path }overrides the route, andendpoint: falseremoves it. An action whose actors include neither a user nor an agent gets no public endpoint. -
The manifest lock.
stackra.manifest.jsonrecords every identity. A changed identity failsstackra gen --checkunless the class or field carries@Renamed(old). Gen also fails when a derived i18n key is missing in any locale;stackra gen --fix-langadds missing keys to the default locale.
Naming dictionary
-
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.
-
Sibling modules import only public groups. A module imports a sibling only through the sibling’s exported classes,
schemas/,enums/,events/,errors/andaccess/*.permissions.ts, andmodels/frommodels/anddatabase/only, for relations. A package’scontracts/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 MikroORMEntitySchema emission from model classes. All three worked once the rules below were added.
-
The kind-package contract. A kind package builds each kind base so that it works both as
extends Xand asextends 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~kindbrand, 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 asoperations.createorstartOn, is typedabstract 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.jsonor generated output, never from the dependency’s.d.ts. A member that authors kinds never turns onisolatedDeclarations, which refuses an expression such asAction({ ... })in anextendsclause.
- Each factory returns an exported, named class type (
-
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 setexactOptionalPropertyTypes. ADatefield compiles to a codec: ISO 8601 with an offset on the wire, aDatein 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 | nullneeds the@Field(zod)hatch, which carries a reason. - For each shape, gen also emits a type
X$Checkthat 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, withBase$.extend(...)for a configuration override,CursorInputandPage.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.
-
Models hold no constructor-made state. The ORM builds a loaded row without running its constructor, so a model
or trait has no
#privatefield and no transient initializer, and a domain event thatorder.record()collects is kept outside the instance. A model that narrows a trait field usesdeclare, because a redeclared field would reset the trait’s initializer. Models are built throughem.create, factories orCrudService, never withnew. Gen’sEntitySchemaoutput indexes every many-to-one and link column (the Postgres platform does not index foreign keys), maps astringwith no@Str({ max })totext, writes defaults and enum items explicitly, emits the optional-properties augmentation the ORM’screatetyping needs, and turns off the ORM’s default-value inference.Moneyis stored in minor units in a Postgresbigintcolumn, read as a JavaScriptnumber; the money type refuses a value aboveNumber.MAX_SAFE_INTEGER. -
A cross-module relation is a
Link<T>. A relation to a model of another module is typedLink<T>. Gen stores the target’s public id in an indexed<name>_idcolumn with no foreign key, no join and no target metadata.link.idneeds no load, a filter takes a raw id, andlink.load()runs the owning module’s generated query. ALinkhas nounwrap(), 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
extendsclause, 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
@Renamedand 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
EntitySchemaemission 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,overrideon 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
@Intor@Numon everynumber. A plainnumberfails 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 intools/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-decoratorandbase-matches-suffix, and the validator’skind-pathcheck of the tables in this record. - planned — STA-340: the decorator-registry checks
no-kind-decorator,facet-targetandno-parameter-decorator;no-restricted-importsfordefineToken,defineConfig,Injectable,Inject,Crud,OverrideandProviderimported from a@stackra/*package (that source only, so thedefineConfigof avite,vitest/config,tsdownor@playwright/testconfig stays allowed), and forLinkused as a decorator (theLink<T>type stays); ano-restricted-importspattern that refuses a*.servermodule in a file that is not a server file (0301 decision 6);override-only(an override declares no new field and uses noas const);no-new-shapeextended to models; a requireddeclarewhen a model narrows a trait field; the sibling-module boundary rules; andstructure.mjsrebuilt 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_collisionandi18n_key_missing; the generatedX$Checkassertions; the link check of decision 28; and the zod 4.6.7 entry in thepnpm-workspace.yamlcatalog, which does not hold zod yet. - planned — STA-343:
useDefineForClassFields: trueset 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.