Inline authoring
A Kumiko app feature is written inline. Entities, handlers, hooks,
events, screens — they live as literal expressions inside the
defineFeature callback, not behind a factory function that constructs
them at runtime. This looks like a stylistic choice. It is a contract,
and it has a specific reason.
// Inline — what app features must look likeexport default defineFeature("incidents", (r) => { r.entity("incident", { fields: { title: createTextField({ required: true }), severity: createSelectField({ options: ["low", "high"] as const }), }, });
r.writeHandler(resolveIncident); r.queryHandler(listOpenIncidents);});// Factory — what app features must NOT look likeexport function createIncidentsFeature(opts: { auditEnabled?: boolean }) { return defineFeature("incidents", (r) => { r.entity("incident", buildIncidentEntity(opts)); if (opts.auditEnabled) r.requires("audit"); });}The first form is editable by tools. The second is opaque to them.
What reads the source
Section titled “What reads the source”Three classes of tooling depend on the inline form, and each of them fails on the factory version.
The AST patcher. When Kumiko’s designer adds a field to an entity, it
parses the source file, walks to the r.entity("incident", { fields: … }) node, inserts the new field, and writes the file back. The patcher
finds entities by literal path: it expects to see r.entity with a
string-literal name and an object-literal options argument inside the
defineFeature body. A factory call with arguments computed at runtime
gives the patcher no fixed point — it cannot statically know the
options object exists, much less where to insert.
The designer. A non-developer rendering a feature in the schema
designer sees one entity per r.entity call. The designer reads the
source AST, not the runtime registry, because the runtime registry only
exists after a build. A factory function hides the entity behind one
or more layers of indirection that the designer cannot follow without
running the code.
The AI feature builder. An LLM generating a feature edit needs the input to be the same shape as the output. If the codebase is full of factories, every example becomes “decode the factory’s options object, imagine what it produces, edit the imagined version”. Generating the code becomes generating the inputs to a factory you have to construct in your head. Inline removes the layer.
The same property reappears in three places because it is one property: the source must be the canonical representation. Anything that hides the actual feature shape behind a runtime construction breaks every tool that reads source.
What goes inline, what doesn’t
Section titled “What goes inline, what doesn’t”The contract applies to what defines the feature’s shape. Entities,
their fields, the access blocks, the relations, the events, the
projections — all of these are literal expressions in the
defineFeature body. Field factories like createTextField({ required: true }) are allowed, because the input to the factory is itself an
object literal with statically inspectable arguments. The patcher reads
through one layer of factory call as long as the call site is regular.
What does not have to be inline:
- Handler bodies. The function that runs when
incidents:resolveis called is a regular TypeScript function. It can live inhandlers/resolve.write.ts, do whatever it needs to do, import what it needs to import. The patcher and the designer do not read handler bodies. - Helper functions used inside handlers. A
slugify, a date calculation, a domain-specific validation — those are normal code. - Anything used at boot but not defining the feature shape. Schema
helpers that return Zod schemas, constants used in multiple field
definitions, enum-like
as constarrays.
The line is whether the shape of the feature — what entities, what fields, what handlers exist — can be read by walking the source. As long as that property holds, everything else is unconstrained.
Bundled features are exempt
Section titled “Bundled features are exempt”A bundled feature like audit or secrets is a function that returns a
feature definition: createAuditFeature(opts). That is exactly the
factory pattern app features may not use. Bundled features get a pass
because they are not designer- or AI-editable. A user adding a field to
their incidents entity is editing app code. Nobody adds a field to the
audit feature through the designer.
The split keeps the contract honest. Bundled features need configuration options — which transport to use for email, which storage backend to use for files — and forcing them inline would mean every app re-declares those options. App features have no equivalent need: their shape is their definition.
The migration cost is finite
Section titled “The migration cost is finite”Existing apps that were written before the inline contract took shape
sometimes have factory-style features. PublicStatus is the prominent
example in the Kumiko monorepo. The cost of migrating is real but
bounded: each factory becomes a regular defineFeature call, the
options that were arguments become inline literals, and the AST patcher
gains the ability to edit them. Migration is gated on the patcher
landing — until then, factory-style features still run, they just are
not designer- or AI-editable.
What this gets you
Section titled “What this gets you”Inline authoring is the contract that makes everything else work. The schema-as-data property in entities, the boot-time validation of the feature graph, the designer’s ability to render a feature, the AI builder’s ability to edit one — every property compounds on top of the source being readable by tools.
The cost is small: feature definitions are slightly more verbose, because options that would be arguments to a factory become literal fields in an object. The benefit is that everything that needs to read your code, can.
See also
Section titled “See also”- Schemas as data — the property that inline authoring exists to preserve.
- Bundled features vs. custom — why bundled features are exempt from the inline contract.