← Back to home

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:

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.

Validator rules implied.

Affects (proposal rewrites needed).


D-GRANULARITY — storage bindings are per-context, not per-aggregate

Status: PINNED.

Problem. Two proposals disagree:

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.

Validator rules implied.

Affects.


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:

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.)

Validator rules implied.

Sub-questions.

  1. persistedAs inference — RESOLVED: explicit, default state, no inference, no lint.
  2. json shape-hint — RESOLVED: plain json for v1; json<T> out of scope.
  3. Snapshot cadence for eventLog + document — RESOLVED: reuse the snapshot dataSource's every: knob (already in D-STORAGE-SPLIT). Cadence is binding/infra config; no aggregate-header arg.
  4. Per-projection vs per-aggregate shape — RESOLVED: per-projection. The shape is settable per read-model: the per-binding dataSource shape: knob (on the state / snapshot / replica binding) governs that projection's shape; the aggregate-header shape: … 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.
  5. Real document DB — RESOLVED: Postgres-JSONB only in v1 (Marten's own bet); shape: document resolves to JSONB / Marten docs on Postgres. StorageType += mongo deferred.

Affects.


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.

Affects.


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.

Affects.


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.

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.

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:

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.

  1. urlStyle lives on the api, as an optional body — api SalesApi from Sales { urlStyle: literal | resource }, default literal (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/resource are soft-admitted in LooseName/NameRefIdent (the dataSource/money precedent).
  2. routeSlug is a per-action OperationIR field, derived in enrichment — not the proposal's separate agg.lifecycle shape (the Phase-1 creates/destroys arrays 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 on kind + canonical + routeSlug.
  3. The surfacing api is resolved by subdomain. Aggregate → one context → one subdomain → the api from it. Enrichment threads the subdomain's style into enrichAggregate. Top-level contexts (no api) default to literal. If two apis surface one subdomain with differing urlStyle, the first declared wins and the validator warns (loom.subdomain-conflicting-urlstyle).

Consequences (not separate decisions).

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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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:

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:

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.

Open within this decision.

  1. Adapter-surface spelling — style: vs persistence: vs both for the Ash/Ecto choice. RESOLVED by D-REALIZATION-AXES: neither — the domain-framework axis gets a dedicated foundation: keyword (default vanilla), and persistence: narrows to the data-access library only.
  2. Default domain when unspecified on platform: phoenix — ash (matches today's phoenixLiveView behaviour after desugar). PINNED — now expressed as foundation: 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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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 — persistence and directoryLayout. Removed: foundation: (single value vanilla everywhere — grammar clause, DeployableIR.foundation, the R4/R6 rules, the foundation-default-flipping warning all deleted); application:/style (each backend has one fixed emission style — cqrs on dotnet, layered elsewhere — kept as an internal StyleAdapter, no longer a user knob); and transport:/runtime: (name-only TransportAdapter/RuntimeAdapter registries that no emitter ever read — pure validation theater, deleted whole). Every reserved stub adapter (marten/jooq/axon/minimalApi/orleans/genserver/worker/the cqrs/flat/layered style stubs) was removed too — a stub that throws on emit* is speculative-generality debt, not a shipped capability. All foundation:/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:

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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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:

  1. Exception-less alignment. A4 of proposals/exception-less.md deletes route-layer try/catch towers on every backend in favour of variant dispatch on typed or-union returns. The current Phoenix emitter has the tower as a design feature (Plug.ErrorHandler translates Ash.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-variant with-block dispatch (the same shape TS/.NET adopt post-A4).
  2. Pure event sourcing. The cross-backend pure-ES contract (no state table, per-aggregate <agg>_events stream, fold-on-load, emit-and-apply command bodies) is live on Hono/Drizzle, Hono/MikroORM, .NET/EF, and .NET/Dapper. Under foundation: ash there is no clean fit — AshEvents is hybrid (state table, single centralised event log, action-wrapping); AshCommanded is closer but heavy; a custom Ash.DataLayer over 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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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:

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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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:

  1. Split bounded contexts across deployables. State-based contexts deploy under foundation: ash; ES contexts deploy under foundation: vanilla. Each deployable is internally coherent; cross-deployable consumption uses the api surface as on any cross-deployable boundary.
  2. Pick foundation: vanilla for 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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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:

  1. 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.
  2. 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:

  1. Warn-then-flip release. Emit a loom.foundation-default-flipping warning on every bare platform: phoenix (no explicit foundation:) for one release cycle, telling the user to set explicit ash if they want to stay on the current behaviour.
  2. Flip release. Change lower-platform.ts:46's default from ash to vanilla. 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.

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.

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.

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.

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.

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.

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, … } }

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:

Decision: B + C. Loom leads as a first-party platform across both motions — a land-and-expand:

Rationale.

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.

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.

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: elixir is plain Ecto/Phoenix only; foundation: ash is now a validation error and vanilla is 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:

  1. Pure event sourcing — no clean fit on foundation: ash; deferred to vanilla (D-VANILLA-ES-HOME; proposals/workflow-and-applier.md).
  2. Workflow saga / correlation state — kept a plain Ecto.Schema deliberately 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).
  3. Exception-less variant returns — Phoenix is the odd backend out; the Plug.ErrorHandler rescue tower that translates Ash.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.

  1. 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: vanilla as their Phoenix home. foundation: ash is 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).
  2. 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.
  3. Ash is NOT deprecated now. Deprecating foundation: ash is 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.
  4. Sunset is a later, conditional decision. Whether foundation: ash is 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 green phoenix-vanilla-build.yml minor-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:

  1. App shape: SvelteKit static SPA (@sveltejs/adapter-static, ssr = false, fallback index.html), served with vite preview exactly 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.
  2. Data layer: @tanstack/svelte-query v6 — runes-native createQuery(() => 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 shared src/generator/_frontend/zod-schemas.ts.
  3. Forms: hand-rolled runes + zod ($lib/forms.svelte.ts — createForm with values / errors / submit / applyServerErrors), no third-party form dependency. Field templates bind form.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:

  1. Reuse, not fork. Vue pages render through the SHARED markup walker (src/generator/_walker/) with vueTarget supplying 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 to src/generator/_frontend/ and are shared verbatim (TanStack Query's call surface is identical across react-query and vue-query; one import-specifier knob).
  2. Plain Vite SPA + vue-router — createWebHistory, explicit route table in src/router.ts, <script setup lang="ts"> SFC pages, the same two-stage vite-build/vite-preview docker runtime as React. No Nuxt.
  3. vee-validate forms (src/lib/form.ts — useLoomForm): the Vue analog of the React packs' react-hook-form. useForm drives validation + submit over the SAME shared zod request schema, adapted by a locally-emitted toTypedSchema (@vee-validate/zod is NOT a dep — its peer pins zod 3 while the stack is zod 4, so a plain npm install would ERESOLVE; vee-validate core carries no zod peer). values stays a local reactive() draft (so pack markup's v-model + array push/splice keep working) and is pushed into vee-validate on submit; per-field error map + ProblemDetails-style server-error application preserved. The useLoomForm seam 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.

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.

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".

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.

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):

  1. Domain-layer gate — the predicate is asserted by the aggregate's own method, so every caller is gated.
  2. Route-layer by design — when stays 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.

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.

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.

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.

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.

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.

Consequences.

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.

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.

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.

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).

Rationale (avg).

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.

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.

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:

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.

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).

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.


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.

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.

  1. generateDotnetForContexts (M-T9.52). The boundary stays where M-T9.49 drew it: the ratchet gates the WRAPPER (generateDotnet), mirroring Hono's (which gates generateHono, not generateTypeScriptForContexts). Sequence M-T9.42's corpus promotion of generator-dotnet.test.ts FIRST — 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 direct ForContexts importers afterwards, and only then decide whether the rung below needs its own assertion. Verification is two-sided per M-T9.40 (re-seed the enumName: undefined mutation 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.
  2. connection: on a storage (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, so service(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 current Host=db; classifier in src/system/kubernetes.ts is 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.
  3. 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"), which x-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).
  4. scopeId (M-T3.11). scopeId denotes the business boundary a frame belongs to; parentId denotes call structure ("who invoked me") — the two axes execution-context.md separates 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, plus currentUser/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.

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.

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.

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.

  1. 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 needs ref is a ConsumerWidget — which the Flutter emitter ALREADY builds for a read-bearing component (renderConsumerComponent) — and a stateful one is a ConsumerStatefulWidget + ConsumerState, where ref is an inherited property so both setState and the existing read hoisting work unchanged. Nothing about this needs a ruling; it needs an emitter. Successor: M-T1.34.

  2. The route id is 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 named id gets the page's route arg", which would make id a magic parameter name in a component's signature — a second, invisible meaning for an ordinary identifier, decided by spelling. Loom already blesses id inside 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 :id route, 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).

  3. The async effect follows the id ruling, not the ref one. match await <api>.<Agg>.<op>() on an INSTANCE operation posts to /<coll>/$id/<op>, so it needs the route id and is refused for the reason in (2) — even once (1) lands and the component holds a ref. Its register row therefore moves from gap to scope (a declared v1 limit with a named successor), owned by M-T1.34, which closes the ref half and re-narrows the gate's message to name the id as the only remaining cause.

Rationale.

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.

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.

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.

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.

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.

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:

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:

  1. 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 wireShape field on AggregateIR / EntityPartIR / ValueObjectIR, no phase-⑥ stamping, and no wireShapeFor. EnrichedEntityPartIR / EnrichedValueObjectIR alias their base types. What the 179 refs count is call sites of the pure recompute helpers in src/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.
  2. The records do not exist for any shipped model. with scaffoldHandlers is the only thing that splices them, and exactly five tracked .ddd files use it — the per-backend scaffold-handlers compile fixtures. All 79 corpus fixtures, every examples/*.ddd and every playground example use api X from Subdomain, which splices none: 0 of 117 corpus aggregates carry an <Agg>Response record. "Move a consumer onto the records" is therefore not a refactor for the shipping paths; it is gated on proposal step 4 (rewriting the implicit api … from … derivation to expand through the scaffold), which is unbuilt.
  3. 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 an abstract aggregate base, the provenanced<T> carrier, the containment <Part>Response offset, 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.

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:

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.

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:

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.

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:

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.

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.

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.

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.

  1. The chain. migrations/ beside the entry .ddd holds one JSON file per (generation, module): its backend-neutral MigrationsIR steps and the module's schema after them (src/system/migration-chain.ts). Each backend's migration files are rendered from the whole chain on every generate system; nothing migration-related is written under -o, which is fully disposable. Committed with the model.
  2. Recording is explicit. ddd migrate add <name> <file.ddd> diffs the model against the chain's head and appends. generate system refuses a model with an unrecorded schema change (MigrationDriftError). A source with no migrations/ has nothing recorded: generate emits each module's initial migration from the model, with no drift check — so examples and fixtures carry no migration state.
  3. Versions are the UTC clock at migrate add, moved above the head (and above head + INITIAL_RESERVATION after an Initial, 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.
  4. The destructive decision is made once, at migrate add (--allow-destructive), and recorded in the entry; generate has no such flag.
  5. 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

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" }

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.

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.

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.

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.

  1. It folds FOREIGN events. An aggregate's apply folds 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.
  2. It is derived, not a source of truth. It changes only when the upstream events change, so it has no emit and therefore no operations.
  3. 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.

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.

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:

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:

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

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.