Loom decisions log
Pinned decisions referenced by the design corpus in
docs/old/proposals/ and the implementation plans. Each
entry has a stable D-tag that proposals cite when their grammar
or semantics depends on the outcome. Tag scope:
- PINNED — decided; downstream proposals may rely on it.
- OPEN — recommended answer recorded in the proposal/plan, not ratified by the maintainer.
When a proposal's body conflicts with a PINNED decision here, the decision wins; the proposal text needs a rewrite (tracked per entry under "Affects").
Decision tags were introduced by the original
proposals/global-implementation-plan.md
("Decisions to pin before any grammar edit"; that plan was rewritten
2026-06-10 — this log is now the sole home of the tags) and are
elaborated in the per-proposal docs.
D-STORAGE-SPLIT — split the overloaded storage keyword
Status: PINNED.
Problem. storage-and-platform-config.md §3.1/§3.2 use one
storage rule for two unrelated concerns — a physical instance
(storage pg { type: postgres }) vs. a logical aggregate-to-physical
binding with rich per-binding config (storage orderEvents { use: pg, for: Sales.Order, kind: eventLog, schema: "sales" }). The two forms
share nothing beyond the word — the physical form carries
type/instance/connection/outbox/follows; the logical form
carries schema/tablePrefix/kind/ttl/every/retain/etc.
Disambiguation by "presence of for:" is an accident, not a design.
Decision. Three distinct keywords, one job each:
| Keyword | Role | Carries |
|---|---|---|
storage |
Physical infrastructure instance | type, instance, connection, outbox, follows |
dataSource |
Logical (context, kind) → storage binding | for: <Context>, kind: <state|eventLog|snapshot|cache|replica>, use: <storage>, plus per-kind config (schema/ttl/every/retain/keyPrefix/…) |
deployable |
Process/runtime unit | contexts: [...], dataSources: [...], platform config |
Worked example:
storage pg { type: postgres }
storage kafka { type: kafka }
storage redis { type: redis }
dataSource ordersState { for: Orders, kind: state, use: pg }
dataSource ordersEvents { for: Orders, kind: eventLog, use: kafka }
dataSource ordersCache { for: Orders, kind: cache, use: redis, ttl: 60 }
dataSource ordersSnapshot { for: Orders, kind: snapshot, use: redis,
every: 100, retain: 5 }
deployable api {
contexts: [Orders]
dataSources: [ordersState, ordersEvents, ordersCache, ordersSnapshot]
}
Rationale.
- The two forms are not synonyms with shared structure; they are
separate concepts that happen to live in the same domain. A naming
rule that makes them parse identically and rely on a sentinel field
(
for:) is fragile. dataSourcereads naturally for "a configured source/sink of data for one purpose within one context."- Keeping
storagefor the physical form preserves the most-typed surface (every project has physical stores; only some need rich per-context data-source config). deployable.dataSources:is the new clause; it pairs with the existingcontexts:clause.
Validator rules implied.
dataSourcerequires bothfor:(a context) andkind:. Missing either → parse / validate error.- For each
(context, kind)actually needed by aggregates in the context, exactly onedataSourcemust exist with matchingfor:andkind:. State-based aggregate →kind: staterequired; ES aggregate →kind: eventLogrequired;cache-annotated aggregate →kind: cacherequired; etc. - A deployable's
dataSources:must include exactly the dataSourcesfor:the contexts it hosts (one per needed kind) — validator rejects under-listing (missing binding for a needed kind) and over-listing (a dataSourcefor:a context the deployable does not host). - Per-
kind:config keys validated against the resolved physical store'stype(e.g.,ttl:only on Redis kinds).
Affects (proposal rewrites needed).
storage-and-platform-config.md— §2.1 invariant 4 (which asserted "no storage homogeneity per BC") conflicts with D-GRANULARITY below and needs rewriting; §3.2 ("Logical storage") becomes thedataSourcesection; §3.9 ("Module bindings — bare form") replaced by deployable'sdataSources:clause; §3.8 ("Per-deployableoverrides") deferred under D-ENV-SWAP.bounded-context-model.md— the deployable-sidestorage: { Orders: pg, Subscriptions: pg }shorthand becomesdataSources:instead; framework choice oncontextis unchanged.storage-and-platform-config-plan.mdandstorage-and-platform-config-micro-plan.md— F1 sub-PRs that introduce the logical form shipdataSourcefrom day one; per-aggregatefor:does not land in v1 (see D-GRANULARITY).
D-GRANULARITY — storage bindings are per-context, not per-aggregate
Status: PINNED.
Problem. Two proposals disagree:
storage-and-platform-config.md§3.3 + §3.2 commits to per-aggregate binding (for: Sales.Order), and §2.1 invariant 4 explicitly declares "BC = semantic boundary only … no storage homogeneity."bounded-context-model.mdreframes the BC as the unit that owns framework + storage, with per-aggregate binding deferred to v2 as override-only.
Decision. Per-context for v1, all kinds. A dataSource
binds at granularity (context, kind). All aggregates in a context
share the same primary store (state or eventLog), the same derived
stores (cache, snapshot, replica), and the same per-kind config
(snapshot policy, cache TTL, …) for that context.
Per-aggregate binding is deferred to v2 as an override mechanism
for the rare cross-infra-within-one-context case. The storage
proposal's per-aggregate for: <Aggregate> syntax does not land in
v1; if reintroduced later it lands as a deferred override (not the
primary form).
Rationale.
- Transactional feasibility (the "which writes can co-commit?"
chain) is naturally per-context — see
bounded-context-model.md§"Transactional workflow feasibility" link 3. - Real-world v1 cases overwhelmingly have one primary store per context; cross-aggregate-within-one-context infra splits are rare.
- Per-context-per-kind preserves the storage proposal's most valuable insight (different kinds do live in different physical engines — eventLog in Kafka, snapshot in Redis), without multiplying the binding surface.
- v2 per-aggregate override remains compatible: it lifts the granularity inside one context without changing the rest of the model. The storage proposal's grammar work survives there.
Validator rules implied.
dataSource.for:references a context, never an aggregate. Parser/validator rejects an aggregate-qualifiedfor:in v1 with a diagnostic that links to this decision.- "Two aggregates in the same context bind to different physical stores" is unrepresentable in v1.
- ES + state-based aggregates may coexist in one context, each
resolving to the context's
kind: eventLogorkind: statedataSource respectively.
Affects.
storage-and-platform-config.md— §2.1 invariant 4 ("BC = semantic boundary only") rewritten to "BC = transactional-feasibility unit; storage homogeneity per BC is the v1 default"; §3.3 ("for:reference syntax") rewritten to context-only.bounded-context-model.md— §"Per-aggregate storage" still accurately frames v1 + v2; this decision pins it.
D-ENV-SWAP — per-environment storage swap mechanism
Status: DECIDED (owner ruling 2026-10-21) — option ①, no new grammar; option ② is declined.
Context. Storage proposal §2.1 invariant 5: "Storage is swappable per deployable." The test deployable swaps Postgres for in-memory without the domain noticing. Two shapes were considered:
- Option ① — alternate dataSources per environment, deployable
picks:
dataSource ordersStateTest { … use: memTest }listed indeployable apiTest { dataSources: [ordersStateTest, …] }. Verbose if many context × kind combinations, but needs no new grammar. - Option ② — deployable
overrides { storage pg { type: inMemory } }block (existing storage proposal §3.8). Terse; preserves one dataSource set across environments.
Decision. Option ①: a per-environment deployable lists its own
alternate dataSources, with no new grammar beyond what D-STORAGE-SPLIT
landed. Option ② (overrides { storage … }) is declined.
Affects. Storage proposal §3.8 (overrides) does not land.
D-DOCUMENT-AXIS — document storage as two orthogonal header axes
Status: PINNED (core axes, syntax, validation contract); the numbered Open sub-questions below remain OPEN.
Implementation: SHIPPED. json primitive (#703);
persistedAs: eventLog|state (#711); the saving-shape axis —
originally drafted as the boolean normalised(true | false) (#713),
reworked to the 3-valued shape: relational|embedded|document
(#724) once it was clear the axis is a spectrum, not a boolean — with
emission across the backends: embedded on all of dotnet/hono/
phoenixLiveView (EF owned .ToJson() / Drizzle jsonb / Ash embedded
resources, #724/#735/#750), document on dotnet/hono (STJ /
jsonb blob, #724), and a supportedShapes capability validator
(#738) that errors on a shape: … the target backend can't emit. No
new Marten backend. Remaining (deferred): Ash document (non-idiomatic
single-:map, allowed-but-warned) and eventLog + snapshot
rehydration (gated behind the appliers feature). See
document-and-json-hierarchies.md §9.
Problem. Loom models internal hierarchies but the
relational-vs-document storage choice is implicit and unselectable
(value objects → inline JSONB; entity parts → child tables); there is
no open-shape JSON field; and a document/event-store backend (Marten)
has nowhere to attach. The shipped persistenceStrategy: clause also
conflates the event-sourcing body contract with persistence and
sits anomalously inside the aggregate body. Full analysis in
proposals/document-and-json-hierarchies.md.
Decision. Two orthogonal per-aggregate header modifiers, plus a
json field type. "Document" is a field type and a saving choice —
not a new declaration kind.
| Modifier | Axis | Values | Default |
|---|---|---|---|
persistedAs: ... |
primary truth kind | eventLog | state |
state |
shape: ... |
saving shape of the materialised read model / snapshot | relational | embedded | document |
relational |
(shape: ... superseded the original boolean normalised(true\|false)
in #724 — shape: relational == old normalised(true), shape: document
== old normalised(false), with embedded added as the queryable middle.)
persistedAsvalues align to the D-STORAGE-SPLITkindset, soresolve-datasource.ts'seventSourced→eventLog/stateBased→statemapping becomes an identity.persistedAsrenames + relocates the shipped bodypersistenceStrategy: stateBased | eventSourced→ headerpersistedAs: eventLog|state. Breaking change; hard cutover —persistenceStrategy:is removed (not accepted in parallel); existing.dddsources migrate in one step (codemod offered).- All aggregate-level config lives on the header
(
with,extends,persistedAs: …,shape: …,inheritanceUsing: …; bareabstract/crossTenant). Nothing configures in the body — the body holds members only. (The enum-axis modifiers were drafted as paren callspersistedAs(eventLog); M-T5.17 phase 2 cut them over to colon clauses — order-independent — and hoistedcrossTenantto lead besideabstract. The paren form and the trailingcrossTenantno longer parse; theidsclause was removed outright.) - New
jsonprimitive field type — opaque JSONB; a leaf inwireShape(never expanded/diffed). - Rejected:
documentas an aggregate peer. Deferred: a dedicateddocumentvalue-type. Dropped: a per-containmentas document/tablehint. - ES + document needs no new
kind: akind: eventLogbinding + akind: snapshot(orstate) binding carryingshape: document. - No dedicated Marten backend. "Store as a document" is the
aggregate read model in one JSONB column, which every backend's ORM
already supports — so the
documentshape is a mode added to the existing adapters (EF Core.ToJson(), Drizzlejsonb, Ash embedded/:map), advertised via a newsupportedShapescompanion tosupportedStrategieson the existingPersistenceAdapterseam. A separatemartenPersistenceAdapterwas considered and rejected: its document half is EF.ToJson(), and its event-store half (stream + document-snapshot rehydration) needs appliers (workflow-and-applier.md) regardless of backend. So Slice D's achievable target ispersistedAs: state+shape: document; theeventLog+ document case is deferred behind appliers.
Validator rules implied.
persistedAs: eventLogis the declaration of event-sourcing (the rename ofpersistenceStrategy: eventSourced); there is no separateeventSourcedbody marker.- The body-discipline enforcement — operations change state only by
emitting events, an
applyexists per event, no direct:=mutation — is owned by the event-sourcing behavioral feature (appliers,workflow-and-applier.md) and is gated onpersistedAs: eventLog. It is not implemented by thepersistedAsrename itself, and cannot land beforeapplyexists in the grammar. So the rename slice ships no body-contract validator; that enforcement arrives with the applier feature. persistedAs: state(default / absent): operations mutate state directly; noapply.persistedAsis explicit, defaultstate(omitted entirely for state-based aggregates). No inference and no suggestion lint.shape: documentrequires the context to resolve a document-capable store/adapter; it constrains thesnapshotbinding underpersistedAs: eventLog, thestatebinding underpersistedAs: state.- Interaction (D-ES-TPH, generalised): a
persistedAs: eventLogconcrete subtype of asharedTablebase is forced toownTableregardless ofshape.
Sub-questions.
persistedAsinference — RESOLVED: explicit, defaultstate, no inference, no lint.jsonshape-hint — RESOLVED: plainjsonfor v1;json<T>out of scope.- Snapshot cadence for
eventLog+ document — RESOLVED: reuse thesnapshotdataSource'severy:knob (already in D-STORAGE-SPLIT). Cadence is binding/infra config; no aggregate-header arg. - Per-projection vs per-aggregate
shape— RESOLVED: per-projection. The shape is settable per read-model: the per-bindingdataSource shape:knob (on thestate/snapshot/replicabinding) governs that projection's shape; the aggregate-headershape: …is the default. This stays within D-GRANULARITY (per(context, kind)binding, not per-aggregate). Richer named projections (multiple read models of one ES aggregate, each a different shape) depend on future read-model modelling and are out of v1 scope. - Real document DB — RESOLVED: Postgres-JSONB only in v1
(Marten's own bet);
shape: documentresolves to JSONB / Marten docs on Postgres.StorageType += mongodeferred.
Affects.
document-and-json-hierarchies.md— this is its decision record.aggregate-inheritance.md— itsstorage: shared|ownheader clause is renamed by D-RENAME (below) to theinheritanceUsing: …header modifier; same header line.- Shipped grammar —
persistenceStrategy:(body) removed in favour ofpersistedAs: …(header); hard cutover, one-step source migration (codemod). resolve-datasource.ts— mode→kind mapping collapses to identity.
D-BACKEND-PKG — per-version backend packages are canonical
Status: PINNED.
Problem. Two layout docs disagree about where framework-coupled
backend code lives. platform-directory-layout.md recommends
Option A — reverse the src/platform/hono/v4/ hoist back down
into src/generator/<platform>/frameworks/…, shrinking src/platform/
to thin surface records. Separately, docs/old/plans/packaging-split.md
(P0–P4, partly shipped) drives toward the opposite: each backend
becomes a separately-installable per-version npm package
(@loom/backend-hono-v4, @loom/backend-dotnet-v8, -v10, …)
discovered via its loom package.json key, so old + new majors coexist
and @loom/core never statically bundles a backend.
src/platform/hono/v4/ is the staging shape for that relocation
(P3-slice-5), currently blocked only on browser-side backend discovery.
Decision. The packaging-split end-state is canonical. Backends
are per-version packages; @loom/core keeps the framework-neutral emit
(render-expr/render-stmt, DTO/VO/id/event templates) + the
PlatformSurface contract + the resolver. The package → shared
layering invariant — shared code under src/generator/ must never
import from src/platform/<family>/<vN>/, guarded by
test/platform/backend-packages-layering.test.ts — is load-bearing.
platform-directory-layout.md Option A is rejected: it would
re-pin shared core to one framework version (blocking hono@v5) and is
forbidden by the live invariant. The direction that survives is
per-<family>/v<N>/ homes that map 1:1 to packages; the existing hono
hoist is correct, not to be reversed.
Note (2026-10). node@v4 was retired (see #3687): packages/backend-hono-v4/
and its pins are gone, node@v5 is the only registered Hono version, and a
node@v4 pin fails with loom.platform-version-unknown naming node@v5
(RETIRED_BACKEND_VERSIONS in src/platform/metadata.ts).
src/platform/hono/v4/ remains only as the shared Hono emitter +
makeHonoPlatform factory that v5 calls.
Mechanism / sequencing.
- No speculative scaffolding — no
v5/,express/,nestjs/, adapter sub-version dirs, or publish wrappers until a real consumer exists. - The dotnet (then phoenix) core↔backend split — mirroring hono's P2 —
is sanctioned now that F6d proved the boundary, but is sequenced
after the storage F-series' remaining persistence-dispatch slices
land (they still edit
src/generator/dotnet/phoenix-live-view). - Physical relocation into
packages/(P3-slice-5) stays gated on browser-side backend discovery; until then the in-srcstaging dirs- thin re-export wrappers are the reachable shape.
Affects.
platform-directory-layout.md— the "Recommendation" section and the V1 row (recommending Option A) are superseded; the doc carries a "Pinned decisions affecting this proposal" banner pointing here.per-package-output-tree.md— the output-side twin of this decision (per-layer output packages). Right direction, deferred on one-time cost + playground-workspace prerequisite, not rejected; expressible as aLayoutAdapterextension.docs/old/plans/packaging-split.md/backend-packages.md— promoted from "plan" to the pinned target for backend layout.
D-ADAPTER-HOME — persistence/style/layout adapters live on the backend surface
Status: PINNED.
Problem. The storage F-series introduced a persistence/style/layout
adapter taxonomy with the contracts correctly in core
(src/generator/_adapters/), but a central registry
src/platform/adapter-registry.ts that statically imports every
backend's adapter implementations. That central fan-in (a) becomes a
core → package edge — the direction backend-packages-layering.test.ts
forbids — the moment a backend relocates into packages/, and (b)
already causes a load-time import cycle (adapter-registry ← cqrs-style ← … ← platform/registry ← platform/dotnet), which forced F5d/F6d to
bind each orchestrator's own local sibling adapters instead of
resolving through it.
Decision. Adapter implementations belong to the backend, exposed
through its PlatformSurface (an additive contract field carrying the
adapter menu + defaults). @loom/core owns only the contracts
(src/generator/_adapters/) + the resolve* helpers, which read
menu/defaults off the discovered surface. The central
src/platform/adapter-registry.ts is interim and does not
survive the alignment pass.
Mechanism / status.
- The emit half is already decentralised (F5d/F6d orchestrators dispatch through local adapters). Remaining work: source the menu + defaults from the surface, then delete the central registry's static fan-in.
- Version-divergent adapters (efcore8 vs efcore10, Ash 3 vs 4) ship
inside the version's backend package — that divergence is the
package's reason to exist. No shared cross-framework adapter layer
(e.g. a
node-shared drizzle) until ≥2 real consumers exist ("consolidate the present, don't design for the future").
Affects.
storage-and-platform-config*.md— the adapter-registry shape they describe is interim per this decision.- Depends on D-BACKEND-PKG (the surface is the discovery unit).
D-RENAME — aggregate-inheritance layout modifier
Status: PINNED.
Decision. Inheritance table layout is a header colon modifier
inheritanceUsing: sharedTable|ownTable (was the body clause
inheritanceStrategy: shareTable | ownTable). Amended by
D-DOCUMENT-AXIS §4 — all aggregate config moves to the header;
nothing in the body. (Drafted as the paren call
inheritanceUsing(ownTable); recolonized by M-T5.17 phase 2 — the
paren form no longer parses.) Values stay table-baked, spelled
sharedTable | ownTable (refines the earlier shareTable, reads as
"shared table"); the medium-neutral shared | own spelling is
rejected (the choice is specifically about table layout, and naming it
so keeps it honest when document/JSON saving enters via shape).
Validator rules implied.
inheritanceUsing: …is only valid on an aggregate that participates in inheritance (abstractbase orextendssubtype).sharedTable= TPH (single table + discriminator column);ownTable= TPC/TPT (table per concrete). The exact TPC-vs-TPT choice is a backend detail, not surfaced in the modifier.
Affects. aggregate-inheritance.md (its storage: shared|own
clause is this modifier); document-and-json-hierarchies.md §4a (same
header line). Interacts with D-ES-TPH below.
D-ES-TPH — event-sourced concrete subtype of a TPH abstract
Status: PINNED.
Decision. A persistedAs: eventLog concrete subtype of a
sharedTable (TPH) abstract base is forced to
inheritanceUsing: ownTable — an event-sourced stream cannot share a
state table with its siblings. Generalises across shape per
D-DOCUMENT-AXIS: a shape: document (document) concrete of a
sharedTable base is likewise forced to ownTable. The validator
raises an error (not a silent coercion) so the author writes the
forced modifier explicitly.
Extended by D-EMBEDDED-TPH (wave C2): shape: embedded joins the
forced set — the third non-relational shape, missed when this rule was
generalised, and the one compile waivers F11 / F13 were recording.
Affects. aggregate-inheritance.md; the persistence adapter's
table-layout resolution.
D1–D4, D14–D15 — type-system grammar names + ML syntax
Status: PINNED (the six grammar-shaping tags). The remaining
type-system decisions D5–D37 keep their recommended answers in
proposals/implementation-plan.md
and may be taken per-phase without separate ratification (per that
doc's workflow note); only D1–D4 + D14–D15 are pinned here because they
fix the grammar surface before P3.
| Tag | Question | Pinned answer |
|---|---|---|
| D1 | Carrier bound name | carrier (over value/data) |
| D2 | Union discriminator field name | kind (over type/_type) |
| D3 | Union identity | Variant-name-tagged (not structural) |
| D4 | Aggregate-in-carrier semantics | Handle-in-process, wire-at-boundary |
| D14 | Parameterised-payload use-site syntax | Postfix ML (customer page); consistent with Customer id; no angle brackets anywhere |
| D15 | Anonymous or precedence vs postfix constructors |
Postfix binds tighter than or (string or int option parses as string or (int option); parens for the other reading) |
Rationale. D14's postfix ML choice is the load-bearing one — it
keeps customer page, customer id, customer option all reading as
"a page/id/option of customer", with zero generics syntax (<>) in
the language. D1/D2 pick the least-surprising names; D3/D4 were already
pinned in the implementation plan and are restated here for one
authoritative location.
Affects. payload-transport-layer.md (P3/P4 grammar);
exception-less.md (D15 governs or precedence); ddd.langium
TypeRef + payload rules.
D-POLICY-STYLE — authorization grammar shape
Status: PINNED.
Decision. Authorization is expressed in dedicated policy { }
blocks with three sub-sections — data { reachable when … }
(row-set reachability filter, paramless), operations { <op> when … }
(point gates, params from the operation), and fields { mask … }
(field masking) — over the function-style policy DSL alternative.
Reuses function, currentUser, and permissions {} as building
blocks inside the block.
Rationale. The block form keeps authorization as visibly-separate
infrastructure (one place to read "what governs this aggregate"),
makes the reachability-vs-gate distinction structural (different
sub-sections, different binding scopes), and reads as declarative
configuration rather than scattered guard functions. currentUser
member accesses (.permissions, .dataKey, .id) resolve against the
shape pinned in
architecture/request-context.md.
Affects. authorization.md (its central grammar);
policies-supplementary-note.md stays superseded background;
ddd.langium gains the policy rule (Phase 3.2).
D-LIFECYCLE-VERB — lifecycle URL style default
Status: PINNED.
Decision. The api surface carries urlStyle: literal | resource,
default literal. literal uses the operation/create/destroy name
verbatim as the URL slug (operation cancel() → POST /orders/:id/cancel); resource pluralises it via src/util/naming.ts
(operation cancellation() → POST /orders/:id/cancellations). Loom
rejects the Restful Objects two-tree URL idiom outright (POST /services/Customers/actions/createNewCustomer/invoke) in favour of
conventional REST that hand-written clients already understand.
Affects. lifecycle-operations.md; the per-backend route emitters
(slug derivation reads urlStyle).
D-I18N-KEY — i18n key stability
Status: PINNED.
Decision. Option B as default, Option C as escape hatch.
- Inline user-visible literals are keyed by a content hash —
page.<page>.<role>.<sha-6>(6-char base64 ofsha512(source)). Placeholders are normalised to positional for hashing ("Order {0}") but rendered named at use ({orderNumber}). Effect: a placeholder rename leaves the hash stable;ddd i18n syncrewrites the placeholder name in the catalog without re-keying (Option B). - Named
text { }entries get true stable keys (text.<Namespace>.<name>) with author-chosen placeholder names — the escape hatch (Option C) for strings that must survive content edits, not just renames.
Option A (live with churn) is rejected; positional-only hashing without
named render is rejected (leaks DSL/source structure to the client).
The unnamed-placeholder fallback warns (loom.unnamed-placeholder).
Affects. i18n.md, i18n-strings.md; the ddd i18n sync
three-way merge; the generated catalog key shape.
D-I18N-ATTR — who resolves an attribute-position string
Status: PINNED.
Decision. The a11y/i18n helper emits an already-TRANSLATED value; a
design pack never resolves a key. A user-visible string in ATTRIBUTE
position (Button's aria-label, Toolbar's aria-label, Alert/Modal
title, Divider label) reaches a pack in exactly one of two spellings,
both produced by src/generator/_walker/i18n-emit.ts from the same
messageKey() the extraction pass uses:
- an HTML-ish attribute FRAGMENT (
localizedAriaLabelAttr,localizedNamedAttr) — spliced verbatim by the four JSX/markup frontends, whose packs are.hbstemplates that can only interpolate text; - a target-native VALUE (
localizedNamedValue, via the optionalWalkerTarget.renderStringLiteralseam) — for the packs that build props procedurally rather than markup: Feliz (F#prop.ariaLabel …) and Flutter (DartSemantics(label: …)).
Phoenix/HEEx is a third spelling of the SAME rule rather than an
exception: its markup is HTML-ish, but its runtime is gettext, so
localizedHeexAttr emits HEEx's {…} expression form
(aria-label={pgettext("<key>", "<English>")}) — which is why
elixirI18nString escapes {/} in the message. Until it existed
HEEx translated every TEXT slot and no ATTRIBUTE one, so an accessible
name shipped in English at every locale beside a caption that did not.
The rejected alternative is handing the pack the key and letting it
call the runtime: that duplicates the "is this app i18n-enabled at all"
decision into every pack, and one pack forgetting the check emits a
t() into an app with no runtime. Deciding once in the helper keeps the
i18n-OFF path byte-identical by construction, and keeps
.loom/messages.en.json the single catalog for both spellings.
Corollary. A pack that drops a user-visible slot is a bug, not a style choice — the string still reaches the catalog, so a translator translates text the app never renders.
Corollary 2. A slot the helper owns is a slot no pack re-decides.
When Icon's accessible name joined the table, the walker took over the
decorative-vs-named decision as well — a pack now receives a name or
nothing, instead of re-implementing "label: and not decorative:"
from raw args in its own language. Two copies of that predicate (Feliz,
Flutter) and one HTML round-trip disappeared with it.
Affects. i18n.md, accessibility.md; _walker/i18n-emit.ts,
_walker/a11y-emit.ts, _walker/target.ts; every primitive-*.hbs
carrying a user-visible attribute, plus the Feliz/Flutter packs.
D-PACK-CHROME — how a design pack makes its own strings translatable
Status: PINNED.
Decision. A pack DECLARES its chrome in pack.json; the toolchain
derives the key and decides whether i18n is on. The user-visible-string
extraction pass walks the IR, so a word that exists only in a .hbs is
invisible to it and therefore untranslatable. The curated chrome.* table
(D-I18N-ATTR and
the localizedChrome* helpers) covers chrome an EMITTER builds and hands a
template as a ready token; it cannot cover a string the pack invents, because
that would need a curated key in the toolchain, a token in the emitter's view
model and a threading argument at every call site — for a word only that pack
renders. Packs genuinely disagree about those words: two of fifteen spell the
empty picker — select — rather than Select…, one labels a breadcrumb
landmark, two carry a visually-hidden Close.
So pack.json grows a chrome map of role → English, and templates spell
it through helpers the loader binds into every pack.render — {{{chrome}}}
(markup text, with ICU hole values as hash args), {{{chromeAttr}}} (a WHOLE
attribute: a bound attribute changes shape per framework, so the template
cannot spell the name and take only the value), {{{chromeValue}}} (a
target-native value), {{{chromeImport}}} (whole-FILE templates only).
Keys are pack.<family>.<role>.<hash>, consistent with
D-I18N-KEY: the pack. namespace cannot
collide with page.* / component.* / menu.* or chrome.*; <family>
rather than family@version, because two versions that spell a string
identically should share one translation and the content hash separates them
the moment they diverge; and a rephrase re-keys, so ddd i18n sync sees a
delete-old + add-new instead of silently keeping a translation of the old
wording.
This does not weaken D-I18N-ATTR. A pack names a ROLE; it never resolves
a key and never decides whether the app has a translation runtime. Both stay
in the toolchain (packChromeKey, LoadedPack.setChromeI18n), which is what
keeps the i18n-OFF path byte-identical BY CONSTRUCTION — the binding starts
off, each helper returns the bytes the template previously spelled inline,
and a frontend switches it on only for a UI already translatable by its
authored strings.
Rejected: scraping the .hbs for English. It cannot tell Remove from a
TypeScript generic bound, an @doc string or a class name; it gives an author
no opt-out; it produces no ROLE, which is what makes two occurrences of
Close in one pack distinguishable to a translator; and it cannot be
validated — whereas a declaration is rejected at pack LOAD when its message
carries a character significant to the markup it is spliced into unquoted.
Corollary. A declared role no template renders is a dead catalog entry (a
translator translating a string the app never shows) and fails a gate; so does
a template leaving user-visible English in a markup text node or a user-facing
attribute. Both live in test/generator/pack-declared-chrome.test.ts.
Affects. design-packs.md §2 chrome, i18n.md;
_packs/pack-chrome.ts, _packs/loader.ts, _walker/render-primitive.ts,
_frontend/i18n-runtime.ts, system/i18n-catalog.ts; every pack.json.
D-I18N-HEEX-ICU — who formats an interpolated message on Phoenix
Status: PINNED.
Decision. The message stays ICU verbatim; gettext resolves it and
an ICU engine formats the result. Phoenix is the one frontend whose
translation runtime is not Loom's own shim — it uses gettext, which
every Elixir tool speaks. But gettext interpolates %{name}, not ICU
{name}, and has no plural/select arg type, so an interpolated
message has to be reconciled somewhere. Two jobs, split at their natural
seam:
gettext resolves the message → ex_cldr_messages formats the holes
which is exactly the two steps the JS shim already is (messages[key] ?? default, then intl-messageformat). The emitted call nests that way
round — loom_icu(pgettext(<key>, <ICU msgid>), [<holes>]) — so the
holes are formatted in the active locale's translation, which may
reorder them or use plural categories English does not have.
The rejected alternative is rewriting ICU to gettext's %{} grammar at
emit time. It is cheaper, but the Phoenix .po would stop carrying the
same msgid as the other five catalogs, and the ,format set and
plural/select would be lost outright. One messageKey() → one catalog →
one message is the invariant the whole i18n design rests on.
Second-tier gate. The CLDR backend costs real compile time, so it
ships only for a ui with an interpolated message — one tier below
the existing "has any string at all" gate. A translatable-but-literal-only
Phoenix app keeps the deps, files and bytes it had before. The gate reads
the extraction pass's per-entry icu marker, not a {-sniff over the
catalog: merged pack chrome carries holes too (chrome.pageOf is "Page
{page} of {pages}") and HEEx renders none of it through this path.
Documented degradation. ex_cldr_messages implements ICU
MessageFormat but not ICU number skeletons — {total, number, ::currency/USD}, the spelling Loom's own grammar documents — and raises
on a date style unless ex_cldr_dates_times is configured. An
unformattable message degrades to raw hole substitution rather than
crashing the render: the value appears unformatted, which is what the
page showed before it was translatable. Same trade, same reason, as the
Flutter runtime's _substitute fallback — though Flutter no longer
needs it for skeletons: its runtime pre-formats the , number /
, date / , time holes through intl's NumberFormat/DateFormat
before MessageFormat parses the message, and keeps _substitute only
for a message the parser genuinely rejects.
Affects. i18n.md; generator/elixir/i18n.ts,
generator/elixir/heex-walker-core.ts,
generator/elixir/vanilla/shell-emit.ts;
_walker/i18n-extract.ts (MessageEntry.icu).
D-CTX-SHAPE — the ambient RequestContext field set
Status: PINNED. Full shape in
architecture/request-context.md.
Decision. One ambient RequestContext value, read by every
governance feature. Two tiers: request-stable (correlationId,
currentUser, locale, startedAt) set once at the boundary;
frame-local (scopeId, parentId) re-derived per execution-context
scope frame. currentUser (id, tenantId, permissions, dataKey)
is the currentUser magic identifier from authorization.md and
the source of user.tenantId for multi-tenancy. No feature opens its
own parallel ambient channel (ICurrentUserAccessor, getLocale(), a
logging MDC, …) — they all read slices of this one value.
Affects. execution-context.md (defines the frame tier),
multi-tenancy-design-note.md (tenantId), authorization.md
(currentUser/dataKey), sensitivity-and-compliance.md
(declassification clearance), i18n.md (locale),
audit-and-logging.md (actor + correlation), observability.md
(correlation). PlatformSurface lifecycle hooks receive its accessor.
D-ENVELOPE — the wire envelope rule
Status: PINNED. Full rules in
architecture/wire-envelope.md.
Decision. Every HTTP response is exactly one of four shapes — bare
value (single entity/payload/primitive), Paged<T> (lists),
ProblemDetails (RFC 7807, all errors), event-frame ({ kind, occurredAt, correlationId, data }). The HTTP status code is the
success/error discriminator; the success path is never wrapped in
a { kind: "ok", value } envelope (D16) — the payload kind
discriminator (D2/D3) lives inside tagged-union bodies, not at the
envelope level. A uniform { ok, value | error } envelope is rejected
(forces every client to unwrap; duplicates the status line).
Affects. payload-transport-layer.md, exception-less.md (D16,
D17, D18), pagination-design-note.md (Paged<T>/unpaged),
workflow-and-applier.md (event-frame); every backend's DTO/route
emitter; the conformance-parity.yml OpenAPI gate.
D-URLSTYLE — lifecycle URL style on the api body + per-action routeSlug
Status: PINNED. Full design in
proposals/lifecycle-url-style.md;
amends lifecycle-operations.md Phase 2, whose grammar sketch assumed a
fictional per-aggregate api … for <Aggregate> { urlStyle } form.
Problem. lifecycle-operations.md Phase 2 specifies urlStyle on a
per-aggregate api with a body. The real grammar is
api <Name> from <Subdomain> — per-subdomain, body-less. The proposal's
text can't parse against the shipped grammar, so Phase 2 is designed
against the real model here.
Decision.
urlStylelives on theapi, as an optional body —api SalesApi from Sales { urlStyle: literal | resource }, defaultliteral(D-LIFECYCLE-VERB). On the api (the shared contract), not the deployable (URL shape isn't per-process) nor a system default (too coarse; a system-default-with-override is a deferred v2 nicety). Grammar uses a direct optional property, not a members list, until a second api-body clause actually lands.urlStyle/resourceare soft-admitted inLooseName/NameRefIdent(thedataSource/moneyprecedent).routeSlugis a per-actionOperationIRfield, derived in enrichment — not the proposal's separateagg.lifecycleshape (the Phase-1creates/destroysarrays already partition by kind). Derivation:canonical → undefined(bare collection / canonical-id URL);urlStyle: literal → name;urlStyle: resource → plural(name). The HTTP verb + path skeleton stays Phase-3 emitter logic keyed onkind+canonical+routeSlug.- The surfacing api is resolved by subdomain. Aggregate → one
context → one subdomain → the api
fromit. Enrichment threads the subdomain's style intoenrichAggregate. Top-level contexts (no api) default toliteral. If two apis surface one subdomain with differingurlStyle, the first declared wins and the validator warns (loom.subdomain-conflicting-urlstyle).
Consequences (not separate decisions).
- No generated-output change in Phase 2 — no backend reads
routeSlugyet (emitters still build slugs inline assnake(name)), so fixtures stay byte-identical. The re-baseline lands in Phase 3, when emitters consumerouteSlugandresource-style URLs change (a coordinatedrebaseline-Lifecyclemoment). - The verb-name warning (
loom.url-style-naming-warn,cancel → /cancels) is deferred — reliable verb detection needs a lexicon; low value vs false-positive cost.
Affects. lifecycle-operations.md Phase 2 + integration-seams
sections (superseded by lifecycle-url-style.md); ApiIR.urlStyle,
OperationIR.routeSlug; the enrichment pass; depends on D-LIFECYCLE-VERB.
D-PHOENIX-SURFACE — the decomposed Phoenix platform surface
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.
Status: PINNED — amended by D-ELIXIR-PLATFORM (the canonical platform
name renamed phoenix → elixir). The decomposition conclusions of this
decision (one platform for the language ecosystem; UI framework axis on ui;
default domain Ash; no family@version; no apiOnly) all stand; only the
spelling of the canonical platform name changes. (Reconciles two proposals
that, taken individually, collide. Subsumes the D-PHOENIX-ECTO ask from
elixir-ecto-and-api-only-backends.md.)
Amendment (aliases retired). Both the phoenix / phoenixLiveView
platform aliases (D-ELIXIR-PLATFORM amendment) and the liveview framework
alias introduced by this decision have since been removed. framework: phoenixLiveView is the only framework spelling — the bare liveview keyword is
gone from the grammar Framework rule, and the canonicalFramework desugar
(both the lowering-side and validator-side copies) is deleted. platform: elixir + framework: phoenixLiveView are the only spellings.
Problem. Two proposals each free a different axis off the single
phoenixLiveView keyword, and their individually-recommended fixes
conflict:
elixir-ecto-and-api-only-backends.mdfrees the domain axis (Ash vs Ecto) and its Option B recommends carrying that axis in the platform name —phoenixLiveView= Ash, a newphoenix= Ecto.embedded-frontend-composition.mdfrees the hosted-UI-framework axis (LiveView vs embedded React) and recommends retiringphoenixLiveViewentirely — onephoenixplatform, withliveviewdemoted to aframework:value onui.
Composed, they collide: Option B uses the name to encode the domain axis,
while the framework note frees a different axis off that same name and
deletes it. You cannot do both. phoenixLiveView froze two axes plus the
host into one token; the fix must free both axes the same way — neither
should be re-frozen into a platform name.
Decision. One backend platform, phoenix, with both frozen axes
expressed as orthogonal config — not as platform names:
| Concern | Where it lives | Values |
|---|---|---|
| Host runtime / web framework | platform: phoenix |
the Phoenix/BEAM runtime (needsDb: true, apiBasePath: "/api", serves priv/static) |
| Domain / persistence framework | the existing D-ADAPTER-HOME style:/persistence: adapter menu off the backend's PlatformSurface — not a new keyword |
ash | ecto |
| Hosted UI framework | ui { framework: … } + deployable { hosts: } |
liveview (Phoenix-only) | react/… (any static host) |
This makes phoenix.hostableFrameworks = {liveview} ∪ {react, …} — the
richest of any platform — a derived consequence of Phoenix being the only
platform that is both a render runtime and a static-asset host, not a
special case. It is a refinement of D-PHOENIX-ECTO Option B: keep Option
B's "no family@version, no apiOnly platform" conclusions, but reject its
"domain axis = platform name" mechanism (which the framework note shows
double-books the name).
The domain axis is universal, not Phoenix-special. Every backend freezes
a domain/persistence framework — hono→Drizzle, dotnet→EF+Mediator,
phoenix→Ash|Ecto (docs/generators.md:27). Ash-vs-Ecto is the same axis as
Drizzle-vs-Prisma or EF-vs-Dapper; Phoenix only looks special because it is the
first backend whose menu has size > 1, so it is the first where the modifier
is ever written. Therefore:
- No
domain:keyword, and nothing Phoenix-only. The axis is the already-PINNED D-ADAPTER-HOMEstyle:/persistence:adapter surface: each backend exposes its menu + default off itsPlatformSurface;phoenix's menu is{ash, ecto}(defaultash),hono's is{drizzle},dotnet's is{ef}. A size-1 menu means the author never writes the modifier — which is whyplatform: honolooks "domain-free" today. Adding Ecto is populating Phoenix's menu, not minting a platform mechanism. - No second
framework:on the backend.frameworkis now the UI axis (ui { framework: react | liveview }); reusing it backend-side would collide. A backend's web framework simply is itsplatform:(hono/dotnet/phoenix). So a backend has exactly two axes, both already named:platform:(runtime/web framework) andstyle:/persistence:(domain framework) — and the Ecto note's own Option A already spells Ash/Ecto in exactly that surface (persistence: { ectoPostgres },style: { ecto }).
Option A (adapter swap) is therefore not a later factoring for this axis — it is the axis. This decision pins the domain axis onto the D-ADAPTER-HOME surface now (the menu/default fields); only the Ash-emit extraction behind the adapter contract remains as the implementation tail the Ecto note phases.
Consequences.
phoenixLiveViewis retired as a platform token; a desugar shim maps it toplatform: phoenix+ theashadapter (default) + the referenceduigainingframework: liveview(mirrors theplatform: react→ vite-host shim).- API-only stays resolved by D-API-ONLY (absence of a
ui/hosts:mount) — unchanged; it is neither a name nor a flag. - Phoenix-embeds-React (bundle →
priv/static, same-origin/api) becomes expressible for free — thewwwroottwin of dotnet. - The four Phoenix shapes {Ash,Ecto} × {LiveView, embedded-React} + {API-only} are spanned by two orthogonal axes (one of them a pre-existing adapter menu), not 5+ platform names.
Open within this decision.
Adapter-surface spelling —RESOLVED by D-REALIZATION-AXES: neither — the domain-framework axis gets a dedicatedstyle:vspersistence:vs both for the Ash/Ecto choice.foundation:keyword (defaultvanilla), andpersistence:narrows to the data-access library only.- Default domain when unspecified on
platform: phoenix—ash(matches today'sphoenixLiveViewbehaviour after desugar). PINNED — now expressed asfoundation: ash(the default) per D-REALIZATION-AXES.
Affects. embedded-frontend-composition.md §6 (its "retire
phoenixLiveView" is this decision's framework half); elixir-ecto-and-api-only-backends.md
§4 + §6 (supersedes its D-PHOENIX-ECTO Option-B "sibling platform name" with
"one phoenix platform + the domain axis on the D-ADAPTER-HOME surface"); the
Platform grammar enum + Framework enum; src/platform/registry.ts;
checkDeployable; depends on D-ADAPTER-HOME (the domain axis is that
surface's menu/default).
Amended by D-REALIZATION-AXES. Its mechanism for the domain axis — "rides
the style:/persistence: surface, no new keyword" — is superseded: the axis
gets the dedicated keyword foundation: so the data layer stays pickable under a
framework (foundation: ash + persistence: ashSqlite). Every other
conclusion of this decision stands (one phoenix platform, no family@version,
no apiOnly, framework: is UI-only, default domain = Ash).
D-REALIZATION-AXES — the deployable platform-config axes (and the foundation: amendment)
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.Superseded (2026): the realization block was pruned to two axes. The bundle-decomposition instinct below stands, but four of the six axes were inert or theater and were removed, leaving only the two with real per-backend choice —
persistenceanddirectoryLayout. Removed:foundation:(single valuevanillaeverywhere — grammar clause,DeployableIR.foundation, the R4/R6 rules, thefoundation-default-flippingwarning all deleted);application:/style (each backend has one fixed emission style —cqrson dotnet,layeredelsewhere — kept as an internalStyleAdapter, no longer a user knob); andtransport:/runtime:(name-onlyTransportAdapter/RuntimeAdapterregistries that no emitter ever read — pure validation theater, deleted whole). Every reserved stub adapter (marten/jooq/axon/minimalApi/orleans/genserver/worker/thecqrs/flat/layeredstyle stubs) was removed too — a stub that throws onemit*is speculative-generality debt, not a shipped capability. Allfoundation:/application:/transport:/runtime:reasoning below (the amendment, ownership rules, rung table, gating matrix, the R2–R7 rules) is historical.
Status: PINNED. (Amends D-PHOENIX-SURFACE's domain-axis mechanism;
depends on D-ADAPTER-HOME and D-STORAGE-SPLIT. Full matrix, gating rules,
and examples in proposals/platform-realization-axes.md.)
Problem. platform: dotnet bundles EF Core + MediatR-CQRS + minimal API with
no exposed knobs (storage-and-platform-config.md:58). The decomposition into
platform: <name> { … } config needs (a) a fixed, reviewed vocabulary of
axes, and (b) a resolution of D-PHOENIX-SURFACE open-item 1 (where the Ash/Ecto
domain-framework axis lives). The storage doc's style:/layout: names are
weak/colliding, and folding the domain framework into persistence: destroys the
ability to pick the data layer underneath a framework.
Decision. A backend deployable's realization is six orthogonal, optional,
validator-gated axes, each named for the layer it realizes. Each is a
menu+default exposed off the backend's PlatformSurface (D-ADAPTER-HOME); a bare
platform: <name> equals the full default block (byte-identical to today).
| Axis | Realizes | Values (dotnet shown; menu is per-platform) | Default |
|---|---|---|---|
foundation: |
opinionated domain/app framework, or none | vanilla · abp (phoenix/elixir: vanilla only — ash removed; node: vanilla · nestjs) |
vanilla (phoenix/elixir: vanilla) |
application: |
application-layer orchestration topology | flat · serviceLayer · cqrs |
cqrs |
persistence: |
data-access library only | efcore · dapper · marten |
efcore |
directoryLayout: |
source-tree organization | byLayer · byFeature |
byLayer |
transport: |
HTTP surface | controllers · minimalApi |
controllers (the attribute-routed [ApiController] surface the backend has always emitted; the labels were historically inverted and swapped 2026-06-10 — minimalApi is the reserved/unbuilt alternative) |
runtime: |
aggregate execution / concurrency model | transactional · orleans · akka (phoenix: transactional · genserver) |
transactional |
Rulings folded in:
foundation:is its own keyword — this is the amendment to D-PHOENIX-SURFACE. The Ash/Ecto (and EF/ABP, Drizzle/NestJS) domain-framework axis is not apersistence:/style:value; it isfoundation:(defaultvanilla). This keeps the data layer pickable under a framework (foundation: ash+persistence: ashSqlite;foundation: abp+persistence: dapper). It preserves D-PHOENIX-SURFACE's correct nodomain:keyword instinct —foundation:names the framework hosting the domain, not the domain (which is the.dddsource).persistence:narrows to the data-access library only (no domain framework). Prefer it over "dal" (dated acronym).style:→application:(layer-named; notlayering:, which collides with its own value and miscasts CQRS). Values are a topology spectrumflat→serviceLayer→cqrs.flat(nottransactionScript— that is an orthogonal logic-organization pattern spanning all three values; Loom is domain-model by construction so that axis is pinned and unexposed).layout:→directoryLayout:(explicit; disambiguates from the page-levellayout:wrapper — a real collision).runtime:default istransactional(names the DB-transaction consistency model; the contrast to actor-mailbox serialization), not the earlier coinagepooled.- Foundation owns layers. Each
foundation:value declares which of{application, transport, persistence-flavor}it owns; setting an owned knob is an error.vanillaowns nothing. - Actor runtimes need a durable store, not necessarily a journal — event
sourcing is idiomatic for persistent actors, not required (Akka.NET + EF via a
DbContextFactoryis a valid, non-standard state-stored mode).flat× actor runtime is a warning, not an error.
All axes are optional; lowering normalizes each omitted knob to its platform
default, so the IR carries concrete values and ddd snapshot round-trips the
normalized form (mirrors design: via BUILTIN_PACK_LATEST).
Affects. proposals/platform-realization-axes.md (the full spec);
proposals/storage-and-platform-config.md (its style:/layout: names and the
"platform: dotnet defaults" row are renamed per this decision);
proposals/elixir-ecto-and-api-only-backends.md (its Phase-2 "style: vs
persistence: field" question is answered: foundation:); the Platform /
deployable grammar; DeployableIR; checkDeployable; each backend's
PlatformSurface (menu+default fields). Amends D-PHOENIX-SURFACE open-item 1;
depends on D-ADAPTER-HOME.
D-VANILLA-PHOENIX-FOUNDATION — foundation: vanilla is added to the Elixir foundation menu
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.
Status: PINNED — amended by D-ELIXIR-PLATFORM (the foundation menu lives
on platform: elixir after the rename; "Phoenix menu" wording below refers to
the same surface). The decision's substance — foundation: vanilla as a
first-class second adapter on the Elixir backend, foundation: ash remaining
the default, the validator R5 gate — is unchanged. (Concretises one menu slot of
D-REALIZATION-AXES; spec in proposals/vanilla-phoenix-foundation.md.)
Problem. D-REALIZATION-AXES pinned the foundation: axis with phoenix: ash · vanilla as the menu, but the validator (src/language/validators/data/platform-rules.ts:184)
currently returns the single-element ["ash"] and the lowerer
(src/ir/lower/lower-platform.ts:46) defaults to ash — vanilla is a planned
value with no emitter behind it. Two operationally distinct strains on
foundation: ash motivate building the second emitter:
- Exception-less alignment. A4 of
proposals/exception-less.mddeletes route-layer try/catch towers on every backend in favour of variant dispatch on typedor-union returns. The current Phoenix emitter has the tower as a design feature (Plug.ErrorHandlertranslatesAsh.Error.Invalid→ 422,Ash.Error.Forbidden→ 403,Ash.Error.Query.NotFound→ 404). Vanilla Ecto's{:ok, _} | {:error, changeset}is the natural typed-error carrier; the tower collapses into per-variantwith-block dispatch (the same shape TS/.NET adopt post-A4). - Pure event sourcing. The cross-backend pure-ES contract (no state
table, per-aggregate
<agg>_eventsstream, fold-on-load, emit-and-apply command bodies) is live on Hono/Drizzle, Hono/MikroORM, .NET/EF, and .NET/Dapper. Underfoundation: ashthere is no clean fit — AshEvents is hybrid (state table, single centralised event log, action-wrapping); AshCommanded is closer but heavy; a customAsh.DataLayerover event streams is months of work (re-implements AshCommanded's internals).
Both strains are Ash-foundation limitations, not Phoenix-platform
limitations — Phoenix itself (Plug + Endpoint + Router + LiveView) is
domain-layer-agnostic; what doesn't fit is Ash.Resource's changeset-shaped
action model + Ash.DataLayer's queryable-store callback contract.
Decision. foundation: vanilla is added to the Phoenix menu as a
first-class second adapter. Emits plain Phoenix.Endpoint + Phoenix.Router +
LiveView over plain Ecto.Schema / Ecto.Changeset / Ecto.Repo — no
Ash.Resource, no AshPostgres, no AshPhoenix.Form. The existing
foundation: ash path is unchanged and remains first-class; neither is
deprecated.
Affects. src/language/validators/data/platform-rules.ts:184 (lift the
single-element menu); src/ir/lower/lower-platform.ts:46 (keep ash default —
see D-VANILLA-DEFAULT); a new src/generator/phoenix-live-view/vanilla/
subtree (sibling emitters: schema-emit, changeset-emit, policy-emit,
context-emit, repository-emit, vanilla/api-emit,
vanilla/problem-details-emit); a new ecto-postgres-persistence adapter; the
strict-parity conformance gate. Depends on D-REALIZATION-AXES;
enables D-VANILLA-ES-HOME.
D-VANILLA-ES-HOME — pure event sourcing on Elixir lands only under foundation: vanilla
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.
Status: PINNED — amended by D-ELIXIR-PLATFORM (the gap lives on
platform: elixir after the rename; the Ash-foundation-vs-Phoenix-platform
distinction in this decision's body is unchanged — the constraint is the
Ash foundation, on the Elixir platform). (Resolves the
EVENT_SOURCING_BACKENDS Elixir gap left open in
proposals/workflow-and-applier.md; depends on
D-VANILLA-PHOENIX-FOUNDATION.)
Problem. validateEventSourcedStorage (src/ir/validate/checks/system-checks.ts)
rejects persistedAs: eventLog aggregates on Phoenix today;
ash-postgres-persistence.ts:60 advertises supportedStrategies: ["state"]
only. Once vanilla exists, the gate can lift on a foundation-sensitive basis —
but the question of whether to also pursue Ash-foundation ES (via AshEvents
adoption, AshCommanded adoption, or a custom Ash.DataLayer) is independent
and material.
Decision. Pure event sourcing on Phoenix lands only under foundation: vanilla. foundation: ash + persistedAs: eventLog stays a hard error after
vanilla ships, with a structured diagnostic naming the Ash foundation as the
constraint and pointing the user at foundation: vanilla or a non-Phoenix
backend. The following alternatives are explicitly not pursued:
- AshEvents adoption. Its hybrid model (state table + centralised event log + action-wrapping + manual whole-resource replay) diverges from Loom's pure per-aggregate-stream / fold-on-load contract. Reconciling would force either a divergent Phoenix semantics or a fight-the-grain projection.
- AshCommanded adoption. Closest match semantically (Commanded aggregates
- apply/2 + per-aggregate streams) but ships heavy infrastructure (Commanded + EventStore Postgres) for a single foundation, when the vanilla path achieves the same contract zero-dep.
- Custom
Ash.DataLayerover event streams. Re-implements AshCommanded internals; ~months of work; half-bridges leak (AshPhoenix.Form,AshGraphql,AshJsonApi, relationships all assume the data layer answers queries about current state).
Rationale: the cross-backend pure-ES contract is live on four paths already (Hono/Drizzle, Hono/MikroORM, .NET/EF, .NET/Dapper); vanilla joins them as a fifth port of a proven shape (~2–4 days of work). The Ash paths each carry multi-week-to-month costs to land partial fits. Routing ES through vanilla costs nothing additional once vanilla exists.
Affects. src/ir/validate/checks/system-checks.ts
(EVENT_SOURCING_BACKENDS un-gate gains a foundation predicate — phoenix +
foundation: vanilla only); test/ir/eventsourced-storage-support.test.ts
(extend the matrix); the structured diagnostic from
proposals/vanilla-phoenix-foundation.md P0; MigrationsIR consumers (the
shared <agg>_events shape extends to the Ecto migrations renderer in P4).
Depends on D-VANILLA-PHOENIX-FOUNDATION.
D-PHOENIX-FOUNDATION-ROUTING — Phoenix feature parity is reached by routing to vanilla, not by investing in Ash
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.
Status: PINNED — ratified 2026-06 (backend feature-parity plan, W4). Generalises D-VANILLA-ES-HOME from event sourcing to every feature with no idiomatic Ash fit; depends on D-VANILLA-PHOENIX-FOUNDATION.
Problem. Several features (event-sourced storage, event-sourced workflows,
provenanced fields, full shape: document ops, emit/add/remove-bodied
or-union-returning ops) emit cleanly on foundation: vanilla but have no
idiomatic Ash fit. The recurring question is whether to close the
Phoenix-side gap by routing those contexts to vanilla (treating the ash
gates as the deliberate final answer) or by investing in an Ash-idiomatic
emission (AshEvents/AshCommanded for ES, an Ash :map document, an Ash
provenance extension, a custom Ash.DataLayer).
Decision. Route, don't invest. "Full parity" on Phoenix is reached by
foundation: vanilla for these features; foundation: ash keeps each one a
fail-fast validator error that names the constraint and points at
foundation: vanilla (never a silent downgrade). The Ash-side build-out is
explicitly out of scope — the cost is multi-week-to-month for partial
fits (see the "explicitly not pursued" list in D-VANILLA-ES-HOME), against a
zero-additional-cost vanilla port of a proven cross-backend shape. This is the
canonical case that "parity" means emitted or fail-fast-gated, not "every
backend emits every feature" (plans/backend-parity-plan.md).
Already in place (no new compiler work). Every routed feature already
emits on vanilla and gates on ash today: ES storage/workflows
(EVENT_SOURCING_BACKENDS + foundation predicate), provenance
(PROVENANCE_BACKENDS + validateProvenancedStorage), document-CRUD
(loom.vanilla-document-unsupported), returning-op bodies
(loom.operation-return-unsupported). Each is compiled against real
Elixir/Ecto by elixir-vanilla-build.yml. W4 is therefore a documentation +
ratification workstream, not an emission one.
Affects. docs/platforms.md (the Phoenix foundations routing table);
docs/generators.md (the five-backend matrix's elixir·ash / elixir·vanilla
columns); the loom.* gate diagnostics already cited above (unchanged — this
decision ratifies their finality).
D-NO-MIXED-FOUNDATION — one foundation per deployable; per-aggregate override not added
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.
Status: PINNED — amended by D-ELIXIR-PLATFORM (substance unchanged;
platform: phoenix references in the body now read platform: elixir
post-rename). (Structural consequence of D-REALIZATION-AXES + the
deployable model; recorded explicitly to forestall the per-aggregate override
extension request.)
Problem. A natural-sounding feature request is "let me keep Ash resources
for state-based aggregates and use vanilla emit for the ES aggregate in the
same deployable." The motivation is real (some domains genuinely mix
state-based-CRUD aggregates with one or two ES aggregates), but the
implementation cost compounds at every call site: workflow bodies branch on
per-aggregate strategy, forms split between AshPhoenix.Form and stock
to_form(changeset), authorization splits between Ash policies and plain
guard functions, telemetry emission splits between Ash trace events and
hand-emitted equivalents.
Decision. A single deployable carries one foundation value. Per-aggregate
foundation override is not added.
Crucially: this is a structural consequence, not an additional policy.
Under D-REALIZATION-AXES, foundation: is a per-deployable axis on the
deployable's platform: config block (each deployable declares one platform
with one foundation). The per-deployable scope of the axis already makes
mixed foundation within a deployable inexpressible in the grammar — this
decision merely confirms the architecture won't grow a per-aggregate escape
hatch, with the rationale that the plumbing cost (above) outweighs the
mixed-foundation use case.
Users who need both Ash-resource and pure-ES aggregates have two principled paths:
- Split bounded contexts across deployables. State-based contexts deploy
under
foundation: ash; ES contexts deploy underfoundation: vanilla. Each deployable is internally coherent; cross-deployable consumption uses the api surface as on any cross-deployable boundary. - Pick
foundation: vanillafor the whole deployable. State-based aggregates emit as plain Ecto schemas (no loss vs Ash for simple CRUD); ES aggregates emit as fold+repository. Single mental model.
Affects. proposals/vanilla-phoenix-foundation.md (the "Mixed-mode within a
context" section is the conversational origin of this decision). No grammar
change required (the constraint is already structural); no validator rule
needed beyond today's per-deployable foundation: typing.
D-VANILLA-DEFAULT — vanilla becomes Elixir default after stabilisation, not on first ship
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.
Status: PINNED — amended by D-ELIXIR-PLATFORM (the default-flip target
is platform: elixir after the rename; sequencing and rationale unchanged).
(Sequences the default-flip of D-VANILLA-PHOENIX-FOUNDATION; deferred to a
later release than vanilla's initial ship.)
Problem. Today's Phoenix default is foundation: ash
(lower-platform.ts:46). Once vanilla ships and exception-less A4 is in
place, vanilla is objectively the lower-friction default — no
Plug.ErrorHandler rescue tower, direct typed-error mapping, broader contract
coverage (ES + state). But flipping the default in the same release that
introduces vanilla risks two things: (a) silent emit-shape change for every
bare-platform: phoenix deployable that hasn't been re-validated against
vanilla; (b) regression discovery happening in production before vanilla has
seen real usage.
Decision. Vanilla ships opt-in only initially. The default stays
foundation: ash for at least one minor release after vanilla lands. The flip
is gated on two operational signals:
- Vanilla has run at least one minor-release cycle with green CI —
phoenix-vanilla-build.yml(mix compile --warnings-as-errors) and the strict-parity matrix entry pass cleanly without ongoing-fix churn. - No obs-e2e regressions observed on
phoenix-obs-e2e.yml(vanilla variant) for the same cycle.
When both signals are clear, flip in two steps:
- Warn-then-flip release. Emit a
loom.foundation-default-flippingwarning on every bareplatform: phoenix(no explicitfoundation:) for one release cycle, telling the user to set explicitashif they want to stay on the current behaviour. - Flip release. Change
lower-platform.ts:46's default fromashtovanilla. Users who didn't act on the warning get vanilla emit; the one-line escape hatch (foundation: ash) remains supported and first-class.
Rationale: zero-surprise migration; opt-in window gives real users time to validate vanilla before it becomes the default emission; the warning cycle makes the change visible without breaking anyone's build.
Affects. src/ir/lower/lower-platform.ts:46 (default flip, gated on
release sequencing); src/language/validators/data/platform-rules.ts
(loom.foundation-default-flipping warning during the warn cycle); release
notes for the two flip-related releases. Depends on
D-VANILLA-PHOENIX-FOUNDATION.
D-NODE-PLATFORM — node is the JS-runtime platform; hono is a transport: value
Status: PINNED — amended by D-ELIXIR-PLATFORM (this decision's body
asserts "dotnet/phoenix name the language-ecosystem" — a rationalisation,
since phoenix is a web framework, not a language-ecosystem; the actual
ecosystem is Elixir. D-ELIXIR-PLATFORM completes the rename pattern by
making elixir the canonical language-ecosystem platform and phoenix a
back-compat alias. The framing in the original problem statement should now
be read as "dotnet/elixir name the language-ecosystem".) (Mirrors
D-PHOENIX-SURFACE's rename pattern; depends on D-REALIZATION-AXES for
the transport: axis. Rollout in proposals/realization-axes-rollout.md
Phase 3.)
Amendment (alias retired). The hono → node back-compat alias described
below has since been removed — node is now the only spelling. The
hono keyword is gone from the grammar Platform rule, LEGACY_PLATFORM_ALIASES
(src/platform/metadata.ts) and canonicalPlatform (src/ir/lower/lower-platform.ts)
no longer map it, and platform: hono / platform: "hono@v4" now fail validation
as unknown platforms. All in-tree sources, fixtures and docs were migrated to
platform: node. The Hono web framework keeps its name only as the transport:
value on platform: node (and as the src/platform/hono/ / @loom/backend-hono-v4
package directory). The historical body below is preserved as the original
rationale for the rename. (2026-10: node@v4 itself was later retired, see
#3687 — @loom/backend-hono-v5 is now the only Hono package.)
Problem. platform: hono conflates the JS runtime (Node) with the web
framework (Hono) in one token — the same conflation just resolved for
phoenixLiveView. dotnet / phoenix name the language-ecosystem; hono names
only one of several interchangeable TS web frameworks (Hono / Express /
Fastify / Elysia). The codebase already splits these: language codegen lives in
src/generator/typescript/, while src/platform/hono/ is the Hono web-framework
backend.
Decision.
nodeis the canonical JS-runtime platform (language TypeScript, derived — not a name prefix; cf.dotnetis notcsharp-dotnet). Legacyplatform: honois admitted as a back-compat alias that desugars toplatform: node { transport: hono }(same mechanism asphoenixLiveView→phoenix).- The web framework is the
transport:axis (D-REALIZATION-AXES).node's menu:hono* ·express·fastify·elysia; defaulthono. (dotnettransport staysminimalApi*·controllers;phoenixphoenixRouter*.) - NestJS is a
foundation:value (rung-3): it owns application + transport and runs on an underlying http adapter, sofoundation: nestjslockstransport:via R4 — identical shape tofoundation: abpon dotnet. languageis a derived surface property (typescriptfornode/react,csharpfordotnet,elixirforphoenix), consumed by the Phase-F shared-contracts grouping — not a platform-name prefix.- Future JS runtimes (
bun/deno/edge) are siblingplatform:values (distinct stdlib/deploy), all TypeScript — not atypescript-Xprefix and not a new sub-axis.
Affects. src/platform/registry.ts (add node, alias hono→node); the
Platform IR union + grammar Platform rule (add node, keep hono as
back-compat keyword); the transport: menu (hono becomes its default value);
src/platform/hono/ (reframed as node's Hono transport); the derived language
property on PlatformSurface. Depends on D-REALIZATION-AXES; mirrors
D-PHOENIX-SURFACE.
D-ELIXIR-PLATFORM — elixir is the canonical language-ecosystem platform; phoenix is a back-compat alias
Status: PINNED. (Mirrors D-NODE-PLATFORM's rename pattern; amends
D-PHOENIX-SURFACE's platform-name choice; depends on D-REALIZATION-AXES
for the transport: axis. Spec in proposals/elixir-platform-rename.md.)
Amendment (alias retired). The phoenix / phoenixLiveView → elixir
back-compat platform aliases described below have since been removed —
elixir is now the only spelling, exactly mirroring the retired hono →
node alias (D-NODE-PLATFORM). The phoenix and phoenixLiveView keywords
are gone from the grammar Platform rule, LEGACY_PLATFORM_ALIASES
(src/platform/metadata.ts) and canonicalPlatform
(src/ir/lower/lower-platform.ts) no longer map them, and platform: phoenix
/ platform: "phoenixLiveView" now fail validation as unknown platforms. All
in-tree sources, fixtures and tests were migrated to platform: elixir. The
Phoenix web framework keeps its name only as the transport: phoenix value
(D-PHOENIX-TRANSPORT) and the phoenixLiveView framework value (with its
liveview alias, D-PHOENIX-SURFACE — not retired); the ashPhoenix design
pack and the generated-project code likewise keep the framework name. The
historical body below is preserved as the original rationale for the rename.
Problem. D-NODE-PLATFORM (the later decision) renamed platform: hono →
platform: node on the principle that platform names the language-ecosystem,
transport names the web framework. Its own text justifies itself by asserting
"dotnet/phoenix name the language-ecosystem" (decisions.md:1072) — a
rationalisation, since phoenix is a web framework, not a language-ecosystem.
The actual ecosystem is Elixir.
The result was visible repetition: platform: phoenix, transport: phoenixRouter
reads as "Phoenix Phoenix" — two restatements of the same framework. And the
generator scaffolding around the platform name (src/generator/phoenix-live-view/
the directory, src/platform/phoenix-live-view.ts the module, phoenix-build.yml
the CI workflow) carried the legacy phoenixLiveView-era spelling that
D-PHOENIX-SURFACE retired at the platform name level but never followed
through at the scaffolding level.
Decision.
elixiris the canonical language-ecosystem platform. The platform surface that emits the fullstack Elixir/Ash + Phoenix LiveView project registers underelixirinsrc/platform/registry.ts.phoenixandphoenixLiveVieware back-compat aliases that desugar toelixirat the lowering boundary (canonicalPlatform/LEGACY_PLATFORM_ALIASES), preserving any@versionpin (phoenix@v1→elixir@v1;phoenixLiveView@v1→elixir@v1). Every existing.dddsource continues to parse, validate, lower, and emit byte-identical output. Identical mechanism tohono→node(D-NODE-PLATFORM).languageis a derived surface property (elixirforelixir,typescriptfornode/react,csharpfordotnet), consumed by the Phase-F shared-contracts grouping — not a platform-name prefix. Matches D-NODE-PLATFORM's framing now thatphoenixis no longer the platform.
Affects. src/platform/registry.ts (register under elixir; add phoenix
and phoenixLiveView to LEGACY_PLATFORM_ALIASES); the Platform IR union
("phoenix" → "elixir"); grammar Platform rule (add elixir keyword);
src/ir/lower/lower-platform.ts (canonicalPlatform adds the phoenix
arm); every family === "phoenix" check across the toolchain (renamed to
"elixir"); CLI --platform (canonical elixir, phoenix accepted as
alias); the seven affected decisions get an amend-by-this-one note.
Mirrors D-NODE-PLATFORM; amends D-PHOENIX-SURFACE.
D-PHOENIX-TRANSPORT — Phoenix is the transport: value on platform: elixir; phoenixRouter is a back-compat alias
Status: PINNED. (Depends on D-ELIXIR-PLATFORM and D-REALIZATION-AXES;
spec in proposals/elixir-platform-rename.md.)
Amendment (alias retired). The phoenixRouter → phoenix back-compat
transport alias described below has since been removed — phoenix is the
only transport: value now. The canonicalTransport desugar helper (which was
already dead code — nothing called it) is gone, and transport: phoenixRouter
no longer resolves to any adapter (it fails the realization-axes menu check
like any unknown transport). Mirrors the retired platform aliases
(hono/phoenix/fastapi) and the liveview framework alias.
Problem. The transport value phoenixRouter carried a redundant Router
suffix that named no real distinction (Phoenix has one router; there's no
"Phoenix-but-not-the-router" alternative). Under foundation: ash the
transport axis is owned (FOUNDATION_OWNED_AXES.ash = ["application", "transport"]), so users never even wrote it. It existed only as a default
value in the IR — repeating the framework name with no information added.
Decision.
phoenixis the canonicaltransport:value onplatform: elixir(D-ELIXIR-PLATFORM). Names the Phoenix web framework, parallel to howtransport: hononames the Hono web framework onplatform: node.phoenixRouteris a back-compat alias that desugars tophoenixat the lowering boundary (canonicalTransportinsrc/ir/lower/lower-platform.ts). Every existing source with explicittransport: phoenixRouterkeeps working unchanged.- Future Elixir web frameworks (a Plug-only minimal API, hypothetical
alternatives) slot into
transport:as siblings; the menu grows from size-1 to size-N without a platform-level change.
Affects. src/ir/lower/lower-platform.ts (new canonicalTransport +
greenfieldMenu returns "phoenix" for elixir); src/language/validators/data/platform-rules.ts
(transport menu update); test expectations across the lowering / axes test
files; proposals/elixir-platform-rename.md. Depends on
D-ELIXIR-PLATFORM.
D-PHOENIX-DIR — generator directory + platform module + CI workflow renames
Status: PINNED. (Mechanical completion of the D-ELIXIR-PLATFORM rename;
spec in proposals/elixir-platform-rename.md.)
Problem. When D-PHOENIX-SURFACE renamed platform: phoenixLiveView →
platform: phoenix, the legacy directory src/generator/phoenix-live-view/,
the platform module src/platform/phoenix-live-view.ts, and the CI workflows
phoenix-*.yml were not renamed alongside. After D-ELIXIR-PLATFORM the
debt compounds: the LiveView part of the name is no longer accurate (the
emitter outputs the whole Phoenix project — API controllers, OpenAPI,
shell scaffolding — most of which has nothing to do with LiveView), and
the phoenix part needs to align with the new elixir platform name.
Decision. Three coordinated renames, no back-compat alias at the directory / module / workflow level (callers reach these through the registered platform surface and the CI matrix, not directly):
| Today | After |
|---|---|
src/generator/phoenix-live-view/ |
src/generator/elixir/ |
src/platform/phoenix-live-view.ts |
src/platform/elixir.ts |
generatePhoenixLiveViewProject |
generateElixirProject |
GeneratePhoenixLiveViewArgs |
GenerateElixirArgs |
phoenixPlatform (registry alias) |
elixirPlatform |
.github/workflows/phoenix-build.yml |
.github/workflows/elixir-ash-build.yml |
.github/workflows/phoenix-dialyzer.yml |
.github/workflows/elixir-ash-dialyzer.yml |
.github/workflows/phoenix-obs-e2e.yml |
.github/workflows/elixir-ash-obs-e2e.yml |
The CI rename uses the elixir-ash- prefix (foundation in the name) so a
future elixir-vanilla-build.yml pairs cleanly when P2 of
vanilla-phoenix-foundation.md ships.
Affects. ~80 import paths across src/ and test/ (mechanical sed,
TypeScript imports resolve through the rename); workflow display names
updated alongside (Phoenix LiveView build verification → Elixir / Ash build verification, etc.). The ashPhoenix design pack name is not
renamed in this pass — its foundation-aware rework belongs to P2 of
vanilla-phoenix-foundation.md. Depends on D-ELIXIR-PLATFORM.
D-SEED-PATH — seed rows go through the domain create
Status: PINNED.
Problem. database-seeding.md must pick how a declarative seed row
reaches the database. Target frameworks split: EF HasData and Drizzle
insert go straight to tables; Ash goes through actions (enforcing
changesets). Loom needs one default.
Decision. Seed rows lower through the aggregate's canonical
create by default — the same path a real request takes — so
invariants and create-time logic run, and a seed that violates an
invariant is caught at boot rather than producing a corrupt row. A
seed raw { … } modifier opts a block into table-level inserts for
bulk fixtures where the domain pass is deliberately bypassed (or too
slow); raw carries a loom.seed-raw-unchecked warning when a value
would fail an invariant.
Rationale.
- The domain
createalready encodes the invariants; bypassing it to insert rows is the same mistake as letting an API skip validation. - Ash's native seed idiom is
Ash.create!; routing throughcreatemakes the Loom surface map cleanly onto the most-opinionated backend rather than the least. rawkeeps the escape hatch explicit and visible, not the default.
Consequences. SeedIR.path: "domain" | "raw"; the declarative
record shape is the aggregate's create-parameter shape (no id
field — the framework mints ids). Object graphs built by operations
(not create params) are the imperative-body's job, not the declarative
form's.
Affects. database-seeding.md §2 (D-SEED-PATH), §3.2, §6 (per-
backend emitters), §8 (loom.seed-raw-unchecked).
D-SEED-IDEMPOTENCY — v1 is ship-once via a dataset marker
Status: PINNED.
Problem. Re-running a seed must not duplicate rows. Two mechanisms were on the table: (a) an applied-marker table; (b) per-row upsert by a declared natural key.
Decision. v1 is ship-once via an applied-marker: a __loom_seed
table holds one row per applied (module, dataset); the seeder skips
the whole set on boot if the marker is present. This matches the Rails
db:seed / Ecto seeds.exs contract, needs no natural key on the
rows, and covers the quick-start demo entirely. Editing an
already-applied seed has no effect until the marker is cleared
(ddd seed --reset <dataset>); seeding is forward-only, mirroring the
migrations stance.
Per-row upsert by a declared natural key (key Aggregate.field),
for reference data corrected in place over time, is deferred — it
adds a second idempotency mechanism, a grammar clause, a validation
rule, and a per-backend upsert branch, and earns its keep only once a
model actually has evolving lookup data. The grammar reserves the slot
(seed <dataset> key Aggregate.field) so it lands additively later.
Rationale.
- The driving use case (first-boot demo content) is served by the marker alone; the marker is the smaller, simpler mechanism.
- Marker-by-content-hash was rejected: re-applying forward-only
creates on a changed set just collides on existing rows — the very thing upsert would fix — so hashing buys nothing without also buying upsert. Keying the marker by dataset name sidesteps it.
Consequences. No key/contentHash in SeedIR for v1; the marker
table is emitted as a synthetic createTable step by the owning
module's migration pass. LOOM_SEED gates which datasets run.
Affects. database-seeding.md §2 (D-SEED-IDEMPOTENCY), §7, §10
(deferred upsert), build-order §11 phase 6.
D-SEED-XREF — seed cross-references are explicit ids (no @handle)
Status: PINNED.
Problem. Declarative seed data sometimes needs to relate rows (an
Order referencing a Customer). How does a referencing row name the
referenced row's id?
Decision. Explicit ids on the raw path — the declarative-fixture
model of Django fixtures, EF Core HasData, and raw SQL. A raw row is a
literal record (explicit id + literal FK columns) inserted directly,
bypassing the domain create; the author writes the same literal id in the
referenced row and the referencing FK, and orders parents before children.
No bespoke handle/reference construct, no topological reorder.
A symbolic-reference form (Customer @acme { … } / Order { customerId: @acme }, lowering to a SeedRef ExprIR + topo-sort) was prototyped and
dropped: no concrete stack has it (imperative seeders use host-language
local variables; declarative ones use explicit ids), it duplicates what the
imperative body's let bindings already give for free, and it spread a new
ExprIR variant + topo-sort + three validators across the toolchain for a
convenience neither camp felt the need to invent.
Consequences. The default domain path mints ids and therefore has no
cross-references (a minted id isn't knowable to reference) — flat demo /
reference data only. Cross-referenced / relational seed data uses raw.
SeedRowIR carries no handle; there is no seed-ref ExprIR; SeedIR.rows
stay in source order.
Affects. database-seeding.md §3.1/§3.2 (cross-ref design), §5 (IR), §8
(validation), §11 (build order — raw path is the cross-ref home).
D-AUTH-OIDC — turnkey auth delegates to OIDC; Loom does not build an auth runtime
Status: PINNED.
Problem. quickstart-and-day-one-batteries.md §4 ("turnkey auth") sketched
a self-built auth runtime: an auth { providers: [email, google, github] }
block that makes Loom generate an AuthUser aggregate with hashed
passwords, signup/login/verify-email endpoints, OAuth clients, session
issuance, and login/signup UI — across Hono, .NET, and Phoenix, × four
design packs. That is a large, security-critical, perpetually-maintained
surface for a code generator to own.
Decision. Turnkey auth delegates to an OIDC identity provider.
Keycloak is the self-hostable default; Auth0 / Cognito / Zitadel / Ory /
Entra ID are the same thing to Loom — an issuer URL. "Don't roll your own
auth": Loom validates tokens and maps claims into the existing typed
user {} shape; the IdP owns credential storage, password reset, MFA,
lockout, and the hosted login/signup pages. Loom generates no AuthUser
aggregate, no password column, no OAuth client code — only an OIDC verifier
(the batteries-included fill-in for the already-shipped
IUserVerifier / registerUserVerifier seam), the /auth/login|callback|logout
redirect handshake, session issuance, and a route guard.
Rationale.
- "Don't roll your own auth" is the strongest security guidance in the field; passwords/MFA/reset/lockout are deep and ruinous to get wrong.
- OIDC is the universal protocol — every IdP plugs in uniformly behind one
issuer. Loom's per-backend work shrinks to "validate a token" (mature libs everywhere:jose,Microsoft.Identity,oidcc/Ueberauth) + a redirect. - It completes what
auth.mdalready ships (typeduser {},currentUser,requires,auth: requiredmiddleware, the verifier hook) rather than adding a new runtime. OIDC is just the batteries-included verifier. - The multi-backend × multi-pack cost of a hand-built password/session/login-UI runtime is a maintenance + security liability where any divergence is a vulnerability.
Zero-config quick-start. The one cost of self-hosted OIDC — standing up an
IdP — is closed by bundling a dev IdP: the generated docker-compose.yml
adds a Keycloak service with a pre-provisioned realm + a seeded demo user
(seeding feature, database-seeding.md §5.4), so docker compose up logs in
out of the box; production repoints issuer: at a real IdP. The on-ramp owns
zero auth logic.
Consequences. The auth {} surface is reframed from
providers: [email, google, github] to auth { oidc { issuer, clientId, … } }
- a
claims:map. A self-contained email/password mode, if ever wanted, is a secondary, library-backed option — never hand-rolled across backends, and not the headline. Default-deny enforcement (enforcement: denyByDefault) is unaffected.
Affects. quickstart-and-day-one-batteries.md §1 (battery list), §3.1
(saas template), §4 (entire turnkey-auth section), §7 (build order);
auth.md (the verifier-hook seam is OIDC's mount point — no rewrite, just the
completion it always anticipated).
D-AI-EMPHASIS — Loom leads as a platform (mass-market land + regulated expand), IR-embedding deferred
Status: PINNED.
Problem. ai-generation-platform.md
§4.4/§7 leaves the platform-vs-IR emphasis open. Three coherent paths
were on the table:
- A — IR-first. Ship
@loom/coreas the deterministic, multi-stack, auditable engine that other AI builders embed (B2B2C). Lowest risk, plays to the compiler strength, dodges consumer-AI UX — but has no direct customer, leans on integrators with weak incentive to adopt a DDD compiler that constrains their breadth, and cannot monetise the niche that actually values the differentiation. - B — Mass-market platform. Loom's own end-to-end AI generation platform competing for the broad "describe an app" market.
- C — Vertical platform. The same platform aimed at the regulated / domain-heavy / engineering-led niche (fintech, healthtech, govtech), where determinism + ownership + multi-stack + governance are non-negotiable and the consumer-grade incumbents cannot follow.
Decision: B + C. Loom leads as a first-party platform across both motions — a land-and-expand:
- B is the funnel. A free/low tier for the broad market is the distribution and brand engine. It is winnable not on UX polish alone but on the genuine differentiators: model-as-memory (no context rot as the app grows), determinism (maintainable/upgradable output), multi-stack, and code ownership. The pitch is "AI apps that don't collapse at scale and that you own," not "a prettier Bolt."
- C is the revenue. The regulated/engineering niche is where pricing
power lives — governance/conformance/provenance reporting, private
backends, hosted
verify, SLA'd determinism. B lands these users; C expands them. - A is deferred, not dropped. IR-embedding (
@loom/coreas an engine other builders license) is a later channel/partnership play, reachable from a proven platform; the reverse climb (embedded engine → owns the customer) is much harder.
Rationale.
- The emphasis question is GTM and narrative, not architecture. Platform
and IR run the same validate→repair→verify loop over the same model
patches (
ai-authoring-loop.md); the wedge demo advances all paths. So committing to B+C costs nothing technically and keeps A open. - Owning the customer (B+C) is the only path that monetises Loom's defensible whitespace directly; A buries the differentiation three layers down someone else's stack.
- B funds C: the mass-market top-of-funnel supplies the distribution that a pure-niche motion lacks, while C supplies the margin a pure-mass motion lacks.
Consequences / honest caveats. B+C is the highest-prize and
highest-cost path, and it leans hardest on Loom's weakest muscle — consumer
AI UX and the capital/team to compete on it. It is justified only if (a) the
on-ramp can be made cheap (grammar-constrained .ddd authoring + the
context-pack, ai-authoring-loop.md §5) so the engine carries the UX, and
(b) the build sequences the wedge demo first (prove the loop end-to-end in
the browser playground) before any mass-market growth spend. If funding/team
for B does not materialise, fall back to C-only (vertical-first) rather
than A — keep the direct customer.
Affects. ai-generation-platform.md §4.4 (reframe the "two strategies,
sequence them" block from IR-first to B+C platform-first with A deferred) and
§7 (resolve the platform-vs-IR open question, citing this tag).
D-API-TOOLKIT — one transport-neutral toolkit core, thin adapters per surface
Status: PINNED.
Problem. Loom's structured operations (validate, generate, patch,
plus outline and the diagnostics/fixHint serializers) were being grown inside
src/cli/ (json-report.ts, runParseJson), which is Node-bound. But the
same operations are needed by at least four surfaces — the CLI, an MCP
server (agents), the LSP (Monaco/VS Code), and the in-browser playground — and
re-implementing them per surface is exactly the drift the structured contract
exists to prevent. The patch/diagnostic format is also ours
(ai-diagnostics-contract.md), not an
editor/agent standard, so it needs one authoritative implementation plus thin
adapters at the boundaries.
Decision. A single transport-neutral toolkit at src/api/ is the
shared core; every surface is a thin adapter over it.
| Layer | Home | Role |
|---|---|---|
| Toolkit core | src/api/ |
validate(source)→ValidateReport, generate(source)→GenerateReport, applyPatches(source,patches)→PatchResult; pure, in-memory, browser-safe (parses on EmptyFileSystem, no langium/node). src/api/report.ts holds the diagnostic/outline serializers. |
| CLI | src/cli/ |
argv + stdout/exit only; calls the toolkit. |
| MCP server | (future) | tool handlers calling the toolkit — the recognized way agents call tools. |
| LSP adapters | src/language/lsp/ |
ModelPatch → WorkspaceEdit/TextEdit, fixHint → CodeAction, JsonDiagnostic → Diagnostic. |
| Web playground | web/ |
imports the toolkit directly (../src/api). |
Consequences.
- The CLI shrank to thin wrappers (
runParseJson/runGenerateJsonare a few lines each); the fat report-building moved out ofsrc/cli/. applyPatchesswitchedNodeFileSystem→EmptyFileSystem, restoring the "src/language/is browser-safe" invariant (CLAUDE.md).- The node-addressed
ModelPatchstays the loop-native format (it survives re-printing and joins to diagnostics/outline); LSP/MCP are adapters at the edge, so the system is "recognizable by everything" without compromising the core. - A new operation (e.g.
rename,verify) is added once in the toolkit and every surface inherits it. src/api/sits abovelanguage/ir/generator/system(an orchestration/entrypoint layer, likecli); it is not scanned by the pipeline-layering invariant and creates no back-edge.
Affects. ai-diagnostics-contract.md (--json scope note now points at the
toolkit, not the CLI); ai-authoring-loop.md §3 (the tool surface is the
toolkit + an MCP transport); future MCP-server and LSP-adapter slices build on
this seam.
D-AGENT-TOOLS — one tool catalog over the toolkit; MCP and in-browser are transports
Status: PINNED.
Problem. Loom's operations need to be agent-callable tools. Two surfaces want them: external agent hosts (Claude Desktop, IDE agents, CI) via MCP, and the in-browser playground agentic chat. An MCP stdio server runs as a Node subprocess and cannot run in a browser; hand-coding the tool schemas separately for each surface would let them drift — the same mistake D-API-TOOLKIT fixed one layer down.
Decision. A single transport-neutral tool catalog at src/tools/ over
the src/api/ toolkit is the source of truth; every transport is a thin adapter.
| Layer | Home | Role |
|---|---|---|
| Tool catalog | src/tools/ |
{ name, description, inputSchema (JSON Schema), handler(args)→toolkit } per tool. Browser-safe (imports only src/api/ + contract types; no MCP dependency). |
| MCP stdio server | packages/ddd-mcp/ |
Node entrypoint registering the catalog; for external hosts (npx ddd-mcp). |
| Playground chat | web/ |
imports the same catalog; dispatches the LLM's tool_use calls directly to handler(args) in-browser. (Optional in-memory MCP for byte-identical parity.) |
Tools are pure and stateless. Each tool is a function of its inputs (model
source in → report/new-source out); no server-side model state, no
filesystem side effect. The host owns the working model and threads it through.
This makes the server safe by default (read-only/functional — no consent
prompts), keeps the browser transport trivial, and inherits the toolkit's
determinism + browser-safety. File emission stays in the CLI, never in a tool.
Two verb families, one catalog. (a) Generative (the v1 authoring loop,
pure functions of source): loom_validate {source}→ValidateReport,
loom_apply_patch {source,patches}→PatchResult, loom_generate
{source}→GenerateReport, loom_outline {source}→Outline. (b)
Navigational (query/refactor over the LSP providers, by-name dotted-symbol
addressing): loom_find_symbol / loom_references / loom_hover /
loom_rename / loom_quickfix / loom_unfold_macro — edits returned as an
LSP WorkspaceEdit, never applied to disk (consistent with pure tools).
(c) Evolution (pure functions surfacing the derived-artifact deltas the
playground's Migrations dock shows a human): loom_snapshot {source}→SnapshotReport
(the ddd snapshot provenance capture, returned as data — no file emission)
and loom_diff {source,baseline?}→DiffReport (the schema-migration + wire-
contract delta a change implies, with the destructive/breaking gate surfaced).
Folded from the superseded language-services-and-agent-tools proposal; the
navigational verbs gate on fixing the LSP providers' real bugs (operation
rename) first. loom_read_model / loom_list_primitives / loom_snapshot /
loom_diff have shipped; loom_verify follows once the playground can run the
emitted suites in-browser (M-T8.3 / M-T8.6). New diagnostic→fix mappings are
src/language/fix-hints.ts providers (one ModelPatch → both the LSP
code-action and loom_quickfix), not duplicated bespoke verbs.
Consequences / answers the question that prompted this.
- The stdio MCP server is not runnable in the playground browser — and isn't needed there; the playground dispatches tool calls to the in-process catalog.
- Adding playground agentic chat becomes a UI + LLM-wiring task reusing the catalog — no second tool implementation. The only browser-new concern is LLM key/endpoint handling.
- A future Streamable-HTTP transport (hosted Loom MCP endpoint) is another thin adapter over the same catalog.
Rejected. A stateful "session holds the model" MCP server (adds mutable
server state, complicates the browser path, buys nothing a host string can't);
write-capable tools in v1 (kept side-effect-free; generate-to-disk, if ever
wanted, is a separate consent-gated tool).
Affects. ai-authoring-loop.md §3 (the tool surface is the catalog + MCP /
in-browser transports) and §7 item 6; the new agent-tools-and-mcp.md is the
detailed spec; the future playground-chat slice builds on this seam.
D-PHOENIX-FOUNDATION-STRATEGY — vanilla is the home for everything that fights Ash; Ash stays for the CRUD sweet spot, feature-frozen at the boundary, not deprecated
Superseded (2026): the Ash foundation was removed.
platform: elixiris plain Ecto/Phoenix only;foundation: ashis now a validation error andvanillais the default and only valid value. The Ash-vs-vanilla reasoning below is historical.
Status: PINNED. (Go-forward stance over D-VANILLA-PHOENIX-FOUNDATION / D-VANILLA-ES-HOME / D-VANILLA-DEFAULT, prompted by a third Ash-fit deferral. No new mechanism — a prioritisation + feature-direction policy.)
Problem. The friction between Ash's model and Loom's contracts is not a one-off; it recurs, and each occurrence is paid as a per-feature Phoenix deferral or an off-Ash workaround:
- Pure event sourcing — no clean fit on
foundation: ash; deferred to vanilla (D-VANILLA-ES-HOME;proposals/workflow-and-applier.md). - Workflow saga / correlation state — kept a plain
Ecto.Schemadeliberately off the Ash action surface, because the dispatcher mutates it imperatively (load-or-allocate / route-or-drop), not via changeset actions (proposals/channels.md;dispatch-emit.ts). - Exception-less variant returns — Phoenix is the odd backend out; the
Plug.ErrorHandlerrescue tower that translatesAsh.Error.*is a designed-in feature on Ash that fights the cross-backend typed-or-union dispatch (proposals/exception-less.md; D-VANILLA-PHOENIX-FOUNDATION §1).
The common cause is structural and consistent: Ash.Resource's
changeset-shaped action model + Ash.DataLayer's current-state queryable
contract do not fit Loom's imperative / non-CRUD / event-sourced / typed-error
paths. Ash remains a good fit for what it was built for — declarative CRUD
aggregates, AshPostgres migrations, admin-shaped LiveView forms. The boundary is
the issue, not the framework.
Decision.
- Feature-direction policy. New Phoenix domain capabilities that hit the
Ash action-model boundary — event sourcing,
exception-less returns, future saga / projection / outbox work — target
foundation: vanillaas their Phoenix home.foundation: ashis feature-frozen at that boundary: it is fully supported for the CRUD/admin sweet spot and gets cheap parity where it falls out naturally, but we do not grow bespoke Ash workarounds (custom data layers, AshEvents/AshCommanded bridges, action-wrapping) to force ill-fitting patterns onto it. This makes explicit the de-facto pattern already in the tree (1–3 above). - Vanilla is the next foundation investment. Building the vanilla emit
subtree (P2 of
proposals/vanilla-phoenix-foundation.md) is now the gating dependency for multiple deferred features, so its cost is paid once instead of re-paid as recurring deferrals. Prioritise it ahead of further per-feature Phoenix-on-Ash special-casing. - Ash is NOT deprecated now. Deprecating
foundation: ashis premature: vanilla is unbuilt and unproven, Ash's declarative DX is a real asset for the CRUD case, and D-VANILLA-DEFAULT already sequences vanilla-as-default behind a stabilisation cycle. The "neither is deprecated" stance of D-VANILLA-PHOENIX-FOUNDATION holds. - Sunset is a later, conditional decision. Whether
foundation: ashis eventually deprecated is deferred and revisited only after vanilla (a) ships, (b) passes the strict cross-backend parity gate, and (c) clears the same stabilisation signals D-VANILLA-DEFAULT defines (a greenphoenix-vanilla-build.ymlminor-release cycle + no obs-e2e regressions). At that review, weigh the cost of maintaining two Phoenix emitters against Ash's residual value for the CRUD sweet spot. Do not pre-commit either way here.
Affects. No code change. Sequencing/roadmap: elevates
proposals/vanilla-phoenix-foundation.md P2 to the next Phoenix work item;
sets the policy any future
Ash-boundary feature are evaluated against. Depends on
D-VANILLA-PHOENIX-FOUNDATION; informs D-VANILLA-DEFAULT (its
stabilisation signals double as the sunset-review trigger).
D-SVELTE-FRONTEND — Svelte reuses the shared markup walker; SvelteKit static SPA; runes-native data/forms
Decision. The Svelte frontend (platform: svelte) is NOT a fork of the
React generator. Pages flow through the SAME shared markup walker
(src/generator/_walker/walker-core.ts) with a svelteTarget
(WalkerTarget impl) and svelte-format design packs (shadcnSvelte,
flowbite) supplying the framework surface. Three sub-decisions:
- App shape: SvelteKit static SPA (
@sveltejs/adapter-static,ssr = false, fallbackindex.html), served withvite previewexactly like the React SPA. File-based routing maps the page metamodel's routes (/orders/:id→src/routes/(app)/orders/[id]/+page.svelte); route groups(app)/(bare)carry the layout selectors. Chosen over a Vite + community-router SPA because SvelteKit is the ecosystem's maintained routing answer — dependency risk kills generated-code products faster than structural asymmetry. - Data layer:
@tanstack/svelte-queryv6 — runes-nativecreateQuery(() => opts)returns a reactive object with the React-Query property surface (.data/.isPending/.mutate), so the generated api factories keep the TSX hook NAMES (useAllCustomers,useCreateCustomer, …) and the walker's api seam is name-compatible across both frontends. The zod schema half of every api module is emitted byte-identically from the sharedsrc/generator/_frontend/zod-schemas.ts. - Forms: hand-rolled runes + zod (
$lib/forms.svelte.ts—createFormwithvalues/errors/submit/applyServerErrors), no third-party form dependency. Field templates bindform.values.<path>; RFC 7807 422s decode onto the same per-field error map react gets from apply-server-errors. Operation-form modals render as page-scope{#snippet <op>OpModal(form)}blocks (one component per .svelte file — module scope lands in the template).
Walker contract. The Svelte port added five WalkerTarget seams:
the four markup seams (renderComment, renderConditionalChild,
renderStyleAttr, escapeText) plus renderChildrenSlot and
formRuntimeImports. Svelte 5 shares JSX's {expr} interpolation,
<Comp x={y}/> invocation and data-testid={expr} syntax — those stay
hardcoded in the shared walker; the seams cover exactly where the two
markups diverge.
Hosting. svelte joined STATIC_BUNDLE_FRAMEWORKS: dotnet
fullstack hosts embed SvelteKit SPAs under ClientApp/ (the Dockerfile
copies the adapter-static build/ into wwwroot). Phoenix is the
deliberate exception — it serves embedded SPAs under the /app path
prefix, which a SvelteKit bundle needs paths.base threading for; its
surface excludes svelte until that lands. The in-browser playground
preview is likewise deferred (Svelte compiler in the VFS bundler).
See docs/old/plans/svelte-frontend-plan.md for the slice history.
D-VUE-FRONTEND — reuse the shared walker, Vite+vue-router SPA, vee-validate forms
platform: vue (Phase B of the platform-expansion roadmap;
docs/old/plans/vue-frontend-plan.md) ships as the third frontend with
three locked choices:
- Reuse, not fork. Vue pages render through the SHARED markup
walker (
src/generator/_walker/) withvueTargetsupplying the leaf seams. Where Vue genuinely diverges from the JSX family the CONTRACT grew (renderInterpolation / renderAttrBinding / renderMatchChild) rather than the walker forking — every extension byte-identical for TSX/HEEx. The api/workflows module builders moved tosrc/generator/_frontend/and are shared verbatim (TanStack Query's call surface is identical across react-query and vue-query; one import-specifier knob). - Plain Vite SPA + vue-router —
createWebHistory, explicit route table insrc/router.ts,<script setup lang="ts">SFC pages, the same two-stage vite-build/vite-preview docker runtime as React. No Nuxt. - vee-validate forms (
src/lib/form.ts—useLoomForm): the Vue analog of the React packs' react-hook-form.useFormdrives validation + submit over the SAME shared zod request schema, adapted by a locally-emittedtoTypedSchema(@vee-validate/zodis NOT a dep — its peer pins zod 3 while the stack is zod 4, so a plainnpm installwould ERESOLVE; vee-validate core carries no zod peer).valuesstays a localreactive()draft (so pack markup'sv-model+ array push/splice keep working) and is pushed into vee-validate on submit; per-field error map + ProblemDetails-style server-error application preserved. TheuseLoomFormseam keeps the same surface, so pack markup + testids are unchanged across packs.
Packs: vuetify@v3 (npm-package model, the default) and
shadcnVue@v1 (source-copy: reka-ui + Tailwind 4 + a components-ui
barrel; pack-declared imports tables flow into page scripts). Vue
packs own the op-dialog operation-modal wrapper. vue is a
STATIC_BUNDLE_FRAMEWORK — dotnet/java/phoenix hosts embed a
framework: vue ui exactly like a React one.
D-NO-PAGE-ARCHETYPES
Status: PINNED.
Problem. The page DSL shipped three "archetype" builder-call names —
List { of: T }, Detail { of: T, by: id }, MasterDetail { of: T, scope?, detail? } — documented (page-metamodel.md §4/§9) as the canonical page
bodies and used in examples. They were inert: admissibleInSource: true
in the walker registry but with no tsx/heex renderer and no expander
arm, so a body of List { of: Order } parsed, validated, then dead-ended to
a // not supported by the React walker yet comment (and they sat in
NON_PAGE_BODY_LAYOUT_PRIMITIVES, excluded as page bodies outright). Meanwhile
the scaffold sentinels scaffoldList/scaffoldDetails — also
admissibleInSource — are generative (a phase-⑤c expander arm rewrites them
into the full Breadcrumbs · Toolbar · QueryView · Table tree) and are
themselves hand-writable and embeddable anywhere (the expander recurses into
nested bodies). So the archetypes were duplicate names for the working scaffold
sentinels, minus the wiring.
Decision. Remove List / Detail / MasterDetail from the language.
The list/detail surface is the scaffold macro (whole-page generation) plus
the scaffoldList / scaffoldDetails body sentinels (hand-writable, for
embedding a list/detail in a custom page). No capability is lost — the
embeddable case the archetypes were imagined for is already served by writing
scaffoldList { of: T } in a custom page body. MasterDetail's richer
split-pane shape (scope: + detail: lambda) was never implemented; it is
not reintroduced (compose scaffoldList + a state {} selection + a detail
component if needed).
Removed from: src/generator/_walker/registry.ts,
src/language/walker-stdlib.ts, NON_PAGE_BODY_LAYOUT_PRIMITIVES
(walker-core.ts), the web visual-builder model (web/src/builder/page/),
and their tests; examples switched to scaffoldList/scaffoldDetails.
Supersedes the proposals/unfoldable-page-scaffolding.md direction (which
explored implementing the archetypes as emitted components) for the
archetype question specifically. The residual idea in that proposal — that the
phase-⑤c scaffold expansion is opaque "magic" that could instead emit
unfoldable, named components — remains an OPEN, separate consideration for the
scaffoldList/scaffoldDetails sentinels themselves; it is not blocked by
this removal.
Superseded (2026-06-19). The scaffold* page-body sentinels
(scaffoldList / scaffoldDetails / scaffoldNewForm / scaffoldOperations
/ scaffoldWorkflowForm / scaffoldInstanceList /
scaffoldInstanceDetails) — together with their phase-⑤c expander
(src/ir/lower/walker-primitive-expander.ts) — have now been removed
entirely: they were "scaffolds that aren't scaffolds" — opaque,
permanently-in-source indirections with no unfold. The only scaffold surface
is the with scaffold(...) page macro, which emits full unfoldable AST
trees (src/macros/stdlib/scaffold/_body-builders.ts). The sentinels are no
longer admissibleInSource — a hand-written body: scaffoldList { of: X }
now fails validation with "Unknown builder type". Embedding a list/detail in a
custom page therefore means writing the body explicitly (the example
web/src/examples/extern-showcase.ddd shows the inlined list tree).
Update (2026-06-20). The two singleton index-page sentinels (Home /
WorkflowsIndex) — the last holdouts — are gone too. They are
now ordinary scaffold macros (scaffoldHome / scaffoldWorkflowsIndex
in _body-builders.ts) emitting full bodies from the
gathered inventory; the expandInlineScaffoldPrimitives expander, the ⑤c pass,
the Home/WorkflowsIndex registry primitives, and the page
origin/source fields are all removed. walker-primitive-expander.ts is now
just buildExpandContext. A page's kind is derived on demand from its
role-scoped name + area via classifyPage (src/ir/util/page-kind.ts).
D-TENANCY-SCOPE — the per-aggregate tenancy axis is two values
Status: PINNED.
Problem. Earlier drafts of multi-tenancy-design-note.md had a three-value
scope axis — tenantOwned / crossTenant / platform — where platform meant
"admin-only cross-tenant data" (audit trails, projections), distinct from open
reference data.
Decision. The per-aggregate tenancy axis has two values: tenant-owned
(the tenantOwned capability) and crossTenant (unscoped). There is no
platform scope. Who may read is an authorization concern, not a tenancy
scope, so admin-only cross-tenant data is crossTenant + an authorization
default-deny policy. Once depth moved to per-role authz access levels
(D-TENANCY-HIERARCHY) and the registry to a capability (D-TENANCY-REGISTRY),
platform had no scope meaning left. Safety note: crossTenant is fail-open at
the tenancy layer; sensitive cross-tenant data relies on the authz default-deny
gate.
Affects. multi-tenancy-design-note.md R2 (canonical); authorization.md
§0/§2 (no longer lists platform).
D-TENANCY-REGISTRY — registry named in tenancy by … of X + a tenantRegistry capability
Status: PINNED.
Problem. The tenant registry (the Organization/Tenant aggregate) can't be
plain tenant-scoped (it is created before its own tenant context exists, and is
self-keyed with no TenantId column) nor crossTenant (that would leak every
org). An early draft made it a platform aggregate mode.
Decision. The registry is a system-level fact named in tenancy by user.tenantId of Organization. It carries an explicit, unfoldable implements "tenantRegistry" capability that provides an immutable self-referential
parent: Self id? + a managed dataKey path + the path-stamp — fields come from
the local capability, never injected by the distant tenancy by line
(verify-don't-inject). Loom verifies cardinality (exactly one
tenantRegistry) + the of … cross-link + that the claim field exists — not
field-conformance (the capability provides the fields by construction).
Reparent is out of scope (immutable parent ⇒ permanent paths; rare org
moves are an offline migration).
Affects. multi-tenancy-design-note.md R1/R5; typed-capabilities.md (the
tenantRegistry worked case).
D-TENANCY-DEFAULT — no silent default; an explicit-stance lint
Status: PINNED (the lint is an error: loom.tenancy-stance-unmarked).
Problem. A fail-closed silent default ("unmarked ⇒ tenantOwned") would
have Loom implicitly attach the tenantOwned capability, injecting
tenantId/dataKey — the distant-injection magic the capability model forbids.
Decision. No silent default. An unmarked aggregate under a tenancy by
system is unscoped; a lint flags it and suggests the explicit marker
(with tenantOwned or crossTenant). Fields only ever come from an explicit,
unfoldable capability. The lint's severity is the fail-open/fail-closed knob:
error (recommended — the common case is tenant data, so the unmarked fallback
is the dangerous one) gives fail-closed without magic; warning is fail-open.
Shipped as error (loom.tenancy-stance-unmarked, tenancy-checks.ts).
Affects. multi-tenancy-design-note.md R3 (supersedes original decision #2's
silent-default mechanism; the fail-closed goal survives via the lint).
D-TENANCY-HIERARCHY — always hierarchy-ready; depth is a per-role authz access level
Status: PINNED.
Problem. Hierarchical (parent/child org) visibility could be a
flat-vs-hierarchical mode, a per-aggregate subtenantScoped flavor, or a
per-role access level. An entity-flavor can't express "Manager sees Project
deep but Invoice local."
Decision. Always hierarchy-ready — no mode switch. "Flat" is the
degenerate case (every org a root, every read local). tenantId + a
denormalised dataKey (the owning org's materialized path) are stamped on every
tenantOwned aggregate from the token at create (immutable parent ⇒
permanent paths ⇒ orgPath can ride the token), so enabling deep later is
migration-free. Depth is a per-role authorization access level, not an
aggregate marker — authorization.md's directional Self/Descendants/All
(Dynamics' local/deep/global is the same ladder, a mnemonic). deep is a
direct indexed prefix scan on the row's dataKey (no join). Grounded in
Dynamics (Business Unit + Basic/Local/Deep/Global) and Salesforce (Role
Hierarchy). Assumption to verify: token-carried orgPath is a derived
session value, not an IdP claim (D-AUTH-OIDC), so the pure-claim-copy stamp is
contingent on session enrichment, else a per-request cached registry lookup.
Affects. multi-tenancy-design-note.md R5; authorization.md §2 (DataKey
= the hierarchical extension; directional predicates = the access levels).
D-TYPED-CAPABILITIES — capabilities are first-class pure-mixin declarations
Status: PINNED (proposal pending scheduling).
Problem. Loom's capability surface (implements "X" / filter for "X" /
stamp for "X") is the one stringly-typed corner — no resolution, no contract,
and a muddy capability-vs-macro line (capabilities are implemented as macros
today).
Decision. Promote capabilities to a first-class capability { fields + filter + stamp } declaration with typed references (implements X /
with X). It is a pure mixin — everything in the body is provided; no
requires/provides/expects keywords and no field-conformance (every
capability provides what its own behavior uses; the rare "operate on a host
field" case is parameterization, not a contract). Provision is local +
unfoldable (not magic); provided members are non-overridable by default. Lowers
to the existing per-aggregate contextFilters/contextStamps IR ⇒
byte-identical migration. Subsumes the audit/softDelete field+filter+stamp
macros; crudish/scaffold* stay macros (operations/structure). Open: emission
deduplication when a capability is reused (typed-capabilities.md OQ#1).
Affects. typed-capabilities.md (canonical); ../capabilities.md (evolves
its mechanism); multi-tenancy-design-note.md (tenantOwned/tenantRegistry
are capabilities).
D-INDEX-INFRA — manual performance indexes live on the storage binding, not the aggregate
Status: PINNED.
Problem. Domain uniqueness is a unique (...) invariant on the aggregate, but
a plain performance index (speed up a frequent filter) has no domain meaning —
putting it on the aggregate conflates infrastructure with the model.
Decision. A performance index is declared on the resource binding via
index: [Entity.col, Entity.(a, b)] (grammar IndexSpec). It is pure
infrastructure and always non-unique (uniqueness stays the domain
invariant). The target entity is named explicitly (Project.name), never
inferred from which table owns the column — the binding knows the context's shape,
and the entity may be an aggregate or a contained part. Lowers to manualIndexes
in the IR and lands as CREATE INDEX in the derived migration.
Affects. uniqueness-and-indexes.md §3.2 (canonical); ../resources.md
(surface); ../migrations.md.
D-INDEX-SUGGEST — index suggestions are an advisory lint, not an auto-emitted index
Status: PINNED.
Problem. Loom can see which columns get filtered frequently (find predicates, FK-shaped reads), so it could auto-create covering indexes. But silently emitting indexes hides a cost (write amplification, storage) the author never chose, and an index is an ops decision.
Decision. Loom does not auto-emit performance indexes. It emits an
advisory warning — loom.index-suggestion (validateIndexSuggestions,
src/ir/validate/checks/index-suggestion-checks.ts) — pointing at a
frequently-filtered column with no covering index, and the author opts in via the
manual index: hatch (D-INDEX-INFRA). Advisory, never an error; never changes
emitted schema on its own.
Affects. uniqueness-and-indexes.md §11 (canonical); ../resources.md.
D-MIG-NO-DOWN — down migrations are no-op everywhere, by decision
Status: PINNED.
Problem. Every backend's migration mechanism has a down/rollback slot
(EF Down(), Ecto change/0 reversibility, Drizzle/Flyway/Python file
pairs). Loom has always emitted no-op downs de facto; M-T2.3's data steps
(backfills, raw SQL) forced the question, because a "down" of a backfill is
a data-destroying drop.
Decision. Down migrations are no-op on every backend, permanently. Operators roll forward; recovery is backup + roll-forward. The snapshot ledger re-derives forward state deterministically, so a bad migration is corrected by the next migration, not by rewinding.
Rationale.
- Down paths are untested-by-construction — nothing in the pipeline ever executes them, so shipping them is shipping unverified code.
- The "down" of a data step is destructive by definition (un-backfilling a column is dropping its values), which contradicts the destructive gate's whole posture.
- Forward-only matches the seeding stance (D-SEED-IDEMPOTENCY) and the documented practice of every backend's own community for production data.
Affects. ../migrations.md § Data migrations; dotnet/emit/migrations.ts
(Down() comment); every backend's migration emitter.
D-MIG-DSL-STEPS — data migrations are DSL steps, not emitted stub files
Status: PINNED.
Problem. The original M-T2.3 sketch was "an emitted, history-tracked stub
file the user fills (per backend's native mechanism)". A stub must survive
regeneration (write-once machinery), exists five times (a Drizzle .sql, an
EF class, an Ecto .exs, a Flyway file, a Python .sql) for one logical
change, and breaks the "generated tree is disposable" invariant.
Decision. Data migrations are steps in the migration block —
backfill (Agg.field = <expr>) and raw sql "…" — validated, printed, and
ledgered like every other step, rendered once through the two shared
renderers (sql-pg.ts, Ecto execute/1). No generated file is ever
user-edited. The raw step's exactly-once semantics live in the snapshot
(appliedDataMigrations), not in a new history file — the same "the snapshot
IS the applied-history record" property M-T2.1 pinned for renames.
Rationale.
- One source, five backends: the stub-file design duplicates the same DML per backend and drifts.
- The block already existed (M-T2.1) and was explicitly shaped for these steps; a second surface would be permanent tech debt.
- Procedural, app-level data migrations (beyond SQL) are a documented
non-goal for v1; the pressure valve is the raw
sqlstep's full Postgres expressiveness (CTEs, functions).
Affects. ../migrations.md § Data migrations;
../new-plan/missions/M-T2.3-data-migration-surface-design.md (canonical);
src/system/migrations-builder.ts.
D-DATAGRID-TARGETS — a target hosts DataGrid only if it can host TanStack
Status: PINNED.
Problem. DataGrid (M-T1.1 slice 10) is not a markup mapping. It is a
TanStack Table row model — multi-column sort, per-column filters, column
visibility, pagination, row selection — that each target wires to its own
reactivity through the renderDataGridChild seam. The seam exists precisely so
those semantics are shared rather than re-derived. That makes "should target X
get a DataGrid?" a different question from every other primitive's, and the
tempting answer is the wrong one: most UI toolkits ship a data grid, and
adopting it would satisfy the emitter while quietly forking behaviour.
Decision. A frontend ships DataGrid iff it can run TanStack Table
itself. Not "iff it emits JSX", and not "iff its UI kit has a grid widget".
React / Vue / Svelte / Angular / Feliz — yes. The first four via their official adapters or (Svelte)
@tanstack/table-coredirectly. Feliz too: Fable compiles F# to JavaScript, so it bindstable-corethrough ordinary interop and executes the same row model. No adapter is required, and none exists — that was never the test.Flutter — no, permanently. There is no Dart adapter and no Dart port.
dart:js_interopexists on Flutter web only, while the shipping target (whatgenerated-flutter-build.ymlbuilds) is a native APK with no JS runtime. A primitive that compiles on one Flutter target and not the other is worse than an honest gap.HEEx — no, and (revised) for the same rule, not an unrelated one. LiveView has a JS runtime, so "can it host TanStack?" is not answered by physics the way Flutter's is; it is answered by what hosting would cost. Both available roads fork the seam:
- Hand-rolled server-side. The rows are already in a socket assign, so
sort/filter/visibility/paging would be
Enum.sort_by/Enum.filter/Enum.sliceover that assign — feasible, and that is the problem: it means re-derivingsortingFns.alphanumeric, the multi-sort tie-break order,filterFns.includesStringand TanStack's pagination edges in Elixir, against a library that keeps moving. Identical to the Dart hand-roll this decision already rejects. - A
phx-hookmountingtable-coreover aphx-update="ignore"subtree. Technically the real row model, but a JS-owned island LiveView must not patch — forfeiting the server-rendered markup every other HEEx primitive is built on (page objects, the shared chrome-i18n path,<.input>/<.table>), to gain one primitive.
Tableis server-driven there instead, and carries real sort + pagination.- Hand-rolled server-side. The rows are already in a socket assign, so
sort/filter/visibility/paging would be
Table is the portable answer on both, and it carries column sort, pagination
and filtering on every frontend. loom.target-unsupported#datagrid says so, per
target, rather than "not supported yet".
Correction (DataGrid pin re-examination). Until this revision, HEEx's
exclusion was justified — in KNOWN_HEEX_GAPS, in allowlist-ratchet, and in
the *-unsupported register's "phoenixLiveView is the open leg" — by a blocker
that does not exist: "every interaction would be a handle_event round-trip
re-querying the server … and needs backend support for multi-column ORDER BY,
which list/4's single sort/dir pair does not have." DataGrid drives no
server read on any of the five targets that ship it. It grids the array it was
handed (getSortedRowModel / getFilteredRowModel / getPaginationRowModel
over data: rows; pageSize: is a client page size), so list/4's sort/dir
pair was never on its path — and on LiveView those same rows sit in a socket
assign, so nothing would be re-queried either. The exclusion survives the
correction, but on this decision's own rule rather than on a backend limit.
Column visibility deserves its own note: it is pure assign state touching no row
model at all, so it is the one grid feature with nothing to fork — which makes
it a candidate for Table, not an argument for a partial DataGrid.
Rationale.
- Flutter ships
DataTable/PaginatedDataTable, which is exactly the trap. They would give an author a grid whose sort stability, filter composition, multi-sort semantics and pagination edges differ from the other five — the divergence class the shared seam was built to prevent, reintroduced under a primitive with one name. - The alternative — hand-rolling a row model in Dart — is the same fork with more code, and it is unbounded work: parity is measured against a library that keeps moving.
- Stating the rule as "can it host TanStack" makes both answers fall out mechanically and keeps the next target's decision cheap. Read it as "can it host TanStack without forking the target's own contract" — that is what makes it answer HEEx too, where the literal question has a yes-shaped answer nobody wants.
- The grid already carries one deliberate override of a TanStack comparator
(
compareDecimal, for money columns whoseDecimal.valueOf()returns a string), anddata-grid.test.tsdeclines to add a second because it "would FORK TanStack'stext/alphanumericchoice" — at one column. That is the scale at which forking is already treated as a cost; a whole re-implementation is the same cost everywhere, with no shared spec to hold the targets together.
Affects. the primitive:DataGrid@body row of util/target-capabilities.ts;
DATA_GRID_PRIMITIVES (generator/_packs/required-primitives.ts);
util/flutter-deferred-primitives.ts; generator/feliz/data-grid-child.ts;
page-metamodel.md §9.1; [new-plan/T1-ui-frontend.md](https://github.com/Loom-Harness/Loc/blob/cadbe3ba12fb833565704cb9d628c8f41b2090a8/docs/new-plan/T1-ui-frontend.md)
M-T1.1.
D-WHEN-GATE-DOMAIN — the when state gate is a domain-method property, not an HTTP-edge one
Status: PINNED.
Problem. operation cancel() when <pred> (criterion.md use site 2, the
canCommand state gate) used to be emitted at the route / command-handler layer
only. Every other caller of the domain method walked straight past it — a
workflow step, a saga cascade, an extern command handler, the LiveView action
seam all call aggregate.cancel() directly. The declared rule then silently did
not run on the paths that reach the aggregate from inside the system: no
DisallowedError, no 409, the write landed. It is the absence of a refusal, so
no wire-shape differential can see it — the request that should have been refused
is never made over HTTP at all.
Two coherent answers were open (new-plan/T6-backend-parity.md M-T6.38):
- Domain-layer gate — the predicate is asserted by the aggregate's own method, so every caller is gated.
- Route-layer by design —
whenstays an API-edge gate and the language says so, leaving in-system callers deliberately ungated because the workflow is the authority.
Decision. (1). The when predicate renders as the first line of the
generated operation method on all five backends. The route keeps its own
post-load check so the HTTP answer (409 + the RFC 7807 envelope) is still
produced before the aggregate is touched and the wire contract is unchanged;
the two checks are the same predicate and the same message.
Rationale.
whenis written on the aggregate, in terms of the aggregate's own state, and reads as a statement about when the operation is meaningful — not about who may call it over HTTP. Answer 2 asks the reader to hold a rule whose enforcement depends on the caller's transport, which nothing in the syntax hints at.- It makes the gate uniform with
precondition/invariant, which have always been domain-method properties.requiresstays the deliberate exception — its leading run is hoisted to the calling handler (ir/util/op-gates.ts) because authorization needs a principal the domain layer does not carry. - Phoenix was already doing this (its gate has always lived inside the context function the workflow starter calls), so answer 2 would have meant removing enforcement from a shipping backend to reach parity.
- Cost is bounded: one line per gated operation in five domain emitters, and no wire change — the route-layer check is retained precisely so no status, envelope, or ordering moves.
Affects. generator/typescript/emit/aggregate.ts,
generator/dotnet/emit/entity.ts, generator/java/emit/entity.ts,
generator/python/emit/aggregate.ts (elixir's
vanilla/context-emit.ts already conformed); the route-layer checks in
platform/hono/v4/routes-builder.ts, generator/dotnet/cqrs/commands.ts,
generator/java/emit/service.ts, generator/python/routes-builder.ts are
unchanged. Pinned by test/generator/when-gate-domain-entry.test.ts;
documented in language-reference/06-behavior-and-statements.md.
Wave C0.6 — the fifteen rulings batch (drafted 2026-09-10)
The fifteen entries below were batched by packet C0.6 of Wave C0 in
new-plan/completion-waves-2026-09.md
(the table that used to live in that plan's Wave C5 now points here). They are
drafted so the owner can sign the batch off in one sitting: each carries the
question in one sentence, the options that were on the table, a default, its
reason grounded in the code as it stands on main @ bc7ed8f, what it unblocks
(mission id + wave packet), and Sources.
How the defaults work. Every entry is proposed. Thirteen carry default
applies 48 h after merge unless overridden — an absent owner does not stall the
fleet. Two are owner-only, no default and nothing may proceed on them
without a signature: D-HANDLE-REMOVAL (it deletes surface syntax) and
D-NUMERIC-INGRESS-STRICT (it is a breaking narrowing on a shipping wire).
When a default is taken, flip that entry's Status to PINNED and date it; when
the owner overrides, rewrite the Decision rather than adding a second one beside
it.
| # | tag | default in one line |
|---|---|---|
| 1 | D-ENVELOPE-RATIFY |
X envelope = a single-row find; the {id, ts, body} carrier is dropped |
| 2 | D-HANDLE-REMOVAL |
remove workflow handle; commandHandler is the named command entry — APPLIED (owner-signed) |
| 3 | D-FOR-IN-DOMAIN |
for in a domain body = honest gap + named successor; variant-match / if let = permanent refusals |
| 4 | D-NUMERIC-INGRESS-STRICT |
strict on both real narrowings — owner-only |
| 5 | D-DECIMAL-EXACT-MOMENT |
one PR: node + python + all 54 goldens re-captured; mint RS-35 |
| 6 | D-WRITE-TX |
state + outbox + audit + provenance in one tx; dispatch after commit |
| 7 | D-CROSSTENANT-ACK |
policy { deny on X } is the acknowledgment; allow global stays refused |
| 8 | D-READ-SURFACE-ORDER |
re-verify, then projection masking, then the two small gates, then system reads |
| 9 | D-LONG-AVG-DEFAULTS |
declared 2^53 ceiling for long; projection avg over money retypes to money |
| 10 | D-DAPPER-ALTER |
build the ALTER path in phase ⑨; the widened refusal lands first |
| 11 | D-PROJECTION-IMPLICIT-SUB |
an on(Event) subscribes in-process with or without a channel |
| 12 | D-FIRST-ON-EMPTY |
first is partial and fails on empty; firstOrNull is total; RS-36 minted — APPLIED (packet 2n) |
| 13 | D-ABSENT-JOIN-DATETIME-WIRE |
absent join value = wire null everywhere (RS-34 ratified); datetimes ship milliseconds; mint RS-38 (was RS-37 — Wave C5 5a minted decimal-exact as RS-37 first, the registry forbids gaps) |
| 14 | D-FLUTTER-BEARER |
Flutter native = bearer, Flutter web = cookie (a RULE 2 amendment) |
| 15 | D-MISC-C0 |
four small rulings: the .NET entry-point boundary, connection: semantics, per-op OpenAPI tags, scopeId |
| 16 | D-PAGE-BODY-EXPRESSION |
a page body is an expression tree; the statement if stays refused, owner M-T1.20 |
| 17 | D-SENSITIVE-INSPECT-ONLY |
sensitive(...) is inspect-only today; wire masking is mask unless; phases 2–4 stay M-T3.8 |
| 18 | D-POLYMORPHIC-ID-REPRESENTATION |
a <Base> id to a TPC base is a plain id column — no FK, no discriminator; owner M-T5.7 |
D-ENVELOPE-RATIFY — X envelope is a single-row find, not an {id, ts, body} carrier
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. Does find audit(): Order envelope mean a wrapped
{id, ts, body} payload, a single-row read, or nothing at all until the carrier
is built?
Options. (L) build the {id, ts, body} carrier on all five backends — it
needs a ts source in the IR that nothing supplies; (M) ratify the single-row
meaning node and python already ship and drop the distinct carrier meaning;
(S) refuse envelope until it is built.
Decision. (M) — envelope is arity sugar for a find that returns exactly
one row. This diverges from the completion plan's proposed default (S, "refuse
until built"); the reason is in the Rationale, and PR
#2852 is already open on main
taking this option.
Rationale.
- A wrapper would contradict a PINNED decision. D-ENVELOPE fixes every HTTP
response to one of four shapes — bare value,
Paged<T>, ProblemDetails, event-frame{ kind, occurredAt, correlationId, data }. An{ id, ts, body }wrapper is a fifth shape, and the event-frame is already the carrier for "an event plus its metadata". - Nothing supplies the carrier's own fields.
GENERIC_SHAPES.envelope(src/ir/stdlib/generics.ts:74-77) declares{ id: string, ts: datetime, body: P }andmonomorphizeGenericInstances(src/ir/enrich/enrichments.ts:1446-1458) synthesizes anOrderEnvelopepayload from it — but no emitter has a single arm for the ctor:grep '"envelope"' src/matches only the grammar, theGenericCtorNameunion and that shape table. Java and .NET render it structurally through theirTypeTargetasEnvelope<T>(java/render-expr.ts:1007,dotnet/render-expr.ts:1221); .NET declares the record exactly once (dotnet/emit/common.ts:191) and returns a bareTfrom aTask<Envelope<T>>signature (CS0029), java declares it nowhere (three references, zero definitions), elixir returns a JSON array. Building the carrier means inventing atsvalue five times. - Refusal costs the same emitter surgery as ratification and still leaves the
grammar, the stdlib shape table and
docs/generators.md:66's five ticks promising a carrier no author can use. Zero.dddin the repo uses the keyword (the reason every compile gate was blind), so ratification breaks no model either.
Consequences. X envelope means one row — the bare aggregate/payload wire
shape, 404 when absent. The envelope arm of GENERIC_SHAPES and its
monomorphized <Arg>Envelope payload go with the emitter change, or reduce to an
envelopeReturn() recogniser beside pagedReturn; leaving the {id, ts, body}
field list in the stdlib while no backend emits it is exactly the drift this
ruling removes. Java/.NET drop Envelope<T> from ports and impls (and delete the
now-dead record); elixir's find-controller reads and serialises ONE record
instead of Repo.all. A corpus fixture (test/fixtures/corpus/envelope.ddd)
enters the compile matrix in the same PR, because the gates cannot see the
carrier without one (rule 13). If a real event-envelope carrier is wanted later
it is a NEW ctor with a designed ts source, not this one.
Unblocks. M-T6.57 (P0) → wave C1 packet 1c; retires M-T6.14's DEBT-08
envelope deferral.
Sources. T6-backend-parity.md M-T6.57 +
its 2026-09-09 fleet note (F57);
2026-09-03-language-docs-audit-findings.md
F21; PR #2852; src/ir/stdlib/generics.ts, src/ir/enrich/enrichments.ts,
src/generator/{java,dotnet}/render-expr.ts,
src/generator/dotnet/emit/common.ts.
D-HANDLE-REMOVAL — workflow handle is removed; commandHandler is the named command entry
Status: applied — the owner signed option (b), remove, on 2026-10-21
(#3316 / M-T6.58, recorded on #3458), and the removal shipped as a hard cutover
with a codemod per the breaking-change policy (#3608): HandleDecl left the
grammar together with HandleIR, WorkflowIR.handlers, lowerHandle and
loom.workflow-handle-unsupported; a leftover handle is the parse error
loom.parse-error#workflow-handle-removed, which names commandHandler and
scripts/codemod-workflow-handle.mjs. The named-create half of the original
question is NOT part of this removal — a named command-triggered create still
parses, and its missing route is tracked on #4094.
Question. Does the workflow handle name(...) { … } declaration become a
shipping HTTP entry point on all five backends, or is it removed as redundant
with commandHandler?
Options. (a) emit a route per handle on all five and design the addressing
surface it lacks; (b) remove the declaration — grammar, lowering, and the
diagnostic that promises it — and point authors at commandHandler; (c) keep it
parsing behind an honest refusal.
Decision. (b) — remove, in one step: the grammar removal and the refusal
(a parse error naming commandHandler) landed together, so (c) was not needed.
Rationale.
- The addressing surface does not exist.
HandleDecl(src/language/ddd.langium:1488-1491) is'handle' name '(' params ')' ('requires' gate)? '{' body '}'— nobyclause, unlike its siblingWorkflowCreateDecl(:1473-1476, which carries('by' correlation=CorrelationExpr)?) and unlikeOnDecl, whosebywas deferred with a written reason.HandleIR(src/ir/types/loom-ir.ts:1361-1368) carriesname/params/statements/savesAtExitand no correlation. So even a fully built route cannot say WHICH saga instance it runs against: option (a) is not "write five emitters", it is "design a correlation surface first, then write five emitters". commandHandleralready covers the use case, with a full emitter on all five and an explicit-handler route surface.- Three places promise routing and zero deliver. Lowering fills
WorkflowIR.handlers(src/ir/lower/lower-workflow.ts:124-174),test/ir/workflow-handle.test.tspins it, andloom.duplicate-handler(src/diagnostics/messages.ts:314-317) tells the author that "aroute -> <Ctx>.<name>would be ambiguous" — while every emitter drops it through the same fail-openif (!h) continue;. Namedcreateis dropped by a different mechanism (lowerWorkflowpicks one primary). - Owner-only because a default must never take a keyword out of the language unattended.
Consequences. handle stops parsing (it is an ordinary name again); HandleIR,
WorkflowIR.handlers and lowerHandle are deleted; loom.duplicate-handler
loses its workflow-handle clause and keeps the handler-vs-handler arm. The
migration is scripts/codemod-workflow-handle.mjs: it lifts each handle into a
commandHandler written after its workflow, and leaves in place, marked, the
ones with no faithful rewrite — a body reading the workflow's own state or
helpers, an eventSourced workflow, a requires gate or modifier (a
commandHandler takes none), a parameter named id, a body reaching more than
one aggregate, or a name clash. docs/workflow.md § "There is no handle"
carries the migration note. No correlation-addressed continuation
(handle f(…) by <expr>) will be designed.
Unblocks. M-T6.58 → wave C1 packet 1a.
Sources. T6-backend-parity.md M-T6.58 + its
2026-09-09 fleet note;
2026-09-03-language-docs-audit-findings.md
F13; src/language/ddd.langium, src/ir/types/loom-ir.ts,
src/ir/lower/lower-workflow.ts, src/diagnostics/messages.ts.
D-FOR-IN-DOMAIN — for in a domain body is an honest gap with a successor; variant-match and if let outside their home are permanent refusals
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. for, if let and variant-match all parse inside an operation
body but lower only inside a workflow (or a page) — is each a permanent refusal,
or a refusal today with lowering promised?
Options. Gate all three permanently; lower all three; or split them.
Decision. Split, as the 2026-09-09 fleet recommended. variant-match
outside a ui action → permanent refusal. if let outside a workflow → permanent
refusal. for in a domain body → honest gap: a refusal code today, with a NAMED
successor mission to lower it.
Rationale.
- All three are silent today, so a code is the floor under every option.
src/ir/lower/lower-stmt.ts(the domain-body lowerer) has noForStmtorIfLetStmtarm at all and ends inreturn { stmt: { kind: "call", target: "function", name: "<unknown>", args: [] } }(:374-378), sooperation touch() { for n in notes { owner := n } }reports0 error(s), 0 warning(s)and emitsthis.<unknown>();.variant-matchreachessrc/generator/_stmt/target.tsand throws. fordiffers in KIND, not only in cost.ForStmtsits in the sharedStatementunion (ddd.langium:2075-2091), andlower-workflow.ts:588already lowers it with a per-iteration save — iterating a collection inside an operation is an ordinary domain shape the language cannot otherwise express.variant-matchis frontend-shaped by construction (its arms render event handlers) andif letis the workflow'srepo-letnarrowing; both have a home the author is being pointed back to, andfordoes not.- A "permanent" refusal on
forwould be re-opened the first time somebody models a bulk operation. Naming the successor now costs one heading and stops the code being read as a decision that iteration is unwanted.
Consequences. Three codes in src/diagnostics/messages.ts (C1 packet 1b is
the only packet permitted to mint), each with a code-docs.ts anchor and a
firing fixture. The for code's message says not yet lowered in a domain body
and names its successor; the other two say by design and name the construct's
home. Neither message may recommend the .NET/Java match-error form until
M-T6.61's binding fix is in. The <unknown> fallback in lower-stmt.ts is
itself a silent sentinel of the M-T9.55 class and is routed through the give-up
gate in the same wave.
Unblocks. M-T5.28 → wave C1 packet 1b.
Sources. T5-language-core.md M-T5.28;
2026-09-03-language-docs-audit-findings.md
F1/F4; src/ir/lower/lower-stmt.ts, src/ir/lower/lower-workflow.ts,
src/generator/_stmt/target.ts, src/language/ddd.langium.
D-NUMERIC-INGRESS-STRICT — request-side numeric lenience is narrowed to match the strict majority
Status: proposed — owner-only, no default. It is a breaking narrowing on a shipping wire; no client can be signed up to it by a timer.
Question. python and elixir accept request values the other three refuse — a
stringified number for an int field, and (elixir alone) a JSON number for a
money field. Narrow them, or ratify the lenience?
Options. (a) strict everywhere — refuse a string where a number is declared,
refuse a JSON number where money is declared; (b) lenient everywhere — widen
node/.NET/java to coerce; (c) leave the skew and document it.
Decision (proposed). (a) strict, on both rows.
Rationale.
- The contract is already strict on the majority and in the published schema.
node emits
z.number().int(), .NET'sSystem.Text.Jsonrefuses String→Number withoutAllowReadingFromString, and java'sWireNumberStrictnessfails theLogicalType.Integerstring coercion — the one backend that was moved there deliberately, by M-T6.48. python's acceptance is pydantic 2 lax mode and elixir's isEcto.Type.cast(:integer, "5"): two framework defaults nobody chose. - The money row is worse than a skew. Four backends type a
moneyrequest field as a STRING (z.string(),string Price,String price,Annotated[str, …]), so a JSON number never reaches a guard. Elixir's create/update path casts straight onto the:decimalcolumn, so nothing types the wire value andEcto.Type.cast(:decimal, 12.5)succeeds. RS-12 governs the RESPONSE direction only — it says how money serializes, not what a request may carry — which is why no existing gate saw this. - Owner-only because a client sending
{"qty":"5"}to a python or elixir deployable starts getting a 4xx it did not get before. Nothing in the repo can make that call for a downstream consumer.
Consequences. ConfigDict(strict=True) on python request models — or
per-field Strict(), which is narrower and leaves datetime parsing alone — and a
pre-cast/3 wire-type guard on the elixir changeset path
(__loom_int_field / __loom_money_field, answering through the existing
__loom_param_error responder the op-param arm already emits). The
characterization pins in the second describe of
test/conformance/numeric-ingress-parity.test.ts are deleted and replaced by the
same assertions inverted, in the same diff, so the contract is visibly moving;
the file's header measurement table is re-measured against the real
deserializers, not edited.
Not in this ruling — the third divergence is an ordinary defect. M-T6.60 also
records a 40-digit money string parsing on node/java/python/elixir, reaching
NUMERIC(19,4), and 500-ing from the database (only .NET refuses it, because
decimal.TryParse stops at ~29 significant digits). A 500 is never the right
answer, so there is nothing to rule: it is a defect, bounded from
MONEY_WIRE_SCALE + the NUMERIC(19,4) precision in
src/generator/money-scale.ts so one constant governs both the wire guard and
the column, and it drains with wave C1 packet 1e's G2644 numeric-ingress
residue row rather than waiting on a signature.
Unblocks. M-T6.60 → wave C2 packet 2f; Schemathesis F11.
Sources. T6-backend-parity.md M-T6.60;
numeric-types-audit-2026-08-23.md
F12 annex; M-T6.48 and test/conformance/numeric-ingress-parity.test.ts; RS-12,
RS-24.
D-DECIMAL-EXACT-MOMENT — decimal exactness ships as ONE PR with every wire golden re-captured
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0). The
semantic ruling was already given by the owner on 2026-09-07 (decimal
arithmetic is EXACT) and is not re-opened here; what this entry rules is the
rule NUMBER and the shipping shape.
Question. The owner ruled decimal arithmetic exact — under which RS number,
and how does a change that MOVES the oracle every other backend is compared
against actually land?
Options. (i) node first, then python, re-capturing goldens twice; (ii) one PR carrying node + python + every re-captured golden; (iii) freeze the goldens and compare against a second oracle.
Decision. (ii) — one coordinated PR, and mint RS-35 (not RS-31; see Consequences).
Rationale.
- The oracle is not one golden's property, it is every golden's.
jq -r .oracle test/behavioral/wire-golden/*.jsonanswersnodefor all 54 files (measured 2026-09-10). Any PR that changes node's decimal arithmetic moves all 54 at once, so a partial landing leaves the differential tier comparing four backends against an oracle that no longer describes node — green exactly where it should be red. - Splitting by backend also splits the review. The mission demands the goldens be reviewed diff-by-diff and never as a drive-by rebaseline; that review is only meaningful once, against the final oracle.
LOOM_WIRE_UPDATE=1is the capture path, and it is a single command over the whole set — sequencing buys nothing it does not cost twice.
Consequences.
- The plan's "mint RS-31" is stale.
docs/conformance-semantics.mdalready carries RS-31 (code-point.length), RS-32, RS-33 and RS-34. Under that file's own "Claim the NUMBER before you build" protocol the next free number is RS-35, and no open PR claims it (checked against the 11 open PRs on 2026-09-10). RS-36 and RS-37 are claimed inside this same batch by D-FIRST-ON-EMPTY and D-ABSENT-JOIN-DATETIME-WIRE — take RS-38 next. Superseded at the Wave C5 5a fold (2026-09-28): the datetime rule had not landed when decimal-exact minted, and the registry test forbids a gap, so decimal-exact took RS-37; D-ABSENT-JOIN-DATETIME-WIRE mints RS-38 when it lands. - The PR body states that historical rows are not rewritten, so values persisted before the change may disagree with values persisted after, and it carries the migration note.
- The blast-radius items the mission attached ride the same PR: node's
decimal.jsrunning at the default 20 significant digits (noDecimal.setemitted) against 28+ elsewhere, the inbound decimal precision-acceptance skew (java unlimited / .NET 28–29 / double-clamped), and thesum → decimalcatalog signature insrc/util/collection-ops.tsdisagreeing withtype-system.ts's body-type rule. - Scope the node implementation BEFORE writing it and report the finding: python
already has
Decimalon the column side (M-T6.45), so its gap may be narrow, while node likely needs a decimal library threaded through the domain layer and the derived-field evaluator.
Unblocks. M-T5.22 → wave C2 packet 2f.
Sources. T5-language-core.md M-T5.22 (the
2026-09-07 ruling);
numeric-types-audit-2026-08-23.md
F11 + annex; conformance-semantics.md RS-24 and the
claim-the-number protocol; test/behavioral/wire-golden/*.json.
D-WRITE-TX — one write transaction: state + outbox + audit + provenance; dispatch after commit
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. Which writes and which emits share a transaction, and what does a write path with no enclosing transaction do instead?
Options. (a) one transaction per aggregate save carrying the outbox, audit and provenance rows, with in-process dispatch after the commit; (b) a transaction per statement, relying on the relay to repair; (c) leave it per adapter, as today.
Decision. (a). The rule, stated once: an aggregate save opens exactly ONE
database transaction; the durable-event outbox rows, the audit rows and the
provenance rows written for that save commit INSIDE it; the remaining (ephemeral)
events are dispatched in-process AFTER the commit, never inside it. A write path
with no enclosing transaction — a workflow step, an extern handler, a timer —
opens one for the same set rather than inserting outside one.
Rationale.
- The shape already exists and is proven; the ruling ratifies the majority
rather than inventing a rule. node/drizzle drains
aggregate.pullEvents()BEFORE the write transaction, records the durable ones on that sametxhandle, and dispatches only the remainder after commit (src/generator/typescript/repository-save-builder.ts:247-255). .NET's relational path does the same, with the reason written into the emitter: "DispatchAsync AFTER the commit, on a second SaveChangesAsync, loses an event" (src/generator/dotnet/emit/repository.ts:455-463). - Dispatch-inside-the-transaction is the worse failure. A handler that throws rolls back a write that had already succeeded, and a handler that reads sees rows the caller can still roll back.
- Implementers: half of M-T4.3 item 5's text is stale on this head. The
mikroorm save now wraps in
this.em.fork({ keepTransactionContext: true }).transactional(async (em) => …)and records durable events on that callback's ownem(src/generator/typescript/emit/mikroorm.ts:1022-1067,MIKRO_OUTBOX_DRAIN_LINES/MIKRO_OUTBOX_RECORD_LINE) —G2667-C4landed. What still reproduces: .NET's document and event-sourced repositories dispatch afterSaveChangesAsyncwith noRecordDurableAsyncat all (dotnet/emit/repository.ts:909-921and:1155-1178); node's workflow-step / extern-handler / timer emits are deliberately fenced outside any transaction (src/platform/hono/v4/workflow-builder.ts:1239-1244); and java's class-@Transactionalcommand handlers against .NET/node's per-SaveAsynccommit is the §D5 asymmetry. Verify each before fixing (RUNBOOK §1).
Consequences. G2667-C4 / G2667-C5 / G2667-D5 close against a written
contract, so every remaining deviation is a defect rather than an undecided fork.
A cross-backend gate asserts the ORDER in the emitted source per adapter
(record-before-commit, dispatch-after-commit) and, wherever a runtime leg exists,
a booted-app proof of the crash window (rule 10) — a constant read back out of
the emitter proves consistency, not correctness (rule 12).
Unblocks. M-T4.3 item 5 → wave C2 packets 2b / 2c.
Sources. T4-eventing-temporal.md M-T4.3
item 5; generator-code-review-2026-08-24.md
§Follow-up register rows 2–3 and §D5; ledger rows G2667-C4 / G2667-C5 /
G2667-D5; dispatch-delivery-semantics.md.
D-CROSSTENANT-ACK — policy { deny on X } is the crossTenant acknowledgment; allow global stays refused
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. What does an author write to acknowledge that a crossTenant
aggregate is deliberately unscoped, so that a loom.crosstenant-needs-policy
gate has something to require?
Options. (a) relax loom.policy-target-not-tenant-owned so
policy { allow global on ExchangeRate } is accepted as acknowledgment-only;
(b) accept an explicit policy { deny on X } (and any ordinary authorization
rule naming the target); (c) mint a new clause
(crossTenant acknowledged / unscoped:).
Decision. (b).
Rationale.
- (a) carries a live trap. The ladder's
allow globallowers to ascopesentinel over thedataKeyandtenantIdCOLUMNS — columns thetenantOwnedcapability provides and acrossTenantaggregate does not have. Admitting the rule therefore ALSO requires teaching enrichment to treat it as acknowledgment-only and derive no filter; miss that and the emitters reference columns that are not in the table (a codegen crash, or worse, DDL-valid but wrong SQL).deny's always-false sentinel touches no column, so (b) has no such half. - (c) adds surface for a statement the policy block can already make, plus a grammar rule, a printer arm and a per-backend no-op.
- (b) keeps
loom.policy-target-not-tenant-ownedmeaning what it says — the read/write ladder is for tenant-owned targets — instead of acquiring an exception whose semantics differ from identical syntax on the next aggregate.
Consequences. A new loom.crosstenant-needs-policy requires every
crossTenant aggregate under a tenancy by system to carry an explicit
authorization rule naming it; a deny rule satisfies it, and so does any
allow/policy rule that names the target. Two fixtures gain the line
(tenancy-owned.ddd, policy-deny.ddd). D-TENANCY-SCOPE's safety note —
"crossTenant is fail-open at the tenancy layer" — finally gets its
enforcement: the fail-open stance becomes unwritable by accident, which is the
whole point of the acknowledgment.
Unblocks. M-T3.6 item (6) → wave C6.
Sources. T3-security-governance.md
M-T3.6 (6); tenancy-authorization-final-surface.md;
src/diagnostics/messages.ts loom.policy-target-not-tenant-owned#read|#write;
D-TENANCY-SCOPE, D-TENANCY-DEFAULT.
D-READ-SURFACE-ORDER — projection masking first, then the two small gates, then the system-read construct
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. M-T3.15 has sat at plan (awaiting sequencing sign-off) while its
premise moved under it — in what order do its remaining items land?
Options. Masking first; the system-read construct first; the two small security gates first.
Decision. In this order: (1) re-verify the mission body against the merged
head — #2523 landed the read-gate slice and #2766 moved more of it; (2)
projection masking (the loom.field-mask-projection-source launder, what is
left of root cause B); (3) the two small, independent, security-relevant items
in the same wave — gating the workflow-instance reads and the
commandHandler/queryHandler route gate; (4) the system-read construct
last, as its own mission.
Rationale.
- Masking is what other work waits on. Whether
sensitive-wire-unsupportedbecomes M-T3.8 phases 2–4 or a recordedscopedecision depends on whether a gated projection can carrymask unlessat all — and that is a row in wave C2 packet 2f. Masking first unblocks a drain; masking second means a second wave. - The launder is a security bug wearing documentation clothes.
loom.field-mask-projection-sourcemakes gated projections andmask unlessmutually exclusive, and its own remedy points authors back at the ungated CRUD routes. That should not sit behind a new-surface design. - The system-read construct is the only item that ADDS surface. Under the standing owner directive (gaps and bugs outrank architectural improvements, which outrank new surface area) it goes last — and it is cheaper once masking has settled what a compiler-owned read may return.
- (1) is not ceremony. The mission header already says "premise partly stale", and two of its four root causes have been closed by merged PRs. The RUNBOOK requires the re-verification before any of (2)–(4).
Consequences. M-T3.15's status line carries this order, and the two small gates may be split into their own headings if the wave needs the tree fence. If the re-verification in (1) finds masking already resolved, the order collapses to (3) then (4) and this tag is amended, not ignored.
Unblocks. M-T3.15 → wave C6; and the sensitive-wire-unsupported row in
wave C2 packet 2f.
Sources. T3-security-governance.md
M-T3.15; M-T3.15 plan;
PRs #2523, #2766; src/diagnostics/messages.ts
loom.field-mask-projection-source.
D-LONG-AVG-DEFAULTS — long gets a declared 2^53 ceiling; projection avg over money is typed money
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. Two numeric rows whose proposed defaults were recorded but never
signed off: what is long's contract, and what type does a query-time
projection's avg over a money column carry?
Options (long). (a) declare and enforce a 2^53 safe-integer ceiling now;
(b) upgrade the representation (BigInt / string wire) on node and python; (c)
leave it. Options (avg). (a) retype to money; (b) keep decimal and
document the divergence from the in-memory avg.
Decision. (a) and (a).
Rationale (long).
- Today node stores
longas a JSnumber(bigint(col, { mode: "number" })insrc/generator/typescript/emit/schema.ts; mikroormts: "number") and python routes declared int/long sums throughfloat()— both corrupt silently past 2^53, while .NET/Java/Elixir carry int64 exactly. There is no validator and no doc caveat. - Aggregate overflow is three-way divergent for one
.ddd: java's((Number) x).intValue()wraps silently, .NET's(int)cast throws (a 500), the rest pass the too-big value through. The unification takes .NET's behaviour: a value that does not fit is an ERROR. Java's silent wrap is the one shape that produces a wrong ANSWER instead of a failure. - The ceiling is the honest floor at S cost: it converts silent corruption into a refusal today. The representation upgrade (b) is a wire change on two backends for a bound nobody has yet hit — it becomes a named follow-up mission only if the ceiling pinches.
Rationale (avg).
src/ir/lower/lower-projection.tsstamps query-timeavg → decimaleven over a money column, so the mean of exact money crosses the wire as a float64 JSON number — while the in-memoryavgof the same field typesmoney?(type-system.ts) and ships the RS-12 four-decimal string. One word, two semantics, no gate.- The retype is a lowering change, not five emitters:
aggregateCoercion'sisMoneyarm (src/ir/util/projection-aggregate.ts) already knows how to format money on all five backends.
Consequences. python's declared-int/long aggregates route through int(); a
> 2^53 witness fixture proves the exact backends carry it and the ceiling
refuses on the others. The avg retype MOVES wire goldens, so it rides
D-DECIMAL-EXACT-MOMENT's single re-capture if both land in the same wave —
otherwise it carries its own reviewed re-capture, never a drive-by rebaseline.
Each arm is mutation-proved by file-copy revert.
Unblocks. M-T5.23 and M-T5.24 → wave C2 packet 2f.
LANDED 2026-09-13 (PR #2903).
Both options (a) are implemented, and one thing the decision's text did not
anticipate is worth recording: the avg retype alone would have been inert.
The DECLARED projection row type is what every backend's coercion dispatches on
and nothing checked it, so an author could keep declaring decimal for a money
mean and keep the lossy float — the retype ships with
loom.projection-aggregate-type-mismatch, which also admits the one widening
(int → long) an author needs to opt out of the integral range refusal. The
long ceiling is enforced at the literal (loom.integer-literal-imprecise) and
at the node wire boundary; the residual — the four non-node backends still accept
past the ceiling and store exactly — is pinned in
test/conformance/numeric-ingress-parity.test.ts and belongs to
D-NUMERIC-INGRESS-STRICT, since closing it is either a representation upgrade
or a uniform ingress narrowing.
Sources. T5-language-core.md M-T5.23,
M-T5.24; numeric-types-audit-2026-08-23.md
F13, F14; RS-12; src/ir/lower/lower-projection.ts,
src/ir/util/projection-aggregate.ts, src/generator/typescript/emit/schema.ts.
D-DAPPER-ALTER — dapper and mikroorm get a real ALTER path in phase ⑨; the widened refusal lands first
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. persistence: dapper (and its mikroorm twin) has no ALTER path at
all — is that a permanent scope row, or a phase-⑨ build?
Options. (a) a permanent scope row with a D-tag: "self-provisioning
adapters are CREATE-only by design"; (b) widen the honest gate so any
post-baseline schema change refuses; (c) render the MigrationsIR chain for
these adapters.
Decision. (c) — build it — with (b) landing FIRST, in the same wave, as the interim.
Rationale.
- (a) is not honest for a shipping adapter.
renderDapperSchema(src/generator/dotnet/emit/dapper.ts:2229-2233) is documented as "a self-applied CREATE TABLE IF NOT EXISTS per aggregate", andgrep -c 'ALTER TABLE|ADD COLUMN' src/generator/dotnet/emit/dapper.tsis 0. So on a dapper deployable: add a field to a live database and the column is never created (the app 500s); drop one and it lingers; change a type and nothing happens. - And the existing gate mostly does not fire.
validateMigrationAdapterSupport(src/ir/validate/checks/migration-checks.ts:203-260) raisesloom.dapper-unsupported#migrationsonly when the model DECLARES amigrationblock — it indexes the rename/backfill/SQL-step intents and returns early when they are all empty. The common case is silent. - The build is bounded.
emitDotnetMigrationsalready produces platform-neutral Postgres throughsrc/generator/sql-pg.ts. What is missing is emitting it as an ordered.sqlset applied byDbSchema.EnsureAsyncbehind a__loom_migrationsledger, then flippingconst hasMigrations = !usingDapper && …(src/generator/dotnet/index.ts:990). The mikroorm twin is!usingMikroatsrc/platform/hono/v4/emit.ts:1067and is ruled the same way here. - (b) first because (c) is L and a user can lose a column today. The widened
gate fires when a self-provisioning deployable is regenerated against an
existing baseline whose derived diff is non-empty — the snapshot store is
already threaded into phase ⑨ via
fsSnapshotStore(outDir).
Consequences. A named T2 mission owns the build, with a
test:migration-evolution-dapper (and -mikroorm) leg added to
migration-evolution-e2e.yml — today all five legs run on the default
efcore/drizzle adapters. loom.dapper-unsupported#migrations,
loom.mikroorm-unsupported#migrations and the ledger row
dapper-no-schema-evolution close when that leg is green, not when the gate
widens. Note what the widened gate replaces: today a second generate exits 1 on
snapshot drift with an error that names neither the adapter limitation nor a
remedy, and --allow-rebaseline does not clear it.
Unblocks. dapper-no-schema-evolution → wave C2 packet 2b (the mikroorm
arm in 2c); M-T6.35's #migrations sub-code.
Sources. targets-completeness-2026-08-30.ledger.json
row dapper-no-schema-evolution (fix field: both options, sized);
T6-backend-parity.md M-T6.35;
src/ir/validate/checks/migration-checks.ts, src/generator/dotnet/emit/dapper.ts,
src/generator/dotnet/index.ts, src/platform/hono/v4/emit.ts.
D-PROJECTION-IMPLICIT-SUB — an on(Event) handler subscribes in-process whether or not a channel carries the event
Status: PINNED — APPLIED (2026-09-14, wave C2 packet 2f).
Question. Does a projection … { on(e: E) { … } } whose E is carried by no
declared channel fold, or not? (Today node folds it; python, java, .NET and
elixir never subscribe. This is register/ledger row B20 /
G2646-open-projection-on-event-no-channel.)
Options. (a) it folds — a channel is TRANSPORT, the on declaration is the
subscription; (b) it does not, and node's leniency becomes a refusal; (c) a
diagnostic requires the channel.
Decision. (a) — implicit in-process subscription on every backend.
Rationale.
- (b) removes a working behaviour from a shipping backend to reach parity — the shape D-WHEN-GATE-DOMAIN already rejected for the same reason.
on(e: E)reads as the subscription. Nothing in the projection's own syntax mentions transport, and requiring achannelmakes a read model's correctness depend on a declaration in a different block whose stated purpose (docs/channels.md) is cross-deployable delivery and durability.- The mechanism is one early return.
deriveEventSubscriptionsopens withif (!channels || channels.length === 0) return [](src/ir/enrich/enrichments.ts:1303) and then keeps only events some channelcarries:, so python emits noapp/dispatch.pyat all and java/.NET/elixir emit no fold, whilesrc/generator/typescript/emit/routes.tsfolds off the projection directly. The divergence is not a considered position on four backends; it is a guard nobody revisited. - Scope, stated so it is not re-litigated per handler kind. The same
derivation feeds workflow reactors (
onandcreate … by), so the rule is written once for both: an in-process handler in a deployable that also hosts the emitter subscribes implicitly. Achannelremains required for delivery ACROSS deployables and for durability (retention:) — which is what it is for.
Consequences. The early return is replaced by an implicit subscription
carrying no channel, and every backend's dispatcher builder learns the
channel-less subscription (python must emit dispatch.py in that case). No
diagnostic is needed on either side: the losing behaviour disappears rather than
being refused. A corpus fixture carrying a projection with NO channel — the shape
the audit had to construct by deleting a block from projection.ddd — enters the
fixture set in the same wave (rule 13), because a compile gate over a corpus that
lacks the shape proves nothing.
Unblocks. G2646-open-projection-on-event-no-channel (B20) → closed in wave
C2 packet 2f (2b measured the dotnet arm as a no-op and 2c measured the node
arm as "not a node arm"; both were right — it was one change on the shared IR,
plus the two emitter-side copies of the same carriage filter, in elixir's
resolveProjectionSubs and node's buildProjectionsFile).
As built. deriveEventSubscriptions (src/ir/enrich/enrichments.ts) drops
both filters and yields a subscription per consumer, with channel: undefined
when nothing carries the event (EventSubscriptionIR.channel is now optional; no
emitter read the field). BOTH warnings go, not just the projection one — this
decision's own scope paragraph writes the rule once for both consumer kinds, so
loom.reactor-event-uncarried had become exactly as false as
loom.projection-event-uncarried. Corpus fixture
test/fixtures/corpus/projection-implicit-sub.ddd (backends: ALL) carries an
uncarried projection fold AND an uncarried workflow reactor;
test/generator/projection-implicit-sub.test.ts names the emitted fold and
reactor symbol per backend.
Sources. behavioral-parity-bugs-2026-07.md
B20; language-gaps-2026-08.md (the
"needs a semantics decision" row); src/ir/enrich/enrichments.ts:1284-1340;
src/generator/typescript/emit/routes.ts.
D-POLYMORPHIC-ID-REPRESENTATION — a <Base> id to a TPC base is a plain id column: no FK, no discriminator
Status: applied — the proposed default took effect 2026-09-24 (merged to main 2026-09-22 in #2970; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Owner mission M-T5.7; register row loom.polymorphic-id-ref-unsupported.
Question. src/language/validators/inheritance.ts rule 6 refuses a
<Base> id whose target is an abstract base with the TPC (ownTable) layout,
because "there is no single table to key the FK against". Before any backend
can emit it, the SCHEMA has to be decided — and the choice is visible in the
emitted DDL, so it is a ruling, not an implementation detail.
Options.
(a) a polymorphic FK pair — a <field>_type discriminator column beside
<field>_id, no referential integrity (Postgres cannot FK to a union of
tables); (b) no FK, no discriminator — one plain id column, resolved by the
DELEGATING polymorphic base reader that M-T5.7 already ships; (c) a CREATE VIEW union over the per-concrete tables, read through the view.
Decision. (b). A <Base> id to a TPC base is an ORDINARY id column —
uuid NOT NULL plus its read index, no foreign key and no discriminator — and
the read resolves through the existing delegating base reader.
Rationale — measured on this tree (wave C2 packet 2n), not reasoned. The completion plan proposed (a) as the default. The measurement argues for (b), and it is worth stating why, because three of the pieces (a) would build turn out to exist already:
- The FK is ALREADY dropped, by a rule that predates this question.
migrations-builder.tskeeps a foreign key only when its target table exists in the same schema ("cross-context id references get NO foreign key", M-T4.4). A TPC base OWNS NO TABLE, so its FK fails that test and is filtered out automatically. Generated with rule 6 bypassed, node emits exactly the intended DDL with no code change at all:"payment_id" UUID NOT NULLandCREATE INDEX "receipts_payment_id_idx", noREFERENCES. - The discriminator buys nothing, because ids are globally unique.
lower.tsassigns every aggregateidValueType = "guid", so an id cannot collide across the concrete tables. And the reader that would consume a discriminator does not want one:buildDelegatingBaseReaderFile(src/generator/typescript/base-reader-builder.ts) resolvesfindByIdby trying each concrete repository in turn — which is also what loads each aggregate's FULL tree through its own capability filters, something a discriminator-directed single query would not do. Adding(kind, id)would add a column every writer must keep in step with the row it points at, for a lookup that does not read it. - (c) is strictly worse than (b) here. A union view can only project the
columns the concretes share, so it reads flat scalars and silently drops
contained parts and
X id[]associations — the exact trade the TPC base reader's own comment records rejecting.
Consequences — what is left to build is SMALL, and it is not the schema.
Generated with rule 6 bypassed, all five backends emit the reference column
correctly and ddd generate reports 0 error(s). The gap is two missing
IDENTITY TYPES:
| target | state with the gate bypassed |
|---|---|
| node | correct — payment_id uuid, index, no FK; Ids.PaymentId exists |
| python | correct — PaymentId = NewType("PaymentId", str) exists |
| elixir | correct — field :payment_id, :binary_id, no identity type needed |
| java | breaks — Receipt.java declares @Embedded PaymentId paymentId but no PaymentId.java is emitted: src/generator/java/index.ts skips an abstract TPC base (if (agg.isAbstract && !isTphBase(...)) continue;) |
| dotnet | breaks — ReceiptConfiguration.cs calls new PaymentId(v) with no Domain/Ids/PaymentId.cs: the same skip in src/generator/dotnet/context-scaffolding-emit.ts |
Both skips carry the comment "an abstract TPC base keeps no identity (each
concrete owns a typed id)", which was TRUE exactly while this reference was
refused. Lifting rule 6's ownTable arm therefore means emitting <Base>Id
for a TPC abstract base on those two — a value wrapper, not a schema change.
Still open, and NOT settled by this ruling. Whether FOLLOWING such a
reference works: the id-follow bulk load (src/ir/util/id-follow.ts) and a
query-time projection … join <Base> as p on this.paymentId were not
exercised, and they are the shapes most likely to need the base reader wired in
rather than a per-concrete repository. Rule 6's MIXED-strategy arm is also
entangled: with the ownTable arm removed, loom.polymorphic-id-ref-mixed-strategy
fires on a PURE TPC hierarchy (every concrete is ownTable, so every concrete
reads as an "override"), so its predicate has to narrow to a sharedTable base
in the same change.
Sources. src/language/validators/inheritance.ts rule 6;
src/system/migrations-builder.ts (the M-T4.4 FK filter);
src/generator/typescript/base-reader-builder.ts; src/ir/lower/lower.ts
(idValueType = "guid"); wave C2 packet 2n's hand-off note
(docs/new-plan/waves/handoffs/wave-c2-2n-backends.md), which carries the
generated output the table above summarises.
D-FIRST-ON-EMPTY — first is partial and fails on an empty collection; firstOrNull is the total form
Status: PINNED — APPLIED (wave C2 packet 2n). RS-36 is minted in
conformance-semantics.md; ledger row F2-EXPR-7
is done.
Question. .first on an empty collection throws on three backends and yields
an undefined/nil typed as non-optional on two — which is the contract?
Options. (a) first: T is PARTIAL and fails on empty everywhere;
(b) first becomes T? (total), and every model reading .first.x must change;
(c) leave it per target.
Decision. (a). first: T keeps its non-optional type and fails on an
empty receiver on every target; firstOrNull: T? is the total form, and the
failure message names it.
Rationale.
- (b) contradicts the declared signature.
src/util/collection-ops.ts:23already declaresfirstas returning a non-optionalT, and (b) would break everylines.first.skuin the language for a case authors can already express withfirstOrNull. - The two degrading targets are the ones violating their own declared type.
node renders
${recv}[0](src/generator/_expr/js-collection-ops.ts:67→undefined) and elixir rendersList.first(${recv})for bothfirstandfirstOrNull(src/generator/elixir/render-expr.ts:905-906— literally the same snippet), so astring-typed getter returnsnil/undefinedand the wrong value flows onto the wire or dies later somewhere that does not mention the collection. The three that fail — dotnet.First()(:948), java.get(0)(:634), python[0](:577) — at least fail at the point of the mistake. - A failure at the read is diagnosable; a null that ships is not. This is the same argument RS-34 makes for the absent-join case, reaching the opposite conclusion only because there the absent value has a MEANING (no joined row) and here it does not — the model asserted a first element exists.
Consequences. Mints RS-36 in docs/conformance-semantics.md (RS-35 is
claimed by D-DECIMAL-EXACT-MOMENT in this batch). elixir moves first to hd/1
and keeps List.first/1 for firstOrNull; node emits an explicit guard that
throws instead of [0]. The failure surfaces as the sanitized 500 RS-28
already governs — it is not a domain-floor 422, because the request was valid and
the model's assumption was not. The frontends follow the same rule under
frontend-collection-op-unsupported (wave C2 packet 2g) rather than degrading to
undefined in a page body; the seven emitters render the raise and the error
boundary shows it. The contract is pinned in src/util/collection-ops.ts the way
src/util/intrinsics.ts pins scalar edge behaviour, plus a per-backend arm test.
Unblocks. ledger row F2-EXPR-7 → wave C2 packet 2g (frontend half:
M-T1.20).
Sources. targets-completeness-2026-08-30.ledger.json
row F2-EXPR-7 (its fix field poses exactly this fork);
conformance-semantics.md RS-28, RS-34;
src/util/collection-ops.ts, src/util/intrinsics.ts, the five
render-expr.ts leaf tables and src/generator/_expr/js-collection-ops.ts.
As built (wave C2 packet 2n). Two things the ruling did not anticipate.
(1) The node guard could not be a ternary: recv.length > 0 ? recv[0] : …
emits the whole receiver CHAIN twice, and that table is shared with the four JS
frontend walkers, where the receiver can contain hook calls — so it is an arrow
IIFE taking the receiver as a parameter, and the "evaluated exactly once"
property is itself pinned. (2) The assertion that actually catches the elixir
shape is not "first raises" but "first and firstOrNull render
DIFFERENTLY": List.first/1 reads as a plausible implementation of either op,
so a raise-only gate would have passed on the defect. The FRONTEND half named
in Consequences is vacuous today — loom.frontend-collection-op-unsupported
refuses every stdlib collection op in a page body — so the shared table's guard
is backend-reachable only; that is recorded in RS-36 rather than left as a
claim the frontends were changed.
D-ABSENT-JOIN-DATETIME-WIRE — an absent join value is wire null on every target; sub-second datetimes ship as milliseconds
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. Two wire-form questions the G2667-D3 row left open: what does a
LEFT-JOINed field carry when the join target is absent, and what fractional-second
form does a datetime take on the wire?
Options (join). wire null everywhere / each language's own zero default /
drop the source row. Options (datetime). node's minimal-digit trim (.12Z) /
exactly three digits when a fraction is present (.120Z) / microseconds
(.120000Z).
Decision. Join = wire null, on every backend and every field type —
RS-34 already pins this and is ratified here, including the .NET value-typed
case its own "Open" section leaves unfixed. Datetime = milliseconds: at most
three fractional digits, and a whole-second instant carries no fraction at all
(RS-4's canonical …00Z is preserved).
Rationale (join). RS-34 is already in the registry with all five arms landed
and mutation-proven; the only fork left is .NET's default! on a value-typed
joined field, where a joined int/bool/decimal/datetime reads
0/false/DateTime.MinValue instead of null. That is not a second
semantics, it is an unfinished arm — and RS-34's own reason (wire null is the
only value that means the same thing on every backend and needs no per-type
default table) already decides it.
Rationale (datetime).
- The observed triple has one mechanism per backend. node canonicalises with
${date}.toISOString().replace(/\.?0+Z$/, "Z")(src/generator/typescript/repository-wire-builder.ts:139), which strips ALL trailing zeros, so.120spells.12Z; java'sInstant.toString()emits digits in groups of three (.120Z); python'sisoformat()emits six (.120000Z); elixir emits none at all, because its column is:utc_datetime(second precision) where every other backend's isTIMESTAMP WITH TIME ZONE(ledgerF2-W-06). - Milliseconds is the only precision every target can carry end to end. node's
Dateis millisecond-resolution by construction, so a microsecond contract needs a NEW datetime carrier on node (a string or a decimal) — a far larger change than truncating four backends. - Three digits rather than node's minimal trim, because
.12Zand.120Zare the same instant spelled two ways: a differential tier comparing strings then has to normalise, and normalisation is exactly what hidF2-W-05andF2-W-06. - Correction to the plan's proposed default. "millisecond precision, three
digits, on every backend" as written contradicts RS-4, which is PINNED,
conforming on all five, and says a whole-second instant spells
…00Zwith the zero fraction trimmed. This ruling therefore keeps RS-4's whole-second form and applies "exactly three digits" only when a fraction is present.
Consequences. Mints RS-38 for the datetime wire form (RS-35 and RS-36 are
claimed elsewhere in this batch; RS-37 went to decimal-exact at the Wave C5 5a
fold on 2026-09-28, because the registry forbids gaps and this rule had not
landed). RS-34 gains its .NET value-typed arm and its
"Open" section closes — the absent branch stops being default! and the
Response schema widens that joined member to nullable. Elixir moves declared
datetime columns to :utc_datetime_usec with the matching timestamptz
migration column and deletes the truncation machinery that exists only because
of the old type (__truncate_dt/1 in vanilla/context-emit.ts,
stampFieldIsDatetime in stamp-emit.ts, the operation-returns force_change
wrap) — leaving it in place would truncate on the wider column and half-fix the
row. Sub-millisecond input is truncated at ingress, not rounded, so the
stored value and the wire value agree and a read-back equals the write (RS-4);
rounding can carry a value into the next second. The <timestamp> normalisation
that masks this in the differential tier is narrowed in the same PR, or the fix
cannot be seen.
Unblocks. G2667-D3 (.NET value-typed arm) → wave C2 packet 2d;
F2-W-06 → wave C2 packet 2a.
Sources. conformance-semantics.md RS-4, RS-34
(its Open section); targets-completeness-2026-08-30.ledger.json
rows G2667-D3-projection-join-unguarded-index and F2-W-06 (with the W1b
elixir note that sizes it);
src/generator/typescript/repository-wire-builder.ts,
src/generator/dotnet/query-projection-emit.ts,
src/generator/elixir/vanilla/context-emit.ts.
D-FLUTTER-BEARER — Flutter native authenticates with a bearer token; Flutter web keeps the cookie
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. RULE 2 of the realtime contract says a stream carries the same
HttpOnly session cookie as an ordinary API call from the same frontend — a
mobile client cannot hold one. What credential does Flutter use?
Options. (a) bearer on native, cookie on web — two credentials in one client; (b) bearer everywhere, so the Flutter web build drops the cookie too; (c) a short-lived query-param token for the stream on native.
Decision. (a) — recorded as a plan amendment to RULE 2, not a port.
Rationale.
- An HttpOnly cookie cannot exist on an Android/iOS client, so RULE 2's stated credential is unavailable there by construction. RULE 2's own reasoning ("the SPA never sees the raw token") does not hold for a native app, whose OIDC client necessarily holds the token itself.
- Server-side the change costs nothing. Four of five backends already accept
the
sessioncookie alongsideAuthorization: Bearer, and node grew the cookie read in the same wave — bearer is the credential every backend has always accepted. - (c) puts a credential in URLs, and therefore in access logs, proxies and
Referer— the reason the query-param token lost the first time. - (b) would make the web build of a Flutter app diverge from every other
generated SPA for no gain;
BrowserClient()..withCredentials = trueis one line.
Consequences. The fix is upstream of the stream: Flutter's ORDINARY API
calls are bare top-level http.get/http.post with no client at all
(src/generator/flutter/reads-emit.ts, forms-emit.ts, auth-gate.ts:90,
flutter-target.ts:621), so there is no credential path for the stream to
inherit and fixing only the stream would be theatre. Order: (i) an
authenticated http client — BrowserClient()..withCredentials = true on web, a
bearer store fed by the app's OIDC client on native; (ii) thread the
credential into renderFlutterRealtime and emit
web.EventSource(uri.toString(), web.EventSourceInit(withCredentials: true)) in
the web transport plus the bearer header in the IO transport (so
REALTIME_SOURCE_WEB_DART / REALTIME_SOURCE_IO_DART stop being const strings);
(iii) realtimeStreamCredential (src/ir/util/realtime-rooms.ts) gains the
native value, so the gate stays ONE predicate rather than two. auth: none
deployables stay byte-identical.
Unblocks. M-T4.12 item (1) → wave C2 packet 2j, where it shipped (src/generator/flutter/api-client.ts + the two conditional-import halves + lib/loom_bearer.dart; loomEventSource takes {withCredentials, bearer}; realtimeStreamCredential gained "cookie-web-bearer-native" and keys it on deployable.platform, so the gate stayed ONE predicate as this decision required). flutter analyze 0 errors / 0 warnings, flutter test green and flutter build web --release green on the generated credentialed app; auth: none is unchanged.
Sources. T4-eventing-temporal.md
M-T4.12 (Wave 1 packet 1g note, RULE 1 / RULE 2 and "Still open under this ID"
item 1); auth.md ("Session depth");
src/ir/util/realtime-rooms.ts, src/generator/_frontend/realtime.ts,
src/generator/flutter/realtime.ts.
D-MISC-C0 — four small rulings that needed a name, not a debate
Status: applied — the proposed default took effect 2026-09-13 (merged to main 2026-09-11 in #2863; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. Four rows whose proposed answers nobody disputes, but which no tag records — so each is re-decided by whoever picks the row up.
Decisions.
generateDotnetForContexts(M-T9.52). The boundary stays where M-T9.49 drew it: the ratchet gates the WRAPPER (generateDotnet), mirroring Hono's (which gatesgenerateHono, notgenerateTypeScriptForContexts). Sequence M-T9.42's corpus promotion ofgenerator-dotnet.test.tsFIRST — it is 66 of the 150 pinned .NET call sites and M-T9.42's largest single candidate, so doing it first shrinks this row by nearly half and shrinks the ratchet's pins in the same move. Re-measure the remaining directForContextsimporters afterwards, and only then decide whether the rung below needs its own assertion. Verification is two-sided per M-T9.40 (re-seed theenumName: undefinedmutation and report the count with AND without the new assertion) — a one-sided "N tests fail" number does not distinguish a working instrument from an unrelated string assertion.connection:on astorage(M-T7.9) — the two semantics. (a)service(x)names a service the composer is already synthesising; it selects and labels, it does not create a second one, soservice(db)and no clause at all describe the same topology and the declaration's only effect is on the emitted reference (the secret/env key). (b) Where the declared source contradicts the derived topology, the declaration wins and the composer emits what was declared — a heuristic that silently overrides an explicit author statement is unexplainable at the point of failure, and the currentHost=db;classifier insrc/system/kubernetes.tsis exactly such a heuristic.literal("postgres://user:pass@…")stays legal and keeps its warning (it is a real escape hatch for an already-public external endpoint); revisit only if a model needs it silenced.- OpenAPI tag grouping (M-T6.13), decision (f). Emit an explicit lowercase
per-op
tags: ["<slug>"]on .NET and Java too. They emit no explicit per-op tag today, so this is not a regression — it aligns them with the other three. Without it, Swashbuckle/springdoc default the per-op tag to the CONTROLLER name ("Orders"), whichx-tagGroups's"orders"reference does not match, so the feature would silently not group on exactly two of five backends. Verify the default-tag value on a booted app, not from the framework docs — the proposal itself flags the value as extrapolated (rule 12). scopeId(M-T3.11).scopeIddenotes the business boundary a frame belongs to;parentIddenotes call structure ("who invoked me") — the two axesexecution-context.mdseparates and that are easy to conflate. Loom pins the DEFAULT rather than leaving it to configuration: one scope per inbound request, or per workflow RUN, whichever opened the outermost frame; a sub-workflow opens its own scope, a helper call does not. Leaving it "to the system author" is what let five backends differ. Not author-configurable in v1. The ambient carrier keeps its D-CTX-SHAPE field set (correlationId,scopeId,parentId, pluscurrentUser/locale/startedAt); the genealogy detail (operationId,nodeId,kind,timestamp) stays on the emitted scope event.
Rationale. Each of the four has a recorded leaning that survived a code-check, and none has a second defensible answer that anyone has argued for; what they lack is a NAME, so each is re-litigated by the next agent to open the row. Item 4 is the most judgement-laden of the four — the proposal explicitly left it open — and is the one most worth an owner glance.
Consequences. Four mission bodies stop carrying an undecided fork. Item 1
imposes a SEQUENCE (M-T9.42's promotion before M-T9.52), which the C3 packet must
honour. Item 2 deletes the storage-connection row from RESERVED_SURFACES when
the wiring lands, and the k8s emitter's Host=db; classifier is replaced by the
declared source rather than extended.
Unblocks. M-T9.52 → wave C3 packet 3d; M-T7.9, M-T6.13 and M-T3.11 → wave C6.
Sources. T9-toolchain-health.md M-T9.52;
T7-deployment-ops.md M-T7.9;
api-openapi-tag-grouping.md
§Open decisions (f);
T3-security-governance.md M-T3.11 and
execution-context.md §Open questions;
D-CTX-SHAPE.
D-TPH-BEATS-SHAPE — a TPH concrete's shape: never reaches storage
Status: PINNED (2026-09-13, wave C2 packet 2e).
Question. What does aggregate Container extends Shipment shape: embedded
mean when Shipment is inheritanceUsing: sharedTable? Two header modifiers
each claim to decide where the concrete's row lives, and nothing said which wins
— so each emitter answered on its own, and three of them answered differently.
What the code already ruled, before anyone wrote it down. Phase ⑨ decides
what tables EXIST, and tablesForOneAggregate
(src/system/migrations-builder.ts) tests isTphConcrete/isTphBase before
the shape arms: a TPH concrete gets the shared base table plus RELATIONAL child
tables for its containments, and no jsonb column anywhere. The drizzle
schema emitter agrees. So the DDL a shape: embedded TPH concrete runs against
has never had a place to put an embedded document, on any backend.
Decision. TPH wins. A concrete that shares its base's table owns no table
of its own, therefore has nowhere to put a jsonb containment column, therefore
its shape: modifier does not apply: containments persist relationally, FK'd to
the shared row. Every backend's schema emitter and repository builder must agree
with the phase-⑨ DDL, which is the single authority on what exists.
This is the same rule loom.es-tph-forced-own-table already enforces for the two
shapes that CANNOT degrade — persistedAs: eventLog and shape: document have
no queryable root row to share, so the validator makes the author write
inheritanceUsing: ownTable explicitly. embedded is the one shape that can
degrade (its root already is a queryable row), which is why it is allowed through
rather than refused.
Follow-up, deliberately NOT taken here. A silent degrade is still a silent
degrade: the author wrote shape: embedded and got relational children. The
honest alternative is to extend rule 4 of src/language/validators/inheritance.ts
so shape: embedded under a sharedTable base is refused the same way, with the
same "declare inheritanceUsing: ownTable" remedy. That is a language-layer
change touching every backend's expectations at once (it would also close
pairwise F11 on node by deleting the crossing), so it belongs to the packet that
owns src/ir/** + src/language/** — wave C2 packet 2f — not to a
per-backend packet. Until it is taken, the degrade is at least coherent: every
target does the same thing, and the same thing is what the DDL creates.
Consequences. Pairwise F13 closes as a python conformance fix (the schema
emitter's TPH arms move ahead of the shape arms; a TPH concrete takes the
relational repository builder). Pairwise F11 on node is re-read the same way:
the drizzle repository must target the owner table, not the concrete's own. The
crossing enters the curated corpus with tph-crossings.ddd, whose header names
the embedded half as the widening step once F11 closes.
Superseded in part at the wave fold (2026-09-13). The follow-up this entry declined to take was taken in the same wave by packet 2c as D-EMBEDDED-TPH: shape: embedded under a sharedTable base is now REFUSED at phase ④ (loom.es-tph-forced-own-table), so the crossing this entry ruled on is unreachable from .ddd and there is no degrade left to be coherent about. What survives of this entry is its floor: the python schema emitter and repository builder test the TPH arms before the shape arms, mirroring migrations-builder.ts, so a TPH concrete can never again be handed a table phase ⑨ does not create. The python gate that drove the crossing (tph-embedded-storage.test.ts) was deleted at the fold because its fixture no longer validates; the ordering is pinned by the phase-④ refusal plus the migrations builder's own arm order, and the coordinator recorded the trade in [docs/new-plan/waves/wave-c2.md](https://github.com/Loom-Harness/Loc/blob/cadbe3ba12fb833565704cb9d628c8f41b2090a8/docs/new-plan/waves/wave-c2.md).
Sources. src/system/migrations-builder.ts tablesForOneAggregate;
src/ir/util/inheritance.ts tableOwnerName;
src/language/validators/inheritance.ts rule 4;
inheritance.md;
docs/audits/pairwise-corpus-findings-2026-08.md §F11/§F13.
D-PYTHON-SINGLE-REALIZATION — python ships one realization, and that is not a gap
Status: PINNED (2026-09-13, wave C2 packet 2e).
Question. Ledger row G2646-open-python-no-realization-axes reads python's
absence from src/platform/adapter-metadata.ts as debt: persistence: and
directoryLayout: "offer no choice on that backend while node (drizzle/mikroorm)
and dotnet (EF/dapper) do". Sized L, unowned, and re-opened by every parity
sweep.
Measured, on fresh main. The asymmetry the row describes is not
python-vs-the-rest — it is two adapters vs one. BACKEND_ADAPTER_METADATA
lists elixir with exactly one persistence adapter (ecto) and java with
exactly one (jpa); neither offers a choice either. Three of five backends ship
a single realization. Python's only real difference is that it is ABSENT from the
mirror rather than present with a single-valued menu — and the file says that
absence is deliberate and matches the live surface (its PlatformSurface omits
adapters()).
Decision. Python ships one realization — SQLAlchemy 2 async over asyncpg,
one directory layout — and that is a product statement, not an incomplete
implementation. The row is re-classed scope. A second python persistence
adapter (Tortoise, Piccolo, raw asyncpg) is a T6-track mission to be minted
when a user need names the adapter, with the same bar every adapter carries: its
own pairwise cover cell, its own corpus compile leg, its own behavioural leg.
Nothing about the current state is silent — there is no gate to add, because
there is no persistence: value a user can write that python drops.
Owner. The T6 backend track, on demand. Not a completeness row, and not wave
C2's: no packet closes it by building, and leaving it open guarantees it is
re-derived by the next sweep.
Consequences. G2646-open-python-no-realization-axes moves out of the
ledger's open bucket. If python ever does grow a second adapter, the honest
first step is the adapter-metadata.ts entry (a single-valued menu, like java's
and elixir's), so the mirror stops being read as a completeness signal —
recorded here as the shape of that change, not scheduled.
Sources. src/platform/adapter-metadata.ts (BACKEND_ADAPTER_METADATA, and
its own note on python's absence); platforms.md;
docs/audits/targets-completeness-2026-08-30.ledger.json.
D-EMBEDDED-TPH — a shape: embedded concrete of a sharedTable base is forced to ownTable, like the other two non-relational shapes
Status: applied — the proposed default took effect 2026-09-16 (merged to main 2026-09-14 in #2907; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. shape: embedded × TPH (inheritanceUsing: sharedTable) does not
work on any backend that implements embedded. Is that a cross-emitter mission,
or does the language refuse the crossing the way it already refuses the other two
non-relational shapes under a shared table?
Options. (a) build it — the TPH owner table grows one nullable jsonb column
per embedded concrete's containment, on node (drizzle + mikroorm), python and
.NET, plus the matching phase-⑨ DDL; (b) extend Rule 4's forcesOwn set with
embedded, so the author declares inheritanceUsing: ownTable exactly as an
event-sourced or document concrete must; (c) leave it — a recorded compile
waiver per backend.
Decision. (b).
Rationale.
- The rule already exists and already covers the neighbours.
persistedAs: eventLogandshape: documentare forced toownTablebyloom.es-tph-forced-own-table(src/language/validators/inheritance.tsRule 4, D-ES-TPH) for the same underlying reason: their truth is not a row in a shared table.embeddedis the third non-relational shape and was simply missed — (b) adds one disjunct to a set, one word to a message and nothing to the surface. (a) adds a per-backend feature nobody has asked for. - What (c) was actually recording, measured. On node, the drizzle embedded
repository named
schema.thingswhile a TPH concrete's row lives inschema.thingBases(19 × TS2339 per case, compile waiver F11; MikroORM had the same defect one name over,ThingRowvsThingBaseRow) — and the schema emitter did not put the jsonb containment column on the owner table either: it emitted a relationallinesCHILD TABLE, so re-pointing the repository would have moved the error rather than removed it. Python emits BOTH tables (F13); .NET maps no containment at all. Three backends, three different wrong answers, and the phase-⑨ migration DDL mirrors the schema emitter's mistake on all of them. - (a) is a four-file change per backend for a shape with no demand. Two emitters and the phase-⑨ DDL on each of node/python/.NET, held together by a drizzle-schema-vs-migration agreement nothing currently gates. The crossing came from the pairwise MATRIX, not from a user: it is a cell the generator enumerates, not a shape anyone wrote.
- The forced form is not a downgrade.
embedded × ownTablecompiles and runs on both node adapters today (verified:tsc --noEmitclean on the generated project for drizzle and mikroorm), and it is what the author wanted anyway — an aggregate whose containments fold into ITS row needs a row of its own.
Consequences. loom.es-tph-forced-own-table gains shape: embedded; its
message names the shape rather than only the event-sourced arm. COMPILE_WAIVERS
entry F11 is deleted (the crossing can no longer be written), and the pairwise
composer writes the forced override for embedded as it already does for
document / eventLog, so the cover reaches embedded × ownTable instead of
spending its TPH cells on a validator floor. The python twin F13 loses its
source the same way — 2e deletes its own entry. M-T2.10's "embedded on Drizzle
still emits relationally" clause is resolved by the same measurement: it emits
jsonb correctly, and emitted relationally ONLY under TPH.
Reversible. If a real model ever needs the crossing, (a) is unblocked by deleting one disjunct — nothing here bakes the refusal into an emitter.
Sources. test/pairwise/waivers-compile.ts F11 (and F13);
targets-completeness-2026-08-30.ledger.json;
T2-data-evolution.md M-T2.10;
inheritance.md; D-ES-TPH (the rule this extends).
D-EF-TPH-ROOT-READS — .NET EF reads every TPH concrete from its root, under root-hosted, type-guarded named filters
Status: applied (#3960, epic #3954). Supersedes D-TPH-SUBTYPE-FILTER,
which declared a TPH subtype's filter over a subtype-only column a v1 limit of
the EF adapter (loom.tph-filter-unsupported), and the per-read .Where
successor it named (#3391).
Question. EF Core registers every query filter of an inheritance hierarchy
on the ROOT entity type. How does the EF adapter apply a subtype's own
restriction — softDeletable on Car, a filter this.seats > 0 reading a
column only Car has — on every read of that subtype, and on none of its
siblings'?
Options. (a) refuse the shape (the old gate); (b) drop the subtype filters
off the model onto a per-read LINQ .Where at each of ~30 emitter read sites;
(c) keep them on the model, hosted on the root and type-guarded, and read every
concrete from the root set.
Decision. (c). A subtype's own filter is registered on the root config (a
principal one on AppDbContext) as !(x is Car) || <pred over ((Car)x)>,
named Car_<Filter> so two subtypes' filters keep distinct keys. Every read of
a TPH concrete starts from the root: _db.Vehicles.OfType<Car>(). ignoring
maps to IgnoreQueryFilters([names]), the root-hosted names included, and
never to the parameterless overload on a TPH participant. The filters are
rendered from the readScopeFor fact (its subtypeRestrictions are exactly the
guarded set). The polymorphic base read queries DbSet<Base>, where EF applies
every guard to the rows it governs. TPC keeps modelBuilder.Ignore<Base>().
Rationale.
- It is EF's own mechanism, and it holds at runtime. Measured against EF
Core 10.0.10 / Npgsql 10.0.3 on a real Postgres 17: by-id, list, paged
(
Count+Skip/Take), bulk by-ids,Any, tracked update, the base read and a projection over the base all apply the guarded filters;IgnoreQueryFilters(["Car_…"])before or afterOfTypelifts exactly that one; the tenant filter stays. The SQL is the one a hand-written query would carry:WHERE v."TenantId" = @t AND (v.kind <> 'Car' OR NOT v."IsDeleted") AND v.kind = 'Car'. - The guard fails only over a sibling's own set —
(Car)xinside a filter applied toDbSet<Truck>cannot be built ("No coercion operator is defined between types 'Truck' and 'Car'"). So theAppDbContextexposes NODbSet<Concrete>for a TPH concrete: a read naming one does not compile, andtest/generator/dotnet/dotnet-tph-capability-filter.test.tscensuses every corpus hierarchy for one. - A model filter is enforced for every query, a per-read
.Whereonly where an emitter put it. (b) would have traded a framework-enforced restriction for an emitter-enforced one across the adapter; a read path it missed is indistinguishable from success. Under (c) the only read the emitter can get wrong is the query SOURCE, and that is a compile error. - Tenancy stays the DbContext's
_currentUserfield — Microsoft's documented multi-tenancy pattern for query filters.
Consequences. loom.tph-filter-unsupported, its message and its register
row are deleted; tph-crossings and inheritance-read-restrictions generate
and pass on the dotnet behavioural leg, and inheritance-base-reads passes
there too. The Dapper adapter is unaffected (it splices predicates into SQL)
and does not emit the TPH base reader, which queries the EF root set.
Sources. src/generator/dotnet/ef-read-source.ts,
src/generator/dotnet/emit/efcore.ts (hostedQueryFilters, typeGuarded),
src/generator/dotnet/emit/base-reader.ts, src/ir/enrich/read-scope.ts;
https://learn.microsoft.com/en-us/ef/core/querying/filters,
https://learn.microsoft.com/ef/core/modeling/inheritance.
D-PHOENIX-FORMAT-GATE — the generated-Elixir mix format gate is declined permanently; Dialyzer is unscheduled
Status: applied — the proposed default took effect 2026-09-16 (merged to main 2026-09-14 in #2907; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. M-T6.3 has deferred the mix format / Dialyzer CI gates over
generated Phoenix output twice, each time with the same reasoning and no ruling.
Wave C2 packet 2a was told to decide rather than defer a third time. Does Loom
ever run mix format --check-formatted (or Dialyzer) over the projects it
emits?
Options. (a) activate mix format --check-formatted as a per-PR gate and
teach the emitters to satisfy it; (b) activate it nightly-only, accepting a red
nightly until (a) is done; (c) decline the format gate permanently and close the
mission; (d) decline the format gate and schedule Dialyzer instead.
Decision. (c). mix format --check-formatted is never run over
generated output, in any tier. Dialyzer and Credo over generated output are
unscheduled — not deferred, not owned by a mission; anyone who wants either
opens a new mission carrying its own evidence that it finds a class of defect
the compile gate does not. M-T6.3 closes on this ruling; its slice-1 work (the
.formatter.exs that scopes the OpenApiSpex subtree out) stays, because it is
emitted config a human running mix format by hand benefits from — it is not a
gate.
Rationale.
- The measurement is already done and it is not close. M-T6.3's own
source-grounded scoping (2026-07-21, real
mix formatin thehexpm/elixirimage, the api_spec subtree excluded) found an irreducible ~250-diff-line / 20-file structural residual — blank-line insertion,case-clause consistency, un-wrapping the emitter's own pre-wraps — thatline_lengthplateaus against (447 → 253 diff-lines between 98 and 200, then flat) and that no other configuration reaches, becausemix formatis deliberately non-configurable: no per-rule toggles, no# format: offregions. - So the only close is teaching ~10 emitters the formatter's rules, which is an L grind whose result is brittle in the worst direction: an all-or-nothing gate that any future Elixir-emitter edit can break, re-checkable only through the slow docker + hex-mirror loop. That is a gate whose expected cost is dominated by false alarms on unrelated changes.
- The payoff is cosmetic, and the non-cosmetic property is already gated.
Generated Elixir compiles
mix compile --warnings-as-errorsclean inelixir-vanilla-build.yml; that catches unused bindings, undefined references, and unreachable clauses — everything about the output a reader depends on. Nobody hand-edits a generated.ex, which is the only situation where a formatting standard earns its keep. - Deferring again is worse than declining. A mission parked with "reassess if a cheaper mechanism appears" is a standing invitation to re-derive the same measurement; two agents have now paid for it. A named decline stops that, and is trivially reversible — if Elixir ever ships an ignore-region directive, the new mission opens with a one-line premise instead of a re-scoping.
- Dialyzer is a different question and gets a different answer. It finds real type errors rather than cosmetics, so "declined" would be wrong — but on GENERATED code its candidate findings are emitter bugs, and the compile gate plus the per-backend boot legs already reach most of them. A PLT build per run is not free. It has no evidence behind it today, so it gets no mission; it gets the same treatment as any unproposed gate.
Consequences. M-T6.3 moves to archive/T6-done.md with this tag as its
evidence. No workflow gains a mix format step, and a future agent finding
unformatted generated Elixir should cite this tag rather than open a row. The
reusable measurement tooling the scoping produced (the real-formatter diff loop:
startHexMirror → generate system → mix format with import_deps resolved
→ diff) stays described in the archived mission, so reversing the ruling starts
from a recipe rather than from scratch.
Unblocks. M-T6.3 → wave C2 packet 2a (closed by this ruling, no code).
Sources. T6-backend-parity.md M-T6.3 and
its two deferrals; vanilla-phoenix-gaps.md
§7; static-analysis-followups.md
Slices 1–2; src/generator/elixir/vanilla/shell-emit.ts
(renderVanillaFormatterExs).
D-ANGULAR-EXTERN-CHILDREN — children into an Angular component: built for the walked flavour, a language question for extern
Status: applied — the proposed default took effect 2026-09-16 (merged to main 2026-09-14 in #2933; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Raised by wave C2 packet 2h, which was asked to close
loom.component-children-unsupported.
Question. A user component invoked with CHILDREN — the extra positional
argument every JSX-family frontend renders between the open and close tags —
was dropped on Angular, because Angular has no PascalCase component tag and the
call site went through <ng-container [ngComponentOutlet]="X" …>, which sets
INPUTS and has no content-projection channel. Is the whole row a gap to drain?
Options. (a) drain it for both component flavours; (b) drain the walked
flavour and re-class the remainder; (c) refuse permanently and rename the code
out of the -unsupported suffix.
Decision. (b), and the split is not a compromise — the two halves are different KINDS of problem.
A walked component is one Loom emits: components-emit.ts writes the class
and stamps @Component({ selector: "app-panel" }), and its body's Slot { }
already rendered <ng-content></ng-content>. Loom therefore knows a tag for
it, and the call site can be one — <app-panel [label]='"a"'>…children… </app-panel>, with the class in the page's standalone imports: [] instead of
NgComponentOutlet. That is now what is emitted (renderAngularUserComponent,
src/generator/angular/walker/angular-target.ts), so the drop is GONE for this
flavour rather than merely named. It was a gap; it drained.
An extern component is a hand-written Angular class reached through a
re-export shim, and its @Component({ selector }) is the author's. Loom has no
tag to spell, so it must keep the outlet, and the outlet cannot project content
from a template at all (ngComponentOutletContent takes pre-built DOM nodes —
TS-side only). No amount of Angular emitter work closes that: the missing thing
is INFORMATION, and the only place it can come from is the .ddd. Addressing
an extern component by tag needs a surface — a selector clause on extern from
— which is a grammar change plus a lowering field plus a validator rule, on a
declaration every OTHER frontend addresses by imported symbol rather than by
tag. That is a language ruling, not a per-target TODO, which is what makes the
remainder a scope row rather than a gap left half-drained.
NOT (c): the shape is expressible the moment the surface exists, and the diagnostic keeps naming a real loss in the meantime.
Successor. M-T1.33 (docs/new-plan/T1-ui-frontend.md) — the extern
selector clause, its validator rule, and the gate's deletion.
Consequences. loom.component-children-unsupported is kind: "scope",
owner M-T1.33 (MAX_OPEN_GAPS 24 → 23). The gate narrowed to c.extern in
src/ir/util/component-children.ts, so a walked component with children no
longer warns; its message now names the extern flavour and points at dropping
extern from as the in-language remedy. The degradation comment at the call
site (#2734) survives on the outlet arm only.
Sources. src/generator/angular/walker/angular-target.ts
(renderAngularUserComponent, angularTargetFor),
src/generator/angular/walker/page-shell.ts (the two registrations),
src/ir/util/component-children.ts, docs/extern.md,
docs/new-plan/waves/handoffs/wave-c2-2h-angular.md.
D-FLUTTER-COMPONENT-BINDINGS — a Flutter component reaches the ref-backed page bindings, but never the route id
Status: applied — the proposed default took effect 2026-09-16 (merged to main 2026-09-14 in #2933; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Question. loom.user-component-deferred-target refuses nine Flutter
component shapes and loom.flutter-async-effect-unsupported a tenth. All ten
have ONE root: a Flutter user component is emitted as a plain StatelessWidget
/ StatefulWidget, so it holds neither a Riverpod WidgetRef nor a route. It
therefore cannot reach the four bindings a PAGE shell declares — a read
provider, a store provider, the session currentUser, and the route id. Are
all four one problem, and is the answer the same for each?
Measured on this tree (wave C2 packet 2j — every filter bypassed, the project generated, the emitted Dart read; the fixture is in the packet's hand-off note). Nine shapes emit Dart that names an undeclared local and one crashes codegen:
| shape | emitted with the filter bypassed |
|---|---|
derived reads the route id |
String get label => id; |
derived reads a store |
int get n => count; |
derived reads currentUser |
String get who => currentUser.email; |
| body reads a store field / calls a store action | Text('${count}'), onPressed: () { bump(); } |
body reads the bare route id |
Text('${id}') |
body renders DestroyForm |
DeleteOrderForm(id: id) |
body renders OperationForm |
RenameOrderForm(id: id) |
body reads a currentUser claim |
Text('${currentUser.email}') |
state {} and a read |
a StatefulWidget whose build names orderAll, the provider local only a ConsumerWidget hoists |
an action's match await |
no Dart at all — renderNotifierStmt throws its internal floor: "the Flutter Riverpod Notifier emitter cannot render an action statement of kind 'variant-match'" |
So every deferral is honest: emitting instead of deferring produces Undefined name Dart that flutter analyze rejects, which is strictly worse than the
drop it replaced.
Decision — the four bindings split two ways, not one.
The three
ref-backed bindings (a read provider, a store provider,currentUser) are ORDINARY WORK and get a mission. Riverpod already supplies the shape: a stateless component that needsrefis aConsumerWidget— which the Flutter emitter ALREADY builds for a read-bearing component (renderConsumerComponent) — and a stateful one is aConsumerStatefulWidget+ConsumerState, whererefis an inherited property so bothsetStateand the existing read hoisting work unchanged. Nothing about this needs a ruling; it needs an emitter. Successor: M-T1.34.The route
idis REFUSED, permanently, in the form the shapes above ask for it. A component has no route by construction, and there is no honest place to get one from: the only candidate is "a component that declares a param namedidgets the page's route arg", which would makeida magic parameter name in a component's signature — a second, invisible meaning for an ordinary identifier, decided by spelling. Loom already blessesidinside a PAGE body, where it is unambiguous because a page has exactly one route; extending that to components would mean a component's behaviour depends on whether its caller happens to sit on a:idroute, which is exactly the kind of action-at-a-distance the walker's other bindings avoid. The shapes stay refused, and the diagnostic already tells the author the two ways out (host the shape on a page, or pass the record itself as a typed param).The async effect follows the
idruling, not therefone.match await <api>.<Agg>.<op>()on an INSTANCE operation posts to/<coll>/$id/<op>, so it needs the routeidand is refused for the reason in (2) — even once (1) lands and the component holds aref. Its register row therefore moves fromgaptoscope(a declared v1 limit with a named successor), owned by M-T1.34, which closes therefhalf and re-narrows the gate's message to name theidas the only remaining cause.
Rationale.
- The split is what the measurement shows. Three of the four bindings have a Riverpod shape sitting right there and one does not; calling all four "the Flutter component gap" would have produced one oversized mission whose easy three quarters never shipped because its last quarter needed a ruling.
gapmeans "drains to zero". The register's own header says so. The async-effect row cannot drain while its cause is a deliberate refusal, so leaving it agapmakesMAX_OPEN_GAPSa number that can never reach its target — the exact failure thescopekind exists to prevent.- The refusal is already the kinder behaviour. Both alternatives to
deferring were measured:
Undefined nameDart (nine shapes) and a codegen crash (the tenth). The diagnostic a user gets today names the component, the cause, the emitter function and two ways out; that is a better product than either. - Reversing (2) is cheap if the owner disagrees. It is one binding in one
seam (
flutterTarget.renderRouteId, already a per-target seam, and packet 2h established the per-walk…TargetFor(…)factory shape on Angular). What this tag buys is that the next agent does not re-derive the question.
Consequences. loom.flutter-async-effect-unsupported becomes
kind: "scope", mission: "M-T1.34"; MAX_OPEN_GAPS drops 25 → 24.
loom.user-component-deferred-target keeps its nine Flutter arms until M-T1.34
deletes the three ref-backed ones. The measured recipe per shape lives in
M-T1.20 so the next agent starts from emitted Dart rather than from a bypass
run.
Unblocks. flutter-async-effect-unsupported and the deferred-component
shapes → wave C2 packet 2j (dispositioned there); the build → M-T1.34.
Sources. T1-ui-frontend.md M-T1.20, M-T1.32;
src/generator/flutter/component-emit.ts (candidates,
emittableComponentParams, derivedNeedsShell, needsPageShell,
isReadConsumer, hasAsyncEffectAction);
src/ir/validate/checks/ui-component-deferral-checks.ts (flutterDeferrals);
src/ir/validate/checks/store-checks.ts (the async-effect arm);
src/generator/flutter/riverpod-emit.ts (renderVariantMatchNotifier, and the
internal floor the bypass reached).
D-PAGE-BODY-EXPRESSION — a page body is an expression tree; the statement if stays refused on every frontend
Status: applied — the proposed default took effect 2026-09-16 (merged to main 2026-09-14 in #2933; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Raised by wave C2 packet 2f, which was asked to give
loom.if-stmt-page-body-unsupported a mission id or a D-tag.
Question. loom.if-stmt-page-body-unsupported refuses an if STATEMENT in a
ui page / component / store body on every frontend. It sits in the
*-unsupported register as a scope row with no owner — so is it a v1 limit
someone should drain, or the shape of the surface?
Options. (a) keep it a scope row and commission a mission that gives page
bodies statement-form control flow on all six frontends; (b) rule the limit
permanent and rename the code out of the -unsupported suffix; (c) leave it
unowned.
Decision. (a) is declined and (c) is not an option, but the code KEEPS its
name and its row: the limit is ruled permanent in substance, and the row is
re-pointed at M-T1.20 — the mission that already IS the register of frontend
refusals accepted in .ddd, and therefore the place a reader looks for "what
does the page surface refuse, and why". The scope kind is correct — a declared
v1 limit with a named owner — and renaming the code would cost a rename across
six emitters and the docs for no behavioural change.
Rationale.
- It is not a per-target gap, and never was. All six frontends refuse it, for
the same reason: a page body is an EXPRESSION tree. Each emitter builds a
value — JSX, an Elmish view, a Flutter widget, a HEEx template — so a
condition there is a value too. The three spellings already exist and are
rendered on every target: the ternary (
cond ? a : b), the value-formmatch { cond => …, else => … }, andmatch await <op>() { … }for a union result. Nothing an author needs is missing; one spelling is. - The refusal replaced a crash, not a working path. Before the gate the JS
walker hit a bare
throwon the unknown statement kind — a raw stack trace with no code — and the other engines dropped it silently. There is no shipping behaviour to preserve on any target. - Giving page bodies statements is a surface decision, not a drain. It would
mean a statement position in the page metamodel, a rendering strategy per
engine (JSX has no statement position inside an element tree; HEEx has no
handle_eventto hoist from a render), and a printer arm — i.e. a change to what a page IS. That belongs to the frontend-surface mission, not to a packet sweeping honest gates. - So the row's problem was ownership, not classification. An unowned
scoperow reads as "somebody should do this and nobody has"; pointing it at M-T1.20 — the mission that already collects the frontend refusals accepted in.ddd— says what it actually is: the thing that would have to change first.
Consequences. loom.if-stmt-page-body-unsupported gains
mission: "M-T1.20" in src/diagnostics/unsupported-register.ts. Its three
siblings gated by the same reasoning (loom.ui-handler-statement-unknown's
statement vocabulary) are unaffected. No emitter changes.
Unblocks. loom.if-stmt-page-body-unsupported → owned (wave C2 packet 2f).
Sources. src/ir/validate/checks/if-stmt-checks.ts (validateIfStatementPlacement,
the UI arm); src/diagnostics/messages.ts (loom.if-stmt-page-body-unsupported);
page-metamodel.md.
D-SENSITIVE-INSPECT-ONLY — sensitive(...) is a DECLARATION with one shipped consequence; wire masking is mask unless, and phases 2–4 are a commissioned mission
Status: applied — the proposed default took effect 2026-09-16 (merged to main 2026-09-14 in #2933; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Raised by wave C2 packet 2f, which was asked to "build M-T3.8 phases 2–4, or
record the S alternative as a scope decision".
Question. loom.sensitive-wire-unsupported warns on every sensitive(...)
field a caller can actually receive: the tag reaches exactly one consequence
today (the synthesized inspect prints <redacted>), while the response DTO
carries the value in cleartext on all five backends and nothing classifies it at
a log / event / resource sink. It is a gap row in the register — the sprint
backlog. Is it one?
Options. (a) build phases 2–4 here: authorized(<category>) declassification,
wire mask: strategies in five DTO emitters, sink classification; (b) re-class
it scope with M-T3.8 as the named successor, keeping the honest warning;
(c) rename the code out of the suffix — rule sensitive permanently a
documentation-and-inspect tag.
Decision. (b) — scope, successor M-T3.8. Not (c): the tag's name states
a wire promise a reader will assume, and the proposal it comes from specifies the
masking. Not (a) inside a per-target drain packet, for the reason below.
Rationale.
- The row is HONEST already, and specific. The warning names the one
consequence that ships, states plainly that the field is serialized in
cleartext to any caller allowed to read the aggregate, and gives two things
that work TODAY:
mask unless <predicate>(the per-caller read redaction, wired at the response boundary on all five) andinternal/secretaccess (never served). So the author is not surprised, and the surface that redacts is not missing — it is a different surface, with a different meaning (mask unlessis per-caller authorization;sensitiveis a data-category classification). - Phases 2–4 are three features, not one drain. Phase 2 is a type-system
change (
authorized(<category>)declassification, with the 2-lite warnings becoming errors); phase 3 is a per-strategy masking arm in five DTO emitters plus the OpenAPI schema that describes them; phase 4 is sink classification — every log event, every emitted domain event's payload, every outbound resource call. Phase 4 in particular has no chokepoint today: the_obs/catalog, the channel envelope and the resource clients each build their own payload. - Its failure mode is the one a sweep must not risk. A masking arm applied
at four of five response sites, or to the read DTO but not the audit snapshot
(the exact asymmetry
mask unless×auditedhit on .NET, M-T3.9), is indistinguishable from success in every test that asserts by shape. It needs a booted acceptance per backend and a sink census — a mission's acceptance criteria, not a packet's. scopeis what the register means by this. "A declared v1 limit with a named successor" — the limit is declared in the diagnostic, the successor exists and is sized L. Leaving itgapsaid the opposite: that a drain sprint could close it, which is how a security-shaped feature ends up half-ported by someone counting rows.
Consequences. The register row's kind becomes scope (it already names
mission: "M-T3.8"), and MAX_OPEN_GAPS drops by one. No emitter changes and no
message change — the warning keeps firing on every reachable sensitive(...)
field, which is the point. M-T3.8 keeps its partial status and its L size; this
tag is the reason its phases must land together rather than one emitter at a
time.
Unblocks. loom.sensitive-wire-unsupported → scope (wave C2 packet 2f);
M-T3.8 stays open and commissioned.
Sources. src/ir/validate/checks/sensitivity-checks.ts;
T3-security-governance.md M-T3.8;
sensitivity-and-compliance.md;
auth.md (mask unless).
D-ELIXIR-IF-BRANCH — the four refused if-branch shapes on Phoenix are a declared limit of the linear body renderer, not a per-target gap
Status: applied — the proposed default took effect 2026-09-24 (merged to main 2026-09-22 in #2970; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Raised by wave C2 packet 2m, which was asked to build the four sub-shapes
loom.elixir-if-stmt-unsupported still refuses or re-class them.
Question. Wave C2 packet 2a made the if STATEMENT render on the vanilla
Phoenix backend (src/generator/elixir/vanilla/if-stmt-emit.ts, M-T6.59) and
narrowed the gate to four #slug sub-shapes: #return-in-branch,
#guard-in-branch, #event-sourced and #branch-statement. Are those four a
residue a sweep can drain, or the shape of the surface?
Options. (a) build all four; (b) re-class the code scope with the owning
mission named; (c) leave it a gap row and let the next packet re-measure.
Decision. (b). The code KEEPS its name, its four messages and every arm that
fires. What changes is the claim about who closes it: the register row becomes
scope and stays pointed at M-T6.59, which is the mission that owns the
Phoenix body renderer. Each surviving sub-shape needs a change to HOW a Phoenix
operation body is built, not another emitter arm — and three of the four are not
even elixir-local.
Rationale, one sub-shape at a time. Each was re-derived on this head, not inherited from 2a's note.
#return-in-branchis a list-level transform. Elixir has no early exit, so areturninside a branch means restructuring every statement that FOLLOWS theifinto the other arm of acase— the linear renderers emit one Elixir expression perStmtIR, in order, and the sourcemap collector (statementSubRegions) zips that same-length, same-order list. It is already ALLOWED where it is expressible:elixirIfRefusal(stmts, "value")admits it in a tail-value body (adomainServiceoperation, a purefunction) viareturnsAreTailOnly, which is the half that renders today.#guard-in-branchwould change a status code. The op path HOISTS top-levelrequires/preconditioninto a leadingwith :ok <- ensure(…)chain that answers 403/422. A nested guard has nothing to hoist into, so it would have toraise— a 500 where the other four backends answer 403/422. A wire divergence is strictly worse than the refusal, and fixing it properly means giving the Phoenix op path a non-hoisted guard form: a body-renderer change.#event-sourcedis a different body model. An ES command body is not rendered as a statement sequence at all:eventsourced-emit.tssorts it intowith-clauses,lets and oneevents = […]list. A conditionalemithas no position in that shape. Rendering it means giving ES commands a statement spine — the same body-renderer change, on the other body kind.#branch-statementis a CLOSED branch vocabulary, and its two sharpest members live OUTSIDEsrc/generator/elixir/**. The emitters decide an operation's supporting machinery by scanning its TOP-LEVEL statements, and two of those scans cannot simply "walk deeper":- a conditional
emitrenders, but the host module'srequire Loggercomes fromcontextEmitsEvent(vanilla/context-emit.ts:857), and — the part a deeper walk does not fix — the S5a persist-then-dispatch restructure cannot hoist a CONDITIONAL emit past the commit, so a phantom event would fire on a failed write. Ordering, not detection. - a PROVENANCED write in a branch is decided by
opHasProvSite(src/ir/util/prov-id.ts:49), which is TARGET-NEUTRAL and scansop.statementsone level deep for every backend. Deepening it changes what node / .NET / java / python put in provenance-flush mode too, so it is a cross-backend change that a packet fenced tosrc/generator/elixir/**must not make unilaterally (hand-off, §6b of this packet's note).
The vocabulary is closed (fail-closed) deliberately: a NEW
StmtIRkind is refused in a branch rather than silently admitted by an allowlist nobody updated.- a conditional
Consequences. loom.elixir-if-stmt-unsupported becomes kind: "scope" in
src/diagnostics/unsupported-register.ts and keeps mission: "M-T6.59";
MAX_OPEN_GAPS drops by one. No emitter change, no message change, no arm
removed — every one of the four keeps firing with the advice it carries today.
The opHasProvSite deepening is recorded as a cross-backend hand-off rather
than done here.
Unblocks. loom.elixir-if-stmt-unsupported → scope (wave C2 packet
2m); M-T6.59 stays open and owns the body-renderer question.
Sources. src/ir/validate/checks/if-stmt-checks.ts (elixirIfRefusal,
BRANCH_VOCABULARY); src/generator/elixir/vanilla/if-stmt-emit.ts;
src/generator/elixir/vanilla/eventsourced-emit.ts; src/ir/util/prov-id.ts;
T6-backend-parity.md M-T6.59.
D-HEEX-I18N-FORMAT — i18nFormat is a documented permanent LiveView divergence, not a gap
Status: applied — the proposed default took effect 2026-09-24 (merged to main 2026-09-22 in #2970; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Raised by wave C2 packet 2a (§7.1 of its hand-off), which found the ledger's own
proposed disposition unavailable, and taken by packet 2m.
Question. G2646-open-heex-layout-inert carries two live arms. One is the
missing pager on a non-server-paged Table — built in packet 2m. The other is
the i18nFormat wrapper, which heex-walker-core.ts's arm drops by design ("the
format is dropped: render the wrapped operand"). The ledger's fix line says
"honour it or pin it in the heex-parity freeze list with a reason" — but that
freeze list is over walker PRIMITIVES, and i18nFormat is an ExprIR kind, so
the pin it proposes does not exist.
Options. (a) add a CLDR-shaped number/date formatter dependency to every
generated Phoenix app and honour the wrapper; (b) ratify the drop as a permanent,
documented LiveView divergence and retire the arm from the ledger row; (c) mint
a loom.* code so the author is told their format is ignored.
Decision. (b), with the operand's rendering unchanged. The value still renders — what is dropped is the LOCALE-AWARE FORMATTING of it.
Rationale.
- (a) is a dependency decision wearing a gap's clothes. The four JSX targets
format through
Intl, which is in every browser at zero cost. Phoenix renders on the server, where the equivalent is a CLDR library compiled into the app (ex_cldrand friends) — a new hex dependency, a compile-time locale set, and a per-app configuration surface, added to EVERY generated Phoenix app for a wrapper most models never use. That is a feature with a dependency ruling in it, not a drain. - (c) would fire on a model that is otherwise fully supported. The i18n
layer itself ships on this target —
pgettextkeys every user-visible slot, and packet-era work added the ICU engine for INTERPOLATION (D-I18N-HEEX-ICU). Only the number/date FORMAT wrapper is dropped. An error there would refuse a page whose text is already translated. - The drop is already local and documented at the emission site, which is where a reader meets it.
Consequences. The i18nFormat arm is retired from
G2646-open-heex-layout-inert; with its pager arm built in packet 2m and its
Grid arm already retired as stale, the ledger row closes. The emission-site
comment in src/generator/elixir/heex-walker-core.ts is the record. If a
generated Phoenix app ever gains a CLDR dependency for another reason, this
ruling should be re-opened — the cost argument is the whole argument.
Unblocks. G2646-open-heex-layout-inert → done (wave C2 packet 2m).
Sources. src/generator/elixir/heex-walker-core.ts (the i18nFormat arm);
targets-completeness-2026-08-30.md;
D-I18N-HEEX-ICU above (the interpolation half, which DID get an engine).
D-PAGE-PRIMITIVE-SHADOW — a component may not take a walker-primitive name; a valueobject may
Status: applied — the proposed default took effect 2026-09-24 (merged to main 2026-09-22 in #2970; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Raised by wave C2 packet 2g (§7.1, "does a user declaration shadow a page
primitive in a ui body?") and settled by packet 2k on measurement.
Question. The page-body vocabulary (Text, Card, Alert, Table,
Money, Badge, …) is a closed set of ~55 names, and nothing stopped a user
declaration from taking one. Which wins — the primitive or the declaration —
and should the collision be refused?
What was actually measured, on main at 5049b6fea, one generation per
case:
| collision | what happens |
|---|---|
component Alert(msg: string) { body: Heading { msg, level: 3 } } + body: Stack { Alert("hi") } } |
0 error(s), 0 warning(s). src/components/Alert.tsx is written, AND the call site emits the PACK's primitive — import { Alert, … } from "@mantine/core" / <Alert color="red" variant="light">{t("page.Home.alert.sx4lga", "hi")}</Alert>. The author's Heading appears nowhere in the project. |
valueobject Money { amount: decimal currency: string } + body: … Money { 10, "dropped" } |
the PRIMITIVE wins and its own arity gate fires — loom.page-primitive-extra-children. The value object does not capture the name. |
So the two halves are not the same problem, and the blanket rule ("no user declaration may share a primitive name") is wrong in one direction.
Decision. Refuse the COMPONENT collision; admit the value-object / enum / domain-service one.
- A
component(walked orextern) is reached by the page-body dispatcher under exactly the name the primitive is, and the primitive wins. The declaration is therefore dead on arrival: emitted, never rendered, with no diagnostic. That is a silent-codegen defect, and it is the SAME defectloom.extern-function-shadows-stdlibhas refused since it was written — its own comment gives the reason ("the body dispatcher would route the call to the primitive, silently ignoring the user's module"). The component arm was simply missing. New code:loom.component-shadows-stdlib, an error at phase ④ (src/language/validators/ui.ts,checkComponent). - A
valueobject/enum/domainServiceis not reached by the dispatcher at all, so it shadows nothing — and refusing it would rejectweb/src/examples/sales-system.ddd, which declaresvalueobject Moneybeside pages that use theMoneyprimitive. A gate there would be a regression dressed as a fix. (Corpus scan at decision time: zero.dddinexamples/,web/src/examples/,journey/ortest/declares a component named after a primitive, so the new gate breaks nothing.)
Consequence for the ambient decl index. 2g narrowed indexAggregatesDeep
(src/ir/lower/lower.ts) to aggregates ONLY, explicitly deferring the
value-object / enum / domain-service halves to this decision because widening
them looked like it would shadow the primitives. The measurement above says it
does not: the primitive still wins the call and its arity gate still fires, on
the un-narrowed indexMembers path that already indexes value objects and
enums through contexts. The halves may therefore be widened when a row needs
them; this decision removes the blocker, and packet 2k did not widen them
because no row on its tree needed it.
Affects. src/language/validators/ui.ts (checkComponent);
src/diagnostics/messages.ts + code-docs.ts;
test/language/validation/component-shadows-primitive.test.ts (mutation-proved:
removing the gate fails 4 of 7 with expected [] to include 'loom.component-shadows-stdlib'); docs/new-plan/T1-ui-frontend.md M-T1.20.
D-MODAL-CONTROLLED-OP-FORM — Modal { open:, OperationForm } is a supported shape and open: must be honoured
Status: applied — the proposed default took effect 2026-09-24 (merged to main 2026-09-22 in #2970; no override recorded by 2026-09-28; flipped by Wave C5 packet 5.0).
Raised by wave C2 packet 2g (§1), which measured the row and asked for a ruling
before building; re-measured and ruled by packet 2k.
Question. loom.modal-controlled-op-form-unsupported refuses
Modal { open: <stateBool>, OperationForm { … }, trigger: … } on
react / vue / svelte / flutter. Its CONTROLLED_MODAL_OP_FORM_FRAMEWORKS set —
angular, feliz, phoenixLiveView — names the three that do NOT refuse it. But
read what those three do: each forks the primitive, renders the operation form,
and drops the open: binding, driving the dialog from its own trigger
(Angular: renderAngularModal builds its own <opKey>Open signal and never
looks at open:). So the "supported" behaviour is itself a degradation, and
the register's drain condition for the other four ("render the controlled shell
around the recorded OperationFormState") is a strictly higher bar. Which bar
is right?
Options. (a) match the degradation — let react/vue/svelte fall through to
the trigger-driven op-form modal with a synthesised trigger, ~20 lines in
emitModal, gate deleted, MAX_OPEN_GAPS −1; (b) honour open: everywhere,
and fix the three "supported" targets too.
Decision. (b). (a) would ship a silent drop of an authored binding on
seven targets instead of four, which is precisely the class this wave exists to
remove — and it would do it by DELETING the diagnostic that currently names it,
so the degradation would become unfindable. A state field bound to open: is
the author saying "I own this dialog's visibility"; a generated trigger button
they did not write is not that.
Why it is not built here, and what it costs. Measured on main at
5049b6fea:
- an
OperationFormchild emits NO inline markup (walk(formChild, …)returns""). The packs render it fromctx.formOfsthrough their own module component, and that component owns its trigger: shadcn wraps<DialogTrigger asChild><Button …>, mui/chakra emit<Button onClick={() => setOpen(true)}>beside the dialog, and the Svelte packs open a{{opCamel}}ModalOpenlocal from their own button. A controlled shape needs a second template per pack — the op-form module rendered WITHOUT its trigger, driven by the page's state — across the JSX/Vue/Svelte packs; designs/shadcnVue/v1/form-op-module.hbsis currently// TODO(vue-forms): operation modal/form module (v-dialog) lands in the forms slice, so on Vue the uncontrolled shape is not built either: that pack needs the module before it can need a controlled variant;- the three "supported" targets each need their fork taught to read
open:, which is 2m's tree (HEEx) and 2l's (feliz) plus Angular'smodal.ts.
That is a pack-layer packet, not a walker row, and it is HANDED OFF rather than part-landed: a fix on react alone would re-introduce the per-target carve-out the register records as the thing to remove.
Register. The row stays kind: "gap" under M-T1.6 with its membership set
unchanged. It is not re-classed scope, because this decision says the shape
IS supported — it is unbuilt work with a known shape and a measured cost, which
is what gap means.
Affects. src/generator/_walker/primitives/forms.ts (emitModal /
emitControlledModal); designs/*/v*/form-op-module.hbs +
primitive-modal.hbs across the JSX/Vue/Svelte packs;
src/generator/angular/modal.ts; src/ir/validate/checks/ui-collection-display-checks.ts
(CONTROLLED_MODAL_OP_FORM_FRAMEWORKS); mission M-T1.6.
D-WIRESHAPE-KEEP — the derived wire shape stays the source of truth; the declared response record is the thing that must be made to agree with it
Status: interim — the derived shape stays for now; the goal is to retire
it (owner ruling on #3383). Raised and measured by wave C4 packet 4e, which was sent to build slice 1 of
the wireShape retirement or decline with the measurement. It declines.
Question. docs/old/proposals/unfoldable-api-derivation.md steps 6–8 retire
the derived wire shape: every consumer stops calling
forApiRead(wireFieldsFor*(node)) and reads the declared <Agg>Response
contract record instead, after which .loom/wire-spec.json retires too.
M-T5.10 spun that off as "179 refs / 49+45 files, XL". Is it still the right
direction, and what does its first slice cost?
What the measurement found. Three facts, all on the C4 coordinator head:
- Steps 6 and 7 already shipped. #1920 (Phase 1) moved every response
consumer off the stamp and #1937 (Phase 2) deleted it: there is no
wireShapefield onAggregateIR/EntityPartIR/ValueObjectIR, no phase-⑥ stamping, and nowireShapeFor.EnrichedEntityPartIR/EnrichedValueObjectIRalias their base types. What the 179 refs count is call sites of the pure recompute helpers insrc/ir/enrich/wire-projection.ts, which is what "derive, don't stamp" asks for — not readers of a denormalised field. The spun-off item's premise is stale. - The records do not exist for any shipped model.
with scaffoldHandlersis the only thing that splices them, and exactly five tracked.dddfiles use it — the per-backendscaffold-handlerscompile fixtures. All 79 corpus fixtures, everyexamples/*.dddand every playground example useapi X from Subdomain, which splices none: 0 of 117 corpus aggregates carry an<Agg>Responserecord. "Move a consumer onto the records" is therefore not a refactor for the shipping paths; it is gated on proposal step 4 (rewriting the implicitapi … from …derivation to expand through the scaffold), which is unbuilt. - Where the records DO exist, they describe a different shape. Injecting
the macro into all 79 fixtures materialises 126 contracted nodes, of which
25 diverge — capability-injected fields (
softDeletable/auditable/tenantRegistry), every field inherited from anabstract aggregatebase, theprovenanced<T>carrier, the containment<Part>Responseoffset, the optional-containment representation, and one field-ORDER difference. Census and exact baseline:test/system/wire-contract-divergence.test.ts.
Decision. The derived shape stays the single source of truth for the wire.
A consumer is NOT moved onto the contract records until the records reproduce
the derived shape, because three of the divergence classes are live defects
today rather than representational offsets: PR2–PR7 already pointed every
backend's response-DTO and schema emitter at the record while the repository
serializer still reads the derived shape, so a scaffolded softDeletable or
inheriting aggregate serves fields its own OpenAPI schema does not declare
(repo.toWire(found) as z.infer<typeof OrderResponse> — the cast is why tsc
never sees it). Widening that split to a sixth consumer family would ship the
defect further, and it cannot be done byte-identically in any case.
Consequences.
.loom/wire-spec.jsondoes not retire (proposal step 8). #1937 already took the proposal's option 3 — keep the artefact, derive it fromwireFieldsFor— and the conformance-parity tier reads it. Options 1+2 ("drop entirely,ddd snapshot --wireas the escape hatch") presuppose that contract source is the diffable artefact, which fact 2 says it is not.- The retirement is re-scoped as M-T5.40 and inverted: first make the record reproduce the derived shape (closing the 25 rows, which fixes real defects), then — and only then — consider which consumer reads which.
- The access-modifier filter matrix and the three
wireFieldsFor*walks stay where they are (src/ir/enrich/wire-projection.ts), as the one place both the derived shape and any future re-derivation from records agree on.
Re-open it when the record layer reproduces the derived shape for every
corpus node (M-T5.40 rows A1/A2/B3/C closed) and the implicit
api … from … form expands through scaffoldHandlers, so the records exist
for the paths that actually ship. Until both hold, retirement trades a pure
function for a source of truth that is absent 100 % of the time and wrong 20 %
of the rest.
Affects. src/ir/enrich/wire-projection.ts; src/system/wire-spec.ts;
src/macros/api/factories.ts + src/macros/stdlib/scaffold/_contracts-shared.ts
(apiReadFields); the response-DTO emitters on all five backends; missions
M-T5.10 and M-T5.40;
docs/old/proposals/unfoldable-api-derivation.md steps 6–8 and its coordination
note's item 3.
D-KAFKA-START-OFFSET — a new kafka group starts at earliest on a work queue, latest on a log
Status: decided (owner ruling D3 of the 2026-09-28 eval-closure review, item 13).
Question. Every backend's kafka consumer started a NEW consumer group at
the latest offset. On a work-queue channel (delivery: queue /
retention: work) that loses every event published before the consuming
deployable's group first joins — a fresh deploy whose producer boots first, or
a rejoin after the broker expired the group's offsets. The rabbit twin
(F-112) was fixed as a bug; kafka was not.
Decision. A new group on a work-queue channel starts at the earliest
offset; a retention: log (broadcast) channel keeps latest. A group with
committed offsets resumes from them in both cases, so this only decides the
first join. The predicate is kafkaStartsAtEarliest
(src/generator/_channels/bindings.ts); each backend reads it into its binding
row and passes it to the kafka driver only (node kafkajs fromBeginning, java
AUTO_OFFSET_RESET_CONFIG, .NET AutoOffsetReset, python
auto_offset_reset, elixir brod begin_offset). The other drivers have no
offsets and do not see it.
Why. retention: work promises a work item is delivered; a silent drop on
first boot breaks that promise. A replay of already-handled records is safe on
a work queue because consumers dedupe on the envelope id. On a log, a fresh
deployable replaying the whole history is a semantic change of its own
(the replay cursor, M-T4.2), so it stays out of this ruling.
D-MESSAGELESS-RULE-WIRE-MESSAGE — a message-less single-field rule's 422 carries node's synthesized sentence, never the framework default
Status: decided (owner ruling D10 of the 2026-09-28 eval-closure review, item #15d).
Question. A rule with no message "…" clause that is a single-field shape
(invariant seats >= 1, precondition code.matches("^[A-Z]{3}$")) rides each
backend's NATIVE wire constraint, and the native constraint's text is whatever
the framework says. On python that was pydantic's default —
String should match pattern '^[A-Z]{3}$' — which puts the server's regex on
the wire, and Input should be greater than or equal to 1 for a bound. Which
message does the wire carry?
Decision. The sentence node already sends: singleFieldMessage(field, pattern) (src/generator/zod-refine.ts — "Code is not in the expected
format", "Amount must be at least 1") for a shape node chains natively, and
node's refine default (Invariant violated: <src>) for a conjunction node does
not chain (email.matches(r) && email.length <= 120). Wire parity, and the
regex never leaves the server. No errors[].code is attached — node attaches
none for a message-less rule, and the code is the i18n key of AUTHORED text.
Python shape. The Field(...) constraint stays (it is what publishes
pattern/minLength/minimum into the OpenAPI document), and each request /
value-object model gains one @field_validator(<fields>, mode="wrap") whose
(field, pydantic error type) → message table is built from the SAME
classification as the kwargs (src/generator/python/emit/wire-constraints.ts).
Only errors the table arms are re-raised as PydanticCustomError(<type>, <message>); a type error or a nested model's error passes through. Done in the
model rather than the RequestValidationError handler because the handler sees
only a loc, and one field name can carry different bounds on two models.
Consequences. Pinned by test/generator/python/message-less-wire-message.test.ts
(derives node's message set and requires each in python's tables) and by the
validation-messages wire golden (seq 14/15). .NET, Java and Elixir send the
same sentence since D-MESSAGELESS-DEFAULT,
which extends this ruling to every backend and every message-less rule.
D-MESSAGELESS-DEFAULT — every message-less wire rule answers node's sentence, on every backend
Status: proposed (default applies 48 h after merge unless overridden).
Taken as the owner default recorded for wave C3F ("node's sentence is
canonical", wave-c3f.md) and built by its packet F4.
Question. A rule with no message "…" clause still refuses a request, and
the refusal is one errors[].message wire field. Before this ruling it had a
value per framework: FluentValidation's 'Amount' must be greater than or equal to '1'., java's generic Invariant violated: amount >= 1 for a shape it
had classified as single-field, elixir's domain-floor Precondition failed: …
(no errors[] at all), and python's Value error, Invariant violated: … at an
empty pointer. D-MESSAGELESS-RULE-WIRE-MESSAGE had settled python's
single-field case only (M-T6.86 held the rest behind four waivers). Which
sentence does the wire carry, for which rules?
Decision. The one node sends, for every message-less rule the wire validator carries, on every backend:
- a single-field shape →
singleFieldMessage(field, pattern)(src/generator/zod-refine.ts); - any other shape → node's refine default
Invariant violated: <source>, at the pointerpickErrorPathderives (node's refinepath); - no
errors[].code— the code is the i18n key of AUTHORED text.
D-MESSAGELESS-RULE-WIRE-MESSAGE stands; this extends it from python to .NET,
Dapper, java and elixir, and from the single-field shape to every
message-less rule.
Not covered. A MISSING required member is not a rule, and its sentence is
still each framework's (node's zod text is itself zod-major-specific:
Invalid input: expected string, received undefined on zod 4, Required on
3). Whether to adopt a zod text as the cross-backend contract or mint a
Loom-owned sentence is its own question — mission M-T6.93. Elixir's
create/update changeset passes the same sentence as the message: of each
message-less single-field validator, as its operation rung does.
Consequences. .NET appends .WithMessage(<node sentence>) to each
native FluentValidation arm (dotnet/validator-emit.ts); java passes the
sentence to patternCheck (java/emit/validator.ts); elixir lifts every
wire-translatable precondition — messaged or not — to the {:validation_failed, errors} rung, the message-less entry carrying no code
(elixir/vanilla/denial.ts); python raises a PydanticCustomError of type
invariant through ValidationError.from_exception_data, so the entry has the
field's loc and no "Value error, " prefix (python/emit/wire-constraints.ts).
Pinned by RS-43 and the validation-messages / intrinsics wire goldens; the
four M-T6.86 waivers are deleted.
D-FORBIDDEN-DETAIL-DEV-ECHO — a 403 names its gate only under the dev stub; a malformed dev-claims header is a 400
Status: PINNED (owner rulings D4 and D6, 2026-09-29; eval-closure items #20 and #23).
D4 — the 403 body. Every requires gate throws Forbidden: <gate source>.
That text always goes to the backend's forbidden log line. The RFC 7807
detail repeats it only when the deployable runs the dev-stub verifier
(auth: required + a user { … } block + no auth { oidc … } block); under a
real verifier — and on a deployable with no verifier at all — the body is the
constant Forbidden. One compile-time predicate decides it for all five
backends, echoesDenialDetail (src/ir/util/denial-detail.ts), derived from
the same facts that choose the verifier, so the body cannot disagree with the
verifier actually shipped. Every forbidden response funnels through one place
per backend (node ForbiddenError.detail, .NET DomainExceptionFilter, Java
ApiExceptionAdvice, Python install_error_handlers, Phoenix
ProblemDetails.problem_response/4); the ~21 throw sites are unchanged.
Wire-visible: test/behavioral/wire-golden/auth-oidc.json was re-baselined.
D6 — the dev-claims header. A present, non-empty x-loom-dev-claims header
that does not decode to a base64 JSON object answers 400 Bad Request with
the detail malformed x-loom-dev-claims header: expected a base64-encoded JSON object on all five dev stubs, including a stub whose user shape has no
carryable claim. It used to fall back silently to the built-in identity.
Scope choices recorded with them. "Otherwise" in D4 includes the no-verifier deployable (not dev-stub auth, so no echo). The Phoenix LiveView create form's denial flash follows the same rule as the HTTP body.
Affects. docs/auth.md (§ requires gates, § Dev-stub verifier); the five
backends' error handlers and dev stubs; test/generator/forbidden-detail-echo.test.ts,
test/generator/dev-claims-parity.test.ts.
D-TARGET-RESERVED-NAMES — a member named like a target keyword, or a generated helper, is escaped per backend
Status: DECIDED (owner ruling D2, 2026-09-29 eval-closure review; implemented by wave C2).
Question. A .ddd member, parameter, operation or enum value may be named
def, lambda, pass, class — the .ddd grammar admits them, generate system answered 0 error(s), and the python project did not parse. A member
named assertInvariants broke .NET (CS0102), node (duplicate class member) and
python (a backing field overwriting its own method). Refuse such names with a
validator, or escape them per backend?
Decision. Escape per backend; never refuse. The target spelling is a host concern and the user's domain vocabulary should not have to know five languages' keyword lists.
- Python gets ONE identifier funnel (
pythonIdent/pythonWireIdentinsrc/util/naming.ts,def→def_) at every identifier position, and keeps the two outward names: the DB column viamapped_column("def", …), the wire key via a pydanticalias/Query(alias=…). This mirrors Java's M-T6.36 (@JsonProperty/@Column). - Node (Hono / TypeScript) escapes through
escapeTsIdent(class→class_, the strict-mode + module reserved set) at every BINDING position: operation / function / domain-service / VO-ctor / repository-find / criterion-fn / extern-hook / event-applier / projection-fold params, and theconstlocals the route, workflow and handler emitters bind. PROPERTY positions keep the declared name —this._class,get class(),{ class: class_ },schema.things.class, the zod wire key — since any string is a legal TS property, so the wire and the DB column never move. The React / Vue / Svelte / Angular emitters bind no field name as an identifier (form state is keyed objects), so they needed no change. - .NET, node, python rename the generated private invariant helper — and only when a member actually takes its name, so every other model's output is byte-identical (the conservative sub-choice: a fixed rename would have moved every generated entity on three backends for a collision that needs a specific member name).
- The wire, the OpenAPI document and the stored data never differ between backends; the escape is visible only in the generated host source.
Consequences. test/fixtures/corpus/python-reserved-words.ddd (row ALL)
is the ratchet on all five compile tiers and the wire-golden differential;
test/generator/target-reserved-member-names.test.ts takes the python keyword
list from the interpreter itself. The remaining unfunnelled python positions
(projection read models, the auth.user record) and the OTHER helper-state
collisions (_events, pull_events, create, inspect against a member of
that name) are recorded in docs/generators.md § "Names that are Python
keywords" as follow-ups — they fail loudly (a syntax/compile error), not
silently.
Follow-up (2026-10-12). The per-backend keyword sets became ONE
table with ONE escaping path, src/util/target-identifiers.ts
(escapeTargetIdent / isReserved, plus escapeTargetMember for names a
target reserves as a class member), and the ratchet widened from python to
every target: test/fixtures/corpus/target-reserved-words.ddd names a field,
an enum value, an operation and its parameter after every table word the
grammar admits, on all five compile legs. The ruling is unchanged — escape,
never refuse. See generators.md § "The per-target reserved-identifier
table".
Sources. the eval closure review (2026-09-28), item 6;
generators.md § "Names that are Python keywords" and
§ "Names that are Java reserved words".
D-DEFAULT-DENY-LIST-WARN — under denyByDefault an ungated list read is a warning; a cross-context policy call is a named error
Status: ruled by the owner (eval closure review 2026-09-28, rulings D5 and D9; items 22 and 39).
D5: the list read. Every aggregate serves GET /api/<plural>, which is
backed by the repository find named all. Unless the author declares that
find, enrichment injects it without a gate. Under denyByDefault the list
read therefore served every row to any authenticated caller. The by-id
warning fired, but the list did not. default-deny-checks.ts had exempted it
as "compiler-synthesized, no author source line". That reason was weaker
than the by-id one, because the author can write
find all(): T[] requires <expr>, and all five backends enforce it
(src/ir/util/read-gates.ts).
The ruling is a warning, loom.default-deny-list-ungated, at the same
tier as loom.default-deny-by-id-ungated. Existing models keep building, and
the message names the line that silences the warning. An author-declared
find all with no gate gets the same warning. The declared-find error arm
still skips all, so leaving it out would bring back the silent case.
Promoting the warning to an error is left to the runtime-gating mission
(M-T3.19 / #3109).
D9: cross-context policy calls. Function-form policies are
context-local. Before this ruling, a requires P() naming a policy declared
in another context reported only 'requires' must be of type 'bool', got 'unknown'. It now reports loom.policy-out-of-scope: "policy P is declared
in context A; policies are context-local — redeclare it in B". The type
system types such a call bool (outOfScopePolicyCall,
src/language/type-system.ts), so the named error is the only one raised.
The detection has two limits:
- It covers the call forms
P(args)andCtx.P(args)(the qualified spelling an author tries first when sharing a policy). A bare name has too many other readings. - It sees only one document. In a multi-file model, a context declared in another file is not searched, and that case keeps the old wording.
Sharing a policy across contexts (at subdomain or system level) needs its own language decision. It is a separate mission.
Touches: src/ir/validate/checks/default-deny-checks.ts,
src/language/validators/policy-fn.ts, src/language/type-system.ts,
src/diagnostics/messages.ts, docs/auth.md,
docs/language-reference/17-auth.md, docs/language.md.
D-ICU-DOMAIN-DROP-WARN — an ICU branch hole in backend domain code is a warning now; rendering the branches is a mission
Status: applied (owner ruling D8, eval-closure review 2026-09-28, item 38; wave C, implementer C7).
Question. A template hole carrying , plural / , select / , selectordinal
is admitted anywhere a template is. Every backend leaf table renders the
i18nFormat wrapper as its bare operand (i18nFormat: (inner) => inner in the
node, .NET, Java, Python and Elixir render-expr.ts), so in a derived,
function or operation body the BRANCH TEXT vanishes:
derived p: string = `{qty, plural, one {# item} other {# items}}` yields
"3", with zero diagnostics on parse and generate.
Options. (a) render the branches on all five backends as a match/if chain over
the branch keys (English CLDR one/other plus =N exact matches); (b) warn
where the drop happens and document it; (c) refuse it as an error.
Decision. (b): the warning loom.interp-format-dropped-in-domain on every
plural/select/selectordinal hole whose template sits in a derived,
function (incl. top-level) or operation (aggregate or domain-service) body,
and never inside a ui. (a) becomes a mission; (c) would refuse models that
generate and run today.
Scope kept deliberately narrow. number/date/time formats are dropped in
domain code too, but they lose only formatting, not text, so they stay silent
(the docs say so). Templates in other backend bodies (workflows, handlers) are
not covered by the ruling and stay silent until the rendering mission decides
them.
Affects. src/language/validators/template.ts; src/diagnostics/messages.ts;
src/diagnostics/code-docs.ts; docs/language.md;
docs/language-reference/05-expressions.md.
D-REACTOR-SYSTEM-PRINCIPAL — event reactors run as a tenant-scoped system principal
Status: PINNED (owner ruling D1, 2026-09-29, eval closure review).
Question. A reactor — a workflow's event-triggered create(e) by …
starter or on(e) subscription, including one driven by a timerSource
tick or a broker-consumed event — has no request, so it has no request
principal. What does a gated operation it calls do? Before this ruling the
answer was five different defects (eval item 3): node, .NET and java emitted a
currentUser nothing declared (TS2304 / CS0103 / javac), python dropped the
operation's gate entirely (fail-open), elixir passed nil and crashed on
nil.permissions.
Decision. Reactors run as a system principal, tenant-scoped, and gates are evaluated against it — never skipped.
- The principal.
{ isSystem: true, tenant: the triggering event's tenant, causedBy: the originating user's id }. Every declared claim is EMPTY (the zero of its type — never the dev stub's"admin"), so no claims gate passes by accident.causedByis for audit and logs only: it is not a language member and no gate can read it. - Language surface.
currentUser.isSystem(bool). A gate that admits a reactor says so:requires currentUser.isSystem || ….isSystemandcausedByare reserved claim names (loom.user-reserved-field). - Gates. Evaluated normally. A reactor that reaches a gate the system
principal can never satisfy — one that reads
currentUserand never mentionscurrentUser.isSystem— gets a compile-time warning,loom.reactor-gate-unsatisfiable. - Tenancy. Filters and stamps use the event's tenant. In-process, that is
the tenant of the request that raised the event (the system principal copies
the dispatching principal's tenancy claim and
orgPath). - Timers. A tick has no tenant, so a tenant-owned read from a timer-driven
reactor must be explicitly cross-tenant: a
find/ inlineRepo.runwithignoring tenantOwned(the language's existing cross-tenant read clause —crossTenantitself is an aggregate stance, not a read clause). Otherwise errorloom.timer-tenant-read.
Emission (all five backends). The request principal is unchanged on the
wire (/auth/me projects the declared claims): node User.isSystem? /
causedBy? + systemPrincipal() in auth/middleware.ts; .NET init-only
IsSystem / CausedBy (serializer-skipped at default) +
User.SystemPrincipal(origin); java trailing record components with a
claims-only constructor + User.systemPrincipal(origin); python defaulted
dataclass fields + system_principal(); elixir system_principal/0 in the
web Auth module, reading the origin the auth plug stashes in the process
dictionary. A reactor binds it only when its body — or an operation it calls,
hoisted gate included — reads currentUser
(src/ir/util/system-principal.ts reactorNeedsPrincipal).
The event origin crosses the outbox and the broker (item 3 d). In-process
the reactor's tenant is read off the dispatching request, but the outbox relay
and a broker consumer run long after that request is gone. So every backend
snapshots the raising frame's event origin — {tenant, orgPath, causedBy}
— where the event leaves it:
- Outbox row: under the reserved payload key
__loomOrigin(LOOM_OUTBOX_ORIGIN_KEY,src/util/channels.ts) — inside the existing JSONpayload, so the__loom_outboxschema does not change; every relay strips it before decoding the event. - Envelope: the CloudEvents extension attributes
tenantid(already in the optional set),loomorgpathandloomcausedby— each omitted when absent. - Delivery: the relay (per drained row) and the channel consumer (per received envelope) open an ambient frame whose principal IS the system principal of that origin, so the reactor's gates, tenant filters and stamps — everything reading the ambient principal — see the event's tenant. No origin (an auth-less producer, a timer-raised event) ⇒ the frame is not opened and the reactor runs tenant-less, exactly as before (fail-closed).
A system principal propagates its own origin, so an event a reactor raises
keeps the FIRST user as causedBy through any number of hops. Per backend:
node currentEventOrigin / systemPrincipalFor / runAsEventOrigin
(auth/middleware.ts); .NET User.CurrentEventOrigin / SystemPrincipalFor /
EnterEventOrigin + Auth/EventOrigin.cs (and RequestContext.ActorId
answers a system principal's CausedBy); java User.currentEventOrigin /
systemPrincipalFor, CurrentUserAccessor.runAsEventOrigin,
auth/EventOrigin.java; python current_event_origin /
system_principal_for / event_origin_frame (app/auth/user.py); elixir
current_event_origin/0 / system_principal_for/1 / with_event_origin/2
(web Auth). Emitted only on a deployable with auth: required; an auth-less
one keeps the bare row and envelope byte-for-byte.
Trust. tenantid on the wire selects the tenant a consuming reactor runs
in. That is the same trust the broker already carries for the event itself: a
party that can publish onto a Loom channel can already forge any event; broker
auth (channels.md § Broker auth) is the boundary, not the attribute.
Affects. docs/language-reference/17-auth.md § Reactors; docs/auth.md;
docs/tenancy.md; docs/workflow.md; docs/channels.md;
src/ir/validate/checks/reactor-principal-checks.ts.
D-EXPLICIT-ROUTE-WINS — an explicit route owns its slot; the auto-derived REST surface yields it
Status: DECIDED (orchestrator ruling, accepted by the owner, 2026-10-11).
Question. An api's explicit route <METHOD> <PATH> -> <Ctx>.<Handler> can
land on the same router slot (method + shape-normalised path) as a route of the
always-on auto-derived REST surface. with scaffoldHandlers + with scaffoldApi does it for every create / operation / find / get-by-id / destroy;
a hand-written route that shadows GET /orders/{id} does it once. Two
registrations on one slot break differently per framework: elixir fails mix compile --warnings-as-errors, .NET and java throw ambiguity errors at request
time, node and python silently serve whichever mounted first (the explicit
handler was dead code there, and python minted each operationId twice). Which
layer owns the slot?
Decision. The explicit layer wins. An explicit route is declared
intent; the derived surface is a default. enrichLoomModel records the slots
each subdomain's explicit routes occupy (BoundedContextIR.explicitRouteSlots,
keyed by the subdomain of the route's TARGET context), the shared derivation
(deriveAggregateOperations) flags a matching operation
claimedByExplicitRoute, and all five route emitters skip a flagged operation
(servedOperations). Every explicit route mounts under API_BASE_PATH — the
root-mount stop-gap that kept a colliding route out of /api is gone.
- The flagged operation stays in the derived list. The slot is still served (by the explicit handler), so the typed api-clients keep calling it, and the commands, queries, DTOs and service methods behind it are still emitted.
- Where an OpenAPI document is built from the routes themselves (node, python),
the explicit route's entry is the slot's single operation. Where it is built
from the derived list (elixir's
OpenApiSpexspec, java's customizer), the derived entry keeps documenting the slot, since those documents do not list explicit routes. Either way one operationId per slot. - Phoenix matches in registration order, so an explicit route that takes over
a derived slot is spliced where the derived route would have gone — after
the derived static finds — instead of
first. - Models with no explicit route claiming a derived slot generate byte-identical output.
Consequences. scaffoldApi's routes are real, reachable code that unfold
ejects and the user can customise. The generated-name uniqueness census
(test/system/generated-name-uniqueness.test.ts) is the ratchet: it carries no
waiver for the scaffold shape and fails if a derived route and an explicit one
share a slot again. Not covered: the two aggregate-scoped routes the derivation
does not lift (GET <base>/{id}/history, GET <base>/prepare) — an explicit
route on one of those slots still double-registers.
D-EXACT-PINS — generated project manifests pin every dependency to one exact version
Status: DECIDED (owner ruling 2026-10-21, M-T7.11); implemented by #3886.
Problem. A range in a generated manifest (^4.12.0, >=0.115,<1, ~> 1.8,
postgrex >= 0.0.0) resolves at install time. No lockfile is emitted, so a
project that built yesterday could fail today when a dependency released inside
its range — @hono/zod-openapi 1.5.2 broke every cross-field invariant's types
that way, and npm packuments cached before a newer matching version existed
died with ETARGET.
Decision. Every dependency in every generated manifest — package.json
(all four JS stacks, every design pack, the Hono backend and its optional
adapters, the Feliz and e2e projects), pyproject.toml (including the
[build-system] requirement), mix.exs, pubspec.yaml, and the already-exact
*.csproj/*.fsproj/build.gradle.kts — names one exact version. No lockfile
is generated; transitive dependencies still float. Environment constraints
(engines, requires-python, the Dart/Flutter sdk range, the .NET
TargetFramework) are not dependencies and stay ranges.
- A bump edits the literal, then re-resolves the whole set. The pins in one
manifest are checked together, not one by one:
@opentelemetry/apihad to sit at 1.9.1 becausesdk-trace-base2.12 peers<1.10, andex_awsat 2.6.1 because 2.7 needshackney ~> 4while the adapter pins hackney 1.x. Resolve withnpm install --package-lock-only,pip install --dry-run, andmix deps.getbefore landing (see thedependency-upgradeskill). - Waived: Flutter
intlandweb. The SDK pins them (flutter_localizations,flutter_web_plugins) and the generated image tracksflutter:stable, so an exact value is only knowable once the SDK is pinned. - Gate:
test/system/generated-manifest-pins.test.tsgenerates the whole tracked.dddcorpus on all five backends plus every built-in design pack and fails on any range specifier; its waivers ratchet.
Consequences. Security fixes inside a range no longer arrive on their own;
they ride the currency sweeps (#3685). The pinned set is the one the
generated-*-build and test:tsc gates exercise.
D-INHERITED-CAPABILITIES — a capability on an abstract base applies to every subtype
Status: PINNED (owner ruling 2026-10-25, epic #3954).
Question. abstract aggregate Vehicle with softDeletable + aggregate Car extends Vehicle. Does Car hide soft-deleted rows, and may a Car read say
ignoring softDeletable? Before this ruling the base's fields reached the
subtype but its capability identity did not: the subtype carried is_deleted
and nothing filtered it (node, Python, Java, Elixir, .NET Dapper, .NET EF
TPC), and ignoring softDeletable on the subtype was refused as an unknown
capability. Tenancy only held because loom.tenancy-stance-unmarked forced the
author to repeat with tenantOwned on every concrete.
Decision. A Car is a Vehicle, so everything a capability on the base
contributes applies to every concrete that extends it, transitively: its
filters and bare filter declarations, its stamps, its capability tags and its
crossTenant stance.
- Where. Once, in phase ⑥ (
src/ir/enrich/inherited-capabilities.ts). Every backend reads the mergedcontextFilters/contextFilterOrigins/contextStamps/capabilitiesand carries no inheritance logic, and theignoringand tenancy validators resolve against the same set. - Order. Ancestors' filters first (root-most first), then the concrete's
own;
inheritedFilterCount(src/ir/util/inheritance.ts) reads that prefix. - Repeats. A subtype re-applying its base's capability gets that filter once (the inherited entry wins); a stamp assignment the base already makes on the same event is dropped from the subtype's own rules.
- .NET EF TPH. The root config registers the root's filters once,
unguarded; a subtype neither re-registers them nor prefixes their names, so
ignoring <rootCap>on a subtype lifts the root's filter. - Tenancy. An unmarked subtype of a
tenantOwned(orcrossTenant) base has that stance. A subtype declaring the opposite one isloom.tenancy-inherited-stance-conflict.
Gate: test/generator/inherited-capability-filter.test.ts — one case per
backend × TPH/TPC; a Truck read carries the base's is_deleted filter and
ignoring softDeletable on Truck drops it.
D-MIGRATE-WORKFLOW — the migration history is a committed chain beside the source; generate only renders it
Status: PINNED (owner ruling 2026-10-25, #4065).
Question. Where does the state a schema migration is diffed against live,
and who decides when a generation is cut? generate system used to do both:
it diffed the model against .loom/snapshots/ under -o and minted the next
version as a side effect. The output tree is exactly the thing that gets lost
(CI building into a scratch dir, a fresh clone, a second environment), so five
generate-time guards and a fingerprinted side ledger beside the .ddd existed
only to detect a lost or stale baseline — and the ledger, re-recorded by
every PR that touched Initial DDL, was itself a source of churn.
Decision.
- The chain.
migrations/beside the entry.dddholds one JSON file per (generation, module): its backend-neutralMigrationsIRsteps and the module's schema after them (src/system/migration-chain.ts). Each backend's migration files are rendered from the whole chain on everygenerate system; nothing migration-related is written under-o, which is fully disposable. Committed with the model. - Recording is explicit.
ddd migrate add <name> <file.ddd>diffs the model against the chain's head and appends.generate systemrefuses a model with an unrecorded schema change (MigrationDriftError). A source with nomigrations/has nothing recorded:generateemits each module's initial migration from the model, with no drift check — so examples and fixtures carry no migration state. - Versions are the UTC clock at
migrate add, moved above the head (and abovehead + INITIAL_RESERVATIONafter anInitial, which expands into many Ecto files) — unique across modules and strictly increasing, which is what Drizzle's watermark, Flyway's out-of-order refusal and Ecto's file versions each need. Every entry names its parent; two heads (two branches that each recorded a migration) refuse to be read until one is re-recorded on top of the other. - The destructive decision is made once, at
migrate add(--allow-destructive), and recorded in the entry;generatehas no such flag. - Clean break. Loom has no external consumers, so there is no import path
from output-tree snapshots: the guards, the side ledger,
--allow-rebaseline, per-module version blocks and the Dapper tree re-rooting are deleted outright.
Affects. docs/migrations.md § The migration chain; docs/tools.md
§ Migrations workflow. migrate status / remove / rebase are #4083.
D-REPOSITORY-CONTRACT — a repository is a body-less declaration whose surface is one declared verb contract
Status: PINNED (owner ruling, 2026-10-25). The foundation — the contract table and the canonical repository op on the IR — has landed; the surface changes below land in later slices.
Problem. The built-in repository verbs existed only as hard-coded copies:
the typing pass, the IR result type, the read/write classifier, three IR
validators, the not-found predicate and the per-backend emitters each kept
their own list of getById / findById / find / findAll / all / run
/ delete. The copies drifted — findById was typed T in one table and
T? in another, and its emitted method did not exist on .NET and Elixir — and
the IR carried the surface spelling (repo-let.method: "getById",
repo-read.readKind), so every emitter re-dispatched on source text.
Decision. The repository is an explicit, body-less declaration
(repository Products for Product) whose surface is one declared contract.
No built-in verb exists outside that contract, there is no active record, and
the unit of work is unchanged (the body is the unit of work, transactional or
not, saved at exit).
The target surface:
let a = Products.get(id) // T, 404 when missing
let b = Products.find(id) // T?
let c = Products.getBy(sku: s) // T, 404 — only over a declared unique (…)
let d = Products.findBy(sku: s) // T?
if let e = Products.find(Pricey(f)) { … } // first match of a criterion
let xs = Products.all(Priciest(f), page: p) // retrieval = criterion + sort
let ys = Products.all(Pricey(f), sort: [price asc], page: p)
Products.remove(x) // the only explicit write verb
getBy/findBytake named args that must equal a declaredunique (…)tuple exactly.gettakes an id only;get(<Criterion>)is an error pointing tofind.- A retrieval is accepted by
allonly (find(R)is an error). allin a workflow needspage:.removeis legal in write positions only.- "Who may read X" is
policy { read X requires … }; exposure gates (scaffoldPaged(requires:), route, handlerrequires) AND on top. A read gate never sits on the repository. - Removed, by codemod and hard cutover: the
repository { find … }body (includingfind … requires), the anonymousretrieval { … }literal,run,findAll(C),getById,findById,deleteas a repository verb, and per-platform reserved find names. - Unchanged:
Agg.create, operation calls,ignoring,projection,scaffoldPaged,transactional, the save at exit. Not now:count/exists(counts stay aprojection).
The contract, as built. src/util/repository-contract.ts is the one table
(in util/ because language/ and ir/ both read it). Each entry names a
surface verb accepted today, its canonical op, its argument shape, its
result shape (the row, an optional row, rows, or none), its face (read /
write), whether page: and ignoring apply, and the positions it is accepted
in. The ops:
| op | does | result |
|---|---|---|
load |
by id, the row must exist | T (absent → not-found) |
load-optional |
by id | T? |
select-one |
first row matching a criterion | T? |
select-many |
rows matching a criterion / retrieval, or all rows | T[] |
remove |
delete the given aggregate | — |
declared-find |
a find declared in the repository body, by its own name | its declared type |
The IR carries op on repo-let, repo-run, if-let, repo-delete and the
repo-read call info. Typing (language/typing/elaborate.ts) and the IR result
type (ir/lower/repo-read.ts) read the same entry, and emitters dispatch on
op, keeping method only for a declared find's own name. Renaming a surface
verb is an edit to the table plus typing/lowering and a codemod — no emitter.
Consequences. test/system/repo-verb-census.test.ts is the ratchet: a
comparison or set literal keyed on a built-in verb spelling outside the
contract module and the lowerers fails it, unless waived with a count and a
reason (the e2e test client's HTTP verbs, which name routes rather than
repository calls, and the per-platform reserved find names that a later slice
removes).
D-READ-GATE-IN-POLICY — who may read an aggregate is policy { read X requires … }, not a repository find
Status: PINNED (owner ruling, 2026-10-25; parent #3949).
Question. Under denyByDefault the only way to gate an aggregate's
compiler-derived reads was to declare them on the repository with a gate —
find all(): T[] requires G / find byId(id: T id): T? requires G — and every
other read (history, the scaffolded List page) inherited from those finds. That
put an access rule inside a domain collection, made the gate depend on
declaring a find whose only purpose was to carry it, and let two finds of one
aggregate disagree about who may read it.
Decision. "Who may read X" is an access fact and lives in the context's
policy { } block, beside the allow / deny ladder:
repository Orders for Order {}
policy { read Order requires currentUser.role == "clerk" }
- One rule per aggregate gates every read of it: the list and by-id routes,
each declared find's route, the audit history read, and the scaffolded List
page. An exposure gate (
scaffoldPaged(requires:), a route / handlerrequires) still ANDs on top. - Semantics are the old find gate's: evaluated before the load, 403, no
existence oracle,
currentUserand constants only (loom.policy-read-gate-not-current-user). FindDeclloses itsrequiresclause (hard cutover, the #3608 ruling);scripts/codemod-find-gate-to-policy.mjsmigrates a project.- The rule lowers into the carrier the emitters already read: enrichment
(
applyPolicyReadGates) puts the gate on every declared find and on the injectedall; the by-id route takes it fromall(byIdRouteFind) and history copies it (ensureHistoryFind). No emitter changed. readis not a keyword (it is a common permission / operation name): the rule head parses as an identifier and the validator pins it toread(loom.policy-unknown-rule).
Consequences. loom.default-deny-list-ungated /
loom.default-deny-by-id-ungated / loom.audit-history-ungated and the
ungated-find arm of loom.default-deny-ungated name the policy line to write.
loom.find-gate-not-current-user is retired. Two reads of one aggregate can no
longer answer to different principals — model them as different aggregates or
gate the exposure instead. The find all exemption from
loom.repository-find-deprecated is gone with the gate it existed for.
D-LIST-FIND-REMOVED — a list read is a criterion; a list find and a criterion/retrieval name clash are errors
Status: PINNED (owner rulings on #3463 item 6 and #3608, 2026-10-21; slice 2 of #3949).
In force: the name-clash error, the codemod, the corpus run and the list-find
hard error (loom.list-find-removed).
Question. Two read-path rules were soft. A repository find returning a
list (T[] / T paged) was only a warning,
so a new list query can still mint one more find byX method on the
repository. And a name declared both as a criterion and as a retrieval
was resolved by a precedence rule (the retrieval won), so Repo.run(<Name>)
silently picked one meaning.
Decision.
- A
criterionand aretrievalwith the same name in one context are an error,loom.criterion-retrieval-name-clash(reported on the retrieval). There is no precedence rule. - A list
findbecomes an error with no warning window (the #3608 rule for breaking language changes: ascripts/codemod plus a hard cutover). A list read is acriterion(or aretrievalwhen it needs asort:), read withRepo.run(<it>(args))and put on the wire withscaffoldPaged(of: …)+scaffoldPagedApi(of: …)or a routedqueryHandler. - Two finds stay legal: a single-row find (
T/T?and the single-row carriers), and a declaredfind all(): T[] / T paged. Read gates live inpolicy { read X requires … }(D-READ-GATE-IN-POLICY), not on a find. The finds the compiler synthesises in enrichment (auto-findAll, the paged find behindscaffoldPaged, projection sources,findAllBy<Criterion>) are not author-declared and are unaffected. scripts/codemod-list-find-to-criterion.mjsrewrites each list find to a criterion beside its repository (<Plural><Find>, the find'swhereas the body, or the parameter-name convention spelled out for a find written without one; awherethat is exactly a criterion over the find's own params reuses that criterion) and eachRepo.<find>(args)call toRepo.run(<Criterion>(args)). A find carries no sort, so the target is always a criterion. A parameterless, filterless list find read every row and becomes the repository'sfind all. Atest e2eread of the find's route keeps the route: the codemod adds a routedqueryHandler List<Criterion>overRepo.runon the same path (parameters as{token}s, a GET carries no body). What it cannot rewrite (a page reading the route, a find whose api lives in another file) it lists.
Consequences. The grammar keeps parsing FindDecl with any return type —
the list check is by return type, and removing FindDecl altogether belongs
to #3951. The emitters' find paths stay: each is still reached by single-row
finds, find all, or the synthesised paged finds. The scaffolded list page's
filter bar and its per-view pages were built from list finds only, so they
went with the hard error.
D-REPO-KEY-LOOKUP — a natural-key read is getBy / findBy over a declared unique (…) tuple
Status: PINNED (owner ruling on #3949, 2026-10-25; slice 3 of #3949, #3951).
The two verbs, their diagnostics, the emission on all five backends and the
codemod hold now. Removing FindDecl from the grammar follows once the list
finds are gone (#4049).
Question. A single-row lookup by a natural key was a repository find
the author wrote by hand — find bySku(s: string): Product? where this.sku == s — one method per key, with nothing tying the key to a uniqueness rule. A
key no unique (…) covered returned whichever duplicate the database yielded
first (loom.single-row-find-not-unique was only advisory). And
T or <Error> finds let one lookup answer a declared error body on a miss.
Decision.
Repo.getBy(<col>: v, …)yields the row;Repo.findBy(<col>: v, …)yieldsT?. Their named arguments must equal one declaredunique (…)tuple of the aggregate (inherited tuples count) exactly, in any order:loom.repo-key-not-uniquewhen no tuple covers the names (with a did-you-mean over the declared tuples),loom.repo-key-incompletefor part of a tuple or a positional argument. The tuple rule is one function,resolveKeyTupleinsrc/util/repository-contract.ts, read by the validator and the lowerer alike; the contract rows aregetBy→load-by-keyandfindBy→load-by-key-optional.- A
getBymiss answers the standard ProblemDetails 404 ("not_found") on every backend.findByis anif letsource, and a plainletbinds it asT?— like everyT?read (#4258:find(C),findById, aT?find), read through a null test or narrowed byif let, its un-narrowed dereference beingloom.nullable-receiver. - Capability filters apply as on any read. A trailing
ignoring …widens one call — on aletor anif letsource, the two positions that bind a read. - Each backend emits one repository method per used (verb, tuple,
ignoringstance), named from the columns in declared order (findBySku,getByRegionAndName,getBySkuIgnoringAll,keyLookupMethodName) with aWHEREon exactly those columns. The method is DERIVED from the calls in enrichment (src/ir/enrich/key-lookup-finds.ts) as asynthesizedfind, so it rides each backend's existing find emitter and escaping and has no HTTP route;findRaisesNotFound(src/ir/util/key-lookup.ts) is the one predicate the java / python / elixir repository emitters read to raise not-found where node and .NET already did for aTfind. T or <Error>finds are dropped: a miss isT?, and agetBymiss is the standard 404.T optionmaps likeT?,T envelopelikeT.findBy/getBystay unique-only (owner ruling, 2026-10-25, second comment): they promise zero-or-one, and only a declaredunique (…)makes that true. A single-row find over any other key is a specification query — acriterionread withRepo.find(<Criterion>)(first match,T?).scripts/codemod-find-to-findby.mjsmigrates every single-row find: a declared tuple →findBy/getBy(carryingignoring); theid→findById/getById; anything else →criterion <Plural><Find>beside the repository (an existing one reused) andRepo.find(<Criterion>(args)). A clause-less find binding its parameters by name counts as the equality conjunction. A find moves only when every call site takes its target; aT?read is accepted as anif letsource, aletor in a domain-service expression, and a must-exist find over a non-unique key has no target (noelsestatement answers a 404), so those call sites are reported and the find stays.--add-uniquedeclares the key instead; never by default.
Consequences. A natural-key read no longer needs a repository body. The
synthesised method is not on the wire: a client that read a find's route
needs a routed queryHandler over the read. What the codemod reports (route
reads, must-exist finds over non-unique keys, variant matches on a
T or <Error> read, row-less finds) is what the FindDecl removal still has
to settle by hand.
D-TARGET-MATRIX-FROZEN — no new backends or frontends: the target set is five backends and six frontends
Status: owner ruling, 2026-07-17.
Decision. The target matrix is closed: the five backends (node, dotnet,
elixir, python, java) and six frontends (react, vue, svelte, angular, feliz,
flutter) registered in src/platform/registry.ts. The studies for further
targets (Go, PHP, NestJS, Blazor Server, HTMX, Next.js) are design record, not
backlog.
Why. The largest emit surface — entity, schema, repository and routes, roughly 37–70 files per backend — cannot be abstracted (see D-PERSISTENCE-PER-BACKEND), so every new target re-lands it by hand and re-mirrors every authorization-filter sentinel, with a cross-tenant-leak risk each time. With the set closed that cost is bounded ×5: each capability lands once per target and is done, and the breadth budget goes to depth.
Consequence. Do not propose a new target without re-opening this decision.
"Add the target to the gate set" is never a reason to grow the matrix; the
seam rows in src/diagnostics/unsupported-register.ts exist for a target
that is not coming.
D-PERSISTENCE-PER-BACKEND — persistence emission stays per-backend; only the seed spine and the existing substrate are shared
Status: concluded design audit (all five backends).
Decision. The shared persistence layer is the seed spine
(src/generator/_persistence/seed-datasets.ts) plus the substrate that was
already shared: MigrationsIR, wireShape, the Postgres renderer
(src/generator/sql-pg.ts), ExprTarget, the intrinsic keys and the
deny/deep stance detection. Entities, schema, repositories, events, ids, enums,
value objects and the find-where / capability-filter predicate lowering stay
per-backend.
Why. Each ORM composes differently — Drizzle combinators with role-split
operands, SQLAlchemy operator overloads, EF fluent configuration, JPA
annotations, Ecto changesets. "Regular-shaped" (a parallel decision tree per
backend) is strictly weaker than "byte-identical-extractable" (one shared
composition API), and only the latter pays for a seam. The write path is a hard
wall. A shared query target is a cost/benefit decline, not an impossibility: a
callback-returning target could unify the predicate walk, but .NET and Elixir
already lower through ExprTarget, so it would net three backends.
Consequence. A proposed seam is net-negative when it is a ~10-method interface over mostly divergent arms; a contract method with one use folds back into the leaf table.
D-CREATE-GATE-ORDER — wire validation (422) precedes a lifecycle create gate (403) on every backend
Status: applied on all five backends.
Decision. For a guarded canonical create with an INVALID body, every
backend answers 422, not 403. Node, .NET, Java and Python validate at the route
boundary ahead of the handler that evaluates the gate; Elixir's
create_<agg>/2 runs Ecto.Changeset.apply_action(…, :insert) on the base (or
document) changeset before the gate check, covering both the REST and LiveView
doors (src/generator/elixir/vanilla/context-emit.ts).
Why. The wire rung depends only on the body and the published schema, so answering it first leaks nothing a client could not compute from the OpenAPI document. Deny-first would have moved four backends and their route-boundary validators.
Consequence. Pinned by the lifecycle-guard surface "guarded create,
INVALID body" in test/behavioral/cases.mjs (anonymous unpinned, unauthorized 422,
authorized 422). The body-rung pointer is /<field> for a
single-field-shaped rule and "" (the whole request) otherwise; a
message-less domain-floor rule carries no errors[].
D-TIME-AS-EVENT — time enters as an infrastructure-emitted event (timerSource), not as a workflow trigger; the temporal surface is complete
Status: owner ruling, 2026-09-02.
Decision. timerSource { for: <Event>, cron: | every: } is the temporal
feature, and it ships on all five backends. A tick is a real event delivered
through the existing dispatcher, so it fans out to ordinary on(e) reactors
and create(e) by triggers and reuses saga state. There is no further temporal
sugar: timezone in:, overlap: allow, missed:/retry:, per-environment
cadence and saga-deadline sugar are not open work. in: and overlap: stay
parse-only under loom.reserved-not-emitted.
Why. Rejected alternatives: a schedule every 5m { … } workflow member
breaks the uniform (params) by correlation trigger grammar, gives a
stateless schedule a seat among instance-lifecycle members and bakes an
operational cadence into the domain; a system-scope schedule { for: <Workflow> } needs a new paramless workflow entry shape, a bespoke trigger by
the back door; a structured cadence: pseudo-DSL ("daily at 02:00") lowered
to cron anyway and punted to raw cron for real cases. Cadence lives on the
infrastructure binding, so it can change per environment. The one new concept
is "infrastructure-emitted events".
Consequence. Explicit cron: (with @daily-style nicknames) and
every:; an every: that is cleanly cron-expressible is steered to cron:
(loom.timer-cadence). The timer owner is picked like migrationsOwner:
exactly one backend deployable. Re-open temporal surface only for a concrete
user need.
D-LIFECYCLE-SHAPE — POST for every bodied lifecycle action, DELETE only for canonical destroy; three keywords; bodies never call persistence
Status: design ruling behind the shipped create / operation /
destroy surface (the URL half is D-LIFECYCLE-VERB / D-URLSTYLE).
Decision.
- No lifecycle route uses PATCH. The canonical edit is
operation update(…)→POST /<plural>/{id}/update, routed like any other operation. - Three keywords:
operation,create,destroy.destroy, notdelete, because it says nothing about hard vs soft removal and so composes withsoftDeletable. - No generic
action/run/callkind for non-mutating work, and no per-operationroute:alias. - Lifecycle bodies never call persistence: the framework picks INSERT/UPDATE/DELETE from the kind.
Why. One HTTP shape per action and no PATCH-vs-POST branch in the
generator. One keyword with a kind modifier (operation update cancel())
reads as a contradiction, and three keywords that replace operation would
break every model. A non-mutating "action" is always really a mutation with a
marker, a read (find/projection) or a workflow, and an escape hatch gets
abused. A route: alias puts HTTP into the domain layer; URL shape belongs to
the api-level urlStyle. Persistence-free bodies keep one body
platform-neutral across all five backends.
Consequence. Re-open any of these only on purpose.
D-REFCOLL-SET — X id[] is a set: membership only, no stored order, no ordinal on the join table
Status: owner ruling ("ordered X id[]" is a non-feature).
Decision. An X id[] reference collection is contractually a SET. Its
association join table is just its key (owner_fk, target_fk), the whole-row
composite primary key, so inserts are idempotent; it carries no payload
column (tableForAssociation in src/system/migrations-builder.ts).
Why. Order is a read-time projection, not a storage property. When the domain needs a position, it declares one — an explicit ordering field, or a child entity with a rank — and sorts the read.
Consequence. Value-object collections are different and keep
(parentFk, ordinal), because the ordinal is part of their key. Do not add an
ordinal to the association table "for determinism".
D-CHANNEL-KNOBS — one channel with orthogonal delivery × retention knobs, not topic/queue/stream keywords
Status: design ruling behind the shipped channel surface
(channels.md documents the knobs).
Decision. One channel declaration with delivery: broadcast | queue ×
retention: ephemeral | work | log (the NATS JetStream model):
broadcast+ephemeral is pub/sub, broadcast+log a Kafka-style topic,
queue+ephemeral competing consumers, queue+work a durable work queue. The
channel contract never names a transport: a system-scope channelSource binds
it to a broker storage, and with no binding events stay on the in-process
dispatcher.
Why. Separate topic/queue/stream declarations triple the surface and
force a keyword change when a requirement changes (a broadcast topic that later
needs durability would become a different declaration). With orthogonal knobs a
requirement change flips a knob.
Consequence. retention: log plus a cursor is replay, which lines up with
persistedAs: eventLog and the appliers.
D-PROJECTION-NOT-AGGREGATE — a folded projection is its own construct, not an event-sourced aggregate without operations
Status: design ruling.
Decision. projection stays separate from aggregate, and gets no
operations.
Why.
- It folds FOREIGN events. An aggregate's
applyfolds only events it emitted itself (aggregates have appliers, no subscriptions); only workflows and projections subscribe to someone else's stream, which is why the projection fold rides the workflow dispatch path. - It is derived, not a source of truth. It changes only when the upstream
events change, so it has no
emitand therefore no operations. - The fold is therefore PURE — event-only input, no repository reads, deterministic — which keeps the read model disposable and rebuildable by re-folding.
Consequence. Do not "unify" projections with aggregates or add operations to them. Query-time projections are the other flavour: computed on every read, no replay, so reading current state there is correct by definition.
D-FORM-BINDS-ACTION — a form's fields are its bound action's params: no field-walking fallback, no per-form overrides
Status: design ruling behind the shipped form family (CreateForm /
OperationForm / DestroyForm / WorkflowForm; behaviour in
language-reference/16-ui-walker-primitives.md).
Decision.
- A form binds a lifecycle action (canonical by type via
of:, named viafor: X.op) and takes its fields from that action's input. There is no fallback that walks the aggregate's fields. - No per-form
hide:/reorder:/fields:overrides. A different shape is a different action (several creates per aggregate are first-class). - Forms are kind-specific primitives, not one generic
Form. - Forms submit through the generated API client, never a raw fetch, and server 422 errors land in the same form error bag as client errors.
Why. The form layer and the api layer read the same action IR, so they
cannot drift. Submission differs by kind (verb, success navigation,
confirm-only destroy), and a kind-specific primitive lets the validator reject
a mis-bound kind. One client means auth, base URL, urlStyle and error parsing
live in one place; one error bag means one render path.
D-REACT-FORM-NESTED — generated React forms keep nested react-hook-form state; FormState/Payload aliases only when the schema transforms
Status: maintainer ruling, 2026-06-12.
Decision. Generated React forms keep nested, RHF-native state. The dual
<Action>FormState = z.input / <Action>Payload = z.output aliases are
emitted only for actions whose request schema has a real transform (today
money, reached directly or through array/optional/value-object nesting);
transform-less actions keep the single <Action>Request type. Server errors
route through pointerToFlat and nested setError; no per-action FieldMap is
emitted.
Why. A flat dot-key form-state schema is unimplementable: react-hook-form
treats dots in field names as nesting, so register("price.amount") yields
nested values a flat-keyed zod schema would reject. Emitting the alias pair
only on a transform avoids structurally identical alias noise.
Consequence. Gate: test/generator/react/dual-form-types.test.ts.
D-STDLIB-LAYERS — irreducible ops are compiler intrinsics kept as registry rows; the prelude is expression-form Loom; duration is absolute-only
Status: design ruling behind stdlib.md's Layer 0 / Layer 1
split.
Decision. Layer 0 ops are compiler intrinsics, kept as DATA — one row in
src/util/intrinsics.ts, not five render-expr.ts edits. Layer 1, the ambient
prelude, is ordinary expression-form Loom functions inlined at lowering, so the
backends and the query path only ever see Layer-0 ops. duration is an
absolute span; there is no months(n).
Why. A Loom expression has dual semantics: it renders as host code AND as a
queryable predicate (finds, views, criteria, filters). A stdlib function
written as a Loom block body could never push down to SQL. months(n) was
cut because a calendar month has no fixed width: it forced python-dateutil, a
positional validator gate and five divergent calendar paths, and conflated
absolute duration with calendar offset (java.time's Duration vs Period).
Consequence. Calendar units belong to a separate period type if one is
ever added, not to duration.
D-HEADER-MODIFIER-PLACEMENT — aggregate header modifiers are placed by meaning: the prefix says what it is, the header region how it is realized
Status: applied (see the comment on the aggregate header in
src/language/ddd.langium).
Decision.
- Prefix: what the declaration IS at the type level (
abstract). - Header region after the name: how it PARTICIPATES or is REALIZED
(
crossTenant,persistedAs:,shape:,inheritanceUsing:,audited). The region is order-independent and colon-uniform.
Why. The prefix slot reads as access/nature territory (private operation, abstract); crossTenant hoisted there read as a nature claim.
It is per-aggregate participation in a compiler-owned facility (the tenancy by policy), the same category as persistedAs:.
Consequence. A new header modifier is placed by this rule, not by its value type.
D-PRIORITY-ORDER — target gaps and bugs outrank architecture, which outranks new surface area
Status: owner directive, 2026-08-10 (restated 2026-08-24 and 2026-09-10).
Decision. Gaps in implementations (a feature missing on a target) and bugs come first, architectural improvements second, new surface area third. Structural risks are P1 by default: silent data loss, silent or crashing parity failures, security defaults, the un-abstracted persistence axis, and nightly-only feedback.
Why. "A smaller platform whose claims all hold beats a wider one with hollow cells."
Consequence. This is the triage rule for issue priority labels.
D-WIRE-POLICY — a request refusal's status, body and stage come from one IR fact
Status: PINNED (owner rulings 2026-10-21 on #3880, #3881, #3508, #3504, #3721; fact landed for #3593 item 4).
Question. The same malformed request earned a different status or body per backend (#3584, #3585, #3508), because each backend decided for itself which input is a 400 / 415 / 422 / 409, what the problem body says, and which check runs first.
Decision. One derived table, src/ir/enrich/wire-policy.ts, owns those
answers, and backends render it:
- Rejection classes (
wireRejection): status (fixed, or the stdlib error name anhttpStatusoverride retargets),title, fixeddetailor sentence parts, whethererrors[]rides the body, and the stage. Malformed JSON and a foreign media type are 400 / 415 attransport; a bad field is the 422Validation failedrung atschema; awhengate is 409Disallowed; a lost version race and a unique key are 409Conflict. - Field reasons (
FIELD_REASONS): a missing required field is"<field> is required"(#3881), a malformedX idinside well-formed JSON is a 422 at its pointer withExpected UUID.(#3508), plus the fixed range sentences (RS-42). - Ingress (
ingressFor): a numeric field accepts only a JSON number, amoneyfield only its string form, no coercion (D-NUMERIC-INGRESS-STRICT, #3880);""is a valid requiredstring(#3721). - Stages (
WIRE_CHECK_ORDER,ruleStage): transport → schema → authorize → load → state-gate → domain → persist. A rule that reads only the request's members is aschemacheck, refused before the row loads (#3584). - Declared error variants (
errorVariantResponse):application/problem+jsonat the mapped status with the/errors/<kebab>type, never a 200 union (#3504).
Consequences. node/Hono renders every refusal from the fact, and
test/generator/hono/wire-policy-census.test.ts fails on a status, title or
sentence a node emitter picks itself. python/FastAPI renders it through
src/generator/python/wire-policy-py.ts, guarded by
test/generator/python/wire-policy-census.test.ts (request bools are
StrictBool, so "true"/1 is a refusal like every other cross-type value).
The remaining backends move onto it one at a time; until each does, its
wire-golden legs carry a per-golden waiver in test/_helpers/wire-waivers.ts
for the missing-field sentence.
D-CODEC — how a value is encoded on the wire, in a column, in memory and in a lineage comes from one IR fact
Status: PINNED (encodes RS-4, RS-12, RS-24, RS-37, RS-38, RS-42, D-LONG-AVG-DEFAULTS and the owner ruling on #3527; fact landed for #3593 item 5).
Question. Each backend picked the encoding of money, decimal, the
integers and datetime itself, at each read path, column and lineage write.
So the same value reached the wire, the database or a provenance lineage in a
different form per backend (#3297, #3527, #3535, #3544, #3487, #3434).
Decision. One derived table, src/ir/enrich/codec.ts, owns those answers
per storable primitive, and codecOf resolves a declared type (optional,
collection, id, enum, value object) onto it:
- Wire (
WireEncoding):moneyis a fixed-scale-4 decimal string with the RS-42 grammar;decimalis a float64 number;int/longare integers inside int32 / ±(2^53−1);datetimeis ISO-8601 UTC at millisecond precision with no fraction on a whole second, truncated at ingress;dateisYYYY-MM-DD. - Column (
ColumnType):moneyisNUMERIC(19,4),decimalis unbounded,datetimeistimestamptz,Fileis a JSONB reference. The migrations builder reads it. - Memory (
MemoryHint): what the in-memory value must hold exactly, and whether arithmetic is exact decimal. - Lineage (
LineageEncoding):moneyand the integers are written in their wire encoding (#3527). Every other scalar isunruled.
What the fact does not rule is listed in UNRULED, one owner question per
entry, and no backend is moved onto a value for those.
Consequences. The generator-layer numeric decision table
(_numeric/codec.ts) and money-scale.ts now derive from the fact. node/Hono
spells it in src/generator/typescript/codec-spelling.ts and
numeric-codec.ts, and test/generator/hono/codec-census.test.ts fails on a
scale, range bound, column factory, money grammar or ISO-8601 trim that a node
emitter chooses itself. The other four backends move onto the fact one at a
time. Until each does, it boots corpus/provenance-money as a tracked
expected failure (BEHAVIOURAL_XFAIL, #3527).
java/Spring renders the fact through src/generator/java/wire-policy-java.ts
(test/generator/java/wire-policy-census.test.ts is its census, with the
Jackson coercion table derived from ingressFor). Known java subject gap: the
still-referenced 409 detail reads This resource is still referenced… because
the shared advice does not know the aggregate.
D-FAILURE-MODEL — Loom has no exceptions: each failure kind has one construct, and an expected failure is a value the caller must handle
Status: PINNED (owner rulings 2026-10-25 on #4134, #4146, #3951, #3916; parent #4120).
Question. A domain operation can fail in five different ways, and the
language had a construct for each, but nothing made a caller deal with the one
kind that is the caller's business: an expected failure declared in the
signature (operation withdraw(amt: money): Account or InsufficientCredit). A
call statement dropped it, and a workflow answered 204.
Decision. One construct per failure kind, chosen by who can act on it:
| kind | construct | outcome |
|---|---|---|
| absence | T? + narrowing |
null |
| invalid input | value-object invariant, wire validation | 422 |
| precondition | precondition (RS-15) |
422 DomainError |
| expected failure | A or E with an error record (httpStatus maps it) |
the caller's to handle; at the wire, problem+json at the mapped status |
| bug | aggregate invariant | 500 |
- An expected failure is a value, handled in process. An
or Eresult whoseEis anerrormust be matched, returned through a caller whose own declared result admits it, or bound and read (loom.unhandled-result). A call statement and an unreadletdiscard it. There is no implicit propagation and no?operator. (#4134) - Returning
Ediscards the operation's writes. An operation that returns anerrorvariant leaves no trace: nothing it assigned is saved, and the events it raised are neither published nor written to the outbox — the same as a failedprecondition. A failed attempt that must be recorded is recorded explicitly. (#4146) - A read does not encode absence as an error.
T or NotFoundfinds go; a read that may miss isT?(find…), one that must hit isT(get…, the standard 404). (#3951) - Workflows return
or Etoo, once a workflow body canmatcha result (#3367). (#3916) - The wire is the boundary. In process an
Eis a value; a route renders it as problem+json at itshttpStatus(D-WIRE-POLICY,errorVariantResponse), never as a 200 union.
Consequences. loom.unhandled-result refuses a dropped or E
(src/language/validators/unhandled-result.ts). In a workflow body the only
handling route until #3367 lands is binding the result and reading it.
Every backend's operation route or handler checks the error variant before it
stamps, saves, audits or publishes (Java also marks the transaction
rollback-only, since its loaded aggregate is a managed entity); the
operation-returns corpus golden pins the unchanged read-back on every
behavioural leg.